Knowing which part of the page the user is actively engaging with is a common requirement. Consider a dropdown: you need to determine the right moment to close it. A basic approach might involve listening for clicks and verifying they occur outside the dropdown's element. However, the mouse isn't the only input method. Users can also navigate using the keyboard to move focus between elements. Additionally, a dropdown with a nested multi-level menu complicates simple click-target checks.

Let's examine the ActiveZone directive, a solution we developed for the Taiga UI Angular component library. This directive leverages two of my preferred Angular features: dependency injection and RxJS. We'll also need a solid grasp of native DOM events. Even though Angular abstracts away much of plain JavaScript, it still depends on standard Web APIs, making knowledge of vanilla front-end essential.

Defining the objective

A typical use case is a button that triggers 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>

Assume the menu is rendered inside a portal and isn't a child of the button. Our goal is to open and close the menu when the button is clicked. The dropdown should also close if the user clicks elsewhere or uses the tab key to move focus away.

To monitor user interaction, we'll primarily track these events: focusin, focusout, and mousedown.

Unlike focus and blur, the focusin and focusout events do bubble.

Keyboard navigation only affects focusable elements, while mousedown can be triggered by actions like selecting text within a dropdown or interacting with non-focusable page elements. Thus, these three events cover most scenarios we care about. However, we'll encounter some tricky edge cases later on.

As a default browser behavior, focus is lost after a mousedown event. If you call preventDefault() on this event, focus will remain unchanged.

To reach our objective, it's logical to maintain a constant awareness of the current element the user is interacting with. In Angular, this translates to an Observable of the active element. A crucial clarification: we want this to be synchronous. If a developer calls element.blur(), the very next line of code should already reflect that we've left the zone. This requirement makes the task significantly more challenging, as you'll soon discover!

To make the active element Observable accessible throughout the app, we'll wrap it in an InjectionToken. This token has a global scope by default and offers a useful factory option for constructing our stream. The factory will be invoked the first time the token is injected.

Let's create an initial draft. First, we'll define all the necessary constants:

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 these Observables into a single stream of Elements. Since focus loss naturally follows a mousedown event, we'll pause listening to focusout after a mousedown and resume after mouseup. This is why we need the mouseup$ stream — it helps isolate mouse-related activity from focus-related activity:

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

// ... continue below ↓↓↓

For the focusout event, relatedTarget is the element that will receive focus, or null if focus isn't moving anywhere.

To handle focus gain, we simply map the focusin event to its target:

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

// ... continue below ↓↓↓

Mouse interaction is more complex. On every mousedown event, we first check if something is currently focused. If not (i.e., activeElement is the body), we simply map the mousedown event to its target. If something is focused, we then listen for the focusout event to expect focus loss. We use mapTo to convert this event into the mousedown target. However, if the default action was prevented and focus remained, we stop waiting for the focusout event on the next frame (using 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, we merge all these streams. We now have an Observable that emits the Element the user is currently interacting with:

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

The entire stream is piped through distinctUntilChanged and share operators to avoid unnecessary emissions and subscriptions.

With the stream ready, we can build a simple directive. It will map the stream to a boolean, indicating whether the user is currently interacting with a given area. Through DI, it will also locate a parent directive of the same type and register itself as a child zone. This allows us to manage nested dropdowns as mentioned at the start of the article.

Here's a demonstration of the process:

<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 go arbitrarily deep, which is how nested menus and dropdowns remain within the topmost zone. Now, let's write the directive itself:

@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);
  }
}

The directive exposes one public method — contains — which checks if an element is within the current zone or any of its children. Let's add an @Output. Since Angular outputs are Observables, we can simply reuse our stream and pipe it:

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

Each new active element is checked against our directive. The stream starts with false, so distinctUntilChanged won't let through subsequent false values. We also skip the initial value to prevent an immediate emission.

It wouldn't be fun if everything worked on the first try. The code above is clean and functional, but certain cases will cause it to fail. Let's examine these and enhance our solution to handle them.

iframe

There's a frustrating behavior when using iframe. Clicking inside one doesn't trigger the mousedown event. So, if our page contains a nested iframe and the user clicks it, we won't know they've left the active zone. Fortunately, a blur event is dispatched on the window when we start interacting with an iframe. This makes sense – we've left the enclosing window to work with a nested one.

With most focus events, checking document.activeElement would reveal the body. However, in this special case, the active element is already the clicked iframe within the blur event callback. So, we just need to include this in our merge:

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

Another instance where activeElement isn't body inside a focusout event is when we switch tabs. We'll use this later to prevent our dropdowns from closing when you access DevTools!

ShadowDOM

When working with Web Components or ShadowDOM, multiple elements inside can be focusable. The window won't be aware of focus transitions within a shadow root. document.activeElement will remain the same – the shadow root element. That shadow root will have its own activeElement to track the real focused element. Additionally, the target of our events will not be the actual element but the shadow root. The real target is accessible through the composedPath method on the event.

This won't apply to closed shadow roots, but it's the best we can do.

Consequently, we need a utility function to retrieve the actual target. To access activeElement within ShadowDOM, we'll also need a function to get the [DocumentOrShadowRoot](https://html.spec.whatwg.org/multipage/interaction.html#dom-documentorshadowroot-activeelement-dev).

Let's add both functions:

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

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

We must check isConnected because nodes detached from the DOM 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 another 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)),
  );
}

With these 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 moves inside a shadow root, we listen for those encapsulated focus events; otherwise, we return the target as before. For the mousedown event, using getActualTarget is sufficient.

Deletion and disable

Not every focus loss should be treated as leaving the zone. When we explicitly call .blur() on a focused element, it's appropriate to consider it leaving. However, when a button is clicked and becomes disabled (e.g., triggering a loading process), Chrome also dispatches a focusout event. The same applies when a button removes itself or its container upon clicking. When this happens within a dropdown, we likely don't want it to close automatically.

As far as I know, there's no way to differentiate between an element.blur() call and a blur event caused by element removal from the DOM.

Checking for disabled is straightforward, but detecting DOM removal is tricky. Remember, we need to do this synchronously. We can't wait and check if the element disappears next frame. Regrettably, I'm afraid we'll have to use a workaround. Taiga UI requires you to use Angular animations. The AnimationEngine knows which element is being removed. Unfortunately, there's no direct way to access it because it's not public. So, we'll have to rely on private API for this. This is not ideal. But there's no alternative in Chrome, and this hasn't changed since Angular animations were introduced. Let's create a stream for the 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 unpack what's happening here. We create a simple stub and optionally inject AnimationEngine with a fallback. We override onRemovalComplete with our method to notify the BehaviorSubject. The comment on onRemovalComplete reads:

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

So, we're essentially doing what it's meant for, even though AnimationEngine isn't publicly accessible.

We then add a switchMap to reset our stream to null on the next frame.

Let's incorporate this new stream into our chain and write a utility function to determine if we should react to a specific focusout event:

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))
  );
}

That was likely a lot to absorb. This isn't something you'd devise instantly when given this task. Such solutions are developed iteratively. You can see this entire solution in action in the StackBlitz below, including all the discussed edge cases:

This represents my best effort so far and is currently used in the Taiga UI CDK package. If you encounter a bug or an unhandled case, please file an issue!