Skip to content
Merged
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
14 changes: 9 additions & 5 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. 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 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.
Expand All @@ -31,13 +31,17 @@ Use this skill whenever code changes can affect screen-reader users (VoiceOver o
### 1) Composing accessible names

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

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

<Button accessibilityLabelKey='a11y/Send message' iconOnly {...buttonProps} />
```

`useA11yLabel` returns `undefined` when `accessibility.enabled` is false, so the `t()` call is skipped on hot list paths.
`Button` centralizes this same behavior for SDK-owned buttons. In SDK code, pass the key/params only. When migrating a released button that already had an `accessibilityLabel`, make the new translation resolve to the same existing label unless the change is intentionally breaking.

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.

Expand Down Expand Up @@ -95,7 +99,7 @@ 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/...')`.
- **Hardcoded English `accessibilityLabel`** strings inside component code. For SDK `Button`, use `accessibilityLabelKey='a11y/...'`; otherwise 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).
Expand All @@ -116,7 +120,7 @@ Recommended for non-trivial changes:

- [ ] 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)
- [ ] Composed the accessible name via `Button accessibilityLabelKey='a11y/...'` or `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'`)
Expand Down
8 changes: 8 additions & 0 deletions ai-docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ i18n.registerTranslation('nl', {

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

SDK-owned `Button` components can translate their own accessible names from an i18n key:

```tsx
<Button accessibilityLabelKey='a11y/Send message' iconOnly {...buttonProps} />
```

Use `accessibilityLabelParams` for interpolated labels. SDK-owned buttons should pass the key/params only. When migrating an already-released button, keep the translation value aligned with the existing label unless the label change is intentionally breaking.

## Public hooks and components

Importable from `stream-chat-react-native`:
Expand Down
2 changes: 2 additions & 0 deletions examples/SampleApp/src/components/ScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export const BackButton: React.FC<{

return (
<TouchableOpacity
accessibilityLabel='Back'
accessibilityRole='button'
onPress={() => {
if (onBack) {
onBack();
Expand Down
74 changes: 40 additions & 34 deletions package/src/__tests__/offline-support/offline-feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -643,26 +643,29 @@ export const Generic = () => {
await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
const removedChannel = channels[getRandomInt(0, channels.length - 1)].channel;
act(() => dispatchNotificationRemovedFromChannel(chatClient, removedChannel));
await waitFor(async () => {
const channelIdsOnUI = screen
.queryAllByLabelText('list-item')
.map(
(node) =>
(node as unknown as { _fiber: { pendingProps: { testID: string } } })._fiber
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(removedChannel.cid)).toBeFalsy();
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);
await waitFor(
async () => {
const channelIdsOnUI = screen
.queryAllByLabelText('list-item')
.map(
(node) =>
(node as unknown as { _fiber: { pendingProps: { testID: string } } })._fiber
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(removedChannel.cid)).toBeFalsy();
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);

const channelsRows = await BetterSqlite.selectFromTable('channels');
const matchingRows = channelsRows.filter((c) => c.id === removedChannel.id);
const channelsRows = await BetterSqlite.selectFromTable('channels');
const matchingRows = channelsRows.filter((c) => c.id === removedChannel.id);

const messagesRows = await BetterSqlite.selectFromTable('messages');
const matchingMessagesRows = messagesRows.filter((m) => m.cid === removedChannel.cid);
const messagesRows = await BetterSqlite.selectFromTable('messages');
const matchingMessagesRows = messagesRows.filter((m) => m.cid === removedChannel.cid);

expect(matchingRows.length).toBe(0);
expect(matchingMessagesRows.length).toBe(0);
});
expect(matchingRows.length).toBe(0);
expect(matchingMessagesRows.length).toBe(0);
},
{ timeout: 5000 },
);
});

it('should remove the channel from DB if the channel is deleted', async () => {
Expand All @@ -674,26 +677,29 @@ export const Generic = () => {
await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
const removedChannel = channels[getRandomInt(0, channels.length - 1)].channel;
act(() => dispatchChannelDeletedEvent(chatClient, removedChannel));
await waitFor(async () => {
const channelIdsOnUI = screen
.queryAllByLabelText('list-item')
.map(
(node) =>
(node as unknown as { _fiber: { pendingProps: { testID: string } } })._fiber
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(removedChannel.cid)).toBeFalsy();
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);
await waitFor(
async () => {
const channelIdsOnUI = screen
.queryAllByLabelText('list-item')
.map(
(node) =>
(node as unknown as { _fiber: { pendingProps: { testID: string } } })._fiber
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(removedChannel.cid)).toBeFalsy();
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);

const channelsRows = await BetterSqlite.selectFromTable('channels');
const matchingRows = channelsRows.filter((c) => c.id === removedChannel.id);
const channelsRows = await BetterSqlite.selectFromTable('channels');
const matchingRows = channelsRows.filter((c) => c.id === removedChannel.id);

const messagesRows = await BetterSqlite.selectFromTable('messages');
const matchingMessagesRows = messagesRows.filter((m) => m.cid === removedChannel.cid);
const messagesRows = await BetterSqlite.selectFromTable('messages');
const matchingMessagesRows = messagesRows.filter((m) => m.cid === removedChannel.cid);

expect(matchingRows.length).toBe(0);
expect(matchingMessagesRows.length).toBe(0);
});
expect(matchingRows.length).toBe(0);
expect(matchingMessagesRows.length).toBe(0);
},
{ timeout: 5000 },
);
});

it('should correctly mark the channel as hidden in the db', async () => {
Expand Down
4 changes: 2 additions & 2 deletions package/src/a11y/a11yUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ export const formatAccessibilityValue = ({
/**
* Merge two `accessibilityActions` arrays, deduplicating by `name` (later wins).
*/
type A11yAction = { name: string; label?: string };
type A11yAction = Readonly<{ name: string; label?: string }>;

export const mergeAccessibilityActions = (
...actionLists: Array<A11yAction[] | undefined>
...actionLists: Array<ReadonlyArray<A11yAction> | undefined>
): A11yAction[] => {
const byName = new Map<string, A11yAction>();
for (const list of actionLists) {
Expand Down
11 changes: 7 additions & 4 deletions package/src/a11y/hooks/useA11yLabel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useContext } from 'react';

import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext';
import { useTranslationContext } from '../../contexts/translationContext/TranslationContext';
import { TranslationContext } from '../../contexts/translationContext/TranslationContext';

/**
* Returns the translated `a11y/...` label when the AccessibilityContext is enabled,
Expand All @@ -8,12 +10,13 @@ import { useTranslationContext } from '../../contexts/translationContext/Transla
* default disabled-state.
*
* Example:
* const label = useA11yLabel('a11y/Avatar of {{name}}', { name });
* const labelParams = useMemo(() => ({ name }), [name]);
* const label = useA11yLabel('a11y/Avatar of {{name}}', labelParams);
* <Image accessibilityLabel={label} />
*/
export const useA11yLabel = (key: string, params?: Record<string, unknown>): string | undefined => {
const { enabled } = useAccessibilityContext();
const { t } = useTranslationContext();
if (!enabled) return undefined;
const { t } = useContext(TranslationContext);
if (!enabled || !key) return undefined;
return t(key, params);
};
44 changes: 44 additions & 0 deletions package/src/a11y/hooks/useAccessibilityActivateAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { AccessibilityActionEvent, AccessibilityProps } from 'react-native';

import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext';

export type UseAccessibilityActivateActionProps<TPressEvent> = {
onPress?: ((event: TPressEvent) => void) | null;
shouldHandleActivate?: boolean;
};

export type UseAccessibilityActivateActionResult =
| {
accessibilityActions?: AccessibilityProps['accessibilityActions'];
onAccessibilityAction?: AccessibilityProps['onAccessibilityAction'];
}
| undefined;

const accessibilityActivateActions: NonNullable<AccessibilityProps['accessibilityActions']> = [
{ name: 'activate' },
];

/**
* Adds the standard screen-reader `activate` action for labeled pressables when
* SDK accessibility is enabled. Some Android pressable implementations don't
* reliably map TalkBack double-tap to `onPress`, so this bridges the generated
* accessibility action back to the existing press handler.
*/
export const useAccessibilityActivateAction = <TPressEvent>({
onPress,
shouldHandleActivate = false,
}: UseAccessibilityActivateActionProps<TPressEvent>): UseAccessibilityActivateActionResult => {
const { enabled } = useAccessibilityContext();
const shouldHandleAccessibilityActivate = enabled && shouldHandleActivate && !!onPress;

if (!shouldHandleAccessibilityActivate) return undefined;

return {
accessibilityActions: accessibilityActivateActions,
onAccessibilityAction: (event: AccessibilityActionEvent) => {
if (event.nativeEvent.actionName !== 'activate') return;

onPress(event as unknown as TPressEvent);
},
};
};
1 change: 1 addition & 0 deletions package/src/a11y/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './hooks/useReducedMotionPreference';
export * from './hooks/useResolvedModalAccessibilityProps';
export * from './hooks/useAnnounceOnStateChange';
export * from './hooks/useA11yLabel';
export * from './hooks/useAccessibilityActivateAction';
24 changes: 23 additions & 1 deletion package/src/components/AttachmentPicker/AttachmentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
Platform,
View,
LayoutChangeEvent,
useWindowDimensions,
} from 'react-native';

import { runOnJS, useAnimatedReaction, useSharedValue } from 'react-native-reanimated';

import { useBottomSheetSpringConfigs } from '@gorhom/bottom-sheet';
import type { BottomSheetBackgroundProps } from '@gorhom/bottom-sheet';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';

Expand Down Expand Up @@ -38,13 +40,14 @@ export const AttachmentPicker = () => {
attachmentPickerStore,
attachmentPickerBottomSheetHeight,
bottomSheetRef: ref,
bottomInset,
disableAttachmentPicker,
} = useAttachmentPickerContext();
const { AttachmentPickerContent, AttachmentPickerSelectionBar } = useComponentsContext();
const {
theme: { semantics },
} = useTheme();

const { height: windowHeight } = useWindowDimensions();
const [currentIndex, setCurrentIndexInternal] = useState(-1);
const currentIndexRef = useRef<number>(currentIndex);
const setCurrentIndex = useStableCallback((_: number, toIndex: number) => {
Expand Down Expand Up @@ -99,6 +102,10 @@ export const AttachmentPicker = () => {
const selectionBarRef = useRef<number | null>(null);

const initialSnapPoint = attachmentPickerBottomSheetHeight;
const pickerTopInset = Math.max(
0,
windowHeight - attachmentPickerBottomSheetHeight - bottomInset,
);

/**
* Snap points changing cause a rerender of the position,
Expand Down Expand Up @@ -150,8 +157,13 @@ export const AttachmentPicker = () => {

return (
<BottomSheet
accessible={false}
accessibilityLabel={null}
accessibilityRole={null}
android_keyboardInputMode='adjustResize'
backgroundComponent={AttachmentPickerBackground}
backgroundStyle={backgroundStyle}
bottomInset={bottomInset}
enablePanDownToClose={false}
enableContentPanningGesture={false}
enableDynamicSizing={false}
Expand All @@ -162,6 +174,7 @@ export const AttachmentPicker = () => {
// @ts-ignore
ref={ref}
snapPoints={snapPoints}
topInset={pickerTopInset}
animationConfigs={animationConfigs}
>
<View onLayout={onAttachmentPickerSelectionBarLayout}>
Expand All @@ -178,4 +191,13 @@ export const AttachmentPicker = () => {

const RenderNull = () => null;

const AttachmentPickerBackground = ({ pointerEvents, style }: BottomSheetBackgroundProps) => (
<View
accessible={false}
importantForAccessibility='no'
pointerEvents={pointerEvents}
style={style}
/>
);

AttachmentPicker.displayName = 'AttachmentPicker';
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export const AttachmentMediaPicker = (props: AttachmentPickerContentProps) => {
numColumns={numberOfColumns}
onEndReached={photoError ? undefined : getMorePhotos}
renderItem={renderAttachmentPickerItem}
showsVerticalScrollIndicator={false}
testID={'attachment-picker-list'}
updateCellsBatchingPeriod={16}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FileReference, isLocalImageAttachment, isLocalVideoAttachment } from 's

import { isIosLimited, type PhotoContentItemType } from './shared';

import { useA11yLabel } from '../../../../a11y/hooks/useA11yLabel';
import { useAttachmentPickerContext } from '../../../../contexts';
import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext';
import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState';
Expand Down Expand Up @@ -52,9 +53,11 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => {
const { duration: videoDuration, thumb_url } = asset;

const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2;
const selected = selectedIndex !== -1;
const accessibilityLabel = useA11yLabel(selected ? 'a11y/Deselect video' : 'a11y/Select video');

const onPressVideo = async () => {
if (selectedIndex !== -1) {
if (selected) {
const attachment = attachments[selectedIndex];
if (attachment) {
attachmentManager.removeAttachments([attachment.localMetadata.id]);
Expand All @@ -70,6 +73,10 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => {

return (
<BottomSheetTouchableOpacity
accessible={accessibilityLabel ? true : undefined}
accessibilityLabel={accessibilityLabel}
accessibilityRole={accessibilityLabel ? 'button' : undefined}
accessibilityState={accessibilityLabel ? { selected } : undefined}
onPress={onPressVideo}
style={[
{
Expand Down Expand Up @@ -110,11 +117,13 @@ const AttachmentImage = (props: AttachmentPickerItemType) => {
);

const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2;
const selected = selectedIndex !== -1;
const accessibilityLabel = useA11yLabel(selected ? 'a11y/Deselect image' : 'a11y/Select image');

const { uri } = asset;

const onPressImage = async () => {
if (selectedIndex !== -1) {
if (selected) {
const attachment = attachments[selectedIndex];
if (attachment) {
await attachmentManager.removeAttachments([attachment.localMetadata.id]);
Expand All @@ -130,6 +139,10 @@ const AttachmentImage = (props: AttachmentPickerItemType) => {

return (
<BottomSheetTouchableOpacity
accessible={accessibilityLabel ? true : undefined}
accessibilityLabel={accessibilityLabel}
accessibilityRole={accessibilityLabel ? 'button' : undefined}
accessibilityState={accessibilityLabel ? { selected } : undefined}
onPress={onPressImage}
style={[
{
Expand Down
Loading