@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,222 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
import { prefersReducedMotion } from './motion.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `success-checkmark` — SVG path stroke-draw animation. "I confirmed it
|
|
6
|
+
* worked." Pairs with the `validation` trait's success branch and any
|
|
7
|
+
* imperative success milestone (form-saved, payment-confirmed,
|
|
8
|
+
* email-sent).
|
|
9
|
+
*
|
|
10
|
+
* Listens for `validated` with `detail.valid === true`, watches the
|
|
11
|
+
* `data-success-checkmark-trigger` attribute for toggles, and
|
|
12
|
+
* declares a `data-success-checkmark-position` config attribute
|
|
13
|
+
* (`top-right` default, or `center`) to anchor the SVG overlay.
|
|
14
|
+
*
|
|
15
|
+
* On trigger:
|
|
16
|
+
* 1. Stamp an absolutely-positioned SVG checkmark on the host.
|
|
17
|
+
* 2. Animate stroke-dashoffset from full path length → 0 over ~400ms.
|
|
18
|
+
* 3. After ~1.5s total, fade opacity 1 → 0 over 300ms.
|
|
19
|
+
* 4. Remove the SVG, dispatch `success-checkmark-done`.
|
|
20
|
+
*
|
|
21
|
+
* Reduced-motion: render the checkmark instantly (no stroke-draw),
|
|
22
|
+
* still fade out cleanly. The done event fires either way so caller
|
|
23
|
+
* logic gated on it doesn't stall.
|
|
24
|
+
*
|
|
25
|
+
* Composes naturally with `validation`:
|
|
26
|
+
* <input-ui traits="validation success-checkmark" data-validate="email">
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const STROKE_DRAW_MS = 400;
|
|
30
|
+
const VISIBLE_HOLD_MS = 1500; // total visible time before fade starts
|
|
31
|
+
const FADE_OUT_MS = 300;
|
|
32
|
+
const PATH_LENGTH = 36; // approximate path length for the checkmark below
|
|
33
|
+
|
|
34
|
+
// Style ID — checkmark keyframes are shared across all instances since
|
|
35
|
+
// they don't carry per-host config. Mounted once into <head>, never removed
|
|
36
|
+
// (cheap, dedupe by id).
|
|
37
|
+
const SHARED_STYLE_ID = 'adia-success-checkmark-keyframes';
|
|
38
|
+
|
|
39
|
+
function ensureSharedStyles() {
|
|
40
|
+
if (typeof document === 'undefined') return;
|
|
41
|
+
if (document.getElementById(SHARED_STYLE_ID)) return;
|
|
42
|
+
const style = document.createElement('style');
|
|
43
|
+
style.id = SHARED_STYLE_ID;
|
|
44
|
+
style.textContent = `
|
|
45
|
+
@keyframes adia-success-checkmark-draw {
|
|
46
|
+
from { stroke-dashoffset: ${PATH_LENGTH}; }
|
|
47
|
+
to { stroke-dashoffset: 0; }
|
|
48
|
+
}
|
|
49
|
+
@keyframes adia-success-checkmark-fade {
|
|
50
|
+
from { opacity: 1; }
|
|
51
|
+
to { opacity: 0; }
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
document.head.appendChild(style);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildSvg(position, reduced, hostRect) {
|
|
58
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
59
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
60
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
61
|
+
svg.setAttribute('width', '24');
|
|
62
|
+
svg.setAttribute('height', '24');
|
|
63
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
64
|
+
|
|
65
|
+
// Render in viewport coordinates against the body so `overflow: hidden`
|
|
66
|
+
// ancestors (cards, buttons, drawers) can't clip the checkmark. The
|
|
67
|
+
// anchor is the host's getBoundingClientRect() at draw time — for the
|
|
68
|
+
// ~1.8s animation window the host is unlikely to move enough to matter.
|
|
69
|
+
let top, left;
|
|
70
|
+
if (position === 'center') {
|
|
71
|
+
top = hostRect.top + hostRect.height / 2 - 12;
|
|
72
|
+
left = hostRect.left + hostRect.width / 2 - 12;
|
|
73
|
+
} else {
|
|
74
|
+
// top-right (default) — protrude beyond the corner by ~8px so the
|
|
75
|
+
// checkmark reads as an applied stamp, not a content element.
|
|
76
|
+
top = hostRect.top - 8;
|
|
77
|
+
left = hostRect.right - 16;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
svg.style.cssText = `
|
|
81
|
+
position: fixed;
|
|
82
|
+
top: ${top}px;
|
|
83
|
+
left: ${left}px;
|
|
84
|
+
pointer-events: none;
|
|
85
|
+
color: var(--a-success, #22c55e);
|
|
86
|
+
z-index: 99999;
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
// Optional disc background — gives the checkmark a visible bedding
|
|
90
|
+
// when stamped on photographic or low-contrast hosts.
|
|
91
|
+
const circle = document.createElementNS(SVG_NS, 'circle');
|
|
92
|
+
circle.setAttribute('cx', '12');
|
|
93
|
+
circle.setAttribute('cy', '12');
|
|
94
|
+
circle.setAttribute('r', '11');
|
|
95
|
+
circle.setAttribute('fill', 'currentColor');
|
|
96
|
+
svg.appendChild(circle);
|
|
97
|
+
|
|
98
|
+
const path = document.createElementNS(SVG_NS, 'path');
|
|
99
|
+
// ~36 unit path: M6 12 L10 16 L18 8.
|
|
100
|
+
path.setAttribute('d', 'M6 12 L10 16 L18 8');
|
|
101
|
+
path.setAttribute('fill', 'none');
|
|
102
|
+
path.setAttribute('stroke', 'var(--a-success-fg, white)');
|
|
103
|
+
path.setAttribute('stroke-width', '2.5');
|
|
104
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
105
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
106
|
+
path.setAttribute('stroke-dasharray', String(PATH_LENGTH));
|
|
107
|
+
|
|
108
|
+
if (reduced) {
|
|
109
|
+
// Render the checkmark instantly — no stroke-draw — but still let
|
|
110
|
+
// the fade-out keyframe run so the surface tears down gracefully.
|
|
111
|
+
path.setAttribute('stroke-dashoffset', '0');
|
|
112
|
+
} else {
|
|
113
|
+
path.setAttribute('stroke-dashoffset', String(PATH_LENGTH));
|
|
114
|
+
path.style.animation = `adia-success-checkmark-draw ${STROKE_DRAW_MS}ms ease-out forwards`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
svg.appendChild(path);
|
|
118
|
+
return svg;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const successCheckmark = defineTrait({
|
|
122
|
+
name: 'success-checkmark',
|
|
123
|
+
category: 'animation-feedback',
|
|
124
|
+
description: 'Stroke-draw checkmark on validation success',
|
|
125
|
+
attributes: ['data-success-checkmark-active'],
|
|
126
|
+
events: ['success-checkmark-done'],
|
|
127
|
+
config: ['data-success-checkmark-position', 'data-success-checkmark-trigger'],
|
|
128
|
+
setup({ host }) {
|
|
129
|
+
const activeTimers = new Set();
|
|
130
|
+
const liveSvgs = new Set();
|
|
131
|
+
let lastValid = host.hasAttribute('data-validation-valid');
|
|
132
|
+
|
|
133
|
+
function readPosition() {
|
|
134
|
+
const v = host.getAttribute('data-success-checkmark-position');
|
|
135
|
+
return v === 'center' ? 'center' : 'top-right';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function fireDone() {
|
|
139
|
+
// Only clear active when no in-flight checkmark remains.
|
|
140
|
+
if (liveSvgs.size === 0) host.removeAttribute('data-success-checkmark-active');
|
|
141
|
+
host.dispatchEvent(new CustomEvent('success-checkmark-done', { bubbles: true }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function teardownSvg(svg) {
|
|
145
|
+
liveSvgs.delete(svg);
|
|
146
|
+
svg.remove();
|
|
147
|
+
fireDone();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function draw() {
|
|
151
|
+
ensureSharedStyles();
|
|
152
|
+
|
|
153
|
+
const reduced = prefersReducedMotion();
|
|
154
|
+
const position = readPosition();
|
|
155
|
+
|
|
156
|
+
// Anchor against host's viewport rect at trigger time. Detached
|
|
157
|
+
// or happy-dom hosts return zero rects, which renders the SVG at
|
|
158
|
+
// the viewport origin — fine for tests, fine in real browsers
|
|
159
|
+
// because real hosts have real rects.
|
|
160
|
+
const hostRect = host.getBoundingClientRect?.() ||
|
|
161
|
+
{ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
|
162
|
+
|
|
163
|
+
const svg = buildSvg(position, reduced, hostRect);
|
|
164
|
+
|
|
165
|
+
// Append at body level so overflow:hidden ancestors can't clip.
|
|
166
|
+
document.body.appendChild(svg);
|
|
167
|
+
liveSvgs.add(svg);
|
|
168
|
+
host.setAttribute('data-success-checkmark-active', '');
|
|
169
|
+
|
|
170
|
+
// After the visible-hold window, kick off the fade-out. Use a
|
|
171
|
+
// single timer rather than chaining animationend so detached or
|
|
172
|
+
// early-removed SVGs don't strand listeners.
|
|
173
|
+
const fadeStart = setTimeout(() => {
|
|
174
|
+
activeTimers.delete(fadeStart);
|
|
175
|
+
if (!liveSvgs.has(svg)) return;
|
|
176
|
+
svg.style.animation = `adia-success-checkmark-fade ${FADE_OUT_MS}ms ease-out forwards`;
|
|
177
|
+
|
|
178
|
+
const fadeEnd = setTimeout(() => {
|
|
179
|
+
activeTimers.delete(fadeEnd);
|
|
180
|
+
if (liveSvgs.has(svg)) teardownSvg(svg);
|
|
181
|
+
}, FADE_OUT_MS);
|
|
182
|
+
activeTimers.add(fadeEnd);
|
|
183
|
+
}, VISIBLE_HOLD_MS);
|
|
184
|
+
activeTimers.add(fadeStart);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onValidated(e) {
|
|
188
|
+
if (e?.detail && e.detail.valid === true) draw();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Watch data-validation-valid (false → true) + the manual trigger.
|
|
192
|
+
const observer = new MutationObserver((muts) => {
|
|
193
|
+
for (const m of muts) {
|
|
194
|
+
if (m.attributeName === 'data-validation-valid') {
|
|
195
|
+
const nowValid = host.hasAttribute('data-validation-valid');
|
|
196
|
+
if (nowValid && !lastValid) draw();
|
|
197
|
+
lastValid = nowValid;
|
|
198
|
+
} else if (m.attributeName === 'data-success-checkmark-trigger') {
|
|
199
|
+
if (host.hasAttribute('data-success-checkmark-trigger')) draw();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
observer.observe(host, {
|
|
204
|
+
attributes: true,
|
|
205
|
+
attributeFilter: ['data-validation-valid', 'data-success-checkmark-trigger'],
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
host.addEventListener('validated', onValidated);
|
|
209
|
+
|
|
210
|
+
return () => {
|
|
211
|
+
observer.disconnect();
|
|
212
|
+
host.removeEventListener('validated', onValidated);
|
|
213
|
+
for (const t of activeTimers) clearTimeout(t);
|
|
214
|
+
activeTimers.clear();
|
|
215
|
+
// SVGs live at body level (not host), so removing them is the
|
|
216
|
+
// only cleanup needed — no host.style.position to restore.
|
|
217
|
+
for (const svg of liveSvgs) svg.remove();
|
|
218
|
+
liveSvgs.clear();
|
|
219
|
+
host.removeAttribute('data-success-checkmark-active');
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { successCheckmark } from './success-checkmark.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
// Helper: query the SVG that the trait stamps. As of the overflow:hidden
|
|
6
|
+
// fix, the SVG renders at body level (position: fixed) so it can't be
|
|
7
|
+
// clipped by the host's ancestors — query document.body, not the host.
|
|
8
|
+
const findSvg = () => document.body.querySelector('svg');
|
|
9
|
+
|
|
10
|
+
describe('success-checkmark', () => {
|
|
11
|
+
beforeEach(resetDOM);
|
|
12
|
+
|
|
13
|
+
it('schema declares animation-feedback category', () => {
|
|
14
|
+
expect(successCheckmark.schema.category).toBe('animation-feedback');
|
|
15
|
+
expect(successCheckmark.schema.events).toContain('success-checkmark-done');
|
|
16
|
+
expect(successCheckmark.schema.attributes).toContain('data-success-checkmark-active');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('connect → disconnect leaves no managed attribute', () => {
|
|
20
|
+
const host = mountHost();
|
|
21
|
+
const inst = connectTrait(successCheckmark, host);
|
|
22
|
+
inst.disconnect(host);
|
|
23
|
+
expect(host.hasAttribute('data-success-checkmark-active')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('stamps an SVG on validated event with detail.valid === true', () => {
|
|
27
|
+
const host = mountHost();
|
|
28
|
+
connectTrait(successCheckmark, host);
|
|
29
|
+
|
|
30
|
+
host.dispatchEvent(new CustomEvent('validated', {
|
|
31
|
+
detail: { valid: true, errors: [] },
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
expect(host.hasAttribute('data-success-checkmark-active')).toBe(true);
|
|
35
|
+
const svg = findSvg();
|
|
36
|
+
expect(svg).not.toBeNull();
|
|
37
|
+
expect(svg.querySelector('path')).not.toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not stamp on validated event with detail.valid === false', () => {
|
|
41
|
+
const host = mountHost();
|
|
42
|
+
connectTrait(successCheckmark, host);
|
|
43
|
+
|
|
44
|
+
host.dispatchEvent(new CustomEvent('validated', {
|
|
45
|
+
detail: { valid: false, errors: ['nope'] },
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
expect(host.hasAttribute('data-success-checkmark-active')).toBe(false);
|
|
49
|
+
expect(findSvg()).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('fires on data-validation-valid (false → true)', async () => {
|
|
53
|
+
const host = mountHost();
|
|
54
|
+
connectTrait(successCheckmark, host);
|
|
55
|
+
|
|
56
|
+
host.setAttribute('data-validation-valid', '');
|
|
57
|
+
await wait(0);
|
|
58
|
+
expect(host.hasAttribute('data-success-checkmark-active')).toBe(true);
|
|
59
|
+
expect(findSvg()).not.toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('respects data-success-checkmark-trigger attribute toggle', async () => {
|
|
63
|
+
const host = mountHost();
|
|
64
|
+
connectTrait(successCheckmark, host);
|
|
65
|
+
|
|
66
|
+
host.setAttribute('data-success-checkmark-trigger', '');
|
|
67
|
+
await wait(0);
|
|
68
|
+
expect(findSvg()).not.toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('renders position: fixed at viewport coordinates (no longer clipped by host overflow)', () => {
|
|
72
|
+
const host = mountHost();
|
|
73
|
+
connectTrait(successCheckmark, host);
|
|
74
|
+
|
|
75
|
+
host.dispatchEvent(new CustomEvent('validated', {
|
|
76
|
+
detail: { valid: true, errors: [] },
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
const svg = findSvg();
|
|
80
|
+
expect(svg).not.toBeNull();
|
|
81
|
+
// Body-level rendering with position: fixed escapes any
|
|
82
|
+
// overflow:hidden ancestor that the host might be inside.
|
|
83
|
+
expect(svg.style.position).toBe('fixed');
|
|
84
|
+
expect(svg.parentElement).toBe(document.body);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('removes the SVG and fires success-checkmark-done after the visible+fade window', async () => {
|
|
88
|
+
const host = mountHost();
|
|
89
|
+
connectTrait(successCheckmark, host);
|
|
90
|
+
const spy = spyEvent(host, 'success-checkmark-done');
|
|
91
|
+
|
|
92
|
+
host.dispatchEvent(new CustomEvent('validated', { detail: { valid: true } }));
|
|
93
|
+
expect(findSvg()).not.toBeNull();
|
|
94
|
+
|
|
95
|
+
// 1500ms hold + 300ms fade = ~1800ms; give a small grace window.
|
|
96
|
+
await wait(1900);
|
|
97
|
+
expect(findSvg()).toBeNull();
|
|
98
|
+
expect(spy.count).toBe(1);
|
|
99
|
+
expect(host.hasAttribute('data-success-checkmark-active')).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('disconnect removes any in-flight SVG', () => {
|
|
103
|
+
const host = mountHost();
|
|
104
|
+
const inst = connectTrait(successCheckmark, host);
|
|
105
|
+
host.dispatchEvent(new CustomEvent('validated', { detail: { valid: true } }));
|
|
106
|
+
expect(findSvg()).not.toBeNull();
|
|
107
|
+
|
|
108
|
+
inst.disconnect(host);
|
|
109
|
+
expect(findSvg()).toBeNull();
|
|
110
|
+
expect(host.hasAttribute('data-success-checkmark-active')).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not mutate host.style.position (body-level rendering means the host needs no positioning context)', () => {
|
|
114
|
+
const host = mountHost();
|
|
115
|
+
const before = host.style.position;
|
|
116
|
+
connectTrait(successCheckmark, host);
|
|
117
|
+
host.dispatchEvent(new CustomEvent('validated', { detail: { valid: true } }));
|
|
118
|
+
expect(host.style.position).toBe(before);
|
|
119
|
+
});
|
|
120
|
+
});
|
package/traits/tilt-hover.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { tiltHover } from './tilt-hover.js';
|
|
3
|
-
import { mountHost, connectTrait, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('tilt-hover', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
package/traits/tossable.js
CHANGED
|
@@ -28,7 +28,16 @@ export const tossable = defineTrait({
|
|
|
28
28
|
let lastClientY = 0;
|
|
29
29
|
|
|
30
30
|
function parseTranslate() {
|
|
31
|
+
// The trait writes to `style.translate` (independent of `transform` in
|
|
32
|
+
// the modern CSS model). Read translate first so subsequent tosses pick
|
|
33
|
+
// up the current position, then fall back to the transform matrix for
|
|
34
|
+
// callers that nudged via `transform`.
|
|
31
35
|
const style = getComputedStyle(host);
|
|
36
|
+
const t = style.translate;
|
|
37
|
+
if (t && t !== 'none') {
|
|
38
|
+
const parts = t.split(/\s+/).map(parseFloat);
|
|
39
|
+
return { x: parts[0] || 0, y: parts[1] || 0 };
|
|
40
|
+
}
|
|
32
41
|
const matrix = new DOMMatrixReadOnly(style.transform);
|
|
33
42
|
return { x: matrix.m41, y: matrix.m42 };
|
|
34
43
|
}
|
package/traits/tossable.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { tossable } from './tossable.js';
|
|
3
|
-
import { mountHost, connectTrait, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
function pointer(host, type, x, y) {
|
|
6
6
|
host.dispatchEvent(new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, bubbles: true }));
|
|
@@ -10,7 +10,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
10
10
|
import './traits-host.js';
|
|
11
11
|
import './pressable.js';
|
|
12
12
|
import './hoverable.js';
|
|
13
|
-
import { resetDOM } from './
|
|
13
|
+
import { resetDOM } from './test-helpers.js';
|
|
14
14
|
|
|
15
15
|
describe('<traits-host>', () => {
|
|
16
16
|
beforeEach(resetDOM);
|
package/traits/typeahead.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { typeahead } from './typeahead.js';
|
|
3
|
-
import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
function child(host, text) {
|
|
6
6
|
const li = document.createElement('div');
|
package/traits/typewriter.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { typewriter } from './typewriter.js';
|
|
3
|
-
import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('typewriter', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { validation } from './validation.js';
|
|
3
|
-
import { mountHost, connectTrait, spyEvent, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
function setValue(host, value) {
|
|
6
6
|
host.value = value;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
import { prefersReducedMotion } from './motion.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `view-transition` — declarative wrapper around the modern
|
|
6
|
+
* View Transitions API (`document.startViewTransition()`).
|
|
7
|
+
*
|
|
8
|
+
* Sets `view-transition-name: <slug>` on the host so the browser tags
|
|
9
|
+
* this element as a participant in any same-document transition. Reads
|
|
10
|
+
* configuration from data attributes — `data-view-transition-name`
|
|
11
|
+
* (the CSS slug), `data-view-transition-duration` (--vt-duration), and
|
|
12
|
+
* `data-view-transition-easing` (--vt-easing). The trait also installs
|
|
13
|
+
* a `host.startTransition(callback)` method so consumers can fire a
|
|
14
|
+
* transition without importing `document.startViewTransition` directly.
|
|
15
|
+
*
|
|
16
|
+
* Browser-support shape (project floor at Chromium 125+, Safari 18.0+,
|
|
17
|
+
* Firefox 129+): same-document is universal, cross-document is
|
|
18
|
+
* Chromium-only and explicitly out of scope. When the browser lacks
|
|
19
|
+
* the API, `startTransition()` runs the callback synchronously and
|
|
20
|
+
* fires start+end back-to-back so caller logic still resolves.
|
|
21
|
+
*
|
|
22
|
+
* Reduced-motion is honored automatically by the user-agent CSS
|
|
23
|
+
* (`view-transition-name: none` under `prefers-reduced-motion: reduce`),
|
|
24
|
+
* but the trait also detects the preference and runs the no-animation
|
|
25
|
+
* lifecycle synchronously so consumers can rely on the events.
|
|
26
|
+
*
|
|
27
|
+
* <article-ui traits="view-transition" data-view-transition-name="card-42">
|
|
28
|
+
* …
|
|
29
|
+
* </article-ui>
|
|
30
|
+
*
|
|
31
|
+
* const article = document.querySelector('article-ui');
|
|
32
|
+
* article.startTransition(() => {
|
|
33
|
+
* article.classList.add('detail-view');
|
|
34
|
+
* });
|
|
35
|
+
*/
|
|
36
|
+
export const viewTransition = defineTrait({
|
|
37
|
+
name: 'view-transition',
|
|
38
|
+
category: 'motion-positioning',
|
|
39
|
+
description:
|
|
40
|
+
'Wraps document.startViewTransition() for morph animations between DOM states',
|
|
41
|
+
attributes: ['data-view-transition-active'],
|
|
42
|
+
events: ['view-transition-start', 'view-transition-end'],
|
|
43
|
+
config: [
|
|
44
|
+
'data-view-transition-name',
|
|
45
|
+
'data-view-transition-duration',
|
|
46
|
+
'data-view-transition-easing',
|
|
47
|
+
],
|
|
48
|
+
setup({ host }) {
|
|
49
|
+
// ── Apply CSS configuration ─────────────────────────────────────────
|
|
50
|
+
// The browser keys participants by the CSS `view-transition-name`
|
|
51
|
+
// property; we mirror the data-attribute onto the host's inline
|
|
52
|
+
// style so the value can be set declaratively in markup.
|
|
53
|
+
const name = host.getAttribute('data-view-transition-name');
|
|
54
|
+
const duration = host.getAttribute('data-view-transition-duration');
|
|
55
|
+
const easing = host.getAttribute('data-view-transition-easing');
|
|
56
|
+
|
|
57
|
+
const prevName = host.style.viewTransitionName;
|
|
58
|
+
const prevDur = host.style.getPropertyValue('--vt-duration');
|
|
59
|
+
const prevEase = host.style.getPropertyValue('--vt-easing');
|
|
60
|
+
|
|
61
|
+
if (name) host.style.viewTransitionName = name;
|
|
62
|
+
if (duration) host.style.setProperty('--vt-duration', duration);
|
|
63
|
+
if (easing) host.style.setProperty('--vt-easing', easing);
|
|
64
|
+
|
|
65
|
+
// ── start / end event helpers ───────────────────────────────────────
|
|
66
|
+
function fireStart() {
|
|
67
|
+
host.setAttribute('data-view-transition-active', '');
|
|
68
|
+
host.dispatchEvent(
|
|
69
|
+
new CustomEvent('view-transition-start', { bubbles: true }),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
function fireEnd() {
|
|
73
|
+
host.removeAttribute('data-view-transition-active');
|
|
74
|
+
host.dispatchEvent(
|
|
75
|
+
new CustomEvent('view-transition-end', { bubbles: true }),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── host.startTransition(callback) ──────────────────────────────────
|
|
80
|
+
// The unusual-for-a-trait method install. Returns a thin object that
|
|
81
|
+
// exposes `.finished` so consumers can await transition completion
|
|
82
|
+
// — matching the shape of `document.startViewTransition()` itself.
|
|
83
|
+
const supported =
|
|
84
|
+
typeof document !== 'undefined' &&
|
|
85
|
+
'startViewTransition' in document;
|
|
86
|
+
|
|
87
|
+
function startTransition(callback) {
|
|
88
|
+
const cb = typeof callback === 'function' ? callback : () => {};
|
|
89
|
+
const reduced = prefersReducedMotion();
|
|
90
|
+
|
|
91
|
+
// Graceful-degrade: no API support OR user prefers reduced motion.
|
|
92
|
+
// Run the mutation now; emit start+end synchronously so consumers
|
|
93
|
+
// can rely on the lifecycle even when no animation will play.
|
|
94
|
+
if (!supported || reduced) {
|
|
95
|
+
fireStart();
|
|
96
|
+
try { cb(); } catch (err) { fireEnd(); throw err; }
|
|
97
|
+
// Use queueMicrotask so callers attaching a one-shot 'view-transition-end'
|
|
98
|
+
// listener immediately AFTER startTransition() still see the event.
|
|
99
|
+
const finished = Promise.resolve().then(() => { fireEnd(); });
|
|
100
|
+
return { finished, ready: finished, updateCallbackDone: finished, skipTransition() {} };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Real path. The browser's startViewTransition() takes the callback,
|
|
104
|
+
// snapshots the old state, runs the mutation, snapshots the new
|
|
105
|
+
// state, then morphs between them. We dispatch start synchronously
|
|
106
|
+
// (the browser has already snapshotted by the time we resume) and
|
|
107
|
+
// end once the transition resolves.
|
|
108
|
+
fireStart();
|
|
109
|
+
const transition = document.startViewTransition(() => cb());
|
|
110
|
+
transition.finished
|
|
111
|
+
.then(() => fireEnd())
|
|
112
|
+
.catch(() => fireEnd()); // user-skipped or thrown — still emit end
|
|
113
|
+
return transition;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Attach the method. We track whether the host already had one so we
|
|
117
|
+
// can restore it on disconnect (rare, but keeps cleanup symmetric).
|
|
118
|
+
const hadStartTransition = Object.prototype.hasOwnProperty.call(
|
|
119
|
+
host,
|
|
120
|
+
'startTransition',
|
|
121
|
+
);
|
|
122
|
+
const prevStartTransition = host.startTransition;
|
|
123
|
+
host.startTransition = startTransition;
|
|
124
|
+
|
|
125
|
+
return () => {
|
|
126
|
+
// Restore inline styles
|
|
127
|
+
host.style.viewTransitionName = prevName;
|
|
128
|
+
if (prevDur) host.style.setProperty('--vt-duration', prevDur);
|
|
129
|
+
else host.style.removeProperty('--vt-duration');
|
|
130
|
+
if (prevEase) host.style.setProperty('--vt-easing', prevEase);
|
|
131
|
+
else host.style.removeProperty('--vt-easing');
|
|
132
|
+
|
|
133
|
+
// Restore the method (or delete if we added it fresh)
|
|
134
|
+
if (hadStartTransition) host.startTransition = prevStartTransition;
|
|
135
|
+
else delete host.startTransition;
|
|
136
|
+
|
|
137
|
+
host.removeAttribute('data-view-transition-active');
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|