Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
46 changes: 44 additions & 2 deletions .claude/skills/accessibility/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,49 @@ Use this skill whenever code changes can affect screen-reader users (VoiceOver o

## 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`).
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`). **Platform caveat:** `'menu'` and `'menuitem'` are honored by iOS VoiceOver but Android TalkBack silently ignores them (no `UIAccessibilityTraits` equivalent). For interactive items that must be announceable on both platforms, use `'button'` on the leaf `Pressable`; the `'menu'` role can stay on the container as an iOS hint. iOS-supported roles that survive to VoiceOver: `button`, `link`, `search`, `image`, `keyboardkey`, `text`, `adjustable`, `imagebutton`, `header`, `summary`, `none`.
2. **Never hardcode English** in `accessibilityLabel`/`accessibilityHint`/announcement strings. For SDK `Button`, pass `accessibilityLabelKey='a11y/...'` (and `accessibilityLabelParams` when needed). For non-Button components, 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.
7. **Floating overlays need a tall parent for Android a11y.** Android's accessibility framework uses each view's measured layout bounds (`getBoundsInScreen()`) to decide what's focusable at a given screen coordinate. Children rendered *outside* their parent's measured rect get pruned / reported with inverted (empty) bounds — RN doesn't clip them by default so the visual looks fine, but TalkBack can't focus them and `uiautomator dump` shows degenerate `[x,y][x,y]` rects. **Implication:** when mounting a floating overlay (autocomplete picker, popover, tooltip), pick a parent whose measured bounds contain the rendered area. A `flex: 1` Channel-area parent works; a `position: absolute` wrapper inside a small input-row container does not. This is why `AutoCompleteSuggestionList` is mounted from `MessageList` / `MessageFlashList` (full-screen flex parent) instead of `MessageComposer` (~228px composer parent — the suggestion list overflowed it and was a11y-invisible). Verify with `adb shell uiautomator dump` after mounting; if rows show `top > bottom`, the parent isn't tall enough.

## Diagnosing Android a11y with `uiautomator dump`

When TalkBack ignores a view, can't focus a row, or seems to focus the wrong thing, dump the a11y tree and read the bounds directly. This was the load-bearing technique behind rule #7.

**Procedure:**

```bash
# 1. Put the app in the state you want to inspect (open the suggestion list, modal, etc.)
adb shell uiautomator dump /sdcard/window_dump.xml
adb pull /sdcard/window_dump.xml ./window_dump.xml

# 2. Find your view. Grep by a known accessibilityLabel, text, or resource-id.
grep -A2 'text="@channel"' window_dump.xml
grep -B1 -A1 'content-desc="Mention suggestions available"' window_dump.xml
```

**Reading the output:** each `<node>` has `bounds="[left,top][right,bottom]"` in screen pixels.

| Symptom in `bounds` | Meaning |
|---|---|
| `[0,0][0,0]` | View never measured (mid-mount or detached from a11y tree). |
| `top > bottom` or `left > right` | Clipped by parent — `getBoundsInScreen()` clamped to a smaller ancestor. TalkBack treats this as empty. **Move the mount to a taller parent.** |
| Bounds outside the screen | Off-screen or pushed by keyboard; TalkBack won't focus it. |
| Bounds present, `clickable="true"`, `focusable="true"`, but still unreachable | Check `importantForAccessibility` chain and sibling z-order — something opaque may be above it. |

**Other useful node attributes:**
- `class` — the underlying Android View class (`android.widget.HorizontalScrollView`, etc.). Useful when an RN component compiles to something unexpected.
- `package` — confirms you're looking at *your* app, not the system UI.
- `clickable`, `focusable`, `enabled` — these must all be true for a row to take TalkBack focus.
- `content-desc` — what TalkBack will speak. If empty when you expected an `accessibilityLabel`, the prop didn't bind to the right native view.

**Caveats:**
- The dump is a single snapshot. If the view animates in, dump after the animation settles.
- TalkBack can affect what gets dumped on some devices — turn it off when diagnosing layout, on when diagnosing focus order.
- The XML reflects native bounds *after* RN's layout pass, so a wrong dump usually means RN gave Android wrong layout, not that the dump lied.

## Where to put what

Expand Down Expand Up @@ -54,6 +91,8 @@ Two complementary mechanisms:

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

Use `useAnnounceOnShow(visible, message, { delayMs, priority })` for **transient surfaces that appear and disappear repeatedly** (modals, sheets, autocomplete pickers). It announces on each `visible: false → true` transition and resets on hide, so the next show re-announces. The two announcer hooks are not interchangeable: `useAnnounceOnStateChange` dedupes on string equality (correct for "AI is typing" → "AI is generating"), while `useAnnounceOnShow` dedupes on visibility transition (correct for "Suggestions available" each time the picker reopens with the same label). Pair with `useA11yLabel('a11y/…')` for the message so the announcement is i18n'd and gated on the SDK's a11y opt-in.

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
Expand Down Expand Up @@ -104,6 +143,7 @@ Disable spring animations and limit fade durations when this is true.
- **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'`).
- **Auto-focusing the suggestions/listbox of a typeahead on appear** — anti-pattern for combobox-style UI. Each keystroke that produces new suggestions would re-steal focus from the active `TextInput`, breaking continuous typing. ARIA combobox spec specifically forbids this; iOS VoiceOver and Android TalkBack have the same constraint. Announce on show via `useAnnounceOnShow` instead and rely on standard screen-reader navigation gestures (swipe) for the user to reach the list when they want.
- **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
Expand Down Expand Up @@ -136,9 +176,11 @@ Recommended for non-trivial changes:
- `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/a11y/hooks/useAnnounceOnShow.ts` — announce-on-visible helper for transient surfaces.
- `package/src/components/ui/Avatar/Avatar.tsx` — example of `name` + `useA11yLabel` usage.
- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps`.
- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps` and `useAnnounceOnShow`.
- `package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx` — example of `useAnnounceOnStateChange`.
- `package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx` — example of `useAnnounceOnShow` with a per-trigger label (mention/command/emoji).

## Cross-SDK parity

Expand Down
3 changes: 3 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ nodeLinker: node-modules

npmMinimalAgeGate: 3d

npmPreapprovedPackages:
- stream-chat

npmPublishProvenance: true

yarnPath: .yarn/releases/yarn-4.15.0.cjs
3 changes: 2 additions & 1 deletion ai-docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ Importable from `stream-chat-react-native`:
- `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.
- `useAnnounceOnStateChange(message, options)` — debounced live-region helper that announces on message **change** and dedupes consecutive identical strings (good for state-driven labels like loading/error transitions).
- `useAnnounceOnShow(visible, message, { delayMs?, priority? })` — announces on each `visible: false → true` transition and resets on hide, so re-shows re-announce. Pair with `useA11yLabel(...)` for the message. Used by `BottomSheetModal` and `AutoCompleteSuggestionList`.
- `useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })` — throttled, batched announcement of new messages.
- `<NotificationAnnouncer />` — connection-state announcer (mounted by `<Channel>`).

Expand Down
12 changes: 12 additions & 0 deletions examples/SampleApp/android/gradle/gradle-daemon-jvm.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"react-native-teleport": "^1.1.7",
"react-native-video": "^6.19.2",
"react-native-worklets": "^0.8.3",
"stream-chat": "^9.44.2",
"stream-chat": "^9.45.0",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"stream-chat": "^9.44.2",
"stream-chat": "^9.45.0",
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
Expand Down
44 changes: 44 additions & 0 deletions package/src/a11y/hooks/useAnnounceOnShow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react';

import { useAccessibilityAnnouncer } from '../../components/Accessibility/useAccessibilityAnnouncer';

type Options = {
/** Delay before the announcement fires; lets entrance animations settle. */
delayMs?: number;
priority?: 'polite' | 'assertive';
};

/**
* Announces `message` once each time `visible` flips from false to true.
* Resets when `visible` flips back to false, so the next show re-announces —
* unlike `useAnnounceOnStateChange`, which announces on string change and
* dedupes consecutive identical strings.
*
* Use this for transient surfaces that appear and disappear repeatedly within
* a session (modals, autocomplete pickers, bottom sheets) where the user
* benefits from hearing the affordance on every reappearance.
*
* When `message` is undefined (typically because `useA11yLabel` returned
* undefined — a11y disabled or key missing), the hook is a no-op.
*/
export const useAnnounceOnShow = (
visible: boolean,
message: string | undefined,
{ delayMs = 500, priority = 'polite' }: Options = {},
) => {
const announce = useAccessibilityAnnouncer();
const announcedRef = useRef(false);

useEffect(() => {
if (!visible) {
announcedRef.current = false;
return;
}
if (!message || announcedRef.current) return;
const id = setTimeout(() => {
announce(message, priority);
announcedRef.current = true;
}, delayMs);
return () => clearTimeout(id);
}, [visible, message, announce, priority, delayMs]);
};
1 change: 1 addition & 0 deletions package/src/a11y/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from './hooks/useScreenReaderEnabled';
export * from './hooks/useReducedMotionPreference';
export * from './hooks/useResolvedModalAccessibilityProps';
export * from './hooks/useAnnounceOnStateChange';
export * from './hooks/useAnnounceOnShow';
export * from './hooks/useA11yLabel';
export * from './hooks/useAccessibilityActivateAction';
13 changes: 13 additions & 0 deletions package/src/components/AutoCompleteInput/AutoCompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,20 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)
}
}, [text]);

const nativeInputRef = useRef<RNTextInput | null>(null);

const clearState = useCallback(() => {
setLocalText('');
// iOS UITextView caches its intrinsicContentSize while focused, so a
// controlled `value` change to '' after a multiline send doesn't shrink
// the input back to single line height and UIKit keeps rendering at the
// previously cached focused size until blur. Not particularly sure which
// RN version regressed this, but 0.85.3 for sure has the bug. Forcebly
// setting its native prop forces UITextView to reconcile its content size
// and update accordingly.
if (Platform.OS === 'ios') {
nativeInputRef.current?.setNativeProps({ text: '' });
}
}, []);

const restoreState = useStableCallback((restoredText: string) => {
Expand All @@ -175,6 +187,7 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)

const setExtendedInputRef = useCallback(
(ref: RNTextInput | null) => {
nativeInputRef.current = ref;
if (!ref) {
setRef(setInputBoxRef, null);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const CommandsHeader: React.FC<AutoCompleteSuggestionHeaderProps> = () =>
return (
<View style={[styles.container, container]}>
<Text
accessibilityRole='header'
style={[styles.title, { color: semantics.textTertiary }, title]}
testID='commands-header-title'
>
Expand All @@ -52,7 +53,7 @@ export const EmojiHeader: React.FC<AutoCompleteSuggestionHeaderProps> = ({ query
return (
<View style={[styles.container, container]}>
<Smile pathFill={semantics.accentPrimary} />
<Text style={[styles.title, title]} testID='emojis-header-title'>
<Text accessibilityRole='header' style={[styles.title, title]} testID='emojis-header-title'>
{`Emoji matching "${queryText}"`}
</Text>
</View>
Expand Down
Loading
Loading