Skip to content

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

Open
soufian3hm wants to merge 1 commit into
dodopayments:mainfrom
soufian3hm:fix/inbox-a11y-polish
Open

fix(react): inbox a11y and robustness polish#72
soufian3hm wants to merge 1 commit into
dodopayments:mainfrom
soufian3hm:fix/inbox-a11y-polish

Conversation

@soufian3hm

@soufian3hm soufian3hm commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Closes #61.

Addresses 4 of the 6 deferred hostile-frontend review items; the remaining two are called out as follow-ups below.

Done

  1. Portal Tab order -- the popover (role=dialog) now takes focus on open (APG dialog pattern) via tabIndex={-1} + .focus(), so keyboard users reach a document.body-portaled popover directly instead of tabbing through the host page first. Escape still returns focus to the bell.
  2. More-actions menu keyboard semantics -- the role=menu/menuitem markup now has real behavior: focus the first item on open, ArrowDown/ArrowUp (wrapping), Home/End. Escape and outside-click close it unchanged, restoring trigger focus.
  3. Popover width on narrow viewports -- width: min(360px, calc(100vw - 16px)), so it no longer overflows sub-376px viewports that floating-ui shift() cannot compress.
  4. Style re-injection resilience -- ensureStyles() re-probes document.querySelector('style[data-chimely]') on each call instead of trusting a module-level flag, so head-replacing navigation (Turbo/PJAX) cannot leave the inbox unstyled until a full reload. Injection stays idempotent.

Deferred (follow-ups, as the issue frames them)

  • Docs: sharing one client via ChimelyProvider for pages with many inboxes (item 5) -- pure docs.
  • aria-live announcements for the new-arrival pill/badge (item 6) -- the issue itself calls this "a further step"; it carries UX judgment (polite vs assertive, throttling) better settled with maintainer input.

If you'd rather keep #61 open to track those two, happy to drop the Closes keyword or open a follow-up issue -- your call.

Verification

biome, pnpm --filter "./packages/*" build, @chimely/react typecheck, and vitest are all green -- 108 react tests pass, including 3 new ones: focus-into-dialog on open, menu roving focus (arrows/Home/End/wrap), and style re-injection after a simulated head swap. Only @chimely/react is touched; a patch changeset is included.

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 dodopayments#61
@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR polishes the React inbox accessibility and styling behavior. The main changes are:

  • Focus moves into the portaled inbox dialog when it opens.
  • The more-actions menu supports arrow key, Home, and End navigation.
  • The popover width now fits narrow viewports.
  • Style injection now re-checks the DOM after head replacement.
  • Tests and a patch changeset were added for the updated behavior.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
packages/react/src/Inbox.tsx Adds dialog focus on open and makes the popover focusable with tabIndex={-1}.
packages/react/src/components/InboxContent.tsx Adds focus-on-open and roving keyboard focus for the more-actions menu.
packages/react/src/styles.ts Updates popover width and makes stylesheet injection resilient to removed style tags.
packages/react/src/archive-ui.test.tsx Adds tests for more-actions menu keyboard navigation.
packages/react/src/inbox.test.tsx Adds tests for dialog focus and stylesheet re-injection.
.changeset/inbox-a11y-polish.md Adds a patch changeset for the React inbox accessibility and robustness updates.

Reviews (1): Last reviewed commit: "fix(react): inbox a11y and robustness po..." | Re-trigger Greptile

@ravidsrk

Copy link
Copy Markdown

I independently implemented #61 against the same review items and hit two keyboard edge cases in testing that look like they apply to this branch too, so sharing in case they save you a round trip.

  1. Tab can exit the open popover in portal mode while it stays open.

The open effect moves focus in:

useEffect(() => {
  if (!isOpen) {
    return;
  }
  popoverRef.current?.focus();
}, [isOpen]);

but nothing contains it afterwards, and the close triggers are outside pointerdown and Escape only. A portaled popover is the last child of document.body, so once you Tab past its last control (or Shift+Tab from the freshly focused dialog container), focus lands back in the host page with the popover still open and no obvious way back except re-finding the bell. Two coherent fixes here: trap Tab at the dialog edges and declare aria-modal="true" (trapping without declaring modality leaves screen readers treating the page behind as interactive), or keep it non-modal and close when focus leaves. I went with the trap. Closing on focusout interacts badly with archiving: the archive button that has focus gets removed, focus drops to body, and the popover shuts mid-action.

If you go the trap route, one edge that cost me a test cycle: a focusable selector like button:not([disabled]) still matches the tab strip's roving-tabindex buttons, which carry tabindex="-1". In the tabbed empty state the inactive tab then becomes a false trap edge and forward Tab escapes anyway. Every selector branch needs the :not([tabindex="-1"]) exclusion, not just a [tabindex] branch.

  1. The more-actions menu stays open when Tab moves focus out.
// 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.

Tab is neither Escape nor an outside pointerdown, so it moves focus on (the menu items are natural tab stops, so it first walks through them) while the menu stays rendered over the list with aria-expanded still true. There is a second-order effect: the menu's capture-phase Escape listener stays armed, so pressing Escape later from, say, the preferences button closes the stale menu and pulls focus back to the more-actions trigger instead of closing the popover. The APG menu pattern closes the menu when Tab moves focus out. My version closes it, parks focus on the trigger, and leaves the keydown uncancelled so the browser continues Tab from the menu's place in the sequence.

Happy to share the regression tests for both if useful (Tab wrap at the dialog edges, the roving-tab false edge, and Tab-out closing the menu). They are plain testing-library keyDown tests, no new dependencies.

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.

Inbox polish items from the hostile-frontend review

3 participants