Skip to content

Commit 59c3c07

Browse files
committed
chore: update a11y skill and fix tests
1 parent 98efefb commit 59c3c07

2 files changed

Lines changed: 91 additions & 2 deletions

File tree

.claude/skills/accessibility/SKILL.md

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Use this skill whenever code changes can affect screen-reader users (VoiceOver o
1010
## Non-negotiable rules
1111

1212
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`).
13-
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/`.
13+
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 13 locale files in `package/src/i18n/` (`ar, en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr`).
1414
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.
1515
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.
1616
5. **Decorative visuals stay hidden from AT.** Icon-only buttons must carry an `accessibilityLabel` on the wrapper, and the SVG icon should be hidden.
@@ -21,7 +21,7 @@ Use this skill whenever code changes can affect screen-reader users (VoiceOver o
2121
- **Foundation primitives**`package/src/a11y/` (utilities + low-level hooks).
2222
- **Runtime announcer infra**`package/src/components/Accessibility/` (`NotificationAnnouncer`, `useAccessibilityAnnouncer`, `useIncomingMessageAnnouncements`).
2323
- **Config + provider**`package/src/contexts/accessibilityContext/`, mounted by `OverlayProvider`.
24-
- **i18n**`a11y/*` keys in all 12 locale JSONs (`en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr`).
24+
- **i18n**`a11y/*` keys in all 13 locale JSONs (`ar, en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr`).
2525
- **Component-level a11y attributes** → in the component itself.
2626
- **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.
2727
- **Tests** → nearest `__tests__/` folder; use `@testing-library/react-native` semantic queries (`getByRole`, `getByLabelText`).
@@ -97,6 +97,81 @@ const transitionDuration = reduceMotion ? 0 : 250;
9797

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

100+
### 6) Curated single focus stop for visual content — `CompositeAccessibilityProbe`
101+
102+
```tsx
103+
import { CompositeAccessibilityProbe } from 'stream-chat-react-native';
104+
105+
<CompositeAccessibilityProbe label={accessibilityLabel}>
106+
{/* avatars, icons, composed graphics — visually decorative */}
107+
</CompositeAccessibilityProbe>
108+
```
109+
110+
Wraps non-Text visual content with a single, cross-platform-stable focus stop carrying the provided `label`. Renders a hidden `Text` sibling that carries the label + a `View accessibilityElementsHidden importantForAccessibility='no-hide-descendants'` around the children. Use for avatars, mute icons, isolated badges, composed graphics that should announce as one semantic unit.
111+
112+
Pass the result of `useA11yLabel(...)` directly — when `label` is `undefined` (a11y opt-out), the probe is a no-op and renders children untouched.
113+
114+
Live examples: `ChannelAvatar.tsx`, `ChannelPreviewMutedStatus.tsx`, `ChannelMessagePreviewDeliveryStatus.tsx`.
115+
116+
### 7) Splicing extra a11y info into compose — `HiddenA11yText`
117+
118+
```tsx
119+
import { HiddenA11yText } from 'stream-chat-react-native';
120+
121+
<Pressable>
122+
<Icon />
123+
{selected ? <HiddenA11yText label={useA11yLabel('a11y/you reacted')} /> : null}
124+
</Pressable>
125+
```
126+
127+
A visually-invisible `<Text>` that exists only to contribute extra information to a parent's compose loop. Use it to splice in supplementary state ("you reacted", "and N more", "unread") that doesn't have a natural visible Text in the tree.
128+
129+
Different concern from `CompositeAccessibilityProbe`:
130+
- `HiddenA11yText` — "inject extra a11y-only text into a compose chain"
131+
- `CompositeAccessibilityProbe` — "make this whole visual element one focus stop with a curated label"
132+
133+
Live examples: `MessageStatus.tsx`, `ReactionListClustered.tsx`, `ReactionListItem.tsx`.
134+
135+
### 8) Cross-platform auto-compose on a plain View
136+
137+
```tsx
138+
<View accessible accessibilityRole='text'>
139+
{/* children whose labels should auto-compose into one announcement */}
140+
</View>
141+
```
142+
143+
iOS auto-composes descendant labels when a `View` is `accessible={true}` without an explicit `accessibilityLabel`. Android requires the parent to trip a gate — set any of `accessibilityRole`, `accessibilityState`, `accessibilityActions`, or `accessibilityLabelledBy`. `accessibilityRole='text'` (or `'none'`) is the lightest gate-tripper and a no-op for iOS composition.
144+
145+
`Pressable` defaults `accessibilityRole='button'`, so it auto-trips the gate. Plain `View accessible={true}` without a role does NOT — Android falls back to its default heuristic (reads one visible Text descendant only).
146+
147+
Live example: `MessageFooter.tsx``<View accessible accessibilityRole='text'>` makes the footer one focus stop on both platforms reading `"Read 11:05 AM"`.
148+
149+
See full memory: `rn_android_a11y_compose_gate.md`.
150+
151+
### 9) Drill-in for interactive children inside a Pressable
152+
153+
```tsx
154+
<Pressable accessible={hasInteractiveContent ? false : undefined} onLongPress={...}>
155+
{/* mix of interactive children — attachments, quoted reply, poll options, etc. */}
156+
</Pressable>
157+
```
158+
159+
When a Pressable wraps mixed content that includes interactive children, the row's default single-focus-stop behavior subsumes them — screen-reader users can't activate the children individually. Setting `accessible={false}` on the Pressable removes the row stop, so VO/TalkBack drill into each interactive child. The Pressable's `onPress` / `onLongPress` still fire because VO/TalkBack synthesize taps at the focused child's coordinates, which land inside the Pressable's hit area.
160+
161+
Live example: `MessageContent.tsx``accessible={hasInteractiveContent ? false : undefined}` where `hasInteractiveContent` covers poll, quoted message, attachments, shared location.
162+
163+
### 10) Reshow announcements — `useAnnounceOnShow`
164+
165+
```tsx
166+
useAnnounceOnShow(visible, useA11yLabel('a11y/Replying to {{user}}', { user: name }));
167+
```
168+
169+
Announces `label` once each time `visible` flips from `false` to `true`. Resets on hide, so reshows re-announce — unlike `useAnnounceOnStateChange` which dedupes consecutive identical strings.
170+
171+
Use for transient surfaces that appear and disappear repeatedly within a session (modals, autocomplete pickers, reply previews) where the user benefits from hearing the affordance on every reappearance.
172+
173+
Live example: `Reply.tsx` — fires when a reply preview shows in the composer.
174+
100175
## Anti-patterns to avoid
101176

102177
- **Hardcoded English `accessibilityLabel`** strings inside component code. For SDK `Button`, use `accessibilityLabelKey='a11y/...'`; otherwise use `useA11yLabel('a11y/...')` or `t('a11y/...')`.
@@ -134,11 +209,17 @@ Recommended for non-trivial changes:
134209

135210
- `package/src/contexts/accessibilityContext/AccessibilityContext.tsx` — config schema + provider + imperative announcer context.
136211
- `package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts` — port of stream-chat-react's hook.
212+
- `package/src/components/Accessibility/CompositeAccessibilityProbe.tsx` — curated-single-focus-stop wrapper for visual content (avatar, icons, badges).
213+
- `package/src/components/Accessibility/HiddenA11yText.tsx` — visually-invisible Text that splices extra info into a parent's compose chain ("you reacted", "and N more", etc).
137214
- `package/src/a11y/hooks/useA11yLabel.ts` — translated-label-or-undefined.
215+
- `package/src/a11y/hooks/useAnnounceOnStateChange.ts` — announce on string-change with dedup.
216+
- `package/src/a11y/hooks/useAnnounceOnShow.ts` — announce on `visible: false → true` transitions, resets on hide (no dedup).
138217
- `package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts` — modal a11y props.
139218
- `package/src/components/ui/Avatar/Avatar.tsx` — example of `name` + `useA11yLabel` usage.
140219
- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps`.
141220
- `package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx` — example of `useAnnounceOnStateChange`.
221+
- `package/src/components/Message/MessageItemView/MessageFooter.tsx` — example of cross-platform auto-compose on a View (`accessible + accessibilityRole='text'`).
222+
- `package/src/components/Message/MessageItemView/MessageContent.tsx` — example of conditional drill-in (`accessible={hasInteractiveContent ? false : undefined}`).
142223

143224
## Cross-SDK parity
144225

package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,8 @@ exports[`Thread should match thread snapshot 1`] = `
588588
</View>
589589
</View>
590590
<View
591+
accessibilityRole="text"
592+
accessible={true}
591593
style={
592594
[
593595
{
@@ -920,6 +922,8 @@ exports[`Thread should match thread snapshot 1`] = `
920922
</View>
921923
</View>
922924
<View
925+
accessibilityRole="text"
926+
accessible={true}
923927
style={
924928
[
925929
{
@@ -1285,6 +1289,8 @@ exports[`Thread should match thread snapshot 1`] = `
12851289
</View>
12861290
</View>
12871291
<View
1292+
accessibilityRole="text"
1293+
accessible={true}
12881294
style={
12891295
[
12901296
{
@@ -1611,6 +1617,8 @@ exports[`Thread should match thread snapshot 1`] = `
16111617
</View>
16121618
</View>
16131619
<View
1620+
accessibilityRole="text"
1621+
accessible={true}
16141622
style={
16151623
[
16161624
{

0 commit comments

Comments
 (0)