From 3cb73cb280f5f224c0a3a53c29210c903f358c99 Mon Sep 17 00:00:00 2001 From: Nicholas Sollazzo Date: Tue, 7 Jul 2026 04:07:48 +0200 Subject: [PATCH] refactor(ui): extract motionSafe() for reduced-motion transition params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilterToolbar and Toaster each hand-derived the same reduced-motion transition gate (`reducedMotion.current ? { duration: 0 } : …`) — three copies of one accessibility contract. Extract `motionSafe(params)` so the "reduced motion ⇒ instant cut" rule lives in one place, hardening it against a partial gate (e.g. zeroing duration but leaving a `y` offset). Behavior-preserving; unit-tested for both branches. Internal helper only (not added to the public entry point). Co-Authored-By: Paperclip --- src/lib/actions/motion.test.ts | 27 +++++++++++++++++++++++++ src/lib/actions/motion.ts | 24 ++++++++++++++++++++++ src/lib/components/FilterToolbar.svelte | 4 ++-- src/lib/components/Toaster.svelte | 5 +++-- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 src/lib/actions/motion.test.ts create mode 100644 src/lib/actions/motion.ts diff --git a/src/lib/actions/motion.test.ts b/src/lib/actions/motion.test.ts new file mode 100644 index 0000000..d3e6b3a --- /dev/null +++ b/src/lib/actions/motion.test.ts @@ -0,0 +1,27 @@ +import { vi, describe, test, expect, beforeEach } from 'vitest'; + +// Control the reduced-motion preference the helper reads. `vi.hoisted` builds +// the stub before the (hoisted) mock factory runs so we can flip `.current` +// per test. +const { state } = vi.hoisted(() => ({ state: { current: false } })); +vi.mock('./reducedMotion.js', () => ({ reducedMotion: state })); + +import { motionSafe } from './motion.js'; + +describe('motionSafe', () => { + beforeEach(() => { + state.current = false; + }); + + test('passes transition params through unchanged when motion is allowed', () => { + const params = { y: -8, duration: 300 }; + expect(motionSafe(params)).toBe(params); + }); + + test('collapses to an instant cut under reduced motion — no leftover offsets', () => { + state.current = true; + // The whole point: a partial gate (zeroing duration but keeping `y`) would + // still animate position. motionSafe must drop everything but duration: 0. + expect(motionSafe({ y: -8, duration: 300 })).toEqual({ duration: 0 }); + }); +}); diff --git a/src/lib/actions/motion.ts b/src/lib/actions/motion.ts new file mode 100644 index 0000000..0bdac48 --- /dev/null +++ b/src/lib/actions/motion.ts @@ -0,0 +1,24 @@ +import { reducedMotion } from './reducedMotion.js'; + +/** Every Svelte transition params object accepts an optional `duration` (ms). */ +type MotionParams = { duration?: number }; + +/** + * Gate a Svelte transition's params on `prefers-reduced-motion`. + * + * Returns `{ duration: 0 }` (an instant cut — no travel, no fade) when the user + * has requested reduced motion, otherwise `params` unchanged. This keeps the + * reduced-motion contract in one place instead of each animated component + * re-deriving `reducedMotion.current ? { duration: 0 } : …` (and risking a + * partial gate, e.g. zeroing `duration` but leaving a `y` offset). + * + * Call inside a `$derived` so it re-runs when the preference changes: + * + * ```svelte + * const params = $derived(motionSafe({ y: -8, duration: 300 })); + * //
+ * ``` + */ +export function motionSafe(params: T): T | { duration: 0 } { + return reducedMotion.current ? { duration: 0 } : params; +} diff --git a/src/lib/components/FilterToolbar.svelte b/src/lib/components/FilterToolbar.svelte index 8521251..bc8ae58 100644 --- a/src/lib/components/FilterToolbar.svelte +++ b/src/lib/components/FilterToolbar.svelte @@ -1,7 +1,7 @@