Часто необходимо знать, с какой областью страницы взаимодействует пользователь. Например, если вы создаете выпадающее меню, нужно понять, когда его закрывать. Наивная реализация будет просто слушать клики и проверять, произошел ли клик вне элемента выпадающего меню. Но мышь — это не единственный способ взаимодействия, не стоит забывать и про клавиатуру. Кроме того, выпадающее меню может иметь вложенную многоуровневую структуру, что делает простую проверку целевого клика проблематичной.

В этой статье исследуем директиву ActiveZone — подход, который мы использовали в библиотеке компонентов Taiga UI. Она полагается на два моих любимых инструмента Angular: Dependency Injection и RxJS. Нам понадобится глубокое понимание нативных событий DOM. Как бы ни был далек Angular от чистого JavaScript и DOM, он все равно полагается на старые добрые Web API, поэтому важно качать свои знания и в области ванильного frontend.

Определение задачи

Базовым примером использования может служить кнопка с выпадающим меню:

<button 
  [dropdownOpen]="open"
  [dropdownContent]="template" 
  (activeZoneChange)="onActiveZone($event)"
  (click)="onClick()"
>
  Show menu
  <ng-template #template>
    <some-menu-component activeZone></some-menu-component>
  </ng-template>
</button>

Предположим, что меню отображается в портале и не является прямым потомком кнопки. Мы хотим открывать и закрывать меню по клику на кнопку. И если пользователь кликнет в сторону или переместит фокус с помощью клавиши Tab, выпадающее меню должно закрыться.

Для отслеживания взаимодействий пользователя будем работать в основном с событиями: focusin, focusout и mousedown.

События focus/blur не всплывают в отличие от focusin/focusout.

Навигация с клавиатуры происходит только на элементах, доступных для фокуса, в то время как mousedown может быть вызван пользователем, выделяющим текст внутри выпадающего меню. Так что эти три события охватывают практически все, что нас интересует. Но есть несколько сложных граничных случаев, которые мы исследуем ниже.

Потеря фокуса происходит после события mousedown по умолчанию. Если вы вызовете preventDefault() для этого события, фокус останется там, где он был.

Нам надо знать, с каким элементом в данный момент взаимодействует пользователь. В мире Angular это означает наличие Observable активного элемента. Важное уточнение: мы хотим, чтобы он был синхронным. Это означает, что, если разработчик вызовет element.blur() в своем коде, на следующей строке мы уже должны знать, что покинули зону. Это делает нашу задачу в разы сложнее!

Токен ACTIVE_ELEMENT

Чтобы иметь возможность обращаться к Observable активного элемента из любой части нашего приложения, мы превратим его в InjectionToken. Токены — это удобный способ задать значение по умолчанию через фабрику. Она будет вызвана один раз, когда кто-то впервые заинжектит токен.

Сделаем первый набросок. Сначала создадим все необходимые константы:

export const ACTIVE_ELEMENT = new InjectionToken(
  'Элемент, с которым в данный момент взаимодействует пользователь',
  {
    factory: () => {
      const documentRef = inject(DOCUMENT);
      const windowRef = documentRef.defaultView;
      const focusout$ = fromEvent(windowRef, 'focusout');
      const focusin$ = fromEvent(windowRef, 'focusin');
      const mousedown$ = fromEvent(windowRef, 'mousedown');
      const mouseup$ = fromEvent(windowRef, 'mouseup');

      // ... продолжаем ниже ↓↓↓
    }
  },
);

Теперь объединим эти потоки в Observable<Element>. Поскольку потеря фокуса естественным образом происходит после события mousedown, мы прекратим слушать focusout после mousedown и возобновим после mouseup. Так мы отделим всю активность, связанную с мышью, от работы с фокусом:

const loss$ = focusout$.pipe(
  takeUntil(mousedown$),
  repeatWhen(() => mouseup$),
  map(({ relatedTarget }) => relatedTarget)
);

// ... продолжаем ниже ↓↓↓

relatedTarget для события focusout — это элемент, на который фокус собирается переместиться, или null, если фокус уходит в никуда.

Что касается получения фокуса, нам нужно сопоставить событие focusin с целевым элементом:

const gain$ = focusin$.pipe(map(({ target }) => target));

// ... продолжаем ниже ↓↓↓

Взаимодействие с мышью более сложное. На каждом событии mousedown мы проверяем, есть ли что-то в данный момент в фокусе. Если нет (activeElement равен body), мы просто сопоставляем событие mousedown с его целевым элементом. Если что-то находится в фокусе, начинаем слушать событие focusout, ожидая потери фокуса. Используем map, чтобы превратить это событие в целевой элемент mousedown. Но если стандартное действие было предотвращено и фокус остался на месте, мы прекращаем ожидать события focusout на следующем кадре (timer(0)):

const mouse$ = mousedown$.pipe(
  switchMap(({ target }) =>
    documentRef.activeElement === documentRef.body
      ? of(target)
      : focusout$.pipe(
          take(1),
          takeUntil(timer(0)),
          map(() => target),
        )
    )
);

Объединим все эти потоки вместе. Получаем Observable элемента, с которым в данный момент взаимодействует пользователь:

return merge(loss$, gain$, mouse$).pipe( 
  distinctUntilChanged(),
  share() 
);

Добавим операторы distinctUntilChanged и share, чтобы избежать лишних срабатываний и подписок.

Директива ActiveZone

Теперь, когда у нас есть Observable, создадим простую директиву. Она будет превращать поток в boolean, позволяя нам узнать, взаимодействует ли пользователь в данный момент с определенной областью. Она также будет обращаться через DI к родительской директиве того же типа и регистрировать себя как ее дочернюю зону. Так мы сможем обрабатывать вложенные выпадающие меню, упомянутые в начале статьи.

Как это сделать:

<button (activeZoneChange)="onActiveZone($event)">
  Показать меню
  <ng-template #template>
    <!-- 
      "activeZone" внедряет родительскую директиву "activeZoneChange"
      из кнопки выше, даже если шаблон создается
      в другом месте в DOM
    -->
    <some-menu-component activeZone></some-menu-component>
  </ng-template>
</button>

Вложенность DI может быть любого уровня. Именно так сложные меню и выпадающие списки могут оставаться частью самой верхней зоны. Напишем саму директиву:

@Directive({
  selector: '[activeZone],[activeZoneChange]'
})
export class ActiveZoneDirective implements OnDestroy {
  private readonly active$ = inject(ACTIVE_ELEMENT);
  private readonly elementRef = inject(ElementRef);
  private readonly parent = inject(ActiveZoneDirective, { 
    optional: true, 
    skipSelf: true, 
  });

  private children: readonly ActiveZoneDirective[] = [];

  constructor() {
    this.parent?.addChild(this);
  }

  ngOnDestroy() {
    this.parent?.removeChild(this);
  }

  contains(node: Node): boolean {
    return (
      this.elementRef.nativeElement.contains(node) ||
      this.children.some(item => item.contains(node))
    );
  }

  private addChild(activeZone: ActiveZoneDirective) {
    this.children = this.children.concat(activeZone);
  }

  private removeChild(activeZone: ActiveZoneDirective) {
    this.children = this.children.filter(item => item !== activeZone);
  }
}

У директивы только один публичный метод — contains, который используется для проверки, находится элемент внутри текущей зоны или любой из ее дочерних зон. Добавим аутпут — их в Angular легко связать с потоками:

readonly activeZoneChange = outputFromObservable(this.active$.pipe(
  map(element => this.contains(element)),
  startWith(false),
  distinctUntilChanged(),
  skip(1),
));

Каждый новый активный элемент проверяется через contains. Поток начинается с false, чтобы distinctUntilChanged не пропускал последующие результаты false. А еще пропускаем это начальное значение через skip(1).

Подводные камни

Разве это весело, когда все работает с первого раза? Приведенный код довольно чистый и функциональный, но есть определенные случаи, когда он даст сбой. Исследуем эти случаи и расширим решение, чтобы охватить их все.

☹️ iframe. При использовании iframe появляются первые проблемы. Каждый раз, когда вы кликаете на него, событие mousedown не происходит. Это означает, что, если у нас на странице есть вложенный iframe и пользователь кликает на него, мы не будем знать, что он покинул активную зону. К счастью, событие blur срабатывает на window, когда мы начинаем взаимодействовать с iframe. Это имеет смысл: мы покинули окно, чтобы работать с другим документом.

Во время большинства событий фокуса, если попытаться проверить document.activeElement, в нем будет body. В этом случае внутри обработчика события blur активный элемент уже будет кликнутый iframe. Поэтому все, что нам нужно сделать, — это внести поправку в наш поток, включив это в merge:

const iframe$ = fromEvent(windowRef, 'blur').pipe(
  map(() => documentRef.activeElement),
  filter(element => !!element?.matches('iframe')),
);

Еще один случай, когда activeElement внутри события focusout не body, — когда мы покидаем вкладку. Это пригодится позже, чтобы наши выпадашки не закрывались, когда мы переходим в DevTools!

? ShadowDOM. При работе с Web Components или просто ShadowDOM в целом внутри него может быть несколько элементов, доступных для фокуса. window не будет знать о переходах фокуса внутри shadow root. В document.activeElement будет лежать сам shadow root с собственным activeElement для отслеживания реального сфокусированного элемента. Более того, в target всех наших событий будет все тот же shadow root. Настоящий элемент будет доступен нам через метод composedPath в событии.

Для закрытого Shadow DOM composedPath не даст заглянуть внутрь, но это лучшее, что мы можем сделать.

Нам нужна вспомогательная функция для получения фактической цели. Чтобы получить доступ к activeElement внутри ShadowDOM, понадобится функция для получения DocumentOrShadowRoot.

Давайте добавим их обе:

function getActualTarget(event: Event): EventTarget {
  return event.composedPath()[0];
}

function getDocumentOrShadowRoot(node: Node): Node {
  return node.isConnected ? node.getRootNode() : node.ownerDocument;
}

Нужно проверить isConnected, потому что узлы, отсоединенные от DOM, будут возвращать самый верхний элемент в их структуре в качестве корневого узла, даже если это простые элементы, у которых нет shadow root.

Давайте добавим еще одну функцию для отслеживания фокуса внутри теневого корня:

function shadowRootActiveElement(root: Node): Observable<EventTarget> {
  return merge(
    fromEvent(root, 'focusin').pipe(map(({target}) => target)),
    fromEvent(root, 'focusout').pipe(map(({relatedTarget}) => relatedTarget)),
  );
}

Теперь, когда у нас есть эти хелперы, давайте перепишем наш обработчик focusin:

const gain$ = focusin$.pipe(
  switchMap(event => {
    const target = getActualTarget(event);
    const root = getDocumentOrShadowRoot(target);

    return root === documentRef
      ? of(target)
      : shadowRootActiveElement(root).pipe(startWith(target));
  }),
);

Если фокус перемещается внутрь теневого корня, мы начинаем слушать эти инкапсулированные события фокуса, в противном случае просто возвращаем target, как и раньше. Для события mousedown достаточно использовать getActualTarget.

? Удаление и Disabled. Не каждая потеря фокуса должна считаться уходом из зоны. Когда мы явно вызываем .blur() на сфокусированном элементе, это похоже на намеренный уход. Но когда вы кликаете на кнопку и она становится неактивной, например при запуске какого-то процесса загрузки, Chrome также отправит событие focusout. То же самое происходит с кнопкой, которая удаляет себя (или свой контейнер) при клике. Когда это происходит внутри выпадающего меню, скорее всего, мы не хотим, чтобы оно автоматически закрывалось.

Насколько мне известно, нет способа отличить вызов element.blur() от события blur, вызванного удалением элемента из DOM.

Проверить disabled легко, но удаление из DOM — крепкий орешек. Помните: мы должны делать все синхронно. Мы не можем просто подождать и посмотреть, исчез ли элемент в следующем кадре. На этот раз, боюсь, нам придется прибегнуть к костылю. 

Taiga UI требует от вас использования анимаций Angular. И AnimationEngine знает, какой элемент удаляется. К сожалению, нет способа получить к нему доступ, потому что он не экспортируется. Нам придется использовать приватный API для этого, и это плохо. Но в Chrome нет другого выхода. Код не менялся с момента появления анимаций Angular. Создадим поток элемента, который удаляется:

const REMOVED_ELEMENT = new InjectionToken<Observable<Element | null>>(
  'Element currently being removed by AnimationEngine',
  {
    factory: () => {
      const stub = {onRemovalComplete: () => {}};
      const element$ = new BehaviorSubject<Element | null>(null);
      const engine = inject(ɵAnimationEngine, { optional: true }) ?? stub;
      const {onRemovalComplete = stub.onRemovalComplete} = engine;

      engine.onRemovalComplete = (element, context) => {
        element$.next(element);
        onRemovalComplete(element, context);
      };

      return element$.pipe(
        switchMap(element => timer(0).pipe(
          map(() => null), 
          startWith(element)
        )),
        share(),
      );
    },
  },
);

Мы создаем заглушку и опционально внедряем AnimationEngine с фоллбеком на случай, если что-то пойдет не так. Мы заменяем onRemovalComplete своим методом, чтобы уведомить созданный BehaviorSubject. Комментарий к onRemovalComplete гласит:

// this method is designed to be overridden by the code that uses this engine

По сути, мы делаем то, что задумано, если бы AnimationEngine был публичным.

Затем добавляем простой switchMap, чтобы сбросить наш поток обратно в null на следующем кадре.

Добавим все это в нашу цепочку и напишем вспомогательную функцию для проверки того, хотим мы реагировать на конкретное событие focusout или нет:

const loss$ = focusout$.pipe(
  takeUntil(mousedown$),
  repeatWhen(() => mouseup$),
  withLatestFrom(inject(REMOVED_ELEMENT)),
  filter(([event, removedElement]) =>
    isValidFocusout(getActualTarget(event), removedElement),
  ),
  map(([{relatedTarget}]) => relatedTarget),
);

Новые версии Chrome подкинули еще один кейс: скроллящиеся контейнеры теперь тоже фокусируемы, если не содержат в себе интерактивных детей. Если скролл пропадет в результате изменения размера или содержимого, мы получим событие blur. Достаточно убедиться, что цель события фокусируема. Код проверки можно найти у нас в репозитории.

function isValidFocusout(target: any, removed: Element | null): boolean {
  return (
    // Не из-за переключения вкладок/перехода в DevTools
    target.ownerDocument?.activeElement !== target &&
    // Не из-за того, что кнопка/ввод стали неактивными
    !target.matches(‘:disabled’) &&
    // Не из-за удаления элемента из DOM
    (!removed || !removed.contains(target)) &&
    // Не из-за скроллящегося элемента
    isFocusable(target)
  );
}

Результат

Кода получилось много, надеюсь, вы не очень устали. Иногда даже не задумываешься, насколько нетривиально может быть закрывать выпадашку. Я точно не ожидал, с чем мне придется столкнуться, когда сел это реализовывать. 

Подобные решения строятся постепенно, от простой реализации в начале статьи к тому, что мы получили в конце, по мере обнаружения новых сложностей. Если не считать обновление Chrome, этот код служит нам в Тайге верой и правдой не первый год. Вы можете увидеть решение в действии в StackBlitz ниже, со всеми упомянутыми корнер-кейсами.

P.S. Вопрос для любознательных: почему обязательно надо проверять .matches(':disabled'), а не просто свойство .disabled? ?

Комментарии (0)