@adia-ai/web-components 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/agent-trace/agent-trace.css +24 -3
- package/components/button/button.js +3 -0
- package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
- package/components/demo-toggle/demo-toggle.css +120 -0
- package/components/demo-toggle/demo-toggle.js +144 -0
- package/components/demo-toggle/demo-toggle.test.js +102 -0
- package/components/demo-toggle/demo-toggle.yaml +144 -0
- package/components/index.js +1 -0
- package/components/input/input.js +11 -0
- package/components/list/list.css +66 -3
- package/components/nav-group/nav-group.a2ui.json +1 -1
- package/components/nav-group/nav-group.css +5 -5
- package/components/nav-group/nav-group.yaml +1 -1
- package/components/nav-item/nav-item.a2ui.json +1 -1
- package/components/nav-item/nav-item.css +3 -4
- package/components/nav-item/nav-item.yaml +1 -1
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +13 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +258 -5
- package/traits/active-state.test.js +1 -1
- package/traits/anchor-positioning.js +205 -52
- package/traits/anchor-positioning.test.js +77 -4
- package/traits/announcer-stage.js +157 -0
- package/traits/announcer.js +145 -0
- package/traits/announcer.test.js +268 -0
- package/traits/arrow-grid-nav.js +234 -0
- package/traits/arrow-grid-nav.test.js +375 -0
- package/traits/attention-pulse.js +1 -1
- package/traits/attention-pulse.test.js +1 -1
- package/traits/confetti-burst.js +90 -60
- package/traits/confetti-burst.test.js +16 -8
- package/traits/confetti-stage.js +143 -0
- package/traits/confetti.js +44 -47
- package/traits/confetti.test.js +24 -5
- package/traits/count-up.js +31 -6
- package/traits/count-up.test.js +1 -1
- package/traits/declarative.test.js +1 -1
- package/traits/dirty-state.test.js +1 -1
- package/traits/drag-ghost.js +55 -3
- package/traits/drag-ghost.test.js +1 -1
- package/traits/draggable-list-item.js +279 -0
- package/traits/draggable-list-item.test.js +51 -0
- package/traits/draggable.js +14 -4
- package/traits/draggable.test.js +1 -1
- package/traits/drop-target.js +223 -0
- package/traits/drop-target.test.js +241 -0
- package/traits/droppable-collection.js +89 -0
- package/traits/droppable-collection.test.js +99 -0
- package/traits/droppable.js +125 -0
- package/traits/droppable.test.js +54 -0
- package/traits/error-shake.js +157 -0
- package/traits/error-shake.test.js +114 -0
- package/traits/fade-presence.test.js +1 -1
- package/traits/focus-restore.js +135 -0
- package/traits/focus-restore.test.js +202 -0
- package/traits/focus-trap.test.js +1 -1
- package/traits/focusable.test.js +1 -1
- package/traits/glow-focus.js +1 -1
- package/traits/glow-focus.test.js +1 -1
- package/traits/gradient-shift.js +1 -1
- package/traits/gradient-shift.test.js +1 -1
- package/traits/haptic-feedback.test.js +1 -1
- package/traits/hotkey.test.js +1 -1
- package/traits/hoverable.test.js +1 -1
- package/traits/index.js +15 -0
- package/traits/inertia-drag.js +9 -0
- package/traits/inertia-drag.test.js +1 -1
- package/traits/input-mask.js +328 -0
- package/traits/input-mask.test.js +151 -0
- package/traits/intersection-observer.test.js +1 -1
- package/traits/keyboard-nav.test.js +1 -1
- package/traits/keyboard-reorderable.js +254 -0
- package/traits/keyboard-reorderable.test.js +45 -0
- package/traits/layout-animation.js +229 -0
- package/traits/layout-animation.test.js +114 -0
- package/traits/long-press.js +212 -0
- package/traits/long-press.test.js +244 -0
- package/traits/magnetic-hover.js +1 -1
- package/traits/magnetic-hover.test.js +1 -1
- package/traits/noise-texture.js +7 -3
- package/traits/noise-texture.test.js +1 -1
- package/traits/parallax.js +1 -1
- package/traits/parallax.test.js +1 -1
- package/traits/portal.test.js +1 -1
- package/traits/pressable.test.js +1 -1
- package/traits/resettable.js +29 -3
- package/traits/resettable.test.js +34 -1
- package/traits/resizable.test.js +1 -1
- package/traits/resize-observer.test.js +1 -1
- package/traits/ripple.js +1 -1
- package/traits/ripple.test.js +1 -1
- package/traits/roving-tabindex.test.js +1 -1
- package/traits/scale-press.test.js +1 -1
- package/traits/scroll-lock.test.js +1 -1
- package/traits/scroll-progress.js +201 -0
- package/traits/scroll-progress.test.js +182 -0
- package/traits/shimmer-loading.js +1 -1
- package/traits/shimmer-loading.test.js +1 -1
- package/traits/{_smoke.test.js → smoke.test.js} +1 -1
- package/traits/snap-to-grid.test.js +1 -1
- package/traits/sound-feedback.test.js +1 -1
- package/traits/spring-animate.js +8 -3
- package/traits/spring-animate.test.js +1 -1
- package/traits/success-checkmark.js +222 -0
- package/traits/success-checkmark.test.js +120 -0
- package/traits/tilt-hover.js +1 -1
- package/traits/tilt-hover.test.js +1 -1
- package/traits/tossable.js +9 -0
- package/traits/tossable.test.js +1 -1
- package/traits/traits-host.test.js +1 -1
- package/traits/typeahead.test.js +1 -1
- package/traits/typewriter.js +1 -1
- package/traits/typewriter.test.js +1 -1
- package/traits/validation.test.js +1 -1
- package/traits/view-transition.js +140 -0
- package/traits/view-transition.test.js +268 -0
- /package/traits/{_motion.js → motion.js} +0 -0
- /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
package/traits/hotkey.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { hotkey } from './hotkey.js';
|
|
3
|
-
import { mountHost, connectTrait, spyEvent, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('hotkey', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
package/traits/hoverable.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { hoverable } from './hoverable.js';
|
|
3
|
-
import { mountHost, connectTrait, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('hoverable', () => {
|
|
6
6
|
beforeEach(resetDOM);
|
package/traits/index.js
CHANGED
|
@@ -13,6 +13,9 @@ export { pressable } from './pressable.js';
|
|
|
13
13
|
export { focusable } from './focusable.js';
|
|
14
14
|
export { hoverable } from './hoverable.js';
|
|
15
15
|
export { activeState } from './active-state.js';
|
|
16
|
+
export { longPress } from './long-press.js';
|
|
17
|
+
export { droppable } from './droppable.js';
|
|
18
|
+
export { droppableCollection } from './droppable-collection.js';
|
|
16
19
|
|
|
17
20
|
// keyboard-navigation
|
|
18
21
|
export { keyboardNav } from './keyboard-nav.js';
|
|
@@ -20,11 +23,15 @@ export { rovingTabindex } from './roving-tabindex.js';
|
|
|
20
23
|
export { typeahead } from './typeahead.js';
|
|
21
24
|
export { hotkey } from './hotkey.js';
|
|
22
25
|
export { focusTrap } from './focus-trap.js';
|
|
26
|
+
export { focusRestore } from './focus-restore.js';
|
|
27
|
+
export { arrowGridNav } from './arrow-grid-nav.js';
|
|
28
|
+
export { keyboardReorderable } from './keyboard-reorderable.js';
|
|
23
29
|
|
|
24
30
|
// forms-data
|
|
25
31
|
export { validation } from './validation.js';
|
|
26
32
|
export { dirtyState } from './dirty-state.js';
|
|
27
33
|
export { resettable } from './resettable.js';
|
|
34
|
+
export { inputMask } from './input-mask.js';
|
|
28
35
|
|
|
29
36
|
// layout-measurement
|
|
30
37
|
export { resizeObserver } from './resize-observer.js';
|
|
@@ -32,6 +39,7 @@ export { intersectionObserver } from './intersection-observer.js';
|
|
|
32
39
|
export { anchorPositioning } from './anchor-positioning.js';
|
|
33
40
|
export { portal } from './portal.js';
|
|
34
41
|
export { scrollLock } from './scroll-lock.js';
|
|
42
|
+
export { scrollProgress } from './scroll-progress.js';
|
|
35
43
|
|
|
36
44
|
// motion-positioning
|
|
37
45
|
export { draggable } from './draggable.js';
|
|
@@ -40,6 +48,10 @@ export { resizable } from './resizable.js';
|
|
|
40
48
|
export { inertiaDrag } from './inertia-drag.js';
|
|
41
49
|
export { snapToGrid } from './snap-to-grid.js';
|
|
42
50
|
export { dragGhost } from './drag-ghost.js';
|
|
51
|
+
export { dropTarget } from './drop-target.js';
|
|
52
|
+
export { layoutAnimation } from './layout-animation.js';
|
|
53
|
+
export { viewTransition } from './view-transition.js';
|
|
54
|
+
export { draggableListItem } from './draggable-list-item.js';
|
|
43
55
|
|
|
44
56
|
// animation-feedback
|
|
45
57
|
export { ripple } from './ripple.js';
|
|
@@ -47,6 +59,8 @@ export { springAnimate } from './spring-animate.js';
|
|
|
47
59
|
export { fadePresence } from './fade-presence.js';
|
|
48
60
|
export { scalePress } from './scale-press.js';
|
|
49
61
|
export { tiltHover } from './tilt-hover.js';
|
|
62
|
+
export { errorShake } from './error-shake.js';
|
|
63
|
+
export { successCheckmark } from './success-checkmark.js';
|
|
50
64
|
|
|
51
65
|
// visual-dynamics
|
|
52
66
|
export { glowFocus } from './glow-focus.js';
|
|
@@ -66,3 +80,4 @@ export { hapticFeedback } from './haptic-feedback.js';
|
|
|
66
80
|
export { typewriter } from './typewriter.js';
|
|
67
81
|
export { countUp } from './count-up.js';
|
|
68
82
|
export { attentionPulse } from './attention-pulse.js';
|
|
83
|
+
export { announcer } from './announcer.js';
|
package/traits/inertia-drag.js
CHANGED
|
@@ -27,7 +27,16 @@ export const inertiaDrag = defineTrait({
|
|
|
27
27
|
let lastClientY = 0;
|
|
28
28
|
|
|
29
29
|
function parseTranslate() {
|
|
30
|
+
// The trait writes to `style.translate` (independent of `transform` in
|
|
31
|
+
// the modern CSS model). Read translate first so subsequent drags pick
|
|
32
|
+
// up the current position, then fall back to the transform matrix for
|
|
33
|
+
// callers that nudged via `transform`.
|
|
30
34
|
const style = getComputedStyle(host);
|
|
35
|
+
const t = style.translate;
|
|
36
|
+
if (t && t !== 'none') {
|
|
37
|
+
const parts = t.split(/\s+/).map(parseFloat);
|
|
38
|
+
return { x: parts[0] || 0, y: parts[1] || 0 };
|
|
39
|
+
}
|
|
31
40
|
const matrix = new DOMMatrixReadOnly(style.transform);
|
|
32
41
|
return { x: matrix.m41, y: matrix.m42 };
|
|
33
42
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { inertiaDrag } from './inertia-drag.js';
|
|
3
|
-
import { mountHost, connectTrait, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
function pointer(host, type, x, y) {
|
|
6
6
|
host.dispatchEvent(new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, bubbles: true }));
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { defineTrait } from './define.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in named patterns. User-supplied patterns override these.
|
|
5
|
+
*
|
|
6
|
+
* `#` matches a digit, `A` matches a letter, `*` matches any character.
|
|
7
|
+
* Anything else is a literal mask character that gets injected into the
|
|
8
|
+
* formatted output as the user types past it.
|
|
9
|
+
*/
|
|
10
|
+
const NAMED_PATTERNS = {
|
|
11
|
+
'phone-us': '(###) ###-####',
|
|
12
|
+
'phone-intl': '+## ### ### ####',
|
|
13
|
+
'credit-card': '#### #### #### ####',
|
|
14
|
+
'date-iso': '####-##-##',
|
|
15
|
+
'time-24h': '##:##',
|
|
16
|
+
'cvv': '####',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Test whether `c` satisfies the placeholder `slot` (`#`, `A`, `*`).
|
|
21
|
+
* Anything else in the pattern is treated as a literal — never a slot.
|
|
22
|
+
*/
|
|
23
|
+
function matchesSlot(slot, c) {
|
|
24
|
+
if (slot === '#') return /\d/.test(c);
|
|
25
|
+
if (slot === 'A') return /[A-Za-z]/.test(c);
|
|
26
|
+
if (slot === '*') return c.length === 1;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlaceholder(ch) {
|
|
31
|
+
return ch === '#' || ch === 'A' || ch === '*';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the user's `data-mask-pattern` attribute into a literal pattern
|
|
36
|
+
* string. Named shortcuts (`credit-card`, `phone-us`, …) expand to their
|
|
37
|
+
* literal form; everything else is taken verbatim.
|
|
38
|
+
*/
|
|
39
|
+
function resolvePattern(value) {
|
|
40
|
+
if (!value) return '';
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(NAMED_PATTERNS, value)) {
|
|
42
|
+
return NAMED_PATTERNS[value];
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Walk `raw` (the user-entered chars, mask literals already stripped) and
|
|
49
|
+
* apply `pattern` slot-by-slot. Stops emitting once we run out of raw
|
|
50
|
+
* input — the trailing literals don't get echoed until the next real
|
|
51
|
+
* char arrives, otherwise the cursor would land *after* a separator on
|
|
52
|
+
* every keystroke ("(415" → "(415) ", caret at end is awkward).
|
|
53
|
+
*
|
|
54
|
+
* Returns `{ formatted, complete, consumed }` where `complete` is true
|
|
55
|
+
* when every placeholder slot was filled and `consumed` is the count of
|
|
56
|
+
* raw chars actually placed (`raw` may contain more chars than the
|
|
57
|
+
* pattern can hold — for cvv:`####`, raw="12345" → consumed=4).
|
|
58
|
+
*/
|
|
59
|
+
function applyPattern(raw, pattern) {
|
|
60
|
+
let out = '';
|
|
61
|
+
let i = 0;
|
|
62
|
+
let p = 0;
|
|
63
|
+
let placeholderSlots = 0;
|
|
64
|
+
let placeholderFilled = 0;
|
|
65
|
+
|
|
66
|
+
for (let k = 0; k < pattern.length; k++) {
|
|
67
|
+
if (isPlaceholder(pattern[k])) placeholderSlots++;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
while (p < pattern.length && i < raw.length) {
|
|
71
|
+
const slot = pattern[p];
|
|
72
|
+
if (isPlaceholder(slot)) {
|
|
73
|
+
const c = raw[i];
|
|
74
|
+
if (matchesSlot(slot, c)) {
|
|
75
|
+
out += c;
|
|
76
|
+
placeholderFilled++;
|
|
77
|
+
i++;
|
|
78
|
+
p++;
|
|
79
|
+
} else {
|
|
80
|
+
// Skip a non-matching raw char (digit-only mask + user typed a
|
|
81
|
+
// letter). Do not advance `p` — keep looking for a valid char.
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
out += slot;
|
|
86
|
+
p++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
formatted: out,
|
|
92
|
+
complete: placeholderSlots > 0 && placeholderFilled === placeholderSlots,
|
|
93
|
+
consumed: i,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Strip every literal mask char from `formatted` to recover the raw
|
|
99
|
+
* sequence the user typed (just placeholder chars). The pattern is the
|
|
100
|
+
* authority on which positions are literals — we walk both strings in
|
|
101
|
+
* lockstep and copy only chars that landed in placeholder slots.
|
|
102
|
+
*
|
|
103
|
+
* Once the pattern is exhausted, any remaining chars in `formatted` are
|
|
104
|
+
* treated as raw input the user typed past the mask boundary — they get
|
|
105
|
+
* captured so a subsequent re-format can decide whether to drop them
|
|
106
|
+
* (over-budget for a fixed-width mask like cvv) or keep them.
|
|
107
|
+
*/
|
|
108
|
+
function stripPattern(formatted, pattern) {
|
|
109
|
+
let raw = '';
|
|
110
|
+
let p = 0;
|
|
111
|
+
let i = 0;
|
|
112
|
+
for (; i < formatted.length && p < pattern.length; i++) {
|
|
113
|
+
const slot = pattern[p];
|
|
114
|
+
const c = formatted[i];
|
|
115
|
+
if (isPlaceholder(slot)) {
|
|
116
|
+
raw += c;
|
|
117
|
+
p++;
|
|
118
|
+
} else if (slot === c) {
|
|
119
|
+
// mask literal lined up — skip in raw, advance pattern
|
|
120
|
+
p++;
|
|
121
|
+
} else {
|
|
122
|
+
// Diverged from pattern (user typed a literal that wasn't there).
|
|
123
|
+
// Best-effort: keep raw char anyway so subsequent reformatting
|
|
124
|
+
// doesn't drop user input on the floor.
|
|
125
|
+
raw += c;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Trailing chars past the pattern's reach — user's raw input that
|
|
129
|
+
// hasn't been mapped onto a slot yet. Keep them; applyPattern will
|
|
130
|
+
// decide whether the mask has room for them.
|
|
131
|
+
if (i < formatted.length) {
|
|
132
|
+
raw += formatted.slice(i);
|
|
133
|
+
}
|
|
134
|
+
return raw;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Count "real" (user-entered, non-mask) characters before the given
|
|
139
|
+
* cursor position in `formatted`. Used to translate the cursor from
|
|
140
|
+
* the pre-write value into the post-write value, keeping it anchored
|
|
141
|
+
* to the same character the user just touched.
|
|
142
|
+
*
|
|
143
|
+
* Handles three cases at each position:
|
|
144
|
+
* - Pattern slot is a placeholder → count this char as real.
|
|
145
|
+
* - Pattern literal aligns with the char → it's a mask separator, skip.
|
|
146
|
+
* - Pattern literal does NOT align → user is typing the unformatted
|
|
147
|
+
* raw value (first input), so count it as real.
|
|
148
|
+
*/
|
|
149
|
+
function realCharsBefore(formatted, pattern, caret) {
|
|
150
|
+
let count = 0;
|
|
151
|
+
let p = 0;
|
|
152
|
+
const limit = Math.min(caret, formatted.length);
|
|
153
|
+
for (let i = 0; i < limit; i++) {
|
|
154
|
+
const c = formatted[i];
|
|
155
|
+
if (p < pattern.length) {
|
|
156
|
+
const slot = pattern[p];
|
|
157
|
+
if (isPlaceholder(slot)) {
|
|
158
|
+
count++;
|
|
159
|
+
p++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (slot === c) {
|
|
163
|
+
// Mask literal — skip without counting.
|
|
164
|
+
p++;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Either pattern is exhausted or the literal doesn't align: this
|
|
169
|
+
// char is raw input the user typed.
|
|
170
|
+
count++;
|
|
171
|
+
}
|
|
172
|
+
return count;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Find the position in `formatted` after `n` placeholder slots have been
|
|
177
|
+
* filled. The mirror of `realCharsBefore` — used to set the caret on the
|
|
178
|
+
* post-write string.
|
|
179
|
+
*/
|
|
180
|
+
function positionAfterRealChars(formatted, pattern, n) {
|
|
181
|
+
if (n <= 0) return 0;
|
|
182
|
+
let count = 0;
|
|
183
|
+
let p = 0;
|
|
184
|
+
for (let i = 0; i < formatted.length && p < pattern.length; i++) {
|
|
185
|
+
const slot = pattern[p];
|
|
186
|
+
if (isPlaceholder(slot)) {
|
|
187
|
+
count++;
|
|
188
|
+
p++;
|
|
189
|
+
if (count === n) return i + 1;
|
|
190
|
+
} else {
|
|
191
|
+
p++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return formatted.length;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const inputMask = defineTrait({
|
|
198
|
+
name: 'input-mask',
|
|
199
|
+
category: 'forms-data',
|
|
200
|
+
description: 'Locale-aware as-you-type formatter (phone, credit-card, date, currency)',
|
|
201
|
+
attributes: ['data-input-mask-active', 'data-input-mask-complete'],
|
|
202
|
+
events: ['mask-commit'],
|
|
203
|
+
config: ['data-mask-pattern', 'data-mask-strip-on-commit'],
|
|
204
|
+
setup({ host }) {
|
|
205
|
+
function readPattern() {
|
|
206
|
+
return resolvePattern(host.getAttribute('data-mask-pattern') || '');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readValue() {
|
|
210
|
+
return host.value ?? host.getAttribute('value') ?? '';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function writeValue(value) {
|
|
214
|
+
// Assigning `host.value` works for native <input> and any UIElement
|
|
215
|
+
// that reflects a `value` property; fall back to setAttribute
|
|
216
|
+
// otherwise. We don't dispatch a synthetic input event — the
|
|
217
|
+
// trait must not feed itself.
|
|
218
|
+
if ('value' in host) {
|
|
219
|
+
host.value = value;
|
|
220
|
+
} else {
|
|
221
|
+
host.setAttribute('value', value);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function findInternalInput() {
|
|
226
|
+
// For <input-ui> and similar AdiaUI form primitives, the real
|
|
227
|
+
// contenteditable surface is a nested <input>. Use it for caret
|
|
228
|
+
// selection when present.
|
|
229
|
+
if (host.tagName === 'INPUT' || host.tagName === 'TEXTAREA') return host;
|
|
230
|
+
const inner = host.querySelector?.('input, textarea');
|
|
231
|
+
return inner || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getCaret() {
|
|
235
|
+
const target = findInternalInput();
|
|
236
|
+
if (!target) return null;
|
|
237
|
+
try {
|
|
238
|
+
if (target.selectionStart != null) return target.selectionStart;
|
|
239
|
+
} catch {
|
|
240
|
+
// Some input types (number, email) throw on selectionStart access.
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function setCaret(pos) {
|
|
247
|
+
const target = findInternalInput();
|
|
248
|
+
if (!target) return;
|
|
249
|
+
try {
|
|
250
|
+
target.setSelectionRange(pos, pos);
|
|
251
|
+
} catch {
|
|
252
|
+
// Same input-type throw class as getCaret — silently bail.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function reformat() {
|
|
257
|
+
const pattern = readPattern();
|
|
258
|
+
if (!pattern) {
|
|
259
|
+
host.removeAttribute('data-input-mask-active');
|
|
260
|
+
host.removeAttribute('data-input-mask-complete');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const before = readValue();
|
|
265
|
+
const beforeCaret = getCaret();
|
|
266
|
+
|
|
267
|
+
// Recover the raw input by stripping the current pattern, then
|
|
268
|
+
// reapply it. This handles mid-string edits: the user inserted a
|
|
269
|
+
// digit at position 3 of "(415) 555-1234" → strip → reapply.
|
|
270
|
+
const raw = stripPattern(before, pattern);
|
|
271
|
+
const realBeforeCaret = beforeCaret == null
|
|
272
|
+
? raw.length
|
|
273
|
+
: realCharsBefore(before, pattern, beforeCaret);
|
|
274
|
+
|
|
275
|
+
const { formatted, complete } = applyPattern(raw, pattern);
|
|
276
|
+
|
|
277
|
+
host.setAttribute('data-input-mask-active', '');
|
|
278
|
+
if (complete) host.setAttribute('data-input-mask-complete', '');
|
|
279
|
+
else host.removeAttribute('data-input-mask-complete');
|
|
280
|
+
|
|
281
|
+
// Only write back if the formatted value actually differs — this
|
|
282
|
+
// lets the trait coexist with other input listeners without
|
|
283
|
+
// double-firing.
|
|
284
|
+
if (formatted !== before) {
|
|
285
|
+
writeValue(formatted);
|
|
286
|
+
if (beforeCaret != null) {
|
|
287
|
+
const newPos = positionAfterRealChars(formatted, pattern, realBeforeCaret);
|
|
288
|
+
// Defer caret-set: writing to host.value can move the caret to
|
|
289
|
+
// the end on some browsers; we need to clobber that move.
|
|
290
|
+
queueMicrotask(() => setCaret(newPos));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function commit() {
|
|
296
|
+
const pattern = readPattern();
|
|
297
|
+
if (!pattern) return;
|
|
298
|
+
const formatted = readValue();
|
|
299
|
+
const raw = stripPattern(formatted, pattern);
|
|
300
|
+
const stripOnCommit = host.hasAttribute('data-mask-strip-on-commit');
|
|
301
|
+
|
|
302
|
+
if (stripOnCommit && raw !== formatted) {
|
|
303
|
+
writeValue(raw);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
host.dispatchEvent(new CustomEvent('mask-commit', {
|
|
307
|
+
bubbles: true,
|
|
308
|
+
detail: { raw, formatted },
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function onInput() { reformat(); }
|
|
313
|
+
function onBlur() { commit(); }
|
|
314
|
+
|
|
315
|
+
host.addEventListener('input', onInput);
|
|
316
|
+
host.addEventListener('blur', onBlur);
|
|
317
|
+
|
|
318
|
+
// Format any initial value on connect — covers SSR + pre-filled forms.
|
|
319
|
+
reformat();
|
|
320
|
+
|
|
321
|
+
return () => {
|
|
322
|
+
host.removeEventListener('input', onInput);
|
|
323
|
+
host.removeEventListener('blur', onBlur);
|
|
324
|
+
host.removeAttribute('data-input-mask-active');
|
|
325
|
+
host.removeAttribute('data-input-mask-complete');
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { inputMask } from './input-mask.js';
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Drive an `input` event with a new value and a caret-position guess.
|
|
7
|
+
* Mirrors what a real browser dispatches when the user types.
|
|
8
|
+
*/
|
|
9
|
+
function type(host, value, caret) {
|
|
10
|
+
host.value = value;
|
|
11
|
+
if (caret != null) {
|
|
12
|
+
try { host.setSelectionRange(caret, caret); } catch { /* number-typed inputs throw */ }
|
|
13
|
+
}
|
|
14
|
+
host.dispatchEvent(new Event('input'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Microtask-flush helper — caret writes are queued via queueMicrotask
|
|
19
|
+
* so the DOM write doesn't fight the browser's own caret reposition.
|
|
20
|
+
*/
|
|
21
|
+
const flush = () => new Promise(r => queueMicrotask(r));
|
|
22
|
+
|
|
23
|
+
describe('input-mask', () => {
|
|
24
|
+
beforeEach(resetDOM);
|
|
25
|
+
|
|
26
|
+
it('schema declares forms-data category + the documented surface', () => {
|
|
27
|
+
expect(inputMask.schema.category).toBe('forms-data');
|
|
28
|
+
expect(inputMask.schema.attributes).toContain('data-input-mask-active');
|
|
29
|
+
expect(inputMask.schema.attributes).toContain('data-input-mask-complete');
|
|
30
|
+
expect(inputMask.schema.events).toContain('mask-commit');
|
|
31
|
+
expect(inputMask.schema.config).toContain('data-mask-pattern');
|
|
32
|
+
expect(inputMask.schema.config).toContain('data-mask-strip-on-commit');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('phone-us named pattern: 4155551234 → (415) 555-1234', () => {
|
|
36
|
+
const host = mountHost('input', { 'data-mask-pattern': 'phone-us' });
|
|
37
|
+
connectTrait(inputMask, host);
|
|
38
|
+
type(host, '4155551234');
|
|
39
|
+
expect(host.value).toBe('(415) 555-1234');
|
|
40
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('credit-card pattern: 16 digits → 4-4-4-4 grouping', () => {
|
|
44
|
+
const host = mountHost('input', { 'data-mask-pattern': 'credit-card' });
|
|
45
|
+
connectTrait(inputMask, host);
|
|
46
|
+
type(host, '4111111111111111');
|
|
47
|
+
expect(host.value).toBe('4111 1111 1111 1111');
|
|
48
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('partial input does not set complete; full input does', () => {
|
|
52
|
+
const host = mountHost('input', { 'data-mask-pattern': 'date-iso' });
|
|
53
|
+
connectTrait(inputMask, host);
|
|
54
|
+
type(host, '2026');
|
|
55
|
+
expect(host.value).toBe('2026');
|
|
56
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(false);
|
|
57
|
+
type(host, '20260504');
|
|
58
|
+
expect(host.value).toBe('2026-05-04');
|
|
59
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('user-supplied pattern overrides any naming collision', () => {
|
|
63
|
+
// Pure literal pattern — should be taken verbatim, not matched as a name.
|
|
64
|
+
const host = mountHost('input', { 'data-mask-pattern': 'AAA-####' });
|
|
65
|
+
connectTrait(inputMask, host);
|
|
66
|
+
type(host, 'ABC1234');
|
|
67
|
+
expect(host.value).toBe('ABC-1234');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects placeholder-mismatching chars (digit-only mask drops letters)', () => {
|
|
71
|
+
const host = mountHost('input', { 'data-mask-pattern': 'cvv' });
|
|
72
|
+
connectTrait(inputMask, host);
|
|
73
|
+
type(host, '12a3b4');
|
|
74
|
+
// letters skipped, only the 4 digits land in the 4 # slots
|
|
75
|
+
expect(host.value).toBe('1234');
|
|
76
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('caret preservation: typing in the middle keeps the cursor anchored', async () => {
|
|
80
|
+
const host = mountHost('input', { 'data-mask-pattern': 'phone-us' });
|
|
81
|
+
connectTrait(inputMask, host);
|
|
82
|
+
// Seed with 4 digits → "(415) " has 4 fillable slots reached with
|
|
83
|
+
// 3 area-code digits + 1 exchange digit: "(415) 5".
|
|
84
|
+
type(host, '4155');
|
|
85
|
+
expect(host.value).toBe('(415) 5');
|
|
86
|
+
// Append a 5th digit. The trait should reformat to "(415) 55" and
|
|
87
|
+
// place the caret at the position after 5 real chars (index 8).
|
|
88
|
+
type(host, '41555', 8);
|
|
89
|
+
await flush();
|
|
90
|
+
expect(host.value).toBe('(415) 55');
|
|
91
|
+
expect(host.selectionStart).toBe(8);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('mask-commit on blur fires with raw + formatted detail', () => {
|
|
95
|
+
const host = mountHost('input', { 'data-mask-pattern': 'phone-us' });
|
|
96
|
+
connectTrait(inputMask, host);
|
|
97
|
+
const spy = spyEvent(host, 'mask-commit');
|
|
98
|
+
type(host, '4155551234');
|
|
99
|
+
host.dispatchEvent(new Event('blur'));
|
|
100
|
+
expect(spy.count).toBe(1);
|
|
101
|
+
expect(spy.last.formatted).toBe('(415) 555-1234');
|
|
102
|
+
expect(spy.last.raw).toBe('4155551234');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('strip-on-commit replaces the value with the raw form on blur', () => {
|
|
106
|
+
const host = mountHost('input', {
|
|
107
|
+
'data-mask-pattern': 'phone-us',
|
|
108
|
+
'data-mask-strip-on-commit': '',
|
|
109
|
+
});
|
|
110
|
+
connectTrait(inputMask, host);
|
|
111
|
+
type(host, '4155551234');
|
|
112
|
+
expect(host.value).toBe('(415) 555-1234');
|
|
113
|
+
host.dispatchEvent(new Event('blur'));
|
|
114
|
+
expect(host.value).toBe('4155551234');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('disconnect clears managed attributes', () => {
|
|
118
|
+
const host = mountHost('input', { 'data-mask-pattern': 'phone-us' });
|
|
119
|
+
const inst = connectTrait(inputMask, host);
|
|
120
|
+
type(host, '4155551234');
|
|
121
|
+
expect(host.hasAttribute('data-input-mask-active')).toBe(true);
|
|
122
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(true);
|
|
123
|
+
inst.disconnect(host);
|
|
124
|
+
expect(host.hasAttribute('data-input-mask-active')).toBe(false);
|
|
125
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('missing pattern is a no-op (value untouched, no active flag)', () => {
|
|
129
|
+
const host = mountHost('input');
|
|
130
|
+
connectTrait(inputMask, host);
|
|
131
|
+
type(host, 'whatever-the-user-types');
|
|
132
|
+
expect(host.value).toBe('whatever-the-user-types');
|
|
133
|
+
expect(host.hasAttribute('data-input-mask-active')).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('AAA letter slot only accepts letters (digits skipped)', () => {
|
|
137
|
+
const host = mountHost('input', { 'data-mask-pattern': 'AAA' });
|
|
138
|
+
connectTrait(inputMask, host);
|
|
139
|
+
type(host, 'A1B2C3');
|
|
140
|
+
expect(host.value).toBe('ABC');
|
|
141
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('star slot accepts any char', () => {
|
|
145
|
+
const host = mountHost('input', { 'data-mask-pattern': '***-***' });
|
|
146
|
+
connectTrait(inputMask, host);
|
|
147
|
+
type(host, 'aB1xY9');
|
|
148
|
+
expect(host.value).toBe('aB1-xY9');
|
|
149
|
+
expect(host.hasAttribute('data-input-mask-complete')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import { intersectionObserver } from './intersection-observer.js';
|
|
3
|
-
import { mountHost, connectTrait, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('intersection-observer', () => {
|
|
6
6
|
let originalIO;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { keyboardNav } from './keyboard-nav.js';
|
|
3
|
-
import { mountHost, connectTrait, spyEvent, resetDOM } from './
|
|
3
|
+
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
describe('keyboard-nav', () => {
|
|
6
6
|
beforeEach(resetDOM);
|