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
125 changes: 124 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,126 @@ describe("max-resolution", () => {
expect(component.props.style).toStrictEqual(undefined);
});
});

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" });
});
});
15 changes: 13 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,12 @@ 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`. Mirror the prefers-color-scheme wiring, reading the
// live OS flag from the AccessibilityInfo-backed reduceMotion observable.
return value === "no-preference" ? !get(reduceMotion) : get(reduceMotion);
}
case "display-mode":
return value === "native" || Platform.OS === value;
case "min-width":
Expand Down
18 changes: 18 additions & 0 deletions src/native/reactivity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable */
import { createContext } from "react";
import {
AccessibilityInfo,
Appearance,
Dimensions,
type ColorSchemeName,
Expand Down Expand Up @@ -221,6 +222,23 @@ export const colorScheme = observable<ColorSchemeName>(
);
Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));

/** Reduce Motion ************************************************************/

// Mirror the Color Scheme wiring above — but AccessibilityInfo has no
// synchronous getter (Appearance.getColorScheme() does), so this can't be
// seeded synchronously. It starts `false` (motion enabled — the safe default),
// flips when isReduceMotionEnabled() resolves (a brief, unavoidable cold-start
// window), and stays live via reduceMotionChanged. iOS drives this directly;
// on Android the OS surface is the animation duration scale (react-native
// #31221). The one-shot seed read is intentionally fire-and-forget.
export const reduceMotion = observable(false);
AccessibilityInfo.isReduceMotionEnabled().then((enabled) =>
reduceMotion.set(enabled),
);
AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) =>
reduceMotion.set(enabled),
);

/** Containers ****************************************************************/

export type ContainerContextValue = Record<string, WeakKey>;
Expand Down