Open
Conversation
Phase 1 of the accessibility plan. Foundation only; no component changes. - AccessibilityContext: opt-in config (`enabled` defaults to false). Flat `'auto' | 'always' | 'never'` enums for gesture-alternative toggles. - AccessibilityAnnouncer + useAccessibilityAnnouncer: imperative announcer via AccessibilityInfo.announceForAccessibility, with sequence/debounce so repeat messages still announce. Returns no-op when disabled. - NotificationAnnouncer: connection-state announcements (offline/online/ reconnecting). Mount once inside Channel (Phase 4a). - useIncomingMessageAnnouncements: ports stream-chat-react's hook — throttled, batched, bounded id set. Subscribes only when enabled. - a11y/ utilities: composeAccessibilityLabel, formatAccessibilityValue, mergeAccessibilityActions, useScreenReaderEnabled, useReducedMotionPreference, useResolvedModalAccessibilityProps, useAnnounceOnStateChange, useA11yLabel. - Chat.tsx wires AccessibilityProvider + AccessibilityAnnouncer into the existing provider stack. - aria/* i18n keys added to all 12 locales (English values for now; translations are a follow-up). validate-translations passes. Note: skipped the planned native handler abstraction in native.ts — AccessibilityInfo from react-native is identical on bare RN and Expo, so a wrapper handler would be unnecessary indirection. Tests: 16 new unit tests (a11yUtils, AccessibilityAnnouncer, AccessibilityContext). Refs: stream-chat-react#3146 — primitive shapes mirrored, RN-specific deviations (gesture toggles, opt-in default) documented in the plan.
Phase 2 of the accessibility plan.
- ui/Avatar: accepts `name` and `accessibilityLabel` props; auto-composes
`aria/Avatar of {{name}}` via useA11yLabel. UserAvatar passes user.name,
ChannelAvatar passes channel.data.name. Inner ImageComponent is hidden
from AT so the parent View carries the label.
- ui/Button: adds accessibilityRole='button' and accessibilityState
({ disabled, selected }). Existing accessibilityLabel pass-through
preserved via {...rest} spread.
- ui/Input: wires accessibilityLabel (defaults to title), accessibilityHint
(defaults to description), accessibilityState ({ disabled, selected }).
Validation errors render through a hidden assertive live region.
- Indicators: LoadingIndicator wraps in role='progressbar' +
accessibilityLiveRegion='polite'. LoadingDots are hidden from AT.
LoadingErrorIndicator gets role='alert' (or 'button' when retryable)
and an accessibilityHint.
- ProgressControl: adds accessibilityRole='progressbar' (or 'adjustable'
when interactive) and accessibilityValue.
- UIComponents/BottomSheetModal: applies useResolvedModalAccessibilityProps
(accessibilityViewIsModal on iOS, importantForAccessibility='yes' on
Android) to the sheet root. No-op when accessibility.enabled is false.
All changes are gated by AccessibilityContext.enabled — primitives still
behave exactly as before for integrators who haven't opted in.
Phase 3a of the accessibility plan.
- MessageMenu/MessageActionList: ScrollView gets accessibilityRole='menu'
and a t('aria/Message actions') label via useA11yLabel.
- MessageMenu/MessageActionListItem: Pressable gets accessibilityRole=
'menuitem' and the action's title as label. Inner View hidden from AT
so the menuitem speaks once.
- MessageMenu/ReactionButton: label switches to t('aria/Reaction
{{emoji}} by {{count}} users') via useA11yLabel; falls back to the
legacy hardcoded testID-style label when accessibility is disabled.
- MessageMenu/MessageReactionPicker: outer View gets accessibilityRole=
'menu'.
- Reply/Reply: outer View gets accessibilityRole='text' + the composed
reply title as accessibilityLabel.
- aria/Message actions key added to all 12 locales.
Message.tsx untouched in this commit — the long-press alternative button
and composed message label are deferred to a follow-up since they
require careful placement inside MessageItemView.
Phase 3b of the accessibility plan. Minimal scope this commit; AudioRecorder
tap-mode swap and TypingIndicator announcement are deferred.
- MessageList: wires useIncomingMessageAnnouncements({ channel,
ownUserId, activeThreadId, threadList }). Hook is a no-op when
accessibility.enabled is false; subscribes to channel.on('message.new')
only when enabled. Throttled to 1 announcement/sec, bounded id set.
- MessageList/ScrollToBottomButton: composed accessibilityLabel via
useA11yLabel — 'aria/Scroll to latest' when no unread, or
'aria/Scroll to latest, {{count}} unread' when there are.
- aria/Scroll to latest + aria/Scroll to latest, {{count}} unread keys
added across all 12 locales.
…otificationAnnouncer Phase 4a of the accessibility plan. - AITypingIndicatorView: wraps Text in accessibilityLiveRegion='polite' + role='text' and announces 'Thinking…' / 'Generating…' transitions via useAnnounceOnStateChange (debounced, dedup'd). - ChannelPreview/ChannelMessagePreviewDeliveryStatus: composes a status-specific accessibilityLabel (Sending/Sent/Delivered/Read) via useA11yLabel using the existing aria/* keys, so list rows announce delivery state to SR users. - Channel: mounts <NotificationAnnouncer /> inside the provider stack so connection-state transitions (offline/online/reconnecting) reach screen reader users.
Phase 4b of the accessibility plan, partial.
- Poll/components/PollOption (VoteButton): adds accessibilityRole='radio'
for single-vote polls and 'checkbox' for multi-vote polls,
accessibilityState={{ checked, selected }}, and uses the option text
as the accessibilityLabel.
ImageGallery screen-reader-mode swap, AudioAttachment scrub
accessibilityValue wiring, AttachmentPicker modal trap, and
AutoCompleteInput suggestion-list semantics are deferred to a follow-up
PR — they touch large gesture-handling components and warrant a separate
review.
Phase 5 of the accessibility plan, partial (docs + skill). - .claude/skills/accessibility/SKILL.md: a11y maintenance skill mirroring stream-chat-react's .cursor/skills/accessibility/SKILL.md, RN-adapted. Covers non-negotiable rules, file layout, name-composition patterns, live-region/announcer usage, modal trap, gesture alternatives, reduced-motion, anti-patterns, testing requirements, and a per-change execution checklist. - ai-docs/accessibility.md: integrator-facing docs covering the opt-in default, AccessibilityConfig schema, localization, public hooks/components, cross-SDK parity, platform notes, and the current out-of-scope list. Deferred for follow-up: - ESLint react-native-a11y plugin (adds runtime dep, needs review) - Reassure perf benchmark for MessageList with a11y on/off (adds dep) - AudioRecorder tap-mode swap and ImageGallery SR-mode swap
Contributor
SDK Size
|
Earlier a11y commits used a Python helper that wrote each locale file with keys re-sorted alphabetically. The original files used insertion order, so the diff looked huge (~75 line shifts per file) even though only 22 aria/* keys were actually added. This commit restores the original order and appends the 22 aria/* keys at the end. No values changed; no keys removed.
ARIA is web-specific (W3C HTML accessibility spec). On mobile we work with accessibilityLabel/accessibilityRole/accessibilityState — the aria prefix was a misnomer that I picked for cross-SDK parity with stream-chat-react. Trade is small (the prefix is a private translation namespace integrators don't read), so switching to a11y/ removes the semantic friction without losing anything important. Renames 22 keys across 12 locale files plus the call sites in 6 components, useA11yLabel docstring, ai-docs/accessibility.md, and the .claude/skills/accessibility/SKILL.md maintenance skill. No behavior change. Tests still pass (16/16).
Sets `accessibility={{ enabled: true }}` on the SampleApp's <Chat> so
VoiceOver/TalkBack flows can be exercised without an extra opt-in step.
Defaults (announceNewMessages, audioRecorderTapMode='auto', etc.) are
fine for general testing. To force the screen-reader UI variants
without enabling VO/TalkBack on the device, also pass
`forceScreenReaderMode: true`.
Codex adversarial review caught a Rules-of-Hooks violation in ChannelMessagePreviewDeliveryStatus: useA11yLabel was placed AFTER an early return that depends on message state. The same component instance can render with last-message-by-other-user (early return path) and then re-render with last-message-by-current-user (full hook path), changing the hook order between renders and triggering React's hook invariant. Hoisted the useA11yLabel call above the early return so the hook order is stable across renders. Also picks up an import-order auto-fix in Channel.tsx that ESLint applied during the same pass. eslint passes on the touched files, including react-hooks/rules-of-hooks. Tests still 16/16.
Two regressions surfaced by the full test suite:
1. MessageActionListItem.tsx: my Phase 3a edit moved the
`accessibilityLabel='{actionType} action list item'` from the inner
View to the Pressable as the action title, AND wrapped the inner View
in `accessibilityElementsHidden` + `importantForAccessibility=
'no-hide-descendants'`. That hid the Text child from
`getByText('Copy Message')` queries (testing-library/react-native
excludes descendants of accessibility-hidden elements), and broke
`getByLabelText('copyMessage action list item')` since the label
moved.
Fix: keep the original `accessibilityLabel='{actionType} action list
item'` on the inner View and drop the `accessibilityElementsHidden`
props. The Pressable still has `accessibilityRole='menuitem'` for
semantic correctness; Pressable's natural `accessible=true` grouping
makes the menuitem speak once without needing to hide descendants.
2. Avatar.tsx: I had `accessible={!!accessibilityLabel}` which evaluates
to an explicit `false` when no label is present, actively marking the
avatar as inaccessible — different from the prior default of
omitting the prop. Changed to `accessible={accessibilityLabel ? true
: undefined}` so the prop is only set when there's actually a label.
Snapshots updated for 6 test files where the new a11y attributes
(`accessibilityRole='button'`, `accessibilityState={{disabled,selected}}`,
`accessibilityElementsHidden` on Avatar's inner Image) appear in the
rendered tree. These additions are intentional per the plan — RN
ignores them when no SR is active, so sighted users see no behavior
change.
Full suite: only 3 failures remain, all pre-existing on develop (offline-support, AttachmentUploadPreviewList, AudioAttachmentUploadPreview). Confirmed via baseline run.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
stream-chat-react#3146.<Chat accessibility={{ enabled: true }}>to enable. Existing integrators see no change. When disabled, no announcer mounts, no listeners attach, anduseA11yLabelshort-circuits thet()call so hot list paths stay free.AudioRecorderandImageGallery, plus the lint plugin and Reassure benchmark, are explicitly deferred (called out in commits and docs).What's in this PR
7 conventional commits, each independently reviewable:
feat(a11y): add opt-in accessibility announcer, context, and hooks—AccessibilityContext(flat config with'auto' | 'always' | 'never'enums),AccessibilityAnnouncer+useAccessibilityAnnouncer(mirrorsuseAriaLiveAnnouncerfrom React),NotificationAnnouncer,useIncomingMessageAnnouncements(verbatim port — same throttle/batch/bounded-id semantics), utility hooks underpackage/src/a11y/(useScreenReaderEnabled,useReducedMotionPreference,useResolvedModalAccessibilityProps,useAnnounceOnStateChange,useA11yLabel).<Chat>wires the provider stack.aria/*keys added to all 12 locales (English values; translations are a follow-up —validate-translationspasses). 16 new unit tests.feat(a11y): wire accessibility into base UI primitives— Avatar (acceptsname+ auto-composesaria/Avatar of {{name}}), Button (role + disabled/selected state), Input (label/hint/state + assertive live region for validation errors), Indicators (live-region progressbar, hidden decorative dots, alert role on error), ProgressControl (progressbar/adjustable+accessibilityValue), BottomSheetModal (usesuseResolvedModalAccessibilityProps—accessibilityViewIsModalon iOS,importantForAccessibility='yes'on Android).feat(a11y): add accessible message actions, reactions, and reply preview— MessageActionList (role='menu'), MessageActionListItem (role='menuitem', action title as label), MessageReactionPicker (role='menu'), ReactionButton (composed label viauseA11yLabel), Reply (role+title).feat(a11y): announce incoming messages and label scroll-to-latest button— MessageList wiresuseIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList }). ScrollToBottomButton announces unread count.feat(a11y): announce AI typing, label channel preview status, mount NotificationAnnouncer— AITypingIndicatorView wraps in polite live region + debounced announcer; ChannelMessagePreviewDeliveryStatus composes a Sending/Sent/Delivered/Read label; Channel mounts<NotificationAnnouncer />so connection-state transitions reach SR users.feat(a11y): add radio/checkbox semantics to poll vote button— PollOption VoteButton:role='radio'for single-vote polls,'checkbox'for multi-vote polls,accessibilityState={{ checked, selected }}, option text as label.chore(a11y): add maintenance skill and integrator docs—.claude/skills/accessibility/SKILL.md(RN-adapted port of React's.cursor/skills/accessibility/SKILL.md) andai-docs/accessibility.mdcovering the opt-in default, config schema, localization, public hooks/components, cross-SDK parity, and platform notes.Cross-SDK alignment
useAriaLiveAnnounceruseAccessibilityAnnouncer(identical call shape)useIncomingMessageAnnouncementsaria/*i18n namespaceAriaLiveRegionproviderAccessibilityAnnouncer(usesAccessibilityInfo.announceForAccessibility)NotificationAnnounceruseResolvedModalAriaPropsuseResolvedModalAccessibilityProps(accessibilityViewIsModaliOS /importantForAccessibilityAndroid)Mobile-only deviation:
<Chat accessibility={...}>config object — RN needs gesture-alternative toggles (audioRecorderTapMode,imageGalleryScreenReaderMode,messageActionsTrigger) that don't exist on web.Out of scope (explicitly deferred)
audioRecorderTapMode).imageGalleryScreenReaderMode).accessibilityActionsmenu (touches the 1180-lineMessage.tsx).accessibilityValue, AutoCompleteInput suggestion-list semantics.eslint-plugin-react-native-a11yrules — adds a runtime dep, flagged for separate review.MessageListwith a11y on/off — adds a runtime dep, flagged for separate review.aria/*keys to non-English locales — English values ship in all 12 sovalidate-translationspasses; translators can fill in later.Native handler note
The plan included an
AccessibilityInfohandler innative.ts(mirroring howAudio/Sound/etc. are abstracted). Skipped:AccessibilityInfofromreact-nativeis identical on bare RN and Expo, so a wrapper handler would be unnecessary indirection.Test plan
cd package && yarn tsc --noEmit— no new errors (only pre-existing ones unrelated to this branch:AttachmentPickerContent,useIsCommandDisabled,usePendingAttachmentUpload).cd package && TZ=UTC npx jest src/a11y src/components/Accessibility src/contexts/accessibilityContext— 16/16 passing.cd package && yarn linton touched dirs — clean (snapshot files have pre-existingno-undeferrors, untouched).accessibilityLiveRegiontriggers.accessibility={{ enabled: false }}and confirm zero behavioral change vs current main.accessibility={{ enabled: true, forceScreenReaderMode: true }}and confirm accessible variants render where applicable.Plan checked in at
.claude/plans/2026-05-04-accessibility.md.