Skip to content

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

Merged
MartinCupela merged 66 commits into
masterfrom
feat/a11y/react-web-remediation
Jul 7, 2026
Merged

feat(a11y): improve keyboard and screen-reader support across composer, lists, search, and dialogs#3230
MartinCupela merged 66 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 helpers β€” useDebouncedAnnounce, 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)

  • Search β†’ inputProps
  • ChannelListItemUI / ThreadListItemUI β†’ accessibleLabelConfig
  • ContextMenu β†’ initialFocus ('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
…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.
MartinCupela and others added 2 commits July 3, 2026 16:59
The search field labeled its input with a visually-hidden <label>, which put a
standalone "Search" text node in the VoiceOver cursor path AND fed the input's
accessible name β€” so the same word was announced as its own selectable stop and
again as the control's name. Replace it with `aria-label` on the input: the
control keeps a persistent name (a placeholder-only name vanishes once the field
has a value) with no extra tree node, and Tab still lands on the input once. The
`role="search"` landmark and grouping are intentional and unchanged.
The playback-speed announcement test mocked the Accessibility barrel, but
useAudioPlaybackChangeAnnouncements imports useInteractionAnnouncements via
its relative path, so the barrel mock never intercepted it and the real
(provider-less, no-op) hook ran. Retarget the mock to the module the hook
actually imports so announceInteraction is captured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MartinCupela MartinCupela merged commit 2c08f53 into master Jul 7, 2026
17 of 18 checks passed
@MartinCupela MartinCupela deleted the feat/a11y/react-web-remediation branch July 7, 2026 05:39
github-actions Bot pushed a commit that referenced this pull request Jul 9, 2026
## [14.8.0](v14.7.0...v14.8.0) (2026-07-09)

### Bug Fixes

* **ChannelList:** make channel list and thread list width app-driven ([#3235](#3235)) ([c89366b](c89366b))

### Features

* **a11y:** improve keyboard and screen-reader support across composer, lists, search, and dialogs ([#3230](#3230)) ([2c08f53](2c08f53))
@stream-ci-bot

Copy link
Copy Markdown

πŸŽ‰ This PR is included in version 14.8.0 πŸŽ‰

The release is available on:

Your semantic-release bot πŸ“¦πŸš€

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants