@adia-ai/web-components 0.8.19 → 0.8.21

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 (128) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/components/card/card.a2ui.json +6 -3
  3. package/components/card/card.css +15 -1
  4. package/components/card/card.yaml +5 -3
  5. package/components/nav-group/nav-group.a2ui.json +5 -0
  6. package/components/nav-group/nav-group.class.js +14 -6
  7. package/components/nav-group/nav-group.css +5 -2
  8. package/components/nav-group/nav-group.d.ts +2 -0
  9. package/components/nav-group/nav-group.yaml +4 -0
  10. package/components/select/select.a2ui.json +3 -2
  11. package/components/select/select.class.js +14 -1
  12. package/components/select/select.css +13 -1
  13. package/components/select/select.yaml +5 -4
  14. package/components/table/table.class.js +7 -3
  15. package/components/table/table.css +7 -5
  16. package/dist/theme-provider.min.js +1 -1
  17. package/dist/web-components.min.css +1 -1
  18. package/dist/web-components.min.js +19 -19
  19. package/dist/web-components.sheet.js +1 -1
  20. package/package.json +2 -1
  21. package/components/adia-mark/adia-mark.test.js +0 -69
  22. package/components/adia-wordmark/adia-wordmark.test.js +0 -69
  23. package/components/agent-artifact/agent-artifact.test.js +0 -61
  24. package/components/alert/alert.test.js +0 -364
  25. package/components/color-picker/color-picker.test.js +0 -96
  26. package/components/combobox/combobox.test.js +0 -256
  27. package/components/command/command.test.js +0 -99
  28. package/components/date-range-picker/date-range-picker.test.js +0 -387
  29. package/components/datetime-picker/datetime-picker.test.js +0 -454
  30. package/components/demo-toggle/demo-toggle.test.js +0 -102
  31. package/components/description-list/description-list.test.js +0 -72
  32. package/components/display-field/display-field.test.js +0 -115
  33. package/components/empty-state/empty-state.test.js +0 -77
  34. package/components/field/field.test.js +0 -260
  35. package/components/fields/fields.test.js +0 -88
  36. package/components/inline-message/inline-message.test.js +0 -203
  37. package/components/input/input.test.js +0 -335
  38. package/components/integration-card/integration-card.test.js +0 -318
  39. package/components/link/link.test.js +0 -143
  40. package/components/list/list.test.js +0 -106
  41. package/components/list-window/list-window.test.js +0 -303
  42. package/components/loading-overlay/loading-overlay.test.js +0 -257
  43. package/components/menu/menu.test.js +0 -176
  44. package/components/nav/nav.test.js +0 -428
  45. package/components/nav-group/nav-group.test.js +0 -67
  46. package/components/option-card/option-card.test.js +0 -32
  47. package/components/pagination/pagination.test.js +0 -195
  48. package/components/select/select.test.js +0 -481
  49. package/components/slider/slider.test.js +0 -202
  50. package/components/spinner/spinner.test.js +0 -272
  51. package/components/stat/stat.test.js +0 -108
  52. package/components/step-progress/step-progress.test.js +0 -129
  53. package/components/swatch/swatch.test.js +0 -563
  54. package/components/table/table.test.js +0 -479
  55. package/components/table-toolbar/table-toolbar.test.js +0 -152
  56. package/components/tag/tag.test.js +0 -137
  57. package/components/tags-input/tags-input.test.js +0 -477
  58. package/components/text/text.test.js +0 -202
  59. package/components/theme-provider/theme-provider.test.js +0 -94
  60. package/components/time-picker/time-picker.test.js +0 -287
  61. package/components/timeline/timeline.test.js +0 -31
  62. package/components/toggle-scheme/toggle-scheme.test.js +0 -110
  63. package/components/tree/tree.test.js +0 -312
  64. package/core/element.test.js +0 -389
  65. package/core/icons.test.js +0 -187
  66. package/core/logical-children.test.js +0 -54
  67. package/core/markdown.test.js +0 -47
  68. package/core/responsive.test.js +0 -121
  69. package/core/template.test.js +0 -611
  70. package/traits/active-state/active-state.test.js +0 -28
  71. package/traits/anchor-positioning/anchor-positioning.test.js +0 -122
  72. package/traits/announcer/announcer.test.js +0 -268
  73. package/traits/arrow-grid-nav/arrow-grid-nav.test.js +0 -375
  74. package/traits/attention-pulse/attention-pulse.test.js +0 -26
  75. package/traits/confetti/confetti.test.js +0 -46
  76. package/traits/confetti-burst/confetti-burst.test.js +0 -46
  77. package/traits/count-up/count-up.test.js +0 -54
  78. package/traits/declarative.test.js +0 -138
  79. package/traits/dirty-state/dirty-state.test.js +0 -45
  80. package/traits/drag-ghost/drag-ghost.test.js +0 -19
  81. package/traits/draggable/draggable.test.js +0 -60
  82. package/traits/draggable-list-item/draggable-list-item.test.js +0 -51
  83. package/traits/drop-target/drop-target.test.js +0 -241
  84. package/traits/droppable/droppable.test.js +0 -54
  85. package/traits/droppable-collection/droppable-collection.test.js +0 -99
  86. package/traits/error-shake/error-shake.test.js +0 -114
  87. package/traits/fade-presence/fade-presence.test.js +0 -20
  88. package/traits/focus-restore/focus-restore.test.js +0 -202
  89. package/traits/focus-trap/focus-trap.test.js +0 -42
  90. package/traits/focusable/focusable.test.js +0 -53
  91. package/traits/glow-focus/glow-focus.test.js +0 -31
  92. package/traits/gradient-shift/gradient-shift.test.js +0 -22
  93. package/traits/haptic-feedback/haptic-feedback.test.js +0 -52
  94. package/traits/hotkey/hotkey.test.js +0 -61
  95. package/traits/hoverable/hoverable.test.js +0 -24
  96. package/traits/inertia-drag/inertia-drag.test.js +0 -33
  97. package/traits/input-mask/input-mask.test.js +0 -151
  98. package/traits/intersection-observer/intersection-observer.test.js +0 -38
  99. package/traits/keyboard-nav/keyboard-nav.test.js +0 -41
  100. package/traits/keyboard-reorderable/keyboard-reorderable.test.js +0 -45
  101. package/traits/layout-animation/layout-animation.test.js +0 -114
  102. package/traits/long-press/long-press.test.js +0 -244
  103. package/traits/magnetic-hover/magnetic-hover.test.js +0 -30
  104. package/traits/noise-texture/noise-texture.test.js +0 -20
  105. package/traits/parallax/parallax.test.js +0 -26
  106. package/traits/portal/portal.test.js +0 -30
  107. package/traits/pressable/pressable.test.js +0 -73
  108. package/traits/resettable/resettable.test.js +0 -100
  109. package/traits/resizable/resizable.test.js +0 -39
  110. package/traits/resize-observer/resize-observer.test.js +0 -38
  111. package/traits/ripple/ripple.test.js +0 -32
  112. package/traits/roving-tabindex/roving-tabindex.test.js +0 -28
  113. package/traits/scale-press/scale-press.test.js +0 -39
  114. package/traits/scroll-lock/scroll-lock.test.js +0 -45
  115. package/traits/scroll-progress/scroll-progress.test.js +0 -182
  116. package/traits/shimmer-loading/shimmer-loading.test.js +0 -43
  117. package/traits/smoke.test.js +0 -111
  118. package/traits/snap-to-grid/snap-to-grid.test.js +0 -40
  119. package/traits/sound-feedback/sound-feedback.test.js +0 -26
  120. package/traits/spring-animate/spring-animate.test.js +0 -28
  121. package/traits/success-checkmark/success-checkmark.test.js +0 -120
  122. package/traits/tilt-hover/tilt-hover.test.js +0 -32
  123. package/traits/tossable/tossable.test.js +0 -31
  124. package/traits/traits-host.test.js +0 -73
  125. package/traits/typeahead/typeahead.test.js +0 -38
  126. package/traits/typewriter/typewriter.test.js +0 -47
  127. package/traits/validation/validation.test.js +0 -93
  128. 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
- });