From b8209f359c9cec88c8a8f2a547398edc1dd26102 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Wed, 6 May 2026 15:01:43 +0300 Subject: [PATCH] feat: add accessibility adaptation layer --- .../__snapshots__/ListSection.test.tsx.snap | 3 + src/core/PaperProvider.tsx | 61 ++++++++----------- src/core/__tests__/PaperProvider.test.tsx | 18 +++--- src/index.tsx | 3 + src/theme/accessibility/index.ts | 1 + src/theme/accessibility/useAccessibleTheme.ts | 48 +++++++++++++++ src/theme/tokens/sys/motion.ts | 32 ++++++++++ src/theme/types/motion.ts | 1 + src/theme/types/theme.ts | 6 +- 9 files changed, 128 insertions(+), 45 deletions(-) create mode 100644 src/theme/accessibility/index.ts create mode 100644 src/theme/accessibility/useAccessibleTheme.ts diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 8df341420d..d17b1a79fc 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -381,6 +381,7 @@ exports[`renders list section with custom title style 1`] = ` 1, ], }, + "prefersReducedMotion": false, "spring": { "default": { "effects": { @@ -1172,6 +1173,7 @@ exports[`renders list section with subheader 1`] = ` 1, ], }, + "prefersReducedMotion": false, "spring": { "default": { "effects": { @@ -1961,6 +1963,7 @@ exports[`renders list section without subheader 1`] = ` 1, ], }, + "prefersReducedMotion": false, "spring": { "default": { "effects": { diff --git a/src/core/PaperProvider.tsx b/src/core/PaperProvider.tsx index 01a80fdcd9..c3bc02db8c 100644 --- a/src/core/PaperProvider.tsx +++ b/src/core/PaperProvider.tsx @@ -1,31 +1,34 @@ import * as React from 'react'; -import { - AccessibilityInfo, - Appearance, - ColorSchemeName, - NativeEventSubscription, -} from 'react-native'; +import { Appearance, ColorSchemeName } from 'react-native'; import SafeAreaProviderCompat from './SafeAreaProviderCompat'; import { Provider as SettingsProvider, Settings } from './settings'; import { defaultThemes, ThemeProvider } from './theming'; import MaterialCommunityIcon from '../components/MaterialCommunityIcon'; import PortalHost from '../components/Portal/PortalHost'; -import type { ThemeProp } from '../types'; -import { addEventListener } from '../utils/addEventListener'; +import { useAccessibleTheme } from '../theme/accessibility'; +import type { Theme, ThemeProp } from '../types'; export type Props = { children: React.ReactNode; theme?: ThemeProp; settings?: Settings; + /** + * Controls whether OS-level accessibility preferences (reduce motion, + * bold text on iOS) are automatically reflected in the theme. + * - `'auto'` (default) — PaperProvider subscribes to OS events and adapts + * `theme.motion` and `theme.fonts` accordingly. + * - `'off'` — no adaptation; handle accessibility in your own code. + */ + accessibilityAdapters?: 'auto' | 'off'; }; const PaperProvider = (props: Props) => { + const { accessibilityAdapters = 'auto' } = props; + const colorSchemeName = (!props.theme && Appearance?.getColorScheme()) || 'light'; - const [reduceMotionEnabled, setReduceMotionEnabled] = - React.useState(false); const [colorScheme, setColorScheme] = React.useState(colorSchemeName); @@ -37,28 +40,13 @@ const PaperProvider = (props: Props) => { }; React.useEffect(() => { - let subscription: NativeEventSubscription | undefined; - - if (!props.theme) { - subscription = addEventListener( - AccessibilityInfo, - 'reduceMotionChanged', - setReduceMotionEnabled - ); - } - return () => { - if (!props.theme) { - subscription?.remove(); - } - }; - }, [props.theme]); - - React.useEffect(() => { - let appearanceSubscription: NativeEventSubscription | undefined; + let appearanceSubscription: + | ReturnType + | undefined; if (!props.theme) { appearanceSubscription = Appearance?.addChangeListener( handleAppearanceChange - ) as NativeEventSubscription | undefined; + ) as typeof appearanceSubscription; } return () => { if (!props.theme) { @@ -72,19 +60,20 @@ const PaperProvider = (props: Props) => { }; }, [props.theme]); - const theme = React.useMemo(() => { + const rawTheme = React.useMemo(() => { const scheme = colorScheme === 'dark' ? 'dark' : 'light'; - const defaultThemeBase = defaultThemes[scheme]; - + const base = defaultThemes[scheme]; return { - ...defaultThemeBase, + ...base, ...props.theme, animation: { ...props.theme?.animation, - scale: reduceMotionEnabled ? 0 : 1, + scale: props.theme?.animation?.scale ?? 1, }, - }; - }, [colorScheme, props.theme, reduceMotionEnabled]); + } as Theme; + }, [colorScheme, props.theme]); + + const theme = useAccessibleTheme(rawTheme, accessibilityAdapters === 'auto'); const { children, settings } = props; diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx index 1d04ec00ff..cc8ebd21ca 100644 --- a/src/core/__tests__/PaperProvider.test.tsx +++ b/src/core/__tests__/PaperProvider.test.tsx @@ -82,6 +82,7 @@ const mockAccessibilityInfo = () => { removeEventListener: jest.fn((cb) => { listeners.push(cb); }), + isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)), __internalListeners: listeners, }, }; @@ -136,11 +137,11 @@ describe('PaperProvider', () => { }); }); - it('should set AccessibilityInfo listeners, if there is no theme', async () => { + it('should set AccessibilityInfo listeners and adapt theme when reduce motion is enabled', async () => { mockAppearance(); mockAccessibilityInfo(); - const { rerender, getByTestId } = render(createProvider()); + const { getByTestId } = render(createProvider()); expect(AccessibilityInfo.addEventListener).toHaveBeenCalled(); act(() => @@ -152,17 +153,18 @@ describe('PaperProvider', () => { expect( getByTestId('provider-child-view').props.theme.animation.scale ).toStrictEqual(0); - - rerender(createProvider(ExtendedLightTheme)); - expect(AccessibilityInfo.removeEventListener).toHaveBeenCalled(); }); - it('should not set AccessibilityInfo listeners, if there is a theme', async () => { + it('should not set AccessibilityInfo listeners when accessibilityAdapters is off', async () => { mockAppearance(); - const { getByTestId } = render(createProvider(ExtendedDarkTheme)); + mockAccessibilityInfo(); + const { getByTestId } = render( + + + + ); expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled(); - expect(AccessibilityInfo.removeEventListener).not.toHaveBeenCalled(); expect(getByTestId('provider-child-view').props.theme).toStrictEqual( ExtendedDarkTheme ); diff --git a/src/index.tsx b/src/index.tsx index 6160e89c30..8fc041b225 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,9 +18,12 @@ export { cornersToStyle } from './theme/tokens/sys/shape'; export { expressiveMotion, standardMotion, + reducedMotion, toRawSpring, } from './theme/tokens/sys/motion'; +export { useAccessibleTheme } from './theme/accessibility'; + import * as Avatar from './components/Avatar/Avatar'; import * as Drawer from './components/Drawer/Drawer'; import * as List from './components/List/List'; diff --git a/src/theme/accessibility/index.ts b/src/theme/accessibility/index.ts new file mode 100644 index 0000000000..6e85c48f56 --- /dev/null +++ b/src/theme/accessibility/index.ts @@ -0,0 +1 @@ +export { useAccessibleTheme } from './useAccessibleTheme'; diff --git a/src/theme/accessibility/useAccessibleTheme.ts b/src/theme/accessibility/useAccessibleTheme.ts new file mode 100644 index 0000000000..bae2d6e37a --- /dev/null +++ b/src/theme/accessibility/useAccessibleTheme.ts @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { AccessibilityInfo } from 'react-native'; + +import { addEventListener } from '../../utils/addEventListener'; +import { reducedMotion } from '../tokens/sys/motion'; +import type { Theme } from '../types'; + +function applyReducedMotion(theme: Theme): Theme { + return { + ...theme, + animation: { ...theme.animation, scale: 0 }, + motion: reducedMotion, + }; +} + +export function useAccessibleTheme(theme: Theme, enabled = true): Theme { + const [reduceMotion, setReduceMotion] = React.useState(false); + + React.useEffect(() => { + if (!enabled) return; + + let cancelled = false; + + const init = async () => { + const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled?.(); + if (!cancelled && reduceMotion !== undefined) + setReduceMotion(reduceMotion); + }; + + void init(); + + const motionSub = addEventListener( + AccessibilityInfo, + 'reduceMotionChanged', + setReduceMotion + ); + + return () => { + cancelled = true; + motionSub.remove(); + }; + }, [enabled]); + + return React.useMemo(() => { + if (!enabled) return theme; + return reduceMotion ? applyReducedMotion(theme) : theme; + }, [theme, reduceMotion, enabled]); +} diff --git a/src/theme/tokens/sys/motion.ts b/src/theme/tokens/sys/motion.ts index 9bc1d37a72..03165aa1f7 100644 --- a/src/theme/tokens/sys/motion.ts +++ b/src/theme/tokens/sys/motion.ts @@ -78,12 +78,44 @@ export const expressiveMotion: MotionConfig = { ...expressiveSpring, easing: motionEasing, duration: motionDuration, + prefersReducedMotion: false, }; export const standardMotion: MotionConfig = { ...standardSpring, easing: motionEasing, duration: motionDuration, + prefersReducedMotion: false, +}; + +const instantSpring = { stiffness: 10000, damping: 1 }; + +export const reducedMotion: MotionConfig = { + spring: { + fast: { spatial: instantSpring, effects: instantSpring }, + default: { spatial: instantSpring, effects: instantSpring }, + slow: { spatial: instantSpring, effects: instantSpring }, + }, + easing: motionEasing, + prefersReducedMotion: true, + duration: { + short1: 0, + short2: 0, + short3: 0, + short4: 0, + medium1: 0, + medium2: 0, + medium3: 0, + medium4: 0, + long1: 0, + long2: 0, + long3: 0, + long4: 0, + extraLong1: 0, + extraLong2: 0, + extraLong3: 0, + extraLong4: 0, + }, }; /** diff --git a/src/theme/types/motion.ts b/src/theme/types/motion.ts index 9f66491e8b..b4f9012ca0 100644 --- a/src/theme/types/motion.ts +++ b/src/theme/types/motion.ts @@ -47,4 +47,5 @@ export type MotionConfig = { spring: MotionSpring; easing: MotionEasing; duration: MotionDuration; + prefersReducedMotion: boolean; }; diff --git a/src/theme/types/theme.ts b/src/theme/types/theme.ts index 0280495369..574e361cea 100644 --- a/src/theme/types/theme.ts +++ b/src/theme/types/theme.ts @@ -7,16 +7,20 @@ import type { ThemeShapes } from './shape'; import type { ThemeState } from './state'; import type { Typescale } from './typography'; +/** @deprecated Will be removed in a future version. MD3 uses tonal surface colors via `theme.colors.elevation.*`. */ type Mode = 'adaptive' | 'exact'; export type ThemeBase = { dark: boolean; + /** @deprecated Will be removed in a future version. MD3 uses tonal surface colors via `theme.colors.elevation.*`. */ mode?: Mode; /** @deprecated Use `theme.shapes.*` instead. Will be removed in a future version. */ roundness: number; + /** @deprecated Use `theme.motion.*` instead. Will be removed in a future version. */ animation: { + /** @deprecated Use `theme.motion.prefersReducedMotion` instead. Will be removed in a future version. */ scale: number; - /** @deprecated Use `theme.motion.duration.*` instead. Will be removed in a future version. */ + /** @deprecated No-op. Use `theme.motion.duration.*` instead. Will be removed in a future version. */ defaultAnimationDuration?: number; }; };