@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.
Files changed (113) hide show
  1. package/components/button/button.js +3 -0
  2. package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
  3. package/components/demo-toggle/demo-toggle.css +120 -0
  4. package/components/demo-toggle/demo-toggle.js +144 -0
  5. package/components/demo-toggle/demo-toggle.test.js +102 -0
  6. package/components/demo-toggle/demo-toggle.yaml +144 -0
  7. package/components/index.js +1 -0
  8. package/components/input/input.js +11 -0
  9. package/components/list/list.css +21 -0
  10. package/components/textarea/textarea.js +10 -0
  11. package/core/icons.js +12 -1
  12. package/package.json +1 -1
  13. package/styles/components.css +1 -0
  14. package/styles/typography.css +1 -1
  15. package/traits/_catalog.json +257 -4
  16. package/traits/active-state.test.js +1 -1
  17. package/traits/anchor-positioning.js +205 -52
  18. package/traits/anchor-positioning.test.js +77 -4
  19. package/traits/announcer-stage.js +157 -0
  20. package/traits/announcer.js +145 -0
  21. package/traits/announcer.test.js +268 -0
  22. package/traits/arrow-grid-nav.js +234 -0
  23. package/traits/arrow-grid-nav.test.js +375 -0
  24. package/traits/attention-pulse.js +1 -1
  25. package/traits/attention-pulse.test.js +1 -1
  26. package/traits/confetti-burst.js +67 -63
  27. package/traits/confetti-burst.test.js +16 -8
  28. package/traits/confetti-stage.js +143 -0
  29. package/traits/confetti.js +44 -47
  30. package/traits/confetti.test.js +24 -5
  31. package/traits/count-up.js +31 -6
  32. package/traits/count-up.test.js +1 -1
  33. package/traits/declarative.test.js +1 -1
  34. package/traits/dirty-state.test.js +1 -1
  35. package/traits/drag-ghost.js +43 -3
  36. package/traits/drag-ghost.test.js +1 -1
  37. package/traits/draggable-list-item.js +279 -0
  38. package/traits/draggable-list-item.test.js +51 -0
  39. package/traits/draggable.js +14 -4
  40. package/traits/draggable.test.js +1 -1
  41. package/traits/drop-target.js +223 -0
  42. package/traits/drop-target.test.js +241 -0
  43. package/traits/droppable-collection.js +89 -0
  44. package/traits/droppable-collection.test.js +99 -0
  45. package/traits/droppable.js +125 -0
  46. package/traits/droppable.test.js +54 -0
  47. package/traits/error-shake.js +157 -0
  48. package/traits/error-shake.test.js +114 -0
  49. package/traits/fade-presence.test.js +1 -1
  50. package/traits/focus-restore.js +135 -0
  51. package/traits/focus-restore.test.js +202 -0
  52. package/traits/focus-trap.test.js +1 -1
  53. package/traits/focusable.test.js +1 -1
  54. package/traits/glow-focus.js +1 -1
  55. package/traits/glow-focus.test.js +1 -1
  56. package/traits/gradient-shift.js +1 -1
  57. package/traits/gradient-shift.test.js +1 -1
  58. package/traits/haptic-feedback.test.js +1 -1
  59. package/traits/hotkey.test.js +1 -1
  60. package/traits/hoverable.test.js +1 -1
  61. package/traits/index.js +15 -0
  62. package/traits/inertia-drag.js +9 -0
  63. package/traits/inertia-drag.test.js +1 -1
  64. package/traits/input-mask.js +328 -0
  65. package/traits/input-mask.test.js +151 -0
  66. package/traits/intersection-observer.test.js +1 -1
  67. package/traits/keyboard-nav.test.js +1 -1
  68. package/traits/keyboard-reorderable.js +254 -0
  69. package/traits/keyboard-reorderable.test.js +45 -0
  70. package/traits/layout-animation.js +229 -0
  71. package/traits/layout-animation.test.js +114 -0
  72. package/traits/long-press.js +212 -0
  73. package/traits/long-press.test.js +244 -0
  74. package/traits/magnetic-hover.js +1 -1
  75. package/traits/magnetic-hover.test.js +1 -1
  76. package/traits/noise-texture.js +7 -3
  77. package/traits/noise-texture.test.js +1 -1
  78. package/traits/parallax.js +1 -1
  79. package/traits/parallax.test.js +1 -1
  80. package/traits/portal.test.js +1 -1
  81. package/traits/pressable.test.js +1 -1
  82. package/traits/resettable.js +29 -3
  83. package/traits/resettable.test.js +34 -1
  84. package/traits/resizable.test.js +1 -1
  85. package/traits/resize-observer.test.js +1 -1
  86. package/traits/ripple.js +1 -1
  87. package/traits/ripple.test.js +1 -1
  88. package/traits/roving-tabindex.test.js +1 -1
  89. package/traits/scale-press.test.js +1 -1
  90. package/traits/scroll-lock.test.js +1 -1
  91. package/traits/scroll-progress.js +201 -0
  92. package/traits/scroll-progress.test.js +182 -0
  93. package/traits/shimmer-loading.js +1 -1
  94. package/traits/shimmer-loading.test.js +1 -1
  95. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  96. package/traits/snap-to-grid.test.js +1 -1
  97. package/traits/sound-feedback.test.js +1 -1
  98. package/traits/spring-animate.test.js +1 -1
  99. package/traits/success-checkmark.js +222 -0
  100. package/traits/success-checkmark.test.js +120 -0
  101. package/traits/tilt-hover.js +1 -1
  102. package/traits/tilt-hover.test.js +1 -1
  103. package/traits/tossable.js +9 -0
  104. package/traits/tossable.test.js +1 -1
  105. package/traits/traits-host.test.js +1 -1
  106. package/traits/typeahead.test.js +1 -1
  107. package/traits/typewriter.js +1 -1
  108. package/traits/typewriter.test.js +1 -1
  109. package/traits/validation.test.js +1 -1
  110. package/traits/view-transition.js +140 -0
  111. package/traits/view-transition.test.js +268 -0
  112. /package/traits/{_motion.js → motion.js} +0 -0
  113. /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { scalePress } from './scale-press.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('scale-press', () => {
6
6
  beforeEach(resetDOM);
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { scrollLock } from './scroll-lock.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('scroll-lock', () => {
6
6
  beforeEach(() => {
@@ -0,0 +1,201 @@
1
+ import { defineTrait } from './define.js';
2
+
3
+ /**
4
+ * scroll-progress — surfaces page or element scroll progress as a 0..1
5
+ * attribute, CSS custom property, and event. Building block for sticky
6
+ * headers, ToC highlighting, scroll-linked animations, and reading-time
7
+ * indicators.
8
+ *
9
+ * Modes (via `data-scroll-progress-mode`):
10
+ * - "in-view" (default) — 0 = host top edge meets viewport bottom;
11
+ * 1 = host bottom edge crosses viewport top. Useful for fade-in /
12
+ * parallax-on-scroll patterns.
13
+ * - "page" — 0 = page at top, 1 = page fully scrolled. Independent of
14
+ * host position.
15
+ * - "scrolled" — 0 = host content scrollTop is 0, 1 = fully scrolled.
16
+ * For overflow:auto containers.
17
+ *
18
+ * Container override (`data-scroll-container`) accepts a CSS selector;
19
+ * resolves to the scrolling ancestor explicitly. Falls back to the
20
+ * nearest auto/scroll overflow ancestor or `window`.
21
+ *
22
+ * Throttling — scroll events fire frequently. The trait coalesces
23
+ * updates via rAF: at most one progress update per frame. Cleanup
24
+ * cancels any pending rAF + removes the listener.
25
+ */
26
+ export const scrollProgress = defineTrait({
27
+ name: 'scroll-progress',
28
+ category: 'layout-measurement',
29
+ description: 'Page or element scroll progress as 0..1 attribute + CSS variable + event',
30
+ attributes: ['data-scroll-progress-active', 'data-scroll-progress'],
31
+ events: ['scroll-progress'],
32
+ config: ['data-scroll-progress-mode', 'data-scroll-container'],
33
+ setup({ host }) {
34
+ const mode = host.getAttribute('data-scroll-progress-mode') || 'in-view';
35
+ const containerSelector = host.getAttribute('data-scroll-container');
36
+
37
+ // Resolve scroll container — explicit selector wins; otherwise walk
38
+ // up looking for an overflow:auto/scroll ancestor; fall back to window.
39
+ function resolveContainer() {
40
+ if (containerSelector) {
41
+ try {
42
+ const root = host.getRootNode?.() || document;
43
+ const node = root.querySelector?.(containerSelector)
44
+ || document.querySelector(containerSelector);
45
+ if (node) return node;
46
+ } catch {
47
+ // Invalid selector — fall through.
48
+ }
49
+ }
50
+ let node = host.parentElement;
51
+ while (node) {
52
+ const style = (typeof getComputedStyle === 'function')
53
+ ? getComputedStyle(node)
54
+ : null;
55
+ if (style) {
56
+ const flow = style.overflow + style.overflowY + style.overflowX;
57
+ if (/(auto|scroll)/.test(flow)) return node;
58
+ }
59
+ node = node.parentElement;
60
+ }
61
+ return (typeof window !== 'undefined') ? window : null;
62
+ }
63
+
64
+ const container = resolveContainer();
65
+ if (!container) {
66
+ // No place to listen — mark active and bail. Keeps cleanup
67
+ // contract intact (attribute is still removed on disconnect).
68
+ host.setAttribute('data-scroll-progress-active', '');
69
+ return () => {
70
+ host.removeAttribute('data-scroll-progress-active');
71
+ host.removeAttribute('data-scroll-progress');
72
+ host.style.removeProperty('--scroll-progress');
73
+ };
74
+ }
75
+
76
+ const isWindow = container === (typeof window !== 'undefined' ? window : null);
77
+
78
+ function clamp01(v) {
79
+ if (!Number.isFinite(v)) return 0;
80
+ if (v < 0) return 0;
81
+ if (v > 1) return 1;
82
+ return v;
83
+ }
84
+
85
+ function compute() {
86
+ // "page" — scrollY / (scrollHeight - innerHeight)
87
+ if (mode === 'page') {
88
+ if (isWindow) {
89
+ const max = (document.documentElement.scrollHeight || 0) - (window.innerHeight || 0);
90
+ if (max <= 0) return 0;
91
+ return clamp01((window.scrollY || 0) / max);
92
+ }
93
+ const max = (container.scrollHeight || 0) - (container.clientHeight || 0);
94
+ if (max <= 0) return 0;
95
+ return clamp01((container.scrollTop || 0) / max);
96
+ }
97
+
98
+ // "scrolled" — host's own scrollTop / max-scroll
99
+ if (mode === 'scrolled') {
100
+ const max = (host.scrollHeight || 0) - (host.clientHeight || 0);
101
+ if (max <= 0) return 0;
102
+ return clamp01((host.scrollTop || 0) / max);
103
+ }
104
+
105
+ // "in-view" (default) — 0 when host top meets viewport bottom,
106
+ // 1 when host bottom crosses viewport top.
107
+ // We measure the host rect against the container's viewport.
108
+ if (typeof host.getBoundingClientRect !== 'function') return 0;
109
+ const rect = host.getBoundingClientRect();
110
+ let viewTop;
111
+ let viewBottom;
112
+ if (isWindow) {
113
+ viewTop = 0;
114
+ viewBottom = window.innerHeight || 0;
115
+ } else if (typeof container.getBoundingClientRect === 'function') {
116
+ const cRect = container.getBoundingClientRect();
117
+ viewTop = cRect.top;
118
+ viewBottom = cRect.bottom;
119
+ } else {
120
+ viewTop = 0;
121
+ viewBottom = window.innerHeight || 0;
122
+ }
123
+
124
+ const viewHeight = viewBottom - viewTop;
125
+ const total = viewHeight + (rect.height || 0);
126
+ if (total <= 0) return 0;
127
+
128
+ // Distance from "host fully below view" to current position.
129
+ // When rect.top === viewBottom → 0. When rect.bottom === viewTop → 1.
130
+ const distance = viewBottom - rect.top;
131
+ return clamp01(distance / total);
132
+ }
133
+
134
+ let rafId = null;
135
+ let lastWritten = -1;
136
+
137
+ function update() {
138
+ rafId = null;
139
+ const progress = compute();
140
+ // De-dup writes when nothing meaningful changed (3-decimal precision
141
+ // is enough for any visual or reading-progress consumer).
142
+ const rounded = Math.round(progress * 1000) / 1000;
143
+ if (rounded === lastWritten) return;
144
+ lastWritten = rounded;
145
+
146
+ const str = rounded.toFixed(3);
147
+ host.setAttribute('data-scroll-progress', str);
148
+ host.style.setProperty('--scroll-progress', str);
149
+ host.dispatchEvent(new CustomEvent('scroll-progress', {
150
+ bubbles: true,
151
+ detail: { progress: rounded },
152
+ }));
153
+ }
154
+
155
+ function schedule() {
156
+ if (rafId !== null) return;
157
+ if (typeof requestAnimationFrame === 'function') {
158
+ rafId = requestAnimationFrame(update);
159
+ } else {
160
+ // Fallback for environments without rAF — write synchronously.
161
+ update();
162
+ }
163
+ }
164
+
165
+ const target = container;
166
+ const listenerOpts = { passive: true };
167
+
168
+ target.addEventListener?.('scroll', schedule, listenerOpts);
169
+ // For "scrolled" mode the host itself is the scroll surface — also
170
+ // listen on host so progress updates without requiring the resolved
171
+ // container to be the host.
172
+ if (mode === 'scrolled' && target !== host) {
173
+ host.addEventListener?.('scroll', schedule, listenerOpts);
174
+ }
175
+ // Window resize affects in-view + page math; update on resize too.
176
+ if (typeof window !== 'undefined') {
177
+ window.addEventListener?.('resize', schedule, listenerOpts);
178
+ }
179
+
180
+ host.setAttribute('data-scroll-progress-active', '');
181
+ // Seed the initial value on next frame so first paint has the value.
182
+ schedule();
183
+
184
+ return () => {
185
+ if (rafId !== null && typeof cancelAnimationFrame === 'function') {
186
+ cancelAnimationFrame(rafId);
187
+ }
188
+ rafId = null;
189
+ target.removeEventListener?.('scroll', schedule, listenerOpts);
190
+ if (mode === 'scrolled' && target !== host) {
191
+ host.removeEventListener?.('scroll', schedule, listenerOpts);
192
+ }
193
+ if (typeof window !== 'undefined') {
194
+ window.removeEventListener?.('resize', schedule, listenerOpts);
195
+ }
196
+ host.removeAttribute('data-scroll-progress-active');
197
+ host.removeAttribute('data-scroll-progress');
198
+ host.style.removeProperty('--scroll-progress');
199
+ };
200
+ },
201
+ });
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { scrollProgress } from './scroll-progress.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
4
+
5
+ describe('scroll-progress', () => {
6
+ let originalRAF;
7
+ let originalCAF;
8
+
9
+ beforeEach(() => {
10
+ resetDOM();
11
+ // Synchronous rAF — the trait's scheduler calls back immediately so
12
+ // we can assert post-update side-effects without awaiting frames.
13
+ originalRAF = globalThis.requestAnimationFrame;
14
+ originalCAF = globalThis.cancelAnimationFrame;
15
+ globalThis.requestAnimationFrame = (fn) => { fn(performance.now?.() ?? 0); return 1; };
16
+ globalThis.cancelAnimationFrame = vi.fn();
17
+ });
18
+
19
+ afterEach(() => {
20
+ globalThis.requestAnimationFrame = originalRAF;
21
+ globalThis.cancelAnimationFrame = originalCAF;
22
+ });
23
+
24
+ it('connect sets data-scroll-progress-active', () => {
25
+ const host = mountHost();
26
+ connectTrait(scrollProgress, host);
27
+ expect(host.hasAttribute('data-scroll-progress-active')).toBe(true);
28
+ });
29
+
30
+ it('disconnect clears active + progress + CSS variable', () => {
31
+ const host = mountHost();
32
+ const inst = connectTrait(scrollProgress, host);
33
+ inst.disconnect(host);
34
+ expect(host.hasAttribute('data-scroll-progress-active')).toBe(false);
35
+ expect(host.hasAttribute('data-scroll-progress')).toBe(false);
36
+ expect(host.style.getPropertyValue('--scroll-progress')).toBe('');
37
+ });
38
+
39
+ it('writes data-scroll-progress as a 0..1 string after connect', () => {
40
+ const host = mountHost();
41
+ connectTrait(scrollProgress, host);
42
+ // Synchronous rAF means the seed update has already run.
43
+ const v = host.getAttribute('data-scroll-progress');
44
+ expect(v).not.toBeNull();
45
+ const n = parseFloat(v);
46
+ expect(n).toBeGreaterThanOrEqual(0);
47
+ expect(n).toBeLessThanOrEqual(1);
48
+ });
49
+
50
+ it('writes the --scroll-progress CSS custom property', () => {
51
+ const host = mountHost();
52
+ connectTrait(scrollProgress, host);
53
+ const css = host.style.getPropertyValue('--scroll-progress');
54
+ expect(css).not.toBe('');
55
+ const n = parseFloat(css);
56
+ expect(n).toBeGreaterThanOrEqual(0);
57
+ expect(n).toBeLessThanOrEqual(1);
58
+ });
59
+
60
+ it('dispatches a scroll-progress event with { progress } detail', () => {
61
+ const host = mountHost();
62
+ const spy = spyEvent(host, 'scroll-progress');
63
+ connectTrait(scrollProgress, host);
64
+ // Initial seed fires once via synchronous rAF.
65
+ expect(spy.count).toBeGreaterThanOrEqual(1);
66
+ expect(typeof spy.last.progress).toBe('number');
67
+ expect(spy.last.progress).toBeGreaterThanOrEqual(0);
68
+ expect(spy.last.progress).toBeLessThanOrEqual(1);
69
+ });
70
+
71
+ it('mode="page" falls back to 0 when scroll range is zero', () => {
72
+ const host = mountHost('div', { 'data-scroll-progress-mode': 'page' });
73
+ connectTrait(scrollProgress, host);
74
+ // happy-dom reports zero scroll geometry by default.
75
+ expect(host.getAttribute('data-scroll-progress')).toBe('0.000');
76
+ });
77
+
78
+ it('mode="scrolled" reports 0..1 from host scrollTop / max', () => {
79
+ const host = mountHost('div', { 'data-scroll-progress-mode': 'scrolled' });
80
+ // Stub the geometry — happy-dom returns zeros for scroll metrics.
81
+ Object.defineProperty(host, 'scrollHeight', { configurable: true, value: 1000 });
82
+ Object.defineProperty(host, 'clientHeight', { configurable: true, value: 200 });
83
+ Object.defineProperty(host, 'scrollTop', { configurable: true, value: 200, writable: true });
84
+ connectTrait(scrollProgress, host);
85
+ const v = parseFloat(host.getAttribute('data-scroll-progress'));
86
+ // 200 / (1000 - 200) = 0.25
87
+ expect(v).toBeCloseTo(0.25, 2);
88
+ });
89
+
90
+ it('mode="in-view" clamps to [0,1] for offscreen rects', () => {
91
+ const host = mountHost();
92
+ // Far below the viewport.
93
+ host.getBoundingClientRect = () => ({ top: 99999, bottom: 100099, left: 0, right: 0, width: 0, height: 100, x: 0, y: 99999, toJSON() { return {}; } });
94
+ connectTrait(scrollProgress, host);
95
+ expect(parseFloat(host.getAttribute('data-scroll-progress'))).toBe(0);
96
+ });
97
+
98
+ it('mode="in-view" reports 1 for rects fully scrolled past', () => {
99
+ const host = mountHost();
100
+ // Far above the viewport.
101
+ host.getBoundingClientRect = () => ({ top: -2000, bottom: -1900, left: 0, right: 0, width: 0, height: 100, x: 0, y: -2000, toJSON() { return {}; } });
102
+ // Make sure window.innerHeight is sane.
103
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
104
+ connectTrait(scrollProgress, host);
105
+ expect(parseFloat(host.getAttribute('data-scroll-progress'))).toBe(1);
106
+ });
107
+
108
+ it('data-scroll-container selector resolves an explicit ancestor', () => {
109
+ document.body.innerHTML = '<div id="custom-scroller" style="overflow:auto;"><div id="inner"></div></div>';
110
+ const host = document.getElementById('inner');
111
+ host.setAttribute('data-scroll-container', '#custom-scroller');
112
+ const scroller = document.getElementById('custom-scroller');
113
+ const addSpy = vi.spyOn(scroller, 'addEventListener');
114
+ connectTrait(scrollProgress, host);
115
+ // The trait should attach a scroll listener to the resolved container.
116
+ const calls = addSpy.mock.calls.filter(([type]) => type === 'scroll');
117
+ expect(calls.length).toBeGreaterThanOrEqual(1);
118
+ });
119
+
120
+ it('invalid data-scroll-container selector falls through to ancestor walk', () => {
121
+ document.body.innerHTML = '<div id="parent" style="overflow:auto;"><div id="child"></div></div>';
122
+ const host = document.getElementById('child');
123
+ host.setAttribute('data-scroll-container', '###not-a-valid-selector');
124
+ expect(() => connectTrait(scrollProgress, host)).not.toThrow();
125
+ });
126
+
127
+ it('schedules through rAF — second scroll within the same frame coalesces', () => {
128
+ let queued = 0;
129
+ let pending = null;
130
+ globalThis.requestAnimationFrame = (fn) => {
131
+ queued++;
132
+ pending = fn;
133
+ return queued;
134
+ };
135
+ globalThis.cancelAnimationFrame = vi.fn();
136
+ const host = mountHost();
137
+ const inst = connectTrait(scrollProgress, host);
138
+ const baseline = queued;
139
+ // Fire two scroll events back-to-back without flushing the rAF.
140
+ window.dispatchEvent(new Event('scroll'));
141
+ window.dispatchEvent(new Event('scroll'));
142
+ // Only one rAF should be queued in addition to the seed.
143
+ expect(queued - baseline).toBeLessThanOrEqual(1);
144
+ // Flush manually so disconnect has nothing pending.
145
+ if (pending) pending(0);
146
+ inst.disconnect(host);
147
+ });
148
+
149
+ it('disconnect cancels the pending rAF', () => {
150
+ let pendingId = null;
151
+ globalThis.requestAnimationFrame = () => { pendingId = 42; return 42; };
152
+ const cancelSpy = vi.fn();
153
+ globalThis.cancelAnimationFrame = cancelSpy;
154
+ const host = mountHost();
155
+ const inst = connectTrait(scrollProgress, host);
156
+ inst.disconnect(host);
157
+ // The trait queued one rAF on connect; disconnect should cancel it.
158
+ expect(cancelSpy).toHaveBeenCalled();
159
+ });
160
+
161
+ it('disconnect removes the scroll listener from the resolved container', () => {
162
+ document.body.innerHTML = '<div id="scr" style="overflow:auto;"><div id="h"></div></div>';
163
+ const host = document.getElementById('h');
164
+ host.setAttribute('data-scroll-container', '#scr');
165
+ const scroller = document.getElementById('scr');
166
+ const removeSpy = vi.spyOn(scroller, 'removeEventListener');
167
+ const inst = connectTrait(scrollProgress, host);
168
+ inst.disconnect(host);
169
+ const calls = removeSpy.mock.calls.filter(([type]) => type === 'scroll');
170
+ expect(calls.length).toBeGreaterThanOrEqual(1);
171
+ });
172
+
173
+ it('reconnect after disconnect does not throw and restores the active flag', () => {
174
+ const host = mountHost();
175
+ const inst1 = connectTrait(scrollProgress, host);
176
+ inst1.disconnect(host);
177
+ const inst2 = connectTrait(scrollProgress, host);
178
+ expect(host.hasAttribute('data-scroll-progress-active')).toBe(true);
179
+ inst2.disconnect(host);
180
+ expect(host.hasAttribute('data-scroll-progress-active')).toBe(false);
181
+ });
182
+ });
@@ -1,5 +1,5 @@
1
1
  import { defineTrait } from './define.js';
2
- import { prefersReducedMotion } from './_motion.js';
2
+ import { prefersReducedMotion } from './motion.js';
3
3
 
4
4
  export const shimmerLoading = defineTrait({
5
5
  name: 'shimmer-loading',
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { shimmerLoading } from './shimmer-loading.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('shimmer-loading', () => {
6
6
  beforeEach(resetDOM);
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, it, expect, beforeEach } from 'vitest';
10
10
  import * as traits from './index.js';
11
- import { mountHost, connectTrait, expectValidSchema, resetDOM } from './_test-helpers.js';
11
+ import { mountHost, connectTrait, expectValidSchema, resetDOM } from './test-helpers.js';
12
12
 
13
13
  const KNOWN_CATEGORIES = new Set([
14
14
  'input-interaction',
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { snapToGrid } from './snap-to-grid.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('snap-to-grid', () => {
6
6
  beforeEach(resetDOM);
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { soundFeedback } from './sound-feedback.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('sound-feedback', () => {
6
6
  beforeEach(resetDOM);
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { springAnimate } from './spring-animate.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('spring-animate', () => {
6
6
  beforeEach(resetDOM);
@@ -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
+ });