From 48eb155827751e96f6b4d204e8843f404a5feb2a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 27 Oct 2026 02:16:59 +0200 Subject: [PATCH] feat(native): evaluate prefers-reduced-motion so motion-reduce / motion-safe work on native The native runtime evaluated prefers-color-scheme but not prefers-reduced-motion, so Tailwind's motion-reduce: and motion-safe: variants compiled and then never applied on a device. devicePreference() keeps a user preference as the device reports it apart from the app's override, which holds until it is cleared. The reduced-motion preference is built on it: the device's answer comes from AccessibilityInfo.isReduceMotionEnabled() and follows reduceMotionChanged from import, as colorScheme follows Appearance. React Native reads the setting only through a callback, so the preference is unknown until it answers, and a reduced-motion query does not match before then. The evaluator compares the prefers-reduced-motion keyword, and (prefers-reduced-motion) alone matches reduce. reducedMotion joins colorScheme in the API: get() returns the preference, and set() overrides it until set(null). On web get() reads matchMedia on each call and set() changes only what get() returns. The jest helper's reset clears an override and puts the device back to AccessibilityInfo's answer. --- src/__tests__/compiler/compiler.test.tsx | 97 +++++++ src/__tests__/native/_reduced-motion-types.ts | 31 +++ src/__tests__/native/jest-reset.test.ts | 24 ++ src/__tests__/native/media-query.test.tsx | 255 +++++++++++++++++- .../native/reduce-motion-init.test.ts | 164 +++++++++++ src/__tests__/web/reduced-motion.test.ts | 77 ++++++ src/jest/index.ts | 11 +- src/jest/reset.ts | 22 ++ src/native-internal/index.ts | 5 + src/native/api.tsx | 11 + src/native/conditions/media-query.ts | 17 +- src/native/reactivity.ts | 63 +++++ src/runtime.types.ts | 8 + src/web/api.tsx | 31 ++- src/web/browser-preference.ts | 19 ++ 15 files changed, 818 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/native/_reduced-motion-types.ts create mode 100644 src/__tests__/native/jest-reset.test.ts create mode 100644 src/__tests__/native/reduce-motion-init.test.ts create mode 100644 src/__tests__/web/reduced-motion.test.ts create mode 100644 src/jest/reset.ts create mode 100644 src/web/browser-preference.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..24a2cb4f 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -232,6 +232,103 @@ test("light-dark()", () => { }); }); +const reduce = ["=", "prefers-reduced-motion", "reduce"]; +const noPreference = ["=", "prefers-reduced-motion", "no-preference"]; +const dark = ["=", "prefers-color-scheme", "dark"]; + +test.each([ + ["(prefers-reduced-motion: reduce)", reduce], + ["(prefers-reduced-motion: no-preference)", noPreference], + ["(prefers-reduced-motion)", ["!!", "prefers-reduced-motion"]], + ["not (prefers-reduced-motion)", ["!", ["!!", "prefers-reduced-motion"]]], + ["not (prefers-reduced-motion: reduce)", ["!", reduce]], + ["not (prefers-reduced-motion: no-preference)", ["!", noPreference]], + ["not all and (prefers-reduced-motion: reduce)", ["!", reduce]], + ["(PREFERS-REDUCED-MOTION: reduce)", reduce], + ["( prefers-reduced-motion : reduce )", reduce], + ["(prefers-reduced-motion:/* a comment */reduce)", reduce], + ["((prefers-reduced-motion: reduce))", reduce], + ["screen and (prefers-reduced-motion: reduce)", reduce], + ["only screen and (prefers-reduced-motion: reduce)", reduce], + ["all and (prefers-reduced-motion: reduce)", reduce], + ["not screen and (prefers-reduced-motion: reduce)", ["!", reduce]], + ["(prefers-reduced-motion: reduce), print", reduce], + [ + "(prefers-reduced-motion: reduce) and (min-width: 1px)", + ["&", [reduce, [">=", "width", 1]]], + ], + [ + "(prefers-reduced-motion: reduce) and (prefers-color-scheme: dark)", + ["&", [reduce, dark]], + ], + [ + "(prefers-reduced-motion: reduce) or (prefers-color-scheme: dark)", + ["|", [reduce, dark]], + ], + [ + "not ((prefers-reduced-motion: reduce) and (prefers-color-scheme: dark))", + ["!", ["&", [reduce, dark]]], + ], + [ + "(prefers-reduced-motion: reduce) and (not (prefers-color-scheme: dark))", + ["&", [reduce, ["!", dark]]], + ], + [ + "(prefers-reduced-motion: no-preference) and (prefers-reduced-motion: reduce)", + ["&", [noPreference, reduce]], + ], + [ + "(prefers-reduced-motion: reduce) and (unknown-feature)", + ["&", [reduce, ["!!", "unknown-feature"]]], + ], + [ + "(prefers-reduced-motion: reduce) or (unknown-feature)", + ["|", [reduce, ["!!", "unknown-feature"]]], + ], +])("@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 (prefers-reduced-motion: reduce), (prefers-color-scheme: dark) compiles a rule for each query", () => { + expect( + compile( + `@media (prefers-reduced-motion: reduce), (prefers-color-scheme: dark) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { s: [2, 1], m: [reduce], d: [{ opacity: 0 }] }, + { s: [4, 1], m: [dark], d: [{ opacity: 0 }] }, + ], + ], + ], + }); +}); + +test("@media print and (prefers-reduced-motion: reduce) compiles to no rule", () => { + expect( + compile( + `@media print and (prefers-reduced-motion: reduce) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({}); +}); + +test("@media not print and (prefers-reduced-motion: reduce) compiles to a rule with no condition", () => { + expect( + compile( + `@media not print and (prefers-reduced-motion: reduce) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [["my-class", [{ s: [2, 1], d: [{ opacity: 0 }] }]]], + }); +}); + test("media query nested in rules", () => { const compiled = compile(` .my-class { diff --git a/src/__tests__/native/_reduced-motion-types.ts b/src/__tests__/native/_reduced-motion-types.ts new file mode 100644 index 00000000..424aab4a --- /dev/null +++ b/src/__tests__/native/_reduced-motion-types.ts @@ -0,0 +1,31 @@ +import { + reducedMotion as rootReducedMotion, + type ReducedMotionPreference, +} from "react-native-css"; +import { reducedMotion as nativeReducedMotion } from "react-native-css/native"; +import { reducedMotion as webReducedMotion } from "react-native-css/web"; + +// This module is checked by TypeScript and deliberately excluded from Jest. +export const preferences: readonly (ReducedMotionPreference | undefined)[] = [ + rootReducedMotion.get(), + nativeReducedMotion.get(), + webReducedMotion.get(), +]; + +// @ts-expect-error get() is undefined until the device answers. +export const answered: ReducedMotionPreference = rootReducedMotion.get(); + +rootReducedMotion.set("reduce"); +nativeReducedMotion.set("no-preference"); +webReducedMotion.set(null); + +// @ts-expect-error A preference is one of the keywords prefers-reduced-motion takes. +rootReducedMotion.set("bogus"); +// @ts-expect-error A preference is one of the keywords prefers-reduced-motion takes, as written. +nativeReducedMotion.set("REDUCE"); +// @ts-expect-error null, not an empty string, follows the device again. +webReducedMotion.set(""); +// @ts-expect-error null, not undefined, follows the device again. +nativeReducedMotion.set(undefined); +// @ts-expect-error The preference is a keyword, not a boolean. +webReducedMotion.set(true); 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..7512eb14 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,254 @@ 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 156, Firefox 157 and WebKit 27.2 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], + ["not (prefers-reduced-motion: reduce)", false, false, true, true], + ["not (prefers-reduced-motion: no-preference)", true, true, false, false], + ["not all and (prefers-reduced-motion: reduce)", false, false, true, true], + ["(prefers-reduced-motion: bogus)", false, false, false, false], + ["(prefers-reduced-motion: initial)", false, false, false, false], + ["(PREFERS-REDUCED-MOTION: reduce)", true, true, false, false], + ["( prefers-reduced-motion : reduce )", true, true, false, false], + ["(prefers-reduced-motion:/* a comment */reduce)", true, true, false, false], + ["((prefers-reduced-motion: reduce))", true, true, false, false], + ["screen and (prefers-reduced-motion: reduce)", true, true, false, false], + [ + "only screen and (prefers-reduced-motion: reduce)", + true, + true, + false, + false, + ], + ["all and (prefers-reduced-motion: reduce)", true, true, false, false], + ["print and (prefers-reduced-motion: reduce)", false, false, false, false], + ["not screen and (prefers-reduced-motion: reduce)", false, false, true, true], + ["not print and (prefers-reduced-motion: reduce)", true, true, true, true], + ["(prefers-reduced-motion: reduce), print", true, true, false, false], + [ + "(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, + ], + [ + "(prefers-reduced-motion: reduce) or (prefers-color-scheme: dark)", + true, + true, + false, + true, + ], + [ + "not ((prefers-reduced-motion: reduce) and (prefers-color-scheme: dark))", + true, + false, + true, + true, + ], + [ + "(prefers-reduced-motion: reduce) and (not (prefers-color-scheme: dark))", + true, + false, + false, + false, + ], + [ + "(prefers-reduced-motion: no-preference) and (prefers-reduced-motion: reduce)", + false, + false, + false, + false, + ], + [ + "(prefers-reduced-motion: reduce) and (unknown-feature)", + false, + false, + false, + false, + ], + [ + "(prefers-reduced-motion: reduce) or (unknown-feature)", + true, + true, + false, + false, + ], + ["(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..ea970a1f 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 ************************************************************/ + +// React Native reads the setting only through a callback, so the device's preference is unknown until AccessibilityInfo answers +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; + } + }, + }; +}