Tracking user interaction area
Explore one of the most complex pieces of Taiga UI — ActiveZone directive that keeps an eye on what region user is working with. It touches on low-level native DOM events API, advanced RxJS and Dependency Injection, ShadowDOM and more!

Tracking user interaction area
Explore one of the most complex pieces of Taiga UI — ActiveZone directive that keeps an eye on what region user is working with. It touches on low-level native DOM events API, advanced RxJS and Dependency Injection, ShadowDOM and more!


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 taskLink to this section
Basic usage example can be a button with a dropdown menu:
<>Copy<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
andblur
events don't bubble, unlikefocusin
andfocusout
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 aftermousedown
event as a default behavior. If you callpreventDefault()
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:
<>Copyexport 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:
<>Copyconst loss$ = focusout$.pipe( takeUntil(mousedown$), repeatWhen(() => mouseup$), map(({ relatedTarget }) => relatedTarget) ); // ... continue below ↓↓↓
relatedTarget
for thefocusout
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:
<>Copyconst 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)
):
<>Copyconst 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:
<>Copyreturn 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:
<>Copy<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:
<>Copy@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:
<>Copy@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.
iframeLink to this section
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
:
<>Copyconst iframe$ = fromEvent(windowRef, 'blur').pipe( map(() => documentRef.activeElement), filter(element => !!element && element.matches('iframe')), );
Another case whenactiveElement
is notbody
insidefocusout
event is when we leave the tab. We will use it later so our dropdowns won't close when we go to DevTools!
ShadowDOMLink to this section
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:
<>Copyfunction getActualTarget(event: Event): EventTarget { return event.composedPath()[0]; } function getDocumentOrShadowRoot(node: Node): Node { return node.isConnected ? node.getRootNode() : node.ownerDocument; }
We need to checkisConnected
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 theirdocument
.
Let's add one more function to track focus within a shadow root:
<>Copyfunction 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:
<>Copyconst 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 disableLink to this section
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 anelement.blur()
call from ablur
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:
<>Copyexport 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:
<>Copyconst 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!
Comments (0)
Be the first to leave a comment
About the author

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Taiga UI at Tinkoff and I like sharing my findings.

About the author
Alex Inkin
I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Taiga UI at Tinkoff and I like sharing my findings.
About the author

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Taiga UI at Tinkoff and I like sharing my findings.