Skip to content

Commit 965178b

Browse files
committed
feat: add accessibility adaptation layer
1 parent dbc5533 commit 965178b

5 files changed

Lines changed: 115 additions & 47 deletions

File tree

src/core/PaperProvider.tsx

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,36 @@
11
import * as React from 'react';
2-
import {
3-
AccessibilityInfo,
4-
Appearance,
5-
ColorSchemeName,
6-
NativeEventSubscription,
7-
} from 'react-native';
2+
import { Appearance, ColorSchemeName } from 'react-native';
83

94
import SafeAreaProviderCompat from './SafeAreaProviderCompat';
105
import { Provider as SettingsProvider, Settings } from './settings';
116
import { defaultThemes, ThemeProvider } from './theming';
127
import MaterialCommunityIcon from '../components/MaterialCommunityIcon';
138
import PortalHost from '../components/Portal/PortalHost';
14-
import type { ThemeProp } from '../types';
15-
import { addEventListener } from '../utils/addEventListener';
9+
import { ReduceMotionContext } from '../theme/accessibility/ReduceMotionContext';
10+
import {
11+
useResolvedReduceMotion,
12+
type ReduceMotionPreference,
13+
} from '../theme/accessibility/useResolvedReduceMotion';
14+
import type { Theme, ThemeProp } from '../types';
1615

1716
export type Props = {
1817
children: React.ReactNode;
1918
theme?: ThemeProp;
2019
settings?: Settings;
20+
reduceMotion?: ReduceMotionPreference;
2121
};
2222

2323
const PaperProvider = (props: Props) => {
24+
const { reduceMotion = 'auto' } = props;
25+
2426
const colorSchemeName =
2527
(!props.theme && Appearance?.getColorScheme()) || 'light';
2628

27-
const [reduceMotionEnabled, setReduceMotionEnabled] =
28-
React.useState<boolean>(false);
2929
const [colorScheme, setColorScheme] =
3030
React.useState<ColorSchemeName>(colorSchemeName);
3131

32+
const resolvedReduceMotion = useResolvedReduceMotion(reduceMotion);
33+
3234
const handleAppearanceChange = (
3335
preferences: Appearance.AppearancePreferences
3436
) => {
@@ -37,28 +39,13 @@ const PaperProvider = (props: Props) => {
3739
};
3840

3941
React.useEffect(() => {
40-
let subscription: NativeEventSubscription | undefined;
41-
42-
if (!props.theme) {
43-
subscription = addEventListener(
44-
AccessibilityInfo,
45-
'reduceMotionChanged',
46-
setReduceMotionEnabled
47-
);
48-
}
49-
return () => {
50-
if (!props.theme) {
51-
subscription?.remove();
52-
}
53-
};
54-
}, [props.theme]);
55-
56-
React.useEffect(() => {
57-
let appearanceSubscription: NativeEventSubscription | undefined;
42+
let appearanceSubscription:
43+
| ReturnType<typeof Appearance.addChangeListener>
44+
| undefined;
5845
if (!props.theme) {
5946
appearanceSubscription = Appearance?.addChangeListener(
6047
handleAppearanceChange
61-
) as NativeEventSubscription | undefined;
48+
) as typeof appearanceSubscription;
6249
}
6350
return () => {
6451
if (!props.theme) {
@@ -72,19 +59,18 @@ const PaperProvider = (props: Props) => {
7259
};
7360
}, [props.theme]);
7461

75-
const theme = React.useMemo(() => {
62+
const theme = React.useMemo<Theme>(() => {
7663
const scheme = colorScheme === 'dark' ? 'dark' : 'light';
77-
const defaultThemeBase = defaultThemes[scheme];
78-
64+
const base = defaultThemes[scheme];
7965
return {
80-
...defaultThemeBase,
66+
...base,
8167
...props.theme,
8268
animation: {
8369
...props.theme?.animation,
84-
scale: reduceMotionEnabled ? 0 : 1,
70+
scale: resolvedReduceMotion ? 0 : props.theme?.animation?.scale ?? 1,
8571
},
86-
};
87-
}, [colorScheme, props.theme, reduceMotionEnabled]);
72+
} as Theme;
73+
}, [colorScheme, props.theme, resolvedReduceMotion]);
8874

8975
const { children, settings } = props;
9076

@@ -101,7 +87,9 @@ const PaperProvider = (props: Props) => {
10187
<SafeAreaProviderCompat>
10288
<PortalHost>
10389
<SettingsProvider value={settingsValue}>
104-
<ThemeProvider theme={theme}>{children}</ThemeProvider>
90+
<ReduceMotionContext.Provider value={resolvedReduceMotion}>
91+
<ThemeProvider theme={theme}>{children}</ThemeProvider>
92+
</ReduceMotionContext.Provider>
10593
</SettingsProvider>
10694
</PortalHost>
10795
</SafeAreaProviderCompat>

src/core/__tests__/PaperProvider.test.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const mockAccessibilityInfo = () => {
8282
removeEventListener: jest.fn((cb) => {
8383
listeners.push(cb);
8484
}),
85+
isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)),
8586
__internalListeners: listeners,
8687
},
8788
};
@@ -122,11 +123,11 @@ describe('PaperProvider', () => {
122123
);
123124
});
124125

125-
it('should set AccessibilityInfo listeners, if there is no theme', async () => {
126+
it('subscribes to AccessibilityInfo and adapts theme.animation.scale when OS reduce-motion is enabled (auto mode)', async () => {
126127
mockAppearance();
127128
mockAccessibilityInfo();
128129

129-
const { rerender, getByTestId } = render(createProvider());
130+
const { getByTestId } = render(createProvider());
130131

131132
expect(AccessibilityInfo.addEventListener).toHaveBeenCalled();
132133
act(() =>
@@ -138,20 +139,36 @@ describe('PaperProvider', () => {
138139
expect(
139140
getByTestId('provider-child-view').props.theme.animation.scale
140141
).toStrictEqual(0);
141-
142-
rerender(createProvider(ExtendedLightTheme));
143-
expect(AccessibilityInfo.removeEventListener).toHaveBeenCalled();
144142
});
145143

146-
it('should not set AccessibilityInfo listeners, if there is a theme', async () => {
144+
it('does not subscribe to AccessibilityInfo when reduceMotion is "off"', async () => {
147145
mockAppearance();
148-
const { getByTestId } = render(createProvider(ExtendedDarkTheme));
146+
mockAccessibilityInfo();
147+
const { getByTestId } = render(
148+
<PaperProvider theme={ExtendedDarkTheme} reduceMotion="off">
149+
<FakeChild />
150+
</PaperProvider>
151+
);
149152

150153
expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled();
151-
expect(AccessibilityInfo.removeEventListener).not.toHaveBeenCalled();
152-
expect(getByTestId('provider-child-view').props.theme).toStrictEqual(
153-
ExtendedDarkTheme
154+
expect(
155+
getByTestId('provider-child-view').props.theme.animation.scale
156+
).toStrictEqual(1);
157+
});
158+
159+
it('forces animation.scale to 0 when reduceMotion is "on" without subscribing', async () => {
160+
mockAppearance();
161+
mockAccessibilityInfo();
162+
const { getByTestId } = render(
163+
<PaperProvider reduceMotion="on">
164+
<FakeChild />
165+
</PaperProvider>
154166
);
167+
168+
expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled();
169+
expect(
170+
getByTestId('provider-child-view').props.theme.animation.scale
171+
).toStrictEqual(0);
155172
});
156173

157174
it('should set Appearance listeners, if there is no theme', async () => {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as React from 'react';
2+
3+
export const ReduceMotionContext = React.createContext<boolean>(false);
4+
5+
/**
6+
* Returns `true` when the user has requested reduced motion, either via the
7+
* `reduceMotion` prop on `PaperProvider` (`"on"` | `"off"`) or, in `"auto"`
8+
* mode (the default), via the OS-level setting reported by `AccessibilityInfo`.
9+
*
10+
* Use this in component code to gate motion-specific animations (translation,
11+
* scale, transforms) while keeping non-motion animations (opacity, color) intact.
12+
*/
13+
export function useReduceMotion(): boolean {
14+
return React.useContext(ReduceMotionContext);
15+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as React from 'react';
2+
import { AccessibilityInfo } from 'react-native';
3+
4+
import { addEventListener } from '../../utils/addEventListener';
5+
6+
export type ReduceMotionPreference = 'auto' | 'on' | 'off';
7+
8+
/**
9+
* Resolves a reduce-motion preference into a boolean.
10+
*
11+
* - `'on'` / `'off'` are explicit overrides.
12+
* - `'auto'` subscribes to `AccessibilityInfo.reduceMotionChanged` and follows
13+
* the OS-level setting.
14+
*
15+
* `AccessibilityInfo.isReduceMotionEnabled()` is async, so the first render
16+
* returns `false` for one frame regardless of OS state.
17+
*/
18+
export function useResolvedReduceMotion(
19+
preference: ReduceMotionPreference
20+
): boolean {
21+
const [osReduceMotion, setOsReduceMotion] = React.useState(false);
22+
23+
React.useEffect(() => {
24+
if (preference !== 'auto') return;
25+
let cancelled = false;
26+
27+
const init = async () => {
28+
const v = await AccessibilityInfo.isReduceMotionEnabled?.();
29+
if (!cancelled && v != null) setOsReduceMotion(v);
30+
};
31+
void init();
32+
33+
const sub = addEventListener(
34+
AccessibilityInfo,
35+
'reduceMotionChanged',
36+
setOsReduceMotion
37+
);
38+
return () => {
39+
cancelled = true;
40+
sub.remove();
41+
};
42+
}, [preference]);
43+
44+
if (preference === 'on') return true;
45+
if (preference === 'off') return false;
46+
return osReduceMotion;
47+
}

src/theme/types/theme.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type Mode = 'adaptive' | 'exact';
1010

1111
export type ThemeBase = {
1212
dark: boolean;
13+
/** @deprecated Will be removed in a future version. MD3 uses tonal surface colors via `theme.colors.elevation.*`. */
1314
mode?: Mode;
1415
animation: {
1516
scale: number;

0 commit comments

Comments
 (0)