Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/SampleApp/src/components/ScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export const ScreenHeader: React.FC<ScreenHeaderProps> = (props) => {
) : (
!!titleText && (
<Text
accessibilityRole='header'
style={[
styles.title,
{
Expand Down
10 changes: 9 additions & 1 deletion examples/SampleApp/src/screens/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { useChannelMembersStatus } from '../hooks/useChannelMembersStatus';
import type { StackNavigatorParamList } from '../types';
import { channelMessageActions } from '../utils/messageActions.tsx';
import { useCreateDraftFocusEffect } from '../utils/useCreateDraftFocusEffect.tsx';
import { useScreenReaderComposerFocusEffect } from '../utils/useScreenReaderComposerFocusEffect.tsx';
// import { CustomAttachmentPickerSelectionBar } from '../components/AttachmentPickerSelectionBar.tsx';

export type ChannelScreenNavigationProp = NativeStackNavigationProp<
Expand Down Expand Up @@ -155,6 +156,8 @@ export const ChannelScreen: React.FC<ChannelScreenProps> = ({ navigation, route
setSelectedThread(undefined);
});

const { setInputRef } = useScreenReaderComposerFocusEffect();

const onPressMessage: NonNullable<React.ComponentProps<typeof Channel>['onPressMessage']> = (
payload,
) => {
Expand Down Expand Up @@ -259,10 +262,15 @@ export const ChannelScreen: React.FC<ChannelScreenProps> = ({ navigation, route
}

return (
<View style={[styles.flex, { backgroundColor: 'transparent' }]}>
<View
collapsable={false}
onAccessibilityEscape={() => navigation.goBack()}
style={[styles.flex, { backgroundColor: 'transparent' }]}
>
<Channel
audioRecordingEnabled={true}
channel={channel}
setInputRef={setInputRef}
messageInputFloating={messageInputFloating}
onPressMessage={onPressMessage}
initialScrollToFirstUnreadMessage
Expand Down
10 changes: 9 additions & 1 deletion examples/SampleApp/src/screens/ThreadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useLegacyColors } from '../theme/useLegacyColors';
import type { StackNavigatorParamList } from '../types';
import { channelMessageActions } from '../utils/messageActions.tsx';
import { useCreateDraftFocusEffect } from '../utils/useCreateDraftFocusEffect.tsx';
import { useScreenReaderComposerFocusEffect } from '../utils/useScreenReaderComposerFocusEffect.tsx';

// import { CustomAttachmentPickerSelectionBar } from '../components/AttachmentPickerSelectionBar.tsx';

Expand Down Expand Up @@ -83,6 +84,8 @@ export const ThreadScreen: React.FC<ThreadScreenProps> = ({ navigation, route })
const { setThread } = useStreamChatContext();
const { messageInputFloating, messageListImplementation } = useAppContext();

const { setInputRef } = useScreenReaderComposerFocusEffect();

const onPressMessage: NonNullable<React.ComponentProps<typeof Channel>['onPressMessage']> = (
payload,
) => {
Expand Down Expand Up @@ -142,10 +145,15 @@ export const ThreadScreen: React.FC<ThreadScreenProps> = ({ navigation, route })
);

return (
<View style={[styles.container, { backgroundColor: white }]}>
<View
collapsable={false}
onAccessibilityEscape={() => navigation.goBack()}
style={[styles.container, { backgroundColor: white }]}
>
<Channel
audioRecordingEnabled={true}
channel={channel}
setInputRef={setInputRef}
keyboardVerticalOffset={0}
messageActions={messageActions}
messageInputFloating={messageInputFloating}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useCallback, useRef } from 'react';
import { TextInput } from 'react-native';

import { ParamListBase, useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSetAccessibilityFocus } from 'stream-chat-react-native';

/**
* Lands the screen-reader cursor on the message composer input when this screen
* is entered, without opening the keyboard or activating the field (the user
* still double taps to type).
*
* Complements the SDK's builtin focus on mount (`useScreenReaderMountFocus` in
* MessageComposer), which lands Android forward navigation. This handles the
* cases mount can't: back navigation on both platforms and some iOS quirks,
* by firing on the native stack `transitionEnd` event, once the screen is the
* settled, active accessibility layer (a focus set mid transition on iOS races the
* OS's own focus pass and is dropped). `transitionEnd` fires on push and pop reveal.
*/
export const useScreenReaderComposerFocusEffect = () => {
const inputRef = useRef<TextInput | null>(null);
const setAccessibilityFocus = useSetAccessibilityFocus();
const navigation = useNavigation<NativeStackNavigationProp<ParamListBase>>();

const setInputRef = useCallback((ref: TextInput | null) => {
inputRef.current = ref;
}, []);

useFocusEffect(
useCallback(
() =>
navigation.addListener('transitionEnd', (e) => {
if (!e.data.closing) {
setAccessibilityFocus(inputRef);
}
}),
[navigation, setAccessibilityFocus],
),
);

return { setInputRef };
};
21 changes: 21 additions & 0 deletions package/src/a11y/hooks/useScreenReaderMountFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect } from 'react';

import { useScreenReaderEnabled } from './useScreenReaderEnabled';
import {
type AccessibilityFocusTarget,
useSetAccessibilityFocus,
} from './useSetAccessibilityFocus';

/**
* Moves the screen reader cursor onto `target` as it mounts (and refires once the
* screen reader state resolves, since that is detected asynchronously). Like
* {@link useSetAccessibilityFocus}, it only moves accessibility focus.
*/
export const useScreenReaderMountFocus = (target: AccessibilityFocusTarget) => {
const setAccessibilityFocus = useSetAccessibilityFocus();
const screenReaderEnabled = useScreenReaderEnabled();

useEffect(() => {
setAccessibilityFocus(target);
}, [screenReaderEnabled, setAccessibilityFocus, target]);
};
80 changes: 80 additions & 0 deletions package/src/a11y/hooks/useSetAccessibilityFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { type RefObject, useCallback, useEffect, useRef } from 'react';
import { AccessibilityInfo, findNodeHandle } from 'react-native';

import { useScreenReaderEnabled } from './useScreenReaderEnabled';

type FindNodeHandleArg = Parameters<typeof findNodeHandle>[0];

/**
* Something the screen reader cursor can be moved onto: a ref (whose `.current` is
* read at call time), a raw native node handle, or nothing.
*/
export type AccessibilityFocusTarget = RefObject<unknown> | number | null | undefined;

const resolveNode = (target: AccessibilityFocusTarget): number | null => {
if (target == null) {
return null;
}
if (typeof target === 'number') {
return target;
}
const current = target.current;
return current == null ? null : findNodeHandle(current as FindNodeHandleArg);
};

/**
* Returns a stable callback that moves the screen reader cursor onto a given
* target (a ref or a raw node handle). It ONLY moves accessibility focus and it
* does not activate the target: i.e no keyboard opens and no field becomes first
* responder. The user still double taps to act on whatever is focused.
*
* The target's `.current` is read at call time, so this is safe to wire into a
* deferred/event callback.
*
* Noop unless a screen reader is running. {@link useScreenReaderEnabled} returns
* false whenever the SDK's accessibility config is disabled, so this costs nothing
* for the default (a11y opted out) configuration.
*
* Note: Navigation timing is platform nuanced: Android forward navigation lands a focus
* set on mount, whereas iOS forward navigation and back navigation on both
* platforms need it set after the screen transition completes (React Navigation's
* `transitionEnd`). {@link useScreenReaderMountFocus} covers the mount case; wire
* `transitionEnd` yourself for the rest (the SampleApp's
* `useScreenReaderComposerFocusEffect` is a reference implementation).
*/
export const useSetAccessibilityFocus = () => {
const screenReaderEnabled = useScreenReaderEnabled();
// Track the latest value so the returned callback can stay referentially stable
// (it's typically wired into a focus effect and read much later).
const screenReaderEnabledRef = useRef(screenReaderEnabled);
screenReaderEnabledRef.current = screenReaderEnabled;

const rafRef = useRef<number | null>(null);

useEffect(
() => () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
}
},
[],
);

return useCallback((target: AccessibilityFocusTarget) => {
if (!screenReaderEnabledRef.current) {
return;
}
const node = resolveNode(target);
if (node == null) {
return;
}
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
}
// Defer a frame so the target has laid out before we move the cursor onto it.
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
AccessibilityInfo.setAccessibilityFocus(node);
});
}, []);
};
2 changes: 2 additions & 0 deletions package/src/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './a11yUtils';
export * from './hooks/useScreenReaderEnabled';
export * from './hooks/useSetAccessibilityFocus';
export * from './hooks/useScreenReaderMountFocus';
export * from './hooks/useAccessibilityServiceEnabled';
export * from './hooks/useReducedMotionPreference';
export * from './hooks/useResolvedModalAccessibilityProps';
Expand Down
5 changes: 5 additions & 0 deletions package/src/components/MessageInput/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { MicPositionProvider } from './contexts/MicPositionContext';

import { audioRecorderSelector } from './utils/audioRecorderSelectors';

import { useScreenReaderMountFocus } from '../../a11y';

import {
ChatContextValue,
useAttachmentPickerContext,
Expand Down Expand Up @@ -287,6 +289,9 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => {
return result;
};

// immediately focus the screen reader to the input on mount if a11y is enabled.
useScreenReaderMountFocus(inputBoxRef);

const isFocused = inputBoxRef.current?.isFocused();

const micPositionX = useSharedValue(0);
Expand Down
Loading