import { expect, test } from "@playwright/test"; type MotionCase = { name: string; html: string; selector: string; expectations: { checkTransitions?: boolean; checkAnimations?: boolean; expectTransformNone?: boolean; expectOpacity?: string; }; }; const MOTION_CASES: MotionCase[] = [ { name: "primary button", html: '', selector: ".btn", expectations: { checkTransitions: true } }, { name: "card enter motion", html: '
', selector: ".card", expectations: { checkTransitions: true, checkAnimations: true, expectOpacity: "1" } }, { name: "popover panel", html: '
', selector: ".popover__panel", expectations: { checkTransitions: true, expectTransformNone: true } }, { name: "modal panel", html: '
', selector: ".bv-modal__panel", expectations: { checkTransitions: true } }, { name: "video modal content", html: '
', selector: "#playerModal .player-modal__content", expectations: { checkTransitions: true, checkAnimations: true } }, { name: "progress indicator", html: '
', selector: ".progress-bar-fill", expectations: { checkTransitions: true } }, { name: "sidebar nav link", html: '', selector: ".sidebar-nav-link", expectations: { checkTransitions: true } }, { name: "status spinner", html: '
', selector: ".status-spinner--inline", expectations: { checkAnimations: true } } ]; test.describe("reduced motion guardrail", () => { test("collapses transitions and animations when prefers-reduced-motion is set", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/docs/kitchen-sink.html", { waitUntil: "networkidle" }); for (const motionCase of MOTION_CASES) { const styles = await page.evaluate(({ html, selector }) => { const wrapper = document.createElement("div"); wrapper.setAttribute("data-test-motion", "true"); wrapper.innerHTML = html; const element = wrapper.querySelector(selector); if (!element) { throw new Error(`Missing element for selector ${selector}`); } document.body.appendChild(wrapper); const computedStyle = window.getComputedStyle(element); const result = { transitionDuration: computedStyle.transitionDuration, animationDuration: computedStyle.animationDuration, animationName: computedStyle.animationName, transform: computedStyle.transform, opacity: computedStyle.opacity }; wrapper.remove(); return result; }, motionCase); if (motionCase.expectations.checkTransitions) { const durations = styles.transitionDuration .split(",") .map((value) => value.trim()); expect.soft( durations.every((value) => value === "0s"), `${motionCase.name} transition duration` ).toBe(true); } if (motionCase.expectations.checkAnimations) { const durations = styles.animationDuration .split(",") .map((value) => value.trim()); expect.soft( durations.every((value) => value === "0s"), `${motionCase.name} animation duration` ).toBe(true); expect.soft( styles.animationName === "none", `${motionCase.name} animation name` ).toBe(true); } if (motionCase.expectations.expectTransformNone) { expect.soft(styles.transform === "none", `${motionCase.name} transform`).toBe( true ); } if (motionCase.expectations.expectOpacity) { expect.soft(styles.opacity).toBe(motionCase.expectations.expectOpacity); } } }); });