@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.
- package/components/agent-trace/agent-trace.css +24 -3
- 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 +66 -3
- package/components/nav-group/nav-group.a2ui.json +1 -1
- package/components/nav-group/nav-group.css +5 -5
- package/components/nav-group/nav-group.yaml +1 -1
- package/components/nav-item/nav-item.a2ui.json +1 -1
- package/components/nav-item/nav-item.css +3 -4
- package/components/nav-item/nav-item.yaml +1 -1
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +13 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +258 -5
- 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 +90 -60
- 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 +55 -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.js +8 -3
- 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,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 './
|
|
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 './
|
|
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');
|
package/traits/focusable.test.js
CHANGED
|
@@ -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 './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('focusable', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
package/traits/glow-focus.js
CHANGED
|
@@ -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 './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('glow-focus', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
package/traits/gradient-shift.js
CHANGED
|
@@ -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 './
|
|
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 './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('haptic-feedback', () => {
|
|
6
6
|
let originalVibrate;
|