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
5fix: honor prefers-reduced-motion in the shared stylesheet
6
7Adds a `prefers-reduced-motion: reduce` block to the global stylesheet that
8caps transition/animation durations to 0.01ms and disables smooth scrolling,
9so motion-sensitive users get a stable UI. The loading spinner renders
10statically under reduced motion (its meaning is preserved via `role="status"`).
e2e/scenarios/reduced-motion.test.ts+59−0
@@ -0,0 +1,59 @@
1import { expect } from "@effect/vitest";
2import { Effect } from "effect";
3
4import { scenario } from "../src/scenario";
5import { Browser, Target } from "../src/services";
6import { visit } from "../src/surfaces/browser";
7
8scenario(
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
319 opacity: 0.25;319 opacity: 0.25;
320 }320 }
321}321}
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}