Focus
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:
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:
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
saveActiveElement(): voidRemembers what had focus, so untrapFocus() can give it back.
trapFocus
trapFocus(el: HTMLElement, event: KeyboardEvent): voidKeeps Tab and Shift+Tab inside el.
untrapFocus
untrapFocus(): voidReleases 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>.