Skip to content

feat(a11y): Accessibility foundation#3583

Open
oliverlaz wants to merge 12 commits intodevelopfrom
feat/a11y-foundation
Open

feat(a11y): Accessibility foundation#3583
oliverlaz wants to merge 12 commits intodevelopfrom
feat/a11y-foundation

Conversation

@oliverlaz
Copy link
Copy Markdown
Member

Summary

  • Lands an opt-in accessibility layer for VoiceOver (iOS) and TalkBack (Android), aligned with primitive shapes from stream-chat-react#3146.
  • A11y is OFF by default — pass <Chat accessibility={{ enabled: true }}> to enable. Existing integrators see no change. When disabled, no announcer mounts, no listeners attach, and useA11yLabel short-circuits the t() call so hot list paths stay free.
  • Foundation is in place; gesture-alternative rewrites for AudioRecorder and ImageGallery, 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:

  1. feat(a11y): add opt-in accessibility announcer, context, and hooksAccessibilityContext (flat config with 'auto' | 'always' | 'never' enums), AccessibilityAnnouncer + useAccessibilityAnnouncer (mirrors useAriaLiveAnnouncer from React), NotificationAnnouncer, useIncomingMessageAnnouncements (verbatim port — same throttle/batch/bounded-id semantics), utility hooks under package/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-translations passes). 16 new unit tests.
  2. feat(a11y): wire accessibility into base UI primitives — Avatar (accepts name + auto-composes aria/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 (uses useResolvedModalAccessibilityPropsaccessibilityViewIsModal on iOS, importantForAccessibility='yes' on Android).
  3. 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 via useA11yLabel), Reply (role+title).
  4. feat(a11y): announce incoming messages and label scroll-to-latest button — MessageList wires useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList }). ScrollToBottomButton announces unread count.
  5. 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.
  6. 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.
  7. chore(a11y): add maintenance skill and integrator docs.claude/skills/accessibility/SKILL.md (RN-adapted port of React's .cursor/skills/accessibility/SKILL.md) and ai-docs/accessibility.md covering the opt-in default, config schema, localization, public hooks/components, cross-SDK parity, and platform notes.

Cross-SDK alignment

React (web) This PR (RN)
useAriaLiveAnnouncer useAccessibilityAnnouncer (identical call shape)
useIncomingMessageAnnouncements identical params + throttle/batch logic
aria/* i18n namespace shared verbatim
AriaLiveRegion provider AccessibilityAnnouncer (uses AccessibilityInfo.announceForAccessibility)
NotificationAnnouncer same component name; RN announces connection state (no shared notifications queue exists yet)
useResolvedModalAriaProps useResolvedModalAccessibilityProps (accessibilityViewIsModal iOS / importantForAccessibility Android)

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)

  • AudioRecordingButton tap-mode swap (gated on audioRecorderTapMode).
  • ImageGallery screen-reader-mode UI swap (gated on imageGalleryScreenReaderMode).
  • Composed Message label + rotor accessibilityActions menu (touches the 1180-line Message.tsx).
  • AttachmentPicker modal trap, AudioAttachment scrub accessibilityValue, AutoCompleteInput suggestion-list semantics.
  • ESLint eslint-plugin-react-native-a11y rules — adds a runtime dep, flagged for separate review.
  • Reassure perf benchmark for MessageList with a11y on/off — adds a runtime dep, flagged for separate review.
  • Translations of aria/* keys to non-English locales — English values ship in all 12 so validate-translations passes; translators can fill in later.

Native handler note

The plan included an AccessibilityInfo handler in native.ts (mirroring how Audio/Sound/etc. are abstracted). Skipped: AccessibilityInfo from react-native is 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 lint on touched dirs — clean (snapshot files have pre-existing no-undef errors, untouched).
  • Manual SampleApp smoke on iOS w/ VoiceOver — verify incoming-message announcement, modal focus trap, AITypingIndicatorView announcement.
  • Manual SampleApp smoke on Android w/ TalkBack — verify same flows, plus accessibilityLiveRegion triggers.
  • Render with accessibility={{ enabled: false }} and confirm zero behavioral change vs current main.
  • Render with accessibility={{ enabled: true, forceScreenReaderMode: true }} and confirm accessible variants render where applicable.

Plan checked in at .claude/plans/2026-05-04-accessibility.md.

oliverlaz added 7 commits May 4, 2026 12:13
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
@Stream-SDK-Bot
Copy link
Copy Markdown
Contributor

Stream-SDK-Bot commented May 4, 2026

SDK Size

title develop branch diff status
js_bundle_size 360 KB 364 KB +3519 B 🔴

@oliverlaz oliverlaz changed the title feat(a11y): opt-in accessibility foundation aligned with stream-chat-react#3146 feat(a11y): opt-in accessibility foundation May 4, 2026
oliverlaz added 2 commits May 4, 2026 12:40
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).
@oliverlaz oliverlaz changed the title feat(a11y): opt-in accessibility foundation feat(a11y): Accessibility foundation May 4, 2026
oliverlaz added 3 commits May 4, 2026 12:45
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.
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.

2 participants