@adia-ai/web-components 0.2.0 → 0.2.2
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/README.md +5 -2
- package/components/chat-thread/chat-input.css +107 -19
- package/components/index.js +2 -1
- package/components/table/cell-types.js +1 -1
- package/core/element.js +63 -2
- package/package.json +1 -3
- package/styles/colors/semantics.css +4 -4
- package/styles/components.css +1 -1
- package/traits/_catalog.json +509 -0
- package/traits/_motion.js +57 -0
- package/traits/_smoke.test.js +111 -0
- package/traits/_test-helpers.js +82 -0
- package/traits/active-state.js +2 -0
- package/traits/active-state.test.js +28 -0
- package/traits/anchor-positioning.js +2 -0
- package/traits/anchor-positioning.test.js +49 -0
- package/traits/attention-pulse.js +11 -0
- package/traits/attention-pulse.test.js +26 -0
- package/traits/confetti-burst.js +27 -0
- package/traits/confetti-burst.test.js +38 -0
- package/traits/confetti.js +18 -0
- package/traits/confetti.test.js +27 -0
- package/traits/count-up.js +17 -0
- package/traits/count-up.test.js +54 -0
- package/traits/declarative.test.js +138 -0
- package/traits/define.js +43 -3
- package/traits/dirty-state.js +2 -0
- package/traits/dirty-state.test.js +45 -0
- package/traits/drag-ghost.js +2 -0
- package/traits/drag-ghost.test.js +19 -0
- package/traits/draggable.js +2 -0
- package/traits/draggable.test.js +60 -0
- package/traits/fade-presence.js +2 -0
- package/traits/fade-presence.test.js +20 -0
- package/traits/focus-trap.js +2 -0
- package/traits/focus-trap.test.js +42 -0
- package/traits/focusable.js +2 -0
- package/traits/focusable.test.js +53 -0
- package/traits/glow-focus.js +6 -1
- package/traits/glow-focus.test.js +31 -0
- package/traits/gradient-shift.js +9 -0
- package/traits/gradient-shift.test.js +22 -0
- package/traits/haptic-feedback.js +2 -0
- package/traits/haptic-feedback.test.js +52 -0
- package/traits/hotkey.js +2 -0
- package/traits/hotkey.test.js +61 -0
- package/traits/hoverable.js +2 -0
- package/traits/hoverable.test.js +24 -0
- package/traits/index.js +50 -37
- package/traits/inertia-drag.js +2 -0
- package/traits/inertia-drag.test.js +33 -0
- package/traits/intersection-observer.js +2 -0
- package/traits/intersection-observer.test.js +38 -0
- package/traits/keyboard-nav.js +2 -0
- package/traits/keyboard-nav.test.js +41 -0
- package/traits/magnetic-hover.js +8 -0
- package/traits/magnetic-hover.test.js +30 -0
- package/traits/noise-texture.js +2 -0
- package/traits/noise-texture.test.js +20 -0
- package/traits/parallax.js +9 -0
- package/traits/parallax.test.js +26 -0
- package/traits/portal.js +2 -0
- package/traits/portal.test.js +30 -0
- package/traits/pressable.js +2 -0
- package/traits/pressable.test.js +73 -0
- package/traits/resettable.js +40 -0
- package/traits/resettable.test.js +67 -0
- package/traits/resizable.js +2 -0
- package/traits/resizable.test.js +20 -0
- package/traits/resize-observer.js +2 -0
- package/traits/resize-observer.test.js +38 -0
- package/traits/ripple.js +9 -0
- package/traits/ripple.test.js +32 -0
- package/traits/roving-tabindex.js +2 -0
- package/traits/roving-tabindex.test.js +28 -0
- package/traits/scale-press.js +2 -0
- package/traits/scale-press.test.js +39 -0
- package/traits/scroll-lock.js +2 -0
- package/traits/scroll-lock.test.js +45 -0
- package/traits/shimmer-loading.js +20 -0
- package/traits/shimmer-loading.test.js +43 -0
- package/traits/snap-to-grid.js +2 -0
- package/traits/snap-to-grid.test.js +40 -0
- package/traits/sound-feedback.js +2 -0
- package/traits/sound-feedback.test.js +26 -0
- package/traits/spring-animate.js +2 -0
- package/traits/spring-animate.test.js +28 -0
- package/traits/tilt-hover.js +8 -0
- package/traits/tilt-hover.test.js +32 -0
- package/traits/tossable.js +2 -0
- package/traits/tossable.test.js +31 -0
- package/traits/traits-host.js +53 -0
- package/traits/traits-host.test.js +73 -0
- package/traits/typeahead.js +2 -0
- package/traits/typeahead.test.js +38 -0
- package/traits/typewriter.js +17 -0
- package/traits/typewriter.test.js +47 -0
- package/traits/validation.js +2 -0
- package/traits/validation.test.js +93 -0
- package/a2ui/index.js +0 -25
- /package/components/stat/{stat.css → stat-ui.css} +0 -0
- /package/components/stat/{stat.js → stat-ui.js} +0 -0
package/traits/define.js
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* defineTrait() — Factory for creating composable behavior traits.
|
|
3
3
|
*
|
|
4
4
|
* A trait is a reusable behavior package: event listeners, attribute management,
|
|
5
|
-
* and cleanup — defined once, attached to any element.
|
|
5
|
+
* and cleanup — defined once, attached to any element. The trait file is the
|
|
6
|
+
* single source of truth: name, category, description, attributes, events,
|
|
7
|
+
* and config all live here, and the public catalog (a2ui-corpus retrieval/
|
|
8
|
+
* catalog.js) is generated from the live registry — never hand-curated.
|
|
6
9
|
*
|
|
7
10
|
* import { defineTrait } from './define.js';
|
|
8
11
|
*
|
|
9
12
|
* export const pressable = defineTrait({
|
|
10
13
|
* name: 'pressable',
|
|
14
|
+
* category: 'input-interaction',
|
|
15
|
+
* description: 'Normalizes click/tap/keyboard into a single "press" event',
|
|
11
16
|
* attributes: ['data-pressable-pressed'],
|
|
12
17
|
* events: ['press'],
|
|
13
18
|
* config: [],
|
|
@@ -30,12 +35,31 @@
|
|
|
30
35
|
|
|
31
36
|
const registry = new Map();
|
|
32
37
|
|
|
38
|
+
const KNOWN_CATEGORIES = new Set([
|
|
39
|
+
'input-interaction',
|
|
40
|
+
'keyboard-navigation',
|
|
41
|
+
'forms-data',
|
|
42
|
+
'layout-measurement',
|
|
43
|
+
'motion-positioning',
|
|
44
|
+
'animation-feedback',
|
|
45
|
+
'visual-dynamics',
|
|
46
|
+
'interaction-delight',
|
|
47
|
+
'audio-haptics-sensory',
|
|
48
|
+
]);
|
|
49
|
+
|
|
33
50
|
export function defineTrait(spec) {
|
|
34
51
|
if (!spec.name) throw new Error('Trait requires a name');
|
|
35
52
|
if (!spec.setup) throw new Error(`Trait "${spec.name}" requires a setup function`);
|
|
53
|
+
if (!spec.category) throw new Error(`Trait "${spec.name}" requires a category`);
|
|
54
|
+
if (!KNOWN_CATEGORIES.has(spec.category)) {
|
|
55
|
+
throw new Error(`Trait "${spec.name}" has unknown category "${spec.category}". Known: ${[...KNOWN_CATEGORIES].join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
if (!spec.description) throw new Error(`Trait "${spec.name}" requires a description`);
|
|
36
58
|
|
|
37
59
|
const schema = Object.freeze({
|
|
38
60
|
name: spec.name,
|
|
61
|
+
category: spec.category,
|
|
62
|
+
description: spec.description,
|
|
39
63
|
attributes: Object.freeze(spec.attributes || []),
|
|
40
64
|
events: Object.freeze(spec.events || []),
|
|
41
65
|
config: Object.freeze(spec.config || []),
|
|
@@ -63,14 +87,30 @@ export function defineTrait(spec) {
|
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
factory.schema = schema;
|
|
66
|
-
registry.set(spec.name,
|
|
90
|
+
registry.set(spec.name, factory);
|
|
67
91
|
return factory;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Look up a trait factory by its declared name.
|
|
96
|
+
* Returns null if no trait with that name has been imported yet.
|
|
97
|
+
*
|
|
98
|
+
* Used by UIElement to resolve declarative `<comp-ui traits="pressable …">`
|
|
99
|
+
* attributes — the named trait must have been imported somewhere in the
|
|
100
|
+
* app before the element is connected, otherwise the lookup misses.
|
|
101
|
+
*/
|
|
102
|
+
export function getTrait(name) {
|
|
71
103
|
return registry.get(name) || null;
|
|
72
104
|
}
|
|
73
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Look up a trait's frozen schema by name. Convenience wrapper over
|
|
108
|
+
* `getTrait(name)?.schema`.
|
|
109
|
+
*/
|
|
110
|
+
export function getTraitSchema(name) {
|
|
111
|
+
return registry.get(name)?.schema || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
74
114
|
export function listTraits() {
|
|
75
115
|
return [...registry.keys()];
|
|
76
116
|
}
|
package/traits/dirty-state.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const dirtyState = defineTrait({
|
|
4
4
|
name: 'dirty-state',
|
|
5
|
+
category: 'forms-data',
|
|
6
|
+
description: 'Tracks modified vs initial value',
|
|
5
7
|
attributes: ['data-dirty-state-dirty', 'data-dirty-state-pristine'],
|
|
6
8
|
events: [],
|
|
7
9
|
config: [],
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { dirtyState } from './dirty-state.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('dirty-state', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('starts in pristine state', () => {
|
|
9
|
+
const host = mountHost('input', { value: 'initial' });
|
|
10
|
+
connectTrait(dirtyState, host);
|
|
11
|
+
expect(host.hasAttribute('data-dirty-state-pristine')).toBe(true);
|
|
12
|
+
expect(host.hasAttribute('data-dirty-state-dirty')).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('input event with changed value flips to dirty', () => {
|
|
16
|
+
const host = mountHost('input', { value: 'initial' });
|
|
17
|
+
connectTrait(dirtyState, host);
|
|
18
|
+
host.value = 'modified';
|
|
19
|
+
host.dispatchEvent(new Event('input'));
|
|
20
|
+
expect(host.hasAttribute('data-dirty-state-dirty')).toBe(true);
|
|
21
|
+
expect(host.hasAttribute('data-dirty-state-pristine')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('reverting to initial value flips back to pristine', () => {
|
|
25
|
+
const host = mountHost('input', { value: 'initial' });
|
|
26
|
+
connectTrait(dirtyState, host);
|
|
27
|
+
host.value = 'modified';
|
|
28
|
+
host.dispatchEvent(new Event('input'));
|
|
29
|
+
expect(host.hasAttribute('data-dirty-state-dirty')).toBe(true);
|
|
30
|
+
host.value = 'initial';
|
|
31
|
+
host.dispatchEvent(new Event('input'));
|
|
32
|
+
expect(host.hasAttribute('data-dirty-state-pristine')).toBe(true);
|
|
33
|
+
expect(host.hasAttribute('data-dirty-state-dirty')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('disconnect clears both state attributes', () => {
|
|
37
|
+
const host = mountHost('input', { value: 'x' });
|
|
38
|
+
const inst = connectTrait(dirtyState, host);
|
|
39
|
+
host.value = 'changed';
|
|
40
|
+
host.dispatchEvent(new Event('input'));
|
|
41
|
+
inst.disconnect(host);
|
|
42
|
+
expect(host.hasAttribute('data-dirty-state-dirty')).toBe(false);
|
|
43
|
+
expect(host.hasAttribute('data-dirty-state-pristine')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/traits/drag-ghost.js
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { dragGhost } from './drag-ghost.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('drag-ghost', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect attaches without throwing', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
expect(() => connectTrait(dragGhost, host)).not.toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('disconnect clears the active attribute', () => {
|
|
14
|
+
const host = mountHost();
|
|
15
|
+
const inst = connectTrait(dragGhost, host);
|
|
16
|
+
inst.disconnect(host);
|
|
17
|
+
expect(host.hasAttribute('data-drag-ghost-active')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
package/traits/draggable.js
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { draggable } from './draggable.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
function pointer(host, type, x, y) {
|
|
6
|
+
const ev = new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, bubbles: true });
|
|
7
|
+
host.dispatchEvent(ev);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('draggable', () => {
|
|
11
|
+
beforeEach(resetDOM);
|
|
12
|
+
|
|
13
|
+
it('pointerdown sets data-draggable-dragging', () => {
|
|
14
|
+
const host = mountHost();
|
|
15
|
+
connectTrait(draggable, host);
|
|
16
|
+
pointer(host, 'pointerdown', 10, 10);
|
|
17
|
+
expect(host.hasAttribute('data-draggable-dragging')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('pointermove during drag updates translate', () => {
|
|
21
|
+
const host = mountHost();
|
|
22
|
+
connectTrait(draggable, host);
|
|
23
|
+
pointer(host, 'pointerdown', 10, 10);
|
|
24
|
+
pointer(host, 'pointermove', 30, 50);
|
|
25
|
+
expect(host.style.translate).toContain('20px');
|
|
26
|
+
expect(host.style.translate).toContain('40px');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('pointerup fires drag-end with x,y in detail and clears dragging', () => {
|
|
30
|
+
const host = mountHost();
|
|
31
|
+
connectTrait(draggable, host);
|
|
32
|
+
const spy = spyEvent(host, 'drag-end');
|
|
33
|
+
pointer(host, 'pointerdown', 0, 0);
|
|
34
|
+
pointer(host, 'pointermove', 25, 75);
|
|
35
|
+
pointer(host, 'pointerup', 25, 75);
|
|
36
|
+
expect(host.hasAttribute('data-draggable-dragging')).toBe(false);
|
|
37
|
+
expect(spy.count).toBe(1);
|
|
38
|
+
expect(spy.last.x).toBe(25);
|
|
39
|
+
expect(spy.last.y).toBe(75);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('respects [disabled] — no drag, no event', () => {
|
|
43
|
+
const host = mountHost('div', { disabled: '' });
|
|
44
|
+
connectTrait(draggable, host);
|
|
45
|
+
const spy = spyEvent(host, 'drag-end');
|
|
46
|
+
pointer(host, 'pointerdown', 0, 0);
|
|
47
|
+
pointer(host, 'pointermove', 50, 50);
|
|
48
|
+
pointer(host, 'pointerup', 50, 50);
|
|
49
|
+
expect(host.hasAttribute('data-draggable-dragging')).toBe(false);
|
|
50
|
+
expect(spy.count).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('disconnect removes listeners + dragging attribute', () => {
|
|
54
|
+
const host = mountHost();
|
|
55
|
+
const inst = connectTrait(draggable, host);
|
|
56
|
+
pointer(host, 'pointerdown', 0, 0);
|
|
57
|
+
inst.disconnect(host);
|
|
58
|
+
expect(host.hasAttribute('data-draggable-dragging')).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
package/traits/fade-presence.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const fadePresence = defineTrait({
|
|
4
4
|
name: 'fade-presence',
|
|
5
|
+
category: 'animation-feedback',
|
|
6
|
+
description: 'Enter/exit fade with lifecycle',
|
|
5
7
|
attributes: ['data-fade-presence-entering', 'data-fade-presence-exiting'],
|
|
6
8
|
events: ['fade-in-done', 'fade-out-done'],
|
|
7
9
|
config: [],
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { fadePresence } from './fade-presence.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('fade-presence', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect attaches without throwing', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
expect(() => connectTrait(fadePresence, host)).not.toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('disconnect clears managed attributes', () => {
|
|
14
|
+
const host = mountHost();
|
|
15
|
+
const inst = connectTrait(fadePresence, host);
|
|
16
|
+
inst.disconnect(host);
|
|
17
|
+
expect(host.hasAttribute('data-fade-presence-entering')).toBe(false);
|
|
18
|
+
expect(host.hasAttribute('data-fade-presence-exiting')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
package/traits/focus-trap.js
CHANGED
|
@@ -4,6 +4,8 @@ const FOCUSABLE_SELECTOR = 'a[href], [role="button"][tabindex], input:not([disab
|
|
|
4
4
|
|
|
5
5
|
export const focusTrap = defineTrait({
|
|
6
6
|
name: 'focus-trap',
|
|
7
|
+
category: 'keyboard-navigation',
|
|
8
|
+
description: 'Traps Tab/Shift+Tab within a container',
|
|
7
9
|
attributes: ['data-focus-trap-active'],
|
|
8
10
|
events: ['focus-trap-escape'],
|
|
9
11
|
config: [],
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { focusTrap } from './focus-trap.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
function focusableChild(host, label) {
|
|
6
|
+
const btn = document.createElement('button');
|
|
7
|
+
btn.textContent = label;
|
|
8
|
+
host.appendChild(btn);
|
|
9
|
+
return btn;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('focus-trap', () => {
|
|
13
|
+
beforeEach(resetDOM);
|
|
14
|
+
|
|
15
|
+
it('connect sets data-focus-trap-active', () => {
|
|
16
|
+
const host = mountHost();
|
|
17
|
+
focusableChild(host, 'A');
|
|
18
|
+
focusableChild(host, 'B');
|
|
19
|
+
connectTrait(focusTrap, host);
|
|
20
|
+
expect(host.hasAttribute('data-focus-trap-active')).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('Escape fires focus-trap-escape event', () => {
|
|
24
|
+
const host = mountHost();
|
|
25
|
+
focusableChild(host, 'A');
|
|
26
|
+
connectTrait(focusTrap, host);
|
|
27
|
+
const spy = spyEvent(host, 'focus-trap-escape');
|
|
28
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
29
|
+
expect(spy.count).toBeGreaterThanOrEqual(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('disconnect clears attribute and stops listening', () => {
|
|
33
|
+
const host = mountHost();
|
|
34
|
+
focusableChild(host, 'A');
|
|
35
|
+
const inst = connectTrait(focusTrap, host);
|
|
36
|
+
const spy = spyEvent(host, 'focus-trap-escape');
|
|
37
|
+
inst.disconnect(host);
|
|
38
|
+
expect(host.hasAttribute('data-focus-trap-active')).toBe(false);
|
|
39
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
40
|
+
expect(spy.count).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/traits/focusable.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const focusable = defineTrait({
|
|
4
4
|
name: 'focusable',
|
|
5
|
+
category: 'input-interaction',
|
|
6
|
+
description: 'Keyboard-only focus ring, ignores pointer focus',
|
|
5
7
|
attributes: ['data-focusable-keyboard'],
|
|
6
8
|
events: [],
|
|
7
9
|
config: [],
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { focusable } from './focusable.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('focusable', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('keyboard focus → sets data-focusable-keyboard', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(focusable, host);
|
|
11
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
12
|
+
expect(host.hasAttribute('data-focusable-keyboard')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('mouse focus → does NOT set data-focusable-keyboard', () => {
|
|
16
|
+
const host = mountHost();
|
|
17
|
+
connectTrait(focusable, host);
|
|
18
|
+
host.dispatchEvent(new MouseEvent('mousedown'));
|
|
19
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
20
|
+
expect(host.hasAttribute('data-focusable-keyboard')).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('blur clears the attribute', () => {
|
|
24
|
+
const host = mountHost();
|
|
25
|
+
connectTrait(focusable, host);
|
|
26
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
27
|
+
expect(host.hasAttribute('data-focusable-keyboard')).toBe(true);
|
|
28
|
+
host.dispatchEvent(new FocusEvent('blur'));
|
|
29
|
+
expect(host.hasAttribute('data-focusable-keyboard')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('mousedown→blur→keyboard-focus correctly distinguishes the second visit', () => {
|
|
33
|
+
const host = mountHost();
|
|
34
|
+
connectTrait(focusable, host);
|
|
35
|
+
// First visit via mouse
|
|
36
|
+
host.dispatchEvent(new MouseEvent('mousedown'));
|
|
37
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
38
|
+
expect(host.hasAttribute('data-focusable-keyboard')).toBe(false);
|
|
39
|
+
host.dispatchEvent(new FocusEvent('blur'));
|
|
40
|
+
// Second visit via keyboard
|
|
41
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
42
|
+
expect(host.hasAttribute('data-focusable-keyboard')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('disconnect clears attribute and removes listeners', () => {
|
|
46
|
+
const host = mountHost();
|
|
47
|
+
const inst = connectTrait(focusable, host);
|
|
48
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
49
|
+
expect(host.hasAttribute('data-focusable-keyboard')).toBe(true);
|
|
50
|
+
inst.disconnect(host);
|
|
51
|
+
expect(host.hasAttribute('data-focusable-keyboard')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
package/traits/glow-focus.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { defineTrait } from './define.js';
|
|
2
|
+
import { prefersReducedMotion } from './_motion.js';
|
|
2
3
|
|
|
3
4
|
export const glowFocus = defineTrait({
|
|
4
5
|
name: 'glow-focus',
|
|
6
|
+
category: 'visual-dynamics',
|
|
7
|
+
description: 'Animated pulsing glow on focus',
|
|
5
8
|
attributes: ['data-glow-focus-active'],
|
|
6
9
|
events: [],
|
|
7
10
|
config: [],
|
|
8
11
|
setup({ host }) {
|
|
9
12
|
let savedBoxShadow = '';
|
|
13
|
+
const reduced = prefersReducedMotion();
|
|
10
14
|
|
|
11
15
|
function onFocus() {
|
|
12
16
|
savedBoxShadow = host.style.boxShadow;
|
|
13
17
|
host.style.boxShadow = '0 0 8px 2px rgba(59, 130, 246, 0.6)';
|
|
14
|
-
|
|
18
|
+
// Skip the easing under reduced-motion — instant on/off.
|
|
19
|
+
host.style.transition = reduced ? '' : 'box-shadow 200ms ease';
|
|
15
20
|
host.setAttribute('data-glow-focus-active', '');
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { glowFocus } from './glow-focus.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('glow-focus', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('focus sets data-glow-focus-active and applies box-shadow', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(glowFocus, host);
|
|
11
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
12
|
+
expect(host.hasAttribute('data-glow-focus-active')).toBe(true);
|
|
13
|
+
expect(host.style.boxShadow).toContain('rgba(59, 130, 246');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('blur clears active + restores box-shadow', () => {
|
|
17
|
+
const host = mountHost();
|
|
18
|
+
connectTrait(glowFocus, host);
|
|
19
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
20
|
+
host.dispatchEvent(new FocusEvent('blur'));
|
|
21
|
+
expect(host.hasAttribute('data-glow-focus-active')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('disconnect mid-focus clears active', () => {
|
|
25
|
+
const host = mountHost();
|
|
26
|
+
const inst = connectTrait(glowFocus, host);
|
|
27
|
+
host.dispatchEvent(new FocusEvent('focus'));
|
|
28
|
+
inst.disconnect(host);
|
|
29
|
+
expect(host.hasAttribute('data-glow-focus-active')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/traits/gradient-shift.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { defineTrait } from './define.js';
|
|
2
|
+
import { prefersReducedMotion } from './_motion.js';
|
|
2
3
|
|
|
3
4
|
export const gradientShift = defineTrait({
|
|
4
5
|
name: 'gradient-shift',
|
|
6
|
+
category: 'visual-dynamics',
|
|
7
|
+
description: 'Animated rainbow gradient backgrounds',
|
|
5
8
|
attributes: ['data-gradient-shift-active'],
|
|
6
9
|
events: [],
|
|
7
10
|
config: [],
|
|
8
11
|
setup({ host }) {
|
|
12
|
+
// Reduced-motion: leave the gradient static instead of animating it.
|
|
13
|
+
if (prefersReducedMotion()) {
|
|
14
|
+
host.setAttribute('data-gradient-shift-active', '');
|
|
15
|
+
return () => host.removeAttribute('data-gradient-shift-active');
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
const keyframeName = `adia-gradient-${Math.random().toString(36).slice(2, 8)}`;
|
|
10
19
|
|
|
11
20
|
const style = document.createElement('style');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { gradientShift } from './gradient-shift.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('gradient-shift', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect sets data-gradient-shift-active', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(gradientShift, host);
|
|
11
|
+
expect(host.hasAttribute('data-gradient-shift-active')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('disconnect clears active + reverts inline styles', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
const inst = connectTrait(gradientShift, host);
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
expect(host.hasAttribute('data-gradient-shift-active')).toBe(false);
|
|
19
|
+
expect(host.style.animation).toBe('');
|
|
20
|
+
expect(host.style.backgroundSize).toBe('');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const hapticFeedback = defineTrait({
|
|
4
4
|
name: 'haptic-feedback',
|
|
5
|
+
category: 'audio-haptics-sensory',
|
|
6
|
+
description: 'Vibration API feedback',
|
|
5
7
|
attributes: ['data-haptic-feedback-active'],
|
|
6
8
|
events: [],
|
|
7
9
|
config: ['data-haptic-pattern'],
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { hapticFeedback } from './haptic-feedback.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('haptic-feedback', () => {
|
|
6
|
+
let originalVibrate;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
resetDOM();
|
|
10
|
+
originalVibrate = navigator.vibrate;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (originalVibrate) navigator.vibrate = originalVibrate;
|
|
15
|
+
else delete navigator.vibrate;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('without navigator.vibrate: no-op cleanup, no crash', () => {
|
|
19
|
+
delete navigator.vibrate;
|
|
20
|
+
const host = mountHost();
|
|
21
|
+
expect(() => connectTrait(hapticFeedback, host)).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('with navigator.vibrate: press fires vibrate with parsed pattern', () => {
|
|
25
|
+
const vibrate = vi.fn();
|
|
26
|
+
navigator.vibrate = vibrate;
|
|
27
|
+
const host = mountHost('div', { 'data-haptic-pattern': '20,10,20' });
|
|
28
|
+
connectTrait(hapticFeedback, host);
|
|
29
|
+
expect(host.hasAttribute('data-haptic-feedback-active')).toBe(true);
|
|
30
|
+
host.dispatchEvent(new CustomEvent('press', { bubbles: true }));
|
|
31
|
+
expect(vibrate).toHaveBeenCalledWith([20, 10, 20]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('default pattern is [10] when no data-haptic-pattern set', () => {
|
|
35
|
+
const vibrate = vi.fn();
|
|
36
|
+
navigator.vibrate = vibrate;
|
|
37
|
+
const host = mountHost();
|
|
38
|
+
connectTrait(hapticFeedback, host);
|
|
39
|
+
host.dispatchEvent(new CustomEvent('press', { bubbles: true }));
|
|
40
|
+
expect(vibrate).toHaveBeenCalledWith([10]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('disconnect removes the press listener', () => {
|
|
44
|
+
const vibrate = vi.fn();
|
|
45
|
+
navigator.vibrate = vibrate;
|
|
46
|
+
const host = mountHost();
|
|
47
|
+
const inst = connectTrait(hapticFeedback, host);
|
|
48
|
+
inst.disconnect(host);
|
|
49
|
+
host.dispatchEvent(new CustomEvent('press', { bubbles: true }));
|
|
50
|
+
expect(vibrate).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
});
|
package/traits/hotkey.js
CHANGED
|
@@ -32,6 +32,8 @@ function matchesCombo(e, combo) {
|
|
|
32
32
|
|
|
33
33
|
export const hotkey = defineTrait({
|
|
34
34
|
name: 'hotkey',
|
|
35
|
+
category: 'keyboard-navigation',
|
|
36
|
+
description: 'Global or scoped keyboard shortcuts',
|
|
35
37
|
attributes: [],
|
|
36
38
|
events: ['hotkey'],
|
|
37
39
|
config: ['data-hotkey', 'data-hotkey-global'],
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { hotkey } from './hotkey.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('hotkey', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('fires "hotkey" event on matching combo', () => {
|
|
9
|
+
const host = mountHost('div', { 'data-hotkey': 'ctrl+k' });
|
|
10
|
+
connectTrait(hotkey, host);
|
|
11
|
+
const spy = spyEvent(host, 'hotkey');
|
|
12
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
|
|
13
|
+
expect(spy.count).toBe(1);
|
|
14
|
+
expect(spy.last.combo).toBe('ctrl+k');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('does not fire on non-matching combo', () => {
|
|
18
|
+
const host = mountHost('div', { 'data-hotkey': 'ctrl+k' });
|
|
19
|
+
connectTrait(hotkey, host);
|
|
20
|
+
const spy = spyEvent(host, 'hotkey');
|
|
21
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', ctrlKey: true }));
|
|
22
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
|
|
23
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', altKey: true }));
|
|
24
|
+
expect(spy.count).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('shift modifier required when declared', () => {
|
|
28
|
+
const host = mountHost('div', { 'data-hotkey': 'shift+a' });
|
|
29
|
+
connectTrait(hotkey, host);
|
|
30
|
+
const spy = spyEvent(host, 'hotkey');
|
|
31
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
|
32
|
+
expect(spy.count).toBe(0);
|
|
33
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', shiftKey: true }));
|
|
34
|
+
expect(spy.count).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('global mode listens on document', () => {
|
|
38
|
+
const host = mountHost('div', { 'data-hotkey': 'ctrl+/', 'data-hotkey-global': '' });
|
|
39
|
+
connectTrait(hotkey, host);
|
|
40
|
+
const spy = spyEvent(host, 'hotkey');
|
|
41
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', ctrlKey: true }));
|
|
42
|
+
expect(spy.count).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('non-global mode does not listen on document', () => {
|
|
46
|
+
const host = mountHost('div', { 'data-hotkey': 'ctrl+/' });
|
|
47
|
+
connectTrait(hotkey, host);
|
|
48
|
+
const spy = spyEvent(host, 'hotkey');
|
|
49
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', ctrlKey: true }));
|
|
50
|
+
expect(spy.count).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('disconnect removes the keydown listener', () => {
|
|
54
|
+
const host = mountHost('div', { 'data-hotkey': 'ctrl+k', 'data-hotkey-global': '' });
|
|
55
|
+
const inst = connectTrait(hotkey, host);
|
|
56
|
+
const spy = spyEvent(host, 'hotkey');
|
|
57
|
+
inst.disconnect(host);
|
|
58
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
|
|
59
|
+
expect(spy.count).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
package/traits/hoverable.js
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { hoverable } from './hoverable.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('hoverable', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('pointerenter sets data-hoverable-hover; pointerleave clears it', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(hoverable, host);
|
|
11
|
+
host.dispatchEvent(new PointerEvent('pointerenter'));
|
|
12
|
+
expect(host.hasAttribute('data-hoverable-hover')).toBe(true);
|
|
13
|
+
host.dispatchEvent(new PointerEvent('pointerleave'));
|
|
14
|
+
expect(host.hasAttribute('data-hoverable-hover')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('disconnect clears attribute even mid-hover', () => {
|
|
18
|
+
const host = mountHost();
|
|
19
|
+
const inst = connectTrait(hoverable, host);
|
|
20
|
+
host.dispatchEvent(new PointerEvent('pointerenter'));
|
|
21
|
+
inst.disconnect(host);
|
|
22
|
+
expect(host.hasAttribute('data-hoverable-hover')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|