morphicons 1.4.1 → 1.5.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
@@ -45,6 +45,8 @@ bun add morphicons # or npm install / pnpm add
45
45
 
46
46
  ESM only. `react` (>= 18), `vue` (>= 3.3), `svelte` (>= 5) and `react-native` (>= 0.71) + `react-native-svg` (>= 14) are optional peers — only needed for `morphicons/react`, `morphicons/vue`, `morphicons/svelte` and `morphicons/react-native`.
47
47
 
48
+ **Icons come from a data package, not a component package.** morphicons consumes icon *data* (an `IconNode` or a raw `d` string). For Lucide that means the vanilla `lucide` package: `import { Menu, X } from "lucide"` gives you `IconNode`s, which is why every snippet below says "data, not components". The framework packages (`lucide-react`, `lucide-vue-next`, `@lucide/svelte`, `lucide-react-native`) export components, and `MorphIcon` can't consume those. If your app already renders static icons with one of them, keep it: the data and component packages coexist by design and both tree-shake cleanly, so you only pay for the icons you import. Just keep their versions aligned, so the icons you morph match the ones you render statically.
49
+
48
50
  ## Usage
49
51
 
50
52
  ### React — three modes
@@ -65,7 +67,7 @@ ref.current?.morphTo(Check); // animates
65
67
  ref.current?.set(X); // jumps without animating
66
68
  ```
67
69
 
68
- Drop-in replacement for lucide-react: `size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, `className` and the rest of the `<svg>` props pass straight through. Correct accessibility by default: `aria-hidden` unless you pass `label` (→ `role="img"` + `<title>`). Clean SSR: the server emits the exact static SVG (zero flash, zero layout shift); the runtime is born on hydration. `prefers-reduced-motion` degrades to an instant swap.
70
+ Drop-in replacement for lucide-react (the props surface: the `icon` prop takes Lucide *data*, not lucide-react components, see [Install](#install)): `size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, `className` and the rest of the `<svg>` props pass straight through. Correct accessibility by default: `aria-hidden` unless you pass `label` (→ `role="img"` + `<title>`). Clean SSR: the server emits the exact static SVG (zero flash, zero layout shift); the runtime is born on hydration. Morphs play regardless of the OS reduce-motion setting by default; opt into honoring it with `reducedMotion="user"` (see [Reduced motion](#reduced-motion-all-four-bindings)).
69
71
 
70
72
  ### Vue — same three modes
71
73
 
@@ -118,7 +120,7 @@ Same surface as the React binding: presentation props (`size`, `strokeWidth`, `a
118
120
  <MorphIcon bind:this={morph} icon={Menu} />
119
121
  ```
120
122
 
121
- Same surface again: presentation props, `class`/`style`/rest-attr fall-through — fully typed via `svelte/elements`, so SVG attrs, events and ARIA autocomplete and typos fail the build, like lucide-svelte — the same accessibility defaults and the same clean SSR — works with SvelteKit out of the box. Svelte 5 (runes); the component ships as `.svelte` source and your bundler compiles it via the `svelte` export condition, like every Svelte library.
123
+ Same surface again: presentation props, `class`/`style`/rest-attr fall-through — fully typed via `svelte/elements`, so SVG attrs, events and ARIA autocomplete and typos fail the build, like lucide-svelte — the same accessibility defaults and the same clean SSR — works with SvelteKit out of the box. Svelte 5 (runes); the component ships as `.svelte` source and your bundler compiles it via the `svelte` export condition, like every Svelte library. If you followed Lucide's official Svelte guide you already have `@lucide/svelte`: keep it for your static icons; `MorphIcon` takes the data exports from the vanilla `lucide` package (see [Install](#install)).
122
124
 
123
125
  ### React Native — same three modes
124
126
 
@@ -139,7 +141,7 @@ ref.current?.morphTo(Check); // animates
139
141
  ref.current?.set(X); // jumps without animating
140
142
  ```
141
143
 
142
- The DOM-free core beyond the browser: the dom driver is reused verbatim as the engine (React Native has a global `requestAnimationFrame`, and `PathEl` is structural), so the whole platform difference is a shim that forwards the per-frame `d` write to `Path.setNativeProps` of react-native-svg — outside the React render, exactly like the web mutation. Same surface as the React binding (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, plus the native `Svg` props: `testID`, touch handlers…), same accessibility defaults (`aria-hidden` unless you pass `label` → `role="img"` + `aria-label`). Reduced motion comes from `AccessibilityInfo` (best-effort: the query is async; a `reduceMotionChanged` subscription keeps it exact from then on). Requires Metro with package `exports` resolution — default since React Native 0.79; on older versions enable `unstable_enablePackageExports`.
144
+ The DOM-free core beyond the browser: the dom driver is reused verbatim as the engine (React Native has a global `requestAnimationFrame`, and `PathEl` is structural), so the whole platform difference is a shim that forwards the per-frame `d` write to `Path.setNativeProps` of react-native-svg — outside the React render, exactly like the web mutation. Same surface as the React binding (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, plus the native `Svg` props: `testID`, touch handlers…), same accessibility defaults (`aria-hidden` unless you pass `label` → `role="img"` + `aria-label`). With `reducedMotion="user"`, the OS setting comes from `AccessibilityInfo` (best-effort: the query is async; a `reduceMotionChanged` subscription keeps it exact from then on). Requires Metro with package `exports` resolution — default since React Native 0.79; on older versions enable `unstable_enablePackageExports`.
143
145
 
144
146
  ### Lifecycle contract (all four bindings)
145
147
 
@@ -149,6 +151,20 @@ The four components share one contract, pinned by mirrored client-mount tests:
149
151
  - **Controlled wins.** While `from` AND `to` are both present, the pair owns the path: `icon` changes are ignored, no spring fires. Drop the pair and the current `icon` takes over (animated). Mixing the modes is not an error — the precedence is just explicit.
150
152
  - **Clean re-entry.** Any exit from controlled mode (an imperative call or an icon takeover) invalidates the frozen pair, so returning to the same `from`/`to` re-bases on `from` and renders exactly like a clean mount at that `progress`.
151
153
 
154
+ ### Reduced motion (all four bindings)
155
+
156
+ Icon morphs are small, short, communicative micro-transitions: the kind of motion the reduce-motion guidance considers generally acceptable, unlike parallax or full-screen movement. Auto-degrading them made the library look broken to every user with the OS setting on, so since 1.4.2 they play by default and the policy is an explicit prop (the same default Motion, formerly Framer Motion, ships):
157
+
158
+ ```tsx
159
+ <MorphIcon icon={open ? X : Menu} reducedMotion="user" />
160
+ ```
161
+
162
+ - `"never"` (default): morphs always animate; the OS setting is ignored.
163
+ - `"user"`: while the OS reduce-motion setting is on, every `morphTo` degrades to an instant swap (the pre-1.4.2 behavior). The web bindings read `prefers-reduced-motion`; React Native reads `AccessibilityInfo`, and only touches the accessibility bridge when some instance opts in.
164
+ - `"always"`: every `morphTo` jumps without animating (tests, screenshots).
165
+
166
+ The prop is live: changing it governs the next morph. The vanilla driver takes the same policy as an option, `createMorph(el, icon, { reducedMotion: "user" })`, and exposes it as a mutable `morph.reducedMotion`. Apps that must strictly follow the user's system preference should pass `reducedMotion="user"` across the board.
167
+
152
168
  ### Vanilla (no React)
153
169
 
154
170
  ```ts
@@ -159,6 +175,7 @@ m.morphTo(X, "snappy"); // preset or { stiffness, damping }
159
175
  m.set(Check); // jump without animating (canonical d)
160
176
  m.seek(X, 0.4); // morph frozen at t — scrubbing, gestures
161
177
  m.progress = 0.4; // sugar: seek on the active target
178
+ m.reducedMotion = "user"; // live policy: "never" (default) | "user" | "always"
162
179
  m.destroy();
163
180
  ```
164
181
 
@@ -177,6 +194,42 @@ const d = serialize(out, plan.items.map(it => it.closed)); // `d` attribute
177
194
 
178
195
  Accepts `IconNode` (Lucide's data format, structurally typed — Lucide is neither a dependency nor a peer) or a raw `d` attribute. Tabler, Heroicons, Iconoir and custom paths work the same.
179
196
 
197
+ ## Format adapters — `morphicons/adapters`
198
+
199
+ The core has exactly two structural contracts: `IconInput` on the way in (an `IconNode` or a `d` string) and `PathEl` on the way out (anything with `setAttribute` — the same contract that lets the React Native binding drive `setNativeProps`). A **format adapter** converts a foreign icon representation into one of those two contracts. All of them live in one opt-in entry, so the unit of payment is the import: core, driver and bindings never grow for a capability you didn't ask for, and inside the entry each adapter is an independent module — a named import tree-shakes to just that adapter, pinned by per-export size gates in CI. Adapters are per *format*, never per vendor: there is no `/iconify` and never will be.
200
+
201
+ ### `svgToIcon` — SVG markup in
202
+
203
+ For icons that exist as markup rather than data: an Iconify body from `@iconify-json/*` (what UnoCSS `presetIcons` and the Tailwind icon plugins consume), a full `<svg>` copied from a website, a `<path>` from the shadcn registry. One call turns it into an `IconInput` any entry accepts:
204
+
205
+ ```ts
206
+ import { svgToIcon } from "morphicons/adapters";
207
+
208
+ // Parse ONCE, at module scope — reuse the reference so the plan cache holds.
209
+ const MENU = svgToIcon(menuBody); // '<path stroke="currentColor" d="M4 5h16…"/>'
210
+ const X = svgToIcon(xBody); // works in createMorph and all four bindings
211
+ ```
212
+
213
+ Everything else happens under the hood: non-rendered containers (`<defs>`, `<mask>`, `<clipPath>`, `<symbol>`) are stripped, the morphability scan rejects what can't honestly morph (a fill-drawn icon with no stroke — Material Symbols style — a `transform`, an element outside the seven stroke primitives) with a clear error at parse time, and when the markup carries a `viewBox` the geometry is re-gridded onto 24 via [`fitIcon`](#icon-library-compatibility) automatically, so off-grid collections just work.
214
+
215
+ ### `maskTarget` — CSS-mask elements out
216
+
217
+ For apps that render icons as CSS masks — UnoCSS `presetIcons`, `@iconify/tailwind`, `@egoist/tailwindcss-icons` (`i-lucide-*`, `icon-[lucide-x]`): the icon is a `mask-image` data URI on a `<span>`/`<div>`, with **no `<path>` in the DOM** to write to. `maskTarget` creates a hidden pair of referenced `<svg><mask><path>` buffers once, points the element's mask at them, and hands the driver a `PathEl` whose `d` writes land on that real geometry — so the element morphs in place, keeping your `size-*`/`text-*` classes. Two engine traps shaped this mechanism (see `docs/adr/0002`): a data-URI mask swapped per frame never decodes in time in Chromium (blank flight), and WebKit doesn't repaint when a referenced mask's *content* mutates (frozen flight) — hence the pair: each write flips `mask-image` between the two buffers, which forces re-resolution everywhere.
218
+
219
+ ```ts
220
+ import { maskTarget } from "morphicons/adapters";
221
+ import { createMorph } from "morphicons/dom";
222
+
223
+ const target = maskTarget(spanEl); // spanEl: class="size-5 text-current"
224
+ const m = createMorph(target, MENU);
225
+ m.morphTo(X, "snappy"); // full Morph handle: set/seek/progress/destroy
226
+ // on unmount: m.destroy(); target.dispose();
227
+ ```
228
+
229
+ It *is* `createMorph` — only the write target differs — so springs, reduced motion and mid-flight interruption behave identically. On setup it sets `background-color: currentColor`, so a bare `<span>` works with no icon class (`{ paint: false }` keeps an existing `bg-*`; `strokeWidth` and `viewBox` are options). The returned target adds `dispose()` — call it after `morph.destroy()` when the element unmounts, so the hidden mask nodes go with it.
230
+
231
+ **Cost.** A live inline `<path>` gets the cheapest possible write; a mask element additionally makes the browser re-rasterize the mask and repaint the masked box every frame (main thread, no compositor-only path). Great for toggles and short lists (menu↔close, chevrons, play↔pause); for icon-heavy views, the inline-SVG bindings remain the leanest option — same styling, one less indirection.
232
+
180
233
  ## Icon library compatibility
181
234
 
182
235
  morphicons has no per-library adapters — any icon set that meets these requirements works out of the box:
@@ -353,12 +406,17 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
353
406
  | entry | measured | gate |
354
407
  |---|---|---|
355
408
  | `morphicons` (core) | 6.60 KB | 7 KB |
356
- | `morphicons/dom` (core + driver) | 7.04 KB | 7.5 KB |
357
- | `morphicons/react` (all, react external) | 7.89 KB | 8.5 KB |
358
- | `morphicons/react-native` (all, react/rn/rnsvg external) | 8.25 KB | 9 KB |
359
- | `morphicons/vue` (all, vue external) | 7.93 KB | 8.5 KB |
360
- | `morphicons/svelte` TS half (all, svelte external) | 7.54 KB | 8 KB |
361
- | `MorphIcon.svelte` (ships as source, consumer-compiled) | 1.25 KB | 1.4 KB |
409
+ | `morphicons/dom` (core + driver) | 7.12 KB | 7.5 KB |
410
+ | `morphicons/adapters` (aggregate — grows by design) | 4.03 KB | 4.4 KB |
411
+ | `adapters` › `{ svgToIcon }` alone | 3.34 KB | 3.7 KB |
412
+ | `adapters` › `{ maskTarget }` alone | 0.70 KB | 0.8 KB |
413
+ | `morphicons/react` (all, react external) | 8.01 KB | 8.5 KB |
414
+ | `morphicons/react-native` (all, react/rn/rnsvg external) | 8.37 KB | 9 KB |
415
+ | `morphicons/vue` (all, vue external) | 8.04 KB | 8.5 KB |
416
+ | `morphicons/svelte` TS half (all, svelte external) | 7.66 KB | 8 KB |
417
+ | `MorphIcon.svelte` (ships as source, consumer-compiled) | 1.26 KB | 1.4 KB |
418
+
419
+ The adapters entry carries **two kinds of gate**: the aggregate (allowed to grow as adapters are added) and one per named export, which pins each adapter as independently tree-shakeable — if two adapters ever couple, the per-export gate trips in CI. Standalone, `{ svgToIcon }` measures 3.34 KB because auto-fit drags the core's lowering machinery; an app that already imports the core shares that chunk and pays ~0.4 KB incremental.
362
420
 
363
421
  ## Playground
364
422
 
@@ -25,6 +25,7 @@ let {
25
25
  to,
26
26
  progress,
27
27
  spring,
28
+ reducedMotion,
28
29
  size = 24,
29
30
  color = "currentColor",
30
31
  strokeWidth = 2,
@@ -40,17 +41,17 @@ const initialD = computeInitialD({ icon, from, to, progress });
40
41
  let pathEl: SVGPathElement | undefined = $state();
41
42
  // svelte-ignore state_referenced_locally — init-time values on purpose: they
42
43
  // seed the controller's watch baselines so the first $effect run is a no-op.
43
- const ctrl = createController({ icon, from, to, progress });
44
+ const ctrl = createController({ icon, from, to, progress, reducedMotion });
44
45
 
45
46
  onMount(() => {
46
- if (pathEl) ctrl.mount(pathEl, { icon, from, to, progress });
47
+ if (pathEl) ctrl.mount(pathEl, { icon, from, to, progress, reducedMotion });
47
48
  return () => ctrl.destroy();
48
49
  });
49
50
 
50
51
  // Prop changes: one watcher owns the mode logic (controlled wins — change
51
52
  // detection and precedence live in shared.ts).
52
53
  $effect(() => {
53
- const next = { icon, from, to, progress, spring };
54
+ const next = { icon, from, to, progress, spring, reducedMotion };
54
55
  untrack(() => ctrl.watch(next));
55
56
  });
56
57
 
@@ -0,0 +1,55 @@
1
+ import { n as IconInput } from "./types-JAF3s76Y.js";
2
+ import { i as PathEl } from "./index-DXp5b6BU.js";
3
+ //#region src/adapters/mask.d.ts
4
+ /** The subset of an element's inline style the mask target writes — structural
5
+ * so a real HTMLElement's `.style` satisfies it (and a test fake supplies
6
+ * just these). */
7
+ interface MaskStyle {
8
+ maskImage: string;
9
+ webkitMaskImage: string;
10
+ backgroundColor: string;
11
+ }
12
+ /** An element the mask target can drive (an HTMLElement, or a fake in tests). */
13
+ interface MaskEl {
14
+ style: MaskStyle;
15
+ }
16
+ interface MaskTargetOptions {
17
+ /** Stroke width of the mask geometry (Lucide's default is 2). */
18
+ strokeWidth?: number;
19
+ /** Grid the icon's coordinates live on (Lucide/Iconify default: "0 0 24 24").
20
+ * Used to map the geometry onto the element's box. */
21
+ viewBox?: string;
22
+ /** Set `background-color: currentColor` on first setup so the element shows
23
+ * through the mask in the current text color, standalone (no icon class).
24
+ * Turn off to keep an existing paint (e.g. a `bg-*` utility). Default true. */
25
+ paint?: boolean;
26
+ }
27
+ /** What `maskTarget` returns: the driver's write contract plus a `dispose`
28
+ * that removes the hidden mask node (call it after `morph.destroy()` when
29
+ * the element unmounts). */
30
+ interface MaskPathEl extends PathEl {
31
+ dispose(): void;
32
+ }
33
+ /** Adapts an element into a `PathEl` whose writes drive a referenced SVG
34
+ * mask, so `createMorph(maskTarget(el), icon)` animates a mask-styled
35
+ * element instead of an inline `<svg><path>`.
36
+ *
37
+ * DOUBLE BUFFER, on purpose: WebKit does not reliably repaint a masked
38
+ * element when the CONTENT of its referenced `<mask>` mutates — a lone mask
39
+ * whose inner path `d` changes per frame renders frozen in Safari until the
40
+ * flight ends. So the target keeps TWO masks and every `d` write lands on
41
+ * the back buffer and re-points `mask-image` at it: the property VALUE flips
42
+ * (`url(#a)` ↔ `url(#b)`) each frame, which forces reference re-resolution
43
+ * in every engine. Chromium/Gecko pay nothing extra for it. */
44
+ declare function maskTarget(el: MaskEl, opts?: MaskTargetOptions): MaskPathEl;
45
+ //#endregion
46
+ //#region src/adapters/svg.d.ts
47
+ /** SVG markup → an IconInput the pipeline can lower. Strips non-rendered
48
+ * containers, rejects what can't be honestly morphed (a fill-drawn icon with
49
+ * no stroke, a transform, an unsupported element), and re-grids onto 24 via
50
+ * the viewBox when one is present — so an off-grid collection lands on the
51
+ * shared grid automatically. Returns a `d` (when a viewBox forced a fit) or
52
+ * an IconNode. */
53
+ declare function svgToIcon(markup: string): IconInput;
54
+ //#endregion
55
+ export { type MaskEl, type MaskPathEl, type MaskStyle, type MaskTargetOptions, maskTarget, svgToIcon };
@@ -0,0 +1,139 @@
1
+ import { t as fitIcon } from "./normalize-CYnN3Npw.js";
2
+ //#region src/adapters/mask.ts
3
+ const SVG_NS = "http://www.w3.org/2000/svg";
4
+ let seq = 0;
5
+ let host = null;
6
+ /** Adapts an element into a `PathEl` whose writes drive a referenced SVG
7
+ * mask, so `createMorph(maskTarget(el), icon)` animates a mask-styled
8
+ * element instead of an inline `<svg><path>`.
9
+ *
10
+ * DOUBLE BUFFER, on purpose: WebKit does not reliably repaint a masked
11
+ * element when the CONTENT of its referenced `<mask>` mutates — a lone mask
12
+ * whose inner path `d` changes per frame renders frozen in Safari until the
13
+ * flight ends. So the target keeps TWO masks and every `d` write lands on
14
+ * the back buffer and re-points `mask-image` at it: the property VALUE flips
15
+ * (`url(#a)` ↔ `url(#b)`) each frame, which forces reference re-resolution
16
+ * in every engine. Chromium/Gecko pay nothing extra for it. */
17
+ function maskTarget(el, opts = {}) {
18
+ if (typeof document === "undefined") throw new Error("morphicons: maskTarget needs a DOM (client-side only)");
19
+ const doc = document;
20
+ if (!host) {
21
+ host = doc.createElementNS(SVG_NS, "svg");
22
+ host.setAttribute("width", "0");
23
+ host.setAttribute("height", "0");
24
+ host.setAttribute("aria-hidden", "true");
25
+ host.setAttribute("style", "position:absolute");
26
+ doc.body.appendChild(host);
27
+ }
28
+ const vb = (opts.viewBox ?? "0 0 24 24").trim().split(/[\s,]+/).map(Number);
29
+ const [mx, my, w, h] = [
30
+ vb[0] || 0,
31
+ vb[1] || 0,
32
+ vb[2] || 24,
33
+ vb[3] || vb[2] || 24
34
+ ];
35
+ const makeLayer = () => {
36
+ const id = `morphicons-mask-${++seq}`;
37
+ const mask = doc.createElementNS(SVG_NS, "mask");
38
+ mask.setAttribute("id", id);
39
+ mask.setAttribute("maskContentUnits", "objectBoundingBox");
40
+ const g = doc.createElementNS(SVG_NS, "g");
41
+ g.setAttribute("transform", `scale(${1 / w} ${1 / h}) translate(${-mx} ${-my})`);
42
+ const path = doc.createElementNS(SVG_NS, "path");
43
+ path.setAttribute("fill", "none");
44
+ path.setAttribute("stroke", "#fff");
45
+ path.setAttribute("stroke-width", String(opts.strokeWidth ?? 2));
46
+ path.setAttribute("stroke-linecap", "round");
47
+ path.setAttribute("stroke-linejoin", "round");
48
+ g.appendChild(path);
49
+ mask.appendChild(g);
50
+ if (host) host.appendChild(mask);
51
+ return {
52
+ url: `url(#${id})`,
53
+ mask,
54
+ path
55
+ };
56
+ };
57
+ const layers = [makeLayer(), makeLayer()];
58
+ let front = 0;
59
+ const s = el.style;
60
+ s.maskImage = s.webkitMaskImage = layers[0].url;
61
+ if (opts.paint !== false) s.backgroundColor = "currentColor";
62
+ return {
63
+ setAttribute(name, d) {
64
+ if (name !== "d") return;
65
+ front = 1 - front;
66
+ const layer = layers[front];
67
+ layer.path.setAttribute("d", d);
68
+ s.maskImage = s.webkitMaskImage = layer.url;
69
+ },
70
+ dispose() {
71
+ layers[0].mask.remove();
72
+ layers[1].mask.remove();
73
+ }
74
+ };
75
+ }
76
+ //#endregion
77
+ //#region src/adapters/svg.ts
78
+ const SVG_CONTAINER = /<(defs|mask|clipPath|symbol)\b[^>]*>[\s\S]*?<\/\1>/gi;
79
+ const SVG_ELEMENT = /<([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)\/?>/g;
80
+ const SVG_ATTR = /([\w:-]+)\s*=\s*"([^"]*)"|([\w:-]+)\s*=\s*'([^']*)'/g;
81
+ const SVG_VIEWBOX = /<svg\b[^>]*\bviewBox\s*=\s*["']([^"']+)["']/i;
82
+ const SVG_GEOMETRY = /* @__PURE__ */ new Set([
83
+ "path",
84
+ "line",
85
+ "circle",
86
+ "ellipse",
87
+ "rect",
88
+ "polyline",
89
+ "polygon"
90
+ ]);
91
+ const SVG_SKIP = /* @__PURE__ */ new Set([
92
+ "svg",
93
+ "g",
94
+ "title",
95
+ "desc",
96
+ "defs",
97
+ "mask",
98
+ "clippath",
99
+ "symbol"
100
+ ]);
101
+ function readSvgAttrs(raw) {
102
+ const attrs = {};
103
+ SVG_ATTR.lastIndex = 0;
104
+ let m;
105
+ while ((m = SVG_ATTR.exec(raw)) !== null) {
106
+ const name = m[1] ?? m[3];
107
+ if (name) attrs[name] = m[2] ?? m[4] ?? "";
108
+ }
109
+ return attrs;
110
+ }
111
+ /** SVG markup → an IconInput the pipeline can lower. Strips non-rendered
112
+ * containers, rejects what can't be honestly morphed (a fill-drawn icon with
113
+ * no stroke, a transform, an unsupported element), and re-grids onto 24 via
114
+ * the viewBox when one is present — so an off-grid collection lands on the
115
+ * shared grid automatically. Returns a `d` (when a viewBox forced a fit) or
116
+ * an IconNode. */
117
+ function svgToIcon(markup) {
118
+ if (markup.trimStart().charCodeAt(0) !== 60) throw new Error("morphicons: svgToIcon expects SVG markup — a d string or IconNode is already an IconInput");
119
+ const body = markup.replace(SVG_CONTAINER, "");
120
+ const hasStroke = /stroke\s*=\s*["'](?!\s*none\s*["'])/i.test(body);
121
+ const fillNone = /fill\s*=\s*["']\s*none\s*["']/i.test(body);
122
+ if (!hasStroke && !fillNone) throw new Error("morphicons: SVG looks fill-drawn (no stroke) — only stroke-centerline icons morph");
123
+ const node = [];
124
+ SVG_ELEMENT.lastIndex = 0;
125
+ let m;
126
+ while ((m = SVG_ELEMENT.exec(body)) !== null) {
127
+ const tag = m[1].toLowerCase();
128
+ if (SVG_SKIP.has(tag)) continue;
129
+ if (!SVG_GEOMETRY.has(tag)) throw new Error(`morphicons: unsupported SVG element <${tag}>`);
130
+ const attrs = readSvgAttrs(m[2]);
131
+ if ("transform" in attrs) throw new Error(`morphicons: <${tag}> has a transform (unsupported)`);
132
+ node.push([tag, attrs]);
133
+ }
134
+ if (node.length === 0) throw new Error("morphicons: no morphable geometry in SVG");
135
+ const vb = SVG_VIEWBOX.exec(markup);
136
+ return vb ? fitIcon(node, vb[1]) : node;
137
+ }
138
+ //#endregion
139
+ export { maskTarget, svgToIcon };
package/dist/dom.d.ts CHANGED
@@ -1,37 +1,2 @@
1
- import { c as SpringPreset, n as IconInput } from "./types-JAF3s76Y.js";
2
- //#region src/dom/index.d.ts
3
- /** Target element, structurally typed: any object with setAttribute works
4
- * (a real SVGPathElement, or a fake in tests). */
5
- interface PathEl {
6
- setAttribute(name: string, value: string): void;
7
- }
8
- /** Morph physics: named preset or custom spring. */
9
- interface MorphOptions {
10
- stiffness?: number;
11
- damping?: number;
12
- }
13
- interface Morph {
14
- /** Animates toward the icon with spring physics. Interruptible: mid-flight
15
- * it re-plans from the intermediate shape while preserving velocity. */
16
- morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
17
- /** Jumps to the icon without animating (canonical d, no frames). */
18
- set(icon: IconInput): void;
19
- /** Renders the morph toward `icon` frozen at t (no spring) — the
20
- * controlled-mode primitive: scrubbing, gestures, scroll. A later
21
- * morphTo takes off from that intermediate shape. */
22
- seek(icon: IconInput, t: number): void;
23
- /** Current progress (t of the last frame; 1 at rest). Assigning it is
24
- * equivalent to seek(target, t) on the active target. */
25
- progress: number;
26
- /** Unregisters the instance; later calls are no-ops. */
27
- destroy(): void;
28
- }
29
- /** Canonical `d` of an icon: the input string verbatim, or the real cubics
30
- * quantized to 4 decimals (the at-rest snap; engine-stable bytes so SSR
31
- * hydration matches, see fmtCanon in core/serialize). Exported because it
32
- * is what a binding renders at SSR/rest before any runtime exists. */
33
- declare function canonicalD(icon: IconInput): string;
34
- /** Creates the morph instance over a `<path>` and paints the initial icon. */
35
- declare function createMorph(el: PathEl, icon: IconInput): Morph;
36
- //#endregion
37
- export { Morph, MorphOptions, PathEl, canonicalD, createMorph };
1
+ import { a as ReducedMotionMode, i as PathEl, n as Morph, o as canonicalD, r as MorphOptions, s as createMorph, t as CreateMorphOptions } from "./index-DXp5b6BU.js";
2
+ export { CreateMorphOptions, Morph, MorphOptions, PathEl, ReducedMotionMode, canonicalD, createMorph };
package/dist/dom.js CHANGED
@@ -1,4 +1,5 @@
1
- import { a as buildPlan, c as cubicsToPathD, f as interpPolar, l as serialize, n as Spring, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-CBynweFS.js";
1
+ import { a as buildPlan, c as interpPolar, n as Spring, o as allocOutputs, r as resampleIcon, t as SPRING_PRESETS } from "./spring-CFHloqPP.js";
2
+ import { i as serialize, n as iconToCubics, r as cubicsToPathD } from "./normalize-CYnN3Npw.js";
2
3
  //#region src/dom/index.ts
3
4
  const tickers = /* @__PURE__ */ new Set();
4
5
  let rafId = 0;
@@ -76,8 +77,9 @@ function resolveSpring(s) {
76
77
  };
77
78
  }
78
79
  /** Creates the morph instance over a `<path>` and paints the initial icon. */
79
- function createMorph(el, icon) {
80
+ function createMorph(el, icon, options) {
80
81
  const spring = new Spring();
82
+ let reducedMotion = options?.reducedMotion ?? "never";
81
83
  let target = icon;
82
84
  let rest = true;
83
85
  let plan = null;
@@ -142,6 +144,13 @@ function createMorph(el, icon) {
142
144
  target = icon;
143
145
  settle();
144
146
  };
147
+ /** True when the policy says this morphTo must jump instead of flying. */
148
+ const motionOff = () => {
149
+ if (reducedMotion === "always") return true;
150
+ if (reducedMotion !== "user") return false;
151
+ if (typeof matchMedia === "undefined") return false;
152
+ return matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
153
+ };
145
154
  const seek = (icon, tt) => {
146
155
  if (dead) return;
147
156
  const reuse = !rest && plan !== null && icon === target;
@@ -154,11 +163,9 @@ function createMorph(el, icon) {
154
163
  morphTo(icon, sp) {
155
164
  if (dead) return;
156
165
  if (icon === target && (rest || flying)) return;
157
- if (typeof matchMedia !== "undefined") {
158
- if (matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
159
- setNow(icon);
160
- return;
161
- }
166
+ if (motionOff()) {
167
+ setNow(icon);
168
+ return;
162
169
  }
163
170
  const { k, c } = resolveSpring(sp);
164
171
  spring.config(k, c);
@@ -180,6 +187,12 @@ function createMorph(el, icon) {
180
187
  set progress(v) {
181
188
  if (!dead) seek(target, v);
182
189
  },
190
+ get reducedMotion() {
191
+ return reducedMotion;
192
+ },
193
+ set reducedMotion(v) {
194
+ reducedMotion = v;
195
+ },
183
196
  destroy() {
184
197
  stop();
185
198
  dead = true;
@@ -0,0 +1,48 @@
1
+ import { c as SpringPreset, n as IconInput } from "./types-JAF3s76Y.js";
2
+ //#region src/dom/index.d.ts
3
+ /** Target element, structurally typed: any object with setAttribute works
4
+ * (a real SVGPathElement, or a fake in tests). */
5
+ interface PathEl {
6
+ setAttribute(name: string, value: string): void;
7
+ }
8
+ /** Morph physics: named preset or custom spring. */
9
+ interface MorphOptions {
10
+ stiffness?: number;
11
+ damping?: number;
12
+ }
13
+ /** Reduced-motion policy. `"never"` (default): morphs always animate,
14
+ * ignoring the OS setting. `"user"`: while the OS reduce-motion setting is
15
+ * on, `morphTo` degrades to an instant `set`. `"always"`: every `morphTo`
16
+ * jumps (tests, screenshots). */
17
+ type ReducedMotionMode = "never" | "user" | "always";
18
+ interface CreateMorphOptions {
19
+ /** Reduced-motion policy; also live via the `reducedMotion` property. */
20
+ reducedMotion?: ReducedMotionMode;
21
+ }
22
+ interface Morph {
23
+ /** Animates toward the icon with spring physics. Interruptible: mid-flight
24
+ * it re-plans from the intermediate shape while preserving velocity. */
25
+ morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
26
+ /** Jumps to the icon without animating (canonical d, no frames). */
27
+ set(icon: IconInput): void;
28
+ /** Renders the morph toward `icon` frozen at t (no spring) — the
29
+ * controlled-mode primitive: scrubbing, gestures, scroll. A later
30
+ * morphTo takes off from that intermediate shape. */
31
+ seek(icon: IconInput, t: number): void;
32
+ /** Current progress (t of the last frame; 1 at rest). Assigning it is
33
+ * equivalent to seek(target, t) on the active target. */
34
+ progress: number;
35
+ /** Reduced-motion policy, live: assigning it applies to the next morphTo. */
36
+ reducedMotion: ReducedMotionMode;
37
+ /** Unregisters the instance; later calls are no-ops. */
38
+ destroy(): void;
39
+ }
40
+ /** Canonical `d` of an icon: the input string verbatim, or the real cubics
41
+ * quantized to 4 decimals (the at-rest snap; engine-stable bytes so SSR
42
+ * hydration matches, see fmtCanon in core/serialize). Exported because it
43
+ * is what a binding renders at SSR/rest before any runtime exists. */
44
+ declare function canonicalD(icon: IconInput): string;
45
+ /** Creates the morph instance over a `<path>` and paints the initial icon. */
46
+ declare function createMorph(el: PathEl, icon: IconInput, options?: CreateMorphOptions): Morph;
47
+ //#endregion
48
+ export { ReducedMotionMode as a, PathEl as i, Morph as n, canonicalD as o, MorphOptions as r, createMorph as s, CreateMorphOptions as t };
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
- import { a as buildPlan, c as cubicsToPathD, d as interpLinear, f as interpPolar, i as resamplePath, l as serialize, n as Spring, o as fitIcon, r as resampleIcon, s as iconToCubics, t as SPRING_PRESETS, u as allocOutputs } from "./spring-CBynweFS.js";
1
+ import { a as buildPlan, c as interpPolar, i as resamplePath, n as Spring, o as allocOutputs, r as resampleIcon, s as interpLinear, t as SPRING_PRESETS } from "./spring-CFHloqPP.js";
2
+ import { i as serialize, n as iconToCubics, r as cubicsToPathD, t as fitIcon } from "./normalize-CYnN3Npw.js";
2
3
  export { SPRING_PRESETS, Spring, allocOutputs, buildPlan, cubicsToPathD, fitIcon, iconToCubics, interpLinear, interpPolar, resampleIcon, resamplePath, serialize };