From 0ce9092caec6874f53e44980fdee61ef67495b4c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 6 Jun 2026 00:15:31 +0200 Subject: [PATCH 1/9] fix: remove poll rotor options for single accessible elemnts --- package/src/components/Poll/Poll.tsx | 26 +- .../usePollAccessibilityActions.test.tsx | 358 ------------------ .../usePollAccessibilityLabel.test.tsx | 142 ------- .../Poll/hooks/usePollAccessibilityActions.ts | 191 ---------- .../Poll/hooks/usePollAccessibilityLabel.ts | 75 ---- package/src/i18n/ar.json | 4 - package/src/i18n/en.json | 4 - package/src/i18n/es.json | 4 - package/src/i18n/fr.json | 4 - package/src/i18n/he.json | 4 - package/src/i18n/hi.json | 4 - package/src/i18n/it.json | 4 - package/src/i18n/ja.json | 4 - package/src/i18n/ko.json | 4 - package/src/i18n/nl.json | 4 - package/src/i18n/pt-br.json | 4 - package/src/i18n/ru.json | 4 - package/src/i18n/tr.json | 4 - 18 files changed, 1 insertion(+), 843 deletions(-) delete mode 100644 package/src/components/Poll/hooks/__tests__/usePollAccessibilityActions.test.tsx delete mode 100644 package/src/components/Poll/hooks/__tests__/usePollAccessibilityLabel.test.tsx delete mode 100644 package/src/components/Poll/hooks/usePollAccessibilityActions.ts delete mode 100644 package/src/components/Poll/hooks/usePollAccessibilityLabel.ts diff --git a/package/src/components/Poll/Poll.tsx b/package/src/components/Poll/Poll.tsx index 8c839cfa81..0716485fbc 100644 --- a/package/src/components/Poll/Poll.tsx +++ b/package/src/components/Poll/Poll.tsx @@ -6,18 +6,14 @@ import { PollOption as PollOptionClass } from 'stream-chat'; import { PollOption, ShowAllOptionsButton } from './components'; import { PollUIStateProvider } from './contexts/PollUIStateContext'; -import { usePollAccessibilityActions } from './hooks/usePollAccessibilityActions'; -import { usePollAccessibilityLabel } from './hooks/usePollAccessibilityLabel'; import { usePollState } from './hooks/usePollState'; -import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { PollContextProvider, PollContextValue, useTheme, useTranslationContext, } from '../../contexts'; -import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { primitives } from '../../theme'; @@ -66,10 +62,6 @@ export const PollContent = () => { const styles = useStyles(); const { PollButtons: PollButtonsComponent, PollHeader: PollHeaderComponent } = useComponentsContext(); - const { enabled: a11yEnabled } = useAccessibilityContext(); - const accessibilityHint = useA11yLabel('a11y/Double tap and hold to activate contextual menu'); - const accessibilityLabel = usePollAccessibilityLabel(); - const { accessibilityActions, onAccessibilityAction } = usePollAccessibilityActions(); const { theme: { @@ -79,24 +71,8 @@ export const PollContent = () => { }, } = useTheme(); - // NOTE: Android custom accessibilityActions are broken in RN < 0.83.2 — - // see facebook/react-native#47268, fixed by PR #52724. On affected versions - // the actions menu surfaces only a subset of the list and dispatch - // announces "Action not supported". iOS works correctly on all versions. - // Once the SDK's minimum RN reaches 0.83.2, wrap the descendants below in - // so Android - // TalkBack groups them under the composite rather than exposing each - // interactive child as a separate focus stop. return ( - + {options?.slice(0, defaultPollOptionCount)?.map((option: PollOptionClass) => ( diff --git a/package/src/components/Poll/hooks/__tests__/usePollAccessibilityActions.test.tsx b/package/src/components/Poll/hooks/__tests__/usePollAccessibilityActions.test.tsx deleted file mode 100644 index fabff2cf88..0000000000 --- a/package/src/components/Poll/hooks/__tests__/usePollAccessibilityActions.test.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import React from 'react'; - -import type { AccessibilityActionEvent } from 'react-native'; - -import { act, renderHook } from '@testing-library/react-native'; - -import { AccessibilityProvider } from '../../../../contexts/accessibilityContext/AccessibilityContext'; -import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; -import { usePollAccessibilityActions } from '../usePollAccessibilityActions'; - -const mockOpenAddComment = jest.fn(); -const mockOpenAllComments = jest.fn(); -const mockOpenAllOptions = jest.fn(); -const mockOpenSuggestOption = jest.fn(); -const mockOpenViewResults = jest.fn(); -const mockEndVote = jest.fn(); -const mockToggleVote = jest.fn(); - -jest.mock('../../contexts/PollUIStateContext', () => ({ - usePollUIStateContext: () => ({ - openAddComment: mockOpenAddComment, - openAllComments: mockOpenAllComments, - openAllOptions: mockOpenAllOptions, - openSuggestOption: mockOpenSuggestOption, - openViewResults: mockOpenViewResults, - }), -})); - -jest.mock('../usePollStateStore', () => ({ - usePollStateStore: (selector: (state: unknown) => unknown) => selector(mockPollState), -})); - -jest.mock('../useEndVote', () => ({ - useEndVote: () => mockEndVote, -})); - -jest.mock('../usePollVoteToggle', () => ({ - usePollVoteToggle: () => mockToggleVote, -})); - -const mockChatContext = { client: { userID: 'me' } }; -const mockOwnCapabilities = { castPollVote: true }; - -jest.mock('../../../../contexts', () => { - const actual = jest.requireActual('../../../../contexts'); - return { - ...actual, - useChatContext: () => mockChatContext, - useOwnCapabilitiesContext: () => mockOwnCapabilities, - }; -}); - -let mockPollState: Record = {}; - -const setPollState = (state: Record) => { - mockPollState = state; -}; - -const setCastPollVote = (allowed: boolean) => { - mockOwnCapabilities.castPollVote = allowed; -}; - -const setUserID = (id: string) => { - mockChatContext.client.userID = id; -}; - -const t = (key: string, vars?: Record) => { - if (!vars) return key; - if (key === 'a11y/Vote on {{option}}') return `Vote on ${vars.option}`; - return key; -}; - -const wrapper = - (enabled: boolean) => - ({ children }: { children: React.ReactNode }) => ( - - null, - } as never - } - > - {children} - - - ); - -const buildOption = (id: string, text: string) => ({ id, text }); - -const fireAction = ( - handler: ((event: AccessibilityActionEvent) => void) | undefined, - actionName: string, -) => { - handler?.({ nativeEvent: { actionName } } as AccessibilityActionEvent); -}; - -beforeEach(() => { - mockOpenAddComment.mockClear(); - mockOpenAllComments.mockClear(); - mockOpenAllOptions.mockClear(); - mockOpenSuggestOption.mockClear(); - mockOpenViewResults.mockClear(); - mockEndVote.mockClear(); - mockToggleVote.mockClear(); - setCastPollVote(true); - setUserID('me'); -}); - -describe('usePollAccessibilityActions', () => { - it('returns undefined when accessibility is disabled', () => { - setPollState({ - allow_answers: true, - allow_user_suggested_options: true, - created_by: { id: 'me' }, - is_closed: false, - options: [buildOption('o1', 'A')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(false), - }); - - expect(result.current.accessibilityActions).toBeUndefined(); - expect(result.current.onAccessibilityAction).toBeUndefined(); - }); - - it('every action uses the same human label for name and label', () => { - setPollState({ - allow_answers: true, - allow_user_suggested_options: true, - created_by: { id: 'me' }, - is_closed: false, - options: [buildOption('o1', 'Pizza'), buildOption('o2', 'Pasta')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - const actions = result.current.accessibilityActions; - expect(actions).toBeDefined(); - for (const action of actions ?? []) { - expect(action.name).toBe(action.label); - } - }); - - it('exposes only View Results for an ended poll', () => { - setPollState({ - allow_answers: true, - allow_user_suggested_options: true, - created_by: { id: 'me' }, - is_closed: true, - options: [buildOption('o1', 'A'), buildOption('o2', 'B')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - const labels = result.current.accessibilityActions?.map((a) => a.label); - expect(labels).toEqual(['View Results']); - }); - - it('lists vote actions with the option text, plus End vote / Add comment / Suggest option for creator', () => { - setPollState({ - allow_answers: true, - allow_user_suggested_options: true, - created_by: { id: 'me' }, - is_closed: false, - options: [buildOption('o1', 'Pizza'), buildOption('o2', 'Pasta')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - const labels = result.current.accessibilityActions?.map((a) => a.label); - expect(labels).toEqual([ - 'View Results', - 'Vote on Pizza', - 'Vote on Pasta', - 'a11y/End vote', - 'Add a comment', - 'Suggest an option', - ]); - }); - - it('omits End vote when the current user is not the creator', () => { - setUserID('someone-else'); - setPollState({ - allow_answers: false, - allow_user_suggested_options: false, - created_by: { id: 'me' }, - is_closed: false, - options: [buildOption('o1', 'Pizza')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - const labels = result.current.accessibilityActions?.map((a) => a.label); - expect(labels).toEqual(['View Results', 'Vote on Pizza']); - }); - - it('omits vote actions when the user lacks castPollVote capability', () => { - setCastPollVote(false); - setPollState({ - allow_answers: true, - allow_user_suggested_options: false, - created_by: { id: 'somebody' }, - is_closed: false, - options: [buildOption('o1', 'Pizza')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - const labels = result.current.accessibilityActions?.map((a) => a.label); - expect(labels?.some((l) => l?.startsWith('Vote on'))).toBe(false); - }); - - it('exposes "View N comments" when the poll has answers', () => { - setPollState({ - allow_answers: false, - allow_user_suggested_options: false, - answers_count: 4, - created_by: { id: 'somebody' }, - is_closed: true, - options: [buildOption('o1', 'A')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - const labels = result.current.accessibilityActions?.map((a) => a.label); - expect(labels).toContain('View {{count}} comments'); - }); - - it('omits "View N comments" when there are no answers', () => { - setPollState({ - allow_answers: false, - allow_user_suggested_options: false, - answers_count: 0, - created_by: { id: 'somebody' }, - is_closed: true, - options: [buildOption('o1', 'A')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - const labels = result.current.accessibilityActions?.map((a) => a.label); - expect(labels?.some((l) => l?.includes('comments'))).toBe(false); - }); - - it('exposes Show all options when options exceed the visible cap', () => { - const manyOptions = Array.from({ length: 12 }, (_, i) => buildOption(`o${i}`, `Option ${i}`)); - setPollState({ - allow_answers: false, - allow_user_suggested_options: false, - created_by: { id: 'somebody' }, - is_closed: true, - options: manyOptions, - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - const labels = result.current.accessibilityActions?.map((a) => a.label); - expect(labels).toContain('a11y/Show all options'); - }); - - it('routes each action to the right side effect', () => { - setPollState({ - allow_answers: true, - allow_user_suggested_options: true, - created_by: { id: 'me' }, - is_closed: false, - options: [buildOption('o1', 'Pizza'), buildOption('o2', 'Pasta')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - act(() => { - fireAction(result.current.onAccessibilityAction, 'View Results'); - }); - expect(mockOpenViewResults).toHaveBeenCalledTimes(1); - - act(() => { - fireAction(result.current.onAccessibilityAction, 'a11y/End vote'); - }); - expect(mockEndVote).toHaveBeenCalledTimes(1); - - act(() => { - fireAction(result.current.onAccessibilityAction, 'Add a comment'); - }); - expect(mockOpenAddComment).toHaveBeenCalledTimes(1); - - act(() => { - fireAction(result.current.onAccessibilityAction, 'Suggest an option'); - }); - expect(mockOpenSuggestOption).toHaveBeenCalledTimes(1); - - act(() => { - fireAction(result.current.onAccessibilityAction, 'Vote on Pasta'); - }); - expect(mockToggleVote).toHaveBeenCalledWith('o2'); - }); - - it('routes the "View N comments" action to openAllComments', () => { - setPollState({ - allow_answers: false, - allow_user_suggested_options: false, - answers_count: 7, - created_by: { id: 'somebody' }, - is_closed: true, - options: [buildOption('o1', 'A')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - act(() => { - fireAction(result.current.onAccessibilityAction, 'View {{count}} comments'); - }); - expect(mockOpenAllComments).toHaveBeenCalledTimes(1); - }); - - it('ignores unknown action names', () => { - setPollState({ - allow_answers: true, - allow_user_suggested_options: true, - created_by: { id: 'me' }, - is_closed: false, - options: [buildOption('o1', 'Pizza')], - }); - - const { result } = renderHook(() => usePollAccessibilityActions(), { - wrapper: wrapper(true), - }); - - act(() => { - fireAction(result.current.onAccessibilityAction, 'streamPollVoteOption_o1'); - }); - expect(mockToggleVote).not.toHaveBeenCalled(); - expect(mockOpenViewResults).not.toHaveBeenCalled(); - }); -}); diff --git a/package/src/components/Poll/hooks/__tests__/usePollAccessibilityLabel.test.tsx b/package/src/components/Poll/hooks/__tests__/usePollAccessibilityLabel.test.tsx deleted file mode 100644 index 3a0ec50c2a..0000000000 --- a/package/src/components/Poll/hooks/__tests__/usePollAccessibilityLabel.test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react'; - -import { renderHook } from '@testing-library/react-native'; - -import { AccessibilityProvider } from '../../../../contexts/accessibilityContext/AccessibilityContext'; -import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; -import { usePollAccessibilityLabel } from '../usePollAccessibilityLabel'; - -jest.mock('../usePollStateStore', () => ({ - usePollStateStore: (selector: (state: unknown) => unknown) => selector(mockPollState), -})); - -let mockPollState: Record = {}; - -const setPollState = (state: Record) => { - mockPollState = state; -}; - -const t = (key: string, vars?: Record) => { - if (!vars) return key; - if (key === '{{count}} votes') return `${vars.count} votes`; - if (key === 'Select up to {{count}}') return `Select up to ${vars.count}`; - if (key === '+{{count}} More Options') return `+${vars.count} More Options`; - return key; -}; - -const wrapper = - (enabled: boolean) => - ({ children }: { children: React.ReactNode }) => ( - - null, - } as never - } - > - {children} - - - ); - -const buildOption = (id: string, text: string) => ({ id, text }); - -describe('usePollAccessibilityLabel', () => { - it('returns undefined when accessibility is disabled', () => { - setPollState({ - enforce_unique_vote: false, - is_closed: true, - max_votes_allowed: 0, - name: 'Lunch?', - options: [buildOption('o1', 'Pizza')], - vote_counts_by_option: { o1: 3 }, - }); - - const { result } = renderHook(() => usePollAccessibilityLabel(), { - wrapper: wrapper(false), - }); - - expect(result.current).toBeUndefined(); - }); - - it('builds composite label for an ended poll', () => { - setPollState({ - enforce_unique_vote: false, - is_closed: true, - max_votes_allowed: 0, - name: 'Test', - options: [buildOption('o1', 'Option 1'), buildOption('o2', 'Option 2')], - vote_counts_by_option: { o1: 0, o2: 0 }, - }); - - const { result } = renderHook(() => usePollAccessibilityLabel(), { - wrapper: wrapper(true), - }); - - expect(result.current).toBe( - 'Test, Poll has ended, Option 1: 0 votes, Option 2: 0 votes, a11y/Activate to view results', - ); - }); - - it('uses "Select one" for an open enforce-unique-vote poll', () => { - setPollState({ - enforce_unique_vote: true, - is_closed: false, - max_votes_allowed: 0, - name: 'Pick a venue', - options: [buildOption('o1', 'Cafe')], - vote_counts_by_option: { o1: 2 }, - }); - - const { result } = renderHook(() => usePollAccessibilityLabel(), { - wrapper: wrapper(true), - }); - - expect(result.current).toBe( - 'Pick a venue, Select one, Cafe: 2 votes, a11y/Activate to view results', - ); - }); - - it('uses "Select up to N" when maxVotesAllowed is set', () => { - setPollState({ - enforce_unique_vote: false, - is_closed: false, - max_votes_allowed: 3, - name: 'Top picks', - options: [buildOption('o1', 'A')], - vote_counts_by_option: { o1: 1 }, - }); - - const { result } = renderHook(() => usePollAccessibilityLabel(), { - wrapper: wrapper(true), - }); - - expect(result.current).toBe( - 'Top picks, Select up to 3, A: 1 votes, a11y/Activate to view results', - ); - }); - - it('appends overflow hint when options exceed the visible cap', () => { - const manyOptions = Array.from({ length: 12 }, (_, i) => buildOption(`o${i}`, `Option ${i}`)); - const counts = Object.fromEntries(manyOptions.map((o) => [o.id, 0])); - - setPollState({ - enforce_unique_vote: false, - is_closed: false, - max_votes_allowed: 0, - name: 'Big poll', - options: manyOptions, - vote_counts_by_option: counts, - }); - - const { result } = renderHook(() => usePollAccessibilityLabel(), { - wrapper: wrapper(true), - }); - - expect(result.current).toContain('+7 More Options'); - expect(result.current).toContain('Option 0: 0 votes'); - expect(result.current).not.toContain('Option 5:'); - }); -}); diff --git a/package/src/components/Poll/hooks/usePollAccessibilityActions.ts b/package/src/components/Poll/hooks/usePollAccessibilityActions.ts deleted file mode 100644 index 86d85735d7..0000000000 --- a/package/src/components/Poll/hooks/usePollAccessibilityActions.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { useMemo } from 'react'; - -import type { AccessibilityActionEvent, AccessibilityProps } from 'react-native'; - -import { PollOption, PollState, UserResponse } from 'stream-chat'; - -import { useEndVote } from './useEndVote'; - -import { usePollStateStore } from './usePollStateStore'; - -import { usePollVoteToggle } from './usePollVoteToggle'; - -import { - useChatContext, - useOwnCapabilitiesContext, - useTranslationContext, -} from '../../../contexts'; -import { useAccessibilityContext } from '../../../contexts/accessibilityContext/AccessibilityContext'; -import { useStableCallback } from '../../../hooks'; -import { defaultPollOptionCount } from '../../../utils/constants'; -import { usePollUIStateContext } from '../contexts/PollUIStateContext'; - -type AccessibilityAction = NonNullable[number]; -type OnAccessibilityAction = NonNullable; - -type PollA11yActionsSelectorResult = { - allowAnswers: boolean | undefined; - allowUserSuggestedOptions: boolean | undefined; - answersCount: number; - createdBy: UserResponse | null; - isClosed: boolean | undefined; - options: PollOption[]; -}; - -const a11yActionsSelector = (state: PollState): PollA11yActionsSelectorResult => ({ - allowAnswers: state.allow_answers, - allowUserSuggestedOptions: state.allow_user_suggested_options, - answersCount: state.answers_count, - createdBy: state.created_by, - isClosed: state.is_closed, - options: state.options, -}); - -export type UsePollAccessibilityActionsResult = { - accessibilityActions: readonly AccessibilityAction[] | undefined; - onAccessibilityAction: OnAccessibilityAction | undefined; -}; - -type ActionKind = - | { type: 'addComment' } - | { type: 'endVote' } - | { type: 'showAllComments' } - | { type: 'showAllOptions' } - | { type: 'suggestOption' } - | { type: 'viewResults' } - | { type: 'vote'; optionId: string }; - -/** - * Returns the `accessibilityActions` array and `onAccessibilityAction` handler - * for the poll composite container. Action set is gated by poll state + - * capabilities so each rotor entry corresponds to an interaction the user is - * actually allowed to perform. Returns `undefined`s when a11y is disabled. - * - * NOTE: We set both `name` and `label` to the same human-readable string on - * every action. iOS Fabric (new architecture, on by default in RN 0.81+) uses - * `accessibilityAction.name` as the string VoiceOver reads — `label` is - * ignored on that path (RCTViewComponentView.mm). iOS legacy (Paper) and - * Android both read `label`. Using the same value for both fields means the - * announcement is human-readable on every platform/architecture. Dispatch - * uses the action name as the lookup key into an internal kind map, so the - * raw strings never need to be exposed to consumers. - */ -export const usePollAccessibilityActions = (): UsePollAccessibilityActionsResult => { - const { enabled } = useAccessibilityContext(); - const { t } = useTranslationContext(); - const { client } = useChatContext(); - const { castPollVote } = useOwnCapabilitiesContext(); - const { allowAnswers, allowUserSuggestedOptions, answersCount, createdBy, isClosed, options } = - usePollStateStore(a11yActionsSelector); - const { openAddComment, openAllComments, openAllOptions, openSuggestOption, openViewResults } = - usePollUIStateContext(); - const toggleVote = usePollVoteToggle(); - const endVote = useEndVote(); - - const canVote = !isClosed && !!castPollVote; - const canEnd = !isClosed && createdBy?.id === client.userID; - const canComment = !isClosed && !!allowAnswers; - const canSuggest = !isClosed && !!allowUserSuggestedOptions; - const hasMoreOptions = !!options && options.length > defaultPollOptionCount; - const hasComments = answersCount > 0; - - const { accessibilityActions, actionKindByName } = useMemo<{ - accessibilityActions: readonly AccessibilityAction[] | undefined; - actionKindByName: Map | undefined; - }>(() => { - if (!enabled) { - return { accessibilityActions: undefined, actionKindByName: undefined }; - } - - const actions: AccessibilityAction[] = []; - const kindByName = new Map(); - - const push = (name: string, kind: ActionKind) => { - actions.push({ label: name, name }); - kindByName.set(name, kind); - }; - - push(t('View Results'), { type: 'viewResults' }); - - if (canVote && options) { - for (const option of options.slice(0, defaultPollOptionCount)) { - push(t('a11y/Vote on {{option}}', { option: option.text }), { - optionId: option.id, - type: 'vote', - }); - } - } - - if (hasMoreOptions) { - push(t('a11y/Show all options'), { type: 'showAllOptions' }); - } - - if (canEnd) { - push(t('a11y/End vote'), { type: 'endVote' }); - } - - if (canComment) { - push(t('Add a comment'), { type: 'addComment' }); - } - - if (canSuggest) { - push(t('Suggest an option'), { type: 'suggestOption' }); - } - - if (hasComments) { - push(t('View {{count}} comments', { count: answersCount }), { type: 'showAllComments' }); - } - - return { accessibilityActions: actions, actionKindByName: kindByName }; - }, [ - answersCount, - canComment, - canEnd, - canSuggest, - canVote, - enabled, - hasComments, - hasMoreOptions, - options, - t, - ]); - - const onAccessibilityAction = useStableCallback((event: AccessibilityActionEvent) => { - const kind = actionKindByName?.get(event.nativeEvent.actionName); - if (!kind) return; - - switch (kind.type) { - case 'viewResults': - openViewResults(); - return; - case 'showAllOptions': - openAllOptions(); - return; - case 'endVote': - void endVote(); - return; - case 'addComment': - openAddComment(); - return; - case 'suggestOption': - openSuggestOption(); - return; - case 'showAllComments': - openAllComments(); - return; - case 'vote': - void toggleVote(kind.optionId); - return; - default: - return; - } - }); - - return useMemo( - () => ({ - accessibilityActions, - onAccessibilityAction: enabled ? onAccessibilityAction : undefined, - }), - [accessibilityActions, enabled, onAccessibilityAction], - ); -}; diff --git a/package/src/components/Poll/hooks/usePollAccessibilityLabel.ts b/package/src/components/Poll/hooks/usePollAccessibilityLabel.ts deleted file mode 100644 index 5617ae8fd0..0000000000 --- a/package/src/components/Poll/hooks/usePollAccessibilityLabel.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useMemo } from 'react'; - -import { PollOption, PollState } from 'stream-chat'; - -import { usePollStateStore } from './usePollStateStore'; - -import { composeAccessibilityLabel } from '../../../a11y/a11yUtils'; -import { useTranslationContext } from '../../../contexts'; -import { useAccessibilityContext } from '../../../contexts/accessibilityContext/AccessibilityContext'; -import { defaultPollOptionCount } from '../../../utils/constants'; - -type PollA11yLabelSelectorResult = { - enforceUniqueVote: boolean; - isClosed: boolean | undefined; - maxVotesAllowed: number; - name: string; - options: PollOption[]; - voteCountsByOption: Record; -}; - -const a11yLabelSelector = (state: PollState): PollA11yLabelSelectorResult => ({ - enforceUniqueVote: state.enforce_unique_vote, - isClosed: state.is_closed, - maxVotesAllowed: state.max_votes_allowed, - name: state.name, - options: state.options, - voteCountsByOption: state.vote_counts_by_option, -}); - -/** - * Builds the composite accessibility label for a poll bubble: name, status, - * up to `defaultPollOptionCount` options with vote counts, an overflow hint, - * and the primary-tap hint. Returns `undefined` when a11y is disabled so the - * Poll container can leave its `accessibilityLabel` unset. - */ -export const usePollAccessibilityLabel = (): string | undefined => { - const { enabled } = useAccessibilityContext(); - const { t } = useTranslationContext(); - const { enforceUniqueVote, isClosed, maxVotesAllowed, name, options, voteCountsByOption } = - usePollStateStore(a11yLabelSelector); - - return useMemo(() => { - if (!enabled) return undefined; - - let status: string; - if (isClosed) { - status = t('Poll has ended'); - } else if (enforceUniqueVote) { - status = t('Select one'); - } else if (maxVotesAllowed) { - status = t('Select up to {{count}}', { count: maxVotesAllowed }); - } else { - status = t('Select one or more'); - } - - const visibleOptions = options?.slice(0, defaultPollOptionCount) ?? []; - const optionParts = visibleOptions.map((option) => { - const count = voteCountsByOption?.[option.id] ?? 0; - return `${option.text}: ${t('{{count}} votes', { count })}`; - }); - - const overflow = - options && options.length > defaultPollOptionCount - ? t('+{{count}} More Options', { count: options.length - defaultPollOptionCount }) - : null; - - return composeAccessibilityLabel( - name, - status, - ...optionParts, - overflow, - t('a11y/Activate to view results'), - ); - }, [enabled, enforceUniqueVote, isClosed, maxVotesAllowed, name, options, t, voteCountsByOption]); -}; diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index 829a76af98..a3b39dd843 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -352,10 +352,6 @@ "size limit": "حد الحجم", "unknown error": "خطأ غير معروف", "unsupported file type": "نوع ملف غير مدعوم", - "a11y/Activate to view results": "فعّل لعرض النتائج", - "a11y/End vote": "إنهاء التصويت", - "a11y/Show all options": "إظهار جميع الخيارات", - "a11y/Vote on {{option}}": "صوّت على {{option}}", "a11y/Double tap and hold to activate contextual menu": "انقر نقرًا مزدوجًا مع الاستمرار لتفعيل قائمة السياق", "a11y/Swipe right to go through different actions": "اسحب لليمين للتنقل بين الإجراءات المختلفة", "a11y/Close": "Close", diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index d5d3f16471..db361a9c1f 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -308,14 +308,10 @@ "a11y/Stop voice recording": "Stop voice recording", "a11y/Notifications": "Notifications", "a11y/Dismiss notification": "Dismiss notification", - "a11y/Activate to view results": "Activate to view results", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/Close": "Close", "a11y/Double tap and hold to activate contextual menu": "Double tap and hold to activate contextual menu", - "a11y/End vote": "End vote", - "a11y/Show all options": "Show all options", "a11y/Swipe right to go through different actions": "Swipe right to go through different actions", - "a11y/Vote on {{option}}": "Vote on {{option}}", "Attachment upload blocked due to {{reason}}": "Attachment upload blocked due to {{reason}}", "Attachment upload failed due to {{reason}}": "Attachment upload failed due to {{reason}}", "Command not available": "Command not available", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index 75fdc566e5..f1f0047d32 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -352,10 +352,6 @@ "size limit": "límite de tamaño", "unknown error": "error desconocido", "unsupported file type": "tipo de archivo no compatible", - "a11y/Activate to view results": "Activa para ver los resultados", - "a11y/End vote": "Finalizar votación", - "a11y/Show all options": "Mostrar todas las opciones", - "a11y/Vote on {{option}}": "Votar por {{option}}", "a11y/Double tap and hold to activate contextual menu": "Toca dos veces y mantén pulsado para activar el menú contextual", "a11y/Swipe right to go through different actions": "Desliza a la derecha para recorrer las diferentes acciones", "a11y/Close": "Close", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index bc498a9f71..f8d783f2bd 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -352,10 +352,6 @@ "size limit": "limite de taille", "unknown error": "erreur inconnue", "unsupported file type": "type de fichier non pris en charge", - "a11y/Activate to view results": "Activer pour voir les résultats", - "a11y/End vote": "Terminer le vote", - "a11y/Show all options": "Afficher toutes les options", - "a11y/Vote on {{option}}": "Voter pour {{option}}", "a11y/Double tap and hold to activate contextual menu": "Appuyez deux fois et maintenez pour activer le menu contextuel", "a11y/Swipe right to go through different actions": "Glissez vers la droite pour parcourir les différentes actions", "a11y/Close": "Close", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 42c9e51ee0..dfd5d4908c 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -352,10 +352,6 @@ "size limit": "מגבלת גודל", "unknown error": "שגיאה לא ידועה", "unsupported file type": "סוג קובץ לא נתמך", - "a11y/Activate to view results": "הפעל כדי לראות את התוצאות", - "a11y/End vote": "סיים הצבעה", - "a11y/Show all options": "הצג את כל האפשרויות", - "a11y/Vote on {{option}}": "הצבע עבור {{option}}", "a11y/Double tap and hold to activate contextual menu": "הקש פעמיים והחזק כדי להפעיל את התפריט ההקשרי", "a11y/Swipe right to go through different actions": "החלק ימינה כדי לעבור בין הפעולות השונות", "a11y/Close": "Close", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 84586cafd8..cf458d0aef 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -352,10 +352,6 @@ "size limit": "आकार सीमा", "unknown error": "अज्ञात त्रुटि", "unsupported file type": "असमर्थित फ़ाइल प्रकार", - "a11y/Activate to view results": "परिणाम देखने के लिए सक्रिय करें", - "a11y/End vote": "मतदान समाप्त करें", - "a11y/Show all options": "सभी विकल्प दिखाएं", - "a11y/Vote on {{option}}": "{{option}} पर वोट करें", "a11y/Double tap and hold to activate contextual menu": "संदर्भ मेनू सक्रिय करने के लिए दो बार टैप करें और होल्ड करें", "a11y/Swipe right to go through different actions": "विभिन्न क्रियाओं के बीच जाने के लिए दाएं स्वाइप करें", "a11y/Close": "Close", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 4452fb0009..c6319c1f85 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -352,10 +352,6 @@ "size limit": "limite di dimensione", "unknown error": "errore sconosciuto", "unsupported file type": "tipo di file non supportato", - "a11y/Activate to view results": "Attiva per vedere i risultati", - "a11y/End vote": "Termina sondaggio", - "a11y/Show all options": "Mostra tutte le opzioni", - "a11y/Vote on {{option}}": "Vota per {{option}}", "a11y/Double tap and hold to activate contextual menu": "Tocca due volte e tieni premuto per attivare il menu contestuale", "a11y/Swipe right to go through different actions": "Scorri a destra per passare in rassegna le diverse azioni", "a11y/Close": "Close", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 73f05ecc4a..7dcc12459f 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -352,10 +352,6 @@ "size limit": "サイズ制限", "unknown error": "不明なエラー", "unsupported file type": "サポートされていないファイル形式", - "a11y/Activate to view results": "結果を表示するには有効化", - "a11y/End vote": "投票を終了", - "a11y/Show all options": "すべてのオプションを表示", - "a11y/Vote on {{option}}": "{{option}}に投票", "a11y/Double tap and hold to activate contextual menu": "コンテキストメニューを表示するにはダブルタップして長押し", "a11y/Swipe right to go through different actions": "右にスワイプして異なるアクションを切り替えます", "a11y/Close": "Close", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index bec7e20f17..8de6cf83ee 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -352,10 +352,6 @@ "size limit": "크기 제한", "unknown error": "알 수 없는 오류", "unsupported file type": "지원되지 않는 파일 형식", - "a11y/Activate to view results": "결과를 보려면 활성화", - "a11y/End vote": "투표 종료", - "a11y/Show all options": "모든 옵션 표시", - "a11y/Vote on {{option}}": "{{option}}에 투표", "a11y/Double tap and hold to activate contextual menu": "컨텍스트 메뉴를 활성화하려면 두 번 탭하고 길게 누르세요", "a11y/Swipe right to go through different actions": "다른 작업을 탐색하려면 오른쪽으로 스와이프하세요", "a11y/Close": "Close", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 1d0a400967..e37f37f2a2 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -352,10 +352,6 @@ "size limit": "groottelimiet", "unknown error": "onbekende fout", "unsupported file type": "niet-ondersteund bestandstype", - "a11y/Activate to view results": "Activeer om resultaten te bekijken", - "a11y/End vote": "Stemming beëindigen", - "a11y/Show all options": "Alle opties weergeven", - "a11y/Vote on {{option}}": "Stem op {{option}}", "a11y/Double tap and hold to activate contextual menu": "Dubbeltik en houd vast om het contextmenu te openen", "a11y/Swipe right to go through different actions": "Veeg naar rechts om door verschillende acties te bladeren", "a11y/Close": "Close", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 54da88ca23..c150ddc0b4 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -352,10 +352,6 @@ "size limit": "limite de tamanho", "unknown error": "erro desconhecido", "unsupported file type": "tipo de arquivo não compatível", - "a11y/Activate to view results": "Ative para ver os resultados", - "a11y/End vote": "Encerrar votação", - "a11y/Show all options": "Mostrar todas as opções", - "a11y/Vote on {{option}}": "Votar em {{option}}", "a11y/Double tap and hold to activate contextual menu": "Toque duas vezes e segure para ativar o menu contextual", "a11y/Swipe right to go through different actions": "Deslize para a direita para percorrer as diferentes ações", "a11y/Close": "Close", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 32b2f456ed..c52c31c4a6 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -352,10 +352,6 @@ "size limit": "лимит размера", "unknown error": "неизвестная ошибка", "unsupported file type": "неподдерживаемый тип файла", - "a11y/Activate to view results": "Активируйте, чтобы увидеть результаты", - "a11y/End vote": "Завершить голосование", - "a11y/Show all options": "Показать все варианты", - "a11y/Vote on {{option}}": "Голосовать за {{option}}", "a11y/Double tap and hold to activate contextual menu": "Дважды коснитесь и удерживайте, чтобы открыть контекстное меню", "a11y/Swipe right to go through different actions": "Смахните вправо, чтобы переключаться между действиями", "a11y/Close": "Close", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index b3a80c6a3f..d05558b4f0 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -352,10 +352,6 @@ "size limit": "boyut sınırı", "unknown error": "bilinmeyen hata", "unsupported file type": "desteklenmeyen dosya türü", - "a11y/Activate to view results": "Sonuçları görmek için etkinleştir", - "a11y/End vote": "Oylamayı sonlandır", - "a11y/Show all options": "Tüm seçenekleri göster", - "a11y/Vote on {{option}}": "{{option}} için oy ver", "a11y/Double tap and hold to activate contextual menu": "Bağlam menüsünü etkinleştirmek için çift dokunup basılı tut", "a11y/Swipe right to go through different actions": "Farklı eylemler arasında geçiş yapmak için sağa kaydır", "a11y/Close": "Close", From f12d27542437578c6ead0bdac2d41b2a4979c9a1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 6 Jun 2026 01:14:03 +0200 Subject: [PATCH 2/9] fix: pull utility hook from other pr and solve reply component a11y --- package/src/a11y/hooks/useAnnounceOnShow.ts | 44 +++++++++++++ package/src/a11y/index.ts | 1 + .../Message/hooks/useMessageActionHandlers.ts | 7 ++ package/src/components/Reply/Reply.tsx | 65 +++++++++++++++++-- package/src/i18n/ar.json | 4 ++ package/src/i18n/en.json | 4 ++ package/src/i18n/es.json | 4 ++ package/src/i18n/fr.json | 4 ++ package/src/i18n/he.json | 4 ++ package/src/i18n/hi.json | 4 ++ package/src/i18n/it.json | 4 ++ package/src/i18n/ja.json | 4 ++ package/src/i18n/ko.json | 4 ++ package/src/i18n/nl.json | 4 ++ package/src/i18n/pt-br.json | 4 ++ package/src/i18n/ru.json | 4 ++ package/src/i18n/tr.json | 4 ++ 17 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 package/src/a11y/hooks/useAnnounceOnShow.ts diff --git a/package/src/a11y/hooks/useAnnounceOnShow.ts b/package/src/a11y/hooks/useAnnounceOnShow.ts new file mode 100644 index 0000000000..d186c9af1b --- /dev/null +++ b/package/src/a11y/hooks/useAnnounceOnShow.ts @@ -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]); +}; diff --git a/package/src/a11y/index.ts b/package/src/a11y/index.ts index 46279098ce..ffbd8b290f 100644 --- a/package/src/a11y/index.ts +++ b/package/src/a11y/index.ts @@ -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'; diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 5bd9e65a95..8ed432d9c7 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -5,10 +5,12 @@ import { UserResponse } from 'stream-chat'; import { useUserMuteActive } from './useUserMuteActive'; +import { useScreenReaderEnabled } from '../../../a11y/hooks/useScreenReaderEnabled'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; import { MessageComposerAPIContextValue } from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; +import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; @@ -67,6 +69,8 @@ export const useMessageActionHandlers = ({ Pick) => { const { t } = useTranslationContext(); const { addNotification } = useNotificationApi(); + const { inputBoxRef } = useMessageInputContext(); + const screenReaderEnabled = useScreenReaderEnabled(); const handleResendMessage = useStableCallback(() => retrySendMessage(message)); const translatedMessage = useTranslatedMessage(message); @@ -74,6 +78,9 @@ export const useMessageActionHandlers = ({ const handleQuotedReplyMessage = useStableCallback(() => { setQuotedMessage(message); + if (screenReaderEnabled) { + inputBoxRef.current?.focus(); + } }); const handleCopyMessage = useStableCallback(() => { diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 819388e542..6578237412 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -20,6 +20,7 @@ import { import { ReplyMessageView } from './ReplyMessageView'; +import { useAnnounceOnShow } from '../../a11y/hooks/useAnnounceOnShow'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { @@ -42,6 +43,8 @@ const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ quotedMessage: state.quotedMessage, }); +const ANNOUNCEMENT_TEXT_MAX_LENGTH = 120; + const RightContent = React.memo( (props: Pick) => { const { ImageComponent, message } = props; @@ -234,6 +237,43 @@ export const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof Re export type ReplyProps = Partial & Pick; +/** + * Mounted only when the Reply is rendered as the composer header preview + * (edit/reply). Keeps the translation + announcer subscriptions off the + * per-row in-message quoted-reply render path. + */ +const ReplyComposerAnnouncer = ({ + message, + mode, +}: { + message: ReplyPropsWithContext['quotedMessage']; + mode: ReplyPropsWithContext['mode']; +}) => { + const { t } = useTranslationContext(); + const truncatedText = useMemo(() => { + const raw = message?.text?.trim(); + if (!raw) return undefined; + return raw.length > ANNOUNCEMENT_TEXT_MAX_LENGTH + ? `${raw.slice(0, ANNOUNCEMENT_TEXT_MAX_LENGTH).trimEnd()}…` + : raw; + }, [message?.text]); + const announcement = useMemo(() => { + if (mode === 'edit') { + return truncatedText + ? t('a11y/Editing message: {{text}}', { text: truncatedText }) + : t('a11y/Editing message'); + } + const name = message?.user?.name; + if (!name) return undefined; + return truncatedText + ? t('a11y/Replying to {{user}}: {{text}}', { text: truncatedText, user: name }) + : t('a11y/Replying to {{user}}', { user: name }); + }, [mode, message?.user?.name, truncatedText, t]); + + useAnnounceOnShow(true, announcement); + return null; +}; + export const Reply = (props: ReplyProps) => { const { message: messageFromContext } = useMessageContext(); const { client } = useChatContext(); @@ -251,14 +291,25 @@ export const Reply = (props: ReplyProps) => { const isMyMessage = client.user?.id === quotedMessage?.user?.id; + // Composer header passes `onDismiss`; the in-message quoted-reply renderer + // does not. Only the composer-preview path pays for announcement work. + const isComposerPreview = !!props.onDismiss; + return ( - + <> + {isComposerPreview ? ( + // Edit passes the message via `quotedMessage` prop; reply uses the + // composer-state quoted message we computed locally. + + ) : null} + + ); }; diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index a3b39dd843..55c201a7a1 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "Avatar of {{name}}", "a11y/Connected": "Connected", "a11y/Delivered": "Delivered", + "a11y/Editing message": "تعديل الرسالة", + "a11y/Editing message: {{text}}": "تعديل الرسالة: {{text}}", "a11y/Loading": "Loading", "a11y/Loading failed": "Loading failed", "a11y/Message actions": "Message actions", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "Reply to {{user}}", "a11y/Remove edit": "Remove edit", "a11y/Remove reply": "Remove reply", + "a11y/Replying to {{user}}": "الرد على {{user}}", + "a11y/Replying to {{user}}: {{text}}": "الرد على {{user}}: {{text}}", "a11y/Scroll to bottom": "Scroll to bottom", "a11y/Scroll to bottom, {{count}} new messages": "Scroll to bottom, {{count}} new messages", "a11y/Scroll to latest": "Scroll to latest", diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index db361a9c1f..74f0a59385 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "Avatar of {{name}}", "a11y/Connected": "Connected", "a11y/Delivered": "Delivered", + "a11y/Editing message": "Editing message", + "a11y/Editing message: {{text}}": "Editing message: {{text}}", "a11y/Loading": "Loading", "a11y/Loading failed": "Loading failed", "a11y/Message actions": "Message actions", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "Reply to {{user}}", "a11y/Remove edit": "Remove edit", "a11y/Remove reply": "Remove reply", + "a11y/Replying to {{user}}": "Replying to {{user}}", + "a11y/Replying to {{user}}: {{text}}": "Replying to {{user}}: {{text}}", "a11y/Scroll to bottom": "Scroll to bottom", "a11y/Scroll to bottom, {{count}} new messages": "Scroll to bottom, {{count}} new messages", "a11y/Scroll to latest": "Scroll to latest", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index f1f0047d32..543884a560 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "Avatar de {{name}}", "a11y/Connected": "Conectado", "a11y/Delivered": "Entregado", + "a11y/Editing message": "Editando mensaje", + "a11y/Editing message: {{text}}": "Editando mensaje: {{text}}", "a11y/Loading": "Cargando", "a11y/Loading failed": "Error al cargar", "a11y/Message actions": "Acciones del mensaje", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "Responder a {{user}}", "a11y/Remove edit": "Eliminar edición", "a11y/Remove reply": "Eliminar respuesta", + "a11y/Replying to {{user}}": "Respondiendo a {{user}}", + "a11y/Replying to {{user}}: {{text}}": "Respondiendo a {{user}}: {{text}}", "a11y/Scroll to bottom": "Ir al final", "a11y/Scroll to bottom, {{count}} new messages": "Ir al final, {{count}} mensajes nuevos", "a11y/Scroll to latest": "Ir al último mensaje", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index f8d783f2bd..fa9de49e92 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "Avatar de {{name}}", "a11y/Connected": "Connecté", "a11y/Delivered": "Distribué", + "a11y/Editing message": "Modification du message", + "a11y/Editing message: {{text}}": "Modification du message : {{text}}", "a11y/Loading": "Chargement", "a11y/Loading failed": "Échec du chargement", "a11y/Message actions": "Actions du message", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "Répondre à {{user}}", "a11y/Remove edit": "Supprimer la modification", "a11y/Remove reply": "Supprimer la réponse", + "a11y/Replying to {{user}}": "Réponse à {{user}}", + "a11y/Replying to {{user}}: {{text}}": "Réponse à {{user}} : {{text}}", "a11y/Scroll to bottom": "Aller en bas", "a11y/Scroll to bottom, {{count}} new messages": "Aller en bas, {{count}} nouveaux messages", "a11y/Scroll to latest": "Aller au dernier message", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index dfd5d4908c..5dbc661432 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "תמונת פרופיל של {{name}}", "a11y/Connected": "מחובר", "a11y/Delivered": "נמסר", + "a11y/Editing message": "עריכת הודעה", + "a11y/Editing message: {{text}}": "עריכת הודעה: {{text}}", "a11y/Loading": "טוען", "a11y/Loading failed": "הטעינה נכשלה", "a11y/Message actions": "פעולות הודעה", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "השב ל-{{user}}", "a11y/Remove edit": "הסר עריכה", "a11y/Remove reply": "הסר תגובה", + "a11y/Replying to {{user}}": "מגיב/ה ל-{{user}}", + "a11y/Replying to {{user}}: {{text}}": "מגיב/ה ל-{{user}}: {{text}}", "a11y/Scroll to bottom": "גלול לתחתית", "a11y/Scroll to bottom, {{count}} new messages": "גלול לתחתית, {{count}} הודעות חדשות", "a11y/Scroll to latest": "גלול להודעה האחרונה", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index cf458d0aef..3d9c1c2029 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "{{name}} का अवतार", "a11y/Connected": "कनेक्टेड", "a11y/Delivered": "डिलीवर हुआ", + "a11y/Editing message": "संदेश संपादित कर रहे हैं", + "a11y/Editing message: {{text}}": "संदेश संपादित कर रहे हैं: {{text}}", "a11y/Loading": "लोड हो रहा है", "a11y/Loading failed": "लोड नहीं हो सका", "a11y/Message actions": "संदेश की कार्रवाइयां", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "{{user}} को जवाब दें", "a11y/Remove edit": "संपादन हटाएं", "a11y/Remove reply": "जवाब हटाएं", + "a11y/Replying to {{user}}": "{{user}} को उत्तर दे रहे हैं", + "a11y/Replying to {{user}}: {{text}}": "{{user}} को उत्तर दे रहे हैं: {{text}}", "a11y/Scroll to bottom": "नीचे स्क्रॉल करें", "a11y/Scroll to bottom, {{count}} new messages": "नीचे स्क्रॉल करें, {{count}} नए संदेश", "a11y/Scroll to latest": "नवीनतम संदेश पर जाएं", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index c6319c1f85..548f43f18e 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "Avatar di {{name}}", "a11y/Connected": "Connesso", "a11y/Delivered": "Consegnato", + "a11y/Editing message": "Modifica del messaggio", + "a11y/Editing message: {{text}}": "Modifica del messaggio: {{text}}", "a11y/Loading": "Caricamento", "a11y/Loading failed": "Caricamento non riuscito", "a11y/Message actions": "Azioni del messaggio", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "Rispondi a {{user}}", "a11y/Remove edit": "Rimuovi modifica", "a11y/Remove reply": "Rimuovi risposta", + "a11y/Replying to {{user}}": "Rispondendo a {{user}}", + "a11y/Replying to {{user}}: {{text}}": "Rispondendo a {{user}}: {{text}}", "a11y/Scroll to bottom": "Vai in fondo", "a11y/Scroll to bottom, {{count}} new messages": "Vai in fondo, {{count}} nuovi messaggi", "a11y/Scroll to latest": "Vai al messaggio più recente", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 7dcc12459f..e1d00c9635 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "{{name}}のアバター", "a11y/Connected": "接続済み", "a11y/Delivered": "配信済み", + "a11y/Editing message": "メッセージを編集中", + "a11y/Editing message: {{text}}": "メッセージを編集中: {{text}}", "a11y/Loading": "読み込み中", "a11y/Loading failed": "読み込みに失敗しました", "a11y/Message actions": "メッセージの操作", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "{{user}}に返信", "a11y/Remove edit": "編集を削除", "a11y/Remove reply": "返信を削除", + "a11y/Replying to {{user}}": "{{user}}に返信中", + "a11y/Replying to {{user}}: {{text}}": "{{user}}に返信中: {{text}}", "a11y/Scroll to bottom": "一番下へ移動", "a11y/Scroll to bottom, {{count}} new messages": "一番下へ移動、新しいメッセージ{{count}}件", "a11y/Scroll to latest": "最新のメッセージへ移動", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 8de6cf83ee..623589cf9c 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "{{name}}의 아바타", "a11y/Connected": "연결됨", "a11y/Delivered": "전달됨", + "a11y/Editing message": "메시지 편집 중", + "a11y/Editing message: {{text}}": "메시지 편집 중: {{text}}", "a11y/Loading": "로드 중", "a11y/Loading failed": "로드 실패", "a11y/Message actions": "메시지 작업", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "{{user}}님에게 답장", "a11y/Remove edit": "편집 제거", "a11y/Remove reply": "답장 제거", + "a11y/Replying to {{user}}": "{{user}}님에게 답장 중", + "a11y/Replying to {{user}}: {{text}}": "{{user}}님에게 답장 중: {{text}}", "a11y/Scroll to bottom": "맨 아래로 이동", "a11y/Scroll to bottom, {{count}} new messages": "맨 아래로 이동, 새 메시지 {{count}}개", "a11y/Scroll to latest": "최신 메시지로 이동", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index e37f37f2a2..1dd0abf847 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "Avatar van {{name}}", "a11y/Connected": "Verbonden", "a11y/Delivered": "Afgeleverd", + "a11y/Editing message": "Bericht bewerken", + "a11y/Editing message: {{text}}": "Bericht bewerken: {{text}}", "a11y/Loading": "Laden", "a11y/Loading failed": "Laden mislukt", "a11y/Message actions": "Berichtacties", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "Antwoorden op {{user}}", "a11y/Remove edit": "Bewerking verwijderen", "a11y/Remove reply": "Antwoord verwijderen", + "a11y/Replying to {{user}}": "Antwoorden op {{user}}", + "a11y/Replying to {{user}}: {{text}}": "Antwoorden op {{user}}: {{text}}", "a11y/Scroll to bottom": "Ga naar beneden", "a11y/Scroll to bottom, {{count}} new messages": "Ga naar beneden, {{count}} nieuwe berichten", "a11y/Scroll to latest": "Ga naar het nieuwste bericht", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index c150ddc0b4..a88d4d2261 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "Avatar de {{name}}", "a11y/Connected": "Conectado", "a11y/Delivered": "Entregue", + "a11y/Editing message": "Editando mensagem", + "a11y/Editing message: {{text}}": "Editando mensagem: {{text}}", "a11y/Loading": "Carregando", "a11y/Loading failed": "Falha ao carregar", "a11y/Message actions": "Ações da mensagem", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "Responder a {{user}}", "a11y/Remove edit": "Remover edição", "a11y/Remove reply": "Remover resposta", + "a11y/Replying to {{user}}": "Respondendo a {{user}}", + "a11y/Replying to {{user}}: {{text}}": "Respondendo a {{user}}: {{text}}", "a11y/Scroll to bottom": "Ir para o final", "a11y/Scroll to bottom, {{count}} new messages": "Ir para o final, {{count}} novas mensagens", "a11y/Scroll to latest": "Ir para a mensagem mais recente", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index c52c31c4a6..218282ab6b 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "Аватар {{name}}", "a11y/Connected": "Подключено", "a11y/Delivered": "Доставлено", + "a11y/Editing message": "Редактирование сообщения", + "a11y/Editing message: {{text}}": "Редактирование сообщения: {{text}}", "a11y/Loading": "Загрузка", "a11y/Loading failed": "Не удалось загрузить", "a11y/Message actions": "Действия с сообщением", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "Ответить {{user}}", "a11y/Remove edit": "Удалить редактирование", "a11y/Remove reply": "Удалить ответ", + "a11y/Replying to {{user}}": "Ответ {{user}}", + "a11y/Replying to {{user}}: {{text}}": "Ответ {{user}}: {{text}}", "a11y/Scroll to bottom": "Прокрутить вниз", "a11y/Scroll to bottom, {{count}} new messages": "Прокрутить вниз, {{count}} новых сообщений", "a11y/Scroll to latest": "Перейти к последнему сообщению", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index d05558b4f0..427e58bf86 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -255,6 +255,8 @@ "a11y/Avatar of {{name}}": "{{name}} avatarı", "a11y/Connected": "Bağlandı", "a11y/Delivered": "Teslim edildi", + "a11y/Editing message": "Mesaj düzenleniyor", + "a11y/Editing message: {{text}}": "Mesaj düzenleniyor: {{text}}", "a11y/Loading": "Yükleniyor", "a11y/Loading failed": "Yükleme başarısız", "a11y/Message actions": "Mesaj eylemleri", @@ -267,6 +269,8 @@ "a11y/Reply to {{user}}": "{{user}} kullanıcısına yanıt ver", "a11y/Remove edit": "Düzenlemeyi kaldır", "a11y/Remove reply": "Yanıtı kaldır", + "a11y/Replying to {{user}}": "{{user}} kullanıcısına yanıt veriliyor", + "a11y/Replying to {{user}}: {{text}}": "{{user}} kullanıcısına yanıt veriliyor: {{text}}", "a11y/Scroll to bottom": "En alta git", "a11y/Scroll to bottom, {{count}} new messages": "En alta git, {{count}} yeni mesaj", "a11y/Scroll to latest": "En son mesaja git", From 8aa007eadf26688aad6e9adf35190d5f9ab7b90d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 6 Jun 2026 03:03:04 +0200 Subject: [PATCH 3/9] fix: channel preview accessibility and add composite component probe --- .../CompositeAccessibilityProbe.tsx | 58 ++++++++++++++++ package/src/components/Accessibility/index.ts | 1 + .../ChannelMessagePreviewDeliveryStatus.tsx | 7 +- .../ChannelPreview/ChannelPreviewStatus.tsx | 14 ++-- .../ChannelPreviewUnreadCount.tsx | 6 +- .../ChannelPreview/ChannelPreviewView.tsx | 3 + .../components/ui/Avatar/ChannelAvatar.tsx | 68 +++++++++++-------- .../components/ui/Badge/BadgeNotification.tsx | 13 +++- package/src/i18n/ar.json | 7 +- package/src/i18n/en.json | 7 +- package/src/i18n/es.json | 7 +- package/src/i18n/fr.json | 7 +- package/src/i18n/he.json | 7 +- package/src/i18n/hi.json | 7 +- package/src/i18n/it.json | 7 +- package/src/i18n/ja.json | 7 +- package/src/i18n/ko.json | 7 +- package/src/i18n/nl.json | 7 +- package/src/i18n/pt-br.json | 7 +- package/src/i18n/ru.json | 7 +- package/src/i18n/tr.json | 7 +- 21 files changed, 211 insertions(+), 50 deletions(-) create mode 100644 package/src/components/Accessibility/CompositeAccessibilityProbe.tsx diff --git a/package/src/components/Accessibility/CompositeAccessibilityProbe.tsx b/package/src/components/Accessibility/CompositeAccessibilityProbe.tsx new file mode 100644 index 0000000000..4f52b6ad51 --- /dev/null +++ b/package/src/components/Accessibility/CompositeAccessibilityProbe.tsx @@ -0,0 +1,58 @@ +import React, { PropsWithChildren } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +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 `` does this). A zero size accessible + * `` 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) => { + if (!label) return <>{children}; + + return ( + <> + + {''} + + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + hiddenA11yText: { + height: 1, + opacity: 0, + overflow: 'hidden', + position: 'absolute', + width: 1, + }, +}); diff --git a/package/src/components/Accessibility/index.ts b/package/src/components/Accessibility/index.ts index 14d86325c1..42b8711249 100644 --- a/package/src/components/Accessibility/index.ts +++ b/package/src/components/Accessibility/index.ts @@ -1,3 +1,4 @@ +export * from './CompositeAccessibilityProbe'; export * from './NotificationAnnouncer'; export * from './useAccessibilityAnnouncer'; export * from './hooks/useIncomingMessageAnnouncements'; diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx index 642fc565ca..2468c2ae5b 100644 --- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx @@ -83,7 +83,12 @@ export const ChannelMessagePreviewDeliveryStatus = ({ } return ( - + {message.status === MessageStatusTypes.SENDING ? (