@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.
- package/CHANGELOG.md +26 -0
- package/components/card/card.a2ui.json +6 -3
- package/components/card/card.css +15 -1
- package/components/card/card.yaml +5 -3
- package/components/nav-group/nav-group.a2ui.json +5 -0
- package/components/nav-group/nav-group.class.js +14 -6
- package/components/nav-group/nav-group.css +5 -2
- package/components/nav-group/nav-group.d.ts +2 -0
- package/components/nav-group/nav-group.yaml +4 -0
- package/components/select/select.a2ui.json +3 -2
- package/components/select/select.class.js +14 -1
- package/components/select/select.css +13 -1
- package/components/select/select.yaml +5 -4
- package/components/table/table.class.js +7 -3
- package/components/table/table.css +7 -5
- package/dist/theme-provider.min.js +1 -1
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +19 -19
- package/dist/web-components.sheet.js +1 -1
- package/package.json +2 -1
- package/components/adia-mark/adia-mark.test.js +0 -69
- package/components/adia-wordmark/adia-wordmark.test.js +0 -69
- package/components/agent-artifact/agent-artifact.test.js +0 -61
- package/components/alert/alert.test.js +0 -364
- package/components/color-picker/color-picker.test.js +0 -96
- package/components/combobox/combobox.test.js +0 -256
- package/components/command/command.test.js +0 -99
- package/components/date-range-picker/date-range-picker.test.js +0 -387
- package/components/datetime-picker/datetime-picker.test.js +0 -454
- package/components/demo-toggle/demo-toggle.test.js +0 -102
- package/components/description-list/description-list.test.js +0 -72
- package/components/display-field/display-field.test.js +0 -115
- package/components/empty-state/empty-state.test.js +0 -77
- package/components/field/field.test.js +0 -260
- package/components/fields/fields.test.js +0 -88
- package/components/inline-message/inline-message.test.js +0 -203
- package/components/input/input.test.js +0 -335
- package/components/integration-card/integration-card.test.js +0 -318
- package/components/link/link.test.js +0 -143
- package/components/list/list.test.js +0 -106
- package/components/list-window/list-window.test.js +0 -303
- package/components/loading-overlay/loading-overlay.test.js +0 -257
- package/components/menu/menu.test.js +0 -176
- package/components/nav/nav.test.js +0 -428
- package/components/nav-group/nav-group.test.js +0 -67
- package/components/option-card/option-card.test.js +0 -32
- package/components/pagination/pagination.test.js +0 -195
- package/components/select/select.test.js +0 -481
- package/components/slider/slider.test.js +0 -202
- package/components/spinner/spinner.test.js +0 -272
- package/components/stat/stat.test.js +0 -108
- package/components/step-progress/step-progress.test.js +0 -129
- package/components/swatch/swatch.test.js +0 -563
- package/components/table/table.test.js +0 -479
- package/components/table-toolbar/table-toolbar.test.js +0 -152
- package/components/tag/tag.test.js +0 -137
- package/components/tags-input/tags-input.test.js +0 -477
- package/components/text/text.test.js +0 -202
- package/components/theme-provider/theme-provider.test.js +0 -94
- package/components/time-picker/time-picker.test.js +0 -287
- package/components/timeline/timeline.test.js +0 -31
- package/components/toggle-scheme/toggle-scheme.test.js +0 -110
- package/components/tree/tree.test.js +0 -312
- package/core/element.test.js +0 -389
- package/core/icons.test.js +0 -187
- package/core/logical-children.test.js +0 -54
- package/core/markdown.test.js +0 -47
- package/core/responsive.test.js +0 -121
- package/core/template.test.js +0 -611
- package/traits/active-state/active-state.test.js +0 -28
- package/traits/anchor-positioning/anchor-positioning.test.js +0 -122
- package/traits/announcer/announcer.test.js +0 -268
- package/traits/arrow-grid-nav/arrow-grid-nav.test.js +0 -375
- package/traits/attention-pulse/attention-pulse.test.js +0 -26
- package/traits/confetti/confetti.test.js +0 -46
- package/traits/confetti-burst/confetti-burst.test.js +0 -46
- package/traits/count-up/count-up.test.js +0 -54
- package/traits/declarative.test.js +0 -138
- package/traits/dirty-state/dirty-state.test.js +0 -45
- package/traits/drag-ghost/drag-ghost.test.js +0 -19
- package/traits/draggable/draggable.test.js +0 -60
- package/traits/draggable-list-item/draggable-list-item.test.js +0 -51
- package/traits/drop-target/drop-target.test.js +0 -241
- package/traits/droppable/droppable.test.js +0 -54
- package/traits/droppable-collection/droppable-collection.test.js +0 -99
- package/traits/error-shake/error-shake.test.js +0 -114
- package/traits/fade-presence/fade-presence.test.js +0 -20
- package/traits/focus-restore/focus-restore.test.js +0 -202
- package/traits/focus-trap/focus-trap.test.js +0 -42
- package/traits/focusable/focusable.test.js +0 -53
- package/traits/glow-focus/glow-focus.test.js +0 -31
- package/traits/gradient-shift/gradient-shift.test.js +0 -22
- package/traits/haptic-feedback/haptic-feedback.test.js +0 -52
- package/traits/hotkey/hotkey.test.js +0 -61
- package/traits/hoverable/hoverable.test.js +0 -24
- package/traits/inertia-drag/inertia-drag.test.js +0 -33
- package/traits/input-mask/input-mask.test.js +0 -151
- package/traits/intersection-observer/intersection-observer.test.js +0 -38
- package/traits/keyboard-nav/keyboard-nav.test.js +0 -41
- package/traits/keyboard-reorderable/keyboard-reorderable.test.js +0 -45
- package/traits/layout-animation/layout-animation.test.js +0 -114
- package/traits/long-press/long-press.test.js +0 -244
- package/traits/magnetic-hover/magnetic-hover.test.js +0 -30
- package/traits/noise-texture/noise-texture.test.js +0 -20
- package/traits/parallax/parallax.test.js +0 -26
- package/traits/portal/portal.test.js +0 -30
- package/traits/pressable/pressable.test.js +0 -73
- package/traits/resettable/resettable.test.js +0 -100
- package/traits/resizable/resizable.test.js +0 -39
- package/traits/resize-observer/resize-observer.test.js +0 -38
- package/traits/ripple/ripple.test.js +0 -32
- package/traits/roving-tabindex/roving-tabindex.test.js +0 -28
- package/traits/scale-press/scale-press.test.js +0 -39
- package/traits/scroll-lock/scroll-lock.test.js +0 -45
- package/traits/scroll-progress/scroll-progress.test.js +0 -182
- package/traits/shimmer-loading/shimmer-loading.test.js +0 -43
- package/traits/smoke.test.js +0 -111
- package/traits/snap-to-grid/snap-to-grid.test.js +0 -40
- package/traits/sound-feedback/sound-feedback.test.js +0 -26
- package/traits/spring-animate/spring-animate.test.js +0 -28
- package/traits/success-checkmark/success-checkmark.test.js +0 -120
- package/traits/tilt-hover/tilt-hover.test.js +0 -32
- package/traits/tossable/tossable.test.js +0 -31
- package/traits/traits-host.test.js +0 -73
- package/traits/typeahead/typeahead.test.js +0 -38
- package/traits/typewriter/typewriter.test.js +0 -47
- package/traits/validation/validation.test.js +0 -93
- 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
|
-
});
|