diff --git a/examples/SampleApp/src/components/ScreenHeader.tsx b/examples/SampleApp/src/components/ScreenHeader.tsx index 6e2f7113cc..4a5bd2c2e0 100644 --- a/examples/SampleApp/src/components/ScreenHeader.tsx +++ b/examples/SampleApp/src/components/ScreenHeader.tsx @@ -168,6 +168,7 @@ export const ScreenHeader: React.FC = (props) => { ) : ( !!titleText && ( = ({ navigation, route setSelectedThread(undefined); }); + const { setInputRef } = useScreenReaderComposerFocusEffect(); + const onPressMessage: NonNullable['onPressMessage']> = ( payload, ) => { @@ -259,10 +262,15 @@ export const ChannelScreen: React.FC = ({ navigation, route } return ( - + navigation.goBack()} + style={[styles.flex, { backgroundColor: 'transparent' }]} + > = ({ navigation, route }) const { setThread } = useStreamChatContext(); const { messageInputFloating, messageListImplementation } = useAppContext(); + const { setInputRef } = useScreenReaderComposerFocusEffect(); + const onPressMessage: NonNullable['onPressMessage']> = ( payload, ) => { @@ -142,10 +145,15 @@ export const ThreadScreen: React.FC = ({ navigation, route }) ); return ( - + navigation.goBack()} + style={[styles.container, { backgroundColor: white }]} + > { + const inputRef = useRef(null); + const setAccessibilityFocus = useSetAccessibilityFocus(); + const navigation = useNavigation>(); + + 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 }; +}; diff --git a/package/src/a11y/hooks/useScreenReaderMountFocus.ts b/package/src/a11y/hooks/useScreenReaderMountFocus.ts new file mode 100644 index 0000000000..9ecceaf39f --- /dev/null +++ b/package/src/a11y/hooks/useScreenReaderMountFocus.ts @@ -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]); +}; diff --git a/package/src/a11y/hooks/useSetAccessibilityFocus.ts b/package/src/a11y/hooks/useSetAccessibilityFocus.ts new file mode 100644 index 0000000000..11d0ac6e7b --- /dev/null +++ b/package/src/a11y/hooks/useSetAccessibilityFocus.ts @@ -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[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 | 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(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); + }); + }, []); +}; diff --git a/package/src/a11y/index.ts b/package/src/a11y/index.ts index 7febea9555..e534bf2521 100644 --- a/package/src/a11y/index.ts +++ b/package/src/a11y/index.ts @@ -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'; diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index a725177e70..ea19ec9a78 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -16,6 +16,8 @@ import { MicPositionProvider } from './contexts/MicPositionContext'; import { audioRecorderSelector } from './utils/audioRecorderSelectors'; +import { useScreenReaderMountFocus } from '../../a11y'; + import { ChatContextValue, useAttachmentPickerContext, @@ -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);