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 +67 -9
- package/dist/MorphIcon.svelte +4 -3
- package/dist/adapters.d.ts +55 -0
- package/dist/adapters.js +139 -0
- package/dist/dom.d.ts +2 -37
- package/dist/dom.js +20 -7
- package/dist/index-DXp5b6BU.d.ts +48 -0
- package/dist/index.js +2 -1
- package/dist/normalize-CYnN3Npw.js +540 -0
- package/dist/react-native.d.ts +7 -2
- package/dist/react-native.js +13 -5
- package/dist/react.d.ts +6 -2
- package/dist/react.js +11 -4
- package/dist/{spring-CBynweFS.js → spring-CFHloqPP.js} +2 -540
- package/dist/svelte-shared.d.ts +11 -6
- package/dist/svelte-shared.js +11 -6
- package/dist/vue.d.ts +17 -2
- package/dist/vue.js +18 -5
- package/package.json +26 -1
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.
|
|
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`).
|
|
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.
|
|
357
|
-
| `morphicons/
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
| `morphicons/
|
|
361
|
-
| `
|
|
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
|
|
package/dist/MorphIcon.svelte
CHANGED
|
@@ -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 };
|
package/dist/adapters.js
ADDED
|
@@ -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 {
|
|
2
|
-
|
|
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
|
|
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 (
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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 };
|