Skip to content

fix(react): inbox a11y and robustness polish (alt to #72)#82

Closed
ravidsrk wants to merge 9 commits into
dodopayments:mainfrom
ravidsrk:ravidsrk/cs-61-inbox-polish
Closed

fix(react): inbox a11y and robustness polish (alt to #72)#82
ravidsrk wants to merge 9 commits into
dodopayments:mainfrom
ravidsrk:ravidsrk/cs-61-inbox-polish

Conversation

@ravidsrk

Copy link
Copy Markdown

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 polite role=status live 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):

  • The focus trap must exclude tabindex="-1" elements, or roving-tabindex tab buttons in the empty state let Tab escape the portaled dialog.
  • Closing the popover on focusout breaks archiving: removing the focused button drops focus to document.body and 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.

ravidsrk added 8 commits July 15, 2026 23:45
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.
@ravidsrk

Copy link
Copy Markdown
Author

@claude review this PR. @codex review this PR.

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR lands six deferred accessibility and robustness fixes for <Inbox />: APG-compliant focus management on open/close, a focus trap that correctly excludes tabindex="-1" roving-tab elements, APG menu keyboard semantics on the more-actions menu, viewport-clamped popover width, style re-injection after head-replacing navigation, and a polite role=status live region for the new-notification pill.

  • Inbox.tsx: Dialog gets aria-modal="true", tabIndex={-1}, and an onKeyDown trap that wraps Tab/Shift+Tab at the edges; focus moves into the container on open so a portaled popover doesn't let Tab escape to the host page; FOCUSABLE_SELECTOR explicitly excludes tabindex="-1" to prevent inactive roving-tabindex tab buttons from becoming false trap edges.
  • InboxContent.tsx: handleMenuKeyDown delivers the full APG menu contract — first-item focus on open, wrapped arrow navigation, Home/End, and Tab-closes-without-preventDefault so the browser's natural move runs from the trigger; Escape is handled in the capture phase with stopPropagation so it closes only the menu, not the dialog.
  • styles.ts / NotificationList.tsx: ensureStyles drops the module-level flag and re-checks the DOM on every call, fixing Turbo/PJAX head-swap; popover width becomes min(360px, calc(100vw - 16px)); a permanently-mounted role=status live region carries the pill count so AT observes the change rather than a new region appearing.

Confidence Score: 4/5

Safe to merge — all six accessibility items are correctly implemented and each has a matching regression test.

The focus trap, menu keyboard handling, live region, style re-injection, and viewport clamp are all implemented correctly. The one thing worth cleaning up is a comment sentence in trapFocus that describes what the code already shows rather than stating an invariant or failure mode.

No files require special attention beyond the single comment line in packages/react/src/Inbox.tsx.

Important Files Changed

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]
Loading
%%{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]
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(react): trap only actual tab stops" | Re-trigger Greptile

Comment thread packages/react/src/Inbox.tsx Outdated
Comment on lines +170 to +174
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
// 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!

Fix in Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ravidsrk

Copy link
Copy Markdown
Author

Closing in deference to #72, per the one-PR-per-issue preference expressed in #84. The findings from this implementation are already commented on #72, and the branch stays available if any of the tests or fixes are worth cherry-picking.

@ravidsrk ravidsrk closed this Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant