@adia-ai/web-components 0.2.2 → 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 (121) hide show
  1. package/components/agent-trace/agent-trace.css +24 -3
  2. package/components/button/button.js +3 -0
  3. package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
  4. package/components/demo-toggle/demo-toggle.css +120 -0
  5. package/components/demo-toggle/demo-toggle.js +144 -0
  6. package/components/demo-toggle/demo-toggle.test.js +102 -0
  7. package/components/demo-toggle/demo-toggle.yaml +144 -0
  8. package/components/index.js +1 -0
  9. package/components/input/input.js +11 -0
  10. package/components/list/list.css +66 -3
  11. package/components/nav-group/nav-group.a2ui.json +1 -1
  12. package/components/nav-group/nav-group.css +5 -5
  13. package/components/nav-group/nav-group.yaml +1 -1
  14. package/components/nav-item/nav-item.a2ui.json +1 -1
  15. package/components/nav-item/nav-item.css +3 -4
  16. package/components/nav-item/nav-item.yaml +1 -1
  17. package/components/textarea/textarea.js +10 -0
  18. package/core/icons.js +13 -1
  19. package/package.json +1 -1
  20. package/styles/components.css +1 -0
  21. package/styles/typography.css +1 -1
  22. package/traits/_catalog.json +258 -5
  23. package/traits/active-state.test.js +1 -1
  24. package/traits/anchor-positioning.js +205 -52
  25. package/traits/anchor-positioning.test.js +77 -4
  26. package/traits/announcer-stage.js +157 -0
  27. package/traits/announcer.js +145 -0
  28. package/traits/announcer.test.js +268 -0
  29. package/traits/arrow-grid-nav.js +234 -0
  30. package/traits/arrow-grid-nav.test.js +375 -0
  31. package/traits/attention-pulse.js +1 -1
  32. package/traits/attention-pulse.test.js +1 -1
  33. package/traits/confetti-burst.js +90 -60
  34. package/traits/confetti-burst.test.js +16 -8
  35. package/traits/confetti-stage.js +143 -0
  36. package/traits/confetti.js +44 -47
  37. package/traits/confetti.test.js +24 -5
  38. package/traits/count-up.js +31 -6
  39. package/traits/count-up.test.js +1 -1
  40. package/traits/declarative.test.js +1 -1
  41. package/traits/dirty-state.test.js +1 -1
  42. package/traits/drag-ghost.js +55 -3
  43. package/traits/drag-ghost.test.js +1 -1
  44. package/traits/draggable-list-item.js +279 -0
  45. package/traits/draggable-list-item.test.js +51 -0
  46. package/traits/draggable.js +14 -4
  47. package/traits/draggable.test.js +1 -1
  48. package/traits/drop-target.js +223 -0
  49. package/traits/drop-target.test.js +241 -0
  50. package/traits/droppable-collection.js +89 -0
  51. package/traits/droppable-collection.test.js +99 -0
  52. package/traits/droppable.js +125 -0
  53. package/traits/droppable.test.js +54 -0
  54. package/traits/error-shake.js +157 -0
  55. package/traits/error-shake.test.js +114 -0
  56. package/traits/fade-presence.test.js +1 -1
  57. package/traits/focus-restore.js +135 -0
  58. package/traits/focus-restore.test.js +202 -0
  59. package/traits/focus-trap.test.js +1 -1
  60. package/traits/focusable.test.js +1 -1
  61. package/traits/glow-focus.js +1 -1
  62. package/traits/glow-focus.test.js +1 -1
  63. package/traits/gradient-shift.js +1 -1
  64. package/traits/gradient-shift.test.js +1 -1
  65. package/traits/haptic-feedback.test.js +1 -1
  66. package/traits/hotkey.test.js +1 -1
  67. package/traits/hoverable.test.js +1 -1
  68. package/traits/index.js +15 -0
  69. package/traits/inertia-drag.js +9 -0
  70. package/traits/inertia-drag.test.js +1 -1
  71. package/traits/input-mask.js +328 -0
  72. package/traits/input-mask.test.js +151 -0
  73. package/traits/intersection-observer.test.js +1 -1
  74. package/traits/keyboard-nav.test.js +1 -1
  75. package/traits/keyboard-reorderable.js +254 -0
  76. package/traits/keyboard-reorderable.test.js +45 -0
  77. package/traits/layout-animation.js +229 -0
  78. package/traits/layout-animation.test.js +114 -0
  79. package/traits/long-press.js +212 -0
  80. package/traits/long-press.test.js +244 -0
  81. package/traits/magnetic-hover.js +1 -1
  82. package/traits/magnetic-hover.test.js +1 -1
  83. package/traits/noise-texture.js +7 -3
  84. package/traits/noise-texture.test.js +1 -1
  85. package/traits/parallax.js +1 -1
  86. package/traits/parallax.test.js +1 -1
  87. package/traits/portal.test.js +1 -1
  88. package/traits/pressable.test.js +1 -1
  89. package/traits/resettable.js +29 -3
  90. package/traits/resettable.test.js +34 -1
  91. package/traits/resizable.test.js +1 -1
  92. package/traits/resize-observer.test.js +1 -1
  93. package/traits/ripple.js +1 -1
  94. package/traits/ripple.test.js +1 -1
  95. package/traits/roving-tabindex.test.js +1 -1
  96. package/traits/scale-press.test.js +1 -1
  97. package/traits/scroll-lock.test.js +1 -1
  98. package/traits/scroll-progress.js +201 -0
  99. package/traits/scroll-progress.test.js +182 -0
  100. package/traits/shimmer-loading.js +1 -1
  101. package/traits/shimmer-loading.test.js +1 -1
  102. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  103. package/traits/snap-to-grid.test.js +1 -1
  104. package/traits/sound-feedback.test.js +1 -1
  105. package/traits/spring-animate.js +8 -3
  106. package/traits/spring-animate.test.js +1 -1
  107. package/traits/success-checkmark.js +222 -0
  108. package/traits/success-checkmark.test.js +120 -0
  109. package/traits/tilt-hover.js +1 -1
  110. package/traits/tilt-hover.test.js +1 -1
  111. package/traits/tossable.js +9 -0
  112. package/traits/tossable.test.js +1 -1
  113. package/traits/traits-host.test.js +1 -1
  114. package/traits/typeahead.test.js +1 -1
  115. package/traits/typewriter.js +1 -1
  116. package/traits/typewriter.test.js +1 -1
  117. package/traits/validation.test.js +1 -1
  118. package/traits/view-transition.js +140 -0
  119. package/traits/view-transition.test.js +268 -0
  120. /package/traits/{_motion.js → motion.js} +0 -0
  121. /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
@@ -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
+ });
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { focusRestore } from './focus-restore.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
4
+
5
+ function focusableTrigger(label = 'Trigger') {
6
+ const btn = document.createElement('button');
7
+ btn.textContent = label;
8
+ document.body.appendChild(btn);
9
+ return btn;
10
+ }
11
+
12
+ function focusableChild(host, label = 'child', tag = 'button') {
13
+ const el = document.createElement(tag);
14
+ el.textContent = label;
15
+ if (tag !== 'button') el.setAttribute('tabindex', '0');
16
+ host.appendChild(el);
17
+ return el;
18
+ }
19
+
20
+ describe('focus-restore', () => {
21
+ beforeEach(resetDOM);
22
+
23
+ it('connect sets data-focus-restore-active', () => {
24
+ const host = mountHost();
25
+ connectTrait(focusRestore, host);
26
+ expect(host.hasAttribute('data-focus-restore-active')).toBe(true);
27
+ });
28
+
29
+ it('disconnect clears data-focus-restore-active', () => {
30
+ const host = mountHost();
31
+ const inst = connectTrait(focusRestore, host);
32
+ inst.disconnect(host);
33
+ expect(host.hasAttribute('data-focus-restore-active')).toBe(false);
34
+ });
35
+
36
+ it('captures activeElement on connect and restores it on disconnect', () => {
37
+ const trigger = focusableTrigger('Open');
38
+ trigger.focus();
39
+ expect(document.activeElement).toBe(trigger);
40
+
41
+ const host = mountHost();
42
+ const inst = connectTrait(focusRestore, host);
43
+
44
+ // Some other element steals focus while the surface is open.
45
+ const distract = focusableTrigger('Distract');
46
+ distract.focus();
47
+ expect(document.activeElement).toBe(distract);
48
+
49
+ inst.disconnect(host);
50
+
51
+ // Focus snaps back to the originally-captured trigger.
52
+ expect(document.activeElement).toBe(trigger);
53
+ });
54
+
55
+ it('falls back to body when the captured target is removed from DOM', () => {
56
+ const trigger = focusableTrigger('Will be removed');
57
+ trigger.focus();
58
+
59
+ const host = mountHost();
60
+ const inst = connectTrait(focusRestore, host);
61
+
62
+ // Trigger gets nuked while the surface is open — common pattern when
63
+ // the container that opened the modal re-renders.
64
+ trigger.remove();
65
+ expect(trigger.isConnected).toBe(false);
66
+
67
+ inst.disconnect(host);
68
+
69
+ // Restore must not throw. Focus lands on body (or null) — never the
70
+ // removed trigger. The contract is "don't dangle" not "always lands".
71
+ expect(document.activeElement).not.toBe(trigger);
72
+ expect(document.activeElement === document.body || document.activeElement === null).toBe(true);
73
+ });
74
+
75
+ it('falls back when captured target is disabled', () => {
76
+ const trigger = focusableTrigger('Will be disabled');
77
+ trigger.focus();
78
+
79
+ const host = mountHost();
80
+ const inst = connectTrait(focusRestore, host);
81
+
82
+ // Trigger gets disabled while surface is open.
83
+ trigger.setAttribute('disabled', '');
84
+
85
+ inst.disconnect(host);
86
+
87
+ // Disabled element shouldn't receive focus — fallback path engages.
88
+ expect(document.activeElement).not.toBe(trigger);
89
+ });
90
+
91
+ it('dispatches focus-restored on disconnect with detail.restoredTo + detail.capturedTarget', () => {
92
+ const trigger = focusableTrigger('Open');
93
+ trigger.focus();
94
+
95
+ const host = mountHost();
96
+ const inst = connectTrait(focusRestore, host);
97
+ const spy = spyEvent(host, 'focus-restored');
98
+
99
+ inst.disconnect(host);
100
+
101
+ expect(spy.count).toBe(1);
102
+ expect(spy.last.capturedTarget).toBe(trigger);
103
+ expect(spy.last.restoredTo).toBe(trigger);
104
+ });
105
+
106
+ it('data-focus-restore-on-mount="none" (default): does NOT move focus on connect', () => {
107
+ const trigger = focusableTrigger('Stay focused');
108
+ trigger.focus();
109
+
110
+ const host = mountHost();
111
+ focusableChild(host, 'inside');
112
+ connectTrait(focusRestore, host);
113
+
114
+ // Default behavior — captures, doesn't move focus.
115
+ expect(document.activeElement).toBe(trigger);
116
+ });
117
+
118
+ it('data-focus-restore-on-mount="host": moves focus to the host on connect', () => {
119
+ const trigger = focusableTrigger('Open');
120
+ trigger.focus();
121
+
122
+ const host = mountHost('div', {
123
+ tabindex: '-1',
124
+ 'data-focus-restore-on-mount': 'host',
125
+ });
126
+ connectTrait(focusRestore, host);
127
+
128
+ expect(document.activeElement).toBe(host);
129
+ });
130
+
131
+ it('data-focus-restore-on-mount="first-focusable": focuses first tabbable descendant', () => {
132
+ const trigger = focusableTrigger('Open');
133
+ trigger.focus();
134
+
135
+ const host = mountHost('div', {
136
+ 'data-focus-restore-on-mount': 'first-focusable',
137
+ });
138
+ const first = focusableChild(host, 'First');
139
+ focusableChild(host, 'Second');
140
+ connectTrait(focusRestore, host);
141
+
142
+ expect(document.activeElement).toBe(first);
143
+ });
144
+
145
+ it('first-focusable: skips disabled descendants', () => {
146
+ const trigger = focusableTrigger('Open');
147
+ trigger.focus();
148
+
149
+ const host = mountHost('div', {
150
+ 'data-focus-restore-on-mount': 'first-focusable',
151
+ });
152
+ const disabled = focusableChild(host, 'Disabled');
153
+ disabled.setAttribute('disabled', '');
154
+ const enabled = focusableChild(host, 'Enabled');
155
+ connectTrait(focusRestore, host);
156
+
157
+ expect(document.activeElement).toBe(enabled);
158
+ });
159
+
160
+ it('on-mount captures BEFORE moving focus, so disconnect restores correctly', () => {
161
+ // Regression guard: the trait must capture activeElement before
162
+ // its own focus shift, otherwise disconnect would restore to the
163
+ // host (or first child), not the original trigger.
164
+ const trigger = focusableTrigger('Open');
165
+ trigger.focus();
166
+
167
+ const host = mountHost('div', {
168
+ tabindex: '-1',
169
+ 'data-focus-restore-on-mount': 'host',
170
+ });
171
+ const inst = connectTrait(focusRestore, host);
172
+ expect(document.activeElement).toBe(host);
173
+
174
+ inst.disconnect(host);
175
+ expect(document.activeElement).toBe(trigger);
176
+ });
177
+
178
+ it('connect → disconnect → connect cycle works', () => {
179
+ const trigger = focusableTrigger('Reusable');
180
+ trigger.focus();
181
+
182
+ const host = mountHost();
183
+ const inst1 = connectTrait(focusRestore, host);
184
+ inst1.disconnect(host);
185
+ expect(document.activeElement).toBe(trigger);
186
+
187
+ // Refocus and run again — fresh capture, fresh restore.
188
+ const trigger2 = focusableTrigger('Different trigger');
189
+ trigger2.focus();
190
+ const inst2 = connectTrait(focusRestore, host);
191
+ inst2.disconnect(host);
192
+ expect(document.activeElement).toBe(trigger2);
193
+ });
194
+
195
+ it('schema declares the documented contract', () => {
196
+ expect(focusRestore.schema.name).toBe('focus-restore');
197
+ expect(focusRestore.schema.category).toBe('keyboard-navigation');
198
+ expect(focusRestore.schema.attributes).toContain('data-focus-restore-active');
199
+ expect(focusRestore.schema.events).toContain('focus-restored');
200
+ expect(focusRestore.schema.config).toContain('data-focus-restore-on-mount');
201
+ });
202
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { focusTrap } from './focus-trap.js';
3
- import { mountHost, connectTrait, spyEvent, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
4
4
 
5
5
  function focusableChild(host, label) {
6
6
  const btn = document.createElement('button');
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { focusable } from './focusable.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('focusable', () => {
6
6
  beforeEach(resetDOM);
@@ -1,5 +1,5 @@
1
1
  import { defineTrait } from './define.js';
2
- import { prefersReducedMotion } from './_motion.js';
2
+ import { prefersReducedMotion } from './motion.js';
3
3
 
4
4
  export const glowFocus = defineTrait({
5
5
  name: 'glow-focus',
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { glowFocus } from './glow-focus.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('glow-focus', () => {
6
6
  beforeEach(resetDOM);
@@ -1,5 +1,5 @@
1
1
  import { defineTrait } from './define.js';
2
- import { prefersReducedMotion } from './_motion.js';
2
+ import { prefersReducedMotion } from './motion.js';
3
3
 
4
4
  export const gradientShift = defineTrait({
5
5
  name: 'gradient-shift',
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { gradientShift } from './gradient-shift.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('gradient-shift', () => {
6
6
  beforeEach(resetDOM);
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import { hapticFeedback } from './haptic-feedback.js';
3
- import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
3
+ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('haptic-feedback', () => {
6
6
  let originalVibrate;