solid-drift 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1662,6 +1662,70 @@ const sale = createCountdown(new Date("2026-12-01T00:00:00"), {
1662
1662
  - `createCopy(options?)` copies text to the clipboard: `{ copied, error, copy, reset }`. Uses the async Clipboard API with an `execCommand` fallback (`noFallback: true` disables it). `copied()` flips true for `resetDelay` ms (default 2000) for transient "Copied!" feedback. SSR-safe.
1663
1663
  - `createCountdown(target, options?)` counts down to a date, timestamp, or accessor: `{ remaining, days, hours, minutes, seconds, done, running, start, stop, reset }`. Wall-clock based (the moment is fixed even if the tab hides); recomputes on the shared clock throttled to `interval` ms (default 1000); stops itself at zero and fires `onDone` once. SSR-safe.
1664
1664
 
1665
+ ### Marquee, variants, path drawing, press and hover
1666
+
1667
+ ```tsx
1668
+ import {
1669
+ createMarquee,
1670
+ createVariants,
1671
+ createPathDraw,
1672
+ createPress,
1673
+ createHover,
1674
+ } from "solid-drift";
1675
+
1676
+ const marquee = createMarquee({ speed: 80 });
1677
+ const card = createVariants(
1678
+ {
1679
+ idle: { scale: 1 },
1680
+ hover: { scale: 1.04 },
1681
+ press: { scale: 0.96 },
1682
+ },
1683
+ { initial: "idle", duration: 180 },
1684
+ );
1685
+
1686
+ let strip!: HTMLDivElement;
1687
+ let btn!: HTMLButtonElement;
1688
+ let mark!: SVGPathElement;
1689
+ createEffect(() => marquee.setContentSize(strip.scrollWidth / 2));
1690
+ createHover(() => btn, { onChange: (h) => card.go(h ? "hover" : "idle") });
1691
+ createPress(() => btn, { onChange: (p) => card.go(p ? "press" : "idle") });
1692
+ const draw = createPathDraw(() => mark, { duration: 1600 });
1693
+ ```
1694
+
1695
+ - `createMarquee(options?)` infinite scroller: `{ offset, running, setContentSize, start, stop }`. The offset advances at `speed` px/s (`direction` left/right/up/down) on the shared clock and wraps at the content size; render the content twice and translate by `-offset()`. Measure one loop unit and pass it to `setContentSize`. Static under reduced motion; SSR-safe.
1696
+ - `createVariants(defs, options?)` named animation states: `{ current, values, go }`. `go(name)` tweens numeric props from the current values to the target variant (`duration` ms, easing) and snaps non-numeric props at the end; unknown names are ignored. Snaps instantly under reduced motion; SSR-safe.
1697
+ - `createPathDraw(ref, options?)` SVG stroke draw-on: `{ progress, running, start, stop, reset }`. Reads the length with `getTotalLength()` and drives `stroke-dashoffset` to 0, eased; `onDone` fires once; resume keeps a constant speed. Renders fully drawn under reduced motion; SSR-safe.
1698
+ - `createPress(ref, options?)` press gesture state: `{ pressed }`. Pointer down/up/cancel/leave plus Enter/Space keys for keyboard parity; `onChange` fires on change only. State only, no animation; pair with `createVariants`. SSR-safe.
1699
+ - `createHover(ref, options?)` hover gesture state: `{ hovering }`. Pointer enter/leave plus focus/blur for keyboard parity; `onChange` fires on change only. SSR-safe.
1700
+
1701
+ ### App utilities
1702
+
1703
+ ```tsx
1704
+ import {
1705
+ createColorScheme,
1706
+ createIdle,
1707
+ createOnline,
1708
+ createInstallPrompt,
1709
+ createUndo,
1710
+ createFullscreen,
1711
+ } from "solid-drift";
1712
+
1713
+ const theme = createColorScheme(); // follows the OS, persists, writes html[data-theme]
1714
+ const { idle } = createIdle({ timeout: 30_000 }); // auto-hide chrome when idle
1715
+ const { online } = createOnline(); // offline banner
1716
+ const install = createInstallPrompt(); // PWA install button
1717
+ const doc = createUndo({ title: "" }); // undoable form state
1718
+ let stage!: HTMLDivElement;
1719
+ const fs = createFullscreen(() => stage); // fullscreen toggle
1720
+ ```
1721
+
1722
+ - `createColorScheme(options?)`: `{ scheme, preference, setPreference, toggle }`. Resolves `"system"` through the `(prefers-color-scheme: dark)` media query (reactive to OS changes), persists the preference to localStorage (`storageKey`, null disables), and writes the resolved scheme to `<html data-theme="light|dark">` (configurable `attribute`) plus `color-scheme`. SSR-safe (resolves to light on the server).
1723
+ - `createIdle(options?)`: `{ idle, lastActive, reset }`. `idle()` flips true after `timeout` ms (default 60000) without any of the `events` (default mousemove, mousedown, keydown, touchstart, wheel); activity restarts the timer. SSR-safe.
1724
+ - `createOnline()`: `{ online }`. Seeds from `navigator.onLine`, follows window `online`/`offline` events. SSR-safe (assumes online).
1725
+ - `createInstallPrompt()`: `{ canInstall, prompt }`. Captures `beforeinstallprompt` (preventing the browser mini-bar); `prompt()` shows it from a click handler and resolves to the user's choice, or null when unavailable; each captured event is single-use. SSR-safe.
1726
+ - `createUndo(initial, options?)`: undoable state: `{ value, set, undo, redo, clear, reset, canUndo, canRedo, past, future }`. `set()` (value or updater) records history trimmed to `capacity` (default 50); a new `set()` discards the redo stack. Pure logic, SSR-safe.
1727
+ - `createFullscreen(ref, options?)`: `{ fullscreen, enter, exit, toggle }`. Tracks `document.fullscreenElement` so Escape and external changes stay in sync; failures go to `onError` instead of throwing. SSR-safe.
1728
+
1665
1729
  ### Easings
1666
1730
 
1667
1731
  Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack`, plus the cartoon set: `easeInBack` (anticipation dip before movement), `easeInOutBack` (wind-up, overshoot, settle), `easeOutElastic` (decaying rubber-band oscillation), `easeOutBounce` (shrinking cartoon bounces). Also `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
@@ -0,0 +1,205 @@
1
+ import { type Accessor } from "solid-js";
2
+ type MaybeElement = () => Element | null | undefined;
3
+ /** Resolved color scheme. */
4
+ export type ColorScheme = "light" | "dark";
5
+ /** Stored preference: an explicit scheme or following the OS. */
6
+ export type ColorSchemePreference = ColorScheme | "system";
7
+ export interface ColorSchemeOptions {
8
+ /** Preference used when nothing is stored. Default "system". */
9
+ default?: ColorSchemePreference;
10
+ /**
11
+ * localStorage key for the preference. Set to null to disable
12
+ * persistence. Default "solid-drift:color-scheme".
13
+ */
14
+ storageKey?: string | null;
15
+ /**
16
+ * Attribute written on `<html>` with the resolved scheme, so CSS can
17
+ * hook onto it (`html[data-theme="dark"] { ... }`). Default "data-theme".
18
+ */
19
+ attribute?: string;
20
+ }
21
+ export interface ColorSchemeControls {
22
+ /** Resolved scheme: the preference, or the OS scheme when "system". */
23
+ scheme: Accessor<ColorScheme>;
24
+ /** The stored preference, "system" included. */
25
+ preference: Accessor<ColorSchemePreference>;
26
+ setPreference: (preference: ColorSchemePreference) => void;
27
+ /** Flips between light and dark, leaving "system" behind. */
28
+ toggle: () => void;
29
+ }
30
+ /**
31
+ * Color scheme manager. Resolves "system" through the
32
+ * `(prefers-color-scheme: dark)` media query (reactive to OS changes),
33
+ * persists the preference to localStorage, and writes the resolved
34
+ * scheme to `<html data-theme="light|dark">` plus `color-scheme` so
35
+ * form controls and scrollbars follow.
36
+ *
37
+ * SSR-safe: resolves "system" to "light" on the server.
38
+ *
39
+ * ```tsx
40
+ * const theme = createColorScheme();
41
+ * <button onClick={theme.toggle}>
42
+ * {theme.scheme() === "dark" ? "Light mode" : "Dark mode"}
43
+ * </button>
44
+ * ```
45
+ */
46
+ export declare function createColorScheme(options?: ColorSchemeOptions): ColorSchemeControls;
47
+ export interface IdleOptions {
48
+ /** Ms of inactivity before `idle()` flips true. Default 60000. */
49
+ timeout?: number;
50
+ /**
51
+ * Activity events that reset the timer. Default mousemove, mousedown,
52
+ * keydown, touchstart and wheel.
53
+ */
54
+ events?: string[];
55
+ /** Start in the idle state. Default false. */
56
+ initialIdle?: boolean;
57
+ }
58
+ export interface IdleControls {
59
+ /** True once `timeout` ms passed without activity. */
60
+ idle: Accessor<boolean>;
61
+ /** Timestamp of the last activity. */
62
+ lastActive: Accessor<number>;
63
+ /** Mark the user active now and restart the timer. */
64
+ reset: () => void;
65
+ }
66
+ /**
67
+ * Idle detection. `idle()` becomes true after `timeout` ms without any
68
+ * of the activity events; any activity flips it back and restarts the
69
+ * timer. Useful for auto-hiding chrome, pausing ambient animation, or
70
+ * "are you still there" prompts.
71
+ *
72
+ * SSR-safe: never idle on the server.
73
+ *
74
+ * ```tsx
75
+ * const { idle } = createIdle({ timeout: 30_000 });
76
+ * <div classList={{ "controls-hidden": idle() }}>…</div>
77
+ * ```
78
+ */
79
+ export declare function createIdle(options?: IdleOptions): IdleControls;
80
+ export interface OnlineControls {
81
+ /** Mirrors `navigator.onLine`, updated on online/offline events. */
82
+ online: Accessor<boolean>;
83
+ }
84
+ /**
85
+ * Network connectivity state. Seeds from `navigator.onLine` and follows
86
+ * the window `online` / `offline` events. SSR-safe: assumes online on
87
+ * the server.
88
+ *
89
+ * ```tsx
90
+ * const { online } = createOnline();
91
+ * <Show when={!online()}>
92
+ * <p>You are offline. Changes will sync when you reconnect.</p>
93
+ * </Show>
94
+ * ```
95
+ */
96
+ export declare function createOnline(): OnlineControls;
97
+ /** Minimal shape of the PWA `beforeinstallprompt` event. */
98
+ export interface BeforeInstallPromptEvent extends Event {
99
+ prompt: () => Promise<void>;
100
+ userChoice: Promise<{
101
+ outcome: "accepted" | "dismissed";
102
+ }>;
103
+ }
104
+ export interface InstallPromptControls {
105
+ /** True once the browser fired `beforeinstallprompt`. */
106
+ canInstall: Accessor<boolean>;
107
+ /**
108
+ * Shows the install prompt. Returns the user's choice, or null when
109
+ * the browser never offered installation. Each captured event can be
110
+ * used once.
111
+ */
112
+ prompt: () => Promise<{
113
+ outcome: "accepted" | "dismissed";
114
+ } | null>;
115
+ }
116
+ /**
117
+ * PWA install prompt. Captures the `beforeinstallprompt` event (calling
118
+ * `preventDefault()` so the browser does not show its own mini-bar) and
119
+ * exposes it through `prompt()`, which is safe to call from a click
120
+ * handler. SSR-safe: never installable on the server.
121
+ *
122
+ * ```tsx
123
+ * const install = createInstallPrompt();
124
+ * <Show when={install.canInstall()}>
125
+ * <button
126
+ * onClick={async () => {
127
+ * const choice = await install.prompt();
128
+ * if (choice?.outcome === "accepted") toast("Installed!");
129
+ * }}
130
+ * >
131
+ * Install app
132
+ * </button>
133
+ * </Show>
134
+ * ```
135
+ */
136
+ export declare function createInstallPrompt(): InstallPromptControls;
137
+ export interface UndoOptions {
138
+ /** Maximum snapshots kept. Older ones are dropped. Default 50. */
139
+ capacity?: number;
140
+ }
141
+ export interface UndoControls<T> {
142
+ /** Current value. */
143
+ value: Accessor<T>;
144
+ /** Set a new value, recording the previous one for undo. */
145
+ set: (value: T | ((prev: T) => T)) => void;
146
+ /** Step back; no-op when there is nothing to undo. */
147
+ undo: () => void;
148
+ /** Step forward; no-op when there is nothing to redo. */
149
+ redo: () => void;
150
+ /** Drop all history without touching the value. */
151
+ clear: () => void;
152
+ /** Restore the initial value and drop all history. */
153
+ reset: () => void;
154
+ canUndo: Accessor<boolean>;
155
+ canRedo: Accessor<boolean>;
156
+ /** Undo stack, oldest first. */
157
+ past: Accessor<readonly T[]>;
158
+ /** Redo stack, next redo first. */
159
+ future: Accessor<readonly T[]>;
160
+ }
161
+ /**
162
+ * Undoable state. `set()` records the previous value, `undo()` steps
163
+ * back through history, `redo()` steps forward, and any new `set()`
164
+ * discards the redo stack. Pure logic: no DOM, SSR-safe.
165
+ *
166
+ * ```tsx
167
+ * const doc = createUndo({ title: "", body: "" }, { capacity: 100 });
168
+ * <input
169
+ * value={doc.value().title}
170
+ * onInput={(e) => doc.set((d) => ({ ...d, title: e.target.value }))}
171
+ * />
172
+ * <button disabled={!doc.canUndo()} onClick={doc.undo}>Undo</button>
173
+ * <button disabled={!doc.canRedo()} onClick={doc.redo}>Redo</button>
174
+ * ```
175
+ */
176
+ export declare function createUndo<T>(initial: T, options?: UndoOptions): UndoControls<T>;
177
+ export interface FullscreenOptions {
178
+ /** Called when entering/exiting fails or is unsupported. */
179
+ onError?: (error: unknown) => void;
180
+ }
181
+ export interface FullscreenControls {
182
+ /** True while the referenced element is the fullscreen element. */
183
+ fullscreen: Accessor<boolean>;
184
+ enter: () => Promise<void>;
185
+ exit: () => Promise<void>;
186
+ toggle: () => Promise<void>;
187
+ }
188
+ /**
189
+ * Fullscreen for an element. Tracks `document.fullscreenElement` so the
190
+ * state stays correct when the user presses Escape or another element
191
+ * takes over. Failures (unsupported browser, denied request) go to
192
+ * `onError` instead of throwing. SSR-safe: never fullscreen on the
193
+ * server.
194
+ *
195
+ * ```tsx
196
+ * let stage!: HTMLDivElement;
197
+ * const fs = createFullscreen(() => stage);
198
+ * <button onClick={fs.toggle}>
199
+ * {fs.fullscreen() ? "Exit fullscreen" : "Go fullscreen"}
200
+ * </button>
201
+ * <div ref={stage}>…</div>
202
+ * ```
203
+ */
204
+ export declare function createFullscreen(ref: MaybeElement, options?: FullscreenOptions): FullscreenControls;
205
+ export {};
@@ -0,0 +1,326 @@
1
+ import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
2
+ const isSchemePreference = (value) => value === "light" || value === "dark" || value === "system";
3
+ /**
4
+ * Color scheme manager. Resolves "system" through the
5
+ * `(prefers-color-scheme: dark)` media query (reactive to OS changes),
6
+ * persists the preference to localStorage, and writes the resolved
7
+ * scheme to `<html data-theme="light|dark">` plus `color-scheme` so
8
+ * form controls and scrollbars follow.
9
+ *
10
+ * SSR-safe: resolves "system" to "light" on the server.
11
+ *
12
+ * ```tsx
13
+ * const theme = createColorScheme();
14
+ * <button onClick={theme.toggle}>
15
+ * {theme.scheme() === "dark" ? "Light mode" : "Dark mode"}
16
+ * </button>
17
+ * ```
18
+ */
19
+ export function createColorScheme(options = {}) {
20
+ const { default: defaultPreference = "system", storageKey = "solid-drift:color-scheme", attribute = "data-theme", } = options;
21
+ const readStored = () => {
22
+ if (!storageKey || typeof window === "undefined")
23
+ return null;
24
+ try {
25
+ const raw = window.localStorage.getItem(storageKey);
26
+ return isSchemePreference(raw) ? raw : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ };
32
+ const [preference, setPreference] = createSignal(readStored() ?? defaultPreference);
33
+ const [systemDark, setSystemDark] = createSignal(false);
34
+ if (typeof window !== "undefined") {
35
+ createEffect(() => {
36
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
37
+ setSystemDark(mq.matches);
38
+ const onChange = (event) => {
39
+ setSystemDark(event.matches);
40
+ };
41
+ mq.addEventListener("change", onChange);
42
+ onCleanup(() => mq.removeEventListener("change", onChange));
43
+ });
44
+ createEffect(() => {
45
+ const stored = preference();
46
+ const resolved = stored === "system" ? (systemDark() ? "dark" : "light") : stored;
47
+ const root = document.documentElement;
48
+ root.setAttribute(attribute, resolved);
49
+ root.style.colorScheme = resolved;
50
+ if (storageKey) {
51
+ try {
52
+ window.localStorage.setItem(storageKey, preference());
53
+ }
54
+ catch {
55
+ // Storage can be unavailable (private mode); the in-memory
56
+ // preference still works for the session.
57
+ }
58
+ }
59
+ });
60
+ }
61
+ const scheme = () => {
62
+ const p = preference();
63
+ if (p !== "system")
64
+ return p;
65
+ if (typeof window === "undefined")
66
+ return "light";
67
+ return systemDark() ? "dark" : "light";
68
+ };
69
+ return {
70
+ scheme,
71
+ preference,
72
+ setPreference,
73
+ toggle: () => setPreference(scheme() === "dark" ? "light" : "dark"),
74
+ };
75
+ }
76
+ /**
77
+ * Idle detection. `idle()` becomes true after `timeout` ms without any
78
+ * of the activity events; any activity flips it back and restarts the
79
+ * timer. Useful for auto-hiding chrome, pausing ambient animation, or
80
+ * "are you still there" prompts.
81
+ *
82
+ * SSR-safe: never idle on the server.
83
+ *
84
+ * ```tsx
85
+ * const { idle } = createIdle({ timeout: 30_000 });
86
+ * <div classList={{ "controls-hidden": idle() }}>…</div>
87
+ * ```
88
+ */
89
+ export function createIdle(options = {}) {
90
+ const { timeout = 60000, events = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"], initialIdle = false, } = options;
91
+ if (typeof window === "undefined") {
92
+ const falsy = () => false;
93
+ return { idle: falsy, lastActive: () => 0, reset: () => { } };
94
+ }
95
+ const [idle, setIdle] = createSignal(initialIdle);
96
+ const [lastActive, setLastActive] = createSignal(Date.now());
97
+ let timer;
98
+ const arm = () => {
99
+ clearTimeout(timer);
100
+ timer = setTimeout(() => setIdle(true), Math.max(timeout, 0));
101
+ };
102
+ const onActivity = () => {
103
+ setLastActive(Date.now());
104
+ setIdle(false);
105
+ arm();
106
+ };
107
+ createEffect(() => {
108
+ for (const name of events) {
109
+ window.addEventListener(name, onActivity, { passive: true });
110
+ }
111
+ arm();
112
+ onCleanup(() => {
113
+ for (const name of events) {
114
+ window.removeEventListener(name, onActivity);
115
+ }
116
+ clearTimeout(timer);
117
+ });
118
+ });
119
+ return { idle, lastActive, reset: onActivity };
120
+ }
121
+ /**
122
+ * Network connectivity state. Seeds from `navigator.onLine` and follows
123
+ * the window `online` / `offline` events. SSR-safe: assumes online on
124
+ * the server.
125
+ *
126
+ * ```tsx
127
+ * const { online } = createOnline();
128
+ * <Show when={!online()}>
129
+ * <p>You are offline. Changes will sync when you reconnect.</p>
130
+ * </Show>
131
+ * ```
132
+ */
133
+ export function createOnline() {
134
+ if (typeof window === "undefined")
135
+ return { online: () => true };
136
+ const [online, setOnline] = createSignal(typeof navigator !== "undefined" ? navigator.onLine : true);
137
+ createEffect(() => {
138
+ const goOnline = () => {
139
+ setOnline(true);
140
+ };
141
+ const goOffline = () => {
142
+ setOnline(false);
143
+ };
144
+ window.addEventListener("online", goOnline);
145
+ window.addEventListener("offline", goOffline);
146
+ onCleanup(() => {
147
+ window.removeEventListener("online", goOnline);
148
+ window.removeEventListener("offline", goOffline);
149
+ });
150
+ });
151
+ return { online };
152
+ }
153
+ /**
154
+ * PWA install prompt. Captures the `beforeinstallprompt` event (calling
155
+ * `preventDefault()` so the browser does not show its own mini-bar) and
156
+ * exposes it through `prompt()`, which is safe to call from a click
157
+ * handler. SSR-safe: never installable on the server.
158
+ *
159
+ * ```tsx
160
+ * const install = createInstallPrompt();
161
+ * <Show when={install.canInstall()}>
162
+ * <button
163
+ * onClick={async () => {
164
+ * const choice = await install.prompt();
165
+ * if (choice?.outcome === "accepted") toast("Installed!");
166
+ * }}
167
+ * >
168
+ * Install app
169
+ * </button>
170
+ * </Show>
171
+ * ```
172
+ */
173
+ export function createInstallPrompt() {
174
+ if (typeof window === "undefined") {
175
+ return { canInstall: () => false, prompt: async () => null };
176
+ }
177
+ const [deferred, setDeferred] = createSignal(null);
178
+ createEffect(() => {
179
+ const onBeforeInstall = (event) => {
180
+ event.preventDefault();
181
+ setDeferred(event);
182
+ };
183
+ window.addEventListener("beforeinstallprompt", onBeforeInstall);
184
+ onCleanup(() => window.removeEventListener("beforeinstallprompt", onBeforeInstall));
185
+ });
186
+ const prompt = async () => {
187
+ const event = deferred();
188
+ if (!event)
189
+ return null;
190
+ setDeferred(null);
191
+ await event.prompt();
192
+ return event.userChoice;
193
+ };
194
+ return { canInstall: () => deferred() !== null, prompt };
195
+ }
196
+ /**
197
+ * Undoable state. `set()` records the previous value, `undo()` steps
198
+ * back through history, `redo()` steps forward, and any new `set()`
199
+ * discards the redo stack. Pure logic: no DOM, SSR-safe.
200
+ *
201
+ * ```tsx
202
+ * const doc = createUndo({ title: "", body: "" }, { capacity: 100 });
203
+ * <input
204
+ * value={doc.value().title}
205
+ * onInput={(e) => doc.set((d) => ({ ...d, title: e.target.value }))}
206
+ * />
207
+ * <button disabled={!doc.canUndo()} onClick={doc.undo}>Undo</button>
208
+ * <button disabled={!doc.canRedo()} onClick={doc.redo}>Redo</button>
209
+ * ```
210
+ */
211
+ export function createUndo(initial, options = {}) {
212
+ const { capacity = 50 } = options;
213
+ const limit = Math.max(Math.floor(capacity), 1);
214
+ const [value, setValue] = createSignal(initial);
215
+ const [past, setPast] = createSignal([]);
216
+ const [future, setFuture] = createSignal([]);
217
+ const set = (next) => {
218
+ const current = untrack(value);
219
+ const resolved = typeof next === "function" ? next(current) : next;
220
+ setPast((stack) => [...stack.slice(-(limit - 1)), current]);
221
+ setFuture([]);
222
+ setValue(resolved);
223
+ };
224
+ const undo = () => {
225
+ const stack = untrack(past);
226
+ if (stack.length === 0)
227
+ return;
228
+ const previous = stack[stack.length - 1];
229
+ setFuture((redo) => [untrack(value), ...redo]);
230
+ setPast(stack.slice(0, -1));
231
+ setValue(previous);
232
+ };
233
+ const redo = () => {
234
+ const stack = untrack(future);
235
+ if (stack.length === 0)
236
+ return;
237
+ const [next, ...rest] = stack;
238
+ setPast((done) => [...done.slice(-(limit - 1)), untrack(value)]);
239
+ setFuture(rest);
240
+ setValue(next);
241
+ };
242
+ const clear = () => {
243
+ setPast([]);
244
+ setFuture([]);
245
+ };
246
+ const reset = () => {
247
+ clear();
248
+ setValue(initial);
249
+ };
250
+ return {
251
+ value,
252
+ set,
253
+ undo,
254
+ redo,
255
+ clear,
256
+ reset,
257
+ canUndo: () => past().length > 0,
258
+ canRedo: () => future().length > 0,
259
+ past: past,
260
+ future: future,
261
+ };
262
+ }
263
+ /**
264
+ * Fullscreen for an element. Tracks `document.fullscreenElement` so the
265
+ * state stays correct when the user presses Escape or another element
266
+ * takes over. Failures (unsupported browser, denied request) go to
267
+ * `onError` instead of throwing. SSR-safe: never fullscreen on the
268
+ * server.
269
+ *
270
+ * ```tsx
271
+ * let stage!: HTMLDivElement;
272
+ * const fs = createFullscreen(() => stage);
273
+ * <button onClick={fs.toggle}>
274
+ * {fs.fullscreen() ? "Exit fullscreen" : "Go fullscreen"}
275
+ * </button>
276
+ * <div ref={stage}>…</div>
277
+ * ```
278
+ */
279
+ export function createFullscreen(ref, options = {}) {
280
+ const { onError } = options;
281
+ if (typeof window === "undefined") {
282
+ const falsy = () => false;
283
+ const noop = async () => { };
284
+ return { fullscreen: falsy, enter: noop, exit: noop, toggle: noop };
285
+ }
286
+ const [fullscreen, setFullscreen] = createSignal(false);
287
+ const sync = () => {
288
+ const el = ref();
289
+ setFullscreen(el != null && document.fullscreenElement === el);
290
+ };
291
+ createEffect(() => {
292
+ sync();
293
+ document.addEventListener("fullscreenchange", sync);
294
+ onCleanup(() => document.removeEventListener("fullscreenchange", sync));
295
+ });
296
+ const enter = async () => {
297
+ const el = ref();
298
+ if (!el || typeof el.requestFullscreen !== "function") {
299
+ onError?.(new Error("Fullscreen is not supported"));
300
+ return;
301
+ }
302
+ try {
303
+ await el.requestFullscreen();
304
+ }
305
+ catch (error) {
306
+ onError?.(error);
307
+ }
308
+ };
309
+ const exit = async () => {
310
+ if (!document.fullscreenElement)
311
+ return;
312
+ try {
313
+ await document.exitFullscreen();
314
+ }
315
+ catch (error) {
316
+ onError?.(error);
317
+ }
318
+ };
319
+ const toggle = async () => {
320
+ if (fullscreen())
321
+ await exit();
322
+ else
323
+ await enter();
324
+ };
325
+ return { fullscreen, enter, exit, toggle };
326
+ }
package/dist/index.d.ts CHANGED
@@ -56,3 +56,13 @@ export { createCopy } from "./copy.js";
56
56
  export type { CopyOptions, CopyControls } from "./copy.js";
57
57
  export { createCountdown } from "./countdown.js";
58
58
  export type { CountdownOptions, CountdownControls } from "./countdown.js";
59
+ export { createMarquee } from "./marquee.js";
60
+ export type { MarqueeDirection, MarqueeOptions, MarqueeControls, } from "./marquee.js";
61
+ export { createVariants } from "./variants.js";
62
+ export type { VariantDef, VariantsOptions, VariantsControls, } from "./variants.js";
63
+ export { createPathDraw } from "./pathdraw.js";
64
+ export type { PathDrawOptions, PathDrawControls } from "./pathdraw.js";
65
+ export { createPress, createHover } from "./press.js";
66
+ export type { PressOptions, PressControls, HoverOptions, HoverControls, } from "./press.js";
67
+ export { createColorScheme, createIdle, createOnline, createInstallPrompt, createUndo, createFullscreen, } from "./apputils.js";
68
+ export type { ColorScheme, ColorSchemePreference, ColorSchemeOptions, ColorSchemeControls, IdleOptions, IdleControls, OnlineControls, BeforeInstallPromptEvent, InstallPromptControls, UndoOptions, UndoControls, FullscreenOptions, FullscreenControls, } from "./apputils.js";
package/dist/index.js CHANGED
@@ -44,3 +44,8 @@ export { createSkeleton } from "./skeleton.js";
44
44
  export { createScrollSpy } from "./scrollspy.js";
45
45
  export { createCopy } from "./copy.js";
46
46
  export { createCountdown } from "./countdown.js";
47
+ export { createMarquee } from "./marquee.js";
48
+ export { createVariants } from "./variants.js";
49
+ export { createPathDraw } from "./pathdraw.js";
50
+ export { createPress, createHover } from "./press.js";
51
+ export { createColorScheme, createIdle, createOnline, createInstallPrompt, createUndo, createFullscreen, } from "./apputils.js";
@@ -0,0 +1,57 @@
1
+ import { type Accessor } from "solid-js";
2
+ /** Marquee scroll direction. Default "left". */
3
+ export type MarqueeDirection = "left" | "right" | "up" | "down";
4
+ export interface MarqueeOptions {
5
+ /** Pixels per second. Default 60. */
6
+ speed?: number;
7
+ /** Scroll direction. Default "left". */
8
+ direction?: MarqueeDirection;
9
+ /** Start scrolling immediately. Default true. */
10
+ autoStart?: boolean;
11
+ }
12
+ export interface MarqueeControls {
13
+ /**
14
+ * Current offset in px. Wraps at the content size, so rendering two
15
+ * copies of the content side by side and translating by `-offset()`
16
+ * (or `-offset()` on Y for vertical) loops seamlessly.
17
+ */
18
+ offset: Accessor<number>;
19
+ /** Whether the marquee clock task is running. */
20
+ running: Accessor<boolean>;
21
+ /**
22
+ * Width (horizontal directions) or height (vertical directions) of one
23
+ * loop unit in px. Measure the content and call this once it is known.
24
+ */
25
+ setContentSize: (px: number) => void;
26
+ start: () => void;
27
+ stop: () => void;
28
+ }
29
+ /**
30
+ * Infinite marquee scroller. The offset advances at `speed` px/s on the
31
+ * shared clock and wraps at the content size, so a doubled content strip
32
+ * loops seamlessly.
33
+ *
34
+ * Reduced-motion aware: marquees are pure motion, so under reduced motion
35
+ * the marquee stays static (offset 0, never runs). SSR-safe.
36
+ *
37
+ * ```tsx
38
+ * const marquee = createMarquee({ speed: 80 });
39
+ * let strip!: HTMLDivElement;
40
+ * createEffect(() => {
41
+ * marquee.setContentSize(strip.scrollWidth / 2);
42
+ * });
43
+ * <div style={{ overflow: "hidden" }}>
44
+ * <div
45
+ * ref={strip}
46
+ * style={{
47
+ * display: "flex",
48
+ * transform: `translateX(${-marquee.offset()}px)`,
49
+ * "will-change": "transform",
50
+ * }}
51
+ * >
52
+ * {items}{items}
53
+ * </div>
54
+ * </div>
55
+ * ```
56
+ */
57
+ export declare function createMarquee(options?: MarqueeOptions): MarqueeControls;
@@ -0,0 +1,88 @@
1
+ import { createSignal, onCleanup, untrack } from "solid-js";
2
+ import { now, schedule } from "./engine.js";
3
+ import { prefersReducedMotion } from "./reduced-motion.js";
4
+ /**
5
+ * Infinite marquee scroller. The offset advances at `speed` px/s on the
6
+ * shared clock and wraps at the content size, so a doubled content strip
7
+ * loops seamlessly.
8
+ *
9
+ * Reduced-motion aware: marquees are pure motion, so under reduced motion
10
+ * the marquee stays static (offset 0, never runs). SSR-safe.
11
+ *
12
+ * ```tsx
13
+ * const marquee = createMarquee({ speed: 80 });
14
+ * let strip!: HTMLDivElement;
15
+ * createEffect(() => {
16
+ * marquee.setContentSize(strip.scrollWidth / 2);
17
+ * });
18
+ * <div style={{ overflow: "hidden" }}>
19
+ * <div
20
+ * ref={strip}
21
+ * style={{
22
+ * display: "flex",
23
+ * transform: `translateX(${-marquee.offset()}px)`,
24
+ * "will-change": "transform",
25
+ * }}
26
+ * >
27
+ * {items}{items}
28
+ * </div>
29
+ * </div>
30
+ * ```
31
+ */
32
+ export function createMarquee(options = {}) {
33
+ const { speed = 60, direction = "left", autoStart = true } = options;
34
+ if (typeof window === "undefined") {
35
+ const zero = () => 0;
36
+ const falsy = () => false;
37
+ const noop = () => { };
38
+ return {
39
+ offset: zero,
40
+ running: falsy,
41
+ setContentSize: noop,
42
+ start: noop,
43
+ stop: noop,
44
+ };
45
+ }
46
+ const sign = direction === "left" || direction === "up" ? 1 : -1;
47
+ const [offset, setOffset] = createSignal(0);
48
+ const [running, setRunning] = createSignal(false);
49
+ let size = 0;
50
+ let phase = 0;
51
+ let last = 0;
52
+ let stopClock = null;
53
+ const task = (t) => {
54
+ const dt = Math.min(Math.max((t - last) / 1000, 0), 0.25);
55
+ last = t;
56
+ phase += speed * dt;
57
+ if (size > 0) {
58
+ setOffset((((phase * sign) % size) + size) % size);
59
+ }
60
+ return true;
61
+ };
62
+ const start = () => {
63
+ if (untrack(running) || prefersReducedMotion())
64
+ return;
65
+ setRunning(true);
66
+ last = now();
67
+ stopClock = schedule(task);
68
+ };
69
+ const stop = () => {
70
+ stopClock?.();
71
+ stopClock = null;
72
+ setRunning(false);
73
+ };
74
+ onCleanup(stop);
75
+ if (autoStart)
76
+ start();
77
+ return {
78
+ offset,
79
+ running,
80
+ setContentSize: (px) => {
81
+ size = Math.max(px, 0);
82
+ phase = 0;
83
+ setOffset(0);
84
+ },
85
+ start,
86
+ stop,
87
+ };
88
+ }
@@ -0,0 +1,49 @@
1
+ import { type Accessor } from "solid-js";
2
+ import { type Easing } from "./easing.js";
3
+ export interface PathDrawOptions {
4
+ /** Draw duration in ms. Default 1200. */
5
+ duration?: number;
6
+ /** Easing for the draw progress. Default easeInOutCubic. */
7
+ easing?: Easing;
8
+ /** Start drawing immediately. Default true. */
9
+ autoStart?: boolean;
10
+ /** Fires once when the draw completes. */
11
+ onDone?: () => void;
12
+ }
13
+ export interface PathDrawControls {
14
+ /** Eased draw progress, 0 to 1. */
15
+ progress: Accessor<number>;
16
+ /** Whether the draw clock task is running. */
17
+ running: Accessor<boolean>;
18
+ start: () => void;
19
+ stop: () => void;
20
+ /** Back to undrawn (progress 0). */
21
+ reset: () => void;
22
+ }
23
+ /**
24
+ * SVG path drawing animation. Drives `stroke-dashoffset` from the full
25
+ * path length to 0 so the stroke draws itself on, eased on the shared
26
+ * clock. The length is read with `getTotalLength()`, so any path shape
27
+ * works with no manual measuring.
28
+ *
29
+ * Under reduced motion (and on the server) the path renders fully drawn.
30
+ * SSR-safe.
31
+ *
32
+ * ```tsx
33
+ * let path!: SVGPathElement;
34
+ * const draw = createPathDraw(() => path, {
35
+ * duration: 1600,
36
+ * onDone: () => console.log("drawn"),
37
+ * });
38
+ * <svg viewBox="0 0 100 100">
39
+ * <path
40
+ * ref={path}
41
+ * d="M10 80 C 40 10, 60 10, 90 80"
42
+ * fill="none"
43
+ * stroke="currentColor"
44
+ * stroke-width="3"
45
+ * />
46
+ * </svg>
47
+ * ```
48
+ */
49
+ export declare function createPathDraw(ref: () => SVGPathElement | null | undefined, options?: PathDrawOptions): PathDrawControls;
@@ -0,0 +1,106 @@
1
+ import { createSignal, onCleanup } from "solid-js";
2
+ import { now, schedule } from "./engine.js";
3
+ import { easeInOutCubic } from "./easing.js";
4
+ import { prefersReducedMotion } from "./reduced-motion.js";
5
+ /**
6
+ * SVG path drawing animation. Drives `stroke-dashoffset` from the full
7
+ * path length to 0 so the stroke draws itself on, eased on the shared
8
+ * clock. The length is read with `getTotalLength()`, so any path shape
9
+ * works with no manual measuring.
10
+ *
11
+ * Under reduced motion (and on the server) the path renders fully drawn.
12
+ * SSR-safe.
13
+ *
14
+ * ```tsx
15
+ * let path!: SVGPathElement;
16
+ * const draw = createPathDraw(() => path, {
17
+ * duration: 1600,
18
+ * onDone: () => console.log("drawn"),
19
+ * });
20
+ * <svg viewBox="0 0 100 100">
21
+ * <path
22
+ * ref={path}
23
+ * d="M10 80 C 40 10, 60 10, 90 80"
24
+ * fill="none"
25
+ * stroke="currentColor"
26
+ * stroke-width="3"
27
+ * />
28
+ * </svg>
29
+ * ```
30
+ */
31
+ export function createPathDraw(ref, options = {}) {
32
+ const { duration = 1200, easing = easeInOutCubic, autoStart = true, onDone, } = options;
33
+ const [progress, setProgress] = createSignal(0);
34
+ const [running, setRunning] = createSignal(false);
35
+ if (typeof window === "undefined") {
36
+ const noop = () => { };
37
+ return { progress, running, start: noop, stop: noop, reset: noop };
38
+ }
39
+ let stopClock = null;
40
+ let doneFired = false;
41
+ const paint = (p) => {
42
+ const el = ref();
43
+ if (!el || typeof el.getTotalLength !== "function")
44
+ return;
45
+ const len = el.getTotalLength();
46
+ el.style.strokeDasharray = `${len}`;
47
+ el.style.strokeDashoffset = `${len * (1 - p)}`;
48
+ };
49
+ const finish = () => {
50
+ setProgress(1);
51
+ paint(1);
52
+ setRunning(false);
53
+ stopClock = null;
54
+ if (!doneFired) {
55
+ doneFired = true;
56
+ onDone?.();
57
+ }
58
+ };
59
+ const start = () => {
60
+ if (running())
61
+ return;
62
+ if (duration <= 0 || prefersReducedMotion()) {
63
+ setProgress(1);
64
+ finish();
65
+ return;
66
+ }
67
+ doneFired = false;
68
+ setRunning(true);
69
+ const from = progress();
70
+ if (from >= 1) {
71
+ finish();
72
+ return;
73
+ }
74
+ // Remaining time is proportional, so resume keeps a constant speed.
75
+ const span = Math.max(duration * (1 - from), 1);
76
+ const t0 = now();
77
+ paint(from);
78
+ stopClock?.();
79
+ stopClock = schedule((t) => {
80
+ const p = Math.min(Math.max((t - t0) / span, 0), 1);
81
+ const eased = from + (1 - from) * easing(p);
82
+ setProgress(eased);
83
+ paint(eased);
84
+ if (p >= 1) {
85
+ finish();
86
+ return false;
87
+ }
88
+ return true;
89
+ });
90
+ };
91
+ const stop = () => {
92
+ stopClock?.();
93
+ stopClock = null;
94
+ setRunning(false);
95
+ };
96
+ const reset = () => {
97
+ stop();
98
+ doneFired = false;
99
+ setProgress(0);
100
+ paint(0);
101
+ };
102
+ onCleanup(stop);
103
+ if (autoStart)
104
+ start();
105
+ return { progress, running, start, stop, reset };
106
+ }
@@ -0,0 +1,57 @@
1
+ import { type Accessor } from "solid-js";
2
+ type MaybeElement = () => Element | null | undefined;
3
+ export interface PressOptions {
4
+ /** Fires whenever the pressed state changes. */
5
+ onChange?: (pressed: boolean) => void;
6
+ }
7
+ export interface PressControls {
8
+ /** True while the pointer (or keyboard) is pressing the element. */
9
+ pressed: Accessor<boolean>;
10
+ }
11
+ export interface HoverOptions {
12
+ /** Fires whenever the hovering state changes. */
13
+ onChange?: (hovering: boolean) => void;
14
+ }
15
+ export interface HoverControls {
16
+ /** True while the pointer is over the element (or it has focus). */
17
+ hovering: Accessor<boolean>;
18
+ }
19
+ /**
20
+ * Press gesture state. Tracks pointer down/up on the element (mouse,
21
+ * touch, and pen via pointer events) plus Enter/Space key presses for
22
+ * keyboard parity. There is no animation here: the host decides how to
23
+ * respond, typically by driving `createVariants`.
24
+ *
25
+ * SSR-safe: always unpressed on the server.
26
+ *
27
+ * ```tsx
28
+ * let btn!: HTMLButtonElement;
29
+ * const press = createPress(() => btn);
30
+ * <button
31
+ * ref={btn}
32
+ * style={{
33
+ * transform: press.pressed() ? "scale(0.96)" : "scale(1)",
34
+ * transition: "transform 120ms",
35
+ * }}
36
+ * >
37
+ * Hold me
38
+ * </button>
39
+ * ```
40
+ */
41
+ export declare function createPress(ref: MaybeElement, options?: PressOptions): PressControls;
42
+ /**
43
+ * Hover gesture state. Tracks pointer enter/leave plus focus/blur so
44
+ * keyboard users get the same state. Like `createPress` this is state
45
+ * only: pair it with `createVariants` (or plain styles) to animate.
46
+ *
47
+ * SSR-safe: never hovering on the server.
48
+ *
49
+ * ```tsx
50
+ * let card!: HTMLDivElement;
51
+ * const hover = createHover(() => card, {
52
+ * onChange: (h) => variants.go(h ? "hover" : "idle"),
53
+ * });
54
+ * ```
55
+ */
56
+ export declare function createHover(ref: MaybeElement, options?: HoverOptions): HoverControls;
57
+ export {};
package/dist/press.js ADDED
@@ -0,0 +1,116 @@
1
+ import { createEffect, createSignal, onCleanup } from "solid-js";
2
+ const falsy = () => false;
3
+ /**
4
+ * Press gesture state. Tracks pointer down/up on the element (mouse,
5
+ * touch, and pen via pointer events) plus Enter/Space key presses for
6
+ * keyboard parity. There is no animation here: the host decides how to
7
+ * respond, typically by driving `createVariants`.
8
+ *
9
+ * SSR-safe: always unpressed on the server.
10
+ *
11
+ * ```tsx
12
+ * let btn!: HTMLButtonElement;
13
+ * const press = createPress(() => btn);
14
+ * <button
15
+ * ref={btn}
16
+ * style={{
17
+ * transform: press.pressed() ? "scale(0.96)" : "scale(1)",
18
+ * transition: "transform 120ms",
19
+ * }}
20
+ * >
21
+ * Hold me
22
+ * </button>
23
+ * ```
24
+ */
25
+ export function createPress(ref, options = {}) {
26
+ const { onChange } = options;
27
+ if (typeof window === "undefined")
28
+ return { pressed: falsy };
29
+ const [pressed, setPressed] = createSignal(false);
30
+ const set = (value) => {
31
+ setPressed((prev) => {
32
+ if (prev !== value)
33
+ onChange?.(value);
34
+ return value;
35
+ });
36
+ };
37
+ createEffect(() => {
38
+ const el = ref();
39
+ if (!el)
40
+ return;
41
+ const down = () => set(true);
42
+ const up = () => set(false);
43
+ const onKeyDown = (event) => {
44
+ if (event.key === "Enter" || event.key === " ") {
45
+ if (!event.repeat)
46
+ set(true);
47
+ if (event.key === " ")
48
+ event.preventDefault?.();
49
+ }
50
+ };
51
+ const onKeyUp = (event) => {
52
+ if (event.key === "Enter" || event.key === " ")
53
+ set(false);
54
+ };
55
+ el.addEventListener("pointerdown", down);
56
+ el.addEventListener("pointerup", up);
57
+ el.addEventListener("pointercancel", up);
58
+ el.addEventListener("pointerleave", up);
59
+ el.addEventListener("keydown", onKeyDown);
60
+ el.addEventListener("keyup", onKeyUp);
61
+ onCleanup(() => {
62
+ el.removeEventListener("pointerdown", down);
63
+ el.removeEventListener("pointerup", up);
64
+ el.removeEventListener("pointercancel", up);
65
+ el.removeEventListener("pointerleave", up);
66
+ el.removeEventListener("keydown", onKeyDown);
67
+ el.removeEventListener("keyup", onKeyUp);
68
+ });
69
+ });
70
+ return { pressed };
71
+ }
72
+ /**
73
+ * Hover gesture state. Tracks pointer enter/leave plus focus/blur so
74
+ * keyboard users get the same state. Like `createPress` this is state
75
+ * only: pair it with `createVariants` (or plain styles) to animate.
76
+ *
77
+ * SSR-safe: never hovering on the server.
78
+ *
79
+ * ```tsx
80
+ * let card!: HTMLDivElement;
81
+ * const hover = createHover(() => card, {
82
+ * onChange: (h) => variants.go(h ? "hover" : "idle"),
83
+ * });
84
+ * ```
85
+ */
86
+ export function createHover(ref, options = {}) {
87
+ const { onChange } = options;
88
+ if (typeof window === "undefined")
89
+ return { hovering: falsy };
90
+ const [hovering, setHovering] = createSignal(false);
91
+ const set = (value) => {
92
+ setHovering((prev) => {
93
+ if (prev !== value)
94
+ onChange?.(value);
95
+ return value;
96
+ });
97
+ };
98
+ createEffect(() => {
99
+ const el = ref();
100
+ if (!el)
101
+ return;
102
+ const enter = () => set(true);
103
+ const leave = () => set(false);
104
+ el.addEventListener("pointerenter", enter);
105
+ el.addEventListener("pointerleave", leave);
106
+ el.addEventListener("focus", enter);
107
+ el.addEventListener("blur", leave);
108
+ onCleanup(() => {
109
+ el.removeEventListener("pointerenter", enter);
110
+ el.removeEventListener("pointerleave", leave);
111
+ el.removeEventListener("focus", enter);
112
+ el.removeEventListener("blur", leave);
113
+ });
114
+ });
115
+ return { hovering };
116
+ }
@@ -0,0 +1,63 @@
1
+ import { type Accessor } from "solid-js";
2
+ import { type Easing } from "./easing.js";
3
+ /**
4
+ * One named animation state: CSS-ish property values. Numeric values
5
+ * interpolate; anything else snaps to the target when the transition
6
+ * finishes.
7
+ */
8
+ export interface VariantDef {
9
+ [prop: string]: number | string;
10
+ }
11
+ export interface VariantsOptions {
12
+ /** Variant applied immediately on creation. */
13
+ initial?: string;
14
+ /** Transition length in ms. Default 250. */
15
+ duration?: number;
16
+ /** Easing for numeric interpolation. Default easeOutCubic. */
17
+ easing?: Easing;
18
+ }
19
+ export interface VariantsControls {
20
+ /** Name of the last variant passed to `go`. */
21
+ current: Accessor<string | undefined>;
22
+ /**
23
+ * Current interpolated values. Bind these straight into styles:
24
+ * `style={{ transform: `scale(${v.values().scale})` }}`.
25
+ */
26
+ values: Accessor<Record<string, number | string>>;
27
+ /** Transition to the named variant. Unknown names are ignored. */
28
+ go: (name: string) => void;
29
+ }
30
+ /**
31
+ * Named animation states with tweened transitions, in the vocabulary of
32
+ * "variants". `go(name)` interpolates every numeric property from the
33
+ * current values to the target variant on the shared clock; non-numeric
34
+ * properties (colors, keywords) snap at the end of the transition.
35
+ *
36
+ * Pairs with `createHover` / `createPress`: map gesture states to variant
37
+ * names and the element animates between them.
38
+ *
39
+ * Under reduced motion (and on the server) transitions snap instantly.
40
+ * SSR-safe.
41
+ *
42
+ * ```tsx
43
+ * const card = createVariants(
44
+ * {
45
+ * idle: { scale: 1, shadow: 0 },
46
+ * hover: { scale: 1.04, shadow: 12 },
47
+ * press: { scale: 0.96, shadow: 4 },
48
+ * },
49
+ * { initial: "idle", duration: 180 },
50
+ * );
51
+ * createHover(() => el, {
52
+ * onChange: (h) => card.go(h ? "hover" : "idle"),
53
+ * });
54
+ * <div
55
+ * ref={el}
56
+ * style={{
57
+ * transform: `scale(${card.values().scale})`,
58
+ * "box-shadow": `0 ${card.values().shadow}px 24px rgb(0 0 0 / 0.12)`,
59
+ * }}
60
+ * />
61
+ * ```
62
+ */
63
+ export declare function createVariants(variants: Record<string, VariantDef>, options?: VariantsOptions): VariantsControls;
@@ -0,0 +1,86 @@
1
+ import { createSignal, onCleanup, untrack } from "solid-js";
2
+ import { now, schedule } from "./engine.js";
3
+ import { easeOutCubic } from "./easing.js";
4
+ import { prefersReducedMotion } from "./reduced-motion.js";
5
+ /**
6
+ * Named animation states with tweened transitions, in the vocabulary of
7
+ * "variants". `go(name)` interpolates every numeric property from the
8
+ * current values to the target variant on the shared clock; non-numeric
9
+ * properties (colors, keywords) snap at the end of the transition.
10
+ *
11
+ * Pairs with `createHover` / `createPress`: map gesture states to variant
12
+ * names and the element animates between them.
13
+ *
14
+ * Under reduced motion (and on the server) transitions snap instantly.
15
+ * SSR-safe.
16
+ *
17
+ * ```tsx
18
+ * const card = createVariants(
19
+ * {
20
+ * idle: { scale: 1, shadow: 0 },
21
+ * hover: { scale: 1.04, shadow: 12 },
22
+ * press: { scale: 0.96, shadow: 4 },
23
+ * },
24
+ * { initial: "idle", duration: 180 },
25
+ * );
26
+ * createHover(() => el, {
27
+ * onChange: (h) => card.go(h ? "hover" : "idle"),
28
+ * });
29
+ * <div
30
+ * ref={el}
31
+ * style={{
32
+ * transform: `scale(${card.values().scale})`,
33
+ * "box-shadow": `0 ${card.values().shadow}px 24px rgb(0 0 0 / 0.12)`,
34
+ * }}
35
+ * />
36
+ * ```
37
+ */
38
+ export function createVariants(variants, options = {}) {
39
+ const { initial, duration = 250, easing = easeOutCubic } = options;
40
+ const first = initial ?? Object.keys(variants)[0];
41
+ const [current, setCurrent] = createSignal(first);
42
+ const [values, setValues] = createSignal({
43
+ ...(first ? variants[first] : {}),
44
+ });
45
+ if (typeof window === "undefined") {
46
+ return { current, values, go: () => { } };
47
+ }
48
+ let stopClock = null;
49
+ const go = (name) => {
50
+ const target = variants[name];
51
+ if (!target)
52
+ return;
53
+ setCurrent(name);
54
+ stopClock?.();
55
+ stopClock = null;
56
+ const from = untrack(values);
57
+ if (duration <= 0 || prefersReducedMotion()) {
58
+ setValues({ ...target });
59
+ return;
60
+ }
61
+ const start = now();
62
+ const numeric = Object.keys(target).filter((k) => typeof target[k] === "number" && typeof from[k] === "number");
63
+ stopClock = schedule((t) => {
64
+ const p = Math.min(Math.max((t - start) / duration, 0), 1);
65
+ const e = easing(p);
66
+ const next = { ...untrack(values) };
67
+ for (const k of numeric) {
68
+ const a = from[k];
69
+ const b = target[k];
70
+ next[k] = a + (b - a) * e;
71
+ }
72
+ if (p >= 1) {
73
+ // Snap non-numeric props (and exact numeric targets) at the end.
74
+ for (const k of Object.keys(target))
75
+ next[k] = target[k];
76
+ setValues(next);
77
+ stopClock = null;
78
+ return false;
79
+ }
80
+ setValues(next);
81
+ return true;
82
+ });
83
+ };
84
+ onCleanup(() => stopClock?.());
85
+ return { current, values, go };
86
+ }
package/package.json CHANGED
@@ -43,5 +43,5 @@
43
43
  },
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "0.30.0"
46
+ "version": "0.32.0"
47
47
  }