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"
>