Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
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);
};
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 @@ -141,6 +141,7 @@ export const AttachmentCommandPicker = () => {
renderItem={renderItem}
data={commands}
keyExtractor={keyExtractor}
showsVerticalScrollIndicator={false}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ import { Button, ButtonProps } from '../../ui';
import { BottomSheetModal } from '../../UIComponents';

export type AttachmentTypePickerButtonProps = Pick<ButtonProps, 'selected' | 'onPress'> & {
accessibilityLabelKey?: string;
Icon: ButtonProps['LeadingIcon'];
} & Pick<PressableProps, 'testID'>;

const hitSlop = { bottom: 15, top: 15 };

export const AttachmentTypePickerButton = ({
accessibilityLabelKey,
testID,
selected,
onPress: onPressProp,
Expand All @@ -52,6 +54,7 @@ export const AttachmentTypePickerButton = ({

return (
<Button
accessibilityLabelKey={accessibilityLabelKey}
testID={testID}
hitSlop={hitSlop}
onPress={onPress}
Expand Down Expand Up @@ -80,6 +83,7 @@ export const MediaPickerButton = () => {

return hasImagePicker ? (
<AttachmentTypePickerButton
accessibilityLabelKey='a11y/Open photo picker'
testID='upload-photo-touchable'
Icon={Picture}
selected={selectedPicker === 'images'}
Expand Down Expand Up @@ -113,13 +117,15 @@ export const CameraPickerButton = () => {
return hasCameraPicker ? (
<>
<AttachmentTypePickerButton
accessibilityLabelKey='a11y/Open camera'
testID='take-photo-touchable'
Icon={Camera}
selected={selectedPicker === 'camera-photo'}
onPress={onCameraPickerPress}
/>
{Platform.OS === 'android' ? (
<AttachmentTypePickerButton
accessibilityLabelKey='a11y/Open video recorder'
Icon={VideoIcon}
selected={selectedPicker === 'camera-video'}
onPress={onVideoRecorderPickerPress}
Expand All @@ -144,6 +150,7 @@ export const FilePickerButton = () => {

return hasFilePicker ? (
<AttachmentTypePickerButton
accessibilityLabelKey='a11y/Open file picker'
testID='upload-file-touchable'
Icon={FilePickerIcon}
selected={selectedPicker === 'files'}
Expand Down Expand Up @@ -171,6 +178,7 @@ export const PollPickerButton = () => {

return !threadList && hasCreatePoll && ownCapabilities.sendPoll ? ( // do not allow poll creation in threads
<AttachmentTypePickerButton
accessibilityLabelKey='a11y/Open poll creation'
testID='create-poll-touchable'
Icon={PollThumbnail}
selected={selectedPicker === 'polls'}
Expand Down Expand Up @@ -198,6 +206,7 @@ export const CommandsPickerButton = () => {
return hasCommands ? (
<>
<AttachmentTypePickerButton
accessibilityLabelKey='a11y/Open commands'
testID='commands-touchable'
Icon={CommandsIcon}
selected={selectedPicker === 'commands'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ const ImageGalleryComponentVideo = (props: ImageGalleryProps) => {
});

return (
<OverlayProvider value={{ overlayOpacity: { value: 1 } as SharedValue<number> }}>
<OverlayProvider
accessibility={{ enabled: true }}
value={{ overlayOpacity: { value: 1 } as SharedValue<number> }}
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<WithComponents overrides={{ ImageGalleryHeader: undefined as any }}>
<ImageGalleryContext.Provider
Expand Down Expand Up @@ -101,7 +104,10 @@ const ImageGalleryComponentImage = (
});

return (
<OverlayProvider value={{ overlayOpacity: { value: 1 } as SharedValue<number> }}>
<OverlayProvider
accessibility={{ enabled: true }}
value={{ overlayOpacity: { value: 1 } as SharedValue<number> }}
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<WithComponents overrides={{ ImageGalleryHeader: undefined as any }}>
<ImageGalleryContext.Provider
Expand Down Expand Up @@ -129,7 +135,7 @@ describe('ImageGalleryFooter', () => {
});
});

it('render image gallery footer component with Share Button and Grid Icon', async () => {
it('render image gallery footer component with share and grid buttons', async () => {
render(<ImageGalleryComponentVideo />);

await waitFor(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ const ImageGalleryComponent = (props: ImageGalleryProps) => {
}, [imageGalleryStateStore]);

return (
<OverlayProvider value={{ overlayOpacity: { value: 1 } as SharedValue<number> }}>
<OverlayProvider
accessibility={{ enabled: true }}
value={{ overlayOpacity: { value: 1 } as SharedValue<number> }}
>
<ImageGalleryContext.Provider
value={
{
Expand Down
Loading