Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
85 changes: 83 additions & 2 deletions .claude/skills/accessibility/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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`).
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/`.
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`).
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.
Expand All @@ -21,7 +21,7 @@ Use this skill whenever code changes can affect screen-reader users (VoiceOver o
- **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`).
- **i18n** → `a11y/*` keys in all 13 locale JSONs (`ar, 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`).
Expand Down Expand Up @@ -97,6 +97,81 @@ const transitionDuration = reduceMotion ? 0 : 250;

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

### 6) Curated single focus stop for visual content — `CompositeAccessibilityProbe`

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

<CompositeAccessibilityProbe label={accessibilityLabel}>
{/* avatars, icons, composed graphics — visually decorative */}
</CompositeAccessibilityProbe>
```

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.

Pass the result of `useA11yLabel(...)` directly — when `label` is `undefined` (a11y opt-out), the probe is a no-op and renders children untouched.

Live examples: `ChannelAvatar.tsx`, `ChannelPreviewMutedStatus.tsx`, `ChannelMessagePreviewDeliveryStatus.tsx`.

### 7) Splicing extra a11y info into compose — `HiddenA11yText`

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

<Pressable>
<Icon />
{selected ? <HiddenA11yText label={useA11yLabel('a11y/you reacted')} /> : null}
</Pressable>
```

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.

Different concern from `CompositeAccessibilityProbe`:
- `HiddenA11yText` — "inject extra a11y-only text into a compose chain"
- `CompositeAccessibilityProbe` — "make this whole visual element one focus stop with a curated label"

Live examples: `MessageStatus.tsx`, `ReactionListClustered.tsx`, `ReactionListItem.tsx`.

### 8) Cross-platform auto-compose on a plain View

```tsx
<View accessible accessibilityRole='text'>
{/* children whose labels should auto-compose into one announcement */}
</View>
```

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.

`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).

Live example: `MessageFooter.tsx` — `<View accessible accessibilityRole='text'>` makes the footer one focus stop on both platforms reading `"Read 11:05 AM"`.

See full memory: `rn_android_a11y_compose_gate.md`.

### 9) Drill-in for interactive children inside a Pressable

```tsx
<Pressable accessible={hasInteractiveContent ? false : undefined} onLongPress={...}>
{/* mix of interactive children — attachments, quoted reply, poll options, etc. */}
</Pressable>
```

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.

Live example: `MessageContent.tsx` — `accessible={hasInteractiveContent ? false : undefined}` where `hasInteractiveContent` covers poll, quoted message, attachments, shared location.

### 10) Reshow announcements — `useAnnounceOnShow`

```tsx
useAnnounceOnShow(visible, useA11yLabel('a11y/Replying to {{user}}', { user: name }));
```

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.

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.

Live example: `Reply.tsx` — fires when a reply preview shows in the composer.

## Anti-patterns to avoid

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

- `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/components/Accessibility/CompositeAccessibilityProbe.tsx` — curated-single-focus-stop wrapper for visual content (avatar, icons, badges).
- `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).
- `package/src/a11y/hooks/useA11yLabel.ts` — translated-label-or-undefined.
- `package/src/a11y/hooks/useAnnounceOnStateChange.ts` — announce on string-change with dedup.
- `package/src/a11y/hooks/useAnnounceOnShow.ts` — announce on `visible: false → true` transitions, resets on hide (no dedup).
- `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`.
- `package/src/components/Message/MessageItemView/MessageFooter.tsx` — example of cross-platform auto-compose on a View (`accessible + accessibilityRole='text'`).
- `package/src/components/Message/MessageItemView/MessageContent.tsx` — example of conditional drill-in (`accessible={hasInteractiveContent ? false : undefined}`).

## Cross-SDK parity

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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, { PropsWithChildren } from 'react';
import { View } from 'react-native';

import { HiddenA11yText } from './HiddenA11yText';

export type CompositeAccessibilityProbeProps = {
/**
* The accessibility label that VoiceOver / TalkBack should announce for the
* wrapped content. When `undefined`, the probe is a no-op and the children
* render with no a11y modifications — use this to skip the wrapper when the
* SDK's a11y opt-in is off.
*/
label: string | undefined;
};

/**
* Wraps decorative visual content with a single, cross platform stable
* accessibility focus stop carrying the provided `label`.
*
* iOS auto collapses descendants when a parent View is `accessible`, but on
* Android `importantForAccessibility='no-hide-descendants'` on the parent
* gets defeated by deeply nested descendants that set their own
* `accessible={true}` (our SDK's `<Avatar>` does this). A zero size accessible
* `<Text>` sidesteps that - Text is always accessible by default on both
* platforms and carries the label cleanly, while the visual subtree is
* marked decorative. More importantly, it's discoverable very very easily
* by screen readers.
*
* Use this anywhere you have non-Text visual content (avatars, icons,
* composed graphics) that should announce as a single semantic unit with a
* curated label, rather than letting screen readers walk the visual tree
* verbosely.
*/
export const CompositeAccessibilityProbe = ({
children,
label,
}: PropsWithChildren<CompositeAccessibilityProbeProps>) => {
if (!label) return <>{children}</>;

return (
<>
<HiddenA11yText label={label} />
<View accessibilityElementsHidden importantForAccessibility='no-hide-descendants'>
{children}
</View>
</>
);
};
49 changes: 49 additions & 0 deletions package/src/components/Accessibility/HiddenA11yText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import { StyleSheet, Text } from 'react-native';

export type HiddenA11yTextProps = {
/**
* The text to inject into the accessibility tree. Rendered as actual Text
* content (not as `accessibilityLabel`) so the parent's compose loop on
* Android picks it up — Text without its own label isn't
* `isAccessibilityFocusable`, so it gets concatenated into the parent's
* announcement rather than being skipped as a drill-in target.
*
* Pass the result of `useA11yLabel(...)` directly: when SDK a11y is
* opt-out the value is `undefined` and the component renders nothing.
*/
label: string | undefined;
};

/**
* A visually invisible Text that exists only to contribute extra information
* to a screen reader's announcement. Use it inside a parent that auto-composes
* descendant labels (Pressable, or any View with `accessible` + `accessibilityRole`)
* to splice in supplementary state like "you reacted", "and N more", etc.
*
* Not for "this whole element should be one focus stop with a curated label" -
* use `CompositeAccessibilityProbe` for that.
*/
export const HiddenA11yText = ({ label }: HiddenA11yTextProps) => {
if (!label) return null;
// Both content and accessibilityLabel are set to the same string. Content
// keeps the Text on the parent's compose loop (label-only would make it
// `isAccessibilityFocusable` and potentially skipped on Android — though
// the opacity:0 hidden style usually saves it). accessibilityLabel keeps
// testing-library `getByLabelText(...)` queries working.
return (
<Text accessibilityLabel={label} style={styles.hidden}>
{label}
</Text>
);
};

const styles = StyleSheet.create({
hidden: {
height: 1,
opacity: 0,
overflow: 'hidden',
position: 'absolute',
width: 1,
},
});
2 changes: 2 additions & 0 deletions package/src/components/Accessibility/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './CompositeAccessibilityProbe';
export * from './HiddenA11yText';
export * from './NotificationAnnouncer';
export * from './useAccessibilityAnnouncer';
export * from './hooks/useIncomingMessageAnnouncements';
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MessageDeliveryStatus, useMessageDeliveryStatus } from '../../hooks';
import { Check, CheckAll, Time } from '../../icons';
import { primitives } from '../../theme';
import { MessageStatusTypes } from '../../utils/utils';
import { CompositeAccessibilityProbe } from '../Accessibility/CompositeAccessibilityProbe';

export type ChannelMessagePreviewDeliveryStatusProps = Pick<ChannelPreviewProps, 'channel'> & {
message: MessageResponse | LocalMessage;
Expand Down Expand Up @@ -66,11 +67,11 @@ export const ChannelMessagePreviewDeliveryStatus = ({
message.status === MessageStatusTypes.SENDING
? 'a11y/Sending'
: message.status === MessageStatusTypes.RECEIVED && status === MessageDeliveryStatus.READ
? 'a11y/Read'
? 'a11y/Read, sent by you'
: status === MessageDeliveryStatus.DELIVERED
? 'a11y/Delivered'
? 'a11y/Delivered, sent by you'
: status === MessageDeliveryStatus.SENT
? 'a11y/Sent'
? 'a11y/Sent by you'
: 'a11y/Sending',
);

Expand All @@ -83,19 +84,21 @@ export const ChannelMessagePreviewDeliveryStatus = ({
}

return (
<View accessibilityLabel={statusLabel} accessibilityRole='text' style={styles.container}>
{message.status === MessageStatusTypes.SENDING ? (
<Time stroke={semantics.chatTextTimestamp} height={16} width={16} {...timeIcon} />
) : message.status === MessageStatusTypes.RECEIVED &&
status === MessageDeliveryStatus.READ ? (
<CheckAll stroke={semantics.accentPrimary} height={16} width={16} {...checkAllIcon} />
) : status === MessageDeliveryStatus.DELIVERED ? (
<CheckAll stroke={semantics.chatTextTimestamp} height={16} width={16} {...checkAllIcon} />
) : status === MessageDeliveryStatus.SENT ? (
<Check stroke={semantics.chatTextTimestamp} height={16} width={16} {...checkIcon} />
) : null}
<Text style={styles.text}>{t('You')}:</Text>
</View>
<CompositeAccessibilityProbe label={statusLabel}>
<View style={styles.container}>
{message.status === MessageStatusTypes.SENDING ? (
<Time stroke={semantics.chatTextTimestamp} height={16} width={16} {...timeIcon} />
) : message.status === MessageStatusTypes.RECEIVED &&
status === MessageDeliveryStatus.READ ? (
<CheckAll stroke={semantics.accentPrimary} height={16} width={16} {...checkAllIcon} />
) : status === MessageDeliveryStatus.DELIVERED ? (
<CheckAll stroke={semantics.chatTextTimestamp} height={16} width={16} {...checkAllIcon} />
) : status === MessageDeliveryStatus.SENT ? (
<Check stroke={semantics.chatTextTimestamp} height={16} width={16} {...checkIcon} />
) : null}
<Text style={styles.text}>{t('You')}:</Text>
</View>
</CompositeAccessibilityProbe>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from 'react';

import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { Mute } from '../../icons';
import { CompositeAccessibilityProbe } from '../Accessibility/CompositeAccessibilityProbe';

/**
* This UI component displays an avatar for a particular channel.
Expand All @@ -13,6 +15,11 @@ export const ChannelPreviewMutedStatus = () => {
semantics,
},
} = useTheme();
const accessibilityLabel = useA11yLabel('a11y/Muted');

return <Mute height={20} fill={semantics.textTertiary} width={20} {...mutedStatus} />;
return (
<CompositeAccessibilityProbe label={accessibilityLabel}>
<Mute height={20} fill={semantics.textTertiary} width={20} {...mutedStatus} />
</CompositeAccessibilityProbe>
);
};
Loading
Loading