feedback7 G-UI: the import dialog traps keyboard focus and yields while the guide is open (PR #341 by @Xinglan233)
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.
This commit is contained in:
1 parent
d07d97dc4c
commit
e2503c7f60
3 files changed
+259
-11
No files matched your search
+33
-11
@@ -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`<div class="modal" role="presentation"
|
||||
onMouseDown=${(e) => { if (closeOnBackdrop && e.target === e.currentTarget && onClose) onClose(); }}>
|
||||
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true"
|
||||
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true" tabindex="-1"
|
||||
style=${width ? `width:${width}` : undefined}>
|
||||
<div class="modal__stripe" aria-hidden="true"></div>
|
||||
${title || micro ? html`<header class="modal__head">
|
||||
|
||||
Reference in new issue
Block a user