Skip to content

Commit 48eb155

Browse files
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.
1 parent a5002c5 commit 48eb155

15 files changed

Lines changed: 818 additions & 17 deletions

‎src/__tests__/compiler/compiler.test.tsx‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,103 @@ test("light-dark()", () => {
232232
});
233233
});
234234

235+
const reduce = ["=", "prefers-reduced-motion", "reduce"];
236+
const noPreference = ["=", "prefers-reduced-motion", "no-preference"];
237+
const dark = ["=", "prefers-color-scheme", "dark"];
238+
239+
test.each([
240+
["(prefers-reduced-motion: reduce)", reduce],
241+
["(prefers-reduced-motion: no-preference)", noPreference],
242+
["(prefers-reduced-motion)", ["!!", "prefers-reduced-motion"]],
243+
["not (prefers-reduced-motion)", ["!", ["!!", "prefers-reduced-motion"]]],
244+
["not (prefers-reduced-motion: reduce)", ["!", reduce]],
245+
["not (prefers-reduced-motion: no-preference)", ["!", noPreference]],
246+
["not all and (prefers-reduced-motion: reduce)", ["!", reduce]],
247+
["(PREFERS-REDUCED-MOTION: reduce)", reduce],
248+
["( prefers-reduced-motion : reduce )", reduce],
249+
["(prefers-reduced-motion:/* a comment */reduce)", reduce],
250+
["((prefers-reduced-motion: reduce))", reduce],
251+
["screen and (prefers-reduced-motion: reduce)", reduce],
252+
["only screen and (prefers-reduced-motion: reduce)", reduce],
253+
["all and (prefers-reduced-motion: reduce)", reduce],
254+
["not screen and (prefers-reduced-motion: reduce)", ["!", reduce]],
255+
["(prefers-reduced-motion: reduce), print", reduce],
256+
[
257+
"(prefers-reduced-motion: reduce) and (min-width: 1px)",
258+
["&", [reduce, [">=", "width", 1]]],
259+
],
260+
[
261+
"(prefers-reduced-motion: reduce) and (prefers-color-scheme: dark)",
262+
["&", [reduce, dark]],
263+
],
264+
[
265+
"(prefers-reduced-motion: reduce) or (prefers-color-scheme: dark)",
266+
["|", [reduce, dark]],
267+
],
268+
[
269+
"not ((prefers-reduced-motion: reduce) and (prefers-color-scheme: dark))",
270+
["!", ["&", [reduce, dark]]],
271+
],
272+
[
273+
"(prefers-reduced-motion: reduce) and (not (prefers-color-scheme: dark))",
274+
["&", [reduce, ["!", dark]]],
275+
],
276+
[
277+
"(prefers-reduced-motion: no-preference) and (prefers-reduced-motion: reduce)",
278+
["&", [noPreference, reduce]],
279+
],
280+
[
281+
"(prefers-reduced-motion: reduce) and (unknown-feature)",
282+
["&", [reduce, ["!!", "unknown-feature"]]],
283+
],
284+
[
285+
"(prefers-reduced-motion: reduce) or (unknown-feature)",
286+
["|", [reduce, ["!!", "unknown-feature"]]],
287+
],
288+
])("@media %s compiles to its condition", (query, condition) => {
289+
expect(
290+
compile(`@media ${query} { .my-class { opacity: 0 } }`).stylesheet(),
291+
).toStrictEqual({
292+
s: [["my-class", [{ s: [2, 1], m: [condition], d: [{ opacity: 0 }] }]]],
293+
});
294+
});
295+
296+
test("@media (prefers-reduced-motion: reduce), (prefers-color-scheme: dark) compiles a rule for each query", () => {
297+
expect(
298+
compile(
299+
`@media (prefers-reduced-motion: reduce), (prefers-color-scheme: dark) { .my-class { opacity: 0 } }`,
300+
).stylesheet(),
301+
).toStrictEqual({
302+
s: [
303+
[
304+
"my-class",
305+
[
306+
{ s: [2, 1], m: [reduce], d: [{ opacity: 0 }] },
307+
{ s: [4, 1], m: [dark], d: [{ opacity: 0 }] },
308+
],
309+
],
310+
],
311+
});
312+
});
313+
314+
test("@media print and (prefers-reduced-motion: reduce) compiles to no rule", () => {
315+
expect(
316+
compile(
317+
`@media print and (prefers-reduced-motion: reduce) { .my-class { opacity: 0 } }`,
318+
).stylesheet(),
319+
).toStrictEqual({});
320+
});
321+
322+
test("@media not print and (prefers-reduced-motion: reduce) compiles to a rule with no condition", () => {
323+
expect(
324+
compile(
325+
`@media not print and (prefers-reduced-motion: reduce) { .my-class { opacity: 0 } }`,
326+
).stylesheet(),
327+
).toStrictEqual({
328+
s: [["my-class", [{ s: [2, 1], d: [{ opacity: 0 }] }]]],
329+
});
330+
});
331+
235332
test("media query nested in rules", () => {
236333
const compiled = compile(`
237334
.my-class {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
reducedMotion as rootReducedMotion,
3+
type ReducedMotionPreference,
4+
} from "react-native-css";
5+
import { reducedMotion as nativeReducedMotion } from "react-native-css/native";
6+
import { reducedMotion as webReducedMotion } from "react-native-css/web";
7+
8+
// This module is checked by TypeScript and deliberately excluded from Jest.
9+
export const preferences: readonly (ReducedMotionPreference | undefined)[] = [
10+
rootReducedMotion.get(),
11+
nativeReducedMotion.get(),
12+
webReducedMotion.get(),
13+
];
14+
15+
// @ts-expect-error get() is undefined until the device answers.
16+
export const answered: ReducedMotionPreference = rootReducedMotion.get();
17+
18+
rootReducedMotion.set("reduce");
19+
nativeReducedMotion.set("no-preference");
20+
webReducedMotion.set(null);
21+
22+
// @ts-expect-error A preference is one of the keywords prefers-reduced-motion takes.
23+
rootReducedMotion.set("bogus");
24+
// @ts-expect-error A preference is one of the keywords prefers-reduced-motion takes, as written.
25+
nativeReducedMotion.set("REDUCE");
26+
// @ts-expect-error null, not an empty string, follows the device again.
27+
webReducedMotion.set("");
28+
// @ts-expect-error null, not undefined, follows the device again.
29+
nativeReducedMotion.set(undefined);
30+
// @ts-expect-error The preference is a keyword, not a boolean.
31+
webReducedMotion.set(true);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { reducedMotion } from "react-native-css/runtime";
2+
3+
import { resetBetweenTests } from "../../jest/reset";
4+
import { deviceReducedMotion } from "../../native/reactivity";
5+
6+
describe("the react-native-css/jest reset", () => {
7+
test("clears a test's override", () => {
8+
reducedMotion.set("reduce");
9+
expect(reducedMotion.get()).toBe("reduce");
10+
11+
resetBetweenTests();
12+
13+
expect(reducedMotion.get()).toBe("no-preference");
14+
});
15+
16+
test("puts a device a test changed back to AccessibilityInfo's answer", () => {
17+
deviceReducedMotion.set("reduce");
18+
expect(reducedMotion.get()).toBe("reduce");
19+
20+
resetBetweenTests();
21+
22+
expect(reducedMotion.get()).toBe("no-preference");
23+
});
24+
});

0 commit comments

Comments
 (0)