Skip to content

Commit 905e097

Browse files
ra-co88pt-actRhysSullivan
authored
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 --------- Co-authored-by: pt-act <211776491+pt-act@users.noreply.github.com> Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 513426c commit 905e097

3 files changed

Lines changed: 90 additions & 0 deletions

File tree

‎.changeset/reduced-motion.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"`).
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,24 @@
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+
}

0 commit comments

Comments
 (0)