Saturday, 11 September, 2021 UTC


Summary

It is often necessary to know what area of the page the user is interacting with. For example, if you are making a dropdown — you want to know when to close it. A naïve implementation would just to listen to clicks and check if it happened outside of the dropdown element. But the mouse is not the only way of interaction. A person can use a keyboard to navigate focusable elements. And a dropdown can have a nested multi-level menu which makes plain click target checking problematic.
In this article, let’s explore ActiveZone directive — an approach we took in Taiga UI Angular components library. It employs two of my favorite Angular features: dependency injection and RxJS. We will also need a thorough understanding of native DOM events. However abstracted from plain JavaScript Angular might be, it still relies on good old Web APIs so it is important to grow your knowledge of vanilla front-end.
Outlining the task
Basic usage example can be a button with a dropdown menu:
<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>
Let's assume menu is rendered in a portal and is not a direct descendant of button. We want to open and close menu by a click on the button. And if user clicks away or moves focus with a tab key — a dropdown must close.
To track user interactions we will keep an eye mainly on the following events: focusin, focusout and mousedown.
focus and blur events don't bubble, unlike focusin and focusout
Keyboard navigation can only occur on focusable elements, while mousedown can be triggered by user selecting some text inside a dropdown, for example or interacting with non-focusable elements on the page. So those 3 events cover nearly everything we care about. But there are some tricky corner cases we will explore down the line.
Focus loss happens after mousedown event as a default behavior. If you call preventDefault() on this event, focus will remain where it is.
To achieve our goal it makes sense to always know what is the current element user is interacting with. In Angular world this means having an Observable of an active element. A very important clarification: we want it to be synchronous. This means, if a developer calls element.blur() in some of their code, on the very next line we must already know that we left the zone. This makes our task tenfold trickier and soon you will know why!
Dependency Injection token
To be able to reach for active element Observable anywhere within our app, we will turn it into an InjectionToken. It is global by default and provides a handy factory option which we can use to construct our stream. It will be called once somebody first injects the token.
Let's make a first draft. First we will create all the constants we need:
export const ACTIVE_ELEMENT = new InjectionToken(
  'An element that user is currently interacting with',
  {
    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');

      // ... continue below ↓↓↓
    }
  },
);
Now let's combine those Observables into an Element stream. Since focus loss naturally happens after mousedown event we will stop listening to focusout after mousedown and repeat it after mouseup. That is why we need mouseup$ stream — this way we isolate all mouse related activity from focus related:
const loss$ = focusout$.pipe(
  takeUntil(mousedown$),
  repeatWhen(() => mouseup$),
  map(({ relatedTarget }) => relatedTarget)
);

// ... continue below ↓↓↓
relatedTarget for the focusout event is an element that focus is about to be moved to or null if focus is going nowhere.
In terms of focus gain we just need to map focusin event to the target:
const gain$ = focusin$.pipe(map(({ target }) => target));

// ... continue below ↓↓↓
Mouse interaction is more complicated. On each mousedown event we check if something is currently focused. If not (activeElement equals to body) we simply map mousedown event to its target. If something is focused we start to listen to focusout event, expecting focus loss. We use mapTo to turn this event into the mousedown target as well. But if default action was prevented and focus remained where it was, we stop waiting for focusout event on the next frame (timer(0)):
const mouse$ = mousedown$.pipe(
  switchMap(({ target }) =>
    documentRef.activeElement === documentRef.body
      ? of(target)
      : focusout$.pipe(
        take(1),
        takeUntil(timer(0)),
        mapTo(target)
      )
    )
);

// ... continue below ↓↓↓
Next let's merge all those streams together. Now we have an Observable of an Element user currently interacts with:
return merge(loss$, gain$, mouse$).pipe( 
  distinctUntilChanged(),
  share() 
);
We pipe the whole stream through distinctUntilChanged and share operators so there are no extra emission and subscriptions happening.
ActiveZone directive
Now that we have the stream, let's make a simple directive. It would map the stream to boolean letting us know if user is currently interactive with a given area. It would also reach through DI for parent directive of the same kind and register itself as its child zone. This way we can handle nested dropdowns mentioned in the beginning of the article.
Let's see how we can do this:
<button (activeZoneChange)="onActiveZone($event)">
  Show menu
  <ng-template #template>
    <!-- 
      "activeZone" injects parent directive "activeZoneChange"
      from the button above, even if template is instatiated
      in a different place in the DOM
    -->
    <some-menu-component activeZone></some-menu-component>
  </ng-template>
</button>
This DI nesting can be any levels deep, that's how nested menus and dropdowns can still be parts of the topmost zone. Now let's write the actual directive:
@Directive({
  selector: '[activeZone],[activeZoneChange]'
})
export class ActiveZoneDirective implements OnDestroy {
  private children: readonly ActiveZoneDirective[] = [];

  constructor(
    @Inject(ACTIVE_ELEMENT)
    private readonly active$: Observable<Element>,
    private readonly elementRef: ElementRef<Element>,
    @Optional()
    @SkipSelf()
    private readonly parent: ActiveZoneDirective | null,
  ) {
    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);
  }
}
This directive has only one public method — contains which is used to check if an element is located within current zone or any of its children. Now let's add an @Output. Since Angular outputs are Observables we can simply use our stream and pipe it:
@Output()
readonly activeZoneChange = this.active$.pipe(
  map(element => this.contains(element)),
  startWith(false),
  distinctUntilChanged(),
  skip(1),
);
Every new active element is checked against our directive, the stream starts with false so that distinctUntilChanged will not let through subsequent false results. We also skip the starting value so it doesn't immediately emit.
Potholes and pitfalls
Where's the fun in everything working from the get go? The code above is pretty clean and functional, however there are certain cases where it will fail. Let's explore them and expand our solution to cover them all.
iframe
There's a frustrating behavior when using iframe. Whenever you click it, mousedown event does not happen. This means that if we have some nested iframe on our page and user clicks it, we will not know that they left the active zone. Thankfully, a blur event is dispatched on window when we start interacting with an iframe. It makes sense — we left the window to work with a nested one.
During most of the focus events if you were to try and check document.activeElement you will find body there. However in this particular case, inside blur event callback active element is already the clicked iframe. So all we need to do is amend our stream by including this in the merge:
const iframe$ = fromEvent(windowRef, 'blur').pipe(
  map(() => documentRef.activeElement),
  filter(element => !!element && element.matches('iframe')),
);
Another case when activeElement is not body inside focusout event is when we leave the tab. We will use it later so our dropdowns won't close when we go to DevTools!
ShadowDOM
When working with Web Components or just ShadowDOM in general, you might have multiple focusable elements inside. window will not know about focus transitions within the shadow root. document.activeElement will remain the same — the shadow root element. And that shadow root will have its own activeElement to track the real focused element. Moreover, the target of all our events will not be the real element. It will be the shadow root as well. However, real target will be exposed to us through composedPath method on event.
It will not work for closed shadow roots but it's the best we can do
So we need a utility function to retrieve the actual target. To reach for activeElement within ShadowDOM we will also need a function to get DocumentOrShadowRoot.
Let's add them both:
function getActualTarget(event: Event): EventTarget {
  return event.composedPath()[0];
}

function getDocumentOrShadowRoot(node: Node): Node {
  return node.isConnected ? node.getRootNode() : node.ownerDocument;
}
We need to check isConnected because nodes detached from DOM will return the topmost element in their structure as a root node. For detached nodes it returns false and we get their document.
Let's add one more function to track focus within a shadow root:
function shadowRootActiveElement(root: Node): Observable<EventTarget> {
  return merge(
    fromEvent(root, 'focusin').pipe(map(({target}) => target)),
    fromEvent(root, 'focusout').pipe(map(({relatedTarget}) => relatedTarget)),
  );
}
Now that we have those helpers let's rewrite our focusin handler:
const gain$ = focusin$.pipe(
  switchMap(event => {
    const target = getActualTarget(event);
    const root = getDocumentOrShadowRoot(target);

    return root === documentRef
      ? of(target)
      : shadowRootActiveElement(root).pipe(startWith(target));
  }),
);
If focus is moving inside a shadow root we start listening to those encapsulated focus events, otherwise we just return target as before. For the mousedown event it is enough to just use getActualTarget.
Deletion and disable
Not every focus loss should be considered a departure from zone. When we explicitly call .blur() on a focused element — it's a good case to call leaving the zone. However, when you click a button and it becomes disabled, for example by triggering some loading process, Chrome would also dispatch focusout event. Same goes for a button that removes itself (or its container) on click. When this happens inside a dropdown, most likely we do not want to close it automatically.
As far as I know there's no way to tell an element.blur() call from a blur event that was caused by element being removed from DOM.
Checking disabled is easy, but removal from DOM is a hard nut. Remember, we have to do it synchronously. We cannot just wait and check if element disappears in the next frame. This time, I'm afraid we will have to resort to a workaround. Taiga UI requires you to use Angular animations. And AnimationEngine knows what element is being removed. Unfortunately there's no way to reach it because it is not exposed. So we will have to use private API for this. This is bad. But there's nothing else we can do in Chrome. And this hasn't changed since the introduction of Angular animations. Let's make a stream of an element being removed:
export 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, InjectFlags.Optional) ?? stub;
      const {onRemovalComplete = stub.onRemovalComplete} = engine;

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

      return element$.pipe(
        switchMap(element => timer(0).pipe(
          mapTo(null), 
          startWith(element)
        )),
        share(),
      );
    },
  },
);
Let's break down what we're doing here. We make a simple stub and optionally inject AnimationEngine with a fallback just in case. We substitute onRemovalComplete with our own method to notify the BehaviorSubject we created. Comment on onRemovalComplete reads:
// this method is designed to be overridden by the code that uses this engine
So we pretty much do what we're supposed to. If AnimationEngine was exposed.
Then we add a simple switchMap to reset our stream back to null on the next frame.
Let's add this new stream to our chain and write a utility function to check if we want to react to a particular focusout event or not:
const loss$ = focusout$.pipe(
  takeUntil(mousedown$),
  repeatWhen(() => mouseup$),
  withLatestFrom(inject(REMOVED_ELEMENT)),
  filter(([event, removedElement]) =>
    isValidFocusout(getActualTarget(event), removedElement),
  ),
  map(([{relatedTarget}]) => relatedTarget),
);

// ...

function isValidFocusout(target: any, removedElement: Element | null): boolean {
  return (
    // Not due to switching tabs/going to DevTools
    target.ownerDocument?.activeElement !== target &&
    // Not due to button/input becoming disabled
    !target.disabled &&
    // Not due to element being removed from DOM
    (!removedElement || !removedElement.contains(target))
  );
}
End result
That was probably a lot to digest. This is not something one comes up with instantly when given this task. Such things are built gradually. You can see this whole solution in action in the StackBlitz below, with all the mentioned corner cases:
This is the best I've managed so far and it is used in the current Taiga UI CDK package. If you find a bug or a case not taken into account, please file an issue!