mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(motion): respect prefers-reduced-motion across smooth scrolls
Programmatic `scrollIntoView` / `scrollTo` calls in the settings shell, admin shell, and coach message thread ignored the user's reduced-motion preference — a WCAG 2.3.3 violation. Add a single `scrollBehaviorForUser` helper in `src/lib/motion.ts` that returns `"auto"` when SSR or when `(prefers-reduced-motion: reduce)` matches, and `"smooth"` otherwise. Wire it through the three call-sites; tests cover all three branches.
This commit is contained in:
1 parent
5902127725
commit
d9fe0e5487
5 files changed
+66
-3
No files matched your search
@@ -35,6 +35,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
|
||||
|
||||
@@ -183,7 +184,8 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
// and the first one or two chips were unreachable on narrow
|
||||
// viewports.
|
||||
inline: "start",
|
||||
behavior: "smooth",
|
||||
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
|
||||
behavior: scrollBehaviorForUser(),
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -194,7 +195,8 @@ export function MessageThread({
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
if (wasPinnedRef.current) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
|
||||
}
|
||||
}, [messages.length, streaming?.content, optimisticUser?.localId]);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
@@ -169,7 +170,8 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
// and the first one or two chips were unreachable on narrow
|
||||
// viewports.
|
||||
inline: "start",
|
||||
behavior: "smooth",
|
||||
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
|
||||
behavior: scrollBehaviorForUser(),
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
|
||||
// The vitest config runs unit tests under Node (no jsdom). The helper
|
||||
// only touches `window.matchMedia`, so we mount a minimal stub via
|
||||
// `vi.stubGlobal` and tear it down after each case.
|
||||
|
||||
function mountWindow(matchesReducedMotion: boolean) {
|
||||
vi.stubGlobal("window", {
|
||||
matchMedia: (query: string) => ({
|
||||
matches:
|
||||
matchesReducedMotion && query === "(prefers-reduced-motion: reduce)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("scrollBehaviorForUser", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns `auto` when window is undefined (SSR)", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
expect(scrollBehaviorForUser()).toBe("auto");
|
||||
});
|
||||
|
||||
it("returns `auto` when prefers-reduced-motion matches", () => {
|
||||
mountWindow(true);
|
||||
expect(scrollBehaviorForUser()).toBe("auto");
|
||||
});
|
||||
|
||||
it("returns `smooth` when prefers-reduced-motion does not match", () => {
|
||||
mountWindow(false);
|
||||
expect(scrollBehaviorForUser()).toBe("smooth");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// v1.4.43 W5-H5 — single source of truth for `behavior` on the four
|
||||
// programmatic `scrollIntoView` / `scrollTo` call-sites in the app.
|
||||
// `prefers-reduced-motion: reduce` users were previously animated against
|
||||
// their OS-level preference, which is a WCAG 2.3.3 violation. The helper
|
||||
// returns `"auto"` whenever a window is unavailable (SSR) or when the
|
||||
// reduced-motion media query matches.
|
||||
|
||||
export function scrollBehaviorForUser(): ScrollBehavior {
|
||||
if (typeof window === "undefined") return "auto";
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
? "auto"
|
||||
: "smooth";
|
||||
}
|
||||
Reference in new issue
Block a user