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
63 changes: 63 additions & 0 deletions src/__tests__/compiler/compiler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,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 {
Expand Down
155 changes: 154 additions & 1 deletion src/__tests__/native/media-query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -283,3 +283,156 @@ 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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
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(<View testID={testID} className="my-class" />);
const component = screen.getByTestId(testID);

expect(component.props.style).toStrictEqual({ color: "#00f" });

act(() => {
reduceMotion.set(true);
});
expect(component.props.style).toStrictEqual({ color: "#00f" });
});
});
83 changes: 83 additions & 0 deletions src/__tests__/native/reduce-motion-init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// 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<string, unknown> => {
const actual = jest.requireActual<Record<string, unknown>>("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", async () => {
jest.resetModules();
jest.doMock(
"react-native",
withAccessibilityInfo({ addEventListener: () => noopSubscription }),
);

const reactivity = await import("../../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 = await import("../../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 = await import("../../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);
});
});
16 changes: 14 additions & 2 deletions src/native/conditions/media-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -12,8 +12,13 @@ export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) {
function test(mediaQuery: MediaCondition, get: Getter): Boolean {
switch (mediaQuery[0]) {
case "[]":
case "!!":
return false;
case "!!":
// A bare boolean media feature, e.g. `@media (prefers-reduced-motion)`.
// Per CSS, bare `(prefers-reduced-motion)` is equivalent to `reduce`.
return mediaQuery[1] === "prefers-reduced-motion"
? get(reduceMotion)
: false;
case "!":
return !test(mediaQuery[1], get);
case "&":
Expand Down Expand Up @@ -47,6 +52,13 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean {
case "prefers-color-scheme": {
return value === get(colorScheme);
}
case "prefers-reduced-motion": {
// `motion-reduce:` compiles to `reduce`, `motion-safe:` to
// `no-preference`. An equality test rather than a two-way branch, so an
// unrecognised value is false as MQ5 requires, instead of aliasing to
// `reduce`.
return value === (get(reduceMotion) ? "reduce" : "no-preference");
}
case "display-mode":
return value === "native" || Platform.OS === value;
case "min-width":
Expand Down
Loading