Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ exports[`renders list section with custom title style 1`] = `
1,
],
},
"prefersReducedMotion": false,
"spring": {
"default": {
"effects": {
Expand Down Expand Up @@ -1172,6 +1173,7 @@ exports[`renders list section with subheader 1`] = `
1,
],
},
"prefersReducedMotion": false,
"spring": {
"default": {
"effects": {
Expand Down Expand Up @@ -1961,6 +1963,7 @@ exports[`renders list section without subheader 1`] = `
1,
],
},
"prefersReducedMotion": false,
"spring": {
"default": {
"effects": {
Expand Down
61 changes: 25 additions & 36 deletions src/core/PaperProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);
const [colorScheme, setColorScheme] =
React.useState<ColorSchemeName>(colorSchemeName);

Expand All @@ -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<typeof Appearance.addChangeListener>
| undefined;
if (!props.theme) {
appearanceSubscription = Appearance?.addChangeListener(
handleAppearanceChange
) as NativeEventSubscription | undefined;
) as typeof appearanceSubscription;
}
return () => {
if (!props.theme) {
Expand All @@ -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;

Expand Down
18 changes: 10 additions & 8 deletions src/core/__tests__/PaperProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const mockAccessibilityInfo = () => {
removeEventListener: jest.fn((cb) => {
listeners.push(cb);
}),
isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)),
__internalListeners: listeners,
},
};
Expand Down Expand Up @@ -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(() =>
Expand All @@ -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(
<PaperProvider theme={ExtendedDarkTheme} accessibilityAdapters="off">
<FakeChild />
</PaperProvider>
);

expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled();
expect(AccessibilityInfo.removeEventListener).not.toHaveBeenCalled();
expect(getByTestId('provider-child-view').props.theme).toStrictEqual(
ExtendedDarkTheme
);
Expand Down
3 changes: 3 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/theme/accessibility/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useAccessibleTheme } from './useAccessibleTheme';
48 changes: 48 additions & 0 deletions src/theme/accessibility/useAccessibleTheme.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
32 changes: 32 additions & 0 deletions src/theme/tokens/sys/motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};

/**
Expand Down
1 change: 1 addition & 0 deletions src/theme/types/motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,5 @@ export type MotionConfig = {
spring: MotionSpring;
easing: MotionEasing;
duration: MotionDuration;
prefersReducedMotion: boolean;
};
6 changes: 5 additions & 1 deletion src/theme/types/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
Expand Down
Loading