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

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:
2026-07-22 18:55:26 +02:00
co-authored by Claude Opus 4.8
parent 1681ae4e60
commit ae4408c6b2
2 changed files with 107 additions and 1 deletions
+65
View File
@@ -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', () => {
+42 -1
View File
@@ -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 {