import React from 'react'; /** * Constrains keyboard Tab navigation to stay within a given container element. * * This function implements a simple "focus loop" (a.k.a. focus trap) behavior. * When the user presses Tab on the last focusable element, focus will wrap to * the first focusable element. When pressing Shift+Tab on the first focusable * element, focus will wrap to the last. * * Use this in keyboard event handlers (typically onKeyDown) when building * components such as modals, popovers, drawers, or any UI that should trap * focus within a defined scope. * * @param element - The container within which focus should be scoped. * @param keyboardEvent - The keyboard event from React (usually from onKeyDown). */ export function scopeTab( element: HTMLElement, keyboardEvent: React.KeyboardEvent, ) { const focusableSelectors = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', "[tabindex]:not([tabindex='-1'])", ]; const getFocusable = (): HTMLElement[] => Array.from( element.querySelectorAll(focusableSelectors.join(',')), ).filter(isVisible); const focusables = getFocusable(); if (focusables.length === 0) { return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; const active = document.activeElement as HTMLElement | null; if (element === active && keyboardEvent.shiftKey) { // Shift + Tab on the container itself keyboardEvent.preventDefault(); last.focus(); } else if (keyboardEvent.shiftKey) { // Shift + Tab if (active === first) { keyboardEvent.preventDefault(); last.focus(); } } else { // Tab if (active === last) { keyboardEvent.preventDefault(); first.focus(); } } } function isVisible(el: HTMLElement): boolean { if (!el) { return false; } const style = window.getComputedStyle(el); if (style.display === 'none') { return false; } if (style.visibility === 'hidden') { return false; } if (el.getAttribute('aria-hidden') === 'true') { return false; } return true; }