Skip to content

Focus

js
import { 
saveActiveElement
,
trapFocus
,
untrapFocus
} from '@studiometa/js-toolkit/utils';

The three calls a modal surface needs.

Usage

trapFocus() takes the keyboard event, so it is called from a key handler rather than installing a listener of its own:

js
import { 
Base
} from '@studiometa/js-toolkit';
import {
saveActiveElement
,
trapFocus
,
untrapFocus
} from '@studiometa/js-toolkit/utils';
class
Dialog
extends
Base
{
static
config
= {
name
: 'Dialog' };
open
() {
saveActiveElement
();
this.
$el
.
removeAttribute
('hidden');
}
close
() {
this.
$el
.
setAttribute
('hidden', '');
untrapFocus
();
}
onKeydown
(
event
) {
trapFocus
(this.
$el
,
event
);
} }

With useKey() the event is in the props:

js
mounted() {
  return useKey(this.$el).subscribe(({ event, TAB, isDown }) => {
    if (TAB && isDown && event) trapFocus(this.$el, event);
  });
}

This is why the key listeners are not passive

trapFocus() calls preventDefault() on the event it is handed, and a passive listener cannot. See useKey().

The three

saveActiveElement

ts
saveActiveElement(): void

Remembers what had focus, so untrapFocus() can give it back.

trapFocus

ts
trapFocus(el: HTMLElement, event: KeyboardEvent): void

Keeps Tab and Shift+Tab inside el.

untrapFocus

ts
untrapFocus(): void

Releases the trap and restores focus to the saved element.

The saved element is shared across copies

Through the shared runtime, for the same reason the scroll lock counts: there is one focus per document. Two independently evaluated copies of the package must not each think they own it.

<dialog> already does some of this

showModal() gives the top layer, the backdrop, a focus trap and Escape. What it does not do is stop the page behind it from scrolling — see lockScroll().

Reach for these three when the surface is not a native <dialog>.

MIT Licensed