Skip to content

Commit 439776a

Browse files
authored
fix: a11y actionable items fixes (#3628)
## 🎯 Goal This PR should fix some outstanding `a11y` issues for screen readers. The prior foundation work (`a11y` context, `useA11yLabel`, `useAccessibilityAnnouncer`, `useResolvedModalAccessibilityProps`, translation namespace, etc.) shipped the primitives. This PR uses those primitives to fix the surfaces flagged by an audit - polls, message context menus, and bottom sheets. ## 🛠 Implementation details Five focused changes, each scoped to one surface: Poll - actionable rotor (`Poll.tsx`, new `usePollAccessibilityActions`/`usePollAccessibilityLabel`/`PollUIStateContext`) - Voting, ending the vote, and "show all options" are now exposed as standard accessibility actions. Screen reader users reach each interaction via the rotor/actions menu instead of having to hunt for the right subelement - `PollUIStateContext` carries state across the poll's modals (vote / see results / end vote confirm) so the available action set stays correct as the user navigates (we do this so that we have one source of truth for firing these actions) Poll & Message - context menu hint - `accessibilityHint='a11y/Double tap and hold to activate contextual menu'` on both `MessageContent` and `Poll`, so screen reader users know a long-press menu is available before triggering it Message overlay - open announcement (`MessageOverlayHostLayer.tsx`) - One shot `announceForAccessibility('Swipe right to go through different actions')` when the overlay activates. `useRef` dedupes so it speaks once per open and resets on close BottomSheetModal - modal trait + accessible dismiss (`BottomSheetModal.tsx`) - Drag handle is now a focusable element labeled "Close" with `accessibilityRole='button'` - iOS `VoiceOver` double tap goes into `onAccessibilityTap` which goes into `onClose` - Android `TalkBack` double tap goes into `accessibilityActions=[{name:'activate'}]` + `onAccessibilityAction` which then goes into `onClose` as well - iOS two-finger Z scrub triggers `onAccessibilityEscape={onClose}`. Android hardware back was already wired through `Modal.onRequestClose`. - One shot open announcement, deferred `800ms` so `VoiceOver`'s auto focus shift speech ("Close, button") doesn't preempt the announce mid sentence. - Lifts to every consumer automatically: Reactions detail, "More reactions" emoji picker, Attachment type picker, Image gallery grid view. **Translations** - Five new `a11y/*` keys (Bottom sheet opened…, Close, Double tap and hold to activate contextual menu, Swipe right to go through different actions, plus the poll action labels) added to all 13 locales. ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 0447a70 commit 439776a

28 files changed

Lines changed: 1197 additions & 99 deletions

package/src/components/Message/MessageItemView/MessageContent.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ColorValue, Pressable, StyleSheet, View, ViewStyle } from 'react-native
33

44
import { MessageTextContainer } from './MessageTextContainer';
55

6+
import { useA11yLabel } from '../../../a11y/hooks/useA11yLabel';
67
import { useChatContext } from '../../../contexts';
78
import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext';
89
import {
@@ -127,6 +128,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
127128
hidePaddingBottom,
128129
} = props;
129130
const { client } = useChatContext();
131+
const accessibilityHint = useA11yLabel('a11y/Double tap and hold to activate contextual menu');
130132
const {
131133
Attachment,
132134
FileAttachmentGroup,
@@ -318,6 +320,8 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
318320

319321
return (
320322
<Pressable
323+
accessibilityHint={accessibilityHint}
324+
accessible={message.poll_id ? false : undefined}
321325
disabled={preventPress}
322326
onLongPress={(event) => {
323327
if (onLongPress) {

package/src/components/Poll/Poll.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@ import { StyleSheet, Text, View } from 'react-native';
44
import { PollOption as PollOptionClass } from 'stream-chat';
55

66
import { PollOption, ShowAllOptionsButton } from './components';
7+
import { PollUIStateProvider } from './contexts/PollUIStateContext';
78

9+
import { usePollAccessibilityActions } from './hooks/usePollAccessibilityActions';
10+
import { usePollAccessibilityLabel } from './hooks/usePollAccessibilityLabel';
811
import { usePollState } from './hooks/usePollState';
912

13+
import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
1014
import {
1115
PollContextProvider,
1216
PollContextValue,
1317
useTheme,
1418
useTranslationContext,
1519
} from '../../contexts';
20+
import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext';
1621
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
1722

1823
import { primitives } from '../../theme';
@@ -61,6 +66,10 @@ export const PollContent = () => {
6166
const styles = useStyles();
6267
const { PollButtons: PollButtonsComponent, PollHeader: PollHeaderComponent } =
6368
useComponentsContext();
69+
const { enabled: a11yEnabled } = useAccessibilityContext();
70+
const accessibilityHint = useA11yLabel('a11y/Double tap and hold to activate contextual menu');
71+
const accessibilityLabel = usePollAccessibilityLabel();
72+
const { accessibilityActions, onAccessibilityAction } = usePollAccessibilityActions();
6473

6574
const {
6675
theme: {
@@ -70,8 +79,24 @@ export const PollContent = () => {
7079
},
7180
} = useTheme();
7281

82+
// NOTE: Android custom accessibilityActions are broken in RN < 0.83.2 —
83+
// see facebook/react-native#47268, fixed by PR #52724. On affected versions
84+
// the actions menu surfaces only a subset of the list and dispatch
85+
// announces "Action not supported". iOS works correctly on all versions.
86+
// Once the SDK's minimum RN reaches 0.83.2, wrap the descendants below in
87+
// <View importantForAccessibility='no-hide-descendants'> so Android
88+
// TalkBack groups them under the composite rather than exposing each
89+
// interactive child as a separate focus stop.
7390
return (
74-
<View style={[styles.container, container]}>
91+
<View
92+
accessibilityActions={accessibilityActions}
93+
accessibilityHint={accessibilityHint}
94+
accessibilityLabel={accessibilityLabel}
95+
accessibilityRole={a11yEnabled ? 'button' : undefined}
96+
accessible={a11yEnabled || undefined}
97+
onAccessibilityAction={onAccessibilityAction}
98+
style={[styles.container, container]}
99+
>
75100
<PollHeaderComponent />
76101
<View style={[styles.optionsWrapper, optionsWrapper]}>
77102
{options?.slice(0, defaultPollOptionCount)?.map((option: PollOptionClass) => (
@@ -93,7 +118,9 @@ export const Poll = ({ message, poll }: PollProps) => {
93118
poll,
94119
}}
95120
>
96-
{PollContentOverride ? <PollContentOverride /> : <PollContent />}
121+
<PollUIStateProvider>
122+
{PollContentOverride ? <PollContentOverride /> : <PollContent />}
123+
</PollUIStateProvider>
97124
</PollContextProvider>
98125
);
99126
};

package/src/components/Poll/components/PollButtons.tsx

Lines changed: 37 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useMemo, useState } from 'react';
1+
import React, { useCallback, useMemo } from 'react';
22
import { Modal, StyleSheet, View } from 'react-native';
33
import { GestureHandlerRootView } from 'react-native-gesture-handler';
44

@@ -13,13 +13,22 @@ import { useChatContext, usePollContext, useTheme, useTranslationContext } from
1313
import { primitives } from '../../../theme';
1414
import { defaultPollOptionCount } from '../../../utils/constants';
1515
import { SafeAreaViewWrapper } from '../../UIComponents/SafeAreaViewWrapper';
16+
import {
17+
useAddCommentOpen,
18+
useAllCommentsOpen,
19+
useAllOptionsOpen,
20+
usePollUIStateContext,
21+
useSuggestOptionOpen,
22+
useViewResultsOpen,
23+
} from '../contexts/PollUIStateContext';
1624
import { useIsPollCreatedByCurrentUser } from '../hook/useIsPollCreatedByCurrentUser';
1725
import { usePollState } from '../hooks/usePollState';
1826

1927
export const ViewResultsButton = (props: PollButtonProps) => {
2028
const { t } = useTranslationContext();
2129
const { message, poll } = usePollContext();
22-
const [showResults, setShowResults] = useState(false);
30+
const { closeViewResults, openViewResults } = usePollUIStateContext();
31+
const showResults = useViewResultsOpen();
2332
const { onPress } = props;
2433

2534
const onPressHandler = useCallback(() => {
@@ -28,15 +37,11 @@ export const ViewResultsButton = (props: PollButtonProps) => {
2837
return;
2938
}
3039

31-
setShowResults(true);
32-
}, [message, onPress, poll]);
40+
openViewResults();
41+
}, [message, onPress, openViewResults, poll]);
3342

3443
const styles = useStyles();
3544

36-
const onRequestClose = useCallback(() => {
37-
setShowResults(false);
38-
}, []);
39-
4045
return (
4146
<>
4247
<GenericPollButton
@@ -46,10 +51,10 @@ export const ViewResultsButton = (props: PollButtonProps) => {
4651
type='outline'
4752
/>
4853
{showResults ? (
49-
<Modal animationType='slide' onRequestClose={onRequestClose} visible={showResults}>
54+
<Modal animationType='slide' onRequestClose={closeViewResults} visible={showResults}>
5055
<GestureHandlerRootView style={styles.modalRoot}>
5156
<SafeAreaViewWrapper style={styles.safeArea}>
52-
<PollModalHeader onPress={onRequestClose} title={t('Poll Results')} />
57+
<PollModalHeader onPress={closeViewResults} title={t('Poll Results')} />
5358
<PollResults message={message} poll={poll} />
5459
</SafeAreaViewWrapper>
5560
</GestureHandlerRootView>
@@ -61,7 +66,8 @@ export const ViewResultsButton = (props: PollButtonProps) => {
6166

6267
export const ShowAllOptionsButton = (props: PollButtonProps) => {
6368
const { t } = useTranslationContext();
64-
const [showAllOptions, setShowAllOptions] = useState(false);
69+
const { closeAllOptions, openAllOptions } = usePollUIStateContext();
70+
const showAllOptions = useAllOptionsOpen();
6571
const { message, poll } = usePollContext();
6672
const { options } = usePollState();
6773
const { onPress } = props;
@@ -72,12 +78,8 @@ export const ShowAllOptionsButton = (props: PollButtonProps) => {
7278
return;
7379
}
7480

75-
setShowAllOptions(true);
76-
}, [message, onPress, poll]);
77-
78-
const onRequestClose = useCallback(() => {
79-
setShowAllOptions(false);
80-
}, []);
81+
openAllOptions();
82+
}, [message, onPress, openAllOptions, poll]);
8183

8284
const styles = useStyles();
8385

@@ -90,10 +92,10 @@ export const ShowAllOptionsButton = (props: PollButtonProps) => {
9092
/>
9193
) : null}
9294
{showAllOptions ? (
93-
<Modal animationType='slide' onRequestClose={onRequestClose} visible={showAllOptions}>
95+
<Modal animationType='slide' onRequestClose={closeAllOptions} visible={showAllOptions}>
9496
<GestureHandlerRootView style={styles.modalRoot}>
9597
<SafeAreaViewWrapper style={styles.safeArea}>
96-
<PollModalHeader onPress={onRequestClose} title={t('Poll Options')} />
98+
<PollModalHeader onPress={closeAllOptions} title={t('Poll Options')} />
9799
<PollAllOptions message={message} poll={poll} />
98100
</SafeAreaViewWrapper>
99101
</GestureHandlerRootView>
@@ -107,7 +109,8 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => {
107109
const { t } = useTranslationContext();
108110
const { message, poll } = usePollContext();
109111
const { answersCount } = usePollState();
110-
const [showAnswers, setShowAnswers] = useState(false);
112+
const { closeAllComments, openAllComments } = usePollUIStateContext();
113+
const showAnswers = useAllCommentsOpen();
111114
const { onPress } = props;
112115

113116
const onPressHandler = useCallback(() => {
@@ -116,15 +119,11 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => {
116119
return;
117120
}
118121

119-
setShowAnswers(true);
120-
}, [message, onPress, poll]);
122+
openAllComments();
123+
}, [message, onPress, openAllComments, poll]);
121124

122125
const styles = useStyles();
123126

124-
const onRequestClose = useCallback(() => {
125-
setShowAnswers(false);
126-
}, []);
127-
128127
return (
129128
<>
130129
{answersCount && answersCount > 0 ? (
@@ -134,10 +133,10 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => {
134133
/>
135134
) : null}
136135
{showAnswers ? (
137-
<Modal animationType='slide' onRequestClose={onRequestClose} visible={showAnswers}>
136+
<Modal animationType='slide' onRequestClose={closeAllComments} visible={showAnswers}>
138137
<GestureHandlerRootView style={styles.modalRoot}>
139138
<SafeAreaViewWrapper style={styles.safeArea}>
140-
<PollModalHeader onPress={onRequestClose} title={t('Poll Comments')} />
139+
<PollModalHeader onPress={closeAllComments} title={t('Poll Comments')} />
141140
<PollAnswersList message={message} poll={poll} />
142141
</SafeAreaViewWrapper>
143142
</GestureHandlerRootView>
@@ -151,7 +150,8 @@ export const SuggestOptionButton = (props: PollButtonProps) => {
151150
const { t } = useTranslationContext();
152151
const { message, poll } = usePollContext();
153152
const { addOption, allowUserSuggestedOptions, isClosed } = usePollState();
154-
const [showAddOptionDialog, setShowAddOptionDialog] = useState(false);
153+
const { closeSuggestOption, openSuggestOption } = usePollUIStateContext();
154+
const showAddOptionDialog = useSuggestOptionOpen();
155155
const { onPress } = props;
156156

157157
const onPressHandler = useCallback(() => {
@@ -160,12 +160,8 @@ export const SuggestOptionButton = (props: PollButtonProps) => {
160160
return;
161161
}
162162

163-
setShowAddOptionDialog(true);
164-
}, [message, onPress, poll]);
165-
166-
const onRequestClose = useCallback(() => {
167-
setShowAddOptionDialog(false);
168-
}, []);
163+
openSuggestOption();
164+
}, [message, onPress, openSuggestOption, poll]);
169165

170166
return (
171167
<>
@@ -174,7 +170,7 @@ export const SuggestOptionButton = (props: PollButtonProps) => {
174170
) : null}
175171
{showAddOptionDialog ? (
176172
<PollInputDialog
177-
closeDialog={onRequestClose}
173+
closeDialog={closeSuggestOption}
178174
onSubmit={addOption}
179175
placeholder={t('Enter a new option')}
180176
title={t('Suggest an option')}
@@ -189,7 +185,8 @@ export const AddCommentButton = (props: PollButtonProps) => {
189185
const { t } = useTranslationContext();
190186
const { message, poll } = usePollContext();
191187
const { addComment, allowAnswers, isClosed, ownAnswer } = usePollState();
192-
const [showAddCommentDialog, setShowAddCommentDialog] = useState(false);
188+
const { closeAddComment, openAddComment } = usePollUIStateContext();
189+
const showAddCommentDialog = useAddCommentOpen();
193190
const { onPress } = props;
194191

195192
const onPressHandler = useCallback(() => {
@@ -198,12 +195,8 @@ export const AddCommentButton = (props: PollButtonProps) => {
198195
return;
199196
}
200197

201-
setShowAddCommentDialog(true);
202-
}, [message, onPress, poll]);
203-
204-
const onRequestClose = useCallback(() => {
205-
setShowAddCommentDialog(false);
206-
}, []);
198+
openAddComment();
199+
}, [message, onPress, openAddComment, poll]);
207200

208201
return (
209202
<>
@@ -212,7 +205,7 @@ export const AddCommentButton = (props: PollButtonProps) => {
212205
) : null}
213206
{showAddCommentDialog ? (
214207
<PollInputDialog
215-
closeDialog={onRequestClose}
208+
closeDialog={closeAddComment}
216209
initialValue={ownAnswer?.answer_text ?? ''}
217210
onSubmit={addComment}
218211
placeholder={t('Your comment')}

package/src/components/Poll/components/PollOption.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ import { useComponentsContext } from '../../../contexts/componentsContext/Compon
2020

2121
import { Check } from '../../../icons';
2222
import { primitives } from '../../../theme';
23-
import { useNotificationApi } from '../../Notifications';
2423
import { ProgressBar } from '../../ProgressControl/ProgressBar';
2524
import { UserAvatarStack } from '../../ui/Avatar/AvatarStack';
2625
import { useIsPollCreatedByCurrentUser } from '../hook/useIsPollCreatedByCurrentUser';
2726
import { usePollState } from '../hooks/usePollState';
27+
import { usePollVoteToggle } from '../hooks/usePollVoteToggle';
2828

2929
const pollVoteAccessibilityStates = {
3030
checked: { checked: true, selected: true },
@@ -161,7 +161,6 @@ export const PollOption = ({ option, showProgressBar = true, forceIncoming }: Po
161161
export const VoteButton = ({ onPress, option }: PollVoteButtonProps) => {
162162
const { message, poll } = usePollContext();
163163
const { isClosed, ownVotesByOptionId } = usePollState();
164-
const { runWithNotificationTarget } = useNotificationApi();
165164
const ownCapabilities = useOwnCapabilitiesContext();
166165
const {
167166
theme: { semantics },
@@ -179,24 +178,16 @@ export const VoteButton = ({ onPress, option }: PollVoteButtonProps) => {
179178
},
180179
} = useTheme();
181180

182-
const toggleVote = useCallback(async () => {
183-
await runWithNotificationTarget(async () => {
184-
if (ownVotesByOptionId[option.id]) {
185-
await poll.removeVote(ownVotesByOptionId[option.id]?.id, message.id);
186-
} else {
187-
await poll.castVote(option.id, message.id);
188-
}
189-
});
190-
}, [message.id, option.id, ownVotesByOptionId, poll, runWithNotificationTarget]);
181+
const toggleVote = usePollVoteToggle();
191182

192183
const onPressHandler = useCallback(() => {
193184
if (onPress) {
194185
onPress({ message, poll });
195186
return;
196187
}
197188

198-
toggleVote();
199-
}, [message, onPress, poll, toggleVote]);
189+
toggleVote(option.id);
190+
}, [message, onPress, option.id, poll, toggleVote]);
200191

201192
const hasVote = !!ownVotesByOptionId[option.id];
202193
const accessibilityState = hasVote

0 commit comments

Comments
 (0)