solid-drift 0.7.1 → 0.8.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 +199 -1
- package/dist/ai.d.ts +207 -0
- package/dist/ai.js +570 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/motion.d.ts +78 -1
- package/dist/motion.js +81 -49
- package/dist/text.d.ts +27 -0
- package/dist/text.js +84 -0
- package/dist/web3.d.ts +213 -0
- package/dist/web3.js +640 -0
- package/package.json +1 -1
package/dist/web3.js
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web3 family: motion primitives for onchain product UI.
|
|
3
|
+
*
|
|
4
|
+
* Transaction lifecycle, price tickers, mint reveals, and wallet
|
|
5
|
+
* button micro-interactions, without depending on any wallet or chain
|
|
6
|
+
* library. State arrives through accessors in a wagmi/viem-style
|
|
7
|
+
* shape (the adapter pattern), so the primitives stay zero-dependency
|
|
8
|
+
* while your app keeps its own stack.
|
|
9
|
+
*
|
|
10
|
+
* All primitives are signal-native, SSR-safe, dependency-free, and
|
|
11
|
+
* define sensible static behavior under `prefers-reduced-motion`.
|
|
12
|
+
*/
|
|
13
|
+
import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
|
|
14
|
+
import { animate } from "./animate.js";
|
|
15
|
+
import { createSquashStretch } from "./cartoon.js";
|
|
16
|
+
import { createAnticipation } from "./cartoon.js";
|
|
17
|
+
import { resolveEasing } from "./easing.js";
|
|
18
|
+
import { now, schedule } from "./engine.js";
|
|
19
|
+
import { createMagnetic } from "./pointer.js";
|
|
20
|
+
import { prefersReducedMotion } from "./reduced-motion.js";
|
|
21
|
+
import { createSpring } from "./spring.js";
|
|
22
|
+
import { ownerDoc } from "./text.js";
|
|
23
|
+
function clamp01(v) {
|
|
24
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
25
|
+
}
|
|
26
|
+
/** Round to 2 decimals for style values. */
|
|
27
|
+
function fmt(v) {
|
|
28
|
+
return String(Math.round(v * 100) / 100);
|
|
29
|
+
}
|
|
30
|
+
const STATE_PROGRESS = {
|
|
31
|
+
idle: 0,
|
|
32
|
+
signing: 0.25,
|
|
33
|
+
pending: 0.5,
|
|
34
|
+
confirming: 0.75,
|
|
35
|
+
success: 1,
|
|
36
|
+
failed: 1,
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Signal-native transaction lifecycle.
|
|
40
|
+
*
|
|
41
|
+
* Feed it wagmi/viem-style state through `source` and it derives the
|
|
42
|
+
* stage: a reported hash with too few confirmations is "pending",
|
|
43
|
+
* enough confirmations is "confirming", success/error map to
|
|
44
|
+
* "success"/"failed". Stages the chain never reports, like "signing"
|
|
45
|
+
* while the wallet prompt is open, are set manually.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* const tx = createTxLifecycle({
|
|
49
|
+
* source: () => ({
|
|
50
|
+
* status: receiptQuery.status, // "pending" | "success" | "error" | "idle"
|
|
51
|
+
* confirmations: receiptQuery.confirmations,
|
|
52
|
+
* }),
|
|
53
|
+
* requiredConfirmations: 2,
|
|
54
|
+
* })
|
|
55
|
+
* const openWallet = () => {
|
|
56
|
+
* tx.set("signing")
|
|
57
|
+
* sendTransaction()
|
|
58
|
+
* }
|
|
59
|
+
* // <ProgressRing value={tx.progress()} state={tx.state()} />
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* SSR-safe: "idle" with `progress()` 0. Under reduced motion state
|
|
63
|
+
* changes apply instantly and `progress()` jumps to its target.
|
|
64
|
+
*/
|
|
65
|
+
export function createTxLifecycle(options = {}) {
|
|
66
|
+
const { source, requiredConfirmations = 1, stiffness = 170, damping = 26, onEnter, } = options;
|
|
67
|
+
const [state, setState] = createSignal("idle");
|
|
68
|
+
const [target, setTarget] = createSignal(0);
|
|
69
|
+
const progress = createSpring(target, { stiffness, damping });
|
|
70
|
+
const apply = (next) => {
|
|
71
|
+
const current = state();
|
|
72
|
+
if (next === current)
|
|
73
|
+
return;
|
|
74
|
+
setState(next);
|
|
75
|
+
setTarget(STATE_PROGRESS[next]);
|
|
76
|
+
onEnter?.(next, current);
|
|
77
|
+
};
|
|
78
|
+
const derive = (input) => {
|
|
79
|
+
if (input.status === "success")
|
|
80
|
+
return "success";
|
|
81
|
+
if (input.status === "error")
|
|
82
|
+
return "failed";
|
|
83
|
+
if (input.status === "pending") {
|
|
84
|
+
return (input.confirmations ?? 0) >= requiredConfirmations
|
|
85
|
+
? "confirming"
|
|
86
|
+
: "pending";
|
|
87
|
+
}
|
|
88
|
+
return "idle";
|
|
89
|
+
};
|
|
90
|
+
if (source) {
|
|
91
|
+
createEffect(() => {
|
|
92
|
+
const next = derive(source());
|
|
93
|
+
// Apply untracked: reading state() inside apply() must not make this
|
|
94
|
+
// effect depend on state, or a manual set() would be undone by a
|
|
95
|
+
// re-derive from the unchanged source.
|
|
96
|
+
untrack(() => apply(next));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
state,
|
|
101
|
+
set: apply,
|
|
102
|
+
reset: () => apply("idle"),
|
|
103
|
+
progress,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Animated price/balance ticker: per-digit roll, direction flash.
|
|
108
|
+
*
|
|
109
|
+
* Each digit is a 0-9 strip in an overflow-hidden column, driven by a
|
|
110
|
+
* single rAF task for the whole ticker. Rapid source updates batch to
|
|
111
|
+
* one render per frame, keeping only the latest value, so a hot price
|
|
112
|
+
* feed never queues animation debt. Non-digit characters (decimal
|
|
113
|
+
* point, grouping separators, minus sign) render statically.
|
|
114
|
+
*
|
|
115
|
+
* ```tsx
|
|
116
|
+
* let el!: HTMLSpanElement
|
|
117
|
+
* const [price, setPrice] = createSignal(48210.5)
|
|
118
|
+
* const ticker = createTicker(price, () => el, { decimals: 2 })
|
|
119
|
+
* <span ref={el} style={{ color: ticker.direction() === "up" ? "green" : "red" }}>
|
|
120
|
+
* {ticker.display()}
|
|
121
|
+
* </span>
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* SSR-safe: `display()` returns the formatted string with no DOM.
|
|
125
|
+
* Under reduced motion the text swaps instantly: no roll, no flash.
|
|
126
|
+
*/
|
|
127
|
+
export function createTicker(source, ref, options = {}) {
|
|
128
|
+
const { decimals = 2, duration = 400, upColor = "#16a34a", downColor = "#dc2626", flashMs = 600, locale = "en-US", } = options;
|
|
129
|
+
const server = typeof window === "undefined";
|
|
130
|
+
const easing = resolveEasing("easeOutCubic");
|
|
131
|
+
const format = (v) => new Intl.NumberFormat(locale, {
|
|
132
|
+
minimumFractionDigits: decimals,
|
|
133
|
+
maximumFractionDigits: decimals,
|
|
134
|
+
}).format(v);
|
|
135
|
+
const initial = untrack(source);
|
|
136
|
+
const [display, setDisplay] = createSignal(format(initial));
|
|
137
|
+
const [direction, setDirection] = createSignal("flat");
|
|
138
|
+
let prevValue = initial;
|
|
139
|
+
let rendered = "";
|
|
140
|
+
let strips = [];
|
|
141
|
+
let scheduled = false;
|
|
142
|
+
let renderCancel = null;
|
|
143
|
+
let flashStop = null;
|
|
144
|
+
let driveCancel = null;
|
|
145
|
+
let anims = [];
|
|
146
|
+
const isDigit = (ch) => ch >= "0" && ch <= "9";
|
|
147
|
+
/** One rAF task drives every rolling digit. */
|
|
148
|
+
const drive = () => {
|
|
149
|
+
if (driveCancel || anims.length === 0 || server)
|
|
150
|
+
return;
|
|
151
|
+
driveCancel = schedule((t) => {
|
|
152
|
+
let alive = false;
|
|
153
|
+
anims = anims.filter((a) => {
|
|
154
|
+
const local = clamp01((t - a.startAt) / duration);
|
|
155
|
+
const v = a.from + (a.to - a.from) * easing(local);
|
|
156
|
+
a.strip.style.transform = `translateY(${fmt(-v * 10)}%)`;
|
|
157
|
+
if (local < 1)
|
|
158
|
+
alive = true;
|
|
159
|
+
return local < 1;
|
|
160
|
+
});
|
|
161
|
+
if (!alive)
|
|
162
|
+
driveCancel = null;
|
|
163
|
+
return alive;
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
const flash = (color) => {
|
|
167
|
+
const el = ref();
|
|
168
|
+
if (!el)
|
|
169
|
+
return;
|
|
170
|
+
flashStop?.();
|
|
171
|
+
el.style.color = color;
|
|
172
|
+
const timer = animate(0, 1, {
|
|
173
|
+
duration: Math.max(flashMs, 1),
|
|
174
|
+
onComplete: () => {
|
|
175
|
+
el.style.color = "";
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
flashStop = timer.stop;
|
|
179
|
+
};
|
|
180
|
+
const makeStrip = (doc, digit) => {
|
|
181
|
+
const wrap = doc.createElement("span");
|
|
182
|
+
wrap.style.display = "inline-block";
|
|
183
|
+
wrap.style.overflow = "hidden";
|
|
184
|
+
wrap.style.verticalAlign = "top";
|
|
185
|
+
const strip = doc.createElement("span");
|
|
186
|
+
strip.style.display = "block";
|
|
187
|
+
strip.style.willChange = "transform";
|
|
188
|
+
strip.style.transform = `translateY(${-digit * 10}%)`;
|
|
189
|
+
for (let d = 0; d <= 9; d++) {
|
|
190
|
+
const cell = doc.createElement("span");
|
|
191
|
+
cell.textContent = String(d);
|
|
192
|
+
cell.style.display = "block";
|
|
193
|
+
cell.setAttribute("aria-hidden", "true");
|
|
194
|
+
strip.appendChild(cell);
|
|
195
|
+
}
|
|
196
|
+
wrap.appendChild(strip);
|
|
197
|
+
return wrap;
|
|
198
|
+
};
|
|
199
|
+
const stripOf = (wrap) => wrap.firstChild ?? null;
|
|
200
|
+
const render = () => {
|
|
201
|
+
const value = untrack(source);
|
|
202
|
+
const formatted = format(value);
|
|
203
|
+
setDisplay(formatted);
|
|
204
|
+
const dir = value > prevValue ? "up" : value < prevValue ? "down" : "flat";
|
|
205
|
+
prevValue = value;
|
|
206
|
+
setDirection(dir);
|
|
207
|
+
if (dir !== "flat" && !prefersReducedMotion()) {
|
|
208
|
+
flash(dir === "up" ? upColor : downColor);
|
|
209
|
+
}
|
|
210
|
+
const el = ref();
|
|
211
|
+
if (!el || server)
|
|
212
|
+
return;
|
|
213
|
+
const doc = ownerDoc(el);
|
|
214
|
+
if (!doc)
|
|
215
|
+
return;
|
|
216
|
+
if (prefersReducedMotion()) {
|
|
217
|
+
// Accessibility: swap the text instantly, no roll, no flash.
|
|
218
|
+
el.textContent = formatted;
|
|
219
|
+
rendered = formatted;
|
|
220
|
+
strips = [];
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const chars = [...formatted];
|
|
224
|
+
const prevChars = [...rendered];
|
|
225
|
+
const maskChanged = rendered === "" ||
|
|
226
|
+
chars.length !== prevChars.length ||
|
|
227
|
+
chars.some((ch, i) => isDigit(ch) !== isDigit(prevChars[i]));
|
|
228
|
+
if (maskChanged) {
|
|
229
|
+
el.textContent = "";
|
|
230
|
+
strips = [];
|
|
231
|
+
for (const ch of chars) {
|
|
232
|
+
if (isDigit(ch)) {
|
|
233
|
+
const wrap = makeStrip(doc, Number(ch));
|
|
234
|
+
el.appendChild(wrap);
|
|
235
|
+
strips.push(stripOf(wrap));
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
const s = doc.createElement("span");
|
|
239
|
+
s.textContent = ch;
|
|
240
|
+
el.appendChild(s);
|
|
241
|
+
strips.push(null);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
rendered = formatted;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// Same layout: roll only the digits that changed.
|
|
248
|
+
const t = now();
|
|
249
|
+
chars.forEach((ch, i) => {
|
|
250
|
+
if (!isDigit(ch) || ch === prevChars[i])
|
|
251
|
+
return;
|
|
252
|
+
const strip = strips[i];
|
|
253
|
+
if (!strip)
|
|
254
|
+
return;
|
|
255
|
+
anims = anims.filter((a) => a.strip !== strip);
|
|
256
|
+
anims.push({
|
|
257
|
+
strip,
|
|
258
|
+
from: Number(prevChars[i]),
|
|
259
|
+
to: Number(ch),
|
|
260
|
+
startAt: t,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
rendered = formatted;
|
|
264
|
+
drive();
|
|
265
|
+
};
|
|
266
|
+
/** Batch rapid updates: at most one render per frame, latest wins. */
|
|
267
|
+
const requestRender = () => {
|
|
268
|
+
if (scheduled || server)
|
|
269
|
+
return;
|
|
270
|
+
scheduled = true;
|
|
271
|
+
renderCancel?.();
|
|
272
|
+
renderCancel = schedule(() => {
|
|
273
|
+
scheduled = false;
|
|
274
|
+
renderCancel = null;
|
|
275
|
+
render();
|
|
276
|
+
return false;
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
if (!server) {
|
|
280
|
+
createEffect(() => {
|
|
281
|
+
source(); // track
|
|
282
|
+
requestRender();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
onCleanup(() => {
|
|
286
|
+
renderCancel?.();
|
|
287
|
+
renderCancel = null;
|
|
288
|
+
driveCancel?.();
|
|
289
|
+
driveCancel = null;
|
|
290
|
+
flashStop?.();
|
|
291
|
+
flashStop = null;
|
|
292
|
+
});
|
|
293
|
+
return { display, direction };
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Pack-open / card-reveal choreography, built on the cartoon family:
|
|
297
|
+
* an anticipation shake, a rotateY flip with a face swap at the
|
|
298
|
+
* midpoint, and squash and stretch driven by the flip's own velocity
|
|
299
|
+
* as the card lands. Everything stays on compositor-friendly
|
|
300
|
+
* properties (transform, and the CSS `scale` property for the deform).
|
|
301
|
+
*
|
|
302
|
+
* ```tsx
|
|
303
|
+
* let card!: HTMLDivElement
|
|
304
|
+
* const [face, setFace] = createSignal<"back" | "front">("back")
|
|
305
|
+
* const reveal = createMintReveal(() => card, {
|
|
306
|
+
* onFlip: () => setFace("front"), // swap faces mid-flip
|
|
307
|
+
* })
|
|
308
|
+
* <button onClick={() => reveal.play()}>Reveal</button>
|
|
309
|
+
* <div ref={card} style={{ "backface-visibility": "hidden" }}>
|
|
310
|
+
* {face() === "back" ? <CardBack /> : <NftFront />}
|
|
311
|
+
* </div>
|
|
312
|
+
* ```
|
|
313
|
+
*
|
|
314
|
+
* SSR-safe: no-op on the server, `status()` is "revealed" so the face
|
|
315
|
+
* renders statically. Under reduced motion the shake and flip are
|
|
316
|
+
* skipped and the final face shows at once.
|
|
317
|
+
*/
|
|
318
|
+
export function createMintReveal(ref, options = {}) {
|
|
319
|
+
const { shakeDuration = 500, flipDuration = 700, squash = true, easing: easingOpt = "easeOutCubic", onFlip, } = options;
|
|
320
|
+
const easing = resolveEasing(easingOpt);
|
|
321
|
+
const server = typeof window === "undefined";
|
|
322
|
+
const [status, setStatus] = createSignal(server ? "revealed" : "idle");
|
|
323
|
+
const [flipP, setFlipP] = createSignal(0);
|
|
324
|
+
let stopCurrent = null;
|
|
325
|
+
let runToken = 0;
|
|
326
|
+
if (squash) {
|
|
327
|
+
// The flip's own progress is the motion source: the card stretches
|
|
328
|
+
// mid-flip and squashes as it lands. Self-guards SSR and reduced
|
|
329
|
+
// motion, where it applies no deform.
|
|
330
|
+
createSquashStretch(ref, {
|
|
331
|
+
source: flipP,
|
|
332
|
+
fullSpeed: 400,
|
|
333
|
+
impactThreshold: 1200,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const play = () => {
|
|
337
|
+
const token = ++runToken;
|
|
338
|
+
stopCurrent?.();
|
|
339
|
+
stopCurrent = null;
|
|
340
|
+
if (server) {
|
|
341
|
+
setStatus("revealed");
|
|
342
|
+
return Promise.resolve();
|
|
343
|
+
}
|
|
344
|
+
const el = ref();
|
|
345
|
+
if (!el) {
|
|
346
|
+
setStatus("revealed");
|
|
347
|
+
return Promise.resolve();
|
|
348
|
+
}
|
|
349
|
+
if (prefersReducedMotion()) {
|
|
350
|
+
// Accessibility: skip the shake and the flip.
|
|
351
|
+
onFlip?.();
|
|
352
|
+
setFlipP(180);
|
|
353
|
+
setStatus("revealed");
|
|
354
|
+
return Promise.resolve();
|
|
355
|
+
}
|
|
356
|
+
setStatus("anticipating");
|
|
357
|
+
return (async () => {
|
|
358
|
+
// Anticipation shake: wind up before the flip fires.
|
|
359
|
+
// Await `finished` (not onComplete): it resolves when the animation
|
|
360
|
+
// is stopped too, so reset() mid-play cannot strand this promise.
|
|
361
|
+
if (token === runToken) {
|
|
362
|
+
const ctl = createAnticipation(0, 0, {
|
|
363
|
+
windup: 14,
|
|
364
|
+
windupDuration: Math.max(shakeDuration * 0.55, 1),
|
|
365
|
+
holdDuration: Math.max(shakeDuration * 0.15, 0),
|
|
366
|
+
duration: Math.max(shakeDuration * 0.3, 1),
|
|
367
|
+
onUpdate: (v) => {
|
|
368
|
+
if (token === runToken) {
|
|
369
|
+
el.style.transform = `translateX(${fmt(v)}px)`;
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
stopCurrent = ctl.stop;
|
|
374
|
+
await ctl.finished;
|
|
375
|
+
}
|
|
376
|
+
if (token !== runToken)
|
|
377
|
+
return;
|
|
378
|
+
// The flip. onFlip fires at the midpoint, while the card is
|
|
379
|
+
// edge-on and the face swap is invisible.
|
|
380
|
+
setStatus("flipping");
|
|
381
|
+
let flipped = false;
|
|
382
|
+
const flip = animate(0, 180, {
|
|
383
|
+
duration: Math.max(flipDuration, 1),
|
|
384
|
+
easing,
|
|
385
|
+
onUpdate: (v) => {
|
|
386
|
+
if (token !== runToken)
|
|
387
|
+
return;
|
|
388
|
+
setFlipP(v);
|
|
389
|
+
el.style.transform = `rotateY(${fmt(v)}deg)`;
|
|
390
|
+
if (!flipped && v >= 90) {
|
|
391
|
+
flipped = true;
|
|
392
|
+
onFlip?.();
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
stopCurrent = flip.stop;
|
|
397
|
+
await flip.finished;
|
|
398
|
+
stopCurrent = null;
|
|
399
|
+
if (token !== runToken)
|
|
400
|
+
return;
|
|
401
|
+
setFlipP(180);
|
|
402
|
+
el.style.transform = "rotateY(180deg)";
|
|
403
|
+
setStatus("revealed");
|
|
404
|
+
})();
|
|
405
|
+
};
|
|
406
|
+
const reset = () => {
|
|
407
|
+
runToken++;
|
|
408
|
+
stopCurrent?.();
|
|
409
|
+
stopCurrent = null;
|
|
410
|
+
setFlipP(0);
|
|
411
|
+
if (!server) {
|
|
412
|
+
const el = ref();
|
|
413
|
+
if (el)
|
|
414
|
+
el.style.transform = "";
|
|
415
|
+
setStatus("idle");
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
onCleanup(() => {
|
|
419
|
+
runToken++;
|
|
420
|
+
stopCurrent?.();
|
|
421
|
+
stopCurrent = null;
|
|
422
|
+
});
|
|
423
|
+
return { play, reset, status };
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Wallet connect-button micro-interactions: magnetic hover pull,
|
|
427
|
+
* press scale, a checkmark tick for copy-address feedback, and an
|
|
428
|
+
* expanding ring pulse for chain switches.
|
|
429
|
+
*
|
|
430
|
+
* The button's transform is owned by the primitive (magnetic pull
|
|
431
|
+
* plus press scale); the tick and pulse animate overlay spans so
|
|
432
|
+
* they never fight the hover motion. The button is given
|
|
433
|
+
* `position: relative` the first time a tick or pulse runs, to stage
|
|
434
|
+
* the overlays.
|
|
435
|
+
*
|
|
436
|
+
* ```tsx
|
|
437
|
+
* let btn!: HTMLButtonElement
|
|
438
|
+
* const connect = createConnectButton(() => btn)
|
|
439
|
+
* const copy = async () => {
|
|
440
|
+
* await navigator.clipboard.writeText(address())
|
|
441
|
+
* connect.copyTick()
|
|
442
|
+
* }
|
|
443
|
+
* <button ref={btn} onClick={copy}>0x7a…f3c2</button>
|
|
444
|
+
* ```
|
|
445
|
+
*
|
|
446
|
+
* SSR-safe: no-op on the server. Under reduced motion the magnetic
|
|
447
|
+
* pull is off and the tick/pulse become instant state changes (the
|
|
448
|
+
* check shows statically, then hides).
|
|
449
|
+
*/
|
|
450
|
+
export function createConnectButton(ref, options = {}) {
|
|
451
|
+
const { strength = 0.35, pressScale = 0.96 } = options;
|
|
452
|
+
const server = typeof window === "undefined";
|
|
453
|
+
const staticMode = server || prefersReducedMotion();
|
|
454
|
+
const [status, setStatus] = createSignal("idle");
|
|
455
|
+
const [pressed, setPressed] = createSignal(false);
|
|
456
|
+
const magnetic = createMagnetic(ref, { strength });
|
|
457
|
+
let checkEl = null;
|
|
458
|
+
let ringEl = null;
|
|
459
|
+
let tickStop = null;
|
|
460
|
+
let pulseStop = null;
|
|
461
|
+
let tickToken = 0;
|
|
462
|
+
let pulseToken = 0;
|
|
463
|
+
const targets = (el, target) => {
|
|
464
|
+
if (target === el)
|
|
465
|
+
return true;
|
|
466
|
+
const contains = el.contains;
|
|
467
|
+
return (typeof contains === "function" && contains.call(el, target) === true);
|
|
468
|
+
};
|
|
469
|
+
if (!server && !staticMode) {
|
|
470
|
+
const onDown = (e) => {
|
|
471
|
+
const el = ref();
|
|
472
|
+
if (el && targets(el, e.target))
|
|
473
|
+
setPressed(true);
|
|
474
|
+
};
|
|
475
|
+
const onUp = () => {
|
|
476
|
+
setPressed(false);
|
|
477
|
+
};
|
|
478
|
+
const w = window;
|
|
479
|
+
w.addEventListener("pointerdown", onDown);
|
|
480
|
+
w.addEventListener("pointerup", onUp);
|
|
481
|
+
w.addEventListener("pointercancel", onUp);
|
|
482
|
+
onCleanup(() => {
|
|
483
|
+
w.removeEventListener("pointerdown", onDown);
|
|
484
|
+
w.removeEventListener("pointerup", onUp);
|
|
485
|
+
w.removeEventListener("pointercancel", onUp);
|
|
486
|
+
});
|
|
487
|
+
// The primitive owns the button's transform: magnetic pull plus
|
|
488
|
+
// press scale. Tick and pulse use overlays, never the transform.
|
|
489
|
+
createEffect(() => {
|
|
490
|
+
const el = ref();
|
|
491
|
+
if (!el)
|
|
492
|
+
return;
|
|
493
|
+
const s = pressed() ? pressScale : 1;
|
|
494
|
+
el.style.transform =
|
|
495
|
+
`translate(${fmt(magnetic.x())}px, ${fmt(magnetic.y())}px) scale(${fmt(s)})`;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
/** Stage the button for absolutely-positioned overlays. */
|
|
499
|
+
const stage = () => {
|
|
500
|
+
const el = ref();
|
|
501
|
+
if (!el || server)
|
|
502
|
+
return null;
|
|
503
|
+
const doc = ownerDoc(el);
|
|
504
|
+
if (!doc)
|
|
505
|
+
return null;
|
|
506
|
+
if (el.style.position !== "relative" && el.style.position !== "absolute") {
|
|
507
|
+
el.style.position = "relative";
|
|
508
|
+
}
|
|
509
|
+
return { host: el, doc };
|
|
510
|
+
};
|
|
511
|
+
const getCheck = (host, doc) => {
|
|
512
|
+
if (!checkEl) {
|
|
513
|
+
checkEl = doc.createElement("span");
|
|
514
|
+
checkEl.textContent = "✓";
|
|
515
|
+
checkEl.setAttribute("aria-hidden", "true");
|
|
516
|
+
const s = checkEl.style;
|
|
517
|
+
s.position = "absolute";
|
|
518
|
+
s.inset = "0";
|
|
519
|
+
s.display = "flex";
|
|
520
|
+
s.alignItems = "center";
|
|
521
|
+
s.justifyContent = "center";
|
|
522
|
+
s.fontSize = "1.25em";
|
|
523
|
+
s.pointerEvents = "none";
|
|
524
|
+
s.opacity = "0";
|
|
525
|
+
host.appendChild(checkEl);
|
|
526
|
+
}
|
|
527
|
+
return checkEl;
|
|
528
|
+
};
|
|
529
|
+
const getRing = (host, doc) => {
|
|
530
|
+
if (!ringEl) {
|
|
531
|
+
ringEl = doc.createElement("span");
|
|
532
|
+
ringEl.setAttribute("aria-hidden", "true");
|
|
533
|
+
const s = ringEl.style;
|
|
534
|
+
s.position = "absolute";
|
|
535
|
+
s.inset = "-2px";
|
|
536
|
+
s.borderRadius = "inherit";
|
|
537
|
+
s.pointerEvents = "none";
|
|
538
|
+
s.opacity = "0";
|
|
539
|
+
host.appendChild(ringEl);
|
|
540
|
+
}
|
|
541
|
+
return ringEl;
|
|
542
|
+
};
|
|
543
|
+
const copyTick = () => {
|
|
544
|
+
const staged = stage();
|
|
545
|
+
if (!staged)
|
|
546
|
+
return;
|
|
547
|
+
const token = ++tickToken;
|
|
548
|
+
tickStop?.();
|
|
549
|
+
tickStop = null;
|
|
550
|
+
setStatus("ticking");
|
|
551
|
+
const check = getCheck(staged.host, staged.doc);
|
|
552
|
+
if (staticMode) {
|
|
553
|
+
// Accessibility: show the check statically, then hide it.
|
|
554
|
+
check.style.transform = "none";
|
|
555
|
+
check.style.opacity = "1";
|
|
556
|
+
const timer = animate(0, 1, {
|
|
557
|
+
duration: 1200,
|
|
558
|
+
onComplete: () => {
|
|
559
|
+
if (token !== tickToken)
|
|
560
|
+
return;
|
|
561
|
+
check.style.opacity = "0";
|
|
562
|
+
setStatus("idle");
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
tickStop = timer.stop;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
check.style.opacity = "1";
|
|
569
|
+
const pop = animate(0, 1, {
|
|
570
|
+
duration: 280,
|
|
571
|
+
easing: "easeOutBack",
|
|
572
|
+
onUpdate: (v) => {
|
|
573
|
+
if (token !== tickToken)
|
|
574
|
+
return;
|
|
575
|
+
check.style.transform = `scale(${fmt(0.6 + 0.4 * v)})`;
|
|
576
|
+
check.style.opacity = fmt(Math.min(v * 2, 1));
|
|
577
|
+
},
|
|
578
|
+
onComplete: () => {
|
|
579
|
+
if (token !== tickToken)
|
|
580
|
+
return;
|
|
581
|
+
const fade = animate(1, 0, {
|
|
582
|
+
duration: 220,
|
|
583
|
+
onUpdate: (v) => {
|
|
584
|
+
check.style.opacity = fmt(v);
|
|
585
|
+
},
|
|
586
|
+
onComplete: () => {
|
|
587
|
+
if (token !== tickToken)
|
|
588
|
+
return;
|
|
589
|
+
setStatus("idle");
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
tickStop = fade.stop;
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
tickStop = pop.stop;
|
|
596
|
+
};
|
|
597
|
+
const chainPulse = () => {
|
|
598
|
+
const staged = stage();
|
|
599
|
+
if (!staged)
|
|
600
|
+
return;
|
|
601
|
+
const token = ++pulseToken;
|
|
602
|
+
pulseStop?.();
|
|
603
|
+
pulseStop = null;
|
|
604
|
+
setStatus("pulsing");
|
|
605
|
+
const ring = getRing(staged.host, staged.doc);
|
|
606
|
+
if (staticMode) {
|
|
607
|
+
// Accessibility: instant state change, no expanding ring.
|
|
608
|
+
ring.style.opacity = "0";
|
|
609
|
+
setStatus("idle");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const wave = animate(0, 1, {
|
|
613
|
+
duration: 600,
|
|
614
|
+
easing: "easeOutCubic",
|
|
615
|
+
onUpdate: (v) => {
|
|
616
|
+
if (token !== pulseToken)
|
|
617
|
+
return;
|
|
618
|
+
ring.style.opacity = fmt(1 - v);
|
|
619
|
+
ring.style.boxShadow = `0 0 0 ${fmt(v * 14)}px currentColor`;
|
|
620
|
+
},
|
|
621
|
+
onComplete: () => {
|
|
622
|
+
if (token !== pulseToken)
|
|
623
|
+
return;
|
|
624
|
+
ring.style.opacity = "0";
|
|
625
|
+
ring.style.boxShadow = "none";
|
|
626
|
+
setStatus("idle");
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
pulseStop = wave.stop;
|
|
630
|
+
};
|
|
631
|
+
onCleanup(() => {
|
|
632
|
+
tickToken++;
|
|
633
|
+
pulseToken++;
|
|
634
|
+
tickStop?.();
|
|
635
|
+
tickStop = null;
|
|
636
|
+
pulseStop?.();
|
|
637
|
+
pulseStop = null;
|
|
638
|
+
});
|
|
639
|
+
return { copyTick, chainPulse, status };
|
|
640
|
+
}
|
package/package.json
CHANGED