Skip to content

Commit b4a5696

Browse files
committed
feat: add accessibility adaptation layer
1 parent 0e4df75 commit b4a5696

9 files changed

Lines changed: 126 additions & 45 deletions

File tree

src/components/__tests__/__snapshots__/ListSection.test.tsx.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ exports[`renders list section with custom title style 1`] = `
381381
1,
382382
],
383383
},
384+
"prefersReducedMotion": false,
384385
"spring": {
385386
"default": {
386387
"effects": {
@@ -1172,6 +1173,7 @@ exports[`renders list section with subheader 1`] = `
11721173
1,
11731174
],
11741175
},
1176+
"prefersReducedMotion": false,
11751177
"spring": {
11761178
"default": {
11771179
"effects": {
@@ -1961,6 +1963,7 @@ exports[`renders list section without subheader 1`] = `
19611963
1,
19621964
],
19631965
},
1966+
"prefersReducedMotion": false,
19641967
"spring": {
19651968
"default": {
19661969
"effects": {

src/core/PaperProvider.tsx

Lines changed: 23 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
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 { useAccessibleTheme } from '../theme/accessibility';
10+
import type { Theme, ThemeProp } from '../types';
1611

1712
export type Props = {
1813
children: React.ReactNode;
1914
theme?: ThemeProp;
2015
settings?: Settings;
16+
/**
17+
* Whether OS-level accessibility preferences (reduce motion) are automatically
18+
* reflected in the theme. Defaults to `true`. Set to `false` to handle
19+
* accessibility in your own code.
20+
*/
21+
accessibilityAdapters?: boolean;
2122
};
2223

2324
const PaperProvider = (props: Props) => {
25+
const { accessibilityAdapters = true } = props;
26+
2427
const colorSchemeName =
2528
(!props.theme && Appearance?.getColorScheme()) || 'light';
2629

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

@@ -37,28 +38,13 @@ const PaperProvider = (props: Props) => {
3738
};
3839

3940
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;
41+
let appearanceSubscription:
42+
| ReturnType<typeof Appearance.addChangeListener>
43+
| undefined;
5844
if (!props.theme) {
5945
appearanceSubscription = Appearance?.addChangeListener(
6046
handleAppearanceChange
61-
) as NativeEventSubscription | undefined;
47+
) as typeof appearanceSubscription;
6248
}
6349
return () => {
6450
if (!props.theme) {
@@ -72,19 +58,20 @@ const PaperProvider = (props: Props) => {
7258
};
7359
}, [props.theme]);
7460

75-
const theme = React.useMemo(() => {
61+
const rawTheme = React.useMemo(() => {
7662
const scheme = colorScheme === 'dark' ? 'dark' : 'light';
77-
const defaultThemeBase = defaultThemes[scheme];
78-
63+
const base = defaultThemes[scheme];
7964
return {
80-
...defaultThemeBase,
65+
...base,
8166
...props.theme,
8267
animation: {
8368
...props.theme?.animation,
84-
scale: reduceMotionEnabled ? 0 : 1,
69+
scale: props.theme?.animation?.scale ?? 1,
8570
},
86-
};
87-
}, [colorScheme, props.theme, reduceMotionEnabled]);
71+
} as Theme;
72+
}, [colorScheme, props.theme]);
73+
74+
const theme = useAccessibleTheme(rawTheme, accessibilityAdapters !== false);
8875

8976
const { children, settings } = props;
9077

src/core/__tests__/PaperProvider.test.tsx

Lines changed: 10 additions & 8 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
};
@@ -136,11 +137,11 @@ describe('PaperProvider', () => {
136137
});
137138
});
138139

139-
it('should set AccessibilityInfo listeners, if there is no theme', async () => {
140+
it('should set AccessibilityInfo listeners and adapt theme when reduce motion is enabled', async () => {
140141
mockAppearance();
141142
mockAccessibilityInfo();
142143

143-
const { rerender, getByTestId } = render(createProvider());
144+
const { getByTestId } = render(createProvider());
144145

145146
expect(AccessibilityInfo.addEventListener).toHaveBeenCalled();
146147
act(() =>
@@ -152,17 +153,18 @@ describe('PaperProvider', () => {
152153
expect(
153154
getByTestId('provider-child-view').props.theme.animation.scale
154155
).toStrictEqual(0);
155-
156-
rerender(createProvider(ExtendedLightTheme));
157-
expect(AccessibilityInfo.removeEventListener).toHaveBeenCalled();
158156
});
159157

160-
it('should not set AccessibilityInfo listeners, if there is a theme', async () => {
158+
it('should not set AccessibilityInfo listeners when accessibilityAdapters is false', async () => {
161159
mockAppearance();
162-
const { getByTestId } = render(createProvider(ExtendedDarkTheme));
160+
mockAccessibilityInfo();
161+
const { getByTestId } = render(
162+
<PaperProvider theme={ExtendedDarkTheme} accessibilityAdapters={false}>
163+
<FakeChild />
164+
</PaperProvider>
165+
);
163166

164167
expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled();
165-
expect(AccessibilityInfo.removeEventListener).not.toHaveBeenCalled();
166168
expect(getByTestId('provider-child-view').props.theme).toStrictEqual(
167169
ExtendedDarkTheme
168170
);

src/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ export { cornersToStyle } from './theme/tokens/sys/shape';
1818
export {
1919
expressiveMotion,
2020
standardMotion,
21+
reducedMotion,
2122
toRawSpring,
2223
} from './theme/tokens/sys/motion';
2324

25+
export { useAccessibleTheme } from './theme/accessibility';
26+
2427
import * as Avatar from './components/Avatar/Avatar';
2528
import * as Drawer from './components/Drawer/Drawer';
2629
import * as List from './components/List/List';

src/theme/accessibility/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useAccessibleTheme } from './useAccessibleTheme';
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as React from 'react';
2+
import { AccessibilityInfo } from 'react-native';
3+
4+
import { addEventListener } from '../../utils/addEventListener';
5+
import { reducedMotion } from '../tokens/sys/motion';
6+
import type { Theme } from '../types';
7+
8+
function applyReducedMotion(theme: Theme): Theme {
9+
return {
10+
...theme,
11+
animation: { ...theme.animation, scale: 0 },
12+
motion: reducedMotion,
13+
};
14+
}
15+
16+
export function useAccessibleTheme(theme: Theme, enabled = true): Theme {
17+
const [reduceMotion, setReduceMotion] = React.useState(false);
18+
19+
React.useEffect(() => {
20+
if (!enabled) return;
21+
22+
let cancelled = false;
23+
24+
const init = async () => {
25+
const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled?.();
26+
if (!cancelled && reduceMotion !== undefined)
27+
setReduceMotion(reduceMotion);
28+
};
29+
30+
void init();
31+
32+
const motionSub = addEventListener(
33+
AccessibilityInfo,
34+
'reduceMotionChanged',
35+
setReduceMotion
36+
);
37+
38+
return () => {
39+
cancelled = true;
40+
motionSub.remove();
41+
};
42+
}, [enabled]);
43+
44+
return React.useMemo(() => {
45+
if (!enabled) return theme;
46+
return reduceMotion ? applyReducedMotion(theme) : theme;
47+
}, [theme, reduceMotion, enabled]);
48+
}

src/theme/tokens/sys/motion.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,44 @@ export const expressiveMotion: MotionConfig = {
7878
...expressiveSpring,
7979
easing: motionEasing,
8080
duration: motionDuration,
81+
prefersReducedMotion: false,
8182
};
8283

8384
export const standardMotion: MotionConfig = {
8485
...standardSpring,
8586
easing: motionEasing,
8687
duration: motionDuration,
88+
prefersReducedMotion: false,
89+
};
90+
91+
const instantSpring = { stiffness: 10000, damping: 1 };
92+
93+
export const reducedMotion: MotionConfig = {
94+
spring: {
95+
fast: { spatial: instantSpring, effects: instantSpring },
96+
default: { spatial: instantSpring, effects: instantSpring },
97+
slow: { spatial: instantSpring, effects: instantSpring },
98+
},
99+
easing: motionEasing,
100+
prefersReducedMotion: true,
101+
duration: {
102+
short1: 0,
103+
short2: 0,
104+
short3: 0,
105+
short4: 0,
106+
medium1: 0,
107+
medium2: 0,
108+
medium3: 0,
109+
medium4: 0,
110+
long1: 0,
111+
long2: 0,
112+
long3: 0,
113+
long4: 0,
114+
extraLong1: 0,
115+
extraLong2: 0,
116+
extraLong3: 0,
117+
extraLong4: 0,
118+
},
87119
};
88120

89121
/**

src/theme/types/motion.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,5 @@ export type MotionConfig = {
4747
spring: MotionSpring;
4848
easing: MotionEasing;
4949
duration: MotionDuration;
50+
prefersReducedMotion: boolean;
5051
};

src/theme/types/theme.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,20 @@ import type { ThemeShapes } from './shape';
77
import type { ThemeState } from './state';
88
import type { Typescale } from './typography';
99

10+
/** @deprecated Will be removed in a future version. MD3 uses tonal surface colors via `theme.colors.elevation.*`. */
1011
type Mode = 'adaptive' | 'exact';
1112

1213
export type ThemeBase = {
1314
dark: boolean;
15+
/** @deprecated Will be removed in a future version. MD3 uses tonal surface colors via `theme.colors.elevation.*`. */
1416
mode?: Mode;
1517
/** @deprecated Use `theme.shapes.*` instead. Will be removed in a future version. */
1618
roundness: number;
19+
/** @deprecated Use `theme.motion.*` instead. Will be removed in a future version. */
1720
animation: {
21+
/** @deprecated Use `theme.motion.prefersReducedMotion` instead. Will be removed in a future version. */
1822
scale: number;
19-
/** @deprecated Use `theme.motion.duration.*` instead. Will be removed in a future version. */
23+
/** @deprecated No-op. Use `theme.motion.duration.*` instead. Will be removed in a future version. */
2024
defaultAnimationDuration?: number;
2125
};
2226
};

0 commit comments

Comments
 (0)