Skip to content

feat(a11y): improve keyboard and screen-reader support across composer, lists, search, and dialogs#3230

Open
MartinCupela wants to merge 64 commits into
masterfrom
feat/a11y/react-web-remediation
Open

feat(a11y): improve keyboard and screen-reader support across composer, lists, search, and dialogs#3230
MartinCupela wants to merge 64 commits into
masterfrom
feat/a11y/react-web-remediation

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Closes REACT-972

🎯 Goal

Comprehensive accessibility (WCAG 2.1) remediation of the React web SDK, addressing the
Notion "React Web" audit findings. The work makes the core chat surfaces fully operable and
intelligible with a keyboard and a screen reader (verified on VoiceOver/macOS; NVDA/Windows
pass pending), while keeping all behaviour and visuals unchanged for sighted mouse users.

Rather than one-off fixes, it first builds a small set of shared a11y primitives and then
consumes them across the components, so announcements, focus management, and
"hidden ⇒ inert" behaviour are consistent and testable.

Shared a11y primitives (foundation)

  • Unified aria-live announcer — a single AriaLiveAnnouncerProvider (mounted at Chat) with a
    stacked AriaLiveOutlet model; aria-modal dialogs render their own outlet so announcements
    made inside a modal aren't suppressed. Replaces the ad-hoc per-component live regions.
  • useInteractionAnnouncements — a typed registry mapping intent → localized message +
    aria-live priority + delivery policy (immediate / debounced / provider-delayed). One place to add
    an announcement, one literal t('aria/…') per entry for extraction.
  • Scheduling helpersuseDebouncedAnnounce, useSettledAnnouncement (defer until the UI goes
    idle so a message isn't swallowed by the SR's field re-read), useAnnouncementQueue.
  • useFocusReturn — deterministic focus restoration after transient flows.
  • useInertWhenHidden — one canonical way to remove a still-mounted-but-hidden control from the
    tab order and the a11y tree (aria-hidden + inert + tabindex=-1).

Component remediation

Composer — Giphy / commands / autocomplete

  • Giphy preview description announced on focus; Send/Cancel announce and return focus to the
    composer; Shuffle announces the new image; the GIF's accessible name is a description, never a URL.
  • Command mode hides the "+" attachment and emoji buttons from Tab and the a11y tree (inert), with
    the CSS transition preserved; the GIPHY command chip is a single focus stop (the ✕ button).
  • Autocomplete suggestions are exposed as an ARIA listbox with aria-activedescendant (active option
    spoken on arrow nav, not on typing) and a debounced count announcement ("5 Command Suggestions").
  • Composer field is self-named (no "Channels, tab panel" leak); mention selection is confirmed.

Poll creation dialog

  • Dialog identity + description announced on open (assertive, with an Enter-to-field accelerator);
    "Poll sent" announced and focus returned to the composer.
  • Keyboard reordering of options (pick up / move / drop, announced); remove-option button accessible
    and its removal announced; concise "Close" button. All poll announcements route through the registry.

Channel list & Thread list

  • Each row exposes a single composed, configurable accessible name (accessibleLabelConfig).
  • Full keyboard navigation (roving role=listbox/option, Home/End, Load-more focus handling,
    row actions reachable by Tab and arrows); quick-action buttons keep focus while their request runs.
  • Thread list navigation is virtualization-aware; active row highlighted.
  • Selecting a channel/thread announces which was opened, deferred so it doesn't compete with the
    composer's focus read-out.

Search

  • Activation decoupled from focus (WCAG 3.2.1): typing activates, focusing doesn't; Cancel exits on
    activation, not on focus; blur-exit only when focus leaves the whole search widget.
  • Result-count / "search cleared" / end-of-list announcements; keyboard navigation of results.
  • New inputProps passthrough on Search (e.g. to opt the field out of password managers).

Context menus & nav

  • Submenu back-affordance is an activatable, destination-named control; opening a submenu moves
    focus into it (configurable initialFocus), and submenu roles/announcements are correct.
  • The Chat-view selector is a navigation landmark with set-size/position (not a tablist).

Voice recorder

  • Adds interaction announcements for actions that can be executed with the voice recorder
  • Adds playback speed change announcements

Public API (additive, non-breaking)

  • SearchinputProps
  • ChannelListItemUI / ThreadListItemUIaccessibleLabelConfig
  • ContextMenuinitialFocus ('first-item' | 'first' | 'none')
  • New Accessibility hooks/components (announcer provider/outlet, interaction announcements,
    scheduling, focus-return, inert-when-hidden).

Internal-only removal: the legacy AriaLiveRegion component was deleted in favour of the
provider/outlet model. It was not part of the public entry point, so there is no breaking
change to the published API.

i18n

Many new aria/* keys (natural-language) added and filled across all 12 locales; non-English

translations are best-effort and flagged for native review. validate-translations passes.

Demo / example

The examples/vite app gains a view-aware SkipNavigation (channel vs thread list, composer, and a
return link above the thread composer) demonstrating the SDK's skip-nav primitives.

Testing

Extensive unit/integration tests added (announcer registry, keyboard-navigation hooks, composed
labels, search behaviour, context-menu focus, poll dialog, etc.) plus jest-axe checks. jsdom can't
model real SR focus/announcement timing, so those checkpoints were confirmed by hand on VoiceOver.

Manual testing plan

Screen Reader (SR) testing cannot be automated and therefore the below manual testing plan has as well been followed:

manual-test-plan.md

Checklist

  • NVDA/Windows AT pass (follow-up)

Summary by CodeRabbit

  • New Features
    • Enhanced keyboard navigation and focus handling across channel lists, thread lists, search results, suggestion lists, and virtualized lists.
    • Improved screen-reader announcements for composer actions, dialogs/menus, search result counts, notifications, and poll/suggestion interactions.
    • Refined skip-navigation and modal initial-focus behavior for more predictable focus.
  • Bug Fixes
    • Reduced duplicate/confusing announcements by routing aria-live updates through a unified, deduped scheduler.
    • Hidden/inactive UI now reliably stays out of the accessibility tree (inert/aria-hidden/tabindex) and uses consistent aria-disabled styling.
    • Improved accessible labeling for lists and media (e.g., GIF/Giphy) and corrected close-button/labeling behavior in dialogs.

# Conflicts:
#	src/components/MessageActions/MessageActions.defaults.tsx
#	src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx
#	src/components/Modal/GlobalModal.tsx
MartinCupela and others added 14 commits July 1, 2026 18:49
…ation

The in-flight guard read `inProgress` from render state, so two clicks before the
busy state committed both ran `toggle()` — duplicate writes for archive/leave/mute.
Add a ref latch that flips synchronously in the handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The download submenu built "Download <name>" / "Download attachment N" as
hardcoded English while "Download All" was translated. Use the existing
interpolated keys so non-English users get localized labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each off-window key press started its own scroll-then-focus chain; a slower older
chain could steal focus back to an outdated row. Stamp each request and bail when
a newer key press supersedes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The immediate path dropped announce()'s cancel fn, so a second delayed interaction
(channel.opened/thread.opened/command.selected) within the 1500ms window let the
stale first confirmation still speak after the newer one. Track the pending cancel
per interaction and clear it before scheduling the next; also honor it in
cancelInteraction. Not cleared on unmount (delayed emits are meant to survive it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The channel list item announcement derives the sender, delivery status and
time from one message but re-derived the preview text from
`channel.state.latestMessages`, so a caller passing an explicit message could
announce a preview that did not match. Thread the optional `latestMessage`
through `getLatestMessagePreviewParts`, `getLatestMessagePreviewText` and
`getLatestMessagePreview` so all segments describe the same message, falling
back to the channel's latest when omitted.

Also harden the dropdown archive test: await the deferred (rAF) action call
instead of asserting it synchronously after the menu closes.
…ation

The poll dialog wrapped `createPoll()` and `handleSubmitMessage()` in a single
try/catch whose comment only accounted for `createPoll`'s self-published error
notification. When the poll was created but sending the message rejected, the
failure was swallowed and no feedback reached the user (the dialog was already
closed). Split the awaits: bail after a `createPoll` failure (already notified),
and on a `handleSubmitMessage` rejection emit an error notification instead of
the success one.
`exitSearchOnInputBlur` treated a null `relatedTarget` as "focus left the
widget", but browsers also report `null` when the user presses a non-focusable
descendant (the search icon, wrapper padding), so an in-widget click could
collapse search. Track whether the most recent pointerdown landed inside the
container and only exit on a null-relatedTarget blur when it did not. The
focusable-target case still uses `containerRef.contains(relatedTarget)`.
… order

`SuggestionList` sorted command suggestions before assigning option ids and the
highlighted index, but the composer's Enter handler selected from the raw
`searchSource.items`, so `aria-activedescendant` could announce one command
while Enter inserted another. Extract the ordering into a shared
`orderSuggestionItems` helper and consume it in both the list (ids/highlight)
and the Enter handler so the announced option is always the one inserted.
…mpty

The list skip-link always called `preventDefault()`, but `focusFirstListOption`
is a no-op when the list has no `role="option"` (empty/loading), so the link
did nothing. Return whether an option was focused and only `preventDefault()`
then; otherwise let SkipNavigation focus the list container so the link is never
a dead end.
…elector

The command-mode override replaced `buttonProps` with a fresh object, dropping
any ARIA/data props passed in when the component is wired globally via
WithComponents. Spread `props.buttonProps` before setting `tabIndex`.
…ount

After "Load more", focus moved to `options[preLoadCount]`, deciding the next
page had arrived purely from the row count growing. A WebSocket-driven insert or
reorder during the 4s window could grow the list first, landing focus on an
unrelated row. Snapshot the pre-load rows instead: the last pre-load option is
the boundary (the page is appended after it) and the full set lets a genuinely
new row be told from a reordered one. Focus the first new option after the
boundary; if the boundary row is gone, drop the request. Keeps the 4s guard.
Rename the aria-label key `aria/Remove {{ option }}` to
`aria/Remove option: {{ option }}` so the button reads e.g. "Remove option:
Pizza", making the control's purpose explicit for screen-reader users. Fills the
new key across all 12 locales and drops the now-unused old key. Refines
d27fc7d.
… label parts

Every label part (attachments/lastMessage/linkPreview/time) re-derived
`latestMessage ?? channel.state.latestMessages[last]`, and `lastMessage` even
passed the raw arg (not its own resolved value) to getLatestMessagePreviewText.
The "all segments describe the same message" invariant held only because those
duplicated fallbacks happened to match. Resolve the message once in
composeChannelListItemAccessibleLabel and hand every part the same resolved
`latestMessage`, so the invariant is structural rather than coincidental.

No behaviour change (the fallbacks were identical); this API is new on this
branch, so narrowing the parts to consume a pre-resolved message is not a
breaking change.
The suggestion list and the CommandsMenu both re-sorted command items
alphabetically, which discarded the SDK's ordering — CommandSearchSource already
returns commands prefix-match-first (then alphabetical) for a query, so the
blanket client sort demoted the most relevant matches (e.g. typing "/e" put a
mid-string match above a prefix match). Render commands in the search source's /
channel-config order in both surfaces.

This also removes the need for the shared `orderSuggestionItems` helper: with no
client re-sort, the suggestion list and the composer's Enter handler both index
into the same `searchSource.items`, so the highlighted option and the inserted
one stay in sync without it. Deletes the helper and its test.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/ChannelListItem/utils.tsx (1)

185-192: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter og_scrape_url attachments before classifying kind: 'attachment'.

The ARIA builder (Line 267-270) explicitly excludes link-preview attachments (og_scrape_url) when computing the announcement, but the kind classification here counts them via raw attachments?.length. A text-less message whose only attachment is a link preview would be classified 'attachment' and announce a generic "Attachment" instead of falling through to shared_location/empty handling, inconsistent with the filtering logic applied downstream.

🔧 Proposed fix
-  if (latestMessage.attachments?.length) {
+  const realAttachments = latestMessage.attachments?.filter(
+    (attachment) => !attachment.og_scrape_url,
+  );
+  if (realAttachments?.length) {
     return {
       isUserMessageText: false,
       kind: 'attachment',
       latestMessage,
       text: t('🏙 Attachment...'),
     };
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ChannelListItem/utils.tsx` around lines 185 - 192, The
attachment classification in the message utility currently counts all
attachments, including link-preview `og_scrape_url` items, which makes the
`kind` field incorrectly become `attachment` for messages that should fall
through to other handling. Update the attachment check in
`ChannelListItem/utils.tsx` to exclude `og_scrape_url` attachments before
returning the `kind: 'attachment'` result, matching the filtering already used
by the ARIA builder and keeping the `latestMessage` logic consistent with the
shared_location/empty fallback paths.
🧹 Nitpick comments (1)
src/components/ChannelListItem/utils.tsx (1)

91-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update inline/public docs for the new latestMessage parameter.

Three exported helpers (getLatestMessagePreviewParts, getLatestMessagePreviewText, getLatestMessagePreview) gained a new optional parameter. As per coding guidelines, src/**/*.{ts,tsx}: "Ensure public API changes include documentation updates" and "update inline docs and any affected guide pages in the docs site" when altering public API.

Also applies to: 245-251, 296-302

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ChannelListItem/utils.tsx` around lines 91 - 101, The exported
preview helpers now accept a new optional latestMessage parameter, so their
public docs need to be updated to match the API change. Update the inline
comments/docblocks for getLatestMessagePreviewParts,
getLatestMessagePreviewText, and getLatestMessagePreview to describe the new
argument and its behavior, and make any related docs-site guide updates if these
helpers are documented there. Keep the wording consistent with the existing
ChannelListItem utilities and ensure the new parameter is clearly referenced
alongside the existing channel/t/userLanguage arguments.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/components/ChannelListItem/utils.tsx`:
- Around line 185-192: The attachment classification in the message utility
currently counts all attachments, including link-preview `og_scrape_url` items,
which makes the `kind` field incorrectly become `attachment` for messages that
should fall through to other handling. Update the attachment check in
`ChannelListItem/utils.tsx` to exclude `og_scrape_url` attachments before
returning the `kind: 'attachment'` result, matching the filtering already used
by the ARIA builder and keeping the `latestMessage` logic consistent with the
shared_location/empty fallback paths.

---

Nitpick comments:
In `@src/components/ChannelListItem/utils.tsx`:
- Around line 91-101: The exported preview helpers now accept a new optional
latestMessage parameter, so their public docs need to be updated to match the
API change. Update the inline comments/docblocks for
getLatestMessagePreviewParts, getLatestMessagePreviewText, and
getLatestMessagePreview to describe the new argument and its behavior, and make
any related docs-site guide updates if these helpers are documented there. Keep
the wording consistent with the existing ChannelListItem utilities and ensure
the new parameter is clearly referenced alongside the existing
channel/t/userLanguage arguments.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 736a1eaa-9814-48ed-bd63-fa2fb5eb1e70

📥 Commits

Reviewing files that changed from the base of the PR and between da53305 and 329b865.

📒 Files selected for processing (38)
  • examples/vite/src/AccessibilityNavigation/ChatSkipNavigation.tsx
  • examples/vite/src/CommandModeAttachmentSelector.tsx
  • src/a11y/hooks/useVirtualizedListboxKeyboardNavigation.ts
  • src/components/Accessibility/NotificationAnnouncer.tsx
  • src/components/Accessibility/hooks/__tests__/useInteractionAnnouncements.test.tsx
  • src/components/Accessibility/hooks/useInteractionAnnouncements.ts
  • src/components/ChannelList/hooks/__tests__/useChannelListKeyboardNavigation.test.tsx
  • src/components/ChannelList/hooks/useChannelListKeyboardNavigation.ts
  • src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx
  • src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx
  • src/components/ChannelListItem/utils.a11y.ts
  • src/components/ChannelListItem/utils.tsx
  • src/components/ChatView/__tests__/ChatView.test.tsx
  • src/components/MessageActions/DownloadSubmenu.tsx
  • src/components/MessageActions/__tests__/MessageActions.test.tsx
  • src/components/MessageComposer/AttachmentSelector/CommandsMenu.tsx
  • src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx
  • src/components/Modal/GlobalModal.tsx
  • src/components/Poll/PollCreationDialog/OptionFieldSet.tsx
  • src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx
  • src/components/Poll/__tests__/PollCreationDialog.test.tsx
  • src/components/Search/SearchBar/SearchBar.tsx
  • src/components/Search/__tests__/SearchBar.test.tsx
  • src/components/TextareaComposer/SuggestionList/SuggestionList.tsx
  • src/components/TextareaComposer/TextareaComposer.tsx
  • src/components/Threads/UnreadCountBadge.tsx
  • src/i18n/de.json
  • src/i18n/en.json
  • src/i18n/es.json
  • src/i18n/fr.json
  • src/i18n/hi.json
  • src/i18n/it.json
  • src/i18n/ja.json
  • src/i18n/ko.json
  • src/i18n/nl.json
  • src/i18n/pt.json
  • src/i18n/ru.json
  • src/i18n/tr.json
✅ Files skipped from review due to trivial changes (1)
  • src/i18n/ru.json
🚧 Files skipped from review as they are similar to previous changes (33)
  • src/components/Threads/UnreadCountBadge.tsx
  • examples/vite/src/CommandModeAttachmentSelector.tsx
  • src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx
  • src/components/MessageActions/DownloadSubmenu.tsx
  • src/components/MessageActions/tests/MessageActions.test.tsx
  • src/i18n/de.json
  • src/i18n/hi.json
  • src/i18n/es.json
  • src/components/Search/tests/SearchBar.test.tsx
  • src/i18n/ko.json
  • src/components/Accessibility/NotificationAnnouncer.tsx
  • src/components/ChannelListItem/utils.a11y.ts
  • src/components/Search/SearchBar/SearchBar.tsx
  • src/i18n/nl.json
  • src/components/Accessibility/hooks/tests/useInteractionAnnouncements.test.tsx
  • src/i18n/ja.json
  • src/a11y/hooks/useVirtualizedListboxKeyboardNavigation.ts
  • src/i18n/en.json
  • src/components/Accessibility/hooks/useInteractionAnnouncements.ts
  • src/components/TextareaComposer/SuggestionList/SuggestionList.tsx
  • src/components/Poll/tests/PollCreationDialog.test.tsx
  • src/components/Poll/PollCreationDialog/OptionFieldSet.tsx
  • src/components/ChannelListItem/tests/ChannelListItemActionButtons.defaults.test.tsx
  • src/i18n/it.json
  • src/components/ChatView/tests/ChatView.test.tsx
  • src/components/Modal/GlobalModal.tsx
  • src/i18n/fr.json
  • src/components/TextareaComposer/TextareaComposer.tsx
  • src/i18n/pt.json
  • src/i18n/tr.json
  • src/components/MessageComposer/tests/AttachmentSelector.test.tsx
  • src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx
  • examples/vite/src/AccessibilityNavigation/ChatSkipNavigation.tsx

…after the modal closes

Notifications emitted while a modal is open are tagged `target:modal` so they
render inside the modal rather than behind it. But a dialog that closes
optimistically and only then emits a confirmation (e.g. "Poll sent") left the
notification tagged for a modal whose NotificationList had already unmounted —
so the default display filter, which restricted modal-tagged notifications to
`panel === 'modal'`, rendered it nowhere while the aria-live announcer still
spoke it. When no modal is open, fall back to the notification's other target
panel(s) so it is displayed rather than becoming a dead letter; in-modal
exclusivity while a modal is open is unchanged.
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