diff --git a/public/js/ui/components.js b/public/js/ui/components.js index 3d8bff30..25716906 100644 --- a/public/js/ui/components.js +++ b/public/js/ui/components.js @@ -386,7 +386,7 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN // ---- Modal & dialogs --------------------------------------------------------------------------- -const modalStack = []; // open modals, topmost last: only the topmost reacts to Escape +const modalStack = []; // open modals, topmost last: only the topmost handles keyboard focus / Escape /** * Modal dialog (declarative). Esc / backdrop click call onClose. @@ -397,24 +397,46 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid const boxRef = useRef(null); const closeRef = useRef(onClose); closeRef.current = onClose; - useEffect(() => { + useLayoutEffect(() => { if (!open) return undefined; const token = {}; modalStack.push(token); const prevFocus = typeof document !== 'undefined' ? document.activeElement : null; + const box = boxRef.current; + // The independent guide can open above SettingsModal (its z-index is higher than --z-modal). + const topmost = () => modalStack[modalStack.length - 1] === token && !document.querySelector('.guide'); + const available = (el) => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length > 0 + && getComputedStyle(el).visibility !== 'hidden'; + const tabbable = () => [...box.querySelectorAll('button, [href], input, select, textarea, [tabindex], summary')] + .filter((el) => el.tabIndex >= 0 && available(el)); + const focusFirst = () => (tabbable()[0] || box).focus(); const onKey = (e) => { - if (e.key !== 'Escape' || modalStack[modalStack.length - 1] !== token || !closeRef.current) return; - e.stopPropagation(); - closeRef.current(); + if (!topmost()) return; + if (e.key === 'Escape' && closeRef.current) { + e.stopPropagation(); + closeRef.current(); + } else if (e.key === 'Tab') { + const els = tabbable(); + const first = els[0] || box, last = els[els.length - 1] || box; + if (!els.includes(document.activeElement) || document.activeElement === (e.shiftKey ? first : last)) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } + } + }; + const onFocus = (e) => { + if (topmost() && !box.contains(e.target)) focusFirst(); }; window.addEventListener('keydown', onKey); - const t = setTimeout(() => { - const el = boxRef.current?.querySelector('[data-autofocus]') || boxRef.current?.querySelector('button, input'); - el?.focus?.(); - }, 30); + window.addEventListener('focusin', onFocus); + const autofocus = box.querySelector('[data-autofocus]'); + if (topmost()) { + if (autofocus && available(autofocus)) autofocus.focus(); + else focusFirst(); + } return () => { window.removeEventListener('keydown', onKey); - clearTimeout(t); + window.removeEventListener('focusin', onFocus); const i = modalStack.indexOf(token); if (i >= 0) modalStack.splice(i, 1); prevFocus?.focus?.(); @@ -423,7 +445,7 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid if (!open) return null; return html`