fix(react): inbox a11y and robustness polish (alt to #72)#82
Conversation
With portal the popover renders under document.body, so Tab from the bell walked the host page before reaching the dialog. Focus now moves to the dialog on open per the APG dialog pattern. Escape still returns it to the bell.
The header more-actions menu declared role=menu and role=menuitem with Escape as its only key handling. Opening now focuses the first item, ArrowUp and ArrowDown move with wrap, Home and End jump, mirroring the tab strip roving pattern.
width: 360px overflowed viewports narrower than about 376px, and floating-ui shift() repositions but cannot compress. The width is now min(360px, calc(100vw - 16px)).
Head-replacing navigation (Turbo, PJAX) removes the injected style tag while the module-level injected flag stayed true, so styles never re-injected until a full reload. ensureStyles now re-checks the DOM on every call and the flag is gone.
Arrivals held behind the new-notifications pill had no aria-live announcement. A visually hidden role=status region now carries the localized pill text, present before any arrival so only its text changes.
The dialog received focus on open but nothing contained it. Portaled to the end of document.body, Tab past the last focusable landed back in the host page with the popover still open. Tab and Shift+Tab now wrap at the dialog edges and the dialog declares aria-modal. A focusout close was rejected because archiving removes the focused button, which would drop focus to body and shut the popover.
Tab left the more-actions menu open while focus moved on. The menu now closes and parks focus on the trigger with the default action uncancelled, so Tab continues from the menu's place in the tab sequence per the APG menu pattern.
The focusable selector's branches are alternatives, so a native control with tabindex=-1 (an inactive roving tab) still matched and became a false trap edge. In a portaled tabbed empty inbox, forward Tab from the active tab escaped the modal dialog. Every branch now excludes tabindex=-1.
|
| Filename | Overview |
|---|---|
| packages/react/src/Inbox.tsx | Adds focus-trap logic, aria-modal, and focus-on-open to the dialog popover; FOCUSABLE_SELECTOR correctly excludes tabindex=-1 to avoid roving-tabindex trap edges; one comment sentence restates what the code shows rather than stating an invariant. |
| packages/react/src/components/InboxContent.tsx | Adds APG-compliant menu keyboard contract (ArrowDown/Up with wrap, Home/End, Tab closes without preventDefault, focus enters first item on open); implementation is correct and tests cover all cases. |
| packages/react/src/components/NotificationList.tsx | Adds polite role=status live region rendered unconditionally before text changes, matching the required ARIA live region pattern; correctly empty when pendingCount is zero. |
| packages/react/src/styles.ts | Removes the module-level injected flag so ensureStyles re-checks the DOM on every call, fixing style loss after Turbo/PJAX head replacement; adds .chimely-sr-only and clamps popover width to the viewport. |
| packages/react/src/inbox.test.tsx | Adds portal-mode tests for focus-on-open, Tab-wrapping at dialog edges, and the roving-tabindex edge case where the inactive tab must not become a false trap boundary. |
| packages/react/src/archive-ui.test.tsx | New menu keyboard tests cover first-item focus, arrow wrapping, Home/End, and the Tab-closes-without-preventDefault requirement; test assertions are precise. |
| packages/react/src/styles.test.ts | New test file covering viewport-clamp CSS, re-injection after head-replacing navigation, and the never-double-inject invariant. |
| packages/react/src/theming.test.tsx | Adds a test for the polite live region: verifies it exists and is empty before any arrival, then carries the correct string after a new notification is emitted. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Bell click] --> B[isOpen = true]
B --> C[popoverRef.current.focus dialog container tabIndex=-1]
C --> D{User action}
D -->|Tab| E{active === last?}
E -->|Yes| F[preventDefault, focus first]
E -->|No| G[native Tab]
D -->|Shift+Tab| H{active === first or === popover?}
H -->|Yes| I[preventDefault, focus last]
H -->|No| J[native Shift+Tab]
D -->|Escape| K[isOpen = false, bellRef.focus]
D -->|More-actions click| L[menuOpen = true, focus first menuitem]
L --> M{Menu key}
M -->|ArrowDown/Up| N[wrap focus in menu items]
M -->|Home/End| O[jump to first/last item]
M -->|Tab| P[setMenuOpen false, focus trigger, no preventDefault]
P --> Q[Tab bubbles to trapFocus, active=trigger]
M -->|Escape| R[capture phase, stopPropagation, focus trigger]
R --> S[Escape does NOT close dialog]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Bell click] --> B[isOpen = true]
B --> C[popoverRef.current.focus dialog container tabIndex=-1]
C --> D{User action}
D -->|Tab| E{active === last?}
E -->|Yes| F[preventDefault, focus first]
E -->|No| G[native Tab]
D -->|Shift+Tab| H{active === first or === popover?}
H -->|Yes| I[preventDefault, focus last]
H -->|No| J[native Shift+Tab]
D -->|Escape| K[isOpen = false, bellRef.focus]
D -->|More-actions click| L[menuOpen = true, focus first menuitem]
L --> M{Menu key}
M -->|ArrowDown/Up| N[wrap focus in menu items]
M -->|Home/End| O[jump to first/last item]
M -->|Tab| P[setMenuOpen false, focus trigger, no preventDefault]
P --> Q[Tab bubbles to trapFocus, active=trigger]
M -->|Escape| R[capture phase, stopPropagation, focus trigger]
R --> S[Escape does NOT close dialog]
Reviews (1): Last reviewed commit: "fix(react): trap only actual tab stops" | Re-trigger Greptile
| // The dialog is modal, so Tab and Shift+Tab wrap at its edges. A | ||
| // portaled popover is the last child of document.body, so without the | ||
| // wrap Tab past the last focusable lands back in the host page with | ||
| // the dialog still open. Wrapping only fires at the edges, interior | ||
| // moves stay native. |
There was a problem hiding this comment.
The closing sentence of this comment restates what the code already shows (only
first/last trigger a wrap). Per the project's comment guide, a comment must state an invariant, contract reference, or failure mode — not describe what the adjacent code does.
| // The dialog is modal, so Tab and Shift+Tab wrap at its edges. A | |
| // portaled popover is the last child of document.body, so without the | |
| // wrap Tab past the last focusable lands back in the host page with | |
| // the dialog still open. Wrapping only fires at the edges, interior | |
| // moves stay native. | |
| // The dialog is modal, so Tab and Shift+Tab wrap at its edges. A | |
| // portaled popover is the last child of document.body, so without the | |
| // wrap Tab past the last focusable lands back in the host page with | |
| // the dialog still open. |
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Applied in 85654ea. Dropped the closing sentence, the comment now carries only the invariant (modal dialog wraps Tab at its edges) and the failure mode (a portaled popover is the last child of document.body, so Tab past the last focusable would land in the host page with the dialog still open). The edge-only wrap is readable from the first/last guards, so it needed no prose. Gates: packages build, typecheck, 168 tests, biome all green.
This PR is an alternative implementation of #61, opened alongside #72 (thanks @soufian3hm) — maintainers can take whichever they prefer, or parts of both.
It addresses all six deferred hostile-frontend review items: focus moves into the dialog on open with a focus trap and
aria-modal(Escape returns to the bell), APG keyboard semantics on the more-actions menu including Tab closing it, popover width clamped to the viewport, style re-injection after head-replacing navigation, and a politerole=statuslive region for the pill count.Two edge cases this implementation covers that are easy to miss (I left the same notes on #72 after reviewing it):
tabindex="-1"elements, or roving-tabindex tab buttons in the empty state let Tab escape the portaled dialog.focusoutbreaks archiving: removing the focused button drops focus todocument.bodyand shuts the popover mid-action — so close is driven differently here.Each fix landed test-first with a red-before-green regression test; happy to share any of them.