diff --git a/web/src/components/overlay.test.tsx b/web/src/components/overlay.test.tsx index d7f64b3b7..2701460fd 100644 --- a/web/src/components/overlay.test.tsx +++ b/web/src/components/overlay.test.tsx @@ -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(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( + <> + + + + + + + ); + + const panel = screen.getByRole('dialog'); + const outside = screen.getByRole('button', { name: 'Outside' }); + const stops = Array.from(panel.querySelectorAll(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', () => { diff --git a/web/src/components/overlay.tsx b/web/src/components/overlay.tsx index af4857dd7..470dd5f18 100644 --- a/web/src/components/overlay.tsx +++ b/web/src/components/overlay.tsx @@ -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( + '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 {