From e2503c7f609c0753f0e8c650b56dbfaeef76bfc0 Mon Sep 17 00:00:00 2001 From: Xinglan233 Date: Thu, 8 Oct 2026 18:42:38 +0800 Subject: [PATCH] feedback7 G-UI: the import dialog traps keyboard focus and yields while the guide is open (PR #341 by @Xinglan233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. The shared Modal keeps Tab inside the topmost dialog, pulls focus back if it leaves, skips disabled, hidden, inert and zero-size controls, and does not trap while a .guide overlay is open. Escape still closes the dialog. Enter is unchanged, so a focused button keeps its native click. Tests: node --test test/ui/modal.e2e.test.js test/ui/loadout.e2e.test.js test/ui/leftovers.test.js test/ui/match-info.test.js test/ui/loadout.test.js test/ui/playtest3.test.js test/ui/playtest4.test.js test/ui/uiFixes.test.js test/ui/localArt.test.js — 114 pass, 0 fail. The modal and loadout e2e suites skipped because SP_E2E is unset. eslint on the touched files: 0 errors. --- public/js/ui/components.js | 44 +++++++--- test/ui/loadout.e2e.test.js | 58 +++++++++++++ test/ui/modal.e2e.test.js | 168 ++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 test/ui/modal.e2e.test.js 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`