diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..aa8c9e8a 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -232,6 +232,33 @@ test("light-dark()", () => { }); }); +test.each([ + [ + "(prefers-reduced-motion: reduce)", + ["=", "prefers-reduced-motion", "reduce"], + ], + [ + "(prefers-reduced-motion: no-preference)", + ["=", "prefers-reduced-motion", "no-preference"], + ], + ["(prefers-reduced-motion)", ["!!", "prefers-reduced-motion"]], + [ + "(prefers-reduced-motion: REDUCE)", + ["=", "prefers-reduced-motion", "reduce"], + ], + ["(prefers-color-scheme: DARK)", ["=", "prefers-color-scheme", "dark"]], + ["IOS", ["=", "platform", "ios"]], + ["(grid: 1)", ["=", "grid", true]], + ["(color: 8)", ["=", "color", 8]], + ["(-webkit-device-pixel-ratio: 2)", ["=", "-webkit-device-pixel-ratio", 2]], +])("@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 query nested in rules", () => { const compiled = compile(` .my-class { @@ -314,6 +341,27 @@ test("container queries", () => { }); }); +test("a container query keyword compiles in lowercase", () => { + expect( + compile( + `@container (orientation: LANDSCAPE) { .child { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + cq: [{ m: ["=", "orientation", "landscape"] }], + d: [{ opacity: 0 }], + s: [2, 1], + }, + ], + ], + ], + }); +}); + test("warnings", () => { 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..782cff98 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,161 @@ 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(() => { + deviceReducedMotion.set(answered); + }); + + 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 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" }); +}); + +describe("the react-native-css/jest reset", () => { + test("lets a test override the preference", () => { + reducedMotion.set("reduce"); + + expect(reducedMotion.get()).toBe("reduce"); + }); + + test("starts the next test from the device's preference", () => { + expect(reducedMotion.get()).toBe("no-preference"); + }); +}); + +// 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) and (prefers-color-scheme: dark)", + false, + 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) => { + 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..c4984b69 --- /dev/null +++ b/src/__tests__/native/reduce-motion-init.test.ts @@ -0,0 +1,137 @@ +type ReduceMotionListener = (isReduceMotionEnabled: boolean) => void; + +interface AccessibilityInfoDouble { + readonly isReduceMotionEnabled?: () => Promise; + readonly addEventListener?: ( + event: string, + listener: ReduceMotionListener, + ) => { readonly remove: () => void }; +} + +function importReactivityWith(accessibilityInfo: AccessibilityInfoDouble) { + 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("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 AccessibilityInfo has no change event, without failing the import", async () => { + const { reducedMotion } = importReactivityWith({ + isReduceMotionEnabled: () => Promise.resolve(true), + }); + await settle(); + + expect(reducedMotion.get()).toBe("no-preference"); + }); + + test("an override stands over the device's answer until the device's setting changes", async () => { + const { accessibilityInfo, answer, change } = controlledAccessibilityInfo(); + const { reducedMotion } = importReactivityWith(accessibilityInfo); + answer(true); + await settle(); + + reducedMotion.set("no-preference"); + expect(reducedMotion.get()).toBe("no-preference"); + + reducedMotion.set(undefined); + expect(reducedMotion.get()).toBe("reduce"); + + reducedMotion.set("no-preference"); + change(false); + change(true); + expect(reducedMotion.get()).toBe("reduce"); + }); +}); diff --git a/src/__tests__/web/reduced-motion.test.ts b/src/__tests__/web/reduced-motion.test.ts new file mode 100644 index 00000000..5dde99f6 --- /dev/null +++ b/src/__tests__/web/reduced-motion.test.ts @@ -0,0 +1,28 @@ +import { reducedMotion } from "../../web/api"; + +test.each([ + [true, "reduce"], + [false, "no-preference"], +] as const)( + "reducedMotion reads %s from matchMedia as %s, and leaves it to the browser", + (matches, preference) => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)" && matches, + }), + }); + + try { + expect(reducedMotion.get()).toBe(preference); + reducedMotion.set(matches ? "no-preference" : "reduce"); + expect(reducedMotion.get()).toBe(preference); + } finally { + Reflect.deleteProperty(window, "matchMedia"); + } + }, +); + +test("reducedMotion has no preference to read without matchMedia", () => { + expect(reducedMotion.get()).toBeUndefined(); +}); diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts index 1408adc0..66558a07 100644 --- a/src/compiler/media-query.ts +++ b/src/compiler/media-query.ts @@ -8,6 +8,7 @@ import type { QueryFeatureFor_MediaFeatureId, } from "lightningcss"; +import { asciiLowerCase } from "../utilities/ascii-case"; import type { MediaCondition, MediaFeatureComparison, @@ -32,7 +33,8 @@ export function parseMediaQuery( // These all/screen are not conditions, they always apply if (query.mediaType !== "all" && query.mediaType !== "screen") { - platformCondition = ["=", "platform", query.mediaType]; + // Media types are ASCII case-insensitive (MQ4 §3), and lightningcss lowercases only all, print and screen + platformCondition = ["=", "platform", asciiLowerCase(query.mediaType)]; } } @@ -145,8 +147,10 @@ export function parseMediaFeatureValue( builder: StylesheetBuilder, ): StyleDescriptor { switch (value.type) { - case "boolean": case "ident": + // Keywords are ASCII case-insensitive (css-values-4 §4.1) + return asciiLowerCase(value.value); + case "boolean": case "integer": case "number": return value.value; diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..5cdeca28 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -3,9 +3,12 @@ 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 { + colorScheme, + reducedMotion, + StyleCollection, +} from "react-native-css/native"; +import { dimensions } from "react-native-css/native-internal"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -18,11 +21,13 @@ declare global { export const testID = "react-native-css"; +// The components a consumer's suite renders read the package's own build, so the reset reaches them through the package's entry beforeEach(() => { StyleCollection.styles.clear(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); colorScheme.set(null); + reducedMotion.set(null); }); const debugDefault = Boolean( diff --git a/src/native-internal/index.ts b/src/native-internal/index.ts index e80c9bd6..cee72f4e 100644 --- a/src/native-internal/index.ts +++ b/src/native-internal/index.ts @@ -1,3 +1,4 @@ export * from "./root"; export * from "./style-collection"; export * from "./variables"; +export { 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..e62fb5b6 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 every value but no-preference (MQ5 §2.4.2) + 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..4de863df 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,44 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +/** Reduced Motion ************************************************************/ + +// AccessibilityInfo answers asynchronously; until it does the device's preference is unknown, so a query on it does not match and a negated one does +export const deviceReducedMotion = observable< + ReducedMotionPreference | undefined +>(undefined); + +// A script's override stands over the device's preference until the device's setting changes, as colorScheme's does +export const reducedMotion = observable< + ReducedMotionPreference | undefined, + ReducedMotionPreference | undefined +>((read, override) => override ?? read(deviceReducedMotion)); + +function followDeviceReducedMotion(isReduceMotionEnabled: boolean) { + deviceReducedMotion.set(isReduceMotionEnabled ? "reduce" : "no-preference"); + reducedMotion.set(undefined); +} + +function seedDeviceReducedMotion(isReduceMotionEnabled: boolean) { + // A change event that arrived first is newer than what the getter read + if (deviceReducedMotion.get() === undefined) { + deviceReducedMotion.set(isReduceMotionEnabled ? "reduce" : "no-preference"); + } +} + +async function readDeviceReducedMotion(): Promise { + AccessibilityInfo.addEventListener( + "reduceMotionChanged", + followDeviceReducedMotion, + ); + return await AccessibilityInfo.isReduceMotionEnabled(); +} + +// React Native rejects where the platform has no accessibility module, and a test double may lack either call: either way the user has stated no preference to it +readDeviceReducedMotion().then(seedDeviceReducedMotion, () => { + seedDeviceReducedMotion(false); +}); + /** Containers ****************************************************************/ export type ContainerContextValue = Record< diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..f0224372 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; + /** `null` follows the device's setting again */ + set: (value: ReducedMotionPreference | null) => void; +} diff --git a/src/utilities/ascii-case.ts b/src/utilities/ascii-case.ts new file mode 100644 index 00000000..748b92c5 --- /dev/null +++ b/src/utilities/ascii-case.ts @@ -0,0 +1,4 @@ +// Selectors §6.3 and HTML fold ASCII letters only: toLowerCase would also fold U+212A KELVIN SIGN onto k +export function asciiLowerCase(text: string): string { + return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..b2b932f0 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -9,6 +9,7 @@ import { Appearance } from "react-native"; import type { ColorScheme, Props, + ReducedMotion, StyledConfiguration, StyledOptions, } from "react-native-css"; @@ -72,6 +73,22 @@ export const colorScheme: ColorScheme = { }, }; +export const reducedMotion: ReducedMotion = { + get() { + // Server rendering has no window, and React Native's jest environment a window without matchMedia + if (typeof window === "undefined" || !("matchMedia" in window)) { + return undefined; + } + + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "reduce" + : "no-preference"; + }, + set() { + // The browser evaluates prefers-reduced-motion in the stylesheet itself, so a script cannot override it + }, +}; + /** * @deprecated Use `` instead. */