@adia-ai/web-components 0.8.19 → 0.8.20

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 (119) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/components/nav-group/nav-group.a2ui.json +5 -0
  3. package/components/nav-group/nav-group.class.js +14 -6
  4. package/components/nav-group/nav-group.css +5 -2
  5. package/components/nav-group/nav-group.d.ts +2 -0
  6. package/components/nav-group/nav-group.yaml +4 -0
  7. package/dist/theme-provider.min.js +1 -1
  8. package/dist/web-components.min.css +1 -1
  9. package/dist/web-components.min.js +2 -2
  10. package/dist/web-components.sheet.js +1 -1
  11. package/package.json +2 -1
  12. package/components/adia-mark/adia-mark.test.js +0 -69
  13. package/components/adia-wordmark/adia-wordmark.test.js +0 -69
  14. package/components/agent-artifact/agent-artifact.test.js +0 -61
  15. package/components/alert/alert.test.js +0 -364
  16. package/components/color-picker/color-picker.test.js +0 -96
  17. package/components/combobox/combobox.test.js +0 -256
  18. package/components/command/command.test.js +0 -99
  19. package/components/date-range-picker/date-range-picker.test.js +0 -387
  20. package/components/datetime-picker/datetime-picker.test.js +0 -454
  21. package/components/demo-toggle/demo-toggle.test.js +0 -102
  22. package/components/description-list/description-list.test.js +0 -72
  23. package/components/display-field/display-field.test.js +0 -115
  24. package/components/empty-state/empty-state.test.js +0 -77
  25. package/components/field/field.test.js +0 -260
  26. package/components/fields/fields.test.js +0 -88
  27. package/components/inline-message/inline-message.test.js +0 -203
  28. package/components/input/input.test.js +0 -335
  29. package/components/integration-card/integration-card.test.js +0 -318
  30. package/components/link/link.test.js +0 -143
  31. package/components/list/list.test.js +0 -106
  32. package/components/list-window/list-window.test.js +0 -303
  33. package/components/loading-overlay/loading-overlay.test.js +0 -257
  34. package/components/menu/menu.test.js +0 -176
  35. package/components/nav/nav.test.js +0 -428
  36. package/components/nav-group/nav-group.test.js +0 -67
  37. package/components/option-card/option-card.test.js +0 -32
  38. package/components/pagination/pagination.test.js +0 -195
  39. package/components/select/select.test.js +0 -481
  40. package/components/slider/slider.test.js +0 -202
  41. package/components/spinner/spinner.test.js +0 -272
  42. package/components/stat/stat.test.js +0 -108
  43. package/components/step-progress/step-progress.test.js +0 -129
  44. package/components/swatch/swatch.test.js +0 -563
  45. package/components/table/table.test.js +0 -479
  46. package/components/table-toolbar/table-toolbar.test.js +0 -152
  47. package/components/tag/tag.test.js +0 -137
  48. package/components/tags-input/tags-input.test.js +0 -477
  49. package/components/text/text.test.js +0 -202
  50. package/components/theme-provider/theme-provider.test.js +0 -94
  51. package/components/time-picker/time-picker.test.js +0 -287
  52. package/components/timeline/timeline.test.js +0 -31
  53. package/components/toggle-scheme/toggle-scheme.test.js +0 -110
  54. package/components/tree/tree.test.js +0 -312
  55. package/core/element.test.js +0 -389
  56. package/core/icons.test.js +0 -187
  57. package/core/logical-children.test.js +0 -54
  58. package/core/markdown.test.js +0 -47
  59. package/core/responsive.test.js +0 -121
  60. package/core/template.test.js +0 -611
  61. package/traits/active-state/active-state.test.js +0 -28
  62. package/traits/anchor-positioning/anchor-positioning.test.js +0 -122
  63. package/traits/announcer/announcer.test.js +0 -268
  64. package/traits/arrow-grid-nav/arrow-grid-nav.test.js +0 -375
  65. package/traits/attention-pulse/attention-pulse.test.js +0 -26
  66. package/traits/confetti/confetti.test.js +0 -46
  67. package/traits/confetti-burst/confetti-burst.test.js +0 -46
  68. package/traits/count-up/count-up.test.js +0 -54
  69. package/traits/declarative.test.js +0 -138
  70. package/traits/dirty-state/dirty-state.test.js +0 -45
  71. package/traits/drag-ghost/drag-ghost.test.js +0 -19
  72. package/traits/draggable/draggable.test.js +0 -60
  73. package/traits/draggable-list-item/draggable-list-item.test.js +0 -51
  74. package/traits/drop-target/drop-target.test.js +0 -241
  75. package/traits/droppable/droppable.test.js +0 -54
  76. package/traits/droppable-collection/droppable-collection.test.js +0 -99
  77. package/traits/error-shake/error-shake.test.js +0 -114
  78. package/traits/fade-presence/fade-presence.test.js +0 -20
  79. package/traits/focus-restore/focus-restore.test.js +0 -202
  80. package/traits/focus-trap/focus-trap.test.js +0 -42
  81. package/traits/focusable/focusable.test.js +0 -53
  82. package/traits/glow-focus/glow-focus.test.js +0 -31
  83. package/traits/gradient-shift/gradient-shift.test.js +0 -22
  84. package/traits/haptic-feedback/haptic-feedback.test.js +0 -52
  85. package/traits/hotkey/hotkey.test.js +0 -61
  86. package/traits/hoverable/hoverable.test.js +0 -24
  87. package/traits/inertia-drag/inertia-drag.test.js +0 -33
  88. package/traits/input-mask/input-mask.test.js +0 -151
  89. package/traits/intersection-observer/intersection-observer.test.js +0 -38
  90. package/traits/keyboard-nav/keyboard-nav.test.js +0 -41
  91. package/traits/keyboard-reorderable/keyboard-reorderable.test.js +0 -45
  92. package/traits/layout-animation/layout-animation.test.js +0 -114
  93. package/traits/long-press/long-press.test.js +0 -244
  94. package/traits/magnetic-hover/magnetic-hover.test.js +0 -30
  95. package/traits/noise-texture/noise-texture.test.js +0 -20
  96. package/traits/parallax/parallax.test.js +0 -26
  97. package/traits/portal/portal.test.js +0 -30
  98. package/traits/pressable/pressable.test.js +0 -73
  99. package/traits/resettable/resettable.test.js +0 -100
  100. package/traits/resizable/resizable.test.js +0 -39
  101. package/traits/resize-observer/resize-observer.test.js +0 -38
  102. package/traits/ripple/ripple.test.js +0 -32
  103. package/traits/roving-tabindex/roving-tabindex.test.js +0 -28
  104. package/traits/scale-press/scale-press.test.js +0 -39
  105. package/traits/scroll-lock/scroll-lock.test.js +0 -45
  106. package/traits/scroll-progress/scroll-progress.test.js +0 -182
  107. package/traits/shimmer-loading/shimmer-loading.test.js +0 -43
  108. package/traits/smoke.test.js +0 -111
  109. package/traits/snap-to-grid/snap-to-grid.test.js +0 -40
  110. package/traits/sound-feedback/sound-feedback.test.js +0 -26
  111. package/traits/spring-animate/spring-animate.test.js +0 -28
  112. package/traits/success-checkmark/success-checkmark.test.js +0 -120
  113. package/traits/tilt-hover/tilt-hover.test.js +0 -32
  114. package/traits/tossable/tossable.test.js +0 -31
  115. package/traits/traits-host.test.js +0 -73
  116. package/traits/typeahead/typeahead.test.js +0 -38
  117. package/traits/typewriter/typewriter.test.js +0 -47
  118. package/traits/validation/validation.test.js +0 -93
  119. package/traits/view-transition/view-transition.test.js +0 -268
@@ -1,137 +0,0 @@
1
- /**
2
- * tag-ui regression tests.
3
- *
4
- * Source-grep contract for the `content: attr(text)` gating pattern.
5
- * Pre-fix (ungated `:scope::after { content: attr(text) }`), an empty
6
- * `attr(text)` still generated a zero-width pseudo-element flex item,
7
- * and the host's `inline-flex` + `gap` added a phantom `--tag-gap`
8
- * trailing space after slotted content like `<tag-ui>Beta</tag-ui>`.
9
- * The fix gates the pseudo-element on `[text]` attribute presence, so
10
- * slot-content consumers don't pay the gap cost.
11
- *
12
- * jsdom doesn't evaluate `@scope` rules via `getComputedStyle()`, so
13
- * we validate the CSS source shape directly (same recipe as
14
- * description-list.test.js and text.test.js).
15
- */
16
-
17
- import { describe, it, expect } from 'vitest';
18
- import { readFileSync } from 'node:fs';
19
- import { fileURLToPath } from 'node:url';
20
- import { dirname, resolve } from 'node:path';
21
-
22
- const HERE = dirname(fileURLToPath(import.meta.url));
23
- const TAG_CSS = readFileSync(resolve(HERE, 'tag.css'), 'utf8');
24
-
25
- describe('tag-ui — content: attr(text) gating', () => {
26
- it('gates the ::after pseudo-element on [text] attribute presence', () => {
27
- // The fixed rule: `:scope[text]::after { content: attr(text); }`
28
- expect(TAG_CSS).toMatch(/:scope\[text\]::after\s*\{[^}]*content:\s*attr\(text\)/);
29
- });
30
-
31
- it('does NOT carry an ungated :scope::after with content: attr(text)', () => {
32
- // The pre-fix shape would add phantom flex-gap space after slotted
33
- // content. Match the bare `:scope::after` form (no attribute
34
- // selector chained) followed by the attr(text) declaration.
35
- expect(TAG_CSS).not.toMatch(/:scope::after\s*\{[^}]*content:\s*attr\(text\)/);
36
- });
37
-
38
- it('host :scope still declares the flex + gap layout (gap is real, just no longer applied to an empty pseudo)', () => {
39
- // Sanity check that the fix didn't accidentally remove the host
40
- // flex/gap declarations — those are still load-bearing for the
41
- // [icon] + [dismiss] slot rendering and for proper :scope[text]
42
- // gap rendering when text IS set.
43
- expect(TAG_CSS).toMatch(/:scope\s*\{[^}]*display:\s*inline-flex/);
44
- // Tokens are read directly via `var(--prop)`; consumers override the
45
- // un-suffixed token on the element (or via @layer overrides).
46
- expect(TAG_CSS).toMatch(/:scope\s*\{[^}]*gap:\s*var\(--tag-gap\)/);
47
- });
48
- });
49
-
50
- // ── Tone defaults (solid by default for families; muted as opt-out) ──
51
- //
52
- // Default tone is `solid` for family variants — the base `:scope[variant=…]`
53
- // rules pair the saturated `--a-{family}-bg` fill with the palette's own
54
- // Material on-color `--md-sys-color-{family}-on-{family}` (the chip IS the
55
- // state). `[tone="muted"]` opts back into the Material container pair
56
- // `--md-sys-color-{family}-container` + `-on-surface` (same look as
57
- // <badge-ui>'s default). The `default` (no-family) variant stays quiet
58
- // chrome unless `[tone="solid"]` is set explicitly (then it inverts to a
59
- // high-contrast neutral stamp). Token names updated for the Material
60
- // static-token migration (material-color-bridge-contract.md, 2026-07-15).
61
-
62
- describe('tag-ui — variant defaults to solid fill', () => {
63
- it.each([
64
- ['info'],
65
- ['success'],
66
- ['warning'],
67
- ['danger'],
68
- ])('[variant="%s"] base rule uses --a-{family}-bg + --md-sys-color-{family}-on-{family}', (family) => {
69
- const block = new RegExp(
70
- `:scope\\[variant="${family}"\\]\\s*\\{[^}]*` +
71
- `--tag-bg:\\s*var\\(--a-${family}-bg\\)[^}]*` +
72
- `--tag-fg:\\s*var\\(--md-sys-color-${family}-on-${family}\\)`,
73
- 's'
74
- );
75
- expect(TAG_CSS).toMatch(block);
76
- });
77
- });
78
-
79
- describe('tag-ui — [tone="muted"] opts out to the Material container pair', () => {
80
- it.each([
81
- ['info'],
82
- ['success'],
83
- ['warning'],
84
- ['danger'],
85
- ])('[tone="muted"][variant="%s"] uses -container + -on-surface', (family) => {
86
- const block = new RegExp(
87
- `:scope\\[tone="muted"\\]\\[variant="${family}"\\]\\s*\\{[^}]*` +
88
- `--tag-bg:\\s*var\\(--md-sys-color-${family}-container\\)[^}]*` +
89
- `--tag-fg:\\s*var\\(--md-sys-color-${family}-on-surface\\)`,
90
- 's'
91
- );
92
- expect(TAG_CSS).toMatch(block);
93
- });
94
- });
95
-
96
- describe('tag-ui — neutral default + explicit solid stamp', () => {
97
- it('[variant="default"] stays quiet chrome (--a-bg-muted + neutral on-surface)', () => {
98
- expect(TAG_CSS).toMatch(
99
- /:scope\[variant="default"\]\s*\{[^}]*--tag-bg:\s*var\(--a-bg-muted\)[^}]*--tag-fg:\s*var\(--md-sys-color-neutral-on-surface\)/s
100
- );
101
- });
102
-
103
- it('[tone="solid"] without a family inverts to a high-contrast neutral stamp', () => {
104
- expect(TAG_CSS).toMatch(
105
- /:scope\[tone="solid"\]:not\(\[variant="info"\]\)[\s\S]*?--tag-bg:\s*var\(--md-sys-color-neutral-on-surface\)[\s\S]*?--tag-fg:\s*var\(--md-sys-color-neutral-background\)/
106
- );
107
- });
108
- });
109
-
110
- describe('tag-ui — [tone="outline"] strips fill + colors border', () => {
111
- it('[tone="outline"] base rule sets bg to transparent', () => {
112
- expect(TAG_CSS).toMatch(
113
- /:scope\[tone="outline"\]\s*\{[^}]*--tag-bg:\s*transparent/s
114
- );
115
- });
116
-
117
- it.each([
118
- ['info'],
119
- ['success'],
120
- ['warning'],
121
- ['danger'],
122
- ])('[tone="outline"][variant="%s"] colors fg + border per family', (family) => {
123
- const block = new RegExp(
124
- `:scope\\[tone="outline"\\]\\[variant="${family}"\\]\\s*\\{[^}]*` +
125
- `--tag-fg:\\s*var\\(--md-sys-color-${family}-on-surface\\)[^}]*` +
126
- `--tag-border:\\s*var\\(--md-sys-color-${family}-outline\\)`,
127
- 's'
128
- );
129
- expect(TAG_CSS).toMatch(block);
130
- });
131
-
132
- it('[tone="outline"] on neutral uses --a-fg-muted text + neutral outline ring', () => {
133
- expect(TAG_CSS).toMatch(
134
- /:scope\[tone="outline"\]:not\(\[variant="info"\]\)[\s\S]*?--tag-fg:\s*var\(--a-fg-muted\)[\s\S]*?--tag-border:\s*var\(--md-sys-color-neutral-outline\)/
135
- );
136
- });
137
- });
@@ -1,477 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import '../../core/element.js';
3
- import '../tag/tag.js';
4
- import './tags-input.js';
5
-
6
- const tick = () => new Promise((r) => queueMicrotask(r));
7
-
8
- function mount(html) {
9
- const wrap = document.createElement('div');
10
- wrap.innerHTML = html;
11
- document.body.appendChild(wrap);
12
- return wrap.firstElementChild;
13
- }
14
-
15
- function typeText(el, text) {
16
- // Drive the contenteditable surface like a real keyboard. Append the
17
- // character into textContent + fire `input` so the host runs through its
18
- // own input handler.
19
- const surface = el.querySelector(':scope > [data-inline-input]');
20
- surface.textContent = (surface.textContent || '') + text;
21
- surface.dispatchEvent(new InputEvent('input', { bubbles: true }));
22
- }
23
-
24
- function setText(el, text) {
25
- // Like typeText but REPLACES the buffer. Use to simulate a user clearing
26
- // a failed attempt and starting over.
27
- const surface = el.querySelector(':scope > [data-inline-input]');
28
- surface.textContent = text;
29
- surface.dispatchEvent(new InputEvent('input', { bubbles: true }));
30
- }
31
-
32
- function pressKey(el, key) {
33
- const surface = el.querySelector(':scope > [data-inline-input]');
34
- surface.focus();
35
- surface.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
36
- }
37
-
38
- describe('tags-input-ui — structure + defaults', () => {
39
- beforeEach(() => { document.body.innerHTML = ''; });
40
-
41
- it('registers the custom element', () => {
42
- expect(customElements.get('tags-input-ui')).toBeTruthy();
43
- });
44
-
45
- it('stamps a chip list, inline input, spinner, and suggestion popover', async () => {
46
- const el = mount('<tags-input-ui></tags-input-ui>');
47
- await tick();
48
- expect(el.querySelector(':scope > [data-chip-list]')).toBeTruthy();
49
- expect(el.querySelector(':scope > [data-inline-input]')).toBeTruthy();
50
- expect(el.querySelector(':scope > [data-spinner]')).toBeTruthy();
51
- expect(el.querySelector(':scope > [data-suggestions]')).toBeTruthy();
52
- });
53
-
54
- it('host carries role=group by default (no suggestions wired)', async () => {
55
- const el = mount('<tags-input-ui></tags-input-ui>');
56
- await tick();
57
- expect(el.getAttribute('role')).toBe('group');
58
- });
59
-
60
- it('inline input carries role=searchbox + plaintext contenteditable', async () => {
61
- const el = mount('<tags-input-ui></tags-input-ui>');
62
- await tick();
63
- const input = el.querySelector(':scope > [data-inline-input]');
64
- expect(input.getAttribute('role')).toBe('searchbox');
65
- expect(input.getAttribute('contenteditable')).toBe('plaintext-only');
66
- });
67
- });
68
-
69
- describe('tags-input-ui — commit behavior', () => {
70
- beforeEach(() => { document.body.innerHTML = ''; });
71
-
72
- it('Enter commits the typed value as a chip', async () => {
73
- const el = mount('<tags-input-ui></tags-input-ui>');
74
- await tick();
75
- typeText(el, 'hello');
76
- pressKey(el, 'Enter');
77
- await tick();
78
- expect(el.value).toEqual(['hello']);
79
- expect(el.querySelectorAll(':scope > [data-chip-list] > tag-ui').length).toBe(1);
80
- });
81
-
82
- it('default comma delimiter commits inline', async () => {
83
- const el = mount('<tags-input-ui></tags-input-ui>');
84
- await tick();
85
- typeText(el, 'foo,');
86
- await tick();
87
- expect(el.value).toEqual(['foo']);
88
- });
89
-
90
- it('delimiter="enter" disables in-line comma commit', async () => {
91
- const el = mount('<tags-input-ui delimiter="enter"></tags-input-ui>');
92
- await tick();
93
- typeText(el, 'foo,bar');
94
- await tick();
95
- // Nothing committed yet — the comma is treated as literal text.
96
- expect(el.value).toEqual([]);
97
- pressKey(el, 'Enter');
98
- await tick();
99
- expect(el.value).toEqual(['foo,bar']);
100
- });
101
-
102
- it('Backspace from empty input removes the last chip', async () => {
103
- const el = mount('<tags-input-ui></tags-input-ui>');
104
- await tick();
105
- el.value = ['alpha', 'beta'];
106
- await tick();
107
- expect(el.value).toEqual(['alpha', 'beta']);
108
- pressKey(el, 'Backspace');
109
- await tick();
110
- expect(el.value).toEqual(['alpha']);
111
- });
112
-
113
- it('Backspace with non-empty input only deletes a character (no chip removal)', async () => {
114
- const el = mount('<tags-input-ui></tags-input-ui>');
115
- await tick();
116
- el.value = ['alpha'];
117
- typeText(el, 'wip');
118
- await tick();
119
- pressKey(el, 'Backspace');
120
- await tick();
121
- // Chip unchanged.
122
- expect(el.value).toEqual(['alpha']);
123
- });
124
-
125
- it('unique:true (default) silently dedups duplicate commits', async () => {
126
- const el = mount('<tags-input-ui></tags-input-ui>');
127
- await tick();
128
- let invalidCount = 0;
129
- el.addEventListener('invalid', () => { invalidCount += 1; });
130
- typeText(el, 'foo');
131
- pressKey(el, 'Enter');
132
- await tick();
133
- typeText(el, 'foo');
134
- pressKey(el, 'Enter');
135
- await tick();
136
- expect(el.value).toEqual(['foo']);
137
- expect(invalidCount).toBe(0);
138
- });
139
-
140
- it('min-length rejects too-short tokens with reason=too-short', async () => {
141
- const el = mount('<tags-input-ui min-length="3"></tags-input-ui>');
142
- await tick();
143
- let detail = null;
144
- el.addEventListener('invalid', (e) => { detail = e.detail; });
145
- typeText(el, 'ab');
146
- pressKey(el, 'Enter');
147
- await tick();
148
- expect(el.value).toEqual([]);
149
- expect(detail?.reason).toBe('too-short');
150
- });
151
-
152
- it('max-length rejects too-long tokens with reason=too-long', async () => {
153
- const el = mount('<tags-input-ui max-length="5"></tags-input-ui>');
154
- await tick();
155
- let detail = null;
156
- el.addEventListener('invalid', (e) => { detail = e.detail; });
157
- typeText(el, 'too-long-value');
158
- pressKey(el, 'Enter');
159
- await tick();
160
- expect(el.value).toEqual([]);
161
- expect(detail?.reason).toBe('too-long');
162
- });
163
-
164
- it('max count cap rejects further commits with reason=max', async () => {
165
- const el = mount('<tags-input-ui max="2"></tags-input-ui>');
166
- await tick();
167
- let invalidDetail = null;
168
- el.addEventListener('invalid', (e) => { invalidDetail = e.detail; });
169
- el.addToken('one');
170
- el.addToken('two');
171
- expect(el.value).toEqual(['one', 'two']);
172
- typeText(el, 'three');
173
- pressKey(el, 'Enter');
174
- await tick();
175
- expect(el.value).toEqual(['one', 'two']);
176
- expect(invalidDetail?.reason).toBe('max');
177
- });
178
-
179
- it('transform="lowercase" normalizes tokens on commit', async () => {
180
- const el = mount('<tags-input-ui transform="lowercase"></tags-input-ui>');
181
- await tick();
182
- el.addToken('FOO');
183
- el.addToken('Bar');
184
- expect(el.value).toEqual(['foo', 'bar']);
185
- });
186
-
187
- it('transform="strip-spaces" removes all whitespace before commit', async () => {
188
- const el = mount('<tags-input-ui transform="strip-spaces"></tags-input-ui>');
189
- await tick();
190
- el.addToken('hello world');
191
- expect(el.value).toEqual(['helloworld']);
192
- });
193
- });
194
-
195
- describe('tags-input-ui — validators', () => {
196
- beforeEach(() => { document.body.innerHTML = ''; });
197
-
198
- it('sync validateFn rejects invalid tokens with reason=validator', async () => {
199
- const el = mount('<tags-input-ui></tags-input-ui>');
200
- await tick();
201
- el.validateFn = (v) => /^[a-z]+$/.test(v);
202
- let detail = null;
203
- el.addEventListener('invalid', (e) => { detail = e.detail; });
204
- typeText(el, '123');
205
- pressKey(el, 'Enter');
206
- await tick();
207
- expect(el.value).toEqual([]);
208
- expect(detail?.reason).toBe('validator');
209
- // Accepts valid input.
210
- setText(el, 'ok');
211
- pressKey(el, 'Enter');
212
- await tick();
213
- expect(el.value).toEqual(['ok']);
214
- });
215
-
216
- it('async validateFn flips host into [validating] then commits on resolve(true)', async () => {
217
- const el = mount('<tags-input-ui></tags-input-ui>');
218
- await tick();
219
- let resolveCheck;
220
- el.validateFn = () => new Promise((r) => { resolveCheck = r; });
221
- typeText(el, 'pending');
222
- pressKey(el, 'Enter');
223
- await tick();
224
- expect(el.hasAttribute('validating')).toBe(true);
225
- expect(el.value).toEqual([]);
226
- resolveCheck(true);
227
- await tick();
228
- await tick();
229
- expect(el.hasAttribute('validating')).toBe(false);
230
- expect(el.value).toEqual(['pending']);
231
- });
232
-
233
- it('commit event with preventDefault() rejects the token (reason=validator)', async () => {
234
- const el = mount('<tags-input-ui></tags-input-ui>');
235
- await tick();
236
- el.addEventListener('commit', (e) => e.preventDefault());
237
- let detail = null;
238
- el.addEventListener('invalid', (e) => { detail = e.detail; });
239
- typeText(el, 'blocked');
240
- pressKey(el, 'Enter');
241
- await tick();
242
- expect(el.value).toEqual([]);
243
- expect(detail?.reason).toBe('validator');
244
- });
245
- });
246
-
247
- describe('tags-input-ui — paste-split', () => {
248
- beforeEach(() => { document.body.innerHTML = ''; });
249
-
250
- it('pasting "a, b, c" with default paste-split commits three tokens', async () => {
251
- const el = mount('<tags-input-ui></tags-input-ui>');
252
- await tick();
253
- const surface = el.querySelector(':scope > [data-inline-input]');
254
- const dt = new DataTransfer();
255
- dt.setData('text/plain', 'a, b, c');
256
- surface.dispatchEvent(new ClipboardEvent('paste', {
257
- bubbles: true,
258
- cancelable: true,
259
- clipboardData: dt,
260
- }));
261
- await tick();
262
- expect(el.value).toEqual(['a', 'b', 'c']);
263
- });
264
- });
265
-
266
- describe('tags-input-ui — disabled / readonly / form', () => {
267
- beforeEach(() => { document.body.innerHTML = ''; });
268
-
269
- it('disabled blocks all interaction', async () => {
270
- const el = mount('<tags-input-ui disabled></tags-input-ui>');
271
- await tick();
272
- typeText(el, 'nope');
273
- pressKey(el, 'Enter');
274
- await tick();
275
- expect(el.value).toEqual([]);
276
- });
277
-
278
- it('readonly blocks edits but allows inspection (chips lack [removable])', async () => {
279
- const el = mount('<tags-input-ui readonly value=\'["frozen"]\'></tags-input-ui>');
280
- await tick();
281
- expect(el.value).toEqual(['frozen']);
282
- const chip = el.querySelector(':scope > [data-chip-list] > tag-ui');
283
- expect(chip).toBeTruthy();
284
- expect(chip.hasAttribute('removable')).toBe(false);
285
- });
286
-
287
- it('exposes UITagsInput extending UIFormElement (form participation)', async () => {
288
- const el = mount('<tags-input-ui name="x"></tags-input-ui>');
289
- await tick();
290
- expect(typeof el.checkValidity).toBe('function');
291
- expect(typeof el.syncValue).toBe('function');
292
- });
293
-
294
- it('form value serializes as JSON array under [name]', async () => {
295
- const el = mount('<tags-input-ui name="labels"></tags-input-ui>');
296
- await tick();
297
- el.addToken('bug');
298
- el.addToken('high-priority');
299
- // happy-dom's ElementInternals tracks form value via setFormValue — read
300
- // it back via the public `value` accessor + assert via internals' state.
301
- expect(el.value).toEqual(['bug', 'high-priority']);
302
- expect(el.checkValidity()).toBe(true);
303
- });
304
-
305
- it('required attribute reflects on the host (happy-dom internals stub limits deeper assertions)', async () => {
306
- const el = mount('<tags-input-ui required name="x"></tags-input-ui>');
307
- await tick();
308
- expect(el.required).toBe(true);
309
- expect(el.hasAttribute('required')).toBe(true);
310
- // ElementInternals.setValidity is a no-op in happy-dom, so we can't
311
- // assert validity.valueMissing here — gate that in browser smokes.
312
- el.addToken('one');
313
- expect(el.value).toEqual(['one']);
314
- });
315
-
316
- it('parses declarative value="[…]" as a JSON array', async () => {
317
- const el = mount('<tags-input-ui value=\'["a","b","c"]\'></tags-input-ui>');
318
- await tick();
319
- expect(el.value).toEqual(['a', 'b', 'c']);
320
- });
321
- });
322
-
323
- describe('tags-input-ui — chip slot template (TKT-0023)', () => {
324
- beforeEach(() => { document.body.innerHTML = ''; });
325
-
326
- it('with no template, renders the default <tag-ui removable text>', async () => {
327
- const el = mount('<tags-input-ui value=\'["a"]\'></tags-input-ui>');
328
- await tick();
329
- const chip = el.querySelector(':scope > [data-chip-list] > *');
330
- expect(chip.tagName.toLowerCase()).toBe('tag-ui');
331
- expect(chip.getAttribute('text')).toBe('a');
332
- expect(chip.hasAttribute('removable')).toBe(true);
333
- });
334
-
335
- it('an author <template slot="chip"> overrides the default render, substituting {{value}}/{{index}}', async () => {
336
- const el = mount(`
337
- <tags-input-ui value='["alpha","beta"]'>
338
- <template slot="chip"><tag-ui removable text="{{value}}" title="Item #{{index}}"></tag-ui></template>
339
- </tags-input-ui>
340
- `);
341
- await tick();
342
- const chips = el.querySelectorAll(':scope > [data-chip-list] > *');
343
- expect(chips).toHaveLength(2);
344
- expect(chips[0].getAttribute('text')).toBe('alpha');
345
- expect(chips[0].getAttribute('title')).toBe('Item #0');
346
- expect(chips[1].getAttribute('text')).toBe('beta');
347
- expect(chips[1].getAttribute('title')).toBe('Item #1');
348
- });
349
-
350
- it('the chip template survives the first-connect shell stamp (not wiped by innerHTML replacement)', async () => {
351
- const el = mount(`
352
- <tags-input-ui value='["x"]'>
353
- <template slot="chip"><span class="custom">{{value}}</span></template>
354
- </tags-input-ui>
355
- `);
356
- await tick();
357
- expect(el.querySelector(':scope > template[slot="chip"]')).toBeTruthy();
358
- const chip = el.querySelector(':scope > [data-chip-list] > .custom');
359
- expect(chip?.textContent).toBe('x');
360
- });
361
-
362
- it('substitutes {{value}}/{{index}} in attribute values, not just text content', async () => {
363
- const el = mount(`
364
- <tags-input-ui value='["a","b"]'>
365
- <template slot="chip"><span data-slot-value="{{value}}" data-slot-index="{{index}}">{{value}}</span></template>
366
- </tags-input-ui>
367
- `);
368
- await tick();
369
- const chips = el.querySelectorAll(':scope > [data-chip-list] > span');
370
- expect(chips[0].dataset.slotValue).toBe('a');
371
- expect(chips[0].dataset.slotIndex).toBe('0');
372
- expect(chips[1].dataset.slotValue).toBe('b');
373
- expect(chips[1].dataset.slotIndex).toBe('1');
374
- });
375
-
376
- it('reconciles by position: adding a token appends without recreating existing custom chips', async () => {
377
- const el = mount(`
378
- <tags-input-ui value='["a"]'>
379
- <template slot="chip"><span>{{value}}</span></template>
380
- </tags-input-ui>
381
- `);
382
- await tick();
383
- const first = el.querySelector(':scope > [data-chip-list] > span');
384
- el.addToken('b');
385
- await tick();
386
- const chips = el.querySelectorAll(':scope > [data-chip-list] > span');
387
- expect(chips).toHaveLength(2);
388
- expect(chips[0]).toBe(first); // unchanged position — same node, no flicker
389
- expect(chips[1].textContent).toBe('b');
390
- });
391
-
392
- it('a custom chip participates in removal via a bubbling `remove` event from a nested tag-ui', async () => {
393
- const el = mount(`
394
- <tags-input-ui value='["a","b"]'>
395
- <template slot="chip"><span class="wrap"><tag-ui removable text="{{value}}"></tag-ui></span></template>
396
- </tags-input-ui>
397
- `);
398
- await tick();
399
- const nestedTag = el.querySelector(':scope > [data-chip-list] .wrap tag-ui[text="a"]');
400
- nestedTag.dispatchEvent(new CustomEvent('remove', { bubbles: true }));
401
- await tick();
402
- expect(el.value).toEqual(['b']);
403
- });
404
-
405
- it('a malformed template (no element content) degrades to the default chip rather than rendering nothing', async () => {
406
- const el = mount(`
407
- <tags-input-ui value='["a"]'>
408
- <template slot="chip"> </template>
409
- </tags-input-ui>
410
- `);
411
- await tick();
412
- const chip = el.querySelector(':scope > [data-chip-list] > *');
413
- expect(chip.tagName.toLowerCase()).toBe('tag-ui');
414
- expect(chip.getAttribute('text')).toBe('a');
415
- });
416
-
417
- it('a token literally equal to "{{index}}" is not clobbered by the index substitution pass', async () => {
418
- const el = mount(`
419
- <tags-input-ui value='["{{index}}","real"]'>
420
- <template slot="chip"><span data-slot-index="{{index}}">{{value}}</span></template>
421
- </tags-input-ui>
422
- `);
423
- await tick();
424
- const chips = el.querySelectorAll(':scope > [data-chip-list] > span');
425
- expect(chips[0].textContent).toBe('{{index}}');
426
- expect(chips[0].dataset.slotIndex).toBe('0');
427
- expect(chips[1].textContent).toBe('real');
428
- expect(chips[1].dataset.slotIndex).toBe('1');
429
- });
430
- });
431
-
432
- describe('tags-input-ui — change event', () => {
433
- beforeEach(() => { document.body.innerHTML = ''; });
434
-
435
- it('change event detail carries value + added + removed', async () => {
436
- const el = mount('<tags-input-ui></tags-input-ui>');
437
- await tick();
438
- const events = [];
439
- el.addEventListener('change', (e) => events.push(e.detail));
440
- el.addToken('first');
441
- el.addToken('second');
442
- await tick();
443
- expect(events.length).toBe(2);
444
- expect(events[0]).toMatchObject({ added: ['first'], removed: [] });
445
- expect(events[1]).toMatchObject({ added: ['second'], removed: [] });
446
- expect(events[1].value).toEqual(['first', 'second']);
447
- el.removeToken(0);
448
- await tick();
449
- expect(events[2]).toMatchObject({ added: [], removed: ['first'] });
450
- });
451
- });
452
-
453
- describe('tags-input-ui — suggestions', () => {
454
- beforeEach(() => { document.body.innerHTML = ''; });
455
-
456
- it('host carries role=combobox when .suggestions is non-empty', async () => {
457
- const el = mount('<tags-input-ui></tags-input-ui>');
458
- await tick();
459
- el.suggestions = ['alpha', 'beta', 'gamma'];
460
- await tick();
461
- expect(el.getAttribute('role')).toBe('combobox');
462
- expect(el.getAttribute('aria-haspopup')).toBe('listbox');
463
- });
464
-
465
- it('clicking a suggestion option commits it', async () => {
466
- const el = mount('<tags-input-ui></tags-input-ui>');
467
- await tick();
468
- el.suggestions = ['alpha', 'beta'];
469
- typeText(el, 'a');
470
- await tick();
471
- const opt = el.querySelector(':scope > [data-suggestions] > [role="option"][data-value="alpha"]');
472
- expect(opt).toBeTruthy();
473
- opt.click();
474
- await tick();
475
- expect(el.value).toEqual(['alpha']);
476
- });
477
- });