@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
@@ -0,0 +1,254 @@
1
+ import { defineTrait } from './define.js';
2
+
3
+ /**
4
+ * `keyboard-reorderable` — keyboard alternative to `draggable-list-item`.
5
+ *
6
+ * Authored for the Tasks UI playground (docs/projects/tasks-playground/spec/).
7
+ * Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'keyboard-navigation').
8
+ *
9
+ * Implements the WCAG 2.5.7 single-pointer alternative + ARIA 1.1
10
+ * draggable-attribute keyboard pattern:
11
+ *
12
+ * Space / Enter (idle) → lift (emit `dnd-lift`)
13
+ * ArrowUp / ArrowDown → move target index within the source container
14
+ * (emit `dnd-drop-target-change`)
15
+ * ArrowLeft / Right → switch container (move to prev/next sibling
16
+ * `[data-droppable-id]` of the parent collection)
17
+ * Space / Enter (lift) → drop (emit `dnd-drop`)
18
+ * Escape → cancel (emit `dnd-drop-cancel`)
19
+ *
20
+ * Source-of-truth model is the same as `draggable-list-item`: emit DOM
21
+ * CustomEvents that `[droppable]` and `[droppable-collection]` already
22
+ * listen for; the source element does NOT physically move during the
23
+ * keyboard "lift" — only `data-keyboard-reorderable-lifting` reflects.
24
+ *
25
+ * Pairs with `draggable-list-item` on the same host. Both consume the
26
+ * existing `announcer-stage.js` aria-live regions (same singleton the
27
+ * `announcer` trait uses) so a screen reader does not hear two parallel
28
+ * narrations.
29
+ */
30
+
31
+ import { announce } from './announcer-stage.js';
32
+
33
+ // Same debounce gate as draggable-list-item — module-private throttle
34
+ // for drop-target-change announcements; immediate calls supersede.
35
+ const DEBOUNCE_MS = 200;
36
+ let debounceHandle = null;
37
+ function announceDebounced(message) {
38
+ if (debounceHandle != null) clearTimeout(debounceHandle);
39
+ debounceHandle = setTimeout(() => {
40
+ debounceHandle = null;
41
+ announce(message, 'polite');
42
+ }, DEBOUNCE_MS);
43
+ }
44
+ function announceImmediate(message) {
45
+ if (debounceHandle != null) {
46
+ clearTimeout(debounceHandle);
47
+ debounceHandle = null;
48
+ }
49
+ announce(message, 'polite');
50
+ }
51
+
52
+ const ATTR_LIFTING = 'data-keyboard-reorderable-lifting';
53
+ const ATTR_SOURCE_ID = 'data-keyboard-reorderable-id';
54
+
55
+ function readSiblingsContext(host) {
56
+ const container = host.parentElement;
57
+ if (!container) return null;
58
+ const siblings = Array.from(container.children).filter((c) => c.matches?.('[data-keyboard-reorderable-id], [data-draggable-list-item-id]'));
59
+ const index = siblings.indexOf(host);
60
+ return { container, index, siblings };
61
+ }
62
+
63
+ function readContainerId(el) {
64
+ /** @type {Element | null} */
65
+ let cur = el;
66
+ while (cur && cur !== document.body) {
67
+ if (cur.hasAttribute?.('data-droppable-id')) return cur.getAttribute('data-droppable-id');
68
+ cur = cur.parentElement;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function readCollectionContainers(host) {
74
+ // Walk up to the nearest droppable-collection, then enumerate its
75
+ // descendant droppables. If there's no collection, return just the
76
+ // single source container.
77
+ /** @type {Element | null} */
78
+ let cur = host;
79
+ while (cur && cur !== document.body) {
80
+ if (cur.hasAttribute?.('data-droppable-collection-active')) {
81
+ return Array.from(cur.querySelectorAll('[data-droppable-id]'));
82
+ }
83
+ cur = cur.parentElement;
84
+ }
85
+ const sourceContainer = host.closest?.('[data-droppable-id]');
86
+ return sourceContainer ? [sourceContainer] : [];
87
+ }
88
+
89
+ export const keyboardReorderable = defineTrait({
90
+ name: 'keyboard-reorderable',
91
+ category: 'keyboard-navigation',
92
+ description: 'Keyboard alternative to draggable-list-item: arrow keys + Space to lift / drop / Esc to cancel',
93
+ attributes: [ATTR_LIFTING, ATTR_SOURCE_ID],
94
+ events: ['dnd-lift', 'dnd-drop-target-change', 'dnd-drop', 'dnd-drop-cancel'],
95
+ config: [],
96
+ setup({ host }) {
97
+ let id = host.getAttribute(ATTR_SOURCE_ID);
98
+ if (!id) {
99
+ id = `kbreorder-${crypto.randomUUID().slice(0, 8)}`;
100
+ host.setAttribute(ATTR_SOURCE_ID, id);
101
+ }
102
+ if (!host.hasAttribute('tabindex')) host.setAttribute('tabindex', '0');
103
+
104
+ let lifted = false;
105
+ /** @type {string | null} */
106
+ let targetContainerId = null;
107
+ let targetIndex = -1;
108
+ /** @type {{ index: number; container: Element } | null} */
109
+ let sourceCtx = null;
110
+ let sourceContainerId = null;
111
+
112
+ function isDisabled() {
113
+ return host.hasAttribute('disabled') || host.getAttribute('aria-disabled') === 'true';
114
+ }
115
+
116
+ function lift() {
117
+ const ctx = readSiblingsContext(host);
118
+ if (!ctx) return;
119
+ sourceCtx = ctx;
120
+ sourceContainerId = readContainerId(ctx.container);
121
+ lifted = true;
122
+ targetContainerId = sourceContainerId;
123
+ targetIndex = ctx.index;
124
+ host.setAttribute(ATTR_LIFTING, '');
125
+ host.dispatchEvent(new CustomEvent('dnd-lift', {
126
+ bubbles: true,
127
+ composed: false,
128
+ detail: {
129
+ source_id: id,
130
+ source_index: ctx.index,
131
+ source_container_id: sourceContainerId,
132
+ },
133
+ }));
134
+ announceImmediate('Picked up item. Use arrow keys to move. Press Space to drop. Press Escape to cancel.');
135
+ emitTargetChange();
136
+ }
137
+
138
+ function emitTargetChange() {
139
+ document.dispatchEvent(new CustomEvent('dnd-drop-target-change', {
140
+ bubbles: true,
141
+ composed: false,
142
+ detail: {
143
+ source_id: id,
144
+ target_container_id: targetContainerId,
145
+ target_index: targetIndex,
146
+ pointer: { x: 0, y: 0 },
147
+ },
148
+ }));
149
+ if (targetContainerId) {
150
+ announceDebounced(`Drop position ${targetIndex + 1} in ${targetContainerId}.`);
151
+ }
152
+ }
153
+
154
+ function moveWithinContainer(delta) {
155
+ const containers = readCollectionContainers(host);
156
+ const containerEl = containers.find((c) => c.getAttribute('data-droppable-id') === targetContainerId);
157
+ const total = containerEl
158
+ ? containerEl.querySelectorAll('[data-keyboard-reorderable-id], [data-draggable-list-item-id]').length
159
+ : (sourceCtx?.siblings.length ?? 0);
160
+ // Allow target index 0..total inclusive (insert-after-last counts).
161
+ const next = Math.max(0, Math.min(total, targetIndex + delta));
162
+ if (next === targetIndex) return;
163
+ targetIndex = next;
164
+ emitTargetChange();
165
+ }
166
+
167
+ function switchContainer(delta) {
168
+ const containers = readCollectionContainers(host);
169
+ if (containers.length <= 1) return;
170
+ const ids = containers.map((c) => c.getAttribute('data-droppable-id'));
171
+ const curIdx = ids.indexOf(targetContainerId);
172
+ const nextIdx = Math.max(0, Math.min(ids.length - 1, curIdx + delta));
173
+ if (nextIdx === curIdx) return;
174
+ targetContainerId = ids[nextIdx];
175
+ targetIndex = 0;
176
+ emitTargetChange();
177
+ }
178
+
179
+ function drop() {
180
+ if (!lifted || !sourceCtx) return;
181
+ document.dispatchEvent(new CustomEvent('dnd-drop', {
182
+ bubbles: true,
183
+ composed: false,
184
+ detail: {
185
+ source_id: id,
186
+ source_index: sourceCtx.index,
187
+ source_container_id: sourceContainerId,
188
+ target_container_id: targetContainerId,
189
+ target_index: targetIndex,
190
+ },
191
+ }));
192
+ announceImmediate(`Dropped item in ${targetContainerId} at position ${targetIndex + 1}.`);
193
+ reset();
194
+ }
195
+
196
+ function cancel() {
197
+ if (!lifted) return;
198
+ document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
199
+ bubbles: true,
200
+ composed: false,
201
+ detail: { source_id: id, reason: 'esc' },
202
+ }));
203
+ announceImmediate('Drag cancelled. Item returned to its original position.');
204
+ reset();
205
+ }
206
+
207
+ function reset() {
208
+ lifted = false;
209
+ sourceCtx = null;
210
+ sourceContainerId = null;
211
+ targetContainerId = null;
212
+ targetIndex = -1;
213
+ host.removeAttribute(ATTR_LIFTING);
214
+ }
215
+
216
+ function onKeyDown(e) {
217
+ if (isDisabled()) return;
218
+ if (e.key === ' ' || e.key === 'Enter') {
219
+ e.preventDefault();
220
+ if (!lifted) lift();
221
+ else drop();
222
+ return;
223
+ }
224
+ if (e.key === 'Escape' && lifted) {
225
+ e.preventDefault();
226
+ cancel();
227
+ return;
228
+ }
229
+ if (!lifted) return;
230
+ switch (e.key) {
231
+ case 'ArrowUp': e.preventDefault(); moveWithinContainer(-1); break;
232
+ case 'ArrowDown': e.preventDefault(); moveWithinContainer(1); break;
233
+ case 'ArrowLeft': e.preventDefault(); switchContainer(-1); break;
234
+ case 'ArrowRight': e.preventDefault(); switchContainer(1); break;
235
+ }
236
+ }
237
+
238
+ function onBlur() {
239
+ // If focus leaves the host while lifted, cancel — keyboard mode
240
+ // is bound to the focused source. Pointer drag has its own flow.
241
+ if (lifted) cancel();
242
+ }
243
+
244
+ host.addEventListener('keydown', onKeyDown);
245
+ host.addEventListener('blur', onBlur);
246
+
247
+ return () => {
248
+ host.removeEventListener('keydown', onKeyDown);
249
+ host.removeEventListener('blur', onBlur);
250
+ reset();
251
+ host.removeAttribute(ATTR_SOURCE_ID);
252
+ };
253
+ },
254
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { keyboardReorderable } from './keyboard-reorderable.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
+
5
+ // Minimal smoke coverage. Keyboard-reorderable is the WCAG 2.5.7 +
6
+ // ARIA 1.1 alternative to draggable-list-item for the
7
+ // docs/projects/tasks-playground/ DnD model. Cases below verify
8
+ // only the trait's own surface (schema, attribute lifecycle, id
9
+ // assignment, tabindex injection). Multi-container keyboard flow
10
+ // belongs in the playground integration suite.
11
+
12
+ describe('keyboard-reorderable', () => {
13
+ beforeEach(resetDOM);
14
+
15
+ it('schema shape is defined', () => {
16
+ expect(keyboardReorderable.schema.name).toBe('keyboard-reorderable');
17
+ expect(keyboardReorderable.schema.category).toBe('keyboard-navigation');
18
+ expect(keyboardReorderable.schema.description.length).toBeGreaterThanOrEqual(11);
19
+ expect(keyboardReorderable.schema.events).toContain('dnd-lift');
20
+ expect(keyboardReorderable.schema.events).toContain('dnd-drop');
21
+ });
22
+
23
+ it('connect assigns an id + ensures tabindex', () => {
24
+ const host = mountHost('div');
25
+ connectTrait(keyboardReorderable, host);
26
+ expect(host.getAttribute('data-keyboard-reorderable-id')).toBeTruthy();
27
+ expect(host.getAttribute('tabindex')).toBe('0');
28
+ });
29
+
30
+ it('connect respects pre-existing tabindex', () => {
31
+ const host = mountHost('div', { tabindex: '-1' });
32
+ connectTrait(keyboardReorderable, host);
33
+ expect(host.getAttribute('tabindex')).toBe('-1');
34
+ });
35
+
36
+ it('disconnect clears its attributes', () => {
37
+ const host = mountHost('div');
38
+ const inst = keyboardReorderable();
39
+ inst.connect(host);
40
+ host.setAttribute('data-keyboard-reorderable-lifting', '');
41
+ inst.disconnect(host);
42
+ expect(host.hasAttribute('data-keyboard-reorderable-lifting')).toBe(false);
43
+ expect(host.hasAttribute('data-keyboard-reorderable-id')).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,229 @@
1
+ import { defineTrait } from './define.js';
2
+ import { prefersReducedMotion } from './motion.js';
3
+
4
+ /**
5
+ * layout-animation — FLIP (First / Last / Invert / Play) layout transitions.
6
+ *
7
+ * Captures the host's bounding rect BEFORE a layout change, reads the new rect
8
+ * AFTER, then applies an inverse `transform: translate(dx, dy) scale(sx, sy)`
9
+ * that visually pins the host at its old position — finally animating the
10
+ * transform back to identity over a configurable duration. The host's actual
11
+ * layout-driven position (set by Grid / Flex / DOM order / class flips) is
12
+ * what triggers the animation.
13
+ *
14
+ * Trigger model — manual + observer combo:
15
+ * 1. MutationObserver on host.parentElement watching for childList changes.
16
+ * When the host is reordered among its siblings, FLIP runs.
17
+ * 2. `data-layout-animate-trigger` — set any value to fire FLIP comparing
18
+ * the cached snapshot to the current rect. Useful for class-driven
19
+ * layout shifts that don't reorder DOM nodes.
20
+ *
21
+ * Snapshots are captured on connect, after every observer-driven FLIP, and
22
+ * every time the trigger attribute changes. The snapshot read is rAF-debounced
23
+ * so a burst of mutations only schedules one capture.
24
+ *
25
+ * Reduced-motion: skip the inversion entirely; the host snaps to its new
26
+ * position. The done event still fires synchronously so caller logic gated on
27
+ * settle doesn't stall.
28
+ */
29
+ export const layoutAnimation = defineTrait({
30
+ name: 'layout-animation',
31
+ category: 'motion-positioning',
32
+ description: 'FLIP-style layout transition: animate from old to new bounds without explicit coordinates',
33
+ attributes: ['data-layout-animation-active'],
34
+ events: ['layout-animation-done'],
35
+ config: [
36
+ 'data-layout-animate-duration',
37
+ 'data-layout-animate-easing',
38
+ 'data-layout-animate-trigger',
39
+ ],
40
+ setup({ host }) {
41
+ let snapshot = null; // last captured DOMRect-shaped {x,y,w,h}
42
+ let activeAnimation = null; // the in-flight Web Animation
43
+ let captureRafId = null; // debounced rAF for snapshot capture
44
+ let mutationObserver = null;
45
+ let attrObserver = null;
46
+
47
+ function readDuration() {
48
+ const raw = host.getAttribute('data-layout-animate-duration');
49
+ const n = parseInt(raw, 10);
50
+ return Number.isFinite(n) && n > 0 ? n : 300;
51
+ }
52
+
53
+ function readEasing() {
54
+ return host.getAttribute('data-layout-animate-easing') || 'cubic-bezier(0.2, 0.8, 0.2, 1)';
55
+ }
56
+
57
+ function rectOf(el) {
58
+ // getBoundingClientRect is unavailable in some test environments
59
+ // (jsdom is fine; happy-dom returns zeros). Fall back to a zero rect
60
+ // — the FLIP becomes a no-op rather than throwing.
61
+ if (typeof el.getBoundingClientRect !== 'function') {
62
+ return { x: 0, y: 0, width: 0, height: 0 };
63
+ }
64
+ const r = el.getBoundingClientRect();
65
+ return { x: r.left, y: r.top, width: r.width, height: r.height };
66
+ }
67
+
68
+ function captureSnapshot() {
69
+ snapshot = rectOf(host);
70
+ }
71
+
72
+ function scheduleCapture() {
73
+ if (captureRafId != null) return;
74
+ if (typeof requestAnimationFrame !== 'function') {
75
+ captureSnapshot();
76
+ return;
77
+ }
78
+ captureRafId = requestAnimationFrame(() => {
79
+ captureRafId = null;
80
+ captureSnapshot();
81
+ });
82
+ }
83
+
84
+ function fireDone() {
85
+ host.dispatchEvent(new CustomEvent('layout-animation-done', { bubbles: true }));
86
+ }
87
+
88
+ function flip() {
89
+ // No prior snapshot — this is the first observation; just record and exit.
90
+ if (!snapshot) {
91
+ captureSnapshot();
92
+ return;
93
+ }
94
+
95
+ const last = rectOf(host);
96
+ const dx = snapshot.x - last.x;
97
+ const dy = snapshot.y - last.y;
98
+ const sx = snapshot.width > 0 && last.width > 0 ? snapshot.width / last.width : 1;
99
+ const sy = snapshot.height > 0 && last.height > 0 ? snapshot.height / last.height : 1;
100
+
101
+ // No meaningful delta — skip the play and re-baseline.
102
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 && Math.abs(sx - 1) < 0.01 && Math.abs(sy - 1) < 0.01) {
103
+ snapshot = last;
104
+ return;
105
+ }
106
+
107
+ // Reduced-motion: snap to the new position; fire done synchronously.
108
+ if (prefersReducedMotion()) {
109
+ snapshot = last;
110
+ host.setAttribute('data-layout-animation-active', '');
111
+ queueMicrotask(() => {
112
+ host.removeAttribute('data-layout-animation-active');
113
+ fireDone();
114
+ });
115
+ return;
116
+ }
117
+
118
+ // Cancel any previous in-flight FLIP so they don't stack.
119
+ if (activeAnimation && typeof activeAnimation.cancel === 'function') {
120
+ try { activeAnimation.cancel(); } catch { /* noop */ }
121
+ activeAnimation = null;
122
+ }
123
+
124
+ const duration = readDuration();
125
+ const easing = readEasing();
126
+
127
+ // Bail gracefully when Web Animations API isn't available (SSR, JSDOM,
128
+ // happy-dom). Snap + fire done so caller logic doesn't stall.
129
+ if (typeof host.animate !== 'function') {
130
+ snapshot = last;
131
+ host.setAttribute('data-layout-animation-active', '');
132
+ queueMicrotask(() => {
133
+ host.removeAttribute('data-layout-animation-active');
134
+ fireDone();
135
+ });
136
+ return;
137
+ }
138
+
139
+ host.setAttribute('data-layout-animation-active', '');
140
+
141
+ const fromTransform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
142
+ const toTransform = 'translate(0px, 0px) scale(1, 1)';
143
+
144
+ try {
145
+ activeAnimation = host.animate(
146
+ [
147
+ { transform: fromTransform, transformOrigin: 'top left' },
148
+ { transform: toTransform, transformOrigin: 'top left' },
149
+ ],
150
+ { duration, easing, fill: 'none' },
151
+ );
152
+ } catch {
153
+ snapshot = last;
154
+ host.removeAttribute('data-layout-animation-active');
155
+ queueMicrotask(fireDone);
156
+ return;
157
+ }
158
+
159
+ const onFinish = () => {
160
+ activeAnimation = null;
161
+ snapshot = rectOf(host);
162
+ host.removeAttribute('data-layout-animation-active');
163
+ fireDone();
164
+ };
165
+
166
+ activeAnimation.addEventListener?.('finish', onFinish, { once: true });
167
+ activeAnimation.addEventListener?.('cancel', () => {
168
+ activeAnimation = null;
169
+ host.removeAttribute('data-layout-animation-active');
170
+ }, { once: true });
171
+
172
+ // Re-baseline the snapshot to the new rect for the next FLIP.
173
+ snapshot = last;
174
+ }
175
+
176
+ // ── Observers ──────────────────────────────────────────────────────────
177
+
178
+ if (typeof MutationObserver !== 'undefined') {
179
+ const parent = host.parentElement;
180
+ if (parent) {
181
+ mutationObserver = new MutationObserver((records) => {
182
+ // Only react when the host's siblings actually shifted.
183
+ let relevant = false;
184
+ for (const r of records) {
185
+ if (r.type === 'childList') { relevant = true; break; }
186
+ }
187
+ if (relevant) flip();
188
+ });
189
+ mutationObserver.observe(parent, { childList: true });
190
+ }
191
+
192
+ // Watch our own trigger / config attrs. The trigger attr changing is
193
+ // the explicit "run a FLIP now" signal; duration/easing changes are
194
+ // read on the next FLIP without needing an observer entry.
195
+ attrObserver = new MutationObserver((records) => {
196
+ for (const r of records) {
197
+ if (r.type === 'attributes' && r.attributeName === 'data-layout-animate-trigger') {
198
+ flip();
199
+ return;
200
+ }
201
+ }
202
+ });
203
+ attrObserver.observe(host, {
204
+ attributes: true,
205
+ attributeFilter: ['data-layout-animate-trigger'],
206
+ });
207
+ }
208
+
209
+ // Capture an initial snapshot once the host has settled into its first
210
+ // layout pass. Using rAF avoids reading offsetTop / getBoundingClientRect
211
+ // before the first paint, which is unreliable for some flex containers.
212
+ scheduleCapture();
213
+
214
+ return () => {
215
+ if (captureRafId != null && typeof cancelAnimationFrame === 'function') {
216
+ cancelAnimationFrame(captureRafId);
217
+ captureRafId = null;
218
+ }
219
+ if (mutationObserver) { mutationObserver.disconnect(); mutationObserver = null; }
220
+ if (attrObserver) { attrObserver.disconnect(); attrObserver = null; }
221
+ if (activeAnimation && typeof activeAnimation.cancel === 'function') {
222
+ try { activeAnimation.cancel(); } catch { /* noop */ }
223
+ activeAnimation = null;
224
+ }
225
+ snapshot = null;
226
+ host.removeAttribute('data-layout-animation-active');
227
+ };
228
+ },
229
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { layoutAnimation } from './layout-animation.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM, raf } from './test-helpers.js';
4
+
5
+ describe('layout-animation', () => {
6
+ beforeEach(resetDOM);
7
+
8
+ it('connect does not throw and registers no leftover attribute', () => {
9
+ const host = mountHost();
10
+ const inst = connectTrait(layoutAnimation, host);
11
+ expect(host.hasAttribute('data-layout-animation-active')).toBe(false);
12
+ inst.disconnect(host);
13
+ });
14
+
15
+ it('disconnect cleans up the active attribute and is idempotent', () => {
16
+ const host = mountHost();
17
+ const inst = connectTrait(layoutAnimation, host);
18
+ host.setAttribute('data-layout-animation-active', '');
19
+ inst.disconnect(host);
20
+ expect(host.hasAttribute('data-layout-animation-active')).toBe(false);
21
+ // A second disconnect must not throw.
22
+ expect(() => inst.disconnect(host)).not.toThrow();
23
+ });
24
+
25
+ it('reads data-layout-animate-duration + data-layout-animate-easing without throwing', () => {
26
+ const host = mountHost('div', {
27
+ 'data-layout-animate-duration': '500',
28
+ 'data-layout-animate-easing': 'ease-in-out',
29
+ });
30
+ expect(() => connectTrait(layoutAnimation, host)).not.toThrow();
31
+ });
32
+
33
+ it('handles a missing duration / easing — defaults apply silently', () => {
34
+ const host = mountHost();
35
+ expect(() => connectTrait(layoutAnimation, host)).not.toThrow();
36
+ });
37
+
38
+ it('exposes the expected schema surface', () => {
39
+ expect(layoutAnimation.schema.name).toBe('layout-animation');
40
+ expect(layoutAnimation.schema.category).toBe('motion-positioning');
41
+ expect(layoutAnimation.schema.attributes).toContain('data-layout-animation-active');
42
+ expect(layoutAnimation.schema.events).toContain('layout-animation-done');
43
+ expect(layoutAnimation.schema.config).toEqual([
44
+ 'data-layout-animate-duration',
45
+ 'data-layout-animate-easing',
46
+ 'data-layout-animate-trigger',
47
+ ]);
48
+ });
49
+
50
+ it('parent-childList mutation invokes the FLIP path without throwing', async () => {
51
+ // Build a parent + sibling so we can mutate child order.
52
+ const parent = document.createElement('div');
53
+ document.body.appendChild(parent);
54
+ const sib = document.createElement('div');
55
+ const host = document.createElement('div');
56
+ parent.appendChild(sib);
57
+ parent.appendChild(host);
58
+
59
+ const inst = layoutAnimation();
60
+ inst.connect(host);
61
+
62
+ // Reorder siblings — should drive the MutationObserver callback.
63
+ expect(() => parent.insertBefore(host, sib)).not.toThrow();
64
+
65
+ // Allow the MutationObserver microtask + rAF capture to settle.
66
+ await Promise.resolve();
67
+ await raf();
68
+
69
+ inst.disconnect(host);
70
+ expect(host.hasAttribute('data-layout-animation-active')).toBe(false);
71
+ });
72
+
73
+ it('attribute-trigger flip is wired (toggling data-layout-animate-trigger does not throw)', async () => {
74
+ const host = mountHost();
75
+ const inst = connectTrait(layoutAnimation, host);
76
+
77
+ // Set the trigger — observer should fire a flip(); no DOM rect change in
78
+ // the test env means it short-circuits to "no meaningful delta", which we
79
+ // assert by absence of throws + no leftover active attribute after settle.
80
+ host.setAttribute('data-layout-animate-trigger', '1');
81
+
82
+ await Promise.resolve();
83
+ await raf();
84
+
85
+ inst.disconnect(host);
86
+ expect(host.hasAttribute('data-layout-animation-active')).toBe(false);
87
+ });
88
+
89
+ it('layout-animation-done event listener attaches without error', () => {
90
+ const host = mountHost();
91
+ const spy = spyEvent(host, 'layout-animation-done');
92
+ connectTrait(layoutAnimation, host);
93
+ // We don't assert spy.count > 0 here — the test environment (happy-dom)
94
+ // returns zero rects, so the FLIP path short-circuits at the no-delta
95
+ // gate. The contract we care about is that subscribing doesn't throw.
96
+ expect(spy.count).toBe(0);
97
+ });
98
+
99
+ it('two parallel instances on different hosts do not interfere', () => {
100
+ const a = mountHost();
101
+ const b = mountHost();
102
+ const ia = connectTrait(layoutAnimation, a);
103
+ const ib = connectTrait(layoutAnimation, b);
104
+ expect(() => ia.disconnect(a)).not.toThrow();
105
+ expect(() => ib.disconnect(b)).not.toThrow();
106
+ });
107
+
108
+ it('disconnect cancels any scheduled snapshot capture', () => {
109
+ const host = mountHost();
110
+ const inst = connectTrait(layoutAnimation, host);
111
+ // Disconnect synchronously after connect — exercises the rAF-cancel branch.
112
+ expect(() => inst.disconnect(host)).not.toThrow();
113
+ });
114
+ });