fix(248): focus-trap the shared modal overlay so Tab can't escape a dialog
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m42s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m42s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
focus trap added to shared useOverlayBehavior (covers Dialog + SlideOver); Tab/Shift+Tab now cycle within the panel; test added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,71 @@ describe('Dialog', () => {
|
||||
|
||||
expect(document.body.style.overflow).toBe('scroll');
|
||||
});
|
||||
|
||||
it('traps Tab focus inside the dialog and never lets it reach the outside control', { timeout: 15000 }, () => {
|
||||
const focusableSelector =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
// Advance focus like a browser Tab: the trap intercepts the panel edges, and
|
||||
// jsdom does not perform native tabbing, so emulate the in-between move here.
|
||||
const tab = (panel: HTMLElement, shift = false) => {
|
||||
const stops = Array.from(panel.querySelectorAll<HTMLElement>(focusableSelector));
|
||||
const before = document.activeElement as HTMLElement;
|
||||
const index = stops.indexOf(before);
|
||||
fireEvent.keyDown(document, { key: 'Tab', shiftKey: shift });
|
||||
if (document.activeElement === before && index > -1) {
|
||||
const next = shift ? index - 1 : index + 1;
|
||||
if (next >= 0 && next < stops.length) {
|
||||
stops[next].focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render(
|
||||
<>
|
||||
<button type="button">Outside</button>
|
||||
<Dialog open onClose={vi.fn()} title="Edit channel">
|
||||
<button type="button">Alpha</button>
|
||||
<button type="button">Beta</button>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog');
|
||||
const outside = screen.getByRole('button', { name: 'Outside' });
|
||||
const stops = Array.from(panel.querySelectorAll<HTMLElement>(focusableSelector));
|
||||
// Panel order: Close (header), then the body buttons.
|
||||
expect(stops.map((element) => element.textContent?.trim() || element.getAttribute('title'))).toEqual([
|
||||
'Close',
|
||||
'Alpha',
|
||||
'Beta'
|
||||
]);
|
||||
|
||||
// Focus starts on the panel (tabIndex -1); the first Tab pulls it onto the first stop.
|
||||
expect(document.activeElement).toBe(panel);
|
||||
|
||||
// Two full forward cycles: focus stays inside the panel and never lands outside.
|
||||
for (let step = 0; step < stops.length * 2; step += 1) {
|
||||
tab(panel);
|
||||
expect(panel.contains(document.activeElement)).toBe(true);
|
||||
expect(document.activeElement).not.toBe(outside);
|
||||
}
|
||||
|
||||
// Wrap forward off the last stop returns to the first.
|
||||
stops[stops.length - 1].focus();
|
||||
tab(panel);
|
||||
expect(document.activeElement).toBe(stops[0]);
|
||||
|
||||
// Shift+Tab off the first stop wraps to the last.
|
||||
stops[0].focus();
|
||||
tab(panel, true);
|
||||
expect(document.activeElement).toBe(stops[stops.length - 1]);
|
||||
|
||||
// Focus sitting on the outside control is redirected back into the panel.
|
||||
outside.focus();
|
||||
tab(panel);
|
||||
expect(document.activeElement).toBe(stops[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlideOver', () => {
|
||||
|
||||
@@ -36,6 +36,47 @@ function useOverlayBehavior(open: boolean, onClose: () => void, panelRef: React.
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus trap: keep Tab / Shift+Tab cycling only among the focusable elements
|
||||
// inside the panel so keyboard focus can't escape onto the background screen.
|
||||
// Recompute the set on each Tab (dialog contents change) and let the browser
|
||||
// handle the in-between moves — we only intercept the wrap at either edge, or
|
||||
// redirect focus back in when it sits outside the panel (or on the panel itself,
|
||||
// which is tabIndex -1 and so excluded from the stops below).
|
||||
const panel = panelRef.current;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusable = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const index = focusable.indexOf(document.activeElement as HTMLElement);
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (index <= 0) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (index === -1 || index === focusable.length - 1) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
@@ -43,7 +84,7 @@ function useOverlayBehavior(open: boolean, onClose: () => void, panelRef: React.
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, panelRef]);
|
||||
}
|
||||
|
||||
export interface DialogProps {
|
||||
|
||||
Reference in New Issue
Block a user