@adia-ai/web-components 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/button/button.js +3 -0
- package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
- package/components/demo-toggle/demo-toggle.css +120 -0
- package/components/demo-toggle/demo-toggle.js +144 -0
- package/components/demo-toggle/demo-toggle.test.js +102 -0
- package/components/demo-toggle/demo-toggle.yaml +144 -0
- package/components/fields/fields.a2ui.json +106 -0
- package/components/fields/fields.css +60 -0
- package/components/fields/fields.js +96 -0
- package/components/fields/fields.test.js +88 -0
- package/components/fields/fields.yaml +120 -0
- package/components/index.js +2 -0
- package/components/input/input.js +11 -0
- package/components/list/list.css +21 -0
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +12 -1
- package/package.json +10 -10
- package/styles/components.css +2 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +259 -4
- package/traits/active-state.test.js +1 -1
- package/traits/anchor-positioning.js +205 -52
- package/traits/anchor-positioning.test.js +77 -4
- package/traits/announcer-stage.js +157 -0
- package/traits/announcer.js +145 -0
- package/traits/announcer.test.js +268 -0
- package/traits/arrow-grid-nav.js +234 -0
- package/traits/arrow-grid-nav.test.js +375 -0
- package/traits/attention-pulse.js +1 -1
- package/traits/attention-pulse.test.js +1 -1
- package/traits/confetti-burst.js +67 -63
- package/traits/confetti-burst.test.js +16 -8
- package/traits/confetti-stage.js +143 -0
- package/traits/confetti.js +44 -47
- package/traits/confetti.test.js +24 -5
- package/traits/count-up.js +31 -6
- package/traits/count-up.test.js +1 -1
- package/traits/declarative.test.js +1 -1
- package/traits/dirty-state.test.js +1 -1
- package/traits/drag-ghost.js +43 -3
- package/traits/drag-ghost.test.js +1 -1
- package/traits/draggable-list-item.js +458 -0
- package/traits/draggable-list-item.test.js +51 -0
- package/traits/draggable.js +14 -4
- package/traits/draggable.test.js +1 -1
- package/traits/drop-target.js +223 -0
- package/traits/drop-target.test.js +241 -0
- package/traits/droppable-collection.js +89 -0
- package/traits/droppable-collection.test.js +99 -0
- package/traits/droppable.js +136 -0
- package/traits/droppable.test.js +54 -0
- package/traits/error-shake.js +157 -0
- package/traits/error-shake.test.js +114 -0
- package/traits/fade-presence.test.js +1 -1
- package/traits/focus-restore.js +135 -0
- package/traits/focus-restore.test.js +202 -0
- package/traits/focus-trap.test.js +1 -1
- package/traits/focusable.test.js +1 -1
- package/traits/glow-focus.js +1 -1
- package/traits/glow-focus.test.js +1 -1
- package/traits/gradient-shift.js +1 -1
- package/traits/gradient-shift.test.js +1 -1
- package/traits/haptic-feedback.test.js +1 -1
- package/traits/hotkey.test.js +1 -1
- package/traits/hoverable.test.js +1 -1
- package/traits/index.js +15 -0
- package/traits/inertia-drag.js +9 -0
- package/traits/inertia-drag.test.js +1 -1
- package/traits/input-mask.js +328 -0
- package/traits/input-mask.test.js +151 -0
- package/traits/intersection-observer.test.js +1 -1
- package/traits/keyboard-nav.test.js +1 -1
- package/traits/keyboard-reorderable.js +254 -0
- package/traits/keyboard-reorderable.test.js +45 -0
- package/traits/layout-animation.js +229 -0
- package/traits/layout-animation.test.js +114 -0
- package/traits/long-press.js +212 -0
- package/traits/long-press.test.js +244 -0
- package/traits/magnetic-hover.js +1 -1
- package/traits/magnetic-hover.test.js +1 -1
- package/traits/noise-texture.js +7 -3
- package/traits/noise-texture.test.js +1 -1
- package/traits/parallax.js +1 -1
- package/traits/parallax.test.js +1 -1
- package/traits/portal.test.js +1 -1
- package/traits/pressable.test.js +1 -1
- package/traits/resettable.js +29 -3
- package/traits/resettable.test.js +34 -1
- package/traits/resizable.test.js +1 -1
- package/traits/resize-observer.test.js +1 -1
- package/traits/ripple.js +1 -1
- package/traits/ripple.test.js +1 -1
- package/traits/roving-tabindex.test.js +1 -1
- package/traits/scale-press.test.js +1 -1
- package/traits/scroll-lock.test.js +1 -1
- package/traits/scroll-progress.js +201 -0
- package/traits/scroll-progress.test.js +182 -0
- package/traits/shimmer-loading.js +1 -1
- package/traits/shimmer-loading.test.js +1 -1
- package/traits/{_smoke.test.js → smoke.test.js} +1 -1
- package/traits/snap-to-grid.test.js +1 -1
- package/traits/sound-feedback.test.js +1 -1
- package/traits/spring-animate.test.js +1 -1
- package/traits/success-checkmark.js +222 -0
- package/traits/success-checkmark.test.js +120 -0
- package/traits/tilt-hover.js +1 -1
- package/traits/tilt-hover.test.js +1 -1
- package/traits/tossable.js +9 -0
- package/traits/tossable.test.js +1 -1
- package/traits/traits-host.test.js +1 -1
- package/traits/typeahead.test.js +1 -1
- package/traits/typewriter.js +1 -1
- package/traits/typewriter.test.js +1 -1
- package/traits/validation.test.js +1 -1
- package/traits/view-transition.js +140 -0
- package/traits/view-transition.test.js +268 -0
- /package/traits/{_motion.js → motion.js} +0 -0
- /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `droppable` — marks an element as a drop target for `draggable-list-item`.
|
|
5
|
+
*
|
|
6
|
+
* Authored for the Tasks UI playground (docs/projects/tasks-playground/spec/).
|
|
7
|
+
* Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'input-interaction').
|
|
8
|
+
*
|
|
9
|
+
* Pairs with `draggable-list-item` (motion-positioning) which lifts a list
|
|
10
|
+
* item and emits `dnd-lift` / `dnd-drop-target-change` / `dnd-drop` /
|
|
11
|
+
* `dnd-drop-cancel` ON THE DOCUMENT. This trait observes the drag in
|
|
12
|
+
* flight and emits target-side events ON THE HOST:
|
|
13
|
+
*
|
|
14
|
+
* `dnd-drop-enter` pointer entered this droppable
|
|
15
|
+
* `dnd-drop-leave` pointer left this droppable
|
|
16
|
+
* `dnd-drop-receive` the drop landed here (source-side `dnd-drop`
|
|
17
|
+
* on the document is the lifter's "release"
|
|
18
|
+
* signal; the target-side event MUST be named
|
|
19
|
+
* differently or it bubbles back to the
|
|
20
|
+
* document and re-triggers this same listener
|
|
21
|
+
* in an infinite loop)
|
|
22
|
+
*
|
|
23
|
+
* Coordination: pure DOM events (light-DOM, bubbles: true). No shared
|
|
24
|
+
* runtime state. The dragging item dispatches `dnd-drop-target-change`
|
|
25
|
+
* with the candidate target id; the targeted droppable receives a
|
|
26
|
+
* synthetic `dnd-drop-enter` from a tiny internal coordinator.
|
|
27
|
+
*
|
|
28
|
+
* For v1 the coordinator is a single shared module-level Map keyed by
|
|
29
|
+
* `data-droppable-id`. Future versions may promote to a registry trait.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const ATTR_ID = 'data-droppable-id';
|
|
33
|
+
const ATTR_OVER = 'data-droppable-over';
|
|
34
|
+
const ATTR_VALID = 'data-droppable-valid';
|
|
35
|
+
|
|
36
|
+
/** @type {Map<string, HTMLElement>} */
|
|
37
|
+
const REGISTRY = new Map();
|
|
38
|
+
/** @type {string | null} */
|
|
39
|
+
let CURRENT_TARGET_ID = null;
|
|
40
|
+
|
|
41
|
+
export const droppable = defineTrait({
|
|
42
|
+
name: 'droppable',
|
|
43
|
+
category: 'input-interaction',
|
|
44
|
+
description: 'Marks an element as a drop target for draggable-list-item; emits dnd-drop-enter / leave / receive',
|
|
45
|
+
attributes: [ATTR_ID, ATTR_OVER, ATTR_VALID],
|
|
46
|
+
events: ['dnd-drop-enter', 'dnd-drop-leave', 'dnd-drop-receive'],
|
|
47
|
+
config: [],
|
|
48
|
+
setup({ host }) {
|
|
49
|
+
// Assign a stable id for the registry. Authoring code may set it
|
|
50
|
+
// explicitly via the attribute; otherwise generate.
|
|
51
|
+
let id = host.getAttribute(ATTR_ID);
|
|
52
|
+
if (!id) {
|
|
53
|
+
id = `drop-${crypto.randomUUID().slice(0, 8)}`;
|
|
54
|
+
host.setAttribute(ATTR_ID, id);
|
|
55
|
+
}
|
|
56
|
+
REGISTRY.set(id, host);
|
|
57
|
+
|
|
58
|
+
// Listen for drag-in-flight signals on the document so this droppable
|
|
59
|
+
// knows when it should self-mark as the target.
|
|
60
|
+
//
|
|
61
|
+
// Bug guard (2026-05-04): per-host attribute is the source of truth.
|
|
62
|
+
// The previous implementation gated the leave branch on a shared
|
|
63
|
+
// module variable CURRENT_TARGET_ID, which made the result dependent
|
|
64
|
+
// on listener-execution order — when the target swap fired and the
|
|
65
|
+
// NEW target's listener ran before the OLD target's, the old one
|
|
66
|
+
// saw CURRENT_TARGET_ID already moved and skipped clearing its
|
|
67
|
+
// ATTR_OVER, leaving multiple columns visually marked as the drop
|
|
68
|
+
// target. Per-host hasAttribute() is independent of order.
|
|
69
|
+
function onTargetChange(/** @type {CustomEvent} */ e) {
|
|
70
|
+
const detail = e.detail;
|
|
71
|
+
if (!detail) return;
|
|
72
|
+
const isMe = detail.target_container_id === id;
|
|
73
|
+
const wasOver = host.hasAttribute(ATTR_OVER);
|
|
74
|
+
if (isMe && !wasOver) {
|
|
75
|
+
host.setAttribute(ATTR_OVER, '');
|
|
76
|
+
CURRENT_TARGET_ID = id;
|
|
77
|
+
host.dispatchEvent(new CustomEvent('dnd-drop-enter', {
|
|
78
|
+
bubbles: true,
|
|
79
|
+
composed: false,
|
|
80
|
+
detail: { source_id: detail.source_id, pointer_position: detail.pointer ?? { x: 0, y: 0 } },
|
|
81
|
+
}));
|
|
82
|
+
} else if (!isMe && wasOver) {
|
|
83
|
+
host.removeAttribute(ATTR_OVER);
|
|
84
|
+
if (CURRENT_TARGET_ID === id) CURRENT_TARGET_ID = null;
|
|
85
|
+
host.dispatchEvent(new CustomEvent('dnd-drop-leave', {
|
|
86
|
+
bubbles: true,
|
|
87
|
+
composed: false,
|
|
88
|
+
detail: { source_id: detail.source_id ?? null },
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onDrop(/** @type {CustomEvent} */ e) {
|
|
94
|
+
const detail = e.detail;
|
|
95
|
+
if (!detail || detail.target_container_id !== id) return;
|
|
96
|
+
host.removeAttribute(ATTR_OVER);
|
|
97
|
+
CURRENT_TARGET_ID = null;
|
|
98
|
+
host.dispatchEvent(new CustomEvent('dnd-drop-receive', {
|
|
99
|
+
bubbles: true,
|
|
100
|
+
composed: false,
|
|
101
|
+
detail: {
|
|
102
|
+
source_id: detail.source_id,
|
|
103
|
+
source_index: detail.source_index,
|
|
104
|
+
source_container_id: detail.source_container_id,
|
|
105
|
+
target_container_id: id,
|
|
106
|
+
target_index: detail.target_index,
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onDragEnd() {
|
|
112
|
+
// Whatever happened, reset the over state.
|
|
113
|
+
host.removeAttribute(ATTR_OVER);
|
|
114
|
+
if (CURRENT_TARGET_ID === id) CURRENT_TARGET_ID = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
document.addEventListener('dnd-drop-target-change', onTargetChange);
|
|
118
|
+
document.addEventListener('dnd-drop', onDrop);
|
|
119
|
+
document.addEventListener('dnd-drop-cancel', onDragEnd);
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
document.removeEventListener('dnd-drop-target-change', onTargetChange);
|
|
123
|
+
document.removeEventListener('dnd-drop', onDrop);
|
|
124
|
+
document.removeEventListener('dnd-drop-cancel', onDragEnd);
|
|
125
|
+
REGISTRY.delete(id);
|
|
126
|
+
host.removeAttribute(ATTR_ID);
|
|
127
|
+
host.removeAttribute(ATTR_OVER);
|
|
128
|
+
host.removeAttribute(ATTR_VALID);
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** Internal: lookup a droppable host by id. Used by `draggable-list-item`. */
|
|
134
|
+
export function getDroppable(id) {
|
|
135
|
+
return REGISTRY.get(id) ?? null;
|
|
136
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { droppable } from './droppable.js';
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
// Minimal smoke coverage. The droppable trait is authored against the
|
|
6
|
+
// docs/projects/tasks-playground/ DnD coordinator and assumes a sibling
|
|
7
|
+
// `draggable-list-item` trait dispatches `dnd:drop-target-change` /
|
|
8
|
+
// `dnd:drop` on the document. The cases below only exercise the trait's
|
|
9
|
+
// own surface (schema, attribute lifecycle, registry entry, cleanup) so
|
|
10
|
+
// the verify:traits gate stays green; the higher-fidelity DnD-flow tests
|
|
11
|
+
// belong with the Tasks UI initiative that owns the coordinator.
|
|
12
|
+
|
|
13
|
+
describe('droppable', () => {
|
|
14
|
+
beforeEach(resetDOM);
|
|
15
|
+
|
|
16
|
+
it('schema shape is defined', () => {
|
|
17
|
+
expect(droppable.schema.name).toBe('droppable');
|
|
18
|
+
expect(droppable.schema.category).toBe('input-interaction');
|
|
19
|
+
expect(droppable.schema.description.length).toBeGreaterThanOrEqual(11);
|
|
20
|
+
expect(droppable.schema.events).toContain('dnd-drop-enter');
|
|
21
|
+
// Source-side `dnd-drop` (on document, from the lifter) and target-side
|
|
22
|
+
// `dnd-drop-receive` (on host) MUST be distinct names — sharing the name
|
|
23
|
+
// recurses infinitely when the host event bubbles back to the document.
|
|
24
|
+
expect(droppable.schema.events).toContain('dnd-drop-receive');
|
|
25
|
+
expect(droppable.schema.events).not.toContain('dnd-drop');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('connect assigns an id + sets the host attribute', () => {
|
|
29
|
+
const host = mountHost('div');
|
|
30
|
+
connectTrait(droppable, host);
|
|
31
|
+
expect(host.getAttribute('data-droppable-id')).toBeTruthy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('disconnect removes the over attribute if set', () => {
|
|
35
|
+
const host = mountHost('div');
|
|
36
|
+
const inst = droppable();
|
|
37
|
+
inst.connect(host);
|
|
38
|
+
host.setAttribute('data-droppable-over', '');
|
|
39
|
+
inst.disconnect(host);
|
|
40
|
+
expect(host.hasAttribute('data-droppable-over')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reconnect with the same host does not throw', () => {
|
|
44
|
+
const host = mountHost('div');
|
|
45
|
+
const inst = droppable();
|
|
46
|
+
inst.connect(host);
|
|
47
|
+
inst.disconnect(host);
|
|
48
|
+
expect(() => {
|
|
49
|
+
const inst2 = droppable();
|
|
50
|
+
inst2.connect(host);
|
|
51
|
+
inst2.disconnect(host);
|
|
52
|
+
}).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
import { prefersReducedMotion } from './motion.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `error-shake` — horizontal lateral oscillation on validation failure.
|
|
6
|
+
*
|
|
7
|
+
* The de-facto "wrong password" affordance. Listens for the `validated`
|
|
8
|
+
* event with `detail.valid === false` (fired by the `validation` trait),
|
|
9
|
+
* watches the `data-validation-invalid` attribute for false→true
|
|
10
|
+
* transitions, and supports a programmatic `data-error-shake-trigger`
|
|
11
|
+
* attribute toggle so non-form contexts can fire shakes too.
|
|
12
|
+
*
|
|
13
|
+
* Composes naturally with `validation`:
|
|
14
|
+
* <input-ui traits="validation error-shake" data-validate="required, email">
|
|
15
|
+
*
|
|
16
|
+
* On trigger, the host shakes for ~400ms via a translateX-only keyframe
|
|
17
|
+
* animation — never `margin`/`width`, so siblings don't reflow. The
|
|
18
|
+
* keyframes run on the compositor and the keyframe `<style>` is owned
|
|
19
|
+
* by the trait instance (not shared) so disconnect is clean.
|
|
20
|
+
*
|
|
21
|
+
* Reduced-motion: skip the animation entirely, but still mark
|
|
22
|
+
* `data-error-shake-active` for 200ms before removing — gives consumer
|
|
23
|
+
* stylesheets a CSS hook to swap in a static error-tint or border
|
|
24
|
+
* without re-querying the media query themselves.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const DEFAULT_AMPLITUDE = 8; // px lateral travel
|
|
28
|
+
const DEFAULT_DURATION = 400; // ms total
|
|
29
|
+
const REDUCED_MOTION_HOLD = 200; // ms — how long the marker sits in reduced-motion mode
|
|
30
|
+
|
|
31
|
+
export const errorShake = defineTrait({
|
|
32
|
+
name: 'error-shake',
|
|
33
|
+
category: 'animation-feedback',
|
|
34
|
+
description: 'Horizontal lateral oscillation on validation failure',
|
|
35
|
+
attributes: ['data-error-shake-active'],
|
|
36
|
+
events: ['error-shake-done'],
|
|
37
|
+
config: ['data-error-shake-amplitude', 'data-error-shake-duration', 'data-error-shake-trigger'],
|
|
38
|
+
setup({ host }) {
|
|
39
|
+
const activeTimers = new Set();
|
|
40
|
+
let styleEl = null;
|
|
41
|
+
let keyframeName = null;
|
|
42
|
+
let lastInvalid = host.hasAttribute('data-validation-invalid');
|
|
43
|
+
|
|
44
|
+
function readAmplitude() {
|
|
45
|
+
const v = parseFloat(host.getAttribute('data-error-shake-amplitude'));
|
|
46
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_AMPLITUDE;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readDuration() {
|
|
50
|
+
const v = parseInt(host.getAttribute('data-error-shake-duration'), 10);
|
|
51
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_DURATION;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureKeyframes(amp) {
|
|
55
|
+
if (styleEl) styleEl.remove();
|
|
56
|
+
keyframeName = `adia-error-shake-${Math.random().toString(36).slice(2, 8)}`;
|
|
57
|
+
styleEl = document.createElement('style');
|
|
58
|
+
// 4-cycle oscillation. translateX only — never margin/width — so
|
|
59
|
+
// the host's siblings don't reflow during the shake.
|
|
60
|
+
styleEl.textContent = `
|
|
61
|
+
@keyframes ${keyframeName} {
|
|
62
|
+
0%, 100% { transform: translateX(0); }
|
|
63
|
+
15% { transform: translateX(-${amp}px); }
|
|
64
|
+
30% { transform: translateX(${amp}px); }
|
|
65
|
+
45% { transform: translateX(-${amp * 0.7}px); }
|
|
66
|
+
60% { transform: translateX(${amp * 0.7}px); }
|
|
67
|
+
75% { transform: translateX(-${amp * 0.4}px); }
|
|
68
|
+
90% { transform: translateX(${amp * 0.4}px); }
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
document.head.appendChild(styleEl);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fireDone() {
|
|
75
|
+
host.removeAttribute('data-error-shake-active');
|
|
76
|
+
host.dispatchEvent(new CustomEvent('error-shake-done', { bubbles: true }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reducedMotionShake() {
|
|
80
|
+
host.setAttribute('data-error-shake-active', '');
|
|
81
|
+
const t = setTimeout(() => {
|
|
82
|
+
activeTimers.delete(t);
|
|
83
|
+
fireDone();
|
|
84
|
+
}, REDUCED_MOTION_HOLD);
|
|
85
|
+
activeTimers.add(t);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shake() {
|
|
89
|
+
if (prefersReducedMotion()) {
|
|
90
|
+
reducedMotionShake();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const amp = readAmplitude();
|
|
95
|
+
const dur = readDuration();
|
|
96
|
+
ensureKeyframes(amp);
|
|
97
|
+
|
|
98
|
+
// Force-reset any in-flight animation so back-to-back triggers
|
|
99
|
+
// restart cleanly (e.g. user mashes submit on an invalid form).
|
|
100
|
+
host.style.animation = '';
|
|
101
|
+
// Reading offsetWidth flushes the style change — the next assignment
|
|
102
|
+
// is then guaranteed to be seen as a transition by the engine.
|
|
103
|
+
void host.offsetWidth;
|
|
104
|
+
host.style.animation = `${keyframeName} ${dur}ms ease-in-out`;
|
|
105
|
+
host.setAttribute('data-error-shake-active', '');
|
|
106
|
+
|
|
107
|
+
const t = setTimeout(() => {
|
|
108
|
+
activeTimers.delete(t);
|
|
109
|
+
host.style.animation = '';
|
|
110
|
+
fireDone();
|
|
111
|
+
}, dur);
|
|
112
|
+
activeTimers.add(t);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function onValidated(e) {
|
|
116
|
+
if (e?.detail && e.detail.valid === false) shake();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Watch data-validation-invalid + data-error-shake-trigger for
|
|
120
|
+
// attribute changes. validation trait sets data-validation-invalid
|
|
121
|
+
// synchronously *before* dispatching `validated`, so the observer
|
|
122
|
+
// and event listener can both fire — we de-dupe by tracking the
|
|
123
|
+
// last-known invalid state and only triggering on false → true.
|
|
124
|
+
const observer = new MutationObserver((muts) => {
|
|
125
|
+
for (const m of muts) {
|
|
126
|
+
if (m.attributeName === 'data-validation-invalid') {
|
|
127
|
+
const nowInvalid = host.hasAttribute('data-validation-invalid');
|
|
128
|
+
if (nowInvalid && !lastInvalid) shake();
|
|
129
|
+
lastInvalid = nowInvalid;
|
|
130
|
+
} else if (m.attributeName === 'data-error-shake-trigger') {
|
|
131
|
+
// Toggle (any change) fires a shake. Empty-string and "" are
|
|
132
|
+
// both treated as "present" — the convention for boolean attrs.
|
|
133
|
+
if (host.hasAttribute('data-error-shake-trigger')) shake();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
observer.observe(host, {
|
|
138
|
+
attributes: true,
|
|
139
|
+
attributeFilter: ['data-validation-invalid', 'data-error-shake-trigger'],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
host.addEventListener('validated', onValidated);
|
|
143
|
+
|
|
144
|
+
return () => {
|
|
145
|
+
observer.disconnect();
|
|
146
|
+
host.removeEventListener('validated', onValidated);
|
|
147
|
+
for (const t of activeTimers) clearTimeout(t);
|
|
148
|
+
activeTimers.clear();
|
|
149
|
+
if (styleEl) {
|
|
150
|
+
styleEl.remove();
|
|
151
|
+
styleEl = null;
|
|
152
|
+
}
|
|
153
|
+
host.style.animation = '';
|
|
154
|
+
host.removeAttribute('data-error-shake-active');
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { errorShake } from './error-shake.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM, wait } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('error-shake', () => {
|
|
6
|
+
beforeEach(resetDOM);
|
|
7
|
+
|
|
8
|
+
it('schema declares animation-feedback category', () => {
|
|
9
|
+
expect(errorShake.schema.category).toBe('animation-feedback');
|
|
10
|
+
expect(errorShake.schema.events).toContain('error-shake-done');
|
|
11
|
+
expect(errorShake.schema.attributes).toContain('data-error-shake-active');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('connect → disconnect leaves no managed attribute', () => {
|
|
15
|
+
const host = mountHost();
|
|
16
|
+
const inst = connectTrait(errorShake, host);
|
|
17
|
+
inst.disconnect(host);
|
|
18
|
+
expect(host.hasAttribute('data-error-shake-active')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('fires on validated event with detail.valid === false', async () => {
|
|
22
|
+
const host = mountHost();
|
|
23
|
+
connectTrait(errorShake, host);
|
|
24
|
+
const spy = spyEvent(host, 'error-shake-done');
|
|
25
|
+
|
|
26
|
+
host.dispatchEvent(new CustomEvent('validated', {
|
|
27
|
+
detail: { valid: false, errors: ['nope'] },
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
expect(host.hasAttribute('data-error-shake-active')).toBe(true);
|
|
31
|
+
// Wait through the default 400ms duration + a small grace window.
|
|
32
|
+
await wait(450);
|
|
33
|
+
expect(spy.count).toBe(1);
|
|
34
|
+
expect(host.hasAttribute('data-error-shake-active')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not fire on validated event with detail.valid === true', async () => {
|
|
38
|
+
const host = mountHost();
|
|
39
|
+
connectTrait(errorShake, host);
|
|
40
|
+
const spy = spyEvent(host, 'error-shake-done');
|
|
41
|
+
|
|
42
|
+
host.dispatchEvent(new CustomEvent('validated', {
|
|
43
|
+
detail: { valid: true, errors: [] },
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
await wait(50);
|
|
47
|
+
expect(spy.count).toBe(0);
|
|
48
|
+
expect(host.hasAttribute('data-error-shake-active')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('fires when data-validation-invalid is set (false → true)', async () => {
|
|
52
|
+
const host = mountHost();
|
|
53
|
+
connectTrait(errorShake, host);
|
|
54
|
+
const spy = spyEvent(host, 'error-shake-done');
|
|
55
|
+
|
|
56
|
+
host.setAttribute('data-validation-invalid', '');
|
|
57
|
+
// MutationObserver microtask flush.
|
|
58
|
+
await wait(0);
|
|
59
|
+
expect(host.hasAttribute('data-error-shake-active')).toBe(true);
|
|
60
|
+
await wait(450);
|
|
61
|
+
expect(spy.count).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not re-fire when data-validation-invalid stays true across mutations', async () => {
|
|
65
|
+
const host = mountHost('div', { 'data-validation-invalid': '' });
|
|
66
|
+
connectTrait(errorShake, host);
|
|
67
|
+
const spy = spyEvent(host, 'error-shake-done');
|
|
68
|
+
|
|
69
|
+
// Re-set the attribute (no false→true edge — it was already true).
|
|
70
|
+
host.setAttribute('data-validation-invalid', '');
|
|
71
|
+
await wait(20);
|
|
72
|
+
expect(spy.count).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('respects data-error-shake-trigger attribute toggle', async () => {
|
|
76
|
+
const host = mountHost();
|
|
77
|
+
connectTrait(errorShake, host);
|
|
78
|
+
const spy = spyEvent(host, 'error-shake-done');
|
|
79
|
+
|
|
80
|
+
host.setAttribute('data-error-shake-trigger', '');
|
|
81
|
+
await wait(0);
|
|
82
|
+
expect(host.hasAttribute('data-error-shake-active')).toBe(true);
|
|
83
|
+
await wait(450);
|
|
84
|
+
expect(spy.count).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('respects data-error-shake-duration config', async () => {
|
|
88
|
+
const host = mountHost('div', { 'data-error-shake-duration': '100' });
|
|
89
|
+
connectTrait(errorShake, host);
|
|
90
|
+
const spy = spyEvent(host, 'error-shake-done');
|
|
91
|
+
|
|
92
|
+
host.dispatchEvent(new CustomEvent('validated', {
|
|
93
|
+
detail: { valid: false, errors: [''] },
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
// Should resolve in ~100ms, not the default 400ms.
|
|
97
|
+
await wait(150);
|
|
98
|
+
expect(spy.count).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('disconnect clears the keyframe <style> from the document', () => {
|
|
102
|
+
const host = mountHost();
|
|
103
|
+
const inst = connectTrait(errorShake, host);
|
|
104
|
+
host.dispatchEvent(new CustomEvent('validated', {
|
|
105
|
+
detail: { valid: false, errors: [''] },
|
|
106
|
+
}));
|
|
107
|
+
const styleCount = document.querySelectorAll('style').length;
|
|
108
|
+
inst.disconnect(host);
|
|
109
|
+
// After disconnect the keyframe style should be gone — exact count
|
|
110
|
+
// depends on what other styles exist, so we just assert non-greater.
|
|
111
|
+
expect(document.querySelectorAll('style').length).toBeLessThan(styleCount + 1);
|
|
112
|
+
expect(host.style.animation).toBe('');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { fadePresence } from './fade-presence.js';
|
|
3
|
-
import { mountHost, connectTrait, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('fade-presence', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `focus-restore` — capture the previously-focused element on connect,
|
|
5
|
+
* restore focus to it on disconnect.
|
|
6
|
+
*
|
|
7
|
+
* The canonical "modal-close returns focus to the trigger" pattern. Pairs
|
|
8
|
+
* with `focus-trap` (which holds Tab inside a container) + `portal` (which
|
|
9
|
+
* moves the container into a top-layer root). focus-restore closes the
|
|
10
|
+
* loop: when the container goes away, focus returns to where the user was
|
|
11
|
+
* before it appeared.
|
|
12
|
+
*
|
|
13
|
+
* Without this trait, modals / dialogs / drawers leave focus dangling on
|
|
14
|
+
* `<body>` after dismiss — keyboard users have to tab from the start of
|
|
15
|
+
* the document to get back to the trigger they pressed.
|
|
16
|
+
*
|
|
17
|
+
* On connect:
|
|
18
|
+
* 1. Captures `document.activeElement` as the restore target.
|
|
19
|
+
* 2. Sets `data-focus-restore-active` on the host.
|
|
20
|
+
* 3. (Optional) Moves focus per `data-focus-restore-on-mount`:
|
|
21
|
+
* - absent / `"none"`: capture only, don't move focus.
|
|
22
|
+
* - `"host"`: focus the host itself.
|
|
23
|
+
* - `"first-focusable"`: focus the first tabbable descendant.
|
|
24
|
+
*
|
|
25
|
+
* On disconnect:
|
|
26
|
+
* 1. If the captured target is still in the document AND focusable,
|
|
27
|
+
* `el.focus({ preventScroll: true })` it.
|
|
28
|
+
* 2. Otherwise fall back to `<body>` (or the host's parent if body is
|
|
29
|
+
* gone for some reason).
|
|
30
|
+
* 3. Removes `data-focus-restore-active`.
|
|
31
|
+
* 4. Dispatches `focus-restored` so consumers can observe.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const FOCUSABLE_SELECTOR =
|
|
35
|
+
'a[href], [role="button"][tabindex], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]';
|
|
36
|
+
|
|
37
|
+
function isFocusable(el) {
|
|
38
|
+
if (!el || !(el instanceof Element)) return false;
|
|
39
|
+
if (typeof el.focus !== 'function') return false;
|
|
40
|
+
if (el.hasAttribute('disabled')) return false;
|
|
41
|
+
// Element must still be connected to the document for focus to land.
|
|
42
|
+
if (!el.isConnected) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findFirstFocusable(host) {
|
|
47
|
+
// Try descendants in document order.
|
|
48
|
+
const candidates = host.querySelectorAll(FOCUSABLE_SELECTOR);
|
|
49
|
+
for (const el of candidates) {
|
|
50
|
+
if (!el.hasAttribute('disabled')) return el;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const focusRestore = defineTrait({
|
|
56
|
+
name: 'focus-restore',
|
|
57
|
+
category: 'keyboard-navigation',
|
|
58
|
+
description:
|
|
59
|
+
'Capture previously-focused element on connect, restore focus on disconnect',
|
|
60
|
+
attributes: ['data-focus-restore-active'],
|
|
61
|
+
events: ['focus-restored'],
|
|
62
|
+
config: ['data-focus-restore-on-mount'],
|
|
63
|
+
setup({ host }) {
|
|
64
|
+
// Capture BEFORE we move focus — otherwise step 3 below would set the
|
|
65
|
+
// restore target to the host (or first-focusable) and the trait would
|
|
66
|
+
// restore to itself on disconnect, defeating the contract.
|
|
67
|
+
const restoreTarget = document.activeElement;
|
|
68
|
+
|
|
69
|
+
host.setAttribute('data-focus-restore-active', '');
|
|
70
|
+
|
|
71
|
+
const onMount = host.getAttribute('data-focus-restore-on-mount') || 'none';
|
|
72
|
+
if (onMount === 'host') {
|
|
73
|
+
// Host needs to be focusable for this to land — common pattern is
|
|
74
|
+
// tabindex="-1" on the dialog container.
|
|
75
|
+
try {
|
|
76
|
+
host.focus({ preventScroll: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* host not focusable — silent no-op, matches focus-trap's tolerance */
|
|
79
|
+
}
|
|
80
|
+
} else if (onMount === 'first-focusable') {
|
|
81
|
+
const first = findFirstFocusable(host);
|
|
82
|
+
if (first) {
|
|
83
|
+
try {
|
|
84
|
+
first.focus({ preventScroll: true });
|
|
85
|
+
} catch {
|
|
86
|
+
/* swallow — graceful degrade */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// onMount === 'none' (or absent / unrecognized): capture only, don't
|
|
91
|
+
// move focus. Pointer-opened surfaces shouldn't shift focus on mount.
|
|
92
|
+
|
|
93
|
+
return () => {
|
|
94
|
+
host.removeAttribute('data-focus-restore-active');
|
|
95
|
+
|
|
96
|
+
let restoredTo = null;
|
|
97
|
+
|
|
98
|
+
if (isFocusable(restoreTarget)) {
|
|
99
|
+
try {
|
|
100
|
+
restoreTarget.focus({ preventScroll: true });
|
|
101
|
+
restoredTo = restoreTarget;
|
|
102
|
+
} catch {
|
|
103
|
+
/* fall through to fallback */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!restoredTo) {
|
|
108
|
+
// Fallback: focus body (or host's parent if body is somehow gone).
|
|
109
|
+
const fallback =
|
|
110
|
+
(document.body && document.body.isConnected ? document.body : null) ||
|
|
111
|
+
host.parentElement ||
|
|
112
|
+
null;
|
|
113
|
+
if (fallback && typeof fallback.focus === 'function') {
|
|
114
|
+
try {
|
|
115
|
+
// <body> needs a tabindex to receive .focus() in some engines;
|
|
116
|
+
// we don't mutate it (no trait should leave document-level
|
|
117
|
+
// attribute residue). If focus doesn't land, the browser
|
|
118
|
+
// gracefully drops to no-active — same as the no-trait baseline.
|
|
119
|
+
fallback.focus({ preventScroll: true });
|
|
120
|
+
restoredTo = fallback;
|
|
121
|
+
} catch {
|
|
122
|
+
/* nothing more we can do — accept the dangle */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
host.dispatchEvent(
|
|
128
|
+
new CustomEvent('focus-restored', {
|
|
129
|
+
bubbles: true,
|
|
130
|
+
detail: { restoredTo, capturedTarget: restoreTarget },
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
});
|