From b84530cf5379c994366c49bc22fc3d56f1c27960 Mon Sep 17 00:00:00 2001 From: soufian3hm Date: Wed, 15 Jul 2026 00:17:03 +0100 Subject: [PATCH] fix(react): inbox a11y and robustness polish Deferred items from the hostile-frontend review: - move focus into the popover dialog on open (APG dialog pattern) - arrow-key/Home/End navigation for the more-actions menu - cap popover width at min(360px, calc(100vw - 16px)) - ensureStyles re-probes the DOM so head-replacing nav re-injects styles Refs #61 --- .changeset/inbox-a11y-polish.md | 5 +++ packages/react/src/Inbox.tsx | 12 ++++++ packages/react/src/archive-ui.test.tsx | 28 ++++++++++++ .../react/src/components/InboxContent.tsx | 43 ++++++++++++++++++- packages/react/src/inbox.test.tsx | 18 ++++++++ packages/react/src/styles.ts | 12 +++--- 6 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 .changeset/inbox-a11y-polish.md diff --git a/.changeset/inbox-a11y-polish.md b/.changeset/inbox-a11y-polish.md new file mode 100644 index 0000000..5e0c6a6 --- /dev/null +++ b/.changeset/inbox-a11y-polish.md @@ -0,0 +1,5 @@ +--- +"@chimely/react": patch +--- + +Inbox accessibility and robustness polish from the hostile-frontend review: move focus into the popover dialog on open (APG dialog pattern) so keyboard users reach a portaled popover directly, add arrow-key/Home/End navigation to the more-actions menu (honoring its `role=menu` semantics), cap the popover width at the viewport with `min(360px, calc(100vw - 16px))`, and re-probe the DOM in `ensureStyles` each call so head-replacing navigation (Turbo/PJAX) cannot suppress style re-injection. diff --git a/packages/react/src/Inbox.tsx b/packages/react/src/Inbox.tsx index 8b7dabf..99c75ec 100644 --- a/packages/react/src/Inbox.tsx +++ b/packages/react/src/Inbox.tsx @@ -198,6 +198,17 @@ function InboxView(props: InboxProps): ReactNode { }; }, [isOpen]); + // APG dialog pattern: move focus into the dialog on open. A portaled popover + // renders under document.body, after the host page in Tab order, so keyboard + // users would otherwise tab through the whole page to reach it. Escape returns + // focus to the bell (the dismissal effect above). + useEffect(() => { + if (!isOpen) { + return; + } + popoverRef.current?.focus(); + }, [isOpen]); + const popover = isOpen && (
(props: InboxProps): ReactNode { style={slotStyle(props.appearance, 'popover', rootStyle)} role="dialog" aria-labelledby={titleId} + tabIndex={-1} > tabs={props.tabs} diff --git a/packages/react/src/archive-ui.test.tsx b/packages/react/src/archive-ui.test.tsx index ea387e4..e23e732 100644 --- a/packages/react/src/archive-ui.test.tsx +++ b/packages/react/src/archive-ui.test.tsx @@ -98,6 +98,34 @@ describe('more-actions menu', () => { expect(screen.queryByRole('menu')).toBeNull(); }); + test('arrow keys and Home/End roam the menu items, wrapping at the ends', async () => { + const stub = createStubServer(); + stub.addNotification(); + await renderInbox(stub); + + fireEvent.click(screen.getByRole('button', { name: 'More actions' })); + const items = screen.getAllByRole('menuitem'); + // Focus lands on the first item when the menu opens. + expect(document.activeElement).toBe(items[0]); + + fireEvent.keyDown(items[0] as HTMLElement, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(items[1]); + + fireEvent.keyDown(items[1] as HTMLElement, { key: 'End' }); + expect(document.activeElement).toBe(items[items.length - 1]); + + // ArrowDown from the last item wraps to the first. + fireEvent.keyDown(items[items.length - 1] as HTMLElement, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(items[0]); + + // ArrowUp from the first item wraps to the last. + fireEvent.keyDown(items[0] as HTMLElement, { key: 'ArrowUp' }); + expect(document.activeElement).toBe(items[items.length - 1]); + + fireEvent.keyDown(items[items.length - 1] as HTMLElement, { key: 'Home' }); + expect(document.activeElement).toBe(items[0]); + }); + test('escape closes only the menu and restores trigger focus', async () => { const stub = createStubServer(); stub.addNotification(); diff --git a/packages/react/src/components/InboxContent.tsx b/packages/react/src/components/InboxContent.tsx index 4c5f1c6..4a87de7 100644 --- a/packages/react/src/components/InboxContent.tsx +++ b/packages/react/src/components/InboxContent.tsx @@ -150,6 +150,16 @@ export function InboxContent( }; }, [menuOpen]); + // role=menu owes keyboard semantics: focus the first item on open so the + // arrow keys have a starting point. Escape and outside pointerdown (above) + // close it, restoring focus to the trigger. + useEffect(() => { + if (!menuOpen) { + return; + } + menuRef.current?.querySelector('[role="menuitem"]')?.focus(); + }, [menuOpen]); + // Roving tabindex with automatic activation per the ARIA tabs pattern. // Arrows wrap, Home and End jump, and the moved-to tab is selected. const handleTabKeyDown = (event: ReactKeyboardEvent, index: number) => { @@ -178,6 +188,37 @@ export function InboxContent( document.getElementById(tabId(next))?.focus(); }; + // Roving focus across the more-actions menu items. Down and Up wrap, Home + // and End jump. Escape and Tab are left to their default handlers. + const handleMenuKeyDown = (event: ReactKeyboardEvent) => { + const menuItems = Array.from( + menuRef.current?.querySelectorAll('[role="menuitem"]') ?? [], + ); + if (menuItems.length === 0) { + return; + } + const current = menuItems.indexOf(document.activeElement as HTMLButtonElement); + let next: number; + switch (event.key) { + case 'ArrowDown': + next = current < 0 ? 0 : (current + 1) % menuItems.length; + break; + case 'ArrowUp': + next = current <= 0 ? menuItems.length - 1 : current - 1; + break; + case 'Home': + next = 0; + break; + case 'End': + next = menuItems.length - 1; + break; + default: + return; + } + event.preventDefault(); + menuItems[next]?.focus(); + }; + const handleItemClick = (item: InboxItem) => { if (props.onItemClick?.(item) === false) { return; @@ -249,7 +290,7 @@ export function InboxContent( {'\u22ef'} {menuOpen && ( -
+