morphicons 1.6.0 → 1.7.1
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 +49 -7
- package/dist/MorphIcon.astro +109 -0
- package/dist/astro.d.ts +32 -0
- package/dist/controller-CXZuwJ_M.js +152 -0
- package/dist/controller-Dkruvf3Y.d.ts +54 -0
- package/dist/element.d.ts +36 -0
- package/dist/element.js +261 -0
- package/dist/react-native.js +12 -3
- package/dist/svelte-shared.d.ts +2 -41
- package/dist/svelte-shared.js +1 -150
- package/package.json +31 -3
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ The usual icon morphs either interpolate raw coordinates (shapes shrink and shea
|
|
|
43
43
|
bun add morphicons # or npm install / pnpm add
|
|
44
44
|
```
|
|
45
45
|
|
|
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`.
|
|
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`. `morphicons/element` (the `<morph-icon>` custom element) and `morphicons/astro` (its SSR shell) need no peer at all — Astro compiles the shell itself, like every `.astro` component shipped as source.
|
|
47
47
|
|
|
48
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
49
|
|
|
@@ -67,7 +67,7 @@ ref.current?.morphTo(Check); // animates
|
|
|
67
67
|
ref.current?.set(X); // jumps without animating
|
|
68
68
|
```
|
|
69
69
|
|
|
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-
|
|
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-five-bindings)).
|
|
71
71
|
|
|
72
72
|
### Vue — same three modes
|
|
73
73
|
|
|
@@ -141,17 +141,56 @@ ref.current?.morphTo(Check); // animates
|
|
|
141
141
|
ref.current?.set(X); // jumps without animating
|
|
142
142
|
```
|
|
143
143
|
|
|
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`.
|
|
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. On the New Architecture that write is not the last word, because every Fabric commit rebuilds the native path from the props React declares: the binding therefore publishes the driver's live `d` on each render, and re-applies it in a layout effect when a frame landed in between, so an unrelated re-render (a color or selection change, at rest or mid-flight) can no longer restore the icon the component mounted with. 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`.
|
|
145
145
|
|
|
146
|
-
###
|
|
146
|
+
### Astro — same three modes, zero framework runtime
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
```astro
|
|
149
|
+
---
|
|
150
|
+
import MorphIcon from "morphicons/astro";
|
|
151
|
+
import { Menu } from "lucide"; // data, not components
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
<MorphIcon icon={Menu} label="Menu" id="menu-icon" />
|
|
155
|
+
|
|
156
|
+
<script>
|
|
157
|
+
import { X } from "lucide";
|
|
158
|
+
import type { MorphIconElement } from "morphicons/element";
|
|
159
|
+
// Post-load control happens ON the element (it upgrades to <morph-icon>):
|
|
160
|
+
const el = document.querySelector<MorphIconElement>("#menu-icon")!;
|
|
161
|
+
el.morphTo(X); // 3. imperative: morphTo / set, like a ref
|
|
162
|
+
// el.icon = X; // 1. uncontrolled: assigning the property animates
|
|
163
|
+
// el.from = Menu; el.to = X; el.progress = 0.5; // 2. controlled: frozen seek
|
|
164
|
+
</script>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The server emits the exact static SVG with the pure core (zero flash, zero layout shift, works with static output and any SSR adapter) and hydration is just custom-element upgrade: the shell defines `<morph-icon>` once per page and the element adopts the server markup verbatim — zero `d` writes during upgrade, pinned by instrumented tests. No framework runtime ships — the only client bytes are `morphicons/element`. A controlled pair of `d` strings survives upgrade as attributes, so assigning `progress` from a script scrubs the server-frozen pair; `IconNode` pairs are server-only (attributes can't carry them) — control those by assigning the element's properties. Same presentation props as every binding (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, `label`); rest attrs (`id`, `class`, `data-*`…) land on the `<morph-icon>` element, because grabbing it from a client script is the interaction model here. React/Vue/Svelte islands keep working for icons that live inside one — this entry is for the pages that don't need an island at all.
|
|
168
|
+
|
|
169
|
+
### Web component — `<morph-icon>` anywhere HTML reaches
|
|
170
|
+
|
|
171
|
+
The Astro shell is a thin SSR layer over `morphicons/element`, which stands on its own in plain HTML, HTMX, Rails, or any server-rendered stack:
|
|
172
|
+
|
|
173
|
+
```html
|
|
174
|
+
<script type="module">
|
|
175
|
+
import { defineMorphIcon } from "morphicons/element";
|
|
176
|
+
defineMorphIcon(); // idempotent; custom tag: defineMorphIcon("my-icon")
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<!-- attributes take d strings; properties take the full IconInput -->
|
|
180
|
+
<morph-icon icon="M4 6h16M4 12h16M4 18h16" label="Menu"></morph-icon>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The element is the fifth binding, not a second implementation: it wires attributes and properties to the same shared controller, passes the same mirrored mount suite, and exposes the same three modes — `icon` (uncontrolled), `from`/`to`/`progress` (controlled), `morphTo`/`set` methods (imperative, the element IS the handle). Attributes carry strings (`d` paths, preset names); properties accept everything (`IconNode`s, custom springs). If the element already contains a server-rendered `<svg><path>`, it adopts it verbatim and treats its `d` as the at-rest icon — SSR bytes are never rewritten at rest.
|
|
184
|
+
|
|
185
|
+
### Lifecycle contract (all five bindings)
|
|
186
|
+
|
|
187
|
+
The five components share one contract, pinned by mirrored client-mount tests:
|
|
149
188
|
|
|
150
189
|
- **Lazy driver.** Mounting without any icon is fine — SSR emits `<path d="">` and the driver is born with the FIRST icon that shows up, whether a late `icon` prop (data that loads async), a late `from`/`to` pair, or an imperative `set`/`morphTo`. The first icon paints without animating; `morphTo` before the driver exists behaves as `set` (there is nothing to fly from).
|
|
151
190
|
- **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.
|
|
152
191
|
- **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`.
|
|
153
192
|
|
|
154
|
-
### Reduced motion (all
|
|
193
|
+
### Reduced motion (all five bindings)
|
|
155
194
|
|
|
156
195
|
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
196
|
|
|
@@ -295,6 +334,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
|
|
|
295
334
|
```
|
|
296
335
|
┌─────────────────────────────────────────────┐
|
|
297
336
|
│ bindings react · vue · svelte · rn │ thin: ref + createMorph
|
|
337
|
+
│ · element (+ astro SSR shell) │
|
|
298
338
|
├─────────────────────────────────────────────┤
|
|
299
339
|
│ drivers dom (setAttribute + rAF) │ singleton scheduler
|
|
300
340
|
├─────────────────────────────────────────────┤
|
|
@@ -305,7 +345,7 @@ This also covers the [shadcn registry](https://www.shadcn.io/icons), which re-pu
|
|
|
305
345
|
└─────────────────────────────────────────────┘
|
|
306
346
|
```
|
|
307
347
|
|
|
308
|
-
The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence: the React Native binding over `react-native-svg` is just another adapter, not a rewrite — it reuses the dom driver verbatim through a `setNativeProps` shim. One package with subpath exports (`.` core, `./dom`, `./react`, `./react-native`, `./vue`, `./svelte`), ESM only, `sideEffects: false`.
|
|
348
|
+
The hard rule: **the core never touches the DOM**. Pure functions consume icon data and produce `d` strings. Direct consequence: the React Native binding over `react-native-svg` is just another adapter, not a rewrite — it reuses the dom driver verbatim through a `setNativeProps` shim. One package with subpath exports (`.` core, `./dom`, `./react`, `./react-native`, `./vue`, `./svelte`, `./element`, `./astro`), ESM only, `sideEffects: false`.
|
|
309
349
|
|
|
310
350
|
The plan is the central artifact:
|
|
311
351
|
|
|
@@ -454,6 +494,8 @@ CI budget (size-limit, gzip) as an anti-regression tripwire — gates carry ~10%
|
|
|
454
494
|
| `morphicons/vue` (all, vue external) | 8.04 KB | 8.5 KB |
|
|
455
495
|
| `morphicons/svelte` TS half (all, svelte external) | 7.66 KB | 8 KB |
|
|
456
496
|
| `MorphIcon.svelte` (ships as source, consumer-compiled) | 1.26 KB | 1.4 KB |
|
|
497
|
+
| `morphicons/element` (all, `<morph-icon>`) | 8.86 KB | 9.5 KB |
|
|
498
|
+
| `MorphIcon.astro` (ships as source, consumer-compiled) | 1.58 KB | 1.75 KB |
|
|
457
499
|
|
|
458
500
|
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.
|
|
459
501
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
/* Astro binding: SSR shell over the <morph-icon> custom element. The server
|
|
3
|
+
emits the exact static SVG with the pure core (zero flash, zero layout
|
|
4
|
+
shift, no framework runtime) and the element adopts it verbatim on
|
|
5
|
+
upgrade; the processed <script> below defines the element once per page,
|
|
6
|
+
so hydration IS custom-element upgrade. Post-load control (icon swaps,
|
|
7
|
+
controlled scrubbing, imperative calls) happens on the element itself:
|
|
8
|
+
grab it from any client script and assign properties or call morphTo/set.
|
|
9
|
+
Thin shell on purpose, like MorphIcon.svelte: all logic lives in
|
|
10
|
+
typechecked modules; this file only wires props to markup. */
|
|
11
|
+
import type {
|
|
12
|
+
IconInput,
|
|
13
|
+
MorphOptions,
|
|
14
|
+
ReducedMotionMode,
|
|
15
|
+
SpringPreset,
|
|
16
|
+
} from "./element.js";
|
|
17
|
+
import { computeInitialD } from "./element.js";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Uncontrolled mode: the icon at rest (IconNode or `d` string). */
|
|
21
|
+
icon?: IconInput;
|
|
22
|
+
/** Controlled mode: source endpoint of the pair (server-side freeze). */
|
|
23
|
+
from?: IconInput;
|
|
24
|
+
/** Controlled mode: target endpoint of the pair (server-side freeze). */
|
|
25
|
+
to?: IconInput;
|
|
26
|
+
/** Controlled mode: 0..1 progress of the frozen morph. */
|
|
27
|
+
progress?: number;
|
|
28
|
+
/** Physics for client-side morphs: preset name or custom spring (presets
|
|
29
|
+
* ride the attribute; custom springs need a client script). */
|
|
30
|
+
spring?: SpringPreset | MorphOptions;
|
|
31
|
+
reducedMotion?: ReducedMotionMode;
|
|
32
|
+
size?: number | string;
|
|
33
|
+
color?: string;
|
|
34
|
+
strokeWidth?: number | string;
|
|
35
|
+
/** Like lucide: stroke width does not scale with `size`. */
|
|
36
|
+
absoluteStrokeWidth?: boolean;
|
|
37
|
+
/** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
|
|
38
|
+
label?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
icon,
|
|
43
|
+
from,
|
|
44
|
+
to,
|
|
45
|
+
progress,
|
|
46
|
+
spring,
|
|
47
|
+
reducedMotion,
|
|
48
|
+
size = 24,
|
|
49
|
+
color = "currentColor",
|
|
50
|
+
strokeWidth = 2,
|
|
51
|
+
absoluteStrokeWidth = false,
|
|
52
|
+
label,
|
|
53
|
+
...rest
|
|
54
|
+
} = Astro.props;
|
|
55
|
+
|
|
56
|
+
const initialD = computeInitialD({ icon, from, to, progress });
|
|
57
|
+
const sw = absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth;
|
|
58
|
+
|
|
59
|
+
/* String pairs ride attributes so `progress` keeps scrubbing the frozen
|
|
60
|
+
pair after upgrade. BOTH or none — half a mixed pair would mount from the
|
|
61
|
+
lone endpoint (docs/adr/0003). IconNode pairs stay server-only. */
|
|
62
|
+
const stringPair = typeof from === "string" && typeof to === "string";
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
{/* Rest props land on <morph-icon>, not the svg: grabbing the element by
|
|
66
|
+
id/class/data-* from a client script IS the interaction model here. */}
|
|
67
|
+
<morph-icon
|
|
68
|
+
icon={typeof icon === "string" ? icon : undefined}
|
|
69
|
+
from={stringPair ? from : undefined}
|
|
70
|
+
to={stringPair ? to : undefined}
|
|
71
|
+
progress={progress}
|
|
72
|
+
spring={typeof spring === "string" ? spring : undefined}
|
|
73
|
+
reduced-motion={reducedMotion}
|
|
74
|
+
size={String(size)}
|
|
75
|
+
color={color}
|
|
76
|
+
stroke-width={String(strokeWidth)}
|
|
77
|
+
absolute-stroke-width={absoluteStrokeWidth ? "" : undefined}
|
|
78
|
+
label={label}
|
|
79
|
+
{...rest}
|
|
80
|
+
>
|
|
81
|
+
<svg
|
|
82
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
83
|
+
width={size}
|
|
84
|
+
height={size}
|
|
85
|
+
viewBox="0 0 24 24"
|
|
86
|
+
fill="none"
|
|
87
|
+
stroke={color}
|
|
88
|
+
stroke-width={sw}
|
|
89
|
+
stroke-linecap="round"
|
|
90
|
+
stroke-linejoin="round"
|
|
91
|
+
role={label ? "img" : undefined}
|
|
92
|
+
aria-hidden={label ? undefined : "true"}
|
|
93
|
+
>
|
|
94
|
+
{label && <title>{label}</title>}
|
|
95
|
+
<path d={initialD}></path>
|
|
96
|
+
</svg>
|
|
97
|
+
</morph-icon>
|
|
98
|
+
|
|
99
|
+
<style>
|
|
100
|
+
/* Layout-neutral wrapper from the first paint, before any JS runs. */
|
|
101
|
+
morph-icon {
|
|
102
|
+
display: contents;
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
105
|
+
|
|
106
|
+
<script>
|
|
107
|
+
import { defineMorphIcon } from "./element.js";
|
|
108
|
+
defineMorphIcon();
|
|
109
|
+
</script>
|
package/dist/astro.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IconInput,
|
|
3
|
+
MorphOptions,
|
|
4
|
+
ReducedMotionMode,
|
|
5
|
+
SpringPreset,
|
|
6
|
+
} from "./element.js";
|
|
7
|
+
|
|
8
|
+
export interface MorphIconAstroProps {
|
|
9
|
+
icon?: IconInput;
|
|
10
|
+
from?: IconInput;
|
|
11
|
+
to?: IconInput;
|
|
12
|
+
progress?: number;
|
|
13
|
+
spring?: SpringPreset | MorphOptions;
|
|
14
|
+
reducedMotion?: ReducedMotionMode;
|
|
15
|
+
size?: number | string;
|
|
16
|
+
color?: string;
|
|
17
|
+
strokeWidth?: number | string;
|
|
18
|
+
absoluteStrokeWidth?: boolean;
|
|
19
|
+
label?: string;
|
|
20
|
+
[attr: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Astro component: SSR shell over the <morph-icon> custom element. */
|
|
24
|
+
declare const MorphIcon: (props: MorphIconAstroProps) => unknown;
|
|
25
|
+
export default MorphIcon;
|
|
26
|
+
export type {
|
|
27
|
+
IconInput,
|
|
28
|
+
IconNode,
|
|
29
|
+
MorphOptions,
|
|
30
|
+
ReducedMotionMode,
|
|
31
|
+
SpringPreset,
|
|
32
|
+
} from "./element.js";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { a as buildPlan, c as interpPolar, o as allocOutputs, r as resampleIcon } from "./spring-CFHloqPP.js";
|
|
2
|
+
import { i as serialize } from "./normalize-CYnN3Npw.js";
|
|
3
|
+
import { canonicalD, createMorph } from "./dom.js";
|
|
4
|
+
//#region src/dom/controller.ts
|
|
5
|
+
/** Frozen shape of the from→to pair at t, using the pure core (SSR-safe).
|
|
6
|
+
* At exact endpoints returns the canonical `d` (real curves, not polyline). */
|
|
7
|
+
function frozenD(from, to, t) {
|
|
8
|
+
if (t <= 0) return canonicalD(from);
|
|
9
|
+
if (t >= 1) return canonicalD(to);
|
|
10
|
+
const plan = buildPlan(resampleIcon(from), resampleIcon(to));
|
|
11
|
+
const out = allocOutputs(plan);
|
|
12
|
+
interpPolar(plan, t, out);
|
|
13
|
+
return serialize(out, plan.items.map((it) => it.closed));
|
|
14
|
+
}
|
|
15
|
+
/** The initial d is a constant for every binding: computed once from the
|
|
16
|
+
* mount-time props (server and client produce the same string → hydration
|
|
17
|
+
* without mismatch) and from then on only the driver mutates it outside the
|
|
18
|
+
* template. */
|
|
19
|
+
function computeInitialD({ icon, from, to, progress }) {
|
|
20
|
+
if (from !== void 0 && to !== void 0) return frozenD(from, to, progress ?? 0);
|
|
21
|
+
const first = icon ?? from ?? to;
|
|
22
|
+
return first !== void 0 ? canonicalD(first) : "";
|
|
23
|
+
}
|
|
24
|
+
/** Per-instance driver state — the exact logic of the React/Vue bindings
|
|
25
|
+
* (mount, mode watch, controlled seek with re-basing, imperative). Takes the
|
|
26
|
+
* init-time props: they seed the watch baselines so a watcher that fires on
|
|
27
|
+
* mount (Svelte's $effect, unlike Vue's watch) is a no-op. All change
|
|
28
|
+
* detection lives HERE, under tsc — the shells only wire their reactive
|
|
29
|
+
* surface to these methods.
|
|
30
|
+
*
|
|
31
|
+
* Lifecycle contract (shared verbatim by the bindings):
|
|
32
|
+
* - Lazy driver: an iconless mount keeps the element and births the driver
|
|
33
|
+
* on the FIRST icon that shows up (prop or imperative). `morphTo` with no
|
|
34
|
+
* driver behaves as `set` — there is nothing to fly from.
|
|
35
|
+
* - Controlled wins: while `from` and `to` are both present the pair owns
|
|
36
|
+
* the path and `icon` changes are ignored; dropping the pair hands the
|
|
37
|
+
* path back to `icon`.
|
|
38
|
+
* - Every exit from controlled mode (imperative call or icon takeover)
|
|
39
|
+
* invalidates the frozen pair, so returning to it re-bases on `from`. */
|
|
40
|
+
function createController({ icon, from, to, progress, reducedMotion }) {
|
|
41
|
+
let el = null;
|
|
42
|
+
let dead = false;
|
|
43
|
+
let morph = null;
|
|
44
|
+
let rm = reducedMotion ?? "never";
|
|
45
|
+
let based = false;
|
|
46
|
+
let pair = null;
|
|
47
|
+
let prevIcon = icon;
|
|
48
|
+
let prevControlled = from !== void 0 && to !== void 0;
|
|
49
|
+
let prevFrom = from;
|
|
50
|
+
let prevTo = to;
|
|
51
|
+
let prevProgress = progress;
|
|
52
|
+
/** Driver birth, lazy included: the first icon to show up creates it. */
|
|
53
|
+
const ensure = (birth) => {
|
|
54
|
+
if (morph) return morph;
|
|
55
|
+
if (dead || !el) return null;
|
|
56
|
+
morph = createMorph(el, birth, { reducedMotion: rm });
|
|
57
|
+
return morph;
|
|
58
|
+
};
|
|
59
|
+
/** Controlled mode: freeze the pair at `progress` via seek (no spring). */
|
|
60
|
+
const applyPair = (from, to, progress) => {
|
|
61
|
+
const m = morph;
|
|
62
|
+
if (!m) return;
|
|
63
|
+
const t = progress ?? 0;
|
|
64
|
+
if (!pair || pair[0] !== from || pair[1] !== to) {
|
|
65
|
+
pair = [from, to];
|
|
66
|
+
based = false;
|
|
67
|
+
}
|
|
68
|
+
if (t <= 0) {
|
|
69
|
+
m.set(from);
|
|
70
|
+
based = false;
|
|
71
|
+
} else if (t >= 1) {
|
|
72
|
+
m.set(to);
|
|
73
|
+
based = false;
|
|
74
|
+
} else {
|
|
75
|
+
if (!based) {
|
|
76
|
+
m.set(from);
|
|
77
|
+
based = true;
|
|
78
|
+
}
|
|
79
|
+
m.seek(to, t);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
mount(mountEl, { icon, from, to, progress, reducedMotion }) {
|
|
84
|
+
el = mountEl;
|
|
85
|
+
rm = reducedMotion ?? rm;
|
|
86
|
+
const controlled = from !== void 0 && to !== void 0;
|
|
87
|
+
const initialIcon = icon ?? from ?? to;
|
|
88
|
+
if (initialIcon === void 0) return;
|
|
89
|
+
const m = createMorph(el, controlled ? from : initialIcon, { reducedMotion: rm });
|
|
90
|
+
morph = m;
|
|
91
|
+
if (controlled) {
|
|
92
|
+
pair = [from, to];
|
|
93
|
+
const t = progress ?? 0;
|
|
94
|
+
if (t <= 0) m.set(from);
|
|
95
|
+
else if (t >= 1) m.set(to);
|
|
96
|
+
else {
|
|
97
|
+
m.seek(to, t);
|
|
98
|
+
based = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
destroy() {
|
|
103
|
+
dead = true;
|
|
104
|
+
el = null;
|
|
105
|
+
morph?.destroy();
|
|
106
|
+
morph = null;
|
|
107
|
+
based = false;
|
|
108
|
+
pair = null;
|
|
109
|
+
},
|
|
110
|
+
/** Prop watcher: ONE owner decides per run — the controlled pair while it
|
|
111
|
+
* is fully present, `icon` otherwise (mount doesn't fire thanks to the
|
|
112
|
+
* init-time baselines). */
|
|
113
|
+
watch({ icon, from, to, progress, spring, reducedMotion }) {
|
|
114
|
+
rm = reducedMotion ?? "never";
|
|
115
|
+
if (morph) morph.reducedMotion = rm;
|
|
116
|
+
const controlled = from !== void 0 && to !== void 0;
|
|
117
|
+
const left = prevControlled && !controlled;
|
|
118
|
+
const iconChanged = icon !== prevIcon;
|
|
119
|
+
const pairChanged = from !== prevFrom || to !== prevTo || progress !== prevProgress;
|
|
120
|
+
prevControlled = controlled;
|
|
121
|
+
prevIcon = icon;
|
|
122
|
+
prevFrom = from;
|
|
123
|
+
prevTo = to;
|
|
124
|
+
prevProgress = progress;
|
|
125
|
+
if (controlled) {
|
|
126
|
+
if (!pairChanged) return;
|
|
127
|
+
if (!(morph ?? ensure(from))) return;
|
|
128
|
+
applyPair(from, to, progress);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (icon === void 0 || !iconChanged && !left) return;
|
|
132
|
+
pair = null;
|
|
133
|
+
based = false;
|
|
134
|
+
if (morph) morph.morphTo(icon, spring);
|
|
135
|
+
else ensure(icon);
|
|
136
|
+
},
|
|
137
|
+
morphTo(icon, spring) {
|
|
138
|
+
pair = null;
|
|
139
|
+
based = false;
|
|
140
|
+
if (morph) morph.morphTo(icon, spring);
|
|
141
|
+
else ensure(icon);
|
|
142
|
+
},
|
|
143
|
+
set(icon) {
|
|
144
|
+
pair = null;
|
|
145
|
+
based = false;
|
|
146
|
+
if (morph) morph.set(icon);
|
|
147
|
+
else ensure(icon);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
export { createController as n, computeInitialD as t };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { c as SpringPreset, n as IconInput } from "./types-JAF3s76Y.js";
|
|
2
|
+
import { a as ReducedMotionMode, i as PathEl, r as MorphOptions } from "./index-DXp5b6BU.js";
|
|
3
|
+
//#region src/dom/controller.d.ts
|
|
4
|
+
/** Imperative surface exposed by every binding (ref / bind:this / element). */
|
|
5
|
+
interface MorphHandle {
|
|
6
|
+
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
7
|
+
set(icon: IconInput): void;
|
|
8
|
+
}
|
|
9
|
+
/** The mode-deciding props, shared by every binding's prop surface. */
|
|
10
|
+
interface MorphModeProps {
|
|
11
|
+
icon?: IconInput;
|
|
12
|
+
from?: IconInput;
|
|
13
|
+
to?: IconInput;
|
|
14
|
+
progress?: number;
|
|
15
|
+
}
|
|
16
|
+
interface MorphCtrlProps extends MorphModeProps {
|
|
17
|
+
reducedMotion?: ReducedMotionMode;
|
|
18
|
+
}
|
|
19
|
+
interface MorphWatchProps extends MorphCtrlProps {
|
|
20
|
+
spring?: SpringPreset | MorphOptions;
|
|
21
|
+
}
|
|
22
|
+
/** The initial d is a constant for every binding: computed once from the
|
|
23
|
+
* mount-time props (server and client produce the same string → hydration
|
|
24
|
+
* without mismatch) and from then on only the driver mutates it outside the
|
|
25
|
+
* template. */
|
|
26
|
+
declare function computeInitialD({ icon, from, to, progress }: MorphModeProps): string;
|
|
27
|
+
/** Per-instance driver state — the exact logic of the React/Vue bindings
|
|
28
|
+
* (mount, mode watch, controlled seek with re-basing, imperative). Takes the
|
|
29
|
+
* init-time props: they seed the watch baselines so a watcher that fires on
|
|
30
|
+
* mount (Svelte's $effect, unlike Vue's watch) is a no-op. All change
|
|
31
|
+
* detection lives HERE, under tsc — the shells only wire their reactive
|
|
32
|
+
* surface to these methods.
|
|
33
|
+
*
|
|
34
|
+
* Lifecycle contract (shared verbatim by the bindings):
|
|
35
|
+
* - Lazy driver: an iconless mount keeps the element and births the driver
|
|
36
|
+
* on the FIRST icon that shows up (prop or imperative). `morphTo` with no
|
|
37
|
+
* driver behaves as `set` — there is nothing to fly from.
|
|
38
|
+
* - Controlled wins: while `from` and `to` are both present the pair owns
|
|
39
|
+
* the path and `icon` changes are ignored; dropping the pair hands the
|
|
40
|
+
* path back to `icon`.
|
|
41
|
+
* - Every exit from controlled mode (imperative call or icon takeover)
|
|
42
|
+
* invalidates the frozen pair, so returning to it re-bases on `from`. */
|
|
43
|
+
declare function createController({ icon, from, to, progress, reducedMotion }: MorphCtrlProps): {
|
|
44
|
+
mount(mountEl: PathEl, { icon, from, to, progress, reducedMotion }: MorphCtrlProps): void;
|
|
45
|
+
destroy(): void;
|
|
46
|
+
/** Prop watcher: ONE owner decides per run — the controlled pair while it
|
|
47
|
+
* is fully present, `icon` otherwise (mount doesn't fire thanks to the
|
|
48
|
+
* init-time baselines). */
|
|
49
|
+
watch({ icon, from, to, progress, spring, reducedMotion }: MorphWatchProps): void;
|
|
50
|
+
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
51
|
+
set(icon: IconInput): void;
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { computeInitialD as n, createController as r, MorphHandle as t };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { a as Sampled, c as SpringPreset, n as IconInput, r as IconNode } from "./types-JAF3s76Y.js";
|
|
2
|
+
import { a as ReducedMotionMode, i as PathEl, n as Morph, r as MorphOptions } from "./index-DXp5b6BU.js";
|
|
3
|
+
import { n as computeInitialD, t as MorphHandle } from "./controller-Dkruvf3Y.js";
|
|
4
|
+
//#region src/element/index.d.ts
|
|
5
|
+
declare const Base: typeof HTMLElement;
|
|
6
|
+
declare class MorphIconElement extends Base implements MorphHandle {
|
|
7
|
+
#private;
|
|
8
|
+
static observedAttributes: string[];
|
|
9
|
+
get icon(): IconInput | undefined;
|
|
10
|
+
set icon(v: IconInput | undefined);
|
|
11
|
+
get from(): IconInput | undefined;
|
|
12
|
+
set from(v: IconInput | undefined);
|
|
13
|
+
get to(): IconInput | undefined;
|
|
14
|
+
set to(v: IconInput | undefined);
|
|
15
|
+
get progress(): number | undefined;
|
|
16
|
+
set progress(v: number | undefined);
|
|
17
|
+
get spring(): SpringPreset | MorphOptions | undefined;
|
|
18
|
+
set spring(v: SpringPreset | MorphOptions | undefined);
|
|
19
|
+
get reducedMotion(): ReducedMotionMode | undefined;
|
|
20
|
+
set reducedMotion(v: ReducedMotionMode | undefined);
|
|
21
|
+
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
22
|
+
set(icon: IconInput): void;
|
|
23
|
+
connectedCallback(): void;
|
|
24
|
+
disconnectedCallback(): void;
|
|
25
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
|
|
26
|
+
}
|
|
27
|
+
/** Defines `<morph-icon>` (or a custom tag). Idempotent per tag; a no-op
|
|
28
|
+
* without a DOM (safe to call from code that also runs during SSR). */
|
|
29
|
+
declare function defineMorphIcon(tag?: string): void;
|
|
30
|
+
declare global {
|
|
31
|
+
interface HTMLElementTagNameMap {
|
|
32
|
+
"morph-icon": MorphIconElement;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { type IconInput, type IconNode, type Morph, type MorphHandle, MorphIconElement, type MorphOptions, type PathEl, type ReducedMotionMode, type Sampled, type SpringPreset, computeInitialD, defineMorphIcon };
|
package/dist/element.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { n as createController, t as computeInitialD } from "./controller-CXZuwJ_M.js";
|
|
2
|
+
//#region src/element/index.ts
|
|
3
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
4
|
+
const REACTIVE = [
|
|
5
|
+
"icon",
|
|
6
|
+
"from",
|
|
7
|
+
"to",
|
|
8
|
+
"progress",
|
|
9
|
+
"spring",
|
|
10
|
+
"reducedMotion"
|
|
11
|
+
];
|
|
12
|
+
const PRESENTATION = /* @__PURE__ */ new Set([
|
|
13
|
+
"size",
|
|
14
|
+
"color",
|
|
15
|
+
"stroke-width",
|
|
16
|
+
"absolute-stroke-width",
|
|
17
|
+
"label"
|
|
18
|
+
]);
|
|
19
|
+
const Base = typeof HTMLElement === "undefined" ? class {} : HTMLElement;
|
|
20
|
+
var MorphIconElement = class extends Base {
|
|
21
|
+
static observedAttributes = [
|
|
22
|
+
"icon",
|
|
23
|
+
"from",
|
|
24
|
+
"to",
|
|
25
|
+
"progress",
|
|
26
|
+
"spring",
|
|
27
|
+
"reduced-motion",
|
|
28
|
+
"size",
|
|
29
|
+
"color",
|
|
30
|
+
"stroke-width",
|
|
31
|
+
"absolute-stroke-width",
|
|
32
|
+
"label"
|
|
33
|
+
];
|
|
34
|
+
#props = {};
|
|
35
|
+
#ctrl = null;
|
|
36
|
+
#path = null;
|
|
37
|
+
#adopted = false;
|
|
38
|
+
#preConnectDirty = false;
|
|
39
|
+
#size = "24";
|
|
40
|
+
#color = "currentColor";
|
|
41
|
+
#strokeWidth = "2";
|
|
42
|
+
#absoluteStrokeWidth = false;
|
|
43
|
+
#label = null;
|
|
44
|
+
get icon() {
|
|
45
|
+
return this.#props.icon;
|
|
46
|
+
}
|
|
47
|
+
set icon(v) {
|
|
48
|
+
this.#props.icon = v;
|
|
49
|
+
this.#watch();
|
|
50
|
+
}
|
|
51
|
+
get from() {
|
|
52
|
+
return this.#props.from;
|
|
53
|
+
}
|
|
54
|
+
set from(v) {
|
|
55
|
+
this.#props.from = v;
|
|
56
|
+
this.#watch();
|
|
57
|
+
}
|
|
58
|
+
get to() {
|
|
59
|
+
return this.#props.to;
|
|
60
|
+
}
|
|
61
|
+
set to(v) {
|
|
62
|
+
this.#props.to = v;
|
|
63
|
+
this.#watch();
|
|
64
|
+
}
|
|
65
|
+
get progress() {
|
|
66
|
+
return this.#props.progress;
|
|
67
|
+
}
|
|
68
|
+
set progress(v) {
|
|
69
|
+
this.#props.progress = v;
|
|
70
|
+
this.#watch();
|
|
71
|
+
}
|
|
72
|
+
get spring() {
|
|
73
|
+
return this.#props.spring;
|
|
74
|
+
}
|
|
75
|
+
set spring(v) {
|
|
76
|
+
this.#props.spring = v;
|
|
77
|
+
this.#watch();
|
|
78
|
+
}
|
|
79
|
+
get reducedMotion() {
|
|
80
|
+
return this.#props.reducedMotion;
|
|
81
|
+
}
|
|
82
|
+
set reducedMotion(v) {
|
|
83
|
+
this.#props.reducedMotion = v;
|
|
84
|
+
this.#watch();
|
|
85
|
+
}
|
|
86
|
+
morphTo(icon, spring) {
|
|
87
|
+
const ctrl = this.#ctrl;
|
|
88
|
+
if (!ctrl) {
|
|
89
|
+
this.#imperativeBeforeDriver(icon);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
ctrl.morphTo(icon, spring ?? this.#props.spring);
|
|
93
|
+
}
|
|
94
|
+
set(icon) {
|
|
95
|
+
const ctrl = this.#ctrl;
|
|
96
|
+
if (!ctrl) {
|
|
97
|
+
this.#imperativeBeforeDriver(icon);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
ctrl.set(icon);
|
|
101
|
+
}
|
|
102
|
+
connectedCallback() {
|
|
103
|
+
for (const key of REACTIVE) this.#upgradeProperty(key);
|
|
104
|
+
if (!this.#path) this.#adoptOrRender();
|
|
105
|
+
if (this.style.display === "") this.style.display = "contents";
|
|
106
|
+
if (this.#path && !this.#ctrl) {
|
|
107
|
+
const path = this.#path;
|
|
108
|
+
const gate = this.#adopted && !this.#preConnectDirty;
|
|
109
|
+
this.#adopted = false;
|
|
110
|
+
this.#ctrl = createController(this.#props);
|
|
111
|
+
if (gate) {
|
|
112
|
+
let armed = false;
|
|
113
|
+
const gated = { setAttribute(name, value) {
|
|
114
|
+
if (armed) path.setAttribute(name, value);
|
|
115
|
+
} };
|
|
116
|
+
try {
|
|
117
|
+
this.#ctrl.mount(gated, this.#props);
|
|
118
|
+
} finally {
|
|
119
|
+
armed = true;
|
|
120
|
+
}
|
|
121
|
+
} else this.#ctrl.mount(path, this.#props);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
disconnectedCallback() {
|
|
125
|
+
this.#ctrl?.destroy();
|
|
126
|
+
this.#ctrl = null;
|
|
127
|
+
}
|
|
128
|
+
attributeChangedCallback(name, _old, value) {
|
|
129
|
+
switch (name) {
|
|
130
|
+
case "icon":
|
|
131
|
+
this.#props.icon = value ?? void 0;
|
|
132
|
+
break;
|
|
133
|
+
case "from":
|
|
134
|
+
this.#props.from = value ?? void 0;
|
|
135
|
+
break;
|
|
136
|
+
case "to":
|
|
137
|
+
this.#props.to = value ?? void 0;
|
|
138
|
+
break;
|
|
139
|
+
case "progress": {
|
|
140
|
+
const n = value === null ? NaN : Number(value);
|
|
141
|
+
this.#props.progress = Number.isFinite(n) ? n : void 0;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "spring":
|
|
145
|
+
this.#props.spring = value ?? void 0;
|
|
146
|
+
break;
|
|
147
|
+
case "reduced-motion":
|
|
148
|
+
this.#props.reducedMotion = value ?? void 0;
|
|
149
|
+
break;
|
|
150
|
+
case "size":
|
|
151
|
+
this.#size = value ?? "24";
|
|
152
|
+
break;
|
|
153
|
+
case "color":
|
|
154
|
+
this.#color = value ?? "currentColor";
|
|
155
|
+
break;
|
|
156
|
+
case "stroke-width":
|
|
157
|
+
this.#strokeWidth = value ?? "2";
|
|
158
|
+
break;
|
|
159
|
+
case "absolute-stroke-width":
|
|
160
|
+
this.#absoluteStrokeWidth = value !== null;
|
|
161
|
+
break;
|
|
162
|
+
case "label": this.#label = value;
|
|
163
|
+
}
|
|
164
|
+
if (!this.#path) return;
|
|
165
|
+
if (PRESENTATION.has(name)) {
|
|
166
|
+
const svg = this.querySelector("svg");
|
|
167
|
+
if (svg) this.#applyPresentation(svg);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.#watch();
|
|
171
|
+
}
|
|
172
|
+
/** A property assigned before the element was defined lands as an own
|
|
173
|
+
* value that shadows the accessor; re-route it through the setter. */
|
|
174
|
+
#upgradeProperty(key) {
|
|
175
|
+
if (Object.hasOwn(this, key)) {
|
|
176
|
+
const self = this;
|
|
177
|
+
const v = self[key];
|
|
178
|
+
delete self[key];
|
|
179
|
+
self[key] = v;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
#watch() {
|
|
183
|
+
const ctrl = this.#ctrl;
|
|
184
|
+
if (!ctrl) {
|
|
185
|
+
this.#preConnectDirty = true;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
ctrl.watch(this.#props);
|
|
189
|
+
}
|
|
190
|
+
/** Imperative call before the controller exists: adopt the icon as the
|
|
191
|
+
* mount state AND exit controlled mode — "every imperative call
|
|
192
|
+
* invalidates the frozen pair" must hold pre-connect too, or a pending
|
|
193
|
+
* pair would win the mount and silently discard this call. */
|
|
194
|
+
#imperativeBeforeDriver(icon) {
|
|
195
|
+
this.#props.icon = icon;
|
|
196
|
+
this.#props.from = void 0;
|
|
197
|
+
this.#props.to = void 0;
|
|
198
|
+
this.#preConnectDirty = true;
|
|
199
|
+
}
|
|
200
|
+
/** SSR markup is adopted verbatim (the element never rewrites server bytes
|
|
201
|
+
* at rest); without it, the element renders its own <svg> like the
|
|
202
|
+
* framework shells do. */
|
|
203
|
+
#adoptOrRender() {
|
|
204
|
+
const existing = this.querySelector("path");
|
|
205
|
+
if (existing) {
|
|
206
|
+
this.#path = existing;
|
|
207
|
+
this.#adopted = true;
|
|
208
|
+
const p = this.#props;
|
|
209
|
+
if (p.icon === void 0 && p.from === void 0 && p.to === void 0) {
|
|
210
|
+
const d = existing.getAttribute("d");
|
|
211
|
+
if (d) p.icon = d;
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const doc = this.ownerDocument;
|
|
216
|
+
const svg = doc.createElementNS(SVG_NS, "svg");
|
|
217
|
+
svg.setAttribute("xmlns", SVG_NS);
|
|
218
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
219
|
+
svg.setAttribute("fill", "none");
|
|
220
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
221
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
222
|
+
this.#applyPresentation(svg);
|
|
223
|
+
const path = doc.createElementNS(SVG_NS, "path");
|
|
224
|
+
path.setAttribute("d", computeInitialD(this.#props));
|
|
225
|
+
svg.appendChild(path);
|
|
226
|
+
this.appendChild(svg);
|
|
227
|
+
this.#path = path;
|
|
228
|
+
}
|
|
229
|
+
#applyPresentation(svg) {
|
|
230
|
+
svg.setAttribute("width", this.#size);
|
|
231
|
+
svg.setAttribute("height", this.#size);
|
|
232
|
+
svg.setAttribute("stroke", this.#color);
|
|
233
|
+
const sw = this.#absoluteStrokeWidth ? String(Number(this.#strokeWidth) * 24 / Number(this.#size)) : this.#strokeWidth;
|
|
234
|
+
svg.setAttribute("stroke-width", sw);
|
|
235
|
+
const label = this.#label;
|
|
236
|
+
let title = svg.querySelector("title");
|
|
237
|
+
if (label) {
|
|
238
|
+
svg.setAttribute("role", "img");
|
|
239
|
+
svg.removeAttribute("aria-hidden");
|
|
240
|
+
if (!title) {
|
|
241
|
+
title = this.ownerDocument.createElementNS(SVG_NS, "title");
|
|
242
|
+
svg.insertBefore(title, svg.firstChild);
|
|
243
|
+
}
|
|
244
|
+
title.textContent = label;
|
|
245
|
+
} else {
|
|
246
|
+
svg.removeAttribute("role");
|
|
247
|
+
svg.setAttribute("aria-hidden", "true");
|
|
248
|
+
title?.remove();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
let registered = false;
|
|
253
|
+
/** Defines `<morph-icon>` (or a custom tag). Idempotent per tag; a no-op
|
|
254
|
+
* without a DOM (safe to call from code that also runs during SSR). */
|
|
255
|
+
function defineMorphIcon(tag = "morph-icon") {
|
|
256
|
+
if (typeof customElements === "undefined" || customElements.get(tag)) return;
|
|
257
|
+
customElements.define(tag, registered ? class extends MorphIconElement {} : MorphIconElement);
|
|
258
|
+
registered = true;
|
|
259
|
+
}
|
|
260
|
+
//#endregion
|
|
261
|
+
export { MorphIconElement, computeInitialD, defineMorphIcon };
|
package/dist/react-native.js
CHANGED
|
@@ -5,8 +5,10 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffec
|
|
|
5
5
|
import { jsx } from "react/jsx-runtime";
|
|
6
6
|
import { AccessibilityInfo } from "react-native";
|
|
7
7
|
import Svg, { Path } from "react-native-svg";
|
|
8
|
+
//#region src/react-native/iso-layout-effect.ts
|
|
9
|
+
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
10
|
+
//#endregion
|
|
8
11
|
//#region src/react-native/index.tsx
|
|
9
|
-
const useIsoLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
|
10
12
|
let reduced = false;
|
|
11
13
|
let rmInit = false;
|
|
12
14
|
function initReducedMotion() {
|
|
@@ -39,6 +41,8 @@ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
|
|
|
39
41
|
if (controlled) return frozenD(from, to, progress ?? 0);
|
|
40
42
|
return initialIcon !== void 0 ? canonicalD(initialIcon) : "";
|
|
41
43
|
});
|
|
44
|
+
const liveD = useRef(initialD);
|
|
45
|
+
const declaredD = liveD.current;
|
|
42
46
|
const pathRef = useRef(null);
|
|
43
47
|
const morphRef = useRef(null);
|
|
44
48
|
const springRef = useRef(spring);
|
|
@@ -53,7 +57,9 @@ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
|
|
|
53
57
|
/** The PathEl shim: createMorph only ever writes `d`, and Path forwards
|
|
54
58
|
* it to the native view without re-rendering. Stable identity. */
|
|
55
59
|
const el = useRef({ setAttribute: (name, value) => {
|
|
56
|
-
if (name
|
|
60
|
+
if (name !== "d") return;
|
|
61
|
+
liveD.current = value;
|
|
62
|
+
pathRef.current?.setNativeProps({ d: value });
|
|
57
63
|
} });
|
|
58
64
|
/** morphTo that applies the reduced-motion policy ("user" reads the OS
|
|
59
65
|
* setting via AccessibilityInfo, best-effort). */
|
|
@@ -99,6 +105,9 @@ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
|
|
|
99
105
|
pair.current = null;
|
|
100
106
|
};
|
|
101
107
|
}, []);
|
|
108
|
+
useIsoLayoutEffect(() => {
|
|
109
|
+
if (liveD.current !== declaredD) pathRef.current?.setNativeProps({ d: liveD.current });
|
|
110
|
+
});
|
|
102
111
|
useEffect(() => {
|
|
103
112
|
if (reducedMotion === "user") initReducedMotion();
|
|
104
113
|
}, [reducedMotion]);
|
|
@@ -180,7 +189,7 @@ const MorphIcon = forwardRef(function MorphIcon(props, ref) {
|
|
|
180
189
|
...rest,
|
|
181
190
|
children: /* @__PURE__ */ jsx(Path, {
|
|
182
191
|
ref: pathRef,
|
|
183
|
-
d:
|
|
192
|
+
d: declaredD
|
|
184
193
|
})
|
|
185
194
|
});
|
|
186
195
|
});
|
package/dist/svelte-shared.d.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { a as Sampled, c as SpringPreset, n as IconInput, r as IconNode } from "./types-JAF3s76Y.js";
|
|
2
2
|
import { a as ReducedMotionMode, i as PathEl, n as Morph, r as MorphOptions } from "./index-DXp5b6BU.js";
|
|
3
|
+
import { n as computeInitialD, r as createController, t as MorphHandle } from "./controller-Dkruvf3Y.js";
|
|
3
4
|
import { SVGAttributes } from "svelte/elements";
|
|
4
5
|
//#region src/svelte/shared.d.ts
|
|
5
|
-
/** Imperative surface exposed on the component instance (`bind:this`). */
|
|
6
|
-
interface MorphHandle {
|
|
7
|
-
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
8
|
-
set(icon: IconInput): void;
|
|
9
|
-
}
|
|
10
6
|
/** Rest props fall through to the `<svg>` fully typed (class, style, data-*,
|
|
11
7
|
* events, ARIA…) via svelte/elements, like lucide-svelte — typos in an SVG
|
|
12
8
|
* attr fail the build. `from`/`to` are omitted because the SMIL animation
|
|
@@ -34,40 +30,5 @@ interface MorphIconProps extends Omit<SVGAttributes<SVGSVGElement>, "from" | "to
|
|
|
34
30
|
/** Accessibility: with label → role="img" + <title>; without → aria-hidden. */
|
|
35
31
|
label?: string;
|
|
36
32
|
}
|
|
37
|
-
type ModeProps = Pick<MorphIconProps, "icon" | "from" | "to" | "progress">;
|
|
38
|
-
type CtrlProps = ModeProps & Pick<MorphIconProps, "reducedMotion">;
|
|
39
|
-
type WatchProps = CtrlProps & {
|
|
40
|
-
spring?: SpringPreset | MorphOptions;
|
|
41
|
-
};
|
|
42
|
-
/** The initial d is constant for Svelte: computed once from the mount-time
|
|
43
|
-
* props (server and client produce the same string → hydration without
|
|
44
|
-
* mismatch) and from then on only the driver mutates it outside the template. */
|
|
45
|
-
declare function computeInitialD({ icon, from, to, progress }: ModeProps): string;
|
|
46
|
-
/** Per-instance driver state — the exact logic of the React/Vue bindings
|
|
47
|
-
* (mount, mode watch, controlled seek with re-basing, imperative). Takes the
|
|
48
|
-
* init-time props: they seed the watch baselines so the first `$effect` run
|
|
49
|
-
* (which fires on mount in Svelte, unlike Vue's `watch`) is a no-op. All
|
|
50
|
-
* change detection lives HERE, under tsc — the .svelte shell only wires
|
|
51
|
-
* runes to these methods.
|
|
52
|
-
*
|
|
53
|
-
* Lifecycle contract (shared verbatim by the three bindings):
|
|
54
|
-
* - Lazy driver: an iconless mount keeps the element and births the driver
|
|
55
|
-
* on the FIRST icon that shows up (prop or imperative). `morphTo` with no
|
|
56
|
-
* driver behaves as `set` — there is nothing to fly from.
|
|
57
|
-
* - Controlled wins: while `from` and `to` are both present the pair owns
|
|
58
|
-
* the path and `icon` changes are ignored; dropping the pair hands the
|
|
59
|
-
* path back to `icon`.
|
|
60
|
-
* - Every exit from controlled mode (imperative call or icon takeover)
|
|
61
|
-
* invalidates the frozen pair, so returning to it re-bases on `from`. */
|
|
62
|
-
declare function createController({ icon, from, to, progress, reducedMotion }: CtrlProps): {
|
|
63
|
-
mount(mountEl: PathEl, { icon, from, to, progress, reducedMotion }: CtrlProps): void;
|
|
64
|
-
destroy(): void;
|
|
65
|
-
/** Prop watcher: ONE owner decides per run — the controlled pair while it
|
|
66
|
-
* is fully present, `icon` otherwise (mount doesn't fire thanks to the
|
|
67
|
-
* init-time baselines). */
|
|
68
|
-
watch({ icon, from, to, progress, spring, reducedMotion }: WatchProps): void;
|
|
69
|
-
morphTo(icon: IconInput, spring?: SpringPreset | MorphOptions): void;
|
|
70
|
-
set(icon: IconInput): void;
|
|
71
|
-
};
|
|
72
33
|
//#endregion
|
|
73
|
-
export { type IconInput, type IconNode, type Morph, MorphHandle, MorphIconProps, type MorphOptions, type PathEl, type ReducedMotionMode, type Sampled, type SpringPreset, computeInitialD, createController };
|
|
34
|
+
export { type IconInput, type IconNode, type Morph, type MorphHandle, MorphIconProps, type MorphOptions, type PathEl, type ReducedMotionMode, type Sampled, type SpringPreset, computeInitialD, createController };
|
package/dist/svelte-shared.js
CHANGED
|
@@ -1,151 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { i as serialize } from "./normalize-CYnN3Npw.js";
|
|
3
|
-
import { canonicalD, createMorph } from "./dom.js";
|
|
4
|
-
//#region src/svelte/shared.ts
|
|
5
|
-
/** Frozen shape of the from→to pair at t, using the pure core (SSR-safe).
|
|
6
|
-
* At exact endpoints returns the canonical `d` (real curves, not polyline). */
|
|
7
|
-
function frozenD(from, to, t) {
|
|
8
|
-
if (t <= 0) return canonicalD(from);
|
|
9
|
-
if (t >= 1) return canonicalD(to);
|
|
10
|
-
const plan = buildPlan(resampleIcon(from), resampleIcon(to));
|
|
11
|
-
const out = allocOutputs(plan);
|
|
12
|
-
interpPolar(plan, t, out);
|
|
13
|
-
return serialize(out, plan.items.map((it) => it.closed));
|
|
14
|
-
}
|
|
15
|
-
/** The initial d is constant for Svelte: computed once from the mount-time
|
|
16
|
-
* props (server and client produce the same string → hydration without
|
|
17
|
-
* mismatch) and from then on only the driver mutates it outside the template. */
|
|
18
|
-
function computeInitialD({ icon, from, to, progress }) {
|
|
19
|
-
if (from !== void 0 && to !== void 0) return frozenD(from, to, progress ?? 0);
|
|
20
|
-
const first = icon ?? from ?? to;
|
|
21
|
-
return first !== void 0 ? canonicalD(first) : "";
|
|
22
|
-
}
|
|
23
|
-
/** Per-instance driver state — the exact logic of the React/Vue bindings
|
|
24
|
-
* (mount, mode watch, controlled seek with re-basing, imperative). Takes the
|
|
25
|
-
* init-time props: they seed the watch baselines so the first `$effect` run
|
|
26
|
-
* (which fires on mount in Svelte, unlike Vue's `watch`) is a no-op. All
|
|
27
|
-
* change detection lives HERE, under tsc — the .svelte shell only wires
|
|
28
|
-
* runes to these methods.
|
|
29
|
-
*
|
|
30
|
-
* Lifecycle contract (shared verbatim by the three bindings):
|
|
31
|
-
* - Lazy driver: an iconless mount keeps the element and births the driver
|
|
32
|
-
* on the FIRST icon that shows up (prop or imperative). `morphTo` with no
|
|
33
|
-
* driver behaves as `set` — there is nothing to fly from.
|
|
34
|
-
* - Controlled wins: while `from` and `to` are both present the pair owns
|
|
35
|
-
* the path and `icon` changes are ignored; dropping the pair hands the
|
|
36
|
-
* path back to `icon`.
|
|
37
|
-
* - Every exit from controlled mode (imperative call or icon takeover)
|
|
38
|
-
* invalidates the frozen pair, so returning to it re-bases on `from`. */
|
|
39
|
-
function createController({ icon, from, to, progress, reducedMotion }) {
|
|
40
|
-
let el = null;
|
|
41
|
-
let dead = false;
|
|
42
|
-
let morph = null;
|
|
43
|
-
let rm = reducedMotion ?? "never";
|
|
44
|
-
let based = false;
|
|
45
|
-
let pair = null;
|
|
46
|
-
let prevIcon = icon;
|
|
47
|
-
let prevControlled = from !== void 0 && to !== void 0;
|
|
48
|
-
let prevFrom = from;
|
|
49
|
-
let prevTo = to;
|
|
50
|
-
let prevProgress = progress;
|
|
51
|
-
/** Driver birth, lazy included: the first icon to show up creates it. */
|
|
52
|
-
const ensure = (birth) => {
|
|
53
|
-
if (morph) return morph;
|
|
54
|
-
if (dead || !el) return null;
|
|
55
|
-
morph = createMorph(el, birth, { reducedMotion: rm });
|
|
56
|
-
return morph;
|
|
57
|
-
};
|
|
58
|
-
/** Controlled mode: freeze the pair at `progress` via seek (no spring). */
|
|
59
|
-
const applyPair = (from, to, progress) => {
|
|
60
|
-
const m = morph;
|
|
61
|
-
if (!m) return;
|
|
62
|
-
const t = progress ?? 0;
|
|
63
|
-
if (!pair || pair[0] !== from || pair[1] !== to) {
|
|
64
|
-
pair = [from, to];
|
|
65
|
-
based = false;
|
|
66
|
-
}
|
|
67
|
-
if (t <= 0) {
|
|
68
|
-
m.set(from);
|
|
69
|
-
based = false;
|
|
70
|
-
} else if (t >= 1) {
|
|
71
|
-
m.set(to);
|
|
72
|
-
based = false;
|
|
73
|
-
} else {
|
|
74
|
-
if (!based) {
|
|
75
|
-
m.set(from);
|
|
76
|
-
based = true;
|
|
77
|
-
}
|
|
78
|
-
m.seek(to, t);
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
return {
|
|
82
|
-
mount(mountEl, { icon, from, to, progress, reducedMotion }) {
|
|
83
|
-
el = mountEl;
|
|
84
|
-
rm = reducedMotion ?? rm;
|
|
85
|
-
const controlled = from !== void 0 && to !== void 0;
|
|
86
|
-
const initialIcon = icon ?? from ?? to;
|
|
87
|
-
if (initialIcon === void 0) return;
|
|
88
|
-
const m = createMorph(el, controlled ? from : initialIcon, { reducedMotion: rm });
|
|
89
|
-
morph = m;
|
|
90
|
-
if (controlled) {
|
|
91
|
-
pair = [from, to];
|
|
92
|
-
const t = progress ?? 0;
|
|
93
|
-
if (t <= 0) m.set(from);
|
|
94
|
-
else if (t >= 1) m.set(to);
|
|
95
|
-
else {
|
|
96
|
-
m.seek(to, t);
|
|
97
|
-
based = true;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
destroy() {
|
|
102
|
-
dead = true;
|
|
103
|
-
el = null;
|
|
104
|
-
morph?.destroy();
|
|
105
|
-
morph = null;
|
|
106
|
-
based = false;
|
|
107
|
-
pair = null;
|
|
108
|
-
},
|
|
109
|
-
/** Prop watcher: ONE owner decides per run — the controlled pair while it
|
|
110
|
-
* is fully present, `icon` otherwise (mount doesn't fire thanks to the
|
|
111
|
-
* init-time baselines). */
|
|
112
|
-
watch({ icon, from, to, progress, spring, reducedMotion }) {
|
|
113
|
-
rm = reducedMotion ?? "never";
|
|
114
|
-
if (morph) morph.reducedMotion = rm;
|
|
115
|
-
const controlled = from !== void 0 && to !== void 0;
|
|
116
|
-
const left = prevControlled && !controlled;
|
|
117
|
-
const iconChanged = icon !== prevIcon;
|
|
118
|
-
const pairChanged = from !== prevFrom || to !== prevTo || progress !== prevProgress;
|
|
119
|
-
prevControlled = controlled;
|
|
120
|
-
prevIcon = icon;
|
|
121
|
-
prevFrom = from;
|
|
122
|
-
prevTo = to;
|
|
123
|
-
prevProgress = progress;
|
|
124
|
-
if (controlled) {
|
|
125
|
-
if (!pairChanged) return;
|
|
126
|
-
if (!(morph ?? ensure(from))) return;
|
|
127
|
-
applyPair(from, to, progress);
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (icon === void 0 || !iconChanged && !left) return;
|
|
131
|
-
pair = null;
|
|
132
|
-
based = false;
|
|
133
|
-
if (morph) morph.morphTo(icon, spring);
|
|
134
|
-
else ensure(icon);
|
|
135
|
-
},
|
|
136
|
-
morphTo(icon, spring) {
|
|
137
|
-
pair = null;
|
|
138
|
-
based = false;
|
|
139
|
-
if (morph) morph.morphTo(icon, spring);
|
|
140
|
-
else ensure(icon);
|
|
141
|
-
},
|
|
142
|
-
set(icon) {
|
|
143
|
-
pair = null;
|
|
144
|
-
based = false;
|
|
145
|
-
if (morph) morph.set(icon);
|
|
146
|
-
else ensure(icon);
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
//#endregion
|
|
1
|
+
import { n as createController, t as computeInitialD } from "./controller-CXZuwJ_M.js";
|
|
151
2
|
export { computeInitialD, createController };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morphicons",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "Universal morphing for stroke-based icons with spring physics — zero runtime dependencies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Guillermo (https://guillermolg.com)",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"lucide-vue-next",
|
|
36
36
|
"svelte",
|
|
37
37
|
"lucide-svelte",
|
|
38
|
+
"astro",
|
|
39
|
+
"astro-component",
|
|
40
|
+
"web-component",
|
|
41
|
+
"custom-element",
|
|
38
42
|
"icon-animation"
|
|
39
43
|
],
|
|
40
44
|
"type": "module",
|
|
@@ -71,6 +75,14 @@
|
|
|
71
75
|
"types": "./dist/svelte.d.ts",
|
|
72
76
|
"svelte": "./dist/svelte.js",
|
|
73
77
|
"default": "./dist/svelte.js"
|
|
78
|
+
},
|
|
79
|
+
"./element": {
|
|
80
|
+
"types": "./dist/element.d.ts",
|
|
81
|
+
"default": "./dist/element.js"
|
|
82
|
+
},
|
|
83
|
+
"./astro": {
|
|
84
|
+
"types": "./dist/astro.d.ts",
|
|
85
|
+
"default": "./dist/MorphIcon.astro"
|
|
74
86
|
}
|
|
75
87
|
},
|
|
76
88
|
"peerDependencies": {
|
|
@@ -102,11 +114,11 @@
|
|
|
102
114
|
],
|
|
103
115
|
"scripts": {
|
|
104
116
|
"test": "bun test",
|
|
105
|
-
"typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.react-native.json --noEmit && tsc -p tsconfig.vue.json --noEmit && tsc -p tsconfig.svelte.json --noEmit",
|
|
117
|
+
"typecheck": "tsc --noEmit && tsc -p playground --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.react-native.json --noEmit && tsc -p tsconfig.vue.json --noEmit && tsc -p tsconfig.svelte.json --noEmit && tsc -p tsconfig.element.json --noEmit",
|
|
106
118
|
"play": "bun ./playground/index.html",
|
|
107
119
|
"lint": "biome check .",
|
|
108
120
|
"format": "biome check --write .",
|
|
109
|
-
"build": "tsdown && bun scripts/build-svelte.ts",
|
|
121
|
+
"build": "tsdown && bun scripts/build-svelte.ts && bun scripts/build-astro.ts",
|
|
110
122
|
"size": "size-limit",
|
|
111
123
|
"prepublishOnly": "bun run build"
|
|
112
124
|
},
|
|
@@ -203,6 +215,22 @@
|
|
|
203
215
|
],
|
|
204
216
|
"limit": "1.4 KB",
|
|
205
217
|
"gzip": true
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"name": "core + dom + element (<morph-icon> custom element)",
|
|
221
|
+
"path": "dist/element.js",
|
|
222
|
+
"import": "*",
|
|
223
|
+
"limit": "9.5 KB",
|
|
224
|
+
"gzip": true
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"name": "astro shell (ships as source, consumer-compiled)",
|
|
228
|
+
"path": "dist/MorphIcon.astro",
|
|
229
|
+
"disablePlugins": [
|
|
230
|
+
"@size-limit/esbuild"
|
|
231
|
+
],
|
|
232
|
+
"limit": "1.75 KB",
|
|
233
|
+
"gzip": true
|
|
206
234
|
}
|
|
207
235
|
],
|
|
208
236
|
"devDependencies": {
|