Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/lib/actions/motion.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
24 changes: 24 additions & 0 deletions src/lib/actions/motion.ts
Original file line number Diff line number Diff line change
@@ -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 }));
* // <div in:fly={params} …>
* ```
*/
export function motionSafe<T extends MotionParams>(params: T): T | { duration: 0 } {
return reducedMotion.current ? { duration: 0 } : params;
}
4 changes: 2 additions & 2 deletions src/lib/components/FilterToolbar.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { reducedMotion } from '../actions/reducedMotion.js';
import { motionSafe } from '../actions/motion.js';

interface Props {
/** Search text (two-way bindable). Escape clears it. */
Expand Down Expand Up @@ -44,7 +44,7 @@

let inputEl = $state<HTMLInputElement>();

const slideParams = $derived(reducedMotion.current ? { duration: 0 } : { duration: 140 });
const slideParams = $derived(motionSafe({ duration: 140 }));

// The shortcut focuses search from anywhere on the page — a nod to the
// terminal identity — but must never fire while the user is typing elsewhere.
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/Toaster.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import { fly, fade } from 'svelte/transition';
import { reducedMotion } from '../actions/reducedMotion.js';
import { motionSafe } from '../actions/motion.js';
import { useOverlayTheme } from '../theme/context.js';
import { store, remove, type ToastItem } from '../toast/store.svelte.js';

Expand All @@ -23,8 +24,8 @@
const overlayTheme = useOverlayTheme(() => invert);

// Motion is opt-out under prefers-reduced-motion.
const inParams = $derived(reducedMotion.current ? { duration: 0 } : { y: -8, duration: 300 });
const outParams = $derived(reducedMotion.current ? { duration: 0 } : { duration: 300 });
const inParams = $derived(motionSafe({ y: -8, duration: 300 }));
const outParams = $derived(motionSafe({ duration: 300 }));

// Decorative default glyphs so level is not signalled by color alone (WCAG 1.4.1).
const glyphs: Record<ToastItem['level'], string> = {
Expand Down