@adia-ai/web-components 0.2.3 → 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/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 +21 -0
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +12 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +257 -4
- 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 +67 -63
- 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 +43 -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.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,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
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { viewTransition } from './view-transition.js';
|
|
3
|
+
import { _resetMotionCache } from './motion.js';
|
|
4
|
+
import {
|
|
5
|
+
mountHost,
|
|
6
|
+
connectTrait,
|
|
7
|
+
spyEvent,
|
|
8
|
+
resetDOM,
|
|
9
|
+
expectValidSchema,
|
|
10
|
+
wait,
|
|
11
|
+
} from './test-helpers.js';
|
|
12
|
+
|
|
13
|
+
describe('view-transition', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetDOM();
|
|
16
|
+
_resetMotionCache();
|
|
17
|
+
// happy-dom has no startViewTransition; ensure we start clean each test.
|
|
18
|
+
delete document.startViewTransition;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
delete document.startViewTransition;
|
|
23
|
+
_resetMotionCache();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('exposes a valid schema', () => {
|
|
27
|
+
expectValidSchema(viewTransition);
|
|
28
|
+
expect(viewTransition.schema.name).toBe('view-transition');
|
|
29
|
+
expect(viewTransition.schema.category).toBe('motion-positioning');
|
|
30
|
+
expect(viewTransition.schema.events).toContain('view-transition-start');
|
|
31
|
+
expect(viewTransition.schema.events).toContain('view-transition-end');
|
|
32
|
+
expect(viewTransition.schema.config).toContain('data-view-transition-name');
|
|
33
|
+
expect(viewTransition.schema.config).toContain('data-view-transition-duration');
|
|
34
|
+
expect(viewTransition.schema.config).toContain('data-view-transition-easing');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('writes view-transition-name to the host inline style', () => {
|
|
38
|
+
const host = mountHost('div', { 'data-view-transition-name': 'card-42' });
|
|
39
|
+
connectTrait(viewTransition, host);
|
|
40
|
+
expect(host.style.viewTransitionName).toBe('card-42');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('writes --vt-duration / --vt-easing custom props', () => {
|
|
44
|
+
const host = mountHost('div', {
|
|
45
|
+
'data-view-transition-name': 'hero',
|
|
46
|
+
'data-view-transition-duration': '450ms',
|
|
47
|
+
'data-view-transition-easing': 'cubic-bezier(0.2, 0.8, 0.2, 1)',
|
|
48
|
+
});
|
|
49
|
+
connectTrait(viewTransition, host);
|
|
50
|
+
expect(host.style.getPropertyValue('--vt-duration')).toBe('450ms');
|
|
51
|
+
expect(host.style.getPropertyValue('--vt-easing')).toBe(
|
|
52
|
+
'cubic-bezier(0.2, 0.8, 0.2, 1)',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('omits CSS props when their data attrs are absent', () => {
|
|
57
|
+
const host = mountHost('div');
|
|
58
|
+
connectTrait(viewTransition, host);
|
|
59
|
+
// happy-dom returns undefined for unset viewTransitionName; real browsers
|
|
60
|
+
// return ''. Either is acceptable — the contract is "nothing was set."
|
|
61
|
+
expect(host.style.viewTransitionName || '').toBe('');
|
|
62
|
+
expect(host.style.getPropertyValue('--vt-duration')).toBe('');
|
|
63
|
+
expect(host.style.getPropertyValue('--vt-easing')).toBe('');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('installs host.startTransition() method', () => {
|
|
67
|
+
const host = mountHost();
|
|
68
|
+
expect(typeof host.startTransition).toBe('undefined');
|
|
69
|
+
connectTrait(viewTransition, host);
|
|
70
|
+
expect(typeof host.startTransition).toBe('function');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('removes host.startTransition() on disconnect', () => {
|
|
74
|
+
const host = mountHost();
|
|
75
|
+
const inst = connectTrait(viewTransition, host);
|
|
76
|
+
expect(typeof host.startTransition).toBe('function');
|
|
77
|
+
inst.disconnect(host);
|
|
78
|
+
expect(host.startTransition).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('runs the callback when the API is missing (feature-detect fallback)', () => {
|
|
82
|
+
const host = mountHost();
|
|
83
|
+
connectTrait(viewTransition, host);
|
|
84
|
+
let ran = false;
|
|
85
|
+
host.startTransition(() => { ran = true; });
|
|
86
|
+
expect(ran).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('fires start + end events when the API is missing', async () => {
|
|
90
|
+
const host = mountHost();
|
|
91
|
+
connectTrait(viewTransition, host);
|
|
92
|
+
const startSpy = spyEvent(host, 'view-transition-start');
|
|
93
|
+
const endSpy = spyEvent(host, 'view-transition-end');
|
|
94
|
+
host.startTransition(() => {});
|
|
95
|
+
// start fires synchronously; end fires on the next microtask.
|
|
96
|
+
expect(startSpy.count).toBe(1);
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
expect(endSpy.count).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('toggles data-view-transition-active across start → end', async () => {
|
|
102
|
+
const host = mountHost();
|
|
103
|
+
connectTrait(viewTransition, host);
|
|
104
|
+
expect(host.hasAttribute('data-view-transition-active')).toBe(false);
|
|
105
|
+
host.startTransition(() => {
|
|
106
|
+
// Mutation runs while the attribute is set.
|
|
107
|
+
expect(host.hasAttribute('data-view-transition-active')).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
// The end is queued on a microtask in the fallback path.
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
expect(host.hasAttribute('data-view-transition-active')).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns a finished-promise even in the fallback path', async () => {
|
|
115
|
+
const host = mountHost();
|
|
116
|
+
connectTrait(viewTransition, host);
|
|
117
|
+
const transition = host.startTransition(() => {});
|
|
118
|
+
expect(transition).toBeTruthy();
|
|
119
|
+
expect(transition.finished).toBeInstanceOf(Promise);
|
|
120
|
+
await expect(transition.finished).resolves.toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('uses real document.startViewTransition when available', () => {
|
|
124
|
+
const finished = Promise.resolve();
|
|
125
|
+
const fakeTransition = { finished, ready: finished, updateCallbackDone: finished };
|
|
126
|
+
const stub = vi.fn((cb) => { cb(); return fakeTransition; });
|
|
127
|
+
document.startViewTransition = stub;
|
|
128
|
+
|
|
129
|
+
const host = mountHost();
|
|
130
|
+
connectTrait(viewTransition, host);
|
|
131
|
+
const cb = vi.fn();
|
|
132
|
+
const result = host.startTransition(cb);
|
|
133
|
+
|
|
134
|
+
expect(stub).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(result).toBe(fakeTransition);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('fires end after the real transition resolves', async () => {
|
|
140
|
+
let resolveFinished;
|
|
141
|
+
const finished = new Promise((r) => { resolveFinished = r; });
|
|
142
|
+
document.startViewTransition = (cb) => {
|
|
143
|
+
cb();
|
|
144
|
+
return { finished, ready: finished, updateCallbackDone: finished };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const host = mountHost();
|
|
148
|
+
connectTrait(viewTransition, host);
|
|
149
|
+
const endSpy = spyEvent(host, 'view-transition-end');
|
|
150
|
+
|
|
151
|
+
host.startTransition(() => {});
|
|
152
|
+
// End hasn't fired yet — the transition is still in flight.
|
|
153
|
+
expect(endSpy.count).toBe(0);
|
|
154
|
+
|
|
155
|
+
resolveFinished();
|
|
156
|
+
await Promise.resolve();
|
|
157
|
+
await Promise.resolve();
|
|
158
|
+
expect(endSpy.count).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('fires end even if the real transition rejects (user-skipped)', async () => {
|
|
162
|
+
const finished = Promise.reject(new Error('skipped'));
|
|
163
|
+
// Pre-attach a noop catch so the rejection isn't unhandled in the test runner.
|
|
164
|
+
finished.catch(() => {});
|
|
165
|
+
document.startViewTransition = (cb) => {
|
|
166
|
+
cb();
|
|
167
|
+
return { finished, ready: finished, updateCallbackDone: finished };
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const host = mountHost();
|
|
171
|
+
connectTrait(viewTransition, host);
|
|
172
|
+
const endSpy = spyEvent(host, 'view-transition-end');
|
|
173
|
+
|
|
174
|
+
host.startTransition(() => {});
|
|
175
|
+
await Promise.resolve();
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
expect(endSpy.count).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('runs callback synchronously even with a missing/invalid argument', () => {
|
|
181
|
+
const host = mountHost();
|
|
182
|
+
connectTrait(viewTransition, host);
|
|
183
|
+
expect(() => host.startTransition()).not.toThrow();
|
|
184
|
+
expect(() => host.startTransition('not a function')).not.toThrow();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('honors prefers-reduced-motion: reduce — synchronous lifecycle, no animation', async () => {
|
|
188
|
+
// Force prefersReducedMotion() to return true.
|
|
189
|
+
const origMatchMedia = window.matchMedia;
|
|
190
|
+
window.matchMedia = (q) => ({
|
|
191
|
+
matches: typeof q === 'string' && q.includes('prefers-reduced-motion'),
|
|
192
|
+
media: q,
|
|
193
|
+
addEventListener() {},
|
|
194
|
+
removeEventListener() {},
|
|
195
|
+
addListener() {},
|
|
196
|
+
removeListener() {},
|
|
197
|
+
dispatchEvent() { return false; },
|
|
198
|
+
onchange: null,
|
|
199
|
+
});
|
|
200
|
+
_resetMotionCache();
|
|
201
|
+
|
|
202
|
+
// Even if the API is "available", reduced-motion takes the fallback path.
|
|
203
|
+
document.startViewTransition = vi.fn();
|
|
204
|
+
|
|
205
|
+
const host = mountHost();
|
|
206
|
+
connectTrait(viewTransition, host);
|
|
207
|
+
const startSpy = spyEvent(host, 'view-transition-start');
|
|
208
|
+
const endSpy = spyEvent(host, 'view-transition-end');
|
|
209
|
+
let ran = false;
|
|
210
|
+
host.startTransition(() => { ran = true; });
|
|
211
|
+
|
|
212
|
+
expect(ran).toBe(true);
|
|
213
|
+
expect(startSpy.count).toBe(1);
|
|
214
|
+
await Promise.resolve();
|
|
215
|
+
expect(endSpy.count).toBe(1);
|
|
216
|
+
expect(document.startViewTransition).not.toHaveBeenCalled();
|
|
217
|
+
|
|
218
|
+
window.matchMedia = origMatchMedia;
|
|
219
|
+
_resetMotionCache();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('disconnect clears active attr and restores inline styles', () => {
|
|
223
|
+
const host = mountHost('div', {
|
|
224
|
+
'data-view-transition-name': 'card-9',
|
|
225
|
+
'data-view-transition-duration': '300ms',
|
|
226
|
+
});
|
|
227
|
+
const inst = connectTrait(viewTransition, host);
|
|
228
|
+
// Force the attribute on so disconnect's cleanup is observable.
|
|
229
|
+
host.setAttribute('data-view-transition-active', '');
|
|
230
|
+
inst.disconnect(host);
|
|
231
|
+
|
|
232
|
+
expect(host.hasAttribute('data-view-transition-active')).toBe(false);
|
|
233
|
+
expect(host.style.viewTransitionName || '').toBe('');
|
|
234
|
+
expect(host.style.getPropertyValue('--vt-duration')).toBe('');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('does not clobber an existing host.startTransition; restores it on disconnect', () => {
|
|
238
|
+
const host = mountHost();
|
|
239
|
+
const sentinel = () => 'pre-existing';
|
|
240
|
+
host.startTransition = sentinel;
|
|
241
|
+
const inst = connectTrait(viewTransition, host);
|
|
242
|
+
expect(host.startTransition).not.toBe(sentinel); // trait took over
|
|
243
|
+
inst.disconnect(host);
|
|
244
|
+
expect(host.startTransition).toBe(sentinel); // restored
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('connect → disconnect → connect-again does not throw', () => {
|
|
248
|
+
const host = mountHost('div', { 'data-view-transition-name': 'a' });
|
|
249
|
+
const inst1 = connectTrait(viewTransition, host);
|
|
250
|
+
inst1.disconnect(host);
|
|
251
|
+
expect(() => {
|
|
252
|
+
const inst2 = connectTrait(viewTransition, host);
|
|
253
|
+
inst2.disconnect(host);
|
|
254
|
+
}).not.toThrow();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('two parallel hosts each get their own startTransition', () => {
|
|
258
|
+
const a = mountHost();
|
|
259
|
+
const b = mountHost();
|
|
260
|
+
const instA = connectTrait(viewTransition, a);
|
|
261
|
+
const instB = connectTrait(viewTransition, b);
|
|
262
|
+
expect(typeof a.startTransition).toBe('function');
|
|
263
|
+
expect(typeof b.startTransition).toBe('function');
|
|
264
|
+
expect(a.startTransition).not.toBe(b.startTransition);
|
|
265
|
+
instA.disconnect(a);
|
|
266
|
+
instB.disconnect(b);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
File without changes
|
|
File without changes
|