@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.
Files changed (121) hide show
  1. package/components/agent-trace/agent-trace.css +24 -3
  2. package/components/button/button.js +3 -0
  3. package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
  4. package/components/demo-toggle/demo-toggle.css +120 -0
  5. package/components/demo-toggle/demo-toggle.js +144 -0
  6. package/components/demo-toggle/demo-toggle.test.js +102 -0
  7. package/components/demo-toggle/demo-toggle.yaml +144 -0
  8. package/components/index.js +1 -0
  9. package/components/input/input.js +11 -0
  10. package/components/list/list.css +66 -3
  11. package/components/nav-group/nav-group.a2ui.json +1 -1
  12. package/components/nav-group/nav-group.css +5 -5
  13. package/components/nav-group/nav-group.yaml +1 -1
  14. package/components/nav-item/nav-item.a2ui.json +1 -1
  15. package/components/nav-item/nav-item.css +3 -4
  16. package/components/nav-item/nav-item.yaml +1 -1
  17. package/components/textarea/textarea.js +10 -0
  18. package/core/icons.js +13 -1
  19. package/package.json +1 -1
  20. package/styles/components.css +1 -0
  21. package/styles/typography.css +1 -1
  22. package/traits/_catalog.json +258 -5
  23. package/traits/active-state.test.js +1 -1
  24. package/traits/anchor-positioning.js +205 -52
  25. package/traits/anchor-positioning.test.js +77 -4
  26. package/traits/announcer-stage.js +157 -0
  27. package/traits/announcer.js +145 -0
  28. package/traits/announcer.test.js +268 -0
  29. package/traits/arrow-grid-nav.js +234 -0
  30. package/traits/arrow-grid-nav.test.js +375 -0
  31. package/traits/attention-pulse.js +1 -1
  32. package/traits/attention-pulse.test.js +1 -1
  33. package/traits/confetti-burst.js +90 -60
  34. package/traits/confetti-burst.test.js +16 -8
  35. package/traits/confetti-stage.js +143 -0
  36. package/traits/confetti.js +44 -47
  37. package/traits/confetti.test.js +24 -5
  38. package/traits/count-up.js +31 -6
  39. package/traits/count-up.test.js +1 -1
  40. package/traits/declarative.test.js +1 -1
  41. package/traits/dirty-state.test.js +1 -1
  42. package/traits/drag-ghost.js +55 -3
  43. package/traits/drag-ghost.test.js +1 -1
  44. package/traits/draggable-list-item.js +279 -0
  45. package/traits/draggable-list-item.test.js +51 -0
  46. package/traits/draggable.js +14 -4
  47. package/traits/draggable.test.js +1 -1
  48. package/traits/drop-target.js +223 -0
  49. package/traits/drop-target.test.js +241 -0
  50. package/traits/droppable-collection.js +89 -0
  51. package/traits/droppable-collection.test.js +99 -0
  52. package/traits/droppable.js +125 -0
  53. package/traits/droppable.test.js +54 -0
  54. package/traits/error-shake.js +157 -0
  55. package/traits/error-shake.test.js +114 -0
  56. package/traits/fade-presence.test.js +1 -1
  57. package/traits/focus-restore.js +135 -0
  58. package/traits/focus-restore.test.js +202 -0
  59. package/traits/focus-trap.test.js +1 -1
  60. package/traits/focusable.test.js +1 -1
  61. package/traits/glow-focus.js +1 -1
  62. package/traits/glow-focus.test.js +1 -1
  63. package/traits/gradient-shift.js +1 -1
  64. package/traits/gradient-shift.test.js +1 -1
  65. package/traits/haptic-feedback.test.js +1 -1
  66. package/traits/hotkey.test.js +1 -1
  67. package/traits/hoverable.test.js +1 -1
  68. package/traits/index.js +15 -0
  69. package/traits/inertia-drag.js +9 -0
  70. package/traits/inertia-drag.test.js +1 -1
  71. package/traits/input-mask.js +328 -0
  72. package/traits/input-mask.test.js +151 -0
  73. package/traits/intersection-observer.test.js +1 -1
  74. package/traits/keyboard-nav.test.js +1 -1
  75. package/traits/keyboard-reorderable.js +254 -0
  76. package/traits/keyboard-reorderable.test.js +45 -0
  77. package/traits/layout-animation.js +229 -0
  78. package/traits/layout-animation.test.js +114 -0
  79. package/traits/long-press.js +212 -0
  80. package/traits/long-press.test.js +244 -0
  81. package/traits/magnetic-hover.js +1 -1
  82. package/traits/magnetic-hover.test.js +1 -1
  83. package/traits/noise-texture.js +7 -3
  84. package/traits/noise-texture.test.js +1 -1
  85. package/traits/parallax.js +1 -1
  86. package/traits/parallax.test.js +1 -1
  87. package/traits/portal.test.js +1 -1
  88. package/traits/pressable.test.js +1 -1
  89. package/traits/resettable.js +29 -3
  90. package/traits/resettable.test.js +34 -1
  91. package/traits/resizable.test.js +1 -1
  92. package/traits/resize-observer.test.js +1 -1
  93. package/traits/ripple.js +1 -1
  94. package/traits/ripple.test.js +1 -1
  95. package/traits/roving-tabindex.test.js +1 -1
  96. package/traits/scale-press.test.js +1 -1
  97. package/traits/scroll-lock.test.js +1 -1
  98. package/traits/scroll-progress.js +201 -0
  99. package/traits/scroll-progress.test.js +182 -0
  100. package/traits/shimmer-loading.js +1 -1
  101. package/traits/shimmer-loading.test.js +1 -1
  102. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  103. package/traits/snap-to-grid.test.js +1 -1
  104. package/traits/sound-feedback.test.js +1 -1
  105. package/traits/spring-animate.js +8 -3
  106. package/traits/spring-animate.test.js +1 -1
  107. package/traits/success-checkmark.js +222 -0
  108. package/traits/success-checkmark.test.js +120 -0
  109. package/traits/tilt-hover.js +1 -1
  110. package/traits/tilt-hover.test.js +1 -1
  111. package/traits/tossable.js +9 -0
  112. package/traits/tossable.test.js +1 -1
  113. package/traits/traits-host.test.js +1 -1
  114. package/traits/typeahead.test.js +1 -1
  115. package/traits/typewriter.js +1 -1
  116. package/traits/typewriter.test.js +1 -1
  117. package/traits/validation.test.js +1 -1
  118. package/traits/view-transition.js +140 -0
  119. package/traits/view-transition.test.js +268 -0
  120. /package/traits/{_motion.js → motion.js} +0 -0
  121. /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
@@ -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 './_test-helpers.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('hotkey', () => {
6
6
  beforeEach(resetDOM);
@@ -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 './_test-helpers.js';
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';
@@ -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 './_test-helpers.js';
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 './_test-helpers.js';
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 './_test-helpers.js';
3
+ import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
4
4
 
5
5
  describe('keyboard-nav', () => {
6
6
  beforeEach(resetDOM);