@adia-ai/web-components 0.2.3 → 0.2.5

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 (118) 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/fields/fields.a2ui.json +106 -0
  8. package/components/fields/fields.css +60 -0
  9. package/components/fields/fields.js +96 -0
  10. package/components/fields/fields.test.js +88 -0
  11. package/components/fields/fields.yaml +120 -0
  12. package/components/index.js +2 -0
  13. package/components/input/input.js +11 -0
  14. package/components/list/list.css +21 -0
  15. package/components/textarea/textarea.js +10 -0
  16. package/core/icons.js +12 -1
  17. package/package.json +10 -10
  18. package/styles/components.css +2 -0
  19. package/styles/typography.css +1 -1
  20. package/traits/_catalog.json +259 -4
  21. package/traits/active-state.test.js +1 -1
  22. package/traits/anchor-positioning.js +205 -52
  23. package/traits/anchor-positioning.test.js +77 -4
  24. package/traits/announcer-stage.js +157 -0
  25. package/traits/announcer.js +145 -0
  26. package/traits/announcer.test.js +268 -0
  27. package/traits/arrow-grid-nav.js +234 -0
  28. package/traits/arrow-grid-nav.test.js +375 -0
  29. package/traits/attention-pulse.js +1 -1
  30. package/traits/attention-pulse.test.js +1 -1
  31. package/traits/confetti-burst.js +67 -63
  32. package/traits/confetti-burst.test.js +16 -8
  33. package/traits/confetti-stage.js +143 -0
  34. package/traits/confetti.js +44 -47
  35. package/traits/confetti.test.js +24 -5
  36. package/traits/count-up.js +31 -6
  37. package/traits/count-up.test.js +1 -1
  38. package/traits/declarative.test.js +1 -1
  39. package/traits/dirty-state.test.js +1 -1
  40. package/traits/drag-ghost.js +43 -3
  41. package/traits/drag-ghost.test.js +1 -1
  42. package/traits/draggable-list-item.js +458 -0
  43. package/traits/draggable-list-item.test.js +51 -0
  44. package/traits/draggable.js +14 -4
  45. package/traits/draggable.test.js +1 -1
  46. package/traits/drop-target.js +223 -0
  47. package/traits/drop-target.test.js +241 -0
  48. package/traits/droppable-collection.js +89 -0
  49. package/traits/droppable-collection.test.js +99 -0
  50. package/traits/droppable.js +136 -0
  51. package/traits/droppable.test.js +54 -0
  52. package/traits/error-shake.js +157 -0
  53. package/traits/error-shake.test.js +114 -0
  54. package/traits/fade-presence.test.js +1 -1
  55. package/traits/focus-restore.js +135 -0
  56. package/traits/focus-restore.test.js +202 -0
  57. package/traits/focus-trap.test.js +1 -1
  58. package/traits/focusable.test.js +1 -1
  59. package/traits/glow-focus.js +1 -1
  60. package/traits/glow-focus.test.js +1 -1
  61. package/traits/gradient-shift.js +1 -1
  62. package/traits/gradient-shift.test.js +1 -1
  63. package/traits/haptic-feedback.test.js +1 -1
  64. package/traits/hotkey.test.js +1 -1
  65. package/traits/hoverable.test.js +1 -1
  66. package/traits/index.js +15 -0
  67. package/traits/inertia-drag.js +9 -0
  68. package/traits/inertia-drag.test.js +1 -1
  69. package/traits/input-mask.js +328 -0
  70. package/traits/input-mask.test.js +151 -0
  71. package/traits/intersection-observer.test.js +1 -1
  72. package/traits/keyboard-nav.test.js +1 -1
  73. package/traits/keyboard-reorderable.js +254 -0
  74. package/traits/keyboard-reorderable.test.js +45 -0
  75. package/traits/layout-animation.js +229 -0
  76. package/traits/layout-animation.test.js +114 -0
  77. package/traits/long-press.js +212 -0
  78. package/traits/long-press.test.js +244 -0
  79. package/traits/magnetic-hover.js +1 -1
  80. package/traits/magnetic-hover.test.js +1 -1
  81. package/traits/noise-texture.js +7 -3
  82. package/traits/noise-texture.test.js +1 -1
  83. package/traits/parallax.js +1 -1
  84. package/traits/parallax.test.js +1 -1
  85. package/traits/portal.test.js +1 -1
  86. package/traits/pressable.test.js +1 -1
  87. package/traits/resettable.js +29 -3
  88. package/traits/resettable.test.js +34 -1
  89. package/traits/resizable.test.js +1 -1
  90. package/traits/resize-observer.test.js +1 -1
  91. package/traits/ripple.js +1 -1
  92. package/traits/ripple.test.js +1 -1
  93. package/traits/roving-tabindex.test.js +1 -1
  94. package/traits/scale-press.test.js +1 -1
  95. package/traits/scroll-lock.test.js +1 -1
  96. package/traits/scroll-progress.js +201 -0
  97. package/traits/scroll-progress.test.js +182 -0
  98. package/traits/shimmer-loading.js +1 -1
  99. package/traits/shimmer-loading.test.js +1 -1
  100. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  101. package/traits/snap-to-grid.test.js +1 -1
  102. package/traits/sound-feedback.test.js +1 -1
  103. package/traits/spring-animate.test.js +1 -1
  104. package/traits/success-checkmark.js +222 -0
  105. package/traits/success-checkmark.test.js +120 -0
  106. package/traits/tilt-hover.js +1 -1
  107. package/traits/tilt-hover.test.js +1 -1
  108. package/traits/tossable.js +9 -0
  109. package/traits/tossable.test.js +1 -1
  110. package/traits/traits-host.test.js +1 -1
  111. package/traits/typeahead.test.js +1 -1
  112. package/traits/typewriter.js +1 -1
  113. package/traits/typewriter.test.js +1 -1
  114. package/traits/validation.test.js +1 -1
  115. package/traits/view-transition.js +140 -0
  116. package/traits/view-transition.test.js +268 -0
  117. /package/traits/{_motion.js → motion.js} +0 -0
  118. /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
@@ -0,0 +1,136 @@
1
+ import { defineTrait } from './define.js';
2
+
3
+ /**
4
+ * `droppable` — marks an element as a drop target for `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: 'input-interaction').
8
+ *
9
+ * Pairs with `draggable-list-item` (motion-positioning) which lifts a list
10
+ * item and emits `dnd-lift` / `dnd-drop-target-change` / `dnd-drop` /
11
+ * `dnd-drop-cancel` ON THE DOCUMENT. This trait observes the drag in
12
+ * flight and emits target-side events ON THE HOST:
13
+ *
14
+ * `dnd-drop-enter` pointer entered this droppable
15
+ * `dnd-drop-leave` pointer left this droppable
16
+ * `dnd-drop-receive` the drop landed here (source-side `dnd-drop`
17
+ * on the document is the lifter's "release"
18
+ * signal; the target-side event MUST be named
19
+ * differently or it bubbles back to the
20
+ * document and re-triggers this same listener
21
+ * in an infinite loop)
22
+ *
23
+ * Coordination: pure DOM events (light-DOM, bubbles: true). No shared
24
+ * runtime state. The dragging item dispatches `dnd-drop-target-change`
25
+ * with the candidate target id; the targeted droppable receives a
26
+ * synthetic `dnd-drop-enter` from a tiny internal coordinator.
27
+ *
28
+ * For v1 the coordinator is a single shared module-level Map keyed by
29
+ * `data-droppable-id`. Future versions may promote to a registry trait.
30
+ */
31
+
32
+ const ATTR_ID = 'data-droppable-id';
33
+ const ATTR_OVER = 'data-droppable-over';
34
+ const ATTR_VALID = 'data-droppable-valid';
35
+
36
+ /** @type {Map<string, HTMLElement>} */
37
+ const REGISTRY = new Map();
38
+ /** @type {string | null} */
39
+ let CURRENT_TARGET_ID = null;
40
+
41
+ export const droppable = defineTrait({
42
+ name: 'droppable',
43
+ category: 'input-interaction',
44
+ description: 'Marks an element as a drop target for draggable-list-item; emits dnd-drop-enter / leave / receive',
45
+ attributes: [ATTR_ID, ATTR_OVER, ATTR_VALID],
46
+ events: ['dnd-drop-enter', 'dnd-drop-leave', 'dnd-drop-receive'],
47
+ config: [],
48
+ setup({ host }) {
49
+ // Assign a stable id for the registry. Authoring code may set it
50
+ // explicitly via the attribute; otherwise generate.
51
+ let id = host.getAttribute(ATTR_ID);
52
+ if (!id) {
53
+ id = `drop-${crypto.randomUUID().slice(0, 8)}`;
54
+ host.setAttribute(ATTR_ID, id);
55
+ }
56
+ REGISTRY.set(id, host);
57
+
58
+ // Listen for drag-in-flight signals on the document so this droppable
59
+ // knows when it should self-mark as the target.
60
+ //
61
+ // Bug guard (2026-05-04): per-host attribute is the source of truth.
62
+ // The previous implementation gated the leave branch on a shared
63
+ // module variable CURRENT_TARGET_ID, which made the result dependent
64
+ // on listener-execution order — when the target swap fired and the
65
+ // NEW target's listener ran before the OLD target's, the old one
66
+ // saw CURRENT_TARGET_ID already moved and skipped clearing its
67
+ // ATTR_OVER, leaving multiple columns visually marked as the drop
68
+ // target. Per-host hasAttribute() is independent of order.
69
+ function onTargetChange(/** @type {CustomEvent} */ e) {
70
+ const detail = e.detail;
71
+ if (!detail) return;
72
+ const isMe = detail.target_container_id === id;
73
+ const wasOver = host.hasAttribute(ATTR_OVER);
74
+ if (isMe && !wasOver) {
75
+ host.setAttribute(ATTR_OVER, '');
76
+ CURRENT_TARGET_ID = id;
77
+ host.dispatchEvent(new CustomEvent('dnd-drop-enter', {
78
+ bubbles: true,
79
+ composed: false,
80
+ detail: { source_id: detail.source_id, pointer_position: detail.pointer ?? { x: 0, y: 0 } },
81
+ }));
82
+ } else if (!isMe && wasOver) {
83
+ host.removeAttribute(ATTR_OVER);
84
+ if (CURRENT_TARGET_ID === id) CURRENT_TARGET_ID = null;
85
+ host.dispatchEvent(new CustomEvent('dnd-drop-leave', {
86
+ bubbles: true,
87
+ composed: false,
88
+ detail: { source_id: detail.source_id ?? null },
89
+ }));
90
+ }
91
+ }
92
+
93
+ function onDrop(/** @type {CustomEvent} */ e) {
94
+ const detail = e.detail;
95
+ if (!detail || detail.target_container_id !== id) return;
96
+ host.removeAttribute(ATTR_OVER);
97
+ CURRENT_TARGET_ID = null;
98
+ host.dispatchEvent(new CustomEvent('dnd-drop-receive', {
99
+ bubbles: true,
100
+ composed: false,
101
+ detail: {
102
+ source_id: detail.source_id,
103
+ source_index: detail.source_index,
104
+ source_container_id: detail.source_container_id,
105
+ target_container_id: id,
106
+ target_index: detail.target_index,
107
+ },
108
+ }));
109
+ }
110
+
111
+ function onDragEnd() {
112
+ // Whatever happened, reset the over state.
113
+ host.removeAttribute(ATTR_OVER);
114
+ if (CURRENT_TARGET_ID === id) CURRENT_TARGET_ID = null;
115
+ }
116
+
117
+ document.addEventListener('dnd-drop-target-change', onTargetChange);
118
+ document.addEventListener('dnd-drop', onDrop);
119
+ document.addEventListener('dnd-drop-cancel', onDragEnd);
120
+
121
+ return () => {
122
+ document.removeEventListener('dnd-drop-target-change', onTargetChange);
123
+ document.removeEventListener('dnd-drop', onDrop);
124
+ document.removeEventListener('dnd-drop-cancel', onDragEnd);
125
+ REGISTRY.delete(id);
126
+ host.removeAttribute(ATTR_ID);
127
+ host.removeAttribute(ATTR_OVER);
128
+ host.removeAttribute(ATTR_VALID);
129
+ };
130
+ },
131
+ });
132
+
133
+ /** Internal: lookup a droppable host by id. Used by `draggable-list-item`. */
134
+ export function getDroppable(id) {
135
+ return REGISTRY.get(id) ?? null;
136
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { droppable } from './droppable.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
+
5
+ // Minimal smoke coverage. The droppable trait is authored against the
6
+ // docs/projects/tasks-playground/ DnD coordinator and assumes a sibling
7
+ // `draggable-list-item` trait dispatches `dnd:drop-target-change` /
8
+ // `dnd:drop` on the document. The cases below only exercise the trait's
9
+ // own surface (schema, attribute lifecycle, registry entry, cleanup) so
10
+ // the verify:traits gate stays green; the higher-fidelity DnD-flow tests
11
+ // belong with the Tasks UI initiative that owns the coordinator.
12
+
13
+ describe('droppable', () => {
14
+ beforeEach(resetDOM);
15
+
16
+ it('schema shape is defined', () => {
17
+ expect(droppable.schema.name).toBe('droppable');
18
+ expect(droppable.schema.category).toBe('input-interaction');
19
+ expect(droppable.schema.description.length).toBeGreaterThanOrEqual(11);
20
+ expect(droppable.schema.events).toContain('dnd-drop-enter');
21
+ // Source-side `dnd-drop` (on document, from the lifter) and target-side
22
+ // `dnd-drop-receive` (on host) MUST be distinct names — sharing the name
23
+ // recurses infinitely when the host event bubbles back to the document.
24
+ expect(droppable.schema.events).toContain('dnd-drop-receive');
25
+ expect(droppable.schema.events).not.toContain('dnd-drop');
26
+ });
27
+
28
+ it('connect assigns an id + sets the host attribute', () => {
29
+ const host = mountHost('div');
30
+ connectTrait(droppable, host);
31
+ expect(host.getAttribute('data-droppable-id')).toBeTruthy();
32
+ });
33
+
34
+ it('disconnect removes the over attribute if set', () => {
35
+ const host = mountHost('div');
36
+ const inst = droppable();
37
+ inst.connect(host);
38
+ host.setAttribute('data-droppable-over', '');
39
+ inst.disconnect(host);
40
+ expect(host.hasAttribute('data-droppable-over')).toBe(false);
41
+ });
42
+
43
+ it('reconnect with the same host does not throw', () => {
44
+ const host = mountHost('div');
45
+ const inst = droppable();
46
+ inst.connect(host);
47
+ inst.disconnect(host);
48
+ expect(() => {
49
+ const inst2 = droppable();
50
+ inst2.connect(host);
51
+ inst2.disconnect(host);
52
+ }).not.toThrow();
53
+ });
54
+ });
@@ -0,0 +1,157 @@
1
+ import { defineTrait } from './define.js';
2
+ import { prefersReducedMotion } from './motion.js';
3
+
4
+ /**
5
+ * `error-shake` — horizontal lateral oscillation on validation failure.
6
+ *
7
+ * The de-facto "wrong password" affordance. Listens for the `validated`
8
+ * event with `detail.valid === false` (fired by the `validation` trait),
9
+ * watches the `data-validation-invalid` attribute for false→true
10
+ * transitions, and supports a programmatic `data-error-shake-trigger`
11
+ * attribute toggle so non-form contexts can fire shakes too.
12
+ *
13
+ * Composes naturally with `validation`:
14
+ * <input-ui traits="validation error-shake" data-validate="required, email">
15
+ *
16
+ * On trigger, the host shakes for ~400ms via a translateX-only keyframe
17
+ * animation — never `margin`/`width`, so siblings don't reflow. The
18
+ * keyframes run on the compositor and the keyframe `<style>` is owned
19
+ * by the trait instance (not shared) so disconnect is clean.
20
+ *
21
+ * Reduced-motion: skip the animation entirely, but still mark
22
+ * `data-error-shake-active` for 200ms before removing — gives consumer
23
+ * stylesheets a CSS hook to swap in a static error-tint or border
24
+ * without re-querying the media query themselves.
25
+ */
26
+
27
+ const DEFAULT_AMPLITUDE = 8; // px lateral travel
28
+ const DEFAULT_DURATION = 400; // ms total
29
+ const REDUCED_MOTION_HOLD = 200; // ms — how long the marker sits in reduced-motion mode
30
+
31
+ export const errorShake = defineTrait({
32
+ name: 'error-shake',
33
+ category: 'animation-feedback',
34
+ description: 'Horizontal lateral oscillation on validation failure',
35
+ attributes: ['data-error-shake-active'],
36
+ events: ['error-shake-done'],
37
+ config: ['data-error-shake-amplitude', 'data-error-shake-duration', 'data-error-shake-trigger'],
38
+ setup({ host }) {
39
+ const activeTimers = new Set();
40
+ let styleEl = null;
41
+ let keyframeName = null;
42
+ let lastInvalid = host.hasAttribute('data-validation-invalid');
43
+
44
+ function readAmplitude() {
45
+ const v = parseFloat(host.getAttribute('data-error-shake-amplitude'));
46
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_AMPLITUDE;
47
+ }
48
+
49
+ function readDuration() {
50
+ const v = parseInt(host.getAttribute('data-error-shake-duration'), 10);
51
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_DURATION;
52
+ }
53
+
54
+ function ensureKeyframes(amp) {
55
+ if (styleEl) styleEl.remove();
56
+ keyframeName = `adia-error-shake-${Math.random().toString(36).slice(2, 8)}`;
57
+ styleEl = document.createElement('style');
58
+ // 4-cycle oscillation. translateX only — never margin/width — so
59
+ // the host's siblings don't reflow during the shake.
60
+ styleEl.textContent = `
61
+ @keyframes ${keyframeName} {
62
+ 0%, 100% { transform: translateX(0); }
63
+ 15% { transform: translateX(-${amp}px); }
64
+ 30% { transform: translateX(${amp}px); }
65
+ 45% { transform: translateX(-${amp * 0.7}px); }
66
+ 60% { transform: translateX(${amp * 0.7}px); }
67
+ 75% { transform: translateX(-${amp * 0.4}px); }
68
+ 90% { transform: translateX(${amp * 0.4}px); }
69
+ }
70
+ `;
71
+ document.head.appendChild(styleEl);
72
+ }
73
+
74
+ function fireDone() {
75
+ host.removeAttribute('data-error-shake-active');
76
+ host.dispatchEvent(new CustomEvent('error-shake-done', { bubbles: true }));
77
+ }
78
+
79
+ function reducedMotionShake() {
80
+ host.setAttribute('data-error-shake-active', '');
81
+ const t = setTimeout(() => {
82
+ activeTimers.delete(t);
83
+ fireDone();
84
+ }, REDUCED_MOTION_HOLD);
85
+ activeTimers.add(t);
86
+ }
87
+
88
+ function shake() {
89
+ if (prefersReducedMotion()) {
90
+ reducedMotionShake();
91
+ return;
92
+ }
93
+
94
+ const amp = readAmplitude();
95
+ const dur = readDuration();
96
+ ensureKeyframes(amp);
97
+
98
+ // Force-reset any in-flight animation so back-to-back triggers
99
+ // restart cleanly (e.g. user mashes submit on an invalid form).
100
+ host.style.animation = '';
101
+ // Reading offsetWidth flushes the style change — the next assignment
102
+ // is then guaranteed to be seen as a transition by the engine.
103
+ void host.offsetWidth;
104
+ host.style.animation = `${keyframeName} ${dur}ms ease-in-out`;
105
+ host.setAttribute('data-error-shake-active', '');
106
+
107
+ const t = setTimeout(() => {
108
+ activeTimers.delete(t);
109
+ host.style.animation = '';
110
+ fireDone();
111
+ }, dur);
112
+ activeTimers.add(t);
113
+ }
114
+
115
+ function onValidated(e) {
116
+ if (e?.detail && e.detail.valid === false) shake();
117
+ }
118
+
119
+ // Watch data-validation-invalid + data-error-shake-trigger for
120
+ // attribute changes. validation trait sets data-validation-invalid
121
+ // synchronously *before* dispatching `validated`, so the observer
122
+ // and event listener can both fire — we de-dupe by tracking the
123
+ // last-known invalid state and only triggering on false → true.
124
+ const observer = new MutationObserver((muts) => {
125
+ for (const m of muts) {
126
+ if (m.attributeName === 'data-validation-invalid') {
127
+ const nowInvalid = host.hasAttribute('data-validation-invalid');
128
+ if (nowInvalid && !lastInvalid) shake();
129
+ lastInvalid = nowInvalid;
130
+ } else if (m.attributeName === 'data-error-shake-trigger') {
131
+ // Toggle (any change) fires a shake. Empty-string and "" are
132
+ // both treated as "present" — the convention for boolean attrs.
133
+ if (host.hasAttribute('data-error-shake-trigger')) shake();
134
+ }
135
+ }
136
+ });
137
+ observer.observe(host, {
138
+ attributes: true,
139
+ attributeFilter: ['data-validation-invalid', 'data-error-shake-trigger'],
140
+ });
141
+
142
+ host.addEventListener('validated', onValidated);
143
+
144
+ return () => {
145
+ observer.disconnect();
146
+ host.removeEventListener('validated', onValidated);
147
+ for (const t of activeTimers) clearTimeout(t);
148
+ activeTimers.clear();
149
+ if (styleEl) {
150
+ styleEl.remove();
151
+ styleEl = null;
152
+ }
153
+ host.style.animation = '';
154
+ host.removeAttribute('data-error-shake-active');
155
+ };
156
+ },
157
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { errorShake } from './error-shake.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './test-helpers.js';
4
+
5
+ describe('error-shake', () => {
6
+ beforeEach(resetDOM);
7
+
8
+ it('schema declares animation-feedback category', () => {
9
+ expect(errorShake.schema.category).toBe('animation-feedback');
10
+ expect(errorShake.schema.events).toContain('error-shake-done');
11
+ expect(errorShake.schema.attributes).toContain('data-error-shake-active');
12
+ });
13
+
14
+ it('connect → disconnect leaves no managed attribute', () => {
15
+ const host = mountHost();
16
+ const inst = connectTrait(errorShake, host);
17
+ inst.disconnect(host);
18
+ expect(host.hasAttribute('data-error-shake-active')).toBe(false);
19
+ });
20
+
21
+ it('fires on validated event with detail.valid === false', async () => {
22
+ const host = mountHost();
23
+ connectTrait(errorShake, host);
24
+ const spy = spyEvent(host, 'error-shake-done');
25
+
26
+ host.dispatchEvent(new CustomEvent('validated', {
27
+ detail: { valid: false, errors: ['nope'] },
28
+ }));
29
+
30
+ expect(host.hasAttribute('data-error-shake-active')).toBe(true);
31
+ // Wait through the default 400ms duration + a small grace window.
32
+ await wait(450);
33
+ expect(spy.count).toBe(1);
34
+ expect(host.hasAttribute('data-error-shake-active')).toBe(false);
35
+ });
36
+
37
+ it('does not fire on validated event with detail.valid === true', async () => {
38
+ const host = mountHost();
39
+ connectTrait(errorShake, host);
40
+ const spy = spyEvent(host, 'error-shake-done');
41
+
42
+ host.dispatchEvent(new CustomEvent('validated', {
43
+ detail: { valid: true, errors: [] },
44
+ }));
45
+
46
+ await wait(50);
47
+ expect(spy.count).toBe(0);
48
+ expect(host.hasAttribute('data-error-shake-active')).toBe(false);
49
+ });
50
+
51
+ it('fires when data-validation-invalid is set (false → true)', async () => {
52
+ const host = mountHost();
53
+ connectTrait(errorShake, host);
54
+ const spy = spyEvent(host, 'error-shake-done');
55
+
56
+ host.setAttribute('data-validation-invalid', '');
57
+ // MutationObserver microtask flush.
58
+ await wait(0);
59
+ expect(host.hasAttribute('data-error-shake-active')).toBe(true);
60
+ await wait(450);
61
+ expect(spy.count).toBe(1);
62
+ });
63
+
64
+ it('does not re-fire when data-validation-invalid stays true across mutations', async () => {
65
+ const host = mountHost('div', { 'data-validation-invalid': '' });
66
+ connectTrait(errorShake, host);
67
+ const spy = spyEvent(host, 'error-shake-done');
68
+
69
+ // Re-set the attribute (no false→true edge — it was already true).
70
+ host.setAttribute('data-validation-invalid', '');
71
+ await wait(20);
72
+ expect(spy.count).toBe(0);
73
+ });
74
+
75
+ it('respects data-error-shake-trigger attribute toggle', async () => {
76
+ const host = mountHost();
77
+ connectTrait(errorShake, host);
78
+ const spy = spyEvent(host, 'error-shake-done');
79
+
80
+ host.setAttribute('data-error-shake-trigger', '');
81
+ await wait(0);
82
+ expect(host.hasAttribute('data-error-shake-active')).toBe(true);
83
+ await wait(450);
84
+ expect(spy.count).toBe(1);
85
+ });
86
+
87
+ it('respects data-error-shake-duration config', async () => {
88
+ const host = mountHost('div', { 'data-error-shake-duration': '100' });
89
+ connectTrait(errorShake, host);
90
+ const spy = spyEvent(host, 'error-shake-done');
91
+
92
+ host.dispatchEvent(new CustomEvent('validated', {
93
+ detail: { valid: false, errors: [''] },
94
+ }));
95
+
96
+ // Should resolve in ~100ms, not the default 400ms.
97
+ await wait(150);
98
+ expect(spy.count).toBe(1);
99
+ });
100
+
101
+ it('disconnect clears the keyframe <style> from the document', () => {
102
+ const host = mountHost();
103
+ const inst = connectTrait(errorShake, host);
104
+ host.dispatchEvent(new CustomEvent('validated', {
105
+ detail: { valid: false, errors: [''] },
106
+ }));
107
+ const styleCount = document.querySelectorAll('style').length;
108
+ inst.disconnect(host);
109
+ // After disconnect the keyframe style should be gone — exact count
110
+ // depends on what other styles exist, so we just assert non-greater.
111
+ expect(document.querySelectorAll('style').length).toBeLessThan(styleCount + 1);
112
+ expect(host.style.animation).toBe('');
113
+ });
114
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { fadePresence } from './fade-presence.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('fade-presence', () => {
6
6
  beforeEach(resetDOM);
@@ -0,0 +1,135 @@
1
+ import { defineTrait } from './define.js';
2
+
3
+ /**
4
+ * `focus-restore` — capture the previously-focused element on connect,
5
+ * restore focus to it on disconnect.
6
+ *
7
+ * The canonical "modal-close returns focus to the trigger" pattern. Pairs
8
+ * with `focus-trap` (which holds Tab inside a container) + `portal` (which
9
+ * moves the container into a top-layer root). focus-restore closes the
10
+ * loop: when the container goes away, focus returns to where the user was
11
+ * before it appeared.
12
+ *
13
+ * Without this trait, modals / dialogs / drawers leave focus dangling on
14
+ * `<body>` after dismiss — keyboard users have to tab from the start of
15
+ * the document to get back to the trigger they pressed.
16
+ *
17
+ * On connect:
18
+ * 1. Captures `document.activeElement` as the restore target.
19
+ * 2. Sets `data-focus-restore-active` on the host.
20
+ * 3. (Optional) Moves focus per `data-focus-restore-on-mount`:
21
+ * - absent / `"none"`: capture only, don't move focus.
22
+ * - `"host"`: focus the host itself.
23
+ * - `"first-focusable"`: focus the first tabbable descendant.
24
+ *
25
+ * On disconnect:
26
+ * 1. If the captured target is still in the document AND focusable,
27
+ * `el.focus({ preventScroll: true })` it.
28
+ * 2. Otherwise fall back to `<body>` (or the host's parent if body is
29
+ * gone for some reason).
30
+ * 3. Removes `data-focus-restore-active`.
31
+ * 4. Dispatches `focus-restored` so consumers can observe.
32
+ */
33
+
34
+ const FOCUSABLE_SELECTOR =
35
+ 'a[href], [role="button"][tabindex], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]';
36
+
37
+ function isFocusable(el) {
38
+ if (!el || !(el instanceof Element)) return false;
39
+ if (typeof el.focus !== 'function') return false;
40
+ if (el.hasAttribute('disabled')) return false;
41
+ // Element must still be connected to the document for focus to land.
42
+ if (!el.isConnected) return false;
43
+ return true;
44
+ }
45
+
46
+ function findFirstFocusable(host) {
47
+ // Try descendants in document order.
48
+ const candidates = host.querySelectorAll(FOCUSABLE_SELECTOR);
49
+ for (const el of candidates) {
50
+ if (!el.hasAttribute('disabled')) return el;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ export const focusRestore = defineTrait({
56
+ name: 'focus-restore',
57
+ category: 'keyboard-navigation',
58
+ description:
59
+ 'Capture previously-focused element on connect, restore focus on disconnect',
60
+ attributes: ['data-focus-restore-active'],
61
+ events: ['focus-restored'],
62
+ config: ['data-focus-restore-on-mount'],
63
+ setup({ host }) {
64
+ // Capture BEFORE we move focus — otherwise step 3 below would set the
65
+ // restore target to the host (or first-focusable) and the trait would
66
+ // restore to itself on disconnect, defeating the contract.
67
+ const restoreTarget = document.activeElement;
68
+
69
+ host.setAttribute('data-focus-restore-active', '');
70
+
71
+ const onMount = host.getAttribute('data-focus-restore-on-mount') || 'none';
72
+ if (onMount === 'host') {
73
+ // Host needs to be focusable for this to land — common pattern is
74
+ // tabindex="-1" on the dialog container.
75
+ try {
76
+ host.focus({ preventScroll: true });
77
+ } catch {
78
+ /* host not focusable — silent no-op, matches focus-trap's tolerance */
79
+ }
80
+ } else if (onMount === 'first-focusable') {
81
+ const first = findFirstFocusable(host);
82
+ if (first) {
83
+ try {
84
+ first.focus({ preventScroll: true });
85
+ } catch {
86
+ /* swallow — graceful degrade */
87
+ }
88
+ }
89
+ }
90
+ // onMount === 'none' (or absent / unrecognized): capture only, don't
91
+ // move focus. Pointer-opened surfaces shouldn't shift focus on mount.
92
+
93
+ return () => {
94
+ host.removeAttribute('data-focus-restore-active');
95
+
96
+ let restoredTo = null;
97
+
98
+ if (isFocusable(restoreTarget)) {
99
+ try {
100
+ restoreTarget.focus({ preventScroll: true });
101
+ restoredTo = restoreTarget;
102
+ } catch {
103
+ /* fall through to fallback */
104
+ }
105
+ }
106
+
107
+ if (!restoredTo) {
108
+ // Fallback: focus body (or host's parent if body is somehow gone).
109
+ const fallback =
110
+ (document.body && document.body.isConnected ? document.body : null) ||
111
+ host.parentElement ||
112
+ null;
113
+ if (fallback && typeof fallback.focus === 'function') {
114
+ try {
115
+ // <body> needs a tabindex to receive .focus() in some engines;
116
+ // we don't mutate it (no trait should leave document-level
117
+ // attribute residue). If focus doesn't land, the browser
118
+ // gracefully drops to no-active — same as the no-trait baseline.
119
+ fallback.focus({ preventScroll: true });
120
+ restoredTo = fallback;
121
+ } catch {
122
+ /* nothing more we can do — accept the dangle */
123
+ }
124
+ }
125
+ }
126
+
127
+ host.dispatchEvent(
128
+ new CustomEvent('focus-restored', {
129
+ bubbles: true,
130
+ detail: { restoredTo, capturedTarget: restoreTarget },
131
+ }),
132
+ );
133
+ };
134
+ },
135
+ });