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
66 changes: 66 additions & 0 deletions package/src/a11y/hooks/useAccessibilityServiceEnabled.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(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;
};
1 change: 1 addition & 0 deletions package/src/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
20 changes: 14 additions & 6 deletions package/src/components/Accessibility/OverlayA11yShield.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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`
Expand All @@ -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 (
<View
accessibilityElementsHidden={isAnyOverlayActive}
importantForAccessibility={isAnyOverlayActive ? 'no-hide-descendants' : 'auto'}
importantForAccessibility={shouldActivateTrap ? 'no-hide-descendants' : undefined}
style={StyleSheet.absoluteFill}
pointerEvents='box-none'
testID='overlay-a11y-shield'
>
{children}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<OverlayContext.Provider
value={
{
overlay,
setOverlay: () => undefined,
style: undefined,
} as never
}
>
<OverlayA11yShield>
<Text testID='child'>child</Text>
</OverlayA11yShield>
</OverlayContext.Provider>,
<AccessibilityProvider value={{ enabled: true }}>
<OverlayContext.Provider
value={
{
overlay,
setOverlay: () => undefined,
style: undefined,
} as never
}
>
<OverlayA11yShield>
<Text testID='child'>child</Text>
</OverlayA11yShield>
</OverlayContext.Provider>
</AccessibilityProvider>,
);

// The wrapper sets `accessibilityElementsHidden` / `importantForAccessibility`
Expand All @@ -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 });
});
Expand All @@ -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();
});
});

Expand Down
6 changes: 5 additions & 1 deletion package/src/contexts/overlayContext/OverlayProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ export const OverlayProvider = (props: PropsWithChildren<OverlayProviderProps>)
<ImageGalleryProvider value={imageGalleryProviderProps}>
<ThemeProvider style={overlayContext.style}>
<PortalProvider>
<OverlayA11yShield>{children}</OverlayA11yShield>
{accessibility?.enabled ? (
<OverlayA11yShield>{children}</OverlayA11yShield>
) : (
children
)}
{overlay === 'gallery' && <ImageGallery overlayOpacity={overlayOpacity} />}
<MessageOverlayHostLayer />
</PortalProvider>
Expand Down
Loading