Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1444eae
feat(a11y): add opt-in accessibility announcer, context, and hooks
oliverlaz May 4, 2026
ad60bca
feat(a11y): wire accessibility into base UI primitives
oliverlaz May 4, 2026
72c7e6e
feat(a11y): add accessible message actions, reactions, and reply preview
oliverlaz May 4, 2026
b66216c
feat(a11y): announce incoming messages and label scroll-to-latest button
oliverlaz May 4, 2026
5bb4927
feat(a11y): announce AI typing, label channel preview status, mount N…
oliverlaz May 4, 2026
7ebd065
feat(a11y): add radio/checkbox semantics to poll vote button
oliverlaz May 4, 2026
40a50a6
chore(a11y): add maintenance skill and integrator docs
oliverlaz May 4, 2026
515d3ee
chore(a11y): restore original i18n key ordering
oliverlaz May 4, 2026
e4266e4
refactor(a11y): rename i18n prefix from aria/ to a11y/
oliverlaz May 4, 2026
da9c34f
chore(SampleApp): enable a11y for testing
oliverlaz May 4, 2026
93b0332
fix(a11y): move useA11yLabel above conditional return in delivery status
oliverlaz May 4, 2026
6aa02ce
fix(a11y): unbreak MessageActionListItem tests + refresh snapshots
oliverlaz May 4, 2026
f014a6c
Merge branch 'develop' into feat/a11y-foundation
isekovanic May 6, 2026
06f7d26
fix: exit early if announcements are disabled
isekovanic May 6, 2026
91d7509
fix: stabilize accessibility ctx
isekovanic May 6, 2026
3248e1a
fix: stabilize ctx value as well
isekovanic May 6, 2026
eee923f
fix: translations
isekovanic May 6, 2026
19d67af
fix: keep accessibility cfg stable
isekovanic May 6, 2026
2a7ce4a
chore: update sample app
isekovanic May 6, 2026
119a5e6
fix: move provider further up
isekovanic May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 364 additions & 0 deletions .claude/plans/2026-05-04-accessibility.md

Large diffs are not rendered by default.

147 changes: 147 additions & 0 deletions .claude/skills/accessibility/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
name: accessibility
description: Maintain VoiceOver/TalkBack-focused accessibility in stream-chat-react-native. Use when changing interactive components, gestures, modals, lists, media controls, notifications, focus behavior, or live announcements.
---

# Accessibility Maintenance (stream-chat-react-native)

Use this skill whenever code changes can affect screen-reader users (VoiceOver on iOS, TalkBack on Android), gesture-driven flows, focus behavior, motion preferences, or semantic React Native accessibility props.

## Non-negotiable rules

1. **Native semantics first.** Use `Pressable`, `TextInput`, `Switch`, `Image` directly. Use `accessibilityRole` only when native semantics cannot represent the widget (`menu`, `menuitem`, `progressbar`, `radio`, `checkbox`, `article`, `alert`, `tablist`, `tab`).
2. **Never hardcode English** in `accessibilityLabel`/`accessibilityHint`/announcement strings. Use `useA11yLabel('a11y/...', params)` (or `t('a11y/...')` directly when you don't need the disabled-state short-circuit). Add the key to all 12 locale files in `package/src/i18n/`.
3. **Gate behavior on `useAccessibilityContext().enabled`.** A11y is opt-in. New listeners, subscriptions, and announcer mounts must be no-ops when `enabled` is false. New `accessibilityRole`/`accessibilityState` props are fine to render unconditionally — they cost ~zero.
4. **One focusable target per action.** Don't nest `Pressable` inside `Pressable`. Mark inner decorative views with `accessibilityElementsHidden` (iOS) + `importantForAccessibility='no-hide-descendants'` (Android) so the parent carries the label.
5. **Decorative visuals stay hidden from AT.** Icon-only buttons must carry an `accessibilityLabel` on the wrapper, and the SVG icon should be hidden.
6. **Backward-compatible.** All new props are optional. Component override pattern (`WithComponents`) must continue to work.

## Where to put what

- **Foundation primitives** → `package/src/a11y/` (utilities + low-level hooks).
- **Runtime announcer infra** → `package/src/components/Accessibility/` (`NotificationAnnouncer`, `useAccessibilityAnnouncer`, `useIncomingMessageAnnouncements`).
- **Config + provider** → `package/src/contexts/accessibilityContext/`, mounted by `OverlayProvider`.
- **i18n** → `a11y/*` keys in all 12 locale JSONs (`en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr`).
- **Component-level a11y attributes** → in the component itself.
- **Platform divergence (iOS vs Android)** → use `Platform.OS` or `useResolvedModalAccessibilityProps`. Don't duplicate the file — RN doesn't need `.ios.tsx`/`.android.tsx` splits for a11y.
- **Tests** → nearest `__tests__/` folder; use `@testing-library/react-native` semantic queries (`getByRole`, `getByLabelText`).

## Patterns to follow

### 1) Composing accessible names

```tsx
import { useA11yLabel } from 'stream-chat-react-native';

const label = useA11yLabel('a11y/Reaction {{emoji}} by {{count}} users', { emoji, count });
<Pressable accessibilityLabel={label} accessibilityRole='button' accessibilityState={{ selected }} />
```

`useA11yLabel` returns `undefined` when `accessibility.enabled` is false, so the `t()` call is skipped on hot list paths.

For composite labels (sender + timestamp + body + reactions summary), use `composeAccessibilityLabel(...parts)` from `package/src/a11y/a11yUtils.ts` — it filters out empty/null parts and joins with `, ` so screen readers add a brief pause.

### 2) Live-region announcements

Two complementary mechanisms:

- **Imperative**: `useAccessibilityAnnouncer()` returns `(message, priority?) => void`. Same shape as `stream-chat-react`'s `useAriaLiveAnnouncer`. Wraps `AccessibilityInfo.announceForAccessibility` with sequence/debounce so repeat announcements still re-announce.
- **Declarative**: `accessibilityLiveRegion="polite"` (Android only) on a View that re-renders when its label changes.

Use `useAnnounceOnStateChange(message, { debounceMs, priority })` for transitions (AI typing, indicators) — it dedups consecutive same-message calls and applies a default 250ms debounce.

For incoming messages: use `useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })`. It throttles to 1 announcement per second, batches multi-message bursts, and bounds memory at 500 announced ids.

### 3) Modal / sheet focus trap

Use `useResolvedModalAccessibilityProps()` and spread the result on the modal root:

```tsx
const a11yProps = useResolvedModalAccessibilityProps();
<Animated.View {...a11yProps} style={...}>
{/* ... */}
</Animated.View>
```

This returns:
- iOS: `{ accessibilityViewIsModal: true }`
- Android: `{ importantForAccessibility: 'yes' }`
- Either platform when `enabled` is false: `{}`

After opening, set initial focus via `AccessibilityInfo.setAccessibilityFocus(findNodeHandle(titleRef.current))` deferred behind `requestAnimationFrame` so the a11y tree has settled.

### 4) Gesture alternatives

Mobile gestures (long-press, hold-to-record, pinch/pan) must have a tap-equivalent for SR users. Read the component's mode flag from `AccessibilityConfig`:

```tsx
const { audioRecorderTapMode } = useAccessibilityContext();
const screenReaderOn = useScreenReaderEnabled();
const useTapMode =
audioRecorderTapMode === 'always' ||
(audioRecorderTapMode === 'auto' && screenReaderOn);
```

Three-state semantics: `'auto'` (swap when SR is on), `'always'` (swap for everyone), `'never'` (integrator handles).

### 5) Reduced-motion

```tsx
const reduceMotion = useReducedMotionPreference();
const transitionDuration = reduceMotion ? 0 : 250;
```

Disable spring animations and limit fade durations when this is true.

## Anti-patterns to avoid

- **Hardcoded English `accessibilityLabel`** strings inside component code. Always use `useA11yLabel('a11y/...')` or `t('a11y/...')`.
- **Nested focusables**: `<Pressable><Pressable>` causes VO to stop on each. Mark the outer `accessible={false}` or the inner `accessibilityElementsHidden`.
- **Subscribing to `AccessibilityInfo` events when `enabled` is false** — wastes a listener slot. The provided hooks already gate on this; mirror that pattern.
- **`useScreenReaderEnabled()` inside list items** — toggling SR re-renders every item. Only subscribe in components that actually swap UI on SR (`AudioRecorder`, `ImageGallery`, `Message`'s alternative-actions button).
- **Using live regions to force-announce static modal text** — fix the dialog semantics instead (`useResolvedModalAccessibilityProps` + correct `accessibilityRole='alert'`).
- **Mutating `AccessibilityInfo` polyfill state in tests without restoring** — use the mock-builder helpers in `package/src/mock-builders/accessibility/` (or jest.mock the module) and reset between tests.

## Testing requirements per change

Minimum:
- Unit tests for new keyboard/focus/semantics behavior in nearest `__tests__/`.
- Use `@testing-library/react-native` semantic queries: `getByRole`, `getByLabelText`, `getByA11yState`, `getByA11yValue`.

Recommended for non-trivial changes:
- Render with `<OverlayProvider accessibility={{ enabled: true, forceScreenReaderMode: true }}>` and assert the accessible variant renders.
- Render with `<OverlayProvider accessibility={{ enabled: false }}>` and assert the legacy behavior is unchanged (no extra buttons, no listeners).

## Execution checklist (copy this when making an a11y change)

- [ ] Identified the interaction type (button / menuitem / dialog / progressbar / radio / checkbox / live region / image)
- [ ] Picked a native element first; ARIA-style `accessibilityRole` only when necessary
- [ ] Composed `accessibilityLabel` via `useA11yLabel('a11y/...')` (not hardcoded)
- [ ] Added the new `a11y/*` key to all 12 locale JSONs and ran `yarn build-translations`
- [ ] Set `accessibilityState` for stateful widgets (`disabled`, `selected`, `checked`, `busy`, `expanded`)
- [ ] Decorative visuals hidden from AT (`accessibilityElementsHidden` / `importantForAccessibility='no-hide-descendants'`)
- [ ] Modal surfaces use `useResolvedModalAccessibilityProps`
- [ ] New behavior (announcers, listeners) gated on `useAccessibilityContext().enabled`
- [ ] Tested with `<OverlayProvider accessibility={{ enabled: true, forceScreenReaderMode: true }}>` and `enabled: false`
- [ ] Verified `yarn lint` passes (`validate-translations` enforces non-empty `a11y/*` keys)
- [ ] Verified `yarn tsc --noEmit` passes (RN's a11y prop types are strict about `boolean | null | undefined`)

## Reference files (in this repo)

- `package/src/contexts/accessibilityContext/AccessibilityContext.tsx` — config schema + provider + imperative announcer context.
- `package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts` — port of stream-chat-react's hook.
- `package/src/a11y/hooks/useA11yLabel.ts` — translated-label-or-undefined.
- `package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts` — modal a11y props.
- `package/src/components/ui/Avatar/Avatar.tsx` — example of `name` + `useA11yLabel` usage.
- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps`.
- `package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx` — example of `useAnnounceOnStateChange`.

## Cross-SDK parity

API shapes mirror [`stream-chat-react#3146`](https://github.com/GetStream/stream-chat-react/pull/3146):
- `useAccessibilityAnnouncer` ≈ React's `useAriaLiveAnnouncer`
- `useIncomingMessageAnnouncements` — same params, same throttle/batch logic
- `a11y/*` i18n namespace shared
- `<NotificationAnnouncer />` — same component name, `connection-state`-only on RN since RN has no shared notifications queue

When changing one SDK's a11y API, mirror it in the other where the platforms agree.
104 changes: 104 additions & 0 deletions ai-docs/accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Accessibility (a11y) — `stream-chat-react-native`

This SDK ships an opt-in accessibility layer for VoiceOver (iOS) and TalkBack (Android). When enabled, components add the appropriate `accessibilityRole`, `accessibilityState`, `accessibilityLabel`, `accessibilityValue`, and `accessibilityLiveRegion` attributes; the SDK announces incoming messages, AI typing transitions, and connection-state changes via a single imperative announcer; and modal/sheet surfaces apply the platform-correct focus-trap props.

## Opt-in default

A11y is **off by default**. Existing integrators see no behavior change. To enable:

```tsx
import { Chat, OverlayProvider } from 'stream-chat-react-native';

<OverlayProvider accessibility={{ enabled: true }}>
<Chat client={client}>
{/* ... */}
</Chat>
</OverlayProvider>
```

When `enabled` is false:

- No announcer context mounts; `useAccessibilityAnnouncer()` returns a noop.
- `<NotificationAnnouncer />` exits without announcements.
- `useIncomingMessageAnnouncements` does not subscribe to `channel.on('message.new')`.
- No `AccessibilityInfo` event listeners attach.
- Components still render their `accessibilityRole` / `accessibilityState` / etc. attributes (these are passed to native views and only consulted by VO/TalkBack when active — sighted users incur ~zero cost).
- `useA11yLabel(key, params)` returns `undefined` so `t('a11y/...')` is **not** called on hot list paths.

## Configuration shape

```ts
type A11yMode = 'auto' | 'always' | 'never';

type AccessibilityConfig = {
enabled?: boolean; // default false — must opt in
forceScreenReaderMode?: boolean; // default false — testing aid
announceNewMessages?: boolean; // default true (when enabled)
announceTypingIndicator?: boolean; // default false (noisy)
announceConnectionState?: boolean; // default true
audioRecorderTapMode?: A11yMode; // default 'auto'
imageGalleryScreenReaderMode?: A11yMode; // default 'auto'
messageActionsTrigger?: 'long-press' | 'auto' | 'always-button'; // default 'auto'
};
```

For RN-specific gesture-alternative toggles, the enum semantics are:

- `auto` — swap UI when a screen reader is detected via `AccessibilityInfo`.
- `always` — show the accessible variant for everyone (sighted + SR users).
- `never` — SDK never swaps UI; the integrator handles it (e.g. when shipping a custom component via `WithComponents`).

## Localization

All a11y strings flow through the existing `Streami18n` translation pipeline under the `a11y/*` namespace. Defaults ship in English in every locale; integrators can override per-key via the same mechanism they use for other strings:

```ts
const i18n = new Streami18n('nl');
i18n.registerTranslation('nl', {
'a11y/Avatar of {{name}}': 'Avatar van {{name}}',
'a11y/{{count}} new messages': '{{count}} nieuwe berichten',
});
<OverlayProvider accessibility={{ enabled: true }} i18nInstance={i18n}>
<Chat client={client} i18nInstance={i18n}>
{/* ... */}
</Chat>
</OverlayProvider>
```

`validate-translations` (run as part of `yarn lint`) enforces non-empty values for every `a11y/*` key in every locale.

## Public hooks and components

Importable from `stream-chat-react-native`:

- `useAccessibilityContext()` — read the resolved config.
- `useAccessibilityAnnouncer()` — `(message, priority?) => void` imperative announcer; mirrors `useAriaLiveAnnouncer` from `stream-chat-react`.
- `useScreenReaderEnabled()` — live boolean from `AccessibilityInfo.screenReaderChanged`.
- `useReducedMotionPreference()` — live boolean from `AccessibilityInfo.reduceMotionChanged`.
- `useResolvedModalAccessibilityProps()` — returns `{ accessibilityViewIsModal, importantForAccessibility }` for the active platform.
- `useA11yLabel(key, params)` — translated label or `undefined` when disabled.
- `useAnnounceOnStateChange(message, options)` — debounced live-region helper.
- `useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })` — throttled, batched announcement of new messages.
- `<NotificationAnnouncer />` — connection-state announcer (mounted by `<Channel>`).

## Cross-SDK parity

API shapes mirror [`stream-chat-react#3146`](https://github.com/GetStream/stream-chat-react/pull/3146) wherever the platforms agree (`useAccessibilityAnnouncer` ≈ `useAriaLiveAnnouncer`, `useIncomingMessageAnnouncements` ≈ identical params and throttle semantics, `a11y/*` i18n namespace shared). Mobile-only deviations:

- `<OverlayProvider accessibility={...}>` config object — RN needs gesture-alternative toggles (audio hold-to-record, gallery pinch/pan) that don't exist on web.
- No `<VisuallyHidden>`, no `<SkipNavigation>`, no roving-focus utilities — RN announcer is imperative, mobile has no Tab key.
- `useResolvedModalAccessibilityProps` returns `accessibilityViewIsModal` (iOS) + `importantForAccessibility='yes'` (Android) instead of `aria-modal`.
- Live regions: `AccessibilityInfo.announceForAccessibility` cross-platform; Android `accessibilityLiveRegion='polite'` on hidden Views as a backup where useful.

## Platform-specific notes

- **iOS / VoiceOver**: focus management uses `AccessibilityInfo.setAccessibilityFocus(reactTag)`. After modal open or layout shift, defer the focus call by one frame (`requestAnimationFrame`) so the a11y tree has settled.
- **Android / TalkBack**: prefer `accessibilityLiveRegion` over imperative announcements where both are an option — TalkBack interrupts more aggressively when announcing imperatively. The announcer uses `announceForAccessibility` for both platforms because behavior is closer to predictable across devices.
- `setAccessibilityFocus` on Android requires the React tag to come from `findNodeHandle(ref)`; if your custom component override uses a functional ref, expose a method or pass `ref` through.

## Out of scope (current state)

- AudioRecorder gesture-alternative tap mode (planned; gated on `audioRecorderTapMode`).
- ImageGallery screen-reader-mode UI swap (planned; gated on `imageGalleryScreenReaderMode`).
- Reassure performance benchmark (planned for the lint/perf commit).
- ESLint `react-native-a11y` rule plugin (deferred — adds a runtime dependency).
15 changes: 7 additions & 8 deletions examples/SampleApp/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import React, { useEffect, useState } from 'react';
import {
DevSettings,
I18nManager,
LogBox,
Platform,
useColorScheme,
} from 'react-native';
import { DevSettings, I18nManager, LogBox, Platform, useColorScheme } from 'react-native';
import { createDrawerNavigator } from '@react-navigation/drawer';
import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
Expand Down Expand Up @@ -103,6 +97,7 @@ const Drawer = createDrawerNavigator();
const Stack = createNativeStackNavigator<StackNavigatorParamList>();
const UserSelectorStack = createNativeStackNavigator<UserSelectorParamList>();
const RTL_STORAGE_KEY = '@stream-rn-sampleapp-rtl-enabled';
const accessibilityConfig = { enabled: true };

const App = () => {
const { chatClient, isConnecting, loginUser, logout, switchUser } = useChatClient();
Expand Down Expand Up @@ -270,7 +265,11 @@ const App = () => {
>
<GestureHandlerRootView style={{ flex: 1 }}>
<WithComponents overrides={componentOverrides}>
<OverlayProvider value={{ style: streamChatTheme }} i18nInstance={streami18n}>
<OverlayProvider
accessibility={accessibilityConfig}
value={{ style: streamChatTheme }}
i18nInstance={streami18n}
>
<ThemeProvider style={streamChatTheme}>
<NavigationContainer
ref={RootNavigationRef}
Expand Down
4 changes: 2 additions & 2 deletions examples/SampleApp/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3777,7 +3777,7 @@ SPEC CHECKSUMS:
FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4
FirebaseRemoteConfigInterop: 1c6135e8a094cc6368949f5faeeca7ee8948b8aa
FirebaseSessions: eaa8ec037e7793769defe4201c20bd4d976f9677
fmt: 12a698626610c2fef5e7d8de472b100baf225f93
fmt: f2ca71e169c97357ab4e789da2e79d8d714b771a
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
GoogleAppMeasurement: 0dfca1a4b534d123de3945e28f77869d10d0d600
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
Expand All @@ -3792,7 +3792,7 @@ SPEC CHECKSUMS:
op-sqlite: 2e58f87227360fa6251d1fe103d189f11ae8c95f
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
RCT-Folly: 5a8bea092f38495b327c6eff2dc52ee25c10f637
RCT-Folly: 53b8d1d24968b95e1de7b2351e5f130be733a4d6
RCTDeprecation: d4ef510f229cea15314176aee5e3ba10064a8496
RCTRequired: 1e41b794629558f6626e2bc39c166ac0ec1c5878
RCTTypeSafety: 62c8105cf08af634c93d38ea1e8ec8a57b7abc2c
Expand Down
8 changes: 4 additions & 4 deletions examples/SampleApp/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8291,10 +8291,10 @@ stream-chat-react-native-core@8.1.0:
version "0.0.0"
uid ""

stream-chat@^9.43.0:
version "9.43.0"
resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.0.tgz#216a80abadea83dcee6fb339b76035b26af2beb5"
integrity sha512-gc12LZTmRWvSi6EjnMK7Y+D8xOQIouVUO2flUShazG/NqVccJhXYphQ96PzK7Wym+5wwwitTaJqq0m/1VUPBCA==
stream-chat@^9.43.1:
version "9.43.1"
resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.1.tgz#5b2cccdd95ce92cc44c6691c527eeee271ce37bd"
integrity sha512-lP1B3ulv2B20tqbn0xWUaVuKgBPAtgiKRGTBgmZsAIcOKDziR0xbYmZuC8zo9+L6yPh3euSdbF5w+CQ/Rn1FiQ==
dependencies:
"@types/jsonwebtoken" "^9.0.8"
"@types/ws" "^8.5.14"
Expand Down
Loading
Loading