fix: move the focus into the notification panel when it opens, and close it when Tab leaves

This commit is contained in:
Linus Rath
2026-10-28 03:15:02 +01:00
parent 8d43023c48
commit fbbeac5291
2 changed files with 64 additions and 0 deletions
@@ -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(<NavigationRail collapsed />);
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(<NavigationRail collapsed />);
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(<NavigationRail collapsed />);
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();
});
});
@@ -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<HTMLElement>("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 <body>, 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<HTMLDivElement>) => {
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"
>