@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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { resizeObserver } from './resize-observer.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('resize-observer', () => {
|
|
6
|
+
let originalRO;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
resetDOM();
|
|
10
|
+
originalRO = globalThis.ResizeObserver;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (originalRO) globalThis.ResizeObserver = originalRO;
|
|
15
|
+
else delete globalThis.ResizeObserver;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('without ResizeObserver: gracefully no-ops on connect', () => {
|
|
19
|
+
delete globalThis.ResizeObserver;
|
|
20
|
+
const host = mountHost();
|
|
21
|
+
expect(() => connectTrait(resizeObserver, host)).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('with mocked ResizeObserver: observe() called + disconnect cleans up', () => {
|
|
25
|
+
const observe = vi.fn();
|
|
26
|
+
const roDisconnect = vi.fn();
|
|
27
|
+
globalThis.ResizeObserver = function MockRO() {
|
|
28
|
+
this.observe = observe;
|
|
29
|
+
this.disconnect = roDisconnect;
|
|
30
|
+
this.unobserve = vi.fn();
|
|
31
|
+
};
|
|
32
|
+
const host = mountHost();
|
|
33
|
+
const inst = connectTrait(resizeObserver, host);
|
|
34
|
+
expect(observe).toHaveBeenCalledWith(host);
|
|
35
|
+
inst.disconnect(host);
|
|
36
|
+
expect(roDisconnect).toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
});
|
package/traits/ripple.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 ripple = defineTrait({
|
|
4
5
|
name: 'ripple',
|
|
6
|
+
category: 'animation-feedback',
|
|
7
|
+
description: 'Material-style press ripple effect',
|
|
5
8
|
attributes: ['data-ripple-active'],
|
|
6
9
|
events: [],
|
|
7
10
|
config: [],
|
|
8
11
|
setup({ host }) {
|
|
12
|
+
// Reduced-motion: drop the ripple animation; press feedback comes from pressable instead.
|
|
13
|
+
if (prefersReducedMotion()) {
|
|
14
|
+
host.setAttribute('data-ripple-active', '');
|
|
15
|
+
return () => host.removeAttribute('data-ripple-active');
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
host.style.overflow = 'hidden';
|
|
10
19
|
host.style.position = host.style.position || 'relative';
|
|
11
20
|
host.setAttribute('data-ripple-active', '');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ripple } from './ripple.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('ripple', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect sets data-ripple-active', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(ripple, host);
|
|
11
|
+
expect(host.hasAttribute('data-ripple-active')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('disconnect clears active + restores overflow', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
const inst = connectTrait(ripple, host);
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
expect(host.hasAttribute('data-ripple-active')).toBe(false);
|
|
19
|
+
expect(host.style.overflow).toBe('');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('click appends a ripple span (in non-reduced-motion environment)', () => {
|
|
23
|
+
const host = mountHost();
|
|
24
|
+
connectTrait(ripple, host);
|
|
25
|
+
const beforeChildren = host.children.length;
|
|
26
|
+
host.dispatchEvent(new MouseEvent('click', { clientX: 10, clientY: 10, bubbles: true }));
|
|
27
|
+
// In happy-dom + reduced-motion preference unknown, the ripple may or may
|
|
28
|
+
// not append a span. The contract is "connect doesn't throw, no DOM leak
|
|
29
|
+
// on disconnect".
|
|
30
|
+
expect(host.children.length).toBeGreaterThanOrEqual(beforeChildren);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { rovingTabindex } from './roving-tabindex.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
function tabbableChild(host) {
|
|
6
|
+
const btn = document.createElement('button');
|
|
7
|
+
host.appendChild(btn);
|
|
8
|
+
return btn;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('roving-tabindex', () => {
|
|
12
|
+
beforeEach(resetDOM);
|
|
13
|
+
|
|
14
|
+
it('connect attaches without throwing', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
tabbableChild(host);
|
|
17
|
+
tabbableChild(host);
|
|
18
|
+
tabbableChild(host);
|
|
19
|
+
expect(() => connectTrait(rovingTabindex, host)).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('disconnect cleans up', () => {
|
|
23
|
+
const host = mountHost();
|
|
24
|
+
tabbableChild(host);
|
|
25
|
+
const inst = connectTrait(rovingTabindex, host);
|
|
26
|
+
expect(() => inst.disconnect(host)).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
});
|
package/traits/scale-press.js
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { scalePress } from './scale-press.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('scale-press', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('pointerdown applies scale(0.95) + active attribute', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(scalePress, host);
|
|
11
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
12
|
+
expect(host.hasAttribute('data-scale-press-active')).toBe(true);
|
|
13
|
+
expect(host.style.transform).toContain('0.95');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('pointerup releases scale and clears attribute', () => {
|
|
17
|
+
const host = mountHost();
|
|
18
|
+
connectTrait(scalePress, host);
|
|
19
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
20
|
+
host.dispatchEvent(new PointerEvent('pointerup'));
|
|
21
|
+
expect(host.hasAttribute('data-scale-press-active')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('pointerleave during press releases (canceled press)', () => {
|
|
25
|
+
const host = mountHost();
|
|
26
|
+
connectTrait(scalePress, host);
|
|
27
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
28
|
+
host.dispatchEvent(new PointerEvent('pointerleave'));
|
|
29
|
+
expect(host.hasAttribute('data-scale-press-active')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('respects [disabled] — no transform, no attribute', () => {
|
|
33
|
+
const host = mountHost('button', { disabled: '' });
|
|
34
|
+
connectTrait(scalePress, host);
|
|
35
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
36
|
+
expect(host.hasAttribute('data-scale-press-active')).toBe(false);
|
|
37
|
+
expect(host.style.transform).toBe('');
|
|
38
|
+
});
|
|
39
|
+
});
|
package/traits/scroll-lock.js
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { scrollLock } from './scroll-lock.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('scroll-lock', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
resetDOM();
|
|
8
|
+
document.body.style.overflow = '';
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('connect sets body overflow:hidden + active attribute', () => {
|
|
12
|
+
const host = mountHost();
|
|
13
|
+
const inst = connectTrait(scrollLock, host);
|
|
14
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
15
|
+
expect(host.hasAttribute('data-scroll-lock-active')).toBe(true);
|
|
16
|
+
// Balanced disconnect to keep the module-level lockCount honest for sibling tests.
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('disconnect restores body overflow + clears attribute', () => {
|
|
21
|
+
document.body.style.overflow = 'auto';
|
|
22
|
+
const host = mountHost();
|
|
23
|
+
const inst = connectTrait(scrollLock, host);
|
|
24
|
+
inst.disconnect(host);
|
|
25
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
26
|
+
expect(host.hasAttribute('data-scroll-lock-active')).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('refcounted — disconnect order does not unlock until last consumer leaves', () => {
|
|
30
|
+
// The trait is module-scoped (lockCount + savedOverflow live in module state).
|
|
31
|
+
// Snapshot the current state so this assertion is independent of test order.
|
|
32
|
+
const startOverflow = document.body.style.overflow;
|
|
33
|
+
const a = mountHost();
|
|
34
|
+
const b = mountHost();
|
|
35
|
+
const instA = connectTrait(scrollLock, a);
|
|
36
|
+
const instB = connectTrait(scrollLock, b);
|
|
37
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
38
|
+
instA.disconnect(a);
|
|
39
|
+
// Still locked because b is still active
|
|
40
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
41
|
+
instB.disconnect(b);
|
|
42
|
+
// Restored to whatever it was when the *first* lock acquired it
|
|
43
|
+
expect(document.body.style.overflow).toBe(startOverflow);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { defineTrait } from './define.js';
|
|
2
|
+
import { prefersReducedMotion } from './_motion.js';
|
|
2
3
|
|
|
3
4
|
export const shimmerLoading = defineTrait({
|
|
4
5
|
name: 'shimmer-loading',
|
|
6
|
+
category: 'visual-dynamics',
|
|
7
|
+
description: 'Skeleton shimmer effect',
|
|
5
8
|
attributes: ['data-shimmer-loading-active'],
|
|
6
9
|
events: [],
|
|
7
10
|
config: [],
|
|
8
11
|
setup({ host }) {
|
|
12
|
+
// Reduced-motion: render a static skeleton (subtle muted background) instead of the sweeping shimmer.
|
|
13
|
+
if (prefersReducedMotion()) {
|
|
14
|
+
const overlay = document.createElement('div');
|
|
15
|
+
overlay.style.cssText = `
|
|
16
|
+
position: absolute; inset: 0; pointer-events: none; z-index: 1;
|
|
17
|
+
border-radius: inherit;
|
|
18
|
+
background: rgba(0,0,0,0.06);
|
|
19
|
+
`;
|
|
20
|
+
host.style.position = host.style.position || 'relative';
|
|
21
|
+
host.appendChild(overlay);
|
|
22
|
+
host.setAttribute('data-shimmer-loading-active', '');
|
|
23
|
+
return () => {
|
|
24
|
+
overlay.remove();
|
|
25
|
+
host.removeAttribute('data-shimmer-loading-active');
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
9
29
|
const keyframeName = `adia-shimmer-${Math.random().toString(36).slice(2, 8)}`;
|
|
10
30
|
|
|
11
31
|
const style = document.createElement('style');
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { shimmerLoading } from './shimmer-loading.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('shimmer-loading', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('sets data-shimmer-loading-active and appends an overlay', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(shimmerLoading, host);
|
|
11
|
+
expect(host.hasAttribute('data-shimmer-loading-active')).toBe(true);
|
|
12
|
+
// Either a static muted overlay (reduced-motion) or the shimmer overlay
|
|
13
|
+
// is appended as a child div.
|
|
14
|
+
const overlay = host.querySelector('div');
|
|
15
|
+
expect(overlay).toBeTruthy();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('disconnect removes overlay + cleans active attribute', () => {
|
|
19
|
+
const host = mountHost();
|
|
20
|
+
const inst = shimmerLoading();
|
|
21
|
+
inst.connect(host);
|
|
22
|
+
expect(host.querySelector('div')).toBeTruthy();
|
|
23
|
+
inst.disconnect(host);
|
|
24
|
+
expect(host.hasAttribute('data-shimmer-loading-active')).toBe(false);
|
|
25
|
+
expect(host.querySelector('div')).toBeFalsy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('host gets position:relative if not already set (so the overlay anchors)', () => {
|
|
29
|
+
const host = mountHost();
|
|
30
|
+
connectTrait(shimmerLoading, host);
|
|
31
|
+
expect(host.style.position).toBe('relative');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('parallel attach to two hosts: each gets its own overlay', () => {
|
|
35
|
+
const a = mountHost();
|
|
36
|
+
const b = mountHost();
|
|
37
|
+
shimmerLoading().connect(a);
|
|
38
|
+
shimmerLoading().connect(b);
|
|
39
|
+
expect(a.querySelector('div')).toBeTruthy();
|
|
40
|
+
expect(b.querySelector('div')).toBeTruthy();
|
|
41
|
+
expect(a.querySelector('div')).not.toBe(b.querySelector('div'));
|
|
42
|
+
});
|
|
43
|
+
});
|
package/traits/snap-to-grid.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const snapToGrid = defineTrait({
|
|
4
4
|
name: 'snap-to-grid',
|
|
5
|
+
category: 'motion-positioning',
|
|
6
|
+
description: 'Snaps position to configurable grid',
|
|
5
7
|
attributes: ['data-snap-to-grid-snapped'],
|
|
6
8
|
events: [],
|
|
7
9
|
config: ['data-snap-grid'],
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { snapToGrid } from './snap-to-grid.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('snap-to-grid', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('drag-end with x,y rounds to nearest grid increment (default 16)', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(snapToGrid, host);
|
|
11
|
+
host.dispatchEvent(new CustomEvent('drag-end', { detail: { x: 23, y: 41 } }));
|
|
12
|
+
expect(host.style.translate).toBe('16px 48px');
|
|
13
|
+
expect(host.hasAttribute('data-snap-to-grid-snapped')).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('respects data-snap-grid override', () => {
|
|
17
|
+
const host = mountHost('div', { 'data-snap-grid': '50' });
|
|
18
|
+
connectTrait(snapToGrid, host);
|
|
19
|
+
host.dispatchEvent(new CustomEvent('drag-end', { detail: { x: 73, y: 124 } }));
|
|
20
|
+
expect(host.style.translate).toBe('50px 100px');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('drag-end without detail is a no-op', () => {
|
|
24
|
+
const host = mountHost();
|
|
25
|
+
connectTrait(snapToGrid, host);
|
|
26
|
+
host.dispatchEvent(new CustomEvent('drag-end', { detail: {} }));
|
|
27
|
+
// happy-dom may return undefined or '' for unset style.translate; both
|
|
28
|
+
// mean "untouched" which is the contract here.
|
|
29
|
+
expect(host.style.translate || '').toBe('');
|
|
30
|
+
expect(host.hasAttribute('data-snap-to-grid-snapped')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('disconnect clears the snapped attribute', () => {
|
|
34
|
+
const host = mountHost();
|
|
35
|
+
const inst = connectTrait(snapToGrid, host);
|
|
36
|
+
host.dispatchEvent(new CustomEvent('drag-end', { detail: { x: 0, y: 0 } }));
|
|
37
|
+
inst.disconnect(host);
|
|
38
|
+
expect(host.hasAttribute('data-snap-to-grid-snapped')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/traits/sound-feedback.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const soundFeedback = defineTrait({
|
|
4
4
|
name: 'sound-feedback',
|
|
5
|
+
category: 'audio-haptics-sensory',
|
|
6
|
+
description: 'Synthesized tones via Web Audio API',
|
|
5
7
|
attributes: ['data-sound-feedback-active'],
|
|
6
8
|
events: [],
|
|
7
9
|
config: ['data-sound-type'],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { soundFeedback } from './sound-feedback.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('sound-feedback', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect sets data-sound-feedback-active', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(soundFeedback, host);
|
|
11
|
+
expect(host.hasAttribute('data-sound-feedback-active')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('disconnect clears active + stops listening', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
const inst = connectTrait(soundFeedback, host);
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
expect(host.hasAttribute('data-sound-feedback-active')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('without data-sound-type: press fires no audio (no throw)', () => {
|
|
22
|
+
const host = mountHost();
|
|
23
|
+
connectTrait(soundFeedback, host);
|
|
24
|
+
expect(() => host.dispatchEvent(new CustomEvent('press', { bubbles: true }))).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
});
|
package/traits/spring-animate.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTrait } from './define.js';
|
|
|
2
2
|
|
|
3
3
|
export const springAnimate = defineTrait({
|
|
4
4
|
name: 'spring-animate',
|
|
5
|
+
category: 'animation-feedback',
|
|
6
|
+
description: 'Spring-based motion transitions',
|
|
5
7
|
attributes: ['data-spring-animate-active'],
|
|
6
8
|
events: [],
|
|
7
9
|
config: ['data-spring-stiffness', 'data-spring-damping'],
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { springAnimate } from './spring-animate.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('spring-animate', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('connect sets data-spring-animate-active', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(springAnimate, host);
|
|
11
|
+
expect(host.hasAttribute('data-spring-animate-active')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('disconnect clears active', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
const inst = connectTrait(springAnimate, host);
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
expect(host.hasAttribute('data-spring-animate-active')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('respects data-spring-stiffness + data-spring-damping config', () => {
|
|
22
|
+
const host = mountHost('div', {
|
|
23
|
+
'data-spring-stiffness': '200',
|
|
24
|
+
'data-spring-damping': '0.6',
|
|
25
|
+
});
|
|
26
|
+
expect(() => connectTrait(springAnimate, host)).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
});
|
package/traits/tilt-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 tiltHover = defineTrait({
|
|
4
5
|
name: 'tilt-hover',
|
|
6
|
+
category: 'animation-feedback',
|
|
7
|
+
description: 'Tilt based on pointer position',
|
|
5
8
|
attributes: ['data-tilt-hover-active'],
|
|
6
9
|
events: [],
|
|
7
10
|
config: [],
|
|
8
11
|
setup({ host }) {
|
|
12
|
+
// Reduced-motion: don't tilt the host; pointer events still fire elsewhere.
|
|
13
|
+
if (prefersReducedMotion()) {
|
|
14
|
+
return () => {};
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
function onPointerMove(e) {
|
|
10
18
|
const rect = host.getBoundingClientRect();
|
|
11
19
|
const x = (e.clientX - rect.left) / rect.width - 0.5;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { tiltHover } from './tilt-hover.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './_test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('tilt-hover', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('pointermove sets data-tilt-hover-active and applies transform', () => {
|
|
9
|
+
const host = mountHost();
|
|
10
|
+
connectTrait(tiltHover, host);
|
|
11
|
+
host.dispatchEvent(new PointerEvent('pointermove', { clientX: 100, clientY: 100, bubbles: true }));
|
|
12
|
+
expect(host.hasAttribute('data-tilt-hover-active')).toBe(true);
|
|
13
|
+
expect(host.style.transform).toContain('perspective');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('pointerleave clears active', () => {
|
|
17
|
+
const host = mountHost();
|
|
18
|
+
connectTrait(tiltHover, host);
|
|
19
|
+
host.dispatchEvent(new PointerEvent('pointermove', { clientX: 50, clientY: 50, bubbles: true }));
|
|
20
|
+
host.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true }));
|
|
21
|
+
expect(host.hasAttribute('data-tilt-hover-active')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('disconnect mid-tilt clears active + transform', () => {
|
|
25
|
+
const host = mountHost();
|
|
26
|
+
const inst = connectTrait(tiltHover, host);
|
|
27
|
+
host.dispatchEvent(new PointerEvent('pointermove', { clientX: 50, clientY: 50, bubbles: true }));
|
|
28
|
+
inst.disconnect(host);
|
|
29
|
+
expect(host.hasAttribute('data-tilt-hover-active')).toBe(false);
|
|
30
|
+
expect(host.style.transform).toBe('');
|
|
31
|
+
});
|
|
32
|
+
});
|
package/traits/tossable.js
CHANGED
|
@@ -6,6 +6,8 @@ const MIN_VELOCITY = 0.3;
|
|
|
6
6
|
|
|
7
7
|
export const tossable = defineTrait({
|
|
8
8
|
name: 'tossable',
|
|
9
|
+
category: 'motion-positioning',
|
|
10
|
+
description: 'Flick with momentum + viewport bounce',
|
|
9
11
|
attributes: ['data-tossable-flying'],
|
|
10
12
|
events: ['toss-end'],
|
|
11
13
|
config: ['data-tossable-bounds'],
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { tossable } from './tossable.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('tossable', () => {
|
|
10
|
+
beforeEach(resetDOM);
|
|
11
|
+
|
|
12
|
+
it('connect attaches without throwing', () => {
|
|
13
|
+
const host = mountHost();
|
|
14
|
+
expect(() => connectTrait(tossable, host)).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('drag sequence completes without throwing + disconnect cleans up', () => {
|
|
18
|
+
const host = mountHost();
|
|
19
|
+
const inst = connectTrait(tossable, host);
|
|
20
|
+
pointer(host, 'pointerdown', 0, 0);
|
|
21
|
+
pointer(host, 'pointermove', 50, 0);
|
|
22
|
+
pointer(host, 'pointerup', 50, 0);
|
|
23
|
+
expect(() => inst.disconnect(host)).not.toThrow();
|
|
24
|
+
expect(host.hasAttribute('data-tossable-flying')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('respects data-tossable-bounds config', () => {
|
|
28
|
+
const host = mountHost('div', { 'data-tossable-bounds': 'parent' });
|
|
29
|
+
expect(() => connectTrait(tossable, host)).not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <traits-host traits="pressable scale-press ripple">
|
|
3
|
+
* <div>raw markup with trait behaviors attached</div>
|
|
4
|
+
* </traits-host>
|
|
5
|
+
*
|
|
6
|
+
* Tiny pass-through wrapper that extends declarative trait composition
|
|
7
|
+
* to raw HTML. Without this element, only UIElement subclasses can read
|
|
8
|
+
* the `traits=` attribute. Wrap any markup in <traits-host> and the
|
|
9
|
+
* named traits attach to the wrapper itself — events bubble up from
|
|
10
|
+
* children, attribute toggles land on the wrapper, and the children
|
|
11
|
+
* render unaffected.
|
|
12
|
+
*
|
|
13
|
+
* The host uses `display: contents` so it does not introduce a layout
|
|
14
|
+
* box; the wrapped children participate in the parent's flex/grid
|
|
15
|
+
* exactly as if the wrapper were not there.
|
|
16
|
+
*
|
|
17
|
+
* Use this for:
|
|
18
|
+
* - sprinkling `pressable` onto a custom button you don't want to
|
|
19
|
+
* turn into a UIElement
|
|
20
|
+
* - giving a `<dialog>` or `<details>` a `focus-trap` without a
|
|
21
|
+
* wrapper component
|
|
22
|
+
* - composing `magnetic-hover` + `tilt-hover` onto an existing
|
|
23
|
+
* marketing CTA without rewriting it as a component
|
|
24
|
+
*
|
|
25
|
+
* For UIElement subclasses, prefer the bare attribute on the element
|
|
26
|
+
* itself: `<button-ui traits="ripple">…</button-ui>`. The wrapper is
|
|
27
|
+
* for cases where the host element is NOT a UIElement.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { UIElement } from '../core/element.js';
|
|
31
|
+
|
|
32
|
+
class TraitsHost extends UIElement {
|
|
33
|
+
static template = () => null;
|
|
34
|
+
|
|
35
|
+
connected() {
|
|
36
|
+
// No-op — UIElement reads `traits` attribute and applies behavior.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof customElements !== 'undefined' && !customElements.get('traits-host')) {
|
|
41
|
+
customElements.define('traits-host', TraitsHost);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One-shot stylesheet keeps the host out of the layout flow so children
|
|
45
|
+
// render in the parent's box.
|
|
46
|
+
if (typeof document !== 'undefined' && document.head && !document.querySelector('#adia-traits-host-style')) {
|
|
47
|
+
const style = document.createElement('style');
|
|
48
|
+
style.id = 'adia-traits-host-style';
|
|
49
|
+
style.textContent = 'traits-host { display: contents; }';
|
|
50
|
+
document.head.appendChild(style);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { TraitsHost };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <traits-host> behavior tests — focus on the wrapper-specific contract:
|
|
3
|
+
* - children pass through visually (display: contents)
|
|
4
|
+
* - declarative traits attach to the wrapper
|
|
5
|
+
* - traits attribute swaps work the same as on UIElement subclasses
|
|
6
|
+
* - events from children bubble up to the wrapper for trait capture
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
10
|
+
import './traits-host.js';
|
|
11
|
+
import './pressable.js';
|
|
12
|
+
import './hoverable.js';
|
|
13
|
+
import { resetDOM } from './_test-helpers.js';
|
|
14
|
+
|
|
15
|
+
describe('<traits-host>', () => {
|
|
16
|
+
beforeEach(resetDOM);
|
|
17
|
+
|
|
18
|
+
it('is registered as a UIElement subclass', () => {
|
|
19
|
+
const ctor = customElements.get('traits-host');
|
|
20
|
+
expect(ctor).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('declarative traits=" " attaches to the wrapper itself', () => {
|
|
24
|
+
document.body.innerHTML = '<traits-host traits="pressable"><div>Raw</div></traits-host>';
|
|
25
|
+
const host = document.body.firstElementChild;
|
|
26
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
27
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
|
|
28
|
+
host.dispatchEvent(new PointerEvent('pointerup'));
|
|
29
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('events from children bubble up so the trait captures them', () => {
|
|
33
|
+
document.body.innerHTML = '<traits-host traits="hoverable"><span class="inner">Hover</span></traits-host>';
|
|
34
|
+
const host = document.body.firstElementChild;
|
|
35
|
+
const inner = host.querySelector('.inner');
|
|
36
|
+
// Synthesize a pointerenter on the wrapper (DOM bubbling up from inner
|
|
37
|
+
// would also work but happy-dom doesn't always reflect that for hover).
|
|
38
|
+
host.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }));
|
|
39
|
+
expect(host.hasAttribute('data-hoverable-hover')).toBe(true);
|
|
40
|
+
host.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true }));
|
|
41
|
+
expect(host.hasAttribute('data-hoverable-hover')).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('changing traits attribute swaps the trait stack', () => {
|
|
45
|
+
document.body.innerHTML = '<traits-host traits="pressable"><div>x</div></traits-host>';
|
|
46
|
+
const host = document.body.firstElementChild;
|
|
47
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
48
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
|
|
49
|
+
host.dispatchEvent(new PointerEvent('pointerup'));
|
|
50
|
+
|
|
51
|
+
host.setAttribute('traits', 'hoverable');
|
|
52
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
53
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
|
|
54
|
+
host.dispatchEvent(new PointerEvent('pointerenter'));
|
|
55
|
+
expect(host.hasAttribute('data-hoverable-hover')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('removing the host cleans up trait attributes', () => {
|
|
59
|
+
document.body.innerHTML = '<traits-host traits="pressable"><div>x</div></traits-host>';
|
|
60
|
+
const host = document.body.firstElementChild;
|
|
61
|
+
host.dispatchEvent(new PointerEvent('pointerdown'));
|
|
62
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
|
|
63
|
+
host.remove();
|
|
64
|
+
expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('with no traits attribute: pure pass-through, children render', () => {
|
|
68
|
+
document.body.innerHTML = '<traits-host><span class="x">child</span></traits-host>';
|
|
69
|
+
const host = document.body.firstElementChild;
|
|
70
|
+
expect(host.querySelector('.x')).toBeTruthy();
|
|
71
|
+
expect(host.querySelector('.x').textContent).toBe('child');
|
|
72
|
+
});
|
|
73
|
+
});
|