diff --git a/.claude/skills/accessibility/SKILL.md b/.claude/skills/accessibility/SKILL.md
index 785ab09e2c..ab32176cce 100644
--- a/.claude/skills/accessibility/SKILL.md
+++ b/.claude/skills/accessibility/SKILL.md
@@ -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.
@@ -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);
+
+
```
`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.
@@ -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**: `` 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).
@@ -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'`)
diff --git a/ai-docs/accessibility.md b/ai-docs/accessibility.md
index 2d22e92a7a..d4db899d2a 100644
--- a/ai-docs/accessibility.md
+++ b/ai-docs/accessibility.md
@@ -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
+
+```
+
+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`:
diff --git a/examples/SampleApp/src/components/ScreenHeader.tsx b/examples/SampleApp/src/components/ScreenHeader.tsx
index ab1b5393a0..5a7d141f90 100644
--- a/examples/SampleApp/src/components/ScreenHeader.tsx
+++ b/examples/SampleApp/src/components/ScreenHeader.tsx
@@ -64,6 +64,8 @@ export const BackButton: React.FC<{
return (
{
if (onBack) {
onBack();
diff --git a/package/src/__tests__/offline-support/offline-feature.tsx b/package/src/__tests__/offline-support/offline-feature.tsx
index 0db351df0a..0270c67d7c 100644
--- a/package/src/__tests__/offline-support/offline-feature.tsx
+++ b/package/src/__tests__/offline-support/offline-feature.tsx
@@ -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 () => {
@@ -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 () => {
diff --git a/package/src/a11y/a11yUtils.ts b/package/src/a11y/a11yUtils.ts
index b026d47fde..a6fdfb027d 100644
--- a/package/src/a11y/a11yUtils.ts
+++ b/package/src/a11y/a11yUtils.ts
@@ -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
+ ...actionLists: Array | undefined>
): A11yAction[] => {
const byName = new Map();
for (const list of actionLists) {
diff --git a/package/src/a11y/hooks/useA11yLabel.ts b/package/src/a11y/hooks/useA11yLabel.ts
index a27675b864..a3f1d13ec9 100644
--- a/package/src/a11y/hooks/useA11yLabel.ts
+++ b/package/src/a11y/hooks/useA11yLabel.ts
@@ -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,
@@ -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);
*
*/
export const useA11yLabel = (key: string, params?: Record): 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);
};
diff --git a/package/src/a11y/hooks/useAccessibilityActivateAction.ts b/package/src/a11y/hooks/useAccessibilityActivateAction.ts
new file mode 100644
index 0000000000..1fce1c7822
--- /dev/null
+++ b/package/src/a11y/hooks/useAccessibilityActivateAction.ts
@@ -0,0 +1,44 @@
+import type { AccessibilityActionEvent, AccessibilityProps } from 'react-native';
+
+import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext';
+
+export type UseAccessibilityActivateActionProps = {
+ onPress?: ((event: TPressEvent) => void) | null;
+ shouldHandleActivate?: boolean;
+};
+
+export type UseAccessibilityActivateActionResult =
+ | {
+ accessibilityActions?: AccessibilityProps['accessibilityActions'];
+ onAccessibilityAction?: AccessibilityProps['onAccessibilityAction'];
+ }
+ | undefined;
+
+const accessibilityActivateActions: NonNullable = [
+ { 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 = ({
+ onPress,
+ shouldHandleActivate = false,
+}: UseAccessibilityActivateActionProps): 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);
+ },
+ };
+};
diff --git a/package/src/a11y/index.ts b/package/src/a11y/index.ts
index 55ae3fc9ac..46279098ce 100644
--- a/package/src/a11y/index.ts
+++ b/package/src/a11y/index.ts
@@ -4,3 +4,4 @@ export * from './hooks/useReducedMotionPreference';
export * from './hooks/useResolvedModalAccessibilityProps';
export * from './hooks/useAnnounceOnStateChange';
export * from './hooks/useA11yLabel';
+export * from './hooks/useAccessibilityActivateAction';
diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx
index 19022bbc90..3b8586e82f 100644
--- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx
+++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx
@@ -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';
@@ -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(currentIndex);
const setCurrentIndex = useStableCallback((_: number, toIndex: number) => {
@@ -99,6 +102,10 @@ export const AttachmentPicker = () => {
const selectionBarRef = useRef(null);
const initialSnapPoint = attachmentPickerBottomSheetHeight;
+ const pickerTopInset = Math.max(
+ 0,
+ windowHeight - attachmentPickerBottomSheetHeight - bottomInset,
+ );
/**
* Snap points changing cause a rerender of the position,
@@ -150,8 +157,13 @@ export const AttachmentPicker = () => {
return (
{
// @ts-ignore
ref={ref}
snapPoints={snapPoints}
+ topInset={pickerTopInset}
animationConfigs={animationConfigs}
>
@@ -178,4 +191,13 @@ export const AttachmentPicker = () => {
const RenderNull = () => null;
+const AttachmentPickerBackground = ({ pointerEvents, style }: BottomSheetBackgroundProps) => (
+
+);
+
AttachmentPicker.displayName = 'AttachmentPicker';
diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx
index 86e7fac67c..161d7fc6f3 100644
--- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx
+++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx
@@ -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}
/>
diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx
index dbc48c3238..7e274e4273 100644
--- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx
+++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx
@@ -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';
@@ -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]);
@@ -70,6 +73,10 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => {
return (
{
);
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]);
@@ -130,6 +139,10 @@ const AttachmentImage = (props: AttachmentPickerItemType) => {
return (
{
renderItem={renderItem}
data={commands}
keyExtractor={keyExtractor}
+ showsVerticalScrollIndicator={false}
/>
>
);
diff --git a/package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx b/package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx
index a1f061bc7b..bf2ff59a76 100644
--- a/package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx
+++ b/package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx
@@ -28,12 +28,14 @@ import { Button, ButtonProps } from '../../ui';
import { BottomSheetModal } from '../../UIComponents';
export type AttachmentTypePickerButtonProps = Pick & {
+ accessibilityLabelKey?: string;
Icon: ButtonProps['LeadingIcon'];
} & Pick;
const hitSlop = { bottom: 15, top: 15 };
export const AttachmentTypePickerButton = ({
+ accessibilityLabelKey,
testID,
selected,
onPress: onPressProp,
@@ -52,6 +54,7 @@ export const AttachmentTypePickerButton = ({
return (