diff --git a/package/src/a11y/hooks/useAccessibilityServiceEnabled.ts b/package/src/a11y/hooks/useAccessibilityServiceEnabled.ts new file mode 100644 index 0000000000..58753d03a4 --- /dev/null +++ b/package/src/a11y/hooks/useAccessibilityServiceEnabled.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { AccessibilityInfo, AppState, Platform } from 'react-native'; + +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; + +/** + * Subscribes to Android accessibility service availability and returns the live state. + * + * `AccessibilityInfo.isAccessibilityServiceEnabled` reports whether ANY a11y service is + * running - TalkBack, Switch Access, Voice Access, Select to Speak, etc. not just + * screen readers. The signal is Android only; iOS doesn't expose it (and doesn't need to, + * since the SDK's iOS overlay trap uses `accessibilityViewIsModal`). + * + * React Native does not emit a dedicated change event for this signal, so the hook + * re-polls when the app returns to the foreground - which is the realistic path for a + * user toggling Accessibility settings and coming back to the app. The most common + * single service change (TalkBack on/off) is also covered by the `screenReaderChanged` + * listener. + * + * Returns false on non-Android platforms without subscribing. + * Returns false when the `AccessibilityContext` is disabled so consumers don't pay the + * listener cost when the SDK's a11y is opted out. + */ +export const useAccessibilityServiceEnabled = (): boolean => { + const { enabled } = useAccessibilityContext(); + const [isEnabled, setIsEnabled] = useState(false); + + useEffect(() => { + if (Platform.OS !== 'android' || !enabled) { + setIsEnabled(false); + return; + } + + let cancelled = false; + + const refresh = () => { + AccessibilityInfo.isAccessibilityServiceEnabled?.() + .then((value) => { + if (!cancelled) setIsEnabled(value); + }) + .catch(() => { + // Older RN versions / certain environments may not implement this; fall back to false. + }); + }; + + refresh(); + + const screenReaderSubscription = AccessibilityInfo.addEventListener( + 'screenReaderChanged', + refresh, + ); + + const appStateSubscription = AppState.addEventListener('change', (state) => { + if (state === 'active') refresh(); + }); + + return () => { + cancelled = true; + screenReaderSubscription?.remove?.(); + appStateSubscription?.remove?.(); + }; + }, [enabled]); + + if (Platform.OS !== 'android' || !enabled) return false; + return isEnabled; +}; diff --git a/package/src/a11y/index.ts b/package/src/a11y/index.ts index ffbd8b290f..7febea9555 100644 --- a/package/src/a11y/index.ts +++ b/package/src/a11y/index.ts @@ -1,5 +1,6 @@ export * from './a11yUtils'; export * from './hooks/useScreenReaderEnabled'; +export * from './hooks/useAccessibilityServiceEnabled'; export * from './hooks/useReducedMotionPreference'; export * from './hooks/useResolvedModalAccessibilityProps'; export * from './hooks/useAnnounceOnStateChange'; diff --git a/package/src/components/Accessibility/OverlayA11yShield.tsx b/package/src/components/Accessibility/OverlayA11yShield.tsx index 4c8857fdfc..f1f6f78879 100644 --- a/package/src/components/Accessibility/OverlayA11yShield.tsx +++ b/package/src/components/Accessibility/OverlayA11yShield.tsx @@ -1,6 +1,8 @@ import React, { PropsWithChildren } from 'react'; import { Platform, StyleSheet, View } from 'react-native'; +import { useAccessibilityServiceEnabled } from '../../a11y'; +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; import { useOverlayContext } from '../../contexts/overlayContext/OverlayContext'; import { useStateStore } from '../../hooks'; import { overlayStore } from '../../state-store/message-overlay-store'; @@ -9,6 +11,15 @@ const messageOverlayActiveSelector = (state: { id: string | undefined }) => ({ isMessageOverlayActive: state.id !== undefined, }); +const useShouldActivateTrap = (): boolean => { + const { enabled: a11yEnabled } = useAccessibilityContext(); + const { overlay } = useOverlayContext(); + const { isMessageOverlayActive } = useStateStore(overlayStore, messageOverlayActiveSelector); + const a11yServiceEnabled = useAccessibilityServiceEnabled(); + + return a11yEnabled && (overlay === 'gallery' || isMessageOverlayActive) && a11yServiceEnabled; +}; + /** * Android only accessibility focus trap for the OverlayProvider's children * tree. iOS handles modal focus traps natively via `accessibilityViewIsModal` @@ -27,20 +38,17 @@ const messageOverlayActiveSelector = (state: { id: string | undefined }) => ({ * On iOS the wrapper is skipped entirely. */ export function OverlayA11yShield({ children }: PropsWithChildren) { - const { overlay } = useOverlayContext(); - const { isMessageOverlayActive } = useStateStore(overlayStore, messageOverlayActiveSelector); + const shouldActivateTrap = useShouldActivateTrap(); if (Platform.OS !== 'android') { return <>{children}; } - const isAnyOverlayActive = overlay === 'gallery' || isMessageOverlayActive; - return ( {children} diff --git a/package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx b/package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx index 3da9bd7cfd..00eeda463a 100644 --- a/package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx +++ b/package/src/components/Accessibility/__tests__/OverlayA11yShield.test.tsx @@ -1,31 +1,45 @@ import React from 'react'; -import { Platform, Text } from 'react-native'; +import { AccessibilityInfo, Platform, Text } from 'react-native'; -import { act, render, screen } from '@testing-library/react-native'; +import { act, render, screen, waitFor } from '@testing-library/react-native'; +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; import { OverlayContext } from '../../../contexts/overlayContext/OverlayContext'; import { overlayStore } from '../../../state-store/message-overlay-store'; import { OverlayA11yShield } from '../OverlayA11yShield'; +jest.mock('react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn().mockReturnValue({ remove: jest.fn() }), + announceForAccessibility: jest.fn(), + isAccessibilityServiceEnabled: jest.fn().mockResolvedValue(true), + isReduceMotionEnabled: jest.fn().mockResolvedValue(false), + isScreenReaderEnabled: jest.fn().mockResolvedValue(false), + }, +})); + const setPlatform = (os: typeof Platform.OS) => { Object.defineProperty(Platform, 'OS', { configurable: true, get: () => os }); }; const renderShield = (overlay: 'none' | 'gallery' = 'none') => render( - undefined, - style: undefined, - } as never - } - > - - child - - , + + undefined, + style: undefined, + } as never + } + > + + child + + + , ); // The wrapper sets `accessibilityElementsHidden` / `importantForAccessibility` @@ -38,6 +52,7 @@ describe('OverlayA11yShield', () => { afterAll(() => setPlatform(originalOS)); beforeEach(() => { + (AccessibilityInfo.isAccessibilityServiceEnabled as jest.Mock).mockResolvedValue(true); act(() => { overlayStore.partialNext({ closing: false, id: undefined, messageId: undefined }); }); @@ -46,28 +61,48 @@ describe('OverlayA11yShield', () => { describe('on Android', () => { beforeAll(() => setPlatform('android')); - it('renders children inside the wrapper', () => { + it('renders children inside the wrapper', async () => { renderShield(); + await act(async () => { + await Promise.resolve(); + }); expect(screen.getByTestId('child')).toBeTruthy(); expect(wrapper()).toBeTruthy(); }); - it('does not hide descendants when no overlay is active', () => { + it('does not hide descendants when no overlay is active', async () => { renderShield('none'); - expect(wrapper()?.props.importantForAccessibility).toBe('auto'); + await act(async () => { + await Promise.resolve(); + }); + expect(wrapper()?.props.importantForAccessibility).toBeUndefined(); }); - it('hides descendants when the gallery overlay is active', () => { + it('hides descendants when the gallery overlay is active and an a11y service is on', async () => { renderShield('gallery'); - expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants'); + await waitFor(() => { + expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants'); + }); }); - it('hides descendants when the message overlay opens', () => { + it('hides descendants when the message overlay opens and an a11y service is on', async () => { renderShield('none'); act(() => { overlayStore.partialNext({ id: 'msg-1' }); }); - expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants'); + await waitFor(() => { + expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants'); + }); + }); + + it('does not flip the prop when no a11y service is running, even with an overlay active', async () => { + (AccessibilityInfo.isAccessibilityServiceEnabled as jest.Mock).mockResolvedValue(false); + renderShield('gallery'); + // Allow the async isAccessibilityServiceEnabled() resolution to settle. + await act(async () => { + await Promise.resolve(); + }); + expect(wrapper()?.props.importantForAccessibility).toBeUndefined(); }); }); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index ec92fba33a..b4b6107d60 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -108,7 +108,11 @@ export const OverlayProvider = (props: PropsWithChildren) - {children} + {accessibility?.enabled ? ( + {children} + ) : ( + children + )} {overlay === 'gallery' && }