diff --git a/components/notifications/__tests__/notification-center.test.tsx b/components/notifications/__tests__/notification-center.test.tsx index 8bbe2ba38..29f302714 100644 --- a/components/notifications/__tests__/notification-center.test.tsx +++ b/components/notifications/__tests__/notification-center.test.tsx @@ -138,3 +138,34 @@ describe('notification bell', () => { expect(screen.queryByRole('dialog')).toBeNull(); }); }); + +describe('notification panel and the keyboard', () => { + it('moves the focus into the panel when it opens', () => { + act(() => add({ level: 'error', title: 'a', body: '' })); + render(); + fireEvent.click(bell()); + expect(screen.getByRole('button', { name: 'mark_all_read' })).toHaveFocus(); + }); + + it('focuses the panel itself when it has nothing to act on', () => { + render(); + fireEvent.click(bell()); + expect(screen.getByRole('dialog', { name: 'title' })).toHaveFocus(); + }); + + it('closes and returns to the bell when Tab leaves the panel', () => { + act(() => add({ level: 'error', title: 'a', body: '' })); + render(); + fireEvent.click(bell()); + const clear = screen.getByRole('button', { name: 'clear' }); + clear.focus(); + act(() => { fireEvent.keyDown(clear, { key: 'Tab' }); }); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(bell()).toHaveFocus(); + + fireEvent.click(bell()); + const markAll = screen.getByRole('button', { name: 'mark_all_read' }); + act(() => { fireEvent.keyDown(markAll, { key: 'Tab', shiftKey: true }); }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/components/notifications/notification-center.tsx b/components/notifications/notification-center.tsx index ee6a09964..dd91dc95d 100644 --- a/components/notifications/notification-center.tsx +++ b/components/notifications/notification-center.tsx @@ -153,6 +153,11 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo ); } +/** The panel's enabled controls, in tab order. */ +function panelControls(panel: HTMLElement): HTMLElement[] { + return Array.from(panel.querySelectorAll("button:not(:disabled), a[href]")); +} + /** The phone version: the panel over the whole screen. */ export function NotificationSheet({ onClose }: { onClose: () => void }) { const t = useTranslations("notification_center"); @@ -205,6 +210,32 @@ export function NotificationBell({ placement, className }: { placement: "side" | if (open) position(); }, [open, position]); + // The panel is portalled to the end of , so Tab from the bell would + // never reach it: focus moves in on open, to its first control (or the + // panel itself while every control is disabled). + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + if (!panel) return; + (panelControls(panel)[0] ?? panel).focus(); + }, [open]); + + // Tab past either end of the panel closes it and goes back to the bell, so + // the next Tab carries on from where the panel was opened. + const onPanelKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "Tab" || !panelRef.current) return; + const controls = panelControls(panelRef.current); + const first = controls[0]; + const last = controls[controls.length - 1]; + const active = document.activeElement; + const leaving = controls.length === 0 + || (e.shiftKey ? active === first || active === panelRef.current : active === last); + if (!leaving) return; + e.preventDefault(); + setOpen(false); + buttonRef.current?.focus(); + }; + useEffect(() => { if (!open) return; const onPointerDown = (e: MouseEvent) => { @@ -264,6 +295,8 @@ export function NotificationBell({ placement, className }: { placement: "side" | ref={panelRef} role="dialog" aria-label={t("title")} + tabIndex={-1} + onKeyDown={onPanelKeyDown} style={style} className="z-50 w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(32rem,80vh)] flex flex-col overflow-hidden rounded-lg border border-border bg-background shadow-lg" >