@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,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
|
+
});
|