const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); export function getFocusable(container: HTMLElement): HTMLElement[] { return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)); } /** * Confines Tab/Shift+Tab navigation within a container. Layout-agnostic so it * also works under jsdom (no visibility checks). */ export class FocusTrap { private readonly container: HTMLElement; private active = false; constructor(container: HTMLElement) { this.container = container; } private readonly onKeydown = (event: KeyboardEvent): void => { if (event.key !== 'Tab') { return; } const focusable = getFocusable(this.container); if (focusable.length === 0) { event.preventDefault(); return; } const first = focusable[0]!; const last = focusable[focusable.length - 1]!; const current = document.activeElement; if (event.shiftKey && current === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && current === last) { event.preventDefault(); first.focus(); } }; activate(): void { if (this.active) { return; } this.active = true; document.addEventListener('keydown', this.onKeydown, true); } deactivate(): void { if (!this.active) { return; } this.active = false; document.removeEventListener('keydown', this.onKeydown, true); } }