diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..e4c62545 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -232,6 +232,69 @@ test("light-dark()", () => { }); }); +test("prefers-reduced-motion", () => { + // `motion-reduce:` compiles to `reduce`, `motion-safe:` to `no-preference`, + // and a bare `@media (prefers-reduced-motion)` to the boolean (`!!`) form. + // The native runtime evaluates these against the reduceMotion observable + // (see native/media-query.test.tsx). + expect( + compile( + `@media (prefers-reduced-motion: reduce) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["=", "prefers-reduced-motion", "reduce"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); + + expect( + compile( + `@media (prefers-reduced-motion: no-preference) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["=", "prefers-reduced-motion", "no-preference"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); + + expect( + compile( + `@media (prefers-reduced-motion) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["!!", "prefers-reduced-motion"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); +}); + test("media query nested in rules", () => { const compiled = compile(` .my-class { diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 020b4aad..9f5841ae 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -5,7 +5,7 @@ import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; import { colorScheme } from "react-native-css/runtime"; -import { dimensions } from "../../native/reactivity"; +import { dimensions, reduceMotion } from "../../native/reactivity"; jest.mock("react-native", () => { const RN = jest.requireActual("react-native"); @@ -283,3 +283,224 @@ describe("max-resolution", () => { expect(component.props.style).toStrictEqual(undefined); }); }); + +// Outside the describe below, whose beforeEach overwrites the value before any +// assertion can see it. The documented cold-start default is motion ENABLED: the +// getter is async with no synchronous counterpart, so the first paint answers from +// this, and seeding true would suppress motion-safe: styling for every user. +test("reduceMotion defaults to false before AccessibilityInfo answers", () => { + expect(reduceMotion.get()).toBe(false); +}); + +describe("prefers-reduced-motion", () => { + // reduceMotion and colorScheme are module-global observables; reset them so + // each test starts from a known state (motion enabled, light scheme). + beforeEach(() => { + act(() => { + reduceMotion.set(false); + colorScheme.set("light"); + }); + }); + + test("reduce (motion-reduce:) — applies only when reduce motion is enabled", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: reduce) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Default: motion enabled → the reduce rule does not apply. + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + // Reactive both ways — toggling the OS flag off restores the base style. + act(() => { + reduceMotion.set(false); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("no-preference (motion-safe:) — applies only when reduce motion is disabled", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: no-preference) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Default: motion enabled → no-preference matches. + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("composes with prefers-color-scheme via `and`", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: reduce) and (prefers-color-scheme: dark) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Only reduce motion — the rule still needs dark. + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Both conditions now hold. + act(() => { + colorScheme.set("dark"); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("negation — not (prefers-reduced-motion: reduce)", () => { + registerCSS(` +.my-class { color: blue; } + +@media not all and (prefers-reduced-motion: reduce) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Motion enabled → not(reduce) is true → the rule applies. + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("bare boolean — @media (prefers-reduced-motion) is equivalent to reduce", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Bare boolean form matches when reduce motion is enabled (CSS: bare ≡ reduce). + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("an unrecognised value never matches", () => { + // The compiler emits ["=", name, value] for any value, with no allowlist, so + // this condition is reachable. MQ5 makes an unknown value false — a two-way + // branch on `no-preference` would alias everything else to `reduce`. + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: bogus-value) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); +}); + +// 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), (prefers-color-scheme: dark)", + true, + true, + false, + true, + ], + ["not (prefers-reduced-motion: reduce)", false, false, true, true], + ["(prefers-reduced-motion: REDUCE)", true, true, false, false], + ["(prefers-color-scheme: DARK)", false, true, false, 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) => { + afterEach(() => { + act(() => { + reduceMotion.set(false); + colorScheme.set("light"); + }); + }); + + 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(() => { + reduceMotion.set(motion === "reduce"); + 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..17f1e8e4 --- /dev/null +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -0,0 +1,89 @@ +// src/native/reactivity.ts is the root of every media feature — colorScheme, vw/vh, +// containers and reduceMotion all live there. Seeding reduceMotion reaches out to +// AccessibilityInfo at module scope, so a host without a native AccessibilityInfo +// must not take the whole module down with it. +// +// A Proxy over requireActual rather than a spread: spreading react-native eagerly +// triggers its lazy DevMenu getter and throws. +const noopSubscription = { remove: () => undefined }; + +const withAccessibilityInfo = + (accessibilityInfo: unknown) => (): Record => { + const actual = jest.requireActual>("react-native"); + + return new Proxy(actual, { + get: (target, property): unknown => + property === "AccessibilityInfo" + ? accessibilityInfo + : Reflect.get(target, property), + }); + }; + +describe("an AccessibilityInfo without isReduceMotionEnabled", () => { + test("does not stop the reactivity module importing", () => { + jest.resetModules(); + jest.doMock( + "react-native", + withAccessibilityInfo({ addEventListener: () => noopSubscription }), + ); + + const reactivity = jest.requireActual< + typeof import("../../native/reactivity") + >("../../native/reactivity"); + + expect(reactivity.reduceMotion.get()).toBe(false); + expect(reactivity.colorScheme).toBeDefined(); + expect(reactivity.vw).toBeDefined(); + }); +}); + +describe("an AccessibilityInfo whose getter rejects", () => { + test("leaves the safe default in place and does not reject unhandled", async () => { + jest.resetModules(); + jest.doMock( + "react-native", + withAccessibilityInfo({ + isReduceMotionEnabled: () => + Promise.reject(new Error("NativeAccessibilityManager unavailable")), + addEventListener: () => noopSubscription, + }), + ); + + const reactivity = jest.requireActual< + typeof import("../../native/reactivity") + >("../../native/reactivity"); + await Promise.resolve(); + + expect(reactivity.reduceMotion.get()).toBe(false); + }); +}); + +describe("a working AccessibilityInfo", () => { + test("seeds reduceMotion from it and stays live on the change event", async () => { + jest.resetModules(); + let changed: ((enabled: boolean) => void) | undefined; + jest.doMock( + "react-native", + withAccessibilityInfo({ + isReduceMotionEnabled: () => Promise.resolve(true), + addEventListener: (event: string, listener: (v: boolean) => void) => { + if (event === "reduceMotionChanged") changed = listener; + return noopSubscription; + }, + }), + ); + + const reactivity = jest.requireActual< + typeof import("../../native/reactivity") + >("../../native/reactivity"); + await Promise.resolve(); + + // The seed is what connects the observable to the OS at all; without it the + // flag is a value the library only ever writes to itself + expect(reactivity.reduceMotion.get()).toBe(true); + + expect(changed).toBeDefined(); + changed?.(false); + expect(reactivity.reduceMotion.get()).toBe(false); + }); +}); diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 1408adc0..858a22e3 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -145,8 +145,10 @@ export function parseMediaFeatureValue( builder: StylesheetBuilder, ): StyleDescriptor { switch (value.type) { - case "boolean": case "ident": + // A keyword is ASCII case-insensitive (css-values-4 §4.1), so `DARK` names the scheme `dark` does + return value.value.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); + case "boolean": case "integer": case "number": return value.value; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..d29a422e 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -3,7 +3,7 @@ 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, reduceMotion, vh, vw, type Getter } from "../reactivity"; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); @@ -12,8 +12,12 @@ export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { function test(mediaQuery: MediaCondition, get: Getter): Boolean { switch (mediaQuery[0]) { case "[]": - case "!!": return false; + case "!!": + // MQ5: a feature in a boolean context is true unless its value is `no-preference` + return mediaQuery[1] === "prefers-reduced-motion" + ? get(reduceMotion) + : false; case "!": return !test(mediaQuery[1], get); case "&": @@ -47,6 +51,10 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "prefers-color-scheme": { return value === get(colorScheme); } + case "prefers-reduced-motion": { + // An unknown value matches nothing (MQ5), so this is an equality, not a two-way branch + return value === (get(reduceMotion) ? "reduce" : "no-preference"); + } 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..5d2b6715 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, @@ -233,6 +234,28 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +/** Reduce Motion ************************************************************/ + +// AccessibilityInfo answers asynchronously, so motion counts as enabled until it does; Android reports its animator scale (react-native#31221) +export const reduceMotion = observable(false); + +// Every media feature imports this module, so a platform without AccessibilityInfo must not throw here +try { + AccessibilityInfo.isReduceMotionEnabled() + .then((enabled) => reduceMotion.set(enabled)) + .catch(() => undefined); +} catch { + // Motion stays enabled +} + +try { + AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) => + reduceMotion.set(enabled), + ); +} catch { + // The flag keeps the value the seed resolved to +} + /** Containers ****************************************************************/ export type ContainerContextValue = Record<