@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/index.js
CHANGED
|
@@ -1,55 +1,68 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Trait barrel — categories below mirror the canonical taxonomy in
|
|
2
|
+
// _catalog.json (generated from defineTrait() metadata). When adding a
|
|
3
|
+
// trait, place it under its category header here, then run
|
|
4
|
+
// `npm run build:traits` to refresh the catalog.
|
|
5
|
+
//
|
|
6
|
+
// Side-effect: the <traits-host> wrapper is auto-registered when this
|
|
7
|
+
// barrel is imported, so consumers who use the trait library at all
|
|
8
|
+
// also get raw-HTML declarative composition for free.
|
|
9
|
+
import './traits-host.js';
|
|
10
|
+
|
|
11
|
+
// input-interaction
|
|
2
12
|
export { pressable } from './pressable.js';
|
|
3
|
-
export { hoverable } from './hoverable.js';
|
|
4
13
|
export { focusable } from './focusable.js';
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export { inertiaDrag } from './inertia-drag.js';
|
|
8
|
-
export { tossable } from './tossable.js';
|
|
9
|
-
export { scalePress } from './scale-press.js';
|
|
14
|
+
export { hoverable } from './hoverable.js';
|
|
15
|
+
export { activeState } from './active-state.js';
|
|
10
16
|
|
|
11
|
-
//
|
|
12
|
-
export { focusTrap } from './focus-trap.js';
|
|
17
|
+
// keyboard-navigation
|
|
13
18
|
export { keyboardNav } from './keyboard-nav.js';
|
|
14
|
-
export { hotkey } from './hotkey.js';
|
|
15
19
|
export { rovingTabindex } from './roving-tabindex.js';
|
|
16
20
|
export { typeahead } from './typeahead.js';
|
|
21
|
+
export { hotkey } from './hotkey.js';
|
|
22
|
+
export { focusTrap } from './focus-trap.js';
|
|
17
23
|
|
|
18
|
-
//
|
|
19
|
-
export { dirtyState } from './dirty-state.js';
|
|
24
|
+
// forms-data
|
|
20
25
|
export { validation } from './validation.js';
|
|
26
|
+
export { dirtyState } from './dirty-state.js';
|
|
27
|
+
export { resettable } from './resettable.js';
|
|
21
28
|
|
|
22
|
-
//
|
|
29
|
+
// layout-measurement
|
|
30
|
+
export { resizeObserver } from './resize-observer.js';
|
|
31
|
+
export { intersectionObserver } from './intersection-observer.js';
|
|
32
|
+
export { anchorPositioning } from './anchor-positioning.js';
|
|
33
|
+
export { portal } from './portal.js';
|
|
34
|
+
export { scrollLock } from './scroll-lock.js';
|
|
35
|
+
|
|
36
|
+
// motion-positioning
|
|
37
|
+
export { draggable } from './draggable.js';
|
|
38
|
+
export { tossable } from './tossable.js';
|
|
39
|
+
export { resizable } from './resizable.js';
|
|
40
|
+
export { inertiaDrag } from './inertia-drag.js';
|
|
41
|
+
export { snapToGrid } from './snap-to-grid.js';
|
|
42
|
+
export { dragGhost } from './drag-ghost.js';
|
|
43
|
+
|
|
44
|
+
// animation-feedback
|
|
45
|
+
export { ripple } from './ripple.js';
|
|
46
|
+
export { springAnimate } from './spring-animate.js';
|
|
23
47
|
export { fadePresence } from './fade-presence.js';
|
|
24
|
-
export {
|
|
25
|
-
export {
|
|
48
|
+
export { scalePress } from './scale-press.js';
|
|
49
|
+
export { tiltHover } from './tilt-hover.js';
|
|
50
|
+
|
|
51
|
+
// visual-dynamics
|
|
26
52
|
export { glowFocus } from './glow-focus.js';
|
|
27
53
|
export { gradientShift } from './gradient-shift.js';
|
|
28
|
-
export {
|
|
29
|
-
export { confettiBurst } from './confetti-burst.js';
|
|
30
|
-
export { springAnimate } from './spring-animate.js';
|
|
54
|
+
export { parallax } from './parallax.js';
|
|
31
55
|
export { shimmerLoading } from './shimmer-loading.js';
|
|
32
|
-
export {
|
|
56
|
+
export { noiseTexture } from './noise-texture.js';
|
|
33
57
|
|
|
34
|
-
//
|
|
35
|
-
export { anchorPositioning } from './anchor-positioning.js';
|
|
36
|
-
export { portal } from './portal.js';
|
|
37
|
-
export { parallax } from './parallax.js';
|
|
58
|
+
// interaction-delight
|
|
38
59
|
export { magneticHover } from './magnetic-hover.js';
|
|
39
|
-
export {
|
|
40
|
-
export {
|
|
41
|
-
|
|
42
|
-
// Observer
|
|
43
|
-
export { intersectionObserver } from './intersection-observer.js';
|
|
44
|
-
export { resizeObserver } from './resize-observer.js';
|
|
60
|
+
export { confetti } from './confetti.js';
|
|
61
|
+
export { confettiBurst } from './confetti-burst.js';
|
|
45
62
|
|
|
46
|
-
//
|
|
47
|
-
export { noiseTexture } from './noise-texture.js';
|
|
48
|
-
export { dragGhost } from './drag-ghost.js';
|
|
49
|
-
export { hapticFeedback } from './haptic-feedback.js';
|
|
50
|
-
export { tiltHover } from './tilt-hover.js';
|
|
63
|
+
// audio-haptics-sensory
|
|
51
64
|
export { soundFeedback } from './sound-feedback.js';
|
|
65
|
+
export { hapticFeedback } from './haptic-feedback.js';
|
|
52
66
|
export { typewriter } from './typewriter.js';
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
export { activeState } from './active-state.js';
|
|
67
|
+
export { countUp } from './count-up.js';
|
|
68
|
+
export { attentionPulse } from './attention-pulse.js';
|
package/traits/inertia-drag.js
CHANGED
|
@@ -5,6 +5,8 @@ const MIN_VELOCITY = 0.5;
|
|
|
5
5
|
|
|
6
6
|
export const inertiaDrag = defineTrait({
|
|
7
7
|
name: 'inertia-drag',
|
|
8
|
+
category: 'motion-positioning',
|
|
9
|
+
description: 'Momentum-based dragging, smooth deceleration',
|
|
8
10
|
attributes: ['data-inertia-drag-coasting'],
|
|
9
11
|
events: ['drag-end'],
|
|
10
12
|
config: [],
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { inertiaDrag } from './inertia-drag.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
function pointer(host, type, x, y) {
|
|
6
|
+
host.dispatchEvent(new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, bubbles: true }));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('inertia-drag', () => {
|
|
10
|
+
beforeEach(resetDOM);
|
|
11
|
+
|
|
12
|
+
it('connect attaches without throwing', () => {
|
|
13
|
+
const host = mountHost();
|
|
14
|
+
expect(() => connectTrait(inertiaDrag, host)).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('drag sequence does not throw + disconnect cleans up', () => {
|
|
18
|
+
const host = mountHost();
|
|
19
|
+
const inst = connectTrait(inertiaDrag, host);
|
|
20
|
+
pointer(host, 'pointerdown', 0, 0);
|
|
21
|
+
pointer(host, 'pointermove', 30, 30);
|
|
22
|
+
pointer(host, 'pointerup', 30, 30);
|
|
23
|
+
expect(() => inst.disconnect(host)).not.toThrow();
|
|
24
|
+
expect(host.hasAttribute('data-inertia-drag-coasting')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('respects [disabled] — pointerdown is a no-op', () => {
|
|
28
|
+
const host = mountHost('div', { disabled: '' });
|
|
29
|
+
connectTrait(inertiaDrag, host);
|
|
30
|
+
pointer(host, 'pointerdown', 0, 0);
|
|
31
|
+
expect(host.hasAttribute('data-inertia-drag-coasting')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const intersectionObserver = defineTrait({
|
|
4
4
|
name: 'intersection-observer',
|
|
5
|
+
category: 'layout-measurement',
|
|
6
|
+
description: 'Visibility detection (viewport)',
|
|
5
7
|
attributes: ['data-intersection-visible', 'data-intersection-ratio'],
|
|
6
8
|
events: ['element-visible', 'element-hidden'],
|
|
7
9
|
config: ['data-intersection-threshold'],
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { intersectionObserver } from './intersection-observer.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('intersection-observer', () => {
|
|
6
|
+
let originalIO;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
resetDOM();
|
|
10
|
+
originalIO = globalThis.IntersectionObserver;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (originalIO) globalThis.IntersectionObserver = originalIO;
|
|
15
|
+
else delete globalThis.IntersectionObserver;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('without IntersectionObserver: gracefully no-ops on connect', () => {
|
|
19
|
+
delete globalThis.IntersectionObserver;
|
|
20
|
+
const host = mountHost();
|
|
21
|
+
expect(() => connectTrait(intersectionObserver, host)).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('with mocked IntersectionObserver: observe() is called on connect', () => {
|
|
25
|
+
const observe = vi.fn();
|
|
26
|
+
const ioDisconnect = vi.fn();
|
|
27
|
+
globalThis.IntersectionObserver = function MockIO() {
|
|
28
|
+
this.observe = observe;
|
|
29
|
+
this.disconnect = ioDisconnect;
|
|
30
|
+
this.unobserve = vi.fn();
|
|
31
|
+
};
|
|
32
|
+
const host = mountHost('div', { 'data-intersection-threshold': '0.5' });
|
|
33
|
+
const inst = connectTrait(intersectionObserver, host);
|
|
34
|
+
expect(observe).toHaveBeenCalledWith(host);
|
|
35
|
+
inst.disconnect(host);
|
|
36
|
+
expect(ioDisconnect).toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
});
|
package/traits/keyboard-nav.js
CHANGED
|
@@ -13,6 +13,8 @@ const KEY_MAP = {
|
|
|
13
13
|
|
|
14
14
|
export const keyboardNav = defineTrait({
|
|
15
15
|
name: 'keyboard-nav',
|
|
16
|
+
category: 'keyboard-navigation',
|
|
17
|
+
description: 'Arrow keys, Enter, Escape — semantic navigation events',
|
|
16
18
|
attributes: [],
|
|
17
19
|
events: ['nav-up', 'nav-down', 'nav-left', 'nav-right', 'nav-enter', 'nav-escape', 'nav-home', 'nav-end'],
|
|
18
20
|
config: [],
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { keyboardNav } from './keyboard-nav.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('keyboard-nav', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it.each([
|
|
9
|
+
['ArrowUp', 'nav-up'],
|
|
10
|
+
['ArrowDown', 'nav-down'],
|
|
11
|
+
['ArrowLeft', 'nav-left'],
|
|
12
|
+
['ArrowRight', 'nav-right'],
|
|
13
|
+
['Enter', 'nav-enter'],
|
|
14
|
+
['Escape', 'nav-escape'],
|
|
15
|
+
['Home', 'nav-home'],
|
|
16
|
+
['End', 'nav-end'],
|
|
17
|
+
])('%s keydown fires %s event', (key, eventName) => {
|
|
18
|
+
const host = mountHost();
|
|
19
|
+
connectTrait(keyboardNav, host);
|
|
20
|
+
const spy = spyEvent(host, eventName);
|
|
21
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key }));
|
|
22
|
+
expect(spy.count).toBe(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('unrelated key does not fire any nav event', () => {
|
|
26
|
+
const host = mountHost();
|
|
27
|
+
connectTrait(keyboardNav, host);
|
|
28
|
+
const spy = spyEvent(host, 'nav-up');
|
|
29
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
|
30
|
+
expect(spy.count).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('disconnect removes the keydown listener', () => {
|
|
34
|
+
const host = mountHost();
|
|
35
|
+
const inst = connectTrait(keyboardNav, host);
|
|
36
|
+
const spy = spyEvent(host, 'nav-up');
|
|
37
|
+
inst.disconnect(host);
|
|
38
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
|
|
39
|
+
expect(spy.count).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/traits/magnetic-hover.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { defineTrait } from './define.js';
|
|
2
|
+
import { prefersReducedMotion } from './_motion.js';
|
|
2
3
|
|
|
3
4
|
export const magneticHover = defineTrait({
|
|
4
5
|
name: 'magnetic-hover',
|
|
6
|
+
category: 'interaction-delight',
|
|
7
|
+
description: 'Element subtly follows cursor',
|
|
5
8
|
attributes: ['data-magnetic-hover-active'],
|
|
6
9
|
events: [],
|
|
7
10
|
config: [],
|
|
8
11
|
setup({ host }) {
|
|
12
|
+
// Reduced-motion: don't track the cursor; element stays put.
|
|
13
|
+
if (prefersReducedMotion()) {
|
|
14
|
+
return () => {};
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
function onPointerMove(e) {
|
|
10
18
|
const rect = host.getBoundingClientRect();
|
|
11
19
|
const cx = rect.left + rect.width / 2;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { magneticHover } from './magnetic-hover.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('magnetic-hover', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('pointermove sets data-magnetic-hover-active', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(magneticHover, host);
|
|
11
|
+
host.dispatchEvent(new PointerEvent('pointermove', { clientX: 50, clientY: 50, bubbles: true }));
|
|
12
|
+
expect(host.hasAttribute('data-magnetic-hover-active')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('pointerleave clears active + resets translate', () => {
|
|
16
|
+
const host = mountHost();
|
|
17
|
+
connectTrait(magneticHover, host);
|
|
18
|
+
host.dispatchEvent(new PointerEvent('pointermove', { clientX: 50, clientY: 50, bubbles: true }));
|
|
19
|
+
host.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true }));
|
|
20
|
+
expect(host.hasAttribute('data-magnetic-hover-active')).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('disconnect clears all listeners + active state', () => {
|
|
24
|
+
const host = mountHost();
|
|
25
|
+
const inst = connectTrait(magneticHover, host);
|
|
26
|
+
host.dispatchEvent(new PointerEvent('pointermove', { clientX: 0, clientY: 0, bubbles: true }));
|
|
27
|
+
inst.disconnect(host);
|
|
28
|
+
expect(host.hasAttribute('data-magnetic-hover-active')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
package/traits/noise-texture.js
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { noiseTexture } from './noise-texture.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('noise-texture', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect sets data-noise-texture-active and appends an overlay', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(noiseTexture, host);
|
|
11
|
+
expect(host.hasAttribute('data-noise-texture-active')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('disconnect clears active + removes overlay', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
const inst = connectTrait(noiseTexture, host);
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
expect(host.hasAttribute('data-noise-texture-active')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
package/traits/parallax.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 parallax = defineTrait({
|
|
4
5
|
name: 'parallax',
|
|
6
|
+
category: 'visual-dynamics',
|
|
7
|
+
description: 'Layered motion relative to pointer',
|
|
5
8
|
attributes: ['data-parallax-active'],
|
|
6
9
|
events: [],
|
|
7
10
|
config: ['data-parallax-strength'],
|
|
8
11
|
setup({ host }) {
|
|
12
|
+
// Reduced-motion: skip the scroll-driven offset; element stays put.
|
|
13
|
+
if (prefersReducedMotion()) {
|
|
14
|
+
host.setAttribute('data-parallax-active', '');
|
|
15
|
+
return () => host.removeAttribute('data-parallax-active');
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
const strength = parseFloat(host.getAttribute('data-parallax-strength')) || 0.5;
|
|
10
19
|
|
|
11
20
|
function getScrollParent(el) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { parallax } from './parallax.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('parallax', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect sets data-parallax-active', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(parallax, host);
|
|
11
|
+
expect(host.hasAttribute('data-parallax-active')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('disconnect clears active + translate', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
const inst = connectTrait(parallax, host);
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
expect(host.hasAttribute('data-parallax-active')).toBe(false);
|
|
19
|
+
expect(host.style.translate).toBe('');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('respects data-parallax-strength (default 0.5)', () => {
|
|
23
|
+
const host = mountHost('div', { 'data-parallax-strength': '0.2' });
|
|
24
|
+
expect(() => connectTrait(parallax, host)).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
});
|
package/traits/portal.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const portal = defineTrait({
|
|
4
4
|
name: 'portal',
|
|
5
|
+
category: 'layout-measurement',
|
|
6
|
+
description: 'Renders content in a different DOM root',
|
|
5
7
|
attributes: ['data-portal-active'],
|
|
6
8
|
events: [],
|
|
7
9
|
config: ['data-portal-target'],
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { portal } from './portal.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('portal', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect sets data-portal-active', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(portal, host);
|
|
11
|
+
expect(host.hasAttribute('data-portal-active')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('disconnect clears the active attribute', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
const inst = connectTrait(portal, host);
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
expect(host.hasAttribute('data-portal-active')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('with data-portal-target, host is moved to the target element', () => {
|
|
22
|
+
const target = document.createElement('div');
|
|
23
|
+
target.id = 'portal-out';
|
|
24
|
+
document.body.appendChild(target);
|
|
25
|
+
const host = mountHost('div', { 'data-portal-target': '#portal-out' });
|
|
26
|
+
connectTrait(portal, host);
|
|
27
|
+
// The trait moves the host into the target on connect.
|
|
28
|
+
expect(target.contains(host)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
package/traits/pressable.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const pressable = defineTrait({
|
|
4
4
|
name: 'pressable',
|
|
5
|
+
category: 'input-interaction',
|
|
6
|
+
description: 'Normalizes click/tap/keyboard into a single "press" event',
|
|
5
7
|
attributes: ['data-pressable-pressed'],
|
|
6
8
|
events: ['press'],
|
|
7
9
|
config: [],
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { pressable } from './pressable.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('pressable', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('sets data-pressable-pressed on pointerdown, removes on pointerup', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(pressable, host);
|
|
11
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
12
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
|
|
13
|
+
host.dispatchEvent(new PointerEvent('pointerup'));
|
|
14
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('dispatches "press" event on pointerup', () => {
|
|
18
|
+
const host = mountHost();
|
|
19
|
+
connectTrait(pressable, host);
|
|
20
|
+
const spy = spyEvent(host, 'press');
|
|
21
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
22
|
+
host.dispatchEvent(new PointerEvent('pointerup'));
|
|
23
|
+
expect(spy.count).toBe(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('dispatches "press" on Enter/Space keyup', () => {
|
|
27
|
+
const host = mountHost();
|
|
28
|
+
connectTrait(pressable, host);
|
|
29
|
+
const spy = spyEvent(host, 'press');
|
|
30
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
31
|
+
host.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
|
|
32
|
+
expect(spy.count).toBe(1);
|
|
33
|
+
host.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
|
|
34
|
+
host.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
|
|
35
|
+
expect(spy.count).toBe(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('respects [disabled] — no event, no attribute toggle', () => {
|
|
39
|
+
const host = mountHost('button', { disabled: '' });
|
|
40
|
+
connectTrait(pressable, host);
|
|
41
|
+
const spy = spyEvent(host, 'press');
|
|
42
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
43
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
|
|
44
|
+
host.dispatchEvent(new PointerEvent('pointerup'));
|
|
45
|
+
expect(spy.count).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('clears pressed state on pointerleave (canceled press)', () => {
|
|
49
|
+
const host = mountHost();
|
|
50
|
+
connectTrait(pressable, host);
|
|
51
|
+
const spy = spyEvent(host, 'press');
|
|
52
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
53
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
|
|
54
|
+
host.dispatchEvent(new PointerEvent('pointerleave'));
|
|
55
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
|
|
56
|
+
host.dispatchEvent(new PointerEvent('pointerup'));
|
|
57
|
+
expect(spy.count).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('disconnect removes all listeners and clears attribute', () => {
|
|
61
|
+
const host = mountHost();
|
|
62
|
+
const inst = connectTrait(pressable, host);
|
|
63
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
64
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
|
|
65
|
+
inst.disconnect(host);
|
|
66
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
|
|
67
|
+
// Re-firing events post-disconnect is a no-op
|
|
68
|
+
const spy = spyEvent(host, 'press');
|
|
69
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
70
|
+
host.dispatchEvent(new PointerEvent('pointerup'));
|
|
71
|
+
expect(spy.count).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Listens for the host's enclosing form's `reset` event and snaps the host's
|
|
5
|
+
* value back to its initial value (captured on connect). Useful when the host
|
|
6
|
+
* is not a native form-associated control but still wants reset semantics —
|
|
7
|
+
* filter chips, search inputs, custom date pickers, segmented controls.
|
|
8
|
+
*
|
|
9
|
+
* If the host is not inside a form, the trait is a no-op (still registers
|
|
10
|
+
* the active attribute and returns a clean cleanup).
|
|
11
|
+
*/
|
|
12
|
+
export const resettable = defineTrait({
|
|
13
|
+
name: 'resettable',
|
|
14
|
+
category: 'forms-data',
|
|
15
|
+
description: 'Snaps the host value back to its initial value on form reset',
|
|
16
|
+
attributes: ['data-resettable-active'],
|
|
17
|
+
events: ['reset-applied'],
|
|
18
|
+
config: [],
|
|
19
|
+
setup({ host }) {
|
|
20
|
+
const initialValue = host.value ?? host.getAttribute('value') ?? '';
|
|
21
|
+
const form = host.closest?.('form');
|
|
22
|
+
|
|
23
|
+
function onReset() {
|
|
24
|
+
if ('value' in host) host.value = initialValue;
|
|
25
|
+
else host.setAttribute('value', initialValue);
|
|
26
|
+
host.dispatchEvent(new CustomEvent('reset-applied', {
|
|
27
|
+
bubbles: true,
|
|
28
|
+
detail: { initialValue },
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
host.setAttribute('data-resettable-active', '');
|
|
33
|
+
form?.addEventListener('reset', onReset);
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
form?.removeEventListener('reset', onReset);
|
|
37
|
+
host.removeAttribute('data-resettable-active');
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { resettable } from './resettable.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
function mountInForm(initialValue = 'initial') {
|
|
6
|
+
const form = document.createElement('form');
|
|
7
|
+
document.body.appendChild(form);
|
|
8
|
+
const host = document.createElement('input');
|
|
9
|
+
host.value = initialValue;
|
|
10
|
+
form.appendChild(host);
|
|
11
|
+
return { form, host };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('resettable', () => {
|
|
15
|
+
beforeEach(resetDOM);
|
|
16
|
+
|
|
17
|
+
it('captures initial value on connect; restores it on form reset', () => {
|
|
18
|
+
const { form, host } = mountInForm('original');
|
|
19
|
+
connectTrait(resettable, host);
|
|
20
|
+
host.value = 'modified';
|
|
21
|
+
form.dispatchEvent(new Event('reset', { bubbles: true }));
|
|
22
|
+
expect(host.value).toBe('original');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('dispatches reset-applied with the initial value in detail', () => {
|
|
26
|
+
const { form, host } = mountInForm('hello');
|
|
27
|
+
connectTrait(resettable, host);
|
|
28
|
+
const spy = spyEvent(host, 'reset-applied');
|
|
29
|
+
host.value = 'changed';
|
|
30
|
+
form.dispatchEvent(new Event('reset', { bubbles: true }));
|
|
31
|
+
expect(spy.count).toBe(1);
|
|
32
|
+
expect(spy.last.initialValue).toBe('hello');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('outside a form: no-op (no throw, no listener)', () => {
|
|
36
|
+
const host = mountHost('input', { value: 'x' });
|
|
37
|
+
expect(() => connectTrait(resettable, host)).not.toThrow();
|
|
38
|
+
expect(host.hasAttribute('data-resettable-active')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('disconnect removes the form listener', () => {
|
|
42
|
+
const { form, host } = mountInForm('a');
|
|
43
|
+
const inst = resettable();
|
|
44
|
+
inst.connect(host);
|
|
45
|
+
inst.disconnect(host);
|
|
46
|
+
host.value = 'b';
|
|
47
|
+
form.dispatchEvent(new Event('reset', { bubbles: true }));
|
|
48
|
+
// After disconnect, the trait should NOT have reset the value.
|
|
49
|
+
expect(host.value).toBe('b');
|
|
50
|
+
expect(host.hasAttribute('data-resettable-active')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('multiple controls in the same form each reset independently', () => {
|
|
54
|
+
const form = document.createElement('form');
|
|
55
|
+
document.body.appendChild(form);
|
|
56
|
+
const a = document.createElement('input'); a.value = 'A';
|
|
57
|
+
const b = document.createElement('input'); b.value = 'B';
|
|
58
|
+
form.appendChild(a); form.appendChild(b);
|
|
59
|
+
resettable().connect(a);
|
|
60
|
+
resettable().connect(b);
|
|
61
|
+
a.value = 'changed-a';
|
|
62
|
+
b.value = 'changed-b';
|
|
63
|
+
form.dispatchEvent(new Event('reset', { bubbles: true }));
|
|
64
|
+
expect(a.value).toBe('A');
|
|
65
|
+
expect(b.value).toBe('B');
|
|
66
|
+
});
|
|
67
|
+
});
|
package/traits/resizable.js
CHANGED
|
@@ -5,6 +5,8 @@ const EDGES = ['top', 'right', 'bottom', 'left'];
|
|
|
5
5
|
|
|
6
6
|
export const resizable = defineTrait({
|
|
7
7
|
name: 'resizable',
|
|
8
|
+
category: 'motion-positioning',
|
|
9
|
+
description: 'Drag edges/corners to resize',
|
|
8
10
|
attributes: ['data-resizable-resizing', 'data-resizable-edge'],
|
|
9
11
|
events: ['resize-end'],
|
|
10
12
|
config: [],
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { resizable } from './resizable.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('resizable', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect attaches without throwing', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
expect(() => connectTrait(resizable, host)).not.toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('disconnect clears resizing state attributes', () => {
|
|
14
|
+
const host = mountHost();
|
|
15
|
+
const inst = connectTrait(resizable, host);
|
|
16
|
+
expect(() => inst.disconnect(host)).not.toThrow();
|
|
17
|
+
expect(host.hasAttribute('data-resizable-resizing')).toBe(false);
|
|
18
|
+
expect(host.hasAttribute('data-resizable-edge')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const resizeObserver = defineTrait({
|
|
4
4
|
name: 'resize-observer',
|
|
5
|
+
category: 'layout-measurement',
|
|
6
|
+
description: 'Reacts to element size changes',
|
|
5
7
|
attributes: ['data-resize-observer-observed', 'data-resize-width', 'data-resize-height'],
|
|
6
8
|
events: ['element-resize'],
|
|
7
9
|
config: [],
|