@adia-ai/web-components 0.2.2 → 0.2.4
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/components/agent-trace/agent-trace.css +24 -3
- package/components/button/button.js +3 -0
- package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
- package/components/demo-toggle/demo-toggle.css +120 -0
- package/components/demo-toggle/demo-toggle.js +144 -0
- package/components/demo-toggle/demo-toggle.test.js +102 -0
- package/components/demo-toggle/demo-toggle.yaml +144 -0
- package/components/index.js +1 -0
- package/components/input/input.js +11 -0
- package/components/list/list.css +66 -3
- package/components/nav-group/nav-group.a2ui.json +1 -1
- package/components/nav-group/nav-group.css +5 -5
- package/components/nav-group/nav-group.yaml +1 -1
- package/components/nav-item/nav-item.a2ui.json +1 -1
- package/components/nav-item/nav-item.css +3 -4
- package/components/nav-item/nav-item.yaml +1 -1
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +13 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +258 -5
- package/traits/active-state.test.js +1 -1
- package/traits/anchor-positioning.js +205 -52
- package/traits/anchor-positioning.test.js +77 -4
- package/traits/announcer-stage.js +157 -0
- package/traits/announcer.js +145 -0
- package/traits/announcer.test.js +268 -0
- package/traits/arrow-grid-nav.js +234 -0
- package/traits/arrow-grid-nav.test.js +375 -0
- package/traits/attention-pulse.js +1 -1
- package/traits/attention-pulse.test.js +1 -1
- package/traits/confetti-burst.js +90 -60
- package/traits/confetti-burst.test.js +16 -8
- package/traits/confetti-stage.js +143 -0
- package/traits/confetti.js +44 -47
- package/traits/confetti.test.js +24 -5
- package/traits/count-up.js +31 -6
- package/traits/count-up.test.js +1 -1
- package/traits/declarative.test.js +1 -1
- package/traits/dirty-state.test.js +1 -1
- package/traits/drag-ghost.js +55 -3
- package/traits/drag-ghost.test.js +1 -1
- package/traits/draggable-list-item.js +279 -0
- package/traits/draggable-list-item.test.js +51 -0
- package/traits/draggable.js +14 -4
- package/traits/draggable.test.js +1 -1
- package/traits/drop-target.js +223 -0
- package/traits/drop-target.test.js +241 -0
- package/traits/droppable-collection.js +89 -0
- package/traits/droppable-collection.test.js +99 -0
- package/traits/droppable.js +125 -0
- package/traits/droppable.test.js +54 -0
- package/traits/error-shake.js +157 -0
- package/traits/error-shake.test.js +114 -0
- package/traits/fade-presence.test.js +1 -1
- package/traits/focus-restore.js +135 -0
- package/traits/focus-restore.test.js +202 -0
- package/traits/focus-trap.test.js +1 -1
- package/traits/focusable.test.js +1 -1
- package/traits/glow-focus.js +1 -1
- package/traits/glow-focus.test.js +1 -1
- package/traits/gradient-shift.js +1 -1
- package/traits/gradient-shift.test.js +1 -1
- package/traits/haptic-feedback.test.js +1 -1
- package/traits/hotkey.test.js +1 -1
- package/traits/hoverable.test.js +1 -1
- package/traits/index.js +15 -0
- package/traits/inertia-drag.js +9 -0
- package/traits/inertia-drag.test.js +1 -1
- package/traits/input-mask.js +328 -0
- package/traits/input-mask.test.js +151 -0
- package/traits/intersection-observer.test.js +1 -1
- package/traits/keyboard-nav.test.js +1 -1
- package/traits/keyboard-reorderable.js +254 -0
- package/traits/keyboard-reorderable.test.js +45 -0
- package/traits/layout-animation.js +229 -0
- package/traits/layout-animation.test.js +114 -0
- package/traits/long-press.js +212 -0
- package/traits/long-press.test.js +244 -0
- package/traits/magnetic-hover.js +1 -1
- package/traits/magnetic-hover.test.js +1 -1
- package/traits/noise-texture.js +7 -3
- package/traits/noise-texture.test.js +1 -1
- package/traits/parallax.js +1 -1
- package/traits/parallax.test.js +1 -1
- package/traits/portal.test.js +1 -1
- package/traits/pressable.test.js +1 -1
- package/traits/resettable.js +29 -3
- package/traits/resettable.test.js +34 -1
- package/traits/resizable.test.js +1 -1
- package/traits/resize-observer.test.js +1 -1
- package/traits/ripple.js +1 -1
- package/traits/ripple.test.js +1 -1
- package/traits/roving-tabindex.test.js +1 -1
- package/traits/scale-press.test.js +1 -1
- package/traits/scroll-lock.test.js +1 -1
- package/traits/scroll-progress.js +201 -0
- package/traits/scroll-progress.test.js +182 -0
- package/traits/shimmer-loading.js +1 -1
- package/traits/shimmer-loading.test.js +1 -1
- package/traits/{_smoke.test.js → smoke.test.js} +1 -1
- package/traits/snap-to-grid.test.js +1 -1
- package/traits/sound-feedback.test.js +1 -1
- package/traits/spring-animate.js +8 -3
- package/traits/spring-animate.test.js +1 -1
- package/traits/success-checkmark.js +222 -0
- package/traits/success-checkmark.test.js +120 -0
- package/traits/tilt-hover.js +1 -1
- package/traits/tilt-hover.test.js +1 -1
- package/traits/tossable.js +9 -0
- package/traits/tossable.test.js +1 -1
- package/traits/traits-host.test.js +1 -1
- package/traits/typeahead.test.js +1 -1
- package/traits/typewriter.js +1 -1
- package/traits/typewriter.test.js +1 -1
- package/traits/validation.test.js +1 -1
- package/traits/view-transition.js +140 -0
- package/traits/view-transition.test.js +268 -0
- /package/traits/{_motion.js → motion.js} +0 -0
- /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared particle-stage for confetti + confetti-burst traits.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: a session-long body-level singleton `<div popover="manual">`
|
|
5
|
+
* that the Popover API auto-promotes to the browser's top-layer once
|
|
6
|
+
* `showPopover()` is called. Particles spawn as absolutely-positioned
|
|
7
|
+
* `<span>` children of this stage with viewport-relative coordinates
|
|
8
|
+
* read from each host's `getBoundingClientRect()`. Effect: bursts
|
|
9
|
+
* escape `overflow: hidden` ancestors and render above modals,
|
|
10
|
+
* drawers, and any z-stacking context — same anchoring as a
|
|
11
|
+
* top-layer popover.
|
|
12
|
+
*
|
|
13
|
+
* The stage is created lazily on first call, never removed. Both
|
|
14
|
+
* `confetti` and `confetti-burst` import `getStage()` and share the
|
|
15
|
+
* singleton — there's no per-trait container, no z-index war, and no
|
|
16
|
+
* tear-down race when bursts overlap.
|
|
17
|
+
*
|
|
18
|
+
* Notes:
|
|
19
|
+
* - happy-dom stubs `showPopover` to a no-op (see test-setup.js), so
|
|
20
|
+
* the try/catch only matters in browsers without the Popover API
|
|
21
|
+
* (none of our baseline browsers since 2026; Chromium 125+, Safari
|
|
22
|
+
* 17+, Firefox 125+ all ship it).
|
|
23
|
+
* - Each particle self-removes on `animationend`. The stage is never
|
|
24
|
+
* torn down — it's a session-long shared resource.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const STAGE_ID = 'adia-confetti-stage';
|
|
28
|
+
|
|
29
|
+
let stage = null;
|
|
30
|
+
let stylesInjected = false;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lazily create + return the singleton popover stage. Idempotent —
|
|
34
|
+
* subsequent calls return the cached node (or rehydrate if it was
|
|
35
|
+
* removed externally, e.g. by a hot-reload).
|
|
36
|
+
*/
|
|
37
|
+
export function getStage() {
|
|
38
|
+
if (stage && stage.isConnected) return stage;
|
|
39
|
+
|
|
40
|
+
// Existing instance from a prior session / hot-reload?
|
|
41
|
+
const existing = document.getElementById(STAGE_ID);
|
|
42
|
+
if (existing) {
|
|
43
|
+
stage = existing;
|
|
44
|
+
showSilently(stage);
|
|
45
|
+
ensureStyles();
|
|
46
|
+
return stage;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const el = document.createElement('div');
|
|
50
|
+
el.id = STAGE_ID;
|
|
51
|
+
el.setAttribute('aria-hidden', 'true');
|
|
52
|
+
// popover="manual" — auto-promoted to the top-layer once shown,
|
|
53
|
+
// and immune to light-dismiss (no spurious close on outside click).
|
|
54
|
+
if ('popover' in HTMLElement.prototype) el.setAttribute('popover', 'manual');
|
|
55
|
+
el.style.cssText =
|
|
56
|
+
'position:fixed;inset:0;margin:0;padding:0;border:0;background:transparent;' +
|
|
57
|
+
'pointer-events:none;overflow:visible;z-index:99999;';
|
|
58
|
+
document.body.appendChild(el);
|
|
59
|
+
showSilently(el);
|
|
60
|
+
stage = el;
|
|
61
|
+
ensureStyles();
|
|
62
|
+
return stage;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function showSilently(el) {
|
|
66
|
+
// showPopover throws InvalidStateError if already shown. Guard with
|
|
67
|
+
// try/catch — cheaper than checking matches(':popover-open'), which
|
|
68
|
+
// happy-dom doesn't implement either.
|
|
69
|
+
try { el.showPopover?.(); } catch { /* already shown or unsupported */ }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compute a host's center in viewport coordinates. Returns null if
|
|
74
|
+
* the host is detached (rect = 0×0 at 0,0 — bursts on a detached
|
|
75
|
+
* host would render at the viewport corner, so callers should bail).
|
|
76
|
+
*/
|
|
77
|
+
export function viewportCenterOf(host) {
|
|
78
|
+
const r = host.getBoundingClientRect();
|
|
79
|
+
// Detached / display:none host — width+height both 0 with all-zero
|
|
80
|
+
// coords. Treat as "no anchor" and let the trait fall back.
|
|
81
|
+
if (r.width === 0 && r.height === 0 && r.left === 0 && r.top === 0) return null;
|
|
82
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Spawn a single particle <span> into the stage. Caller passes the
|
|
87
|
+
* starting viewport coords (from `viewportCenterOf(host)` plus any
|
|
88
|
+
* per-particle jitter the physics layer adds) and the visual props.
|
|
89
|
+
*
|
|
90
|
+
* The returned element self-removes on `animationend`. Caller is
|
|
91
|
+
* responsible for triggering motion (CSS transition, Web Animations
|
|
92
|
+
* API keyframes, or an inline `animation: …` rule).
|
|
93
|
+
*/
|
|
94
|
+
export function spawnParticle(stage, x, y, { size = 6, color = '#f44' } = {}) {
|
|
95
|
+
const p = document.createElement('span');
|
|
96
|
+
p.style.cssText =
|
|
97
|
+
'position:absolute;' +
|
|
98
|
+
`left:${x}px;top:${y}px;` +
|
|
99
|
+
`width:${size}px;height:${size}px;background:${color};` +
|
|
100
|
+
'border-radius:1px;pointer-events:none;will-change:transform,opacity;';
|
|
101
|
+
stage.appendChild(p);
|
|
102
|
+
p.addEventListener('animationend', () => p.remove(), { once: true });
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Inject the @keyframes blocks the particles animate on. One-time,
|
|
108
|
+
* idempotent — guarded by a module flag. The keyframes drift each
|
|
109
|
+
* particle along a random vector; per-particle randomness is set via
|
|
110
|
+
* inline CSS custom properties (`--dx`, `--dy`, `--rot`).
|
|
111
|
+
*/
|
|
112
|
+
function ensureStyles() {
|
|
113
|
+
if (stylesInjected) return;
|
|
114
|
+
if (typeof document === 'undefined') return;
|
|
115
|
+
const styleEl = document.createElement('style');
|
|
116
|
+
styleEl.id = 'adia-confetti-stage-styles';
|
|
117
|
+
styleEl.textContent = `
|
|
118
|
+
@keyframes adia-confetti-fountain {
|
|
119
|
+
0% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
|
|
120
|
+
100% { transform: translate(var(--dx, 0), var(--dy, 0)) rotate(var(--rot, 0deg)); opacity: 0; }
|
|
121
|
+
}
|
|
122
|
+
@keyframes adia-confetti-fall {
|
|
123
|
+
0% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
|
|
124
|
+
100% { transform: translate(var(--dx, 0), var(--dy, 0)) rotate(var(--rot, 0deg)); opacity: 0; }
|
|
125
|
+
}
|
|
126
|
+
`;
|
|
127
|
+
document.head?.appendChild(styleEl);
|
|
128
|
+
stylesInjected = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const SHARED_COLORS = ['#f44', '#4a4', '#44f', '#ff4', '#f4f', '#4ff'];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Test-only: tear down the stage so test suites don't leak particles
|
|
135
|
+
* across cases. Not part of the public surface.
|
|
136
|
+
*/
|
|
137
|
+
export function _resetStage() {
|
|
138
|
+
if (stage && stage.parentNode) stage.remove();
|
|
139
|
+
const styleEl = document.getElementById('adia-confetti-stage-styles');
|
|
140
|
+
if (styleEl) styleEl.remove();
|
|
141
|
+
stage = null;
|
|
142
|
+
stylesInjected = false;
|
|
143
|
+
}
|
package/traits/confetti.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { defineTrait } from './define.js';
|
|
2
|
-
import { prefersReducedMotion } from './
|
|
2
|
+
import { prefersReducedMotion } from './motion.js';
|
|
3
|
+
import { getStage, viewportCenterOf, spawnParticle, SHARED_COLORS } from './confetti-stage.js';
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* `confetti` — falling-shower particle burst on connect.
|
|
7
|
+
*
|
|
8
|
+
* Particles are pushed into the singleton body-level
|
|
9
|
+
* `<div popover="manual" id="adia-confetti-stage">`, anchored to the
|
|
10
|
+
* host's viewport position at fire-time. The Popover API auto-promotes
|
|
11
|
+
* the stage to the browser's top-layer, so the shower escapes
|
|
12
|
+
* `overflow: hidden` ancestors and renders above modals, drawers, and
|
|
13
|
+
* any z-stacking context.
|
|
14
|
+
*
|
|
15
|
+
* Anchor model: viewport-fixed at fire-time. If the user scrolls
|
|
16
|
+
* during the 1.5s shower, the particles continue from their original
|
|
17
|
+
* viewport position — same UX as standard web-confetti libraries.
|
|
18
|
+
*/
|
|
4
19
|
export const confetti = defineTrait({
|
|
5
20
|
name: 'confetti',
|
|
6
21
|
category: 'interaction-delight',
|
|
@@ -15,61 +30,43 @@ export const confetti = defineTrait({
|
|
|
15
30
|
return () => host.removeAttribute('data-confetti-active');
|
|
16
31
|
}
|
|
17
32
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
host.style.position = host.style.position || 'relative';
|
|
21
|
-
host.appendChild(canvas);
|
|
33
|
+
const stage = getStage();
|
|
34
|
+
const center = viewportCenterOf(host);
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
let rafId = null;
|
|
25
|
-
let running = true;
|
|
36
|
+
host.setAttribute('data-confetti-active', '');
|
|
26
37
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
// Fire one shower of ~60 particles. They fall from a band centered
|
|
39
|
+
// on the host's top edge, drift sideways under wind, and fade out.
|
|
40
|
+
if (center) {
|
|
41
|
+
const rect = host.getBoundingClientRect();
|
|
42
|
+
const halfW = Math.max(rect.width, 80) / 2;
|
|
43
|
+
// Band starts a hair above the host so the first frame lands
|
|
44
|
+
// visibly inside the host's bounds, then drifts down.
|
|
45
|
+
const startY = center.y - rect.height / 2 - 8;
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
for (let i = 0; i < 60; i++) {
|
|
48
|
+
const x = center.x + (Math.random() - 0.5) * halfW * 2;
|
|
49
|
+
const y = startY + Math.random() * 12;
|
|
50
|
+
const dx = (Math.random() - 0.5) * 80;
|
|
51
|
+
const dy = 200 + Math.random() * 240;
|
|
52
|
+
const rot = (Math.random() - 0.5) * 540;
|
|
53
|
+
const size = Math.random() * 4 + 2;
|
|
54
|
+
const color = SHARED_COLORS[Math.floor(Math.random() * SHARED_COLORS.length)];
|
|
55
|
+
const delay = Math.random() * 600;
|
|
56
|
+
const dur = 900 + Math.random() * 700;
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
function tick() {
|
|
52
|
-
if (!running) return;
|
|
53
|
-
resize();
|
|
54
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
55
|
-
for (const p of particles) {
|
|
56
|
-
p.x += p.vx * 0.002;
|
|
57
|
-
p.y += p.vy * 0.003;
|
|
58
|
-
if (p.y > 1.1) { p.y = -0.1; p.x = Math.random(); }
|
|
59
|
-
ctx.fillStyle = p.color;
|
|
60
|
-
ctx.fillRect(p.x * canvas.width, p.y * canvas.height, p.size, p.size);
|
|
58
|
+
const p = spawnParticle(stage, x, y, { size, color });
|
|
59
|
+
p.style.setProperty('--dx', `${dx}px`);
|
|
60
|
+
p.style.setProperty('--dy', `${dy}px`);
|
|
61
|
+
p.style.setProperty('--rot', `${rot}deg`);
|
|
62
|
+
p.style.animation = `adia-confetti-fall ${dur}ms ${delay}ms ease-in forwards`;
|
|
61
63
|
}
|
|
62
|
-
rafId = requestAnimationFrame(tick);
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
host.setAttribute('data-confetti-active', '');
|
|
66
|
-
tick();
|
|
67
|
-
|
|
68
66
|
return () => {
|
|
69
|
-
running = false;
|
|
70
|
-
if (rafId) cancelAnimationFrame(rafId);
|
|
71
|
-
canvas.remove();
|
|
72
67
|
host.removeAttribute('data-confetti-active');
|
|
68
|
+
// In-flight particles self-remove on animationend; nothing to
|
|
69
|
+
// tear down on the stage (it's a session-long singleton).
|
|
73
70
|
};
|
|
74
71
|
},
|
|
75
72
|
});
|
package/traits/confetti.test.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { confetti } from './confetti.js';
|
|
3
|
-
import {
|
|
3
|
+
import { _resetStage } from './confetti-stage.js';
|
|
4
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
5
|
|
|
5
6
|
describe('confetti', () => {
|
|
6
|
-
beforeEach(
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
resetDOM();
|
|
9
|
+
_resetStage();
|
|
10
|
+
});
|
|
7
11
|
|
|
8
12
|
it('connect sets data-confetti-active without throwing', () => {
|
|
9
13
|
const host = mountHost();
|
|
@@ -18,10 +22,25 @@ describe('confetti', () => {
|
|
|
18
22
|
expect(host.hasAttribute('data-confetti-active')).toBe(false);
|
|
19
23
|
});
|
|
20
24
|
|
|
21
|
-
it('
|
|
22
|
-
//
|
|
23
|
-
//
|
|
25
|
+
it('no canvas: graceful degradation in happy-dom', () => {
|
|
26
|
+
// Old contract: this trait used a host-scoped <canvas>. New
|
|
27
|
+
// contract: it pushes <span> particles into the body-level
|
|
28
|
+
// popover stage (no canvas anywhere). The test name is kept for
|
|
29
|
+
// continuity — the assertion is the same: connect must not throw
|
|
30
|
+
// in environments without real layout (rect = 0,0,0,0 in
|
|
31
|
+
// happy-dom; viewportCenterOf returns null and the trait skips
|
|
32
|
+
// particle spawning gracefully).
|
|
24
33
|
const host = mountHost();
|
|
25
34
|
expect(() => connectTrait(confetti, host)).not.toThrow();
|
|
26
35
|
});
|
|
36
|
+
|
|
37
|
+
it('does not append particles inside the host (stage is body-level)', () => {
|
|
38
|
+
const host = mountHost();
|
|
39
|
+
const inst = connectTrait(confetti, host);
|
|
40
|
+
// The old implementation appended a <canvas> child to the host.
|
|
41
|
+
// The new implementation must leave the host's children alone —
|
|
42
|
+
// every particle belongs to the body-level popover stage.
|
|
43
|
+
expect(host.children.length).toBe(0);
|
|
44
|
+
inst.disconnect(host);
|
|
45
|
+
});
|
|
27
46
|
});
|
package/traits/count-up.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineTrait } from './define.js';
|
|
2
|
-
import { prefersReducedMotion } from './
|
|
2
|
+
import { prefersReducedMotion } from './motion.js';
|
|
3
3
|
|
|
4
4
|
export const countUp = defineTrait({
|
|
5
5
|
name: 'count-up',
|
|
@@ -7,14 +7,34 @@ export const countUp = defineTrait({
|
|
|
7
7
|
description: 'Animated numeric transitions',
|
|
8
8
|
attributes: ['data-count-up-active'],
|
|
9
9
|
events: ['count-up-done'],
|
|
10
|
-
config: [
|
|
10
|
+
config: [
|
|
11
|
+
'data-count-up-target',
|
|
12
|
+
'data-count-duration',
|
|
13
|
+
'data-count-prefix',
|
|
14
|
+
'data-count-suffix',
|
|
15
|
+
'data-count-decimals',
|
|
16
|
+
'data-count-locale',
|
|
17
|
+
],
|
|
11
18
|
setup({ host }) {
|
|
12
|
-
const target
|
|
19
|
+
const target = parseFloat(host.getAttribute('data-count-up-target')) || 0;
|
|
13
20
|
const duration = parseInt(host.getAttribute('data-count-duration'), 10) || 1000;
|
|
21
|
+
const prefix = host.getAttribute('data-count-prefix') ?? '';
|
|
22
|
+
const suffix = host.getAttribute('data-count-suffix') ?? '';
|
|
23
|
+
const decimals = Math.max(0, parseInt(host.getAttribute('data-count-decimals'), 10) || 0);
|
|
24
|
+
const locale = host.getAttribute('data-count-locale') || undefined;
|
|
25
|
+
|
|
26
|
+
// Locale-aware separators ("2,438,000" not "2438000"). The decimals attr
|
|
27
|
+
// controls the fraction-digit count for currency / percentage cases
|
|
28
|
+
// ($2,438,000.00 with decimals=2; 12.5% with decimals=1).
|
|
29
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
30
|
+
minimumFractionDigits: decimals,
|
|
31
|
+
maximumFractionDigits: decimals,
|
|
32
|
+
});
|
|
33
|
+
const fmt = (value) => `${prefix}${formatter.format(value)}${suffix}`;
|
|
14
34
|
|
|
15
35
|
// Reduced-motion: jump straight to target and fire done event.
|
|
16
36
|
if (prefersReducedMotion()) {
|
|
17
|
-
host.textContent = target;
|
|
37
|
+
host.textContent = fmt(target);
|
|
18
38
|
host.setAttribute('data-count-up-active', '');
|
|
19
39
|
queueMicrotask(() => {
|
|
20
40
|
host.removeAttribute('data-count-up-active');
|
|
@@ -38,12 +58,17 @@ export const countUp = defineTrait({
|
|
|
38
58
|
const elapsed = now - start;
|
|
39
59
|
const progress = Math.min(elapsed / duration, 1);
|
|
40
60
|
const value = target * easeOutQuart(progress);
|
|
41
|
-
|
|
61
|
+
// Round to the configured decimal precision so the displayed value
|
|
62
|
+
// doesn't flicker between fraction-bearing intermediates.
|
|
63
|
+
const rounded = decimals > 0
|
|
64
|
+
? Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals)
|
|
65
|
+
: Math.round(value);
|
|
66
|
+
host.textContent = fmt(rounded);
|
|
42
67
|
|
|
43
68
|
if (progress < 1) {
|
|
44
69
|
rafId = requestAnimationFrame(tick);
|
|
45
70
|
} else {
|
|
46
|
-
host.textContent = target;
|
|
71
|
+
host.textContent = fmt(target);
|
|
47
72
|
host.removeAttribute('data-count-up-active');
|
|
48
73
|
host.dispatchEvent(new CustomEvent('count-up-done', { bubbles: true }));
|
|
49
74
|
}
|
package/traits/count-up.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { countUp } from './count-up.js';
|
|
3
|
-
import { mountHost, connectTrait, spyEvent, resetDOM, wait, raf } from './
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM, wait, raf } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('count-up', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
|
@@ -16,7 +16,7 @@ import { UIElement } from '../core/element.js';
|
|
|
16
16
|
import { pressable } from './pressable.js';
|
|
17
17
|
import { focusable } from './focusable.js';
|
|
18
18
|
import { hoverable } from './hoverable.js';
|
|
19
|
-
import { resetDOM } from './
|
|
19
|
+
import { resetDOM } from './test-helpers.js';
|
|
20
20
|
|
|
21
21
|
let counter = 0;
|
|
22
22
|
function defineUniqueTag(BaseImpl) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { dirtyState } from './dirty-state.js';
|
|
3
|
-
import { mountHost, connectTrait, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('dirty-state', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
package/traits/drag-ghost.js
CHANGED
|
@@ -9,16 +9,66 @@ export const dragGhost = defineTrait({
|
|
|
9
9
|
config: [],
|
|
10
10
|
setup({ host }) {
|
|
11
11
|
let ghost = null;
|
|
12
|
+
let clickOffsetX = 0;
|
|
13
|
+
let clickOffsetY = 0;
|
|
14
|
+
|
|
15
|
+
function onPointerDown(e) {
|
|
16
|
+
// Capture click offset against host's visual box (post-transform).
|
|
17
|
+
const rect = host.getBoundingClientRect();
|
|
18
|
+
clickOffsetX = e.clientX - rect.left;
|
|
19
|
+
clickOffsetY = e.clientY - rect.top;
|
|
20
|
+
}
|
|
12
21
|
|
|
13
22
|
function onDragStart(e) {
|
|
23
|
+
// Snapshot is taken synchronously at setDragImage() time. We position
|
|
24
|
+
// the ghost over the host's exact viewport rect so the bitmap is
|
|
25
|
+
// pixel-identical to what the user clicked — no CSS-scope drift, no
|
|
26
|
+
// box-sizing surprises, no transform mismatch. The clone is removed
|
|
27
|
+
// before the browser paints its next frame (drag image is the
|
|
28
|
+
// browser's own translucent overlay on the cursor), so the user
|
|
29
|
+
// never sees a doubled element.
|
|
30
|
+
//
|
|
31
|
+
// The data-drag-ghost-active attribute is set AFTER setDragImage(),
|
|
32
|
+
// so the ghost snapshot doesn't pick up the dragged-state styling
|
|
33
|
+
// (e.g. opacity: 0.4 on the original).
|
|
34
|
+
const rect = host.getBoundingClientRect();
|
|
35
|
+
const cs = getComputedStyle(host);
|
|
36
|
+
|
|
14
37
|
ghost = host.cloneNode(true);
|
|
38
|
+
// box-sizing: border-box makes width == rect.width regardless of
|
|
39
|
+
// whether the host was content-box; rect.width/.height already are
|
|
40
|
+
// the host's border-box dimensions (getBoundingClientRect post-
|
|
41
|
+
// transform visual box). margin: 0 prevents collapse-into-body
|
|
42
|
+
// margins from offsetting the ghost.
|
|
15
43
|
ghost.style.cssText = `
|
|
16
|
-
position: fixed;
|
|
17
|
-
|
|
44
|
+
position: fixed;
|
|
45
|
+
left: ${rect.left}px; top: ${rect.top}px;
|
|
46
|
+
width: ${rect.width}px; height: ${rect.height}px;
|
|
47
|
+
box-sizing: border-box;
|
|
48
|
+
margin: 0;
|
|
49
|
+
pointer-events: none;
|
|
50
|
+
opacity: 0.8;
|
|
51
|
+
z-index: 99999;
|
|
18
52
|
`;
|
|
53
|
+
// Preserve the host's transform so a scaled/rotated host produces a
|
|
54
|
+
// matching ghost bitmap.
|
|
55
|
+
if (cs.transform && cs.transform !== 'none') {
|
|
56
|
+
ghost.style.transform = cs.transform;
|
|
57
|
+
ghost.style.transformOrigin = cs.transformOrigin;
|
|
58
|
+
}
|
|
19
59
|
document.body.appendChild(ghost);
|
|
20
|
-
e.dataTransfer?.setDragImage(ghost,
|
|
60
|
+
e.dataTransfer?.setDragImage(ghost, clickOffsetX, clickOffsetY);
|
|
21
61
|
host.setAttribute('data-drag-ghost-active', '');
|
|
62
|
+
// Hide the ghost in the next microtask — setDragImage already took
|
|
63
|
+
// its snapshot synchronously. We hide rather than remove so any
|
|
64
|
+
// browser that re-reads the element during the drag cycle (rare,
|
|
65
|
+
// but Safari has historically done this on drop) still finds it.
|
|
66
|
+
// We hide via visibility (paint suppressed) rather than
|
|
67
|
+
// display:none (which would zero the bitmap if the browser ever
|
|
68
|
+
// re-snapshots).
|
|
69
|
+
queueMicrotask(() => {
|
|
70
|
+
if (ghost) ghost.style.visibility = 'hidden';
|
|
71
|
+
});
|
|
22
72
|
}
|
|
23
73
|
|
|
24
74
|
function onDragEnd() {
|
|
@@ -27,10 +77,12 @@ export const dragGhost = defineTrait({
|
|
|
27
77
|
}
|
|
28
78
|
|
|
29
79
|
host.setAttribute('draggable', 'true');
|
|
80
|
+
host.addEventListener('pointerdown', onPointerDown);
|
|
30
81
|
host.addEventListener('dragstart', onDragStart);
|
|
31
82
|
host.addEventListener('dragend', onDragEnd);
|
|
32
83
|
|
|
33
84
|
return () => {
|
|
85
|
+
host.removeEventListener('pointerdown', onPointerDown);
|
|
34
86
|
host.removeEventListener('dragstart', onDragStart);
|
|
35
87
|
host.removeEventListener('dragend', onDragEnd);
|
|
36
88
|
if (ghost) { ghost.remove(); ghost = null; }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { dragGhost } from './drag-ghost.js';
|
|
3
|
-
import { mountHost, connectTrait, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('drag-ghost', () => {
|
|
6
6
|
beforeEach(resetDOM);
|