diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..f97671b2 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -232,6 +232,24 @@ test("light-dark()", () => { }); }); +test.each([ + [ + "(prefers-reduced-motion: reduce)", + ["=", "prefers-reduced-motion", "reduce"], + ], + [ + "(prefers-reduced-motion: no-preference)", + ["=", "prefers-reduced-motion", "no-preference"], + ], + ["(prefers-reduced-motion)", ["!!", "prefers-reduced-motion"]], +])("@media %s compiles to its condition", (query, condition) => { + expect( + compile(`@media ${query} { .my-class { opacity: 0 } }`).stylesheet(), + ).toStrictEqual({ + s: [["my-class", [{ s: [2, 1], m: [condition], d: [{ opacity: 0 }] }]]], + }); +}); + test("media query nested in rules", () => { const compiled = compile(` .my-class { diff --git a/src/__tests__/native/jest-reset.test.ts b/src/__tests__/native/jest-reset.test.ts new file mode 100644 index 00000000..342ac9b1 --- /dev/null +++ b/src/__tests__/native/jest-reset.test.ts @@ -0,0 +1,24 @@ +import { reducedMotion } from "react-native-css/runtime"; + +import { resetBetweenTests } from "../../jest/reset"; +import { deviceReducedMotion } from "../../native/reactivity"; + +describe("the react-native-css/jest reset", () => { + test("clears a test's override", () => { + reducedMotion.set("reduce"); + expect(reducedMotion.get()).toBe("reduce"); + + resetBetweenTests(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("puts a device a test changed back to AccessibilityInfo's answer", () => { + deviceReducedMotion.set("reduce"); + expect(reducedMotion.get()).toBe("reduce"); + + resetBetweenTests(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); +}); diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 020b4aad..8485bace 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -3,9 +3,9 @@ import { PixelRatio } from "react-native"; import { act, render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; -import { colorScheme } from "react-native-css/runtime"; +import { colorScheme, reducedMotion } from "react-native-css/runtime"; -import { dimensions } from "../../native/reactivity"; +import { deviceReducedMotion, dimensions } from "../../native/reactivity"; jest.mock("react-native", () => { const RN = jest.requireActual("react-native"); @@ -283,3 +283,193 @@ describe("max-resolution", () => { expect(component.props.style).toStrictEqual(undefined); }); }); + +describe("a reduced-motion preference AccessibilityInfo has not answered", () => { + let answered: ReturnType; + + beforeEach(() => { + answered = deviceReducedMotion.get(); + deviceReducedMotion.set(undefined); + }); + + afterEach(() => { + act(() => { + deviceReducedMotion.set(answered); + }); + }); + + test("a mounted view takes the device's answer when it arrives", () => { + registerCSS(` + .my-class { color: #000000; } + @media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); + + render(); + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "#000" }); + + act(() => { + deviceReducedMotion.set("reduce"); + }); + expect(component.props.style).toStrictEqual({ color: "#020202" }); + }); + + test.each([ + "(prefers-reduced-motion: reduce)", + "(prefers-reduced-motion: no-preference)", + "(prefers-reduced-motion)", + ])("leaves @media %s unmatched", (query) => { + registerCSS( + `.my-class { color: #000000; } @media ${query} { .my-class { color: #010101; } }`, + ); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#000", + }); + }); + + test("a mounted view follows the reduced-motion preference as it changes", () => { + registerCSS(` + .my-class { color: #000000; } + @media (prefers-reduced-motion: no-preference) { .my-class { color: #010101; } } + @media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); + + render(); + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "#000" }); + + act(() => { + reducedMotion.set("no-preference"); + }); + expect(component.props.style).toStrictEqual({ color: "#010101" }); + + act(() => { + reducedMotion.set("reduce"); + }); + expect(component.props.style).toStrictEqual({ color: "#020202" }); + + act(() => { + reducedMotion.set("no-preference"); + }); + expect(component.props.style).toStrictEqual({ color: "#010101" }); + }); +}); + +test("reducedMotion.set holds through a change of the device's setting, and set(null) follows the device again", () => { + registerCSS(` +.my-class { color: #000000; } +@media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); + const answered = deviceReducedMotion.get(); + + render(); + const component = screen.getByTestId(testID); + + try { + act(() => { + reducedMotion.set("no-preference"); + deviceReducedMotion.set("reduce"); + }); + expect(reducedMotion.get()).toBe("no-preference"); + expect(component.props.style).toStrictEqual({ color: "#000" }); + + act(() => { + reducedMotion.set(null); + }); + expect(reducedMotion.get()).toBe("reduce"); + expect(component.props.style).toStrictEqual({ color: "#020202" }); + } finally { + act(() => { + deviceReducedMotion.set(answered); + }); + } +}); + +test("reducedMotion.set overrides the device's preference, and set(null) follows the device again at once", () => { + registerCSS(` +.my-class { color: #000000; } +@media (prefers-reduced-motion: reduce) { .my-class { color: #020202; } }`); + + render(); + const component = screen.getByTestId(testID); + expect(reducedMotion.get()).toBe("no-preference"); + expect(component.props.style).toStrictEqual({ color: "#000" }); + + act(() => { + reducedMotion.set("reduce"); + }); + expect(reducedMotion.get()).toBe("reduce"); + expect(component.props.style).toStrictEqual({ color: "#020202" }); + + act(() => { + reducedMotion.set(null); + }); + expect(reducedMotion.get()).toBe("no-preference"); + expect(component.props.style).toStrictEqual({ color: "#000" }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 agree on every row: [query, reduce/light, reduce/dark, no-preference/light, no-preference/dark] +const BROWSER_MEDIA_ROWS = [ + ["(prefers-reduced-motion: reduce)", true, true, false, false], + ["(prefers-reduced-motion: no-preference)", false, false, true, true], + ["(prefers-reduced-motion)", true, true, false, false], + ["not (prefers-reduced-motion)", false, false, true, true], + ["(prefers-reduced-motion: bogus)", false, false, false, false], + ["not all and (prefers-reduced-motion: reduce)", false, false, true, true], + [ + "(prefers-reduced-motion: reduce) and (min-width: 1px)", + true, + true, + false, + false, + ], + [ + "(prefers-reduced-motion: reduce) and (prefers-color-scheme: dark)", + false, + true, + false, + false, + ], + [ + "(prefers-reduced-motion: reduce), (prefers-color-scheme: dark)", + true, + true, + false, + true, + ], + ["not (prefers-reduced-motion: reduce)", false, false, true, true], + ["(prefers-color-scheme: dark)", false, true, false, true], + ["(unknown-feature)", false, false, false, false], +] as const; + +const MEDIA_STATES = [ + ["reduce", "light"], + ["reduce", "dark"], + ["no-preference", "light"], + ["no-preference", "dark"], +] as const; + +describe.each(BROWSER_MEDIA_ROWS)( + "@media %s matches as browsers match it", + (query, ...matches) => { + test.each( + MEDIA_STATES.map( + ([motion, scheme], index) => [motion, scheme, matches[index]] as const, + ), + )("under %s motion in the %s scheme: %s", (motion, scheme, expected) => { + act(() => { + reducedMotion.set(motion); + colorScheme.set(scheme); + }); + registerCSS( + `.my-class { color: #000000; } @media ${query} { .my-class { color: #010101; } }`, + ); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: expected ? "#010101" : "#000", + }); + }); + }, +); diff --git a/src/__tests__/native/reduce-motion-init.test.ts b/src/__tests__/native/reduce-motion-init.test.ts new file mode 100644 index 00000000..70d2ce8a --- /dev/null +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -0,0 +1,164 @@ +type ReduceMotionListener = (isReduceMotionEnabled: boolean) => void; + +interface AccessibilityInfoDouble { + readonly isReduceMotionEnabled?: () => Promise; + readonly addEventListener?: ( + event: string, + listener: ReduceMotionListener, + ) => { readonly remove: () => void }; +} + +function importReactivityWith( + accessibilityInfo: AccessibilityInfoDouble | undefined, +) { + jest.resetModules(); + // A Proxy over requireActual rather than a spread: spreading react-native eagerly triggers its lazy DevMenu getter and throws + jest.doMock("react-native", () => { + const actual = jest.requireActual>("react-native"); + + return new Proxy(actual, { + get: (target, property): unknown => + property === "AccessibilityInfo" + ? accessibilityInfo + : Reflect.get(target, property), + }); + }); + + return jest.requireActual( + "../../native/reactivity", + ); +} + +// Every promise the module chains settles before a macrotask runs +function settle() { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + +function controlledAccessibilityInfo() { + const listeners: ReduceMotionListener[] = []; + let answer: (isReduceMotionEnabled: boolean) => void = () => undefined; + const answered = new Promise((resolve) => { + answer = resolve; + }); + + return { + accessibilityInfo: { + isReduceMotionEnabled: () => answered, + addEventListener: (event: string, listener: ReduceMotionListener) => { + if (event === "reduceMotionChanged") listeners.push(listener); + return { remove: () => undefined }; + }, + }, + answer, + change: (isReduceMotionEnabled: boolean) => { + for (const listener of listeners) listener(isReduceMotionEnabled); + }, + }; +} + +describe("the reduced-motion preference", () => { + test("lets an answer that arrives after the jest helper's reset through, and is reset to it once a test changes it", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + const { resetBetweenTests } = + jest.requireActual("../../jest/reset"); + + resetBetweenTests(); + answer(true); + await settle(); + expect(reducedMotion.get()).toBe("reduce"); + + change(false); + expect(reducedMotion.get()).toBe("no-preference"); + + resetBetweenTests(); + expect(reducedMotion.get()).toBe("reduce"); + }); + + test("is unknown until AccessibilityInfo answers", () => { + const { accessibilityInfo } = controlledAccessibilityInfo(); + + const { reducedMotion } = importReactivityWith(accessibilityInfo); + + expect(reducedMotion.get()).toBeUndefined(); + }); + + test("takes AccessibilityInfo's answer, then follows its change event", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + + answer(true); + await settle(); + expect(reducedMotion.get()).toBe("reduce"); + + change(false); + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("keeps a change event over an answer that resolves after it", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + + change(true); + answer(false); + await settle(); + + expect(reducedMotion.get()).toBe("reduce"); + }); + + test("is no-preference where AccessibilityInfo rejects, without an unhandled rejection", async () => { + const { reducedMotion } = importReactivityWith({ + isReduceMotionEnabled: () => + Promise.reject(new Error("NativeAccessibilityManager unavailable")), + addEventListener: () => ({ remove: () => undefined }), + }); + await settle(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("is no-preference where AccessibilityInfo has no getter, without failing the import", async () => { + const { reducedMotion } = importReactivityWith({ + addEventListener: () => ({ remove: () => undefined }), + }); + await settle(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("is no-preference where react-native has no AccessibilityInfo, without failing the import", async () => { + const { reducedMotion } = importReactivityWith(undefined); + await settle(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("takes AccessibilityInfo's answer where it has no change event, without failing the import", async () => { + const { reducedMotion } = importReactivityWith({ + isReduceMotionEnabled: () => Promise.resolve(true), + }); + await settle(); + + expect(reducedMotion.get()).toBe("reduce"); + }); + + test("an override holds through the device's changes until it is set to undefined", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + answer(true); + await settle(); + + reducedMotion.set("no-preference"); + change(false); + change(true); + expect(reducedMotion.get()).toBe("no-preference"); + + reducedMotion.set(undefined); + expect(reducedMotion.get()).toBe("reduce"); + + change(false); + expect(reducedMotion.get()).toBe("no-preference"); + }); +}); diff --git a/src/__tests__/web/reduced-motion.test.ts b/src/__tests__/web/reduced-motion.test.ts new file mode 100644 index 00000000..ce4091c6 --- /dev/null +++ b/src/__tests__/web/reduced-motion.test.ts @@ -0,0 +1,77 @@ +import { reducedMotion } from "../../web/api"; + +function withMatchMedia(value: unknown, run: () => void) { + Object.defineProperty(window, "matchMedia", { configurable: true, value }); + try { + run(); + } finally { + Reflect.deleteProperty(window, "matchMedia"); + reducedMotion.set(null); + } +} + +test.each([ + [true, "reduce"], + [false, "no-preference"], +] as const)( + "reducedMotion reads %s from matchMedia as %s", + (matches, preference) => { + withMatchMedia( + (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)" && matches, + }), + () => { + expect(reducedMotion.get()).toBe(preference); + }, + ); + }, +); + +test("an override changes what scripts read until set(null) reads matchMedia again", () => { + withMatchMedia( + () => ({ matches: true }), + () => { + reducedMotion.set("no-preference"); + expect(reducedMotion.get()).toBe("no-preference"); + + reducedMotion.set(null); + expect(reducedMotion.get()).toBe("reduce"); + }, + ); +}); + +test.each([ + ["no matchMedia", undefined], + ["a matchMedia that is no function", {}], + ["a matchMedia stub that returns nothing", () => undefined], +])("reducedMotion has no preference to read with %s", (_, value) => { + withMatchMedia(value, () => { + expect(reducedMotion.get()).toBeUndefined(); + }); +}); + +test("a server keeps no override, since it renders every visitor's page in one process", () => { + const browserWindow: unknown = globalThis.window; + Reflect.deleteProperty(globalThis, "window"); + + try { + reducedMotion.set("reduce"); + expect(reducedMotion.get()).toBeUndefined(); + } finally { + Reflect.set(globalThis, "window", browserWindow); + } + + expect(reducedMotion.get()).toBeUndefined(); +}); + +test("reducedMotion reads a matchMedia double's new answer", () => { + const matchMedia = jest.fn(() => ({ matches: false })); + + withMatchMedia(matchMedia, () => { + expect(reducedMotion.get()).toBe("no-preference"); + + matchMedia.mockImplementation(() => ({ matches: true })); + + expect(reducedMotion.get()).toBe("reduce"); + }); +}); diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..57aa5a3d 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -1,11 +1,9 @@ -import { Appearance, Dimensions } from "react-native"; - import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; -import { colorScheme, dimensions } from "../native/reactivity"; +import { resetBetweenTests } from "./reset"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -18,12 +16,7 @@ declare global { export const testID = "react-native-css"; -beforeEach(() => { - StyleCollection.styles.clear(); - dimensions.set(Dimensions.get("window")); - Appearance.setColorScheme("unspecified"); - colorScheme.set(null); -}); +beforeEach(resetBetweenTests); const debugDefault = Boolean( process.env.REACT_NATIVE_CSS_TEST_DEBUG && diff --git a/src/jest/reset.ts b/src/jest/reset.ts new file mode 100644 index 00000000..1a803c82 --- /dev/null +++ b/src/jest/reset.ts @@ -0,0 +1,22 @@ +import { Appearance, Dimensions } from "react-native"; + +import { + colorScheme, + reducedMotion, + StyleCollection, +} from "react-native-css/native"; +import { + deviceReducedMotion, + deviceReducedMotionAnswer, + dimensions, +} from "react-native-css/native-internal"; + +// The components a consumer's suite renders read the package's own build, so the reset reaches them through the package's entry; a test may fire React Native's reduceMotionChanged, so the device goes back to AccessibilityInfo's answer, or waits for it +export function resetBetweenTests(): void { + StyleCollection.styles.clear(); + dimensions.set(Dimensions.get("window")); + Appearance.setColorScheme("unspecified"); + colorScheme.set(null); + deviceReducedMotion.set(deviceReducedMotionAnswer.get()); + reducedMotion.set(null); +} diff --git a/src/native-internal/index.ts b/src/native-internal/index.ts index e80c9bd6..df7389aa 100644 --- a/src/native-internal/index.ts +++ b/src/native-internal/index.ts @@ -1,3 +1,8 @@ export * from "./root"; export * from "./style-collection"; export * from "./variables"; +export { + deviceReducedMotion, + deviceReducedMotionAnswer, + dimensions, +} from "../native/reactivity"; diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..edd8d16f 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -9,6 +9,7 @@ import type { ColorScheme, Props, ReactComponent, + ReducedMotion, Styled, StyledConfiguration, StyledOptions, @@ -18,6 +19,7 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, + reducedMotion as reducedMotionObs, VAR_SYMBOL, type Effect, type Getter, @@ -76,6 +78,15 @@ export const colorScheme: ColorScheme = { }, }; +export const reducedMotion: ReducedMotion = { + get() { + return reducedMotionObs.get(); + }, + set(value) { + reducedMotionObs.set(value ?? undefined); + }, +}; + export const useUnstableNativeVariable = useNativeVariable; export const useCssElement = < diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..bab0e350 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,19 +1,23 @@ -/* eslint-disable */ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; -import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { colorScheme, reducedMotion, vh, vw, type Getter } from "../reactivity"; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); } -function test(mediaQuery: MediaCondition, get: Getter): Boolean { +function test(mediaQuery: MediaCondition, get: Getter): boolean { switch (mediaQuery[0]) { case "[]": - case "!!": return false; + case "!!": + // (prefers-reduced-motion) alone matches reduce, its one value besides no-preference (MQ5 §2.4.2), and nothing before the device answers + return ( + mediaQuery[1] === "prefers-reduced-motion" && + get(reducedMotion) === "reduce" + ); case "!": return !test(mediaQuery[1], get); case "&": @@ -34,7 +38,7 @@ function test(mediaQuery: MediaCondition, get: Getter): Boolean { } } -function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { +function testComparison(mediaQuery: MediaCondition, get: Getter): boolean { const value = mediaQuery[2]; switch (mediaQuery[1]) { @@ -47,6 +51,9 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "prefers-color-scheme": { return value === get(colorScheme); } + case "prefers-reduced-motion": { + return value === get(reducedMotion); + } case "display-mode": return value === "native" || Platform.OS === value; case "min-width": diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..ea3d9ad0 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -1,6 +1,7 @@ /* eslint-disable */ import { createContext } from "react"; import { + AccessibilityInfo, Appearance, Dimensions, type ColorSchemeName, @@ -9,6 +10,8 @@ import { import type { StyleDescriptor } from "react-native-css/compiler"; +import type { ReducedMotionPreference } from "../runtime.types"; + export type Effect = { observers: Set>; run(): void; @@ -233,6 +236,66 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +/** Device Preferences ********************************************************/ + +// A user preference as the device reports it, unknown until it answers, and as the app overrides it until set(undefined), the shape MQ5 §13's requestOverride() and clearOverride() give the web +export function devicePreference() { + const device = observable(undefined); + const preference = observable( + (read, override) => override ?? read(device), + ); + return { device, preference } as const; +} + +/** Reduced Motion ************************************************************/ + +// AccessibilityInfo answers asynchronously; until it does the device's preference is unknown, so a query on it does not match and a negated one does +export const { device: deviceReducedMotion, preference: reducedMotion } = + devicePreference(); + +function toReducedMotionPreference( + isReduceMotionEnabled: boolean, +): ReducedMotionPreference { + return isReduceMotionEnabled ? "reduce" : "no-preference"; +} + +// The answer AccessibilityInfo's read gave, which the jest helper restores between tests; a change event after it is newer +export const deviceReducedMotionAnswer = observable< + ReducedMotionPreference | undefined +>(undefined); + +// A consumer's test double may leave out AccessibilityInfo or its change event, and the read below still answers +function followReduceMotionChanges( + accessibilityInfo: + | Readonly>> + | undefined, +): void { + if (typeof accessibilityInfo?.addEventListener !== "function") { + return; + } + + accessibilityInfo.addEventListener( + "reduceMotionChanged", + (isReduceMotionEnabled: boolean) => { + deviceReducedMotion.set(toReducedMotionPreference(isReduceMotionEnabled)); + }, + ); +} + +followReduceMotionChanges(AccessibilityInfo); + +// React Native rejects where the platform has no accessibility module, and a test double may leave out the call: either way the user has stated no preference to it +void Promise.resolve() + .then(() => AccessibilityInfo.isReduceMotionEnabled()) + .then(toReducedMotionPreference, () => "no-preference" as const) + .then((answer) => { + deviceReducedMotionAnswer.set(answer); + // A change event that arrived first is newer than the answer + if (deviceReducedMotion.get() === undefined) { + deviceReducedMotion.set(answer); + } + }); + /** Containers ****************************************************************/ export type ContainerContextValue = Record< diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..03f1e0c8 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -150,3 +150,11 @@ export interface ColorScheme { get: () => ColorSchemeName | null | undefined; set: (value: ColorSchemeName | null | undefined) => void; } + +export type ReducedMotionPreference = "no-preference" | "reduce"; + +export interface ReducedMotion { + get: () => ReducedMotionPreference | undefined; + /** Overrides the device's setting until `null`, which follows the device again; on web it changes only what `get()` returns */ + set: (value: ReducedMotionPreference | null) => void; +} diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..7687298c 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -9,12 +9,18 @@ import { Appearance } from "react-native"; import type { ColorScheme, Props, + ReducedMotion, StyledConfiguration, StyledOptions, } from "react-native-css"; -import type { ReactComponent, Styled } from "../runtime.types"; +import type { + ReactComponent, + ReducedMotionPreference, + Styled, +} from "../runtime.types"; import { assignStyle } from "./assign-style"; +import { browserPreference } from "./browser-preference"; const defaultMapping: StyledConfiguration> = { className: "style", @@ -72,6 +78,29 @@ export const colorScheme: ColorScheme = { }, }; +export const reducedMotion: ReducedMotion = + browserPreference(() => { + // Server rendering has no window, and React Native's jest environment a window without matchMedia + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return undefined; + } + + // Scripts read this, never a render, so each read asks matchMedia again, whose answer a test's double may change; a test's stub may return no list at all + const matchMedia: ( + this: Window, + query: string, + ) => MediaQueryList | undefined = window.matchMedia; + const query = matchMedia.call(window, "(prefers-reduced-motion: reduce)"); + if (query === undefined) { + return undefined; + } + + return query.matches ? "reduce" : "no-preference"; + }); + /** * @deprecated Use `` instead. */ diff --git a/src/web/browser-preference.ts b/src/web/browser-preference.ts new file mode 100644 index 00000000..ba2b458f --- /dev/null +++ b/src/web/browser-preference.ts @@ -0,0 +1,19 @@ +// The browser evaluates the stylesheet's @media rules and matchMedia() itself, so an override changes only what get() returns, where MQ5 §13's requestOverride() would reach both once a browser ships it +export function browserPreference( + readBrowser: () => Value | undefined, +): { + readonly get: () => Value | undefined; + readonly set: (value: Value | null) => void; +} { + let override: Value | undefined; + + return { + get: () => override ?? readBrowser(), + set: (value) => { + // A server renders every visitor's page in one process, so only a browser keeps an override + if (typeof window !== "undefined") { + override = value ?? undefined; + } + }, + }; +}