History

fix(react): honor prefers-reduced-motion in the shared stylesheet (#1892)

* fix(react): honor prefers-reduced-motion in the shared stylesheet * Test queue timeout with a controlled clock * Cover reduced motion changes in the browser ---------

ra-co88 committed 1 month ago

Commit 905e0971 parent 513426c3 files · +90 −0Browse files

Unified · Split

3 files
  1. .changeset/reduced-motion.md +10 −0
  2. e2e/scenarios/reduced-motion.test.ts +59 −0
  3. packages/react/src/styles/globals.css +21 −0
.changeset/reduced-motion.md+10−0
@@ -0,0 +1,10 @@
1+---
2+"@executor-js/react": patch
3+---
4+
5+fix: honor prefers-reduced-motion in the shared stylesheet
6+
7+Adds a `prefers-reduced-motion: reduce` block to the global stylesheet that
8+caps transition/animation durations to 0.01ms and disables smooth scrolling,
9+so motion-sensitive users get a stable UI. The loading spinner renders
10+statically under reduced motion (its meaning is preserved via `role="status"`).
e2e/scenarios/reduced-motion.test.ts+59−0
@@ -0,0 +1,59 @@
1+import { expect } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import { scenario } from "../src/scenario";
5+import { Browser, Target } from "../src/services";
6+import { visit } from "../src/surfaces/browser";
7+
8+scenario(
9+ "Accessibility · reduced motion removes dialog animation and control transitions",
10+ {},
11+ Effect.gen(function* () {
12+ const target = yield* Target;
13+ const browser = yield* Browser;
14+ const identity = yield* target.newIdentity();
15+
16+ yield* browser.session(identity, async ({ page, step }) => {
17+ await step("Open the API key dialog with normal motion", async () => {
18+ await page.emulateMedia({ reducedMotion: "no-preference" });
19+ await visit(page, "/api-keys");
20+ await page.getByRole("button", { name: "New key" }).click();
21+ await page.getByRole("dialog").waitFor();
22+ const duration = await page
23+ .getByRole("dialog")
24+ .evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration));
25+ expect(duration, "normal motion retains the dialog transition").toBeGreaterThan(0.00001);
26+ });
27+
28+ await step("Enable reduced motion while the dialog is open", async () => {
29+ await page.emulateMedia({ reducedMotion: "reduce" });
30+ const styles = await page.getByRole("dialog").evaluate((element) => {
31+ const style = getComputedStyle(element);
32+ return {
33+ animation: Number.parseFloat(style.animationDuration),
34+ iterations: style.animationIterationCount,
35+ transition: Number.parseFloat(style.transitionDuration),
36+ scroll: style.scrollBehavior,
37+ };
38+ });
39+ expect(styles).toEqual({
40+ animation: 0.00001,
41+ iterations: "1",
42+ transition: 0.00001,
43+ scroll: "auto",
44+ });
45+ await page.locator("#create-key-name").fill("Reduced motion check");
46+ expect(await page.locator("#create-key-name").inputValue()).toBe("Reduced motion check");
47+ });
48+
49+ await step("Restore normal motion without losing the form", async () => {
50+ await page.emulateMedia({ reducedMotion: "no-preference" });
51+ expect(await page.locator("#create-key-name").inputValue()).toBe("Reduced motion check");
52+ const duration = await page
53+ .getByRole("dialog")
54+ .evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration));
55+ expect(duration).toBeGreaterThan(0.00001);
56+ });
57+ });
58+ }),
59+);
packages/react/src/styles/globals.css+21−0
@@ -319,3 +319,24 @@ · 318 unmodified lines
319319 opacity: 0.25;
320320 }
321321 }
322+
323+/* ---------------------------------------------------------------------------
324+ * Reduced motion — WCAG 2.2 (motion-sensitive users).
325+ *
326+ * Neutralizes transitions/animations/scroll-behavior when the OS requests
327+ * reduced motion, per the standard modern-CSS-reset pattern. The spinner
328+ * (ios-spinner-fade) is capped to a single 0.01ms iteration — effectively
329+ * static — which is the standard behavior: its meaning is preserved by
330+ * `role="status"` (announced by assistive tech), and an opacity pulse would
331+ * be a non-motion affordance but is not required for reduced-motion users.
332+ * ------------------------------------------------------------------------- */
333+@media (prefers-reduced-motion: reduce) {
334+ *,
335+ *::before,
336+ *::after {
337+ animation-duration: 0.01ms !important;
338+ animation-iteration-count: 1 !important;
339+ transition-duration: 0.01ms !important;
340+ scroll-behavior: auto !important;
341+ }
342+}