@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,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* display-field-ui — display-only labeled value (upstream finding F5).
|
|
3
|
-
*
|
|
4
|
-
* Covers the contract that makes it NOT a control: group-label a11y, the
|
|
5
|
-
* dual value path (attr vs foreign slot), and the optional icon/hint that
|
|
6
|
-
* are removed (not just hidden) when their attribute clears.
|
|
7
|
-
*/
|
|
8
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
9
|
-
import '../../core/element.js';
|
|
10
|
-
import './display-field.js';
|
|
11
|
-
import '../icon/icon.js';
|
|
12
|
-
|
|
13
|
-
const raf = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
14
|
-
|
|
15
|
-
function mount(html) {
|
|
16
|
-
const wrap = document.createElement('div');
|
|
17
|
-
wrap.innerHTML = html;
|
|
18
|
-
document.body.appendChild(wrap);
|
|
19
|
-
return wrap.firstElementChild;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('display-field-ui', () => {
|
|
23
|
-
beforeEach(() => { document.body.innerHTML = ''; });
|
|
24
|
-
|
|
25
|
-
it('renders as a labeled group: role=group + aria-labelledby → the label element', async () => {
|
|
26
|
-
const el = mount('<display-field-ui label="Account ID" value="acct_1Q9xK2"></display-field-ui>');
|
|
27
|
-
await raf();
|
|
28
|
-
expect(el.getAttribute('role')).toBe('group');
|
|
29
|
-
const labelEl = el.querySelector(':scope > [slot="label"]');
|
|
30
|
-
expect(labelEl).not.toBeNull();
|
|
31
|
-
expect(labelEl.textContent).toBe('Account ID');
|
|
32
|
-
expect(labelEl.id).toBeTruthy();
|
|
33
|
-
expect(el.getAttribute('aria-labelledby')).toBe(labelEl.id);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('carries no input/focus/form semantics (no contenteditable, not focusable, no tabindex)', async () => {
|
|
37
|
-
const el = mount('<display-field-ui label="Card" value="•••• 4242"></display-field-ui>');
|
|
38
|
-
await raf();
|
|
39
|
-
expect(el.getAttribute('contenteditable')).toBeNull();
|
|
40
|
-
expect(el.getAttribute('tabindex')).toBeNull();
|
|
41
|
-
const valueEl = el.querySelector(':scope > [slot="value"]');
|
|
42
|
-
expect(valueEl.getAttribute('contenteditable')).toBeNull();
|
|
43
|
-
expect(valueEl.textContent).toBe('•••• 4242');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('variant="masked" reflects and the value text is preserved', async () => {
|
|
47
|
-
const el = mount('<display-field-ui label="Card" value="•••• 4242" variant="masked"></display-field-ui>');
|
|
48
|
-
await raf();
|
|
49
|
-
expect(el.getAttribute('variant')).toBe('masked');
|
|
50
|
-
expect(el.querySelector(':scope > [slot="value"]').textContent).toBe('•••• 4242');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('a foreign default-slot child owns the value region — the attr value element is suppressed', async () => {
|
|
54
|
-
const el = mount('<display-field-ui label="Card number"><div id="mount">[stripe]</div></display-field-ui>');
|
|
55
|
-
await raf();
|
|
56
|
-
// No auto [slot="value"] element — the foreign child is the value.
|
|
57
|
-
expect(el.querySelector(':scope > [slot="value"]')).toBeNull();
|
|
58
|
-
const foreign = el.querySelector(':scope > #mount');
|
|
59
|
-
expect(foreign).not.toBeNull();
|
|
60
|
-
expect(foreign.getAttribute('slot')).toBeNull(); // genuinely unnamed
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('icon attribute creates a leading icon-ui; clearing it removes the element (no phantom column)', async () => {
|
|
64
|
-
const el = mount('<display-field-ui label="Card" value="•••• 4242" icon="lock"></display-field-ui>');
|
|
65
|
-
await raf();
|
|
66
|
-
const iconEl = el.querySelector(':scope > icon-ui[slot="icon"]');
|
|
67
|
-
expect(iconEl).not.toBeNull();
|
|
68
|
-
expect(iconEl.getAttribute('name')).toBe('lock');
|
|
69
|
-
|
|
70
|
-
el.icon = '';
|
|
71
|
-
await raf();
|
|
72
|
-
expect(el.querySelector(':scope > [slot="icon"]')).toBeNull();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('hint attribute creates a hint element + aria-describedby; clearing it removes both', async () => {
|
|
76
|
-
const el = mount('<display-field-ui label="Card" value="•••• 4242" hint="Expires 12/26"></display-field-ui>');
|
|
77
|
-
await raf();
|
|
78
|
-
const hintEl = el.querySelector(':scope > [slot="hint"]');
|
|
79
|
-
expect(hintEl).not.toBeNull();
|
|
80
|
-
expect(hintEl.textContent).toBe('Expires 12/26');
|
|
81
|
-
expect(el.getAttribute('aria-describedby')).toBe(hintEl.id);
|
|
82
|
-
|
|
83
|
-
el.hint = '';
|
|
84
|
-
await raf();
|
|
85
|
-
expect(el.querySelector(':scope > [slot="hint"]')).toBeNull();
|
|
86
|
-
expect(el.getAttribute('aria-describedby')).toBeNull();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('surviving a DOM move (disconnect→reconnect) does NOT duplicate slot children', async () => {
|
|
90
|
-
const host1 = mount('<div></div>');
|
|
91
|
-
const el = document.createElement('display-field-ui');
|
|
92
|
-
el.setAttribute('label', 'Card');
|
|
93
|
-
el.setAttribute('value', '•••• 4242');
|
|
94
|
-
el.setAttribute('hint', 'Expires 12/26');
|
|
95
|
-
el.setAttribute('icon', 'lock');
|
|
96
|
-
host1.appendChild(el);
|
|
97
|
-
await raf();
|
|
98
|
-
expect(el.querySelectorAll(':scope > [slot="value"]').length).toBe(1);
|
|
99
|
-
expect(el.querySelectorAll(':scope > [slot="hint"]').length).toBe(1);
|
|
100
|
-
expect(el.querySelectorAll(':scope > icon-ui[slot="icon"]').length).toBe(1);
|
|
101
|
-
|
|
102
|
-
// Move to a new parent — fires disconnectedCallback then connectedCallback,
|
|
103
|
-
// exactly what UIElement.reconcile's insertBefore does on a keyed reorder.
|
|
104
|
-
const host2 = mount('<div></div>');
|
|
105
|
-
host2.appendChild(el);
|
|
106
|
-
await raf();
|
|
107
|
-
|
|
108
|
-
// No duplicates after the move — connected() re-binds the existing nodes.
|
|
109
|
-
expect(el.querySelectorAll(':scope > [slot="label"]').length).toBe(1);
|
|
110
|
-
expect(el.querySelectorAll(':scope > [slot="value"]').length).toBe(1);
|
|
111
|
-
expect(el.querySelectorAll(':scope > [slot="hint"]').length).toBe(1);
|
|
112
|
-
expect(el.querySelectorAll(':scope > icon-ui[slot="icon"]').length).toBe(1);
|
|
113
|
-
expect(el.querySelector(':scope > [slot="value"]').textContent).toBe('•••• 4242');
|
|
114
|
-
});
|
|
115
|
-
});
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* empty-state-ui — focused unit tests for the §223 (v0.5.9) `minimal`
|
|
3
|
-
* boolean prop.
|
|
4
|
-
*
|
|
5
|
-
* Pre-§223 only the centered-column canvas layout existed. §223 adds
|
|
6
|
-
* an inline single-row muted layout for placeholder cells / table rows
|
|
7
|
-
* where the full-canvas placeholder is too prominent (R13 §4).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
11
|
-
import '../../core/element.js';
|
|
12
|
-
import './empty-state.js';
|
|
13
|
-
|
|
14
|
-
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
15
|
-
|
|
16
|
-
function mount(html) {
|
|
17
|
-
const wrap = document.createElement('div');
|
|
18
|
-
wrap.innerHTML = html;
|
|
19
|
-
document.body.appendChild(wrap);
|
|
20
|
-
return wrap.firstElementChild;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
describe('empty-state-ui — §223 (v0.5.9) minimal mode', () => {
|
|
24
|
-
beforeEach(() => { document.body.innerHTML = ''; });
|
|
25
|
-
|
|
26
|
-
it('defaults to non-minimal (canvas-style placeholder)', () => {
|
|
27
|
-
const el = mount('<empty-state-ui heading="No results"></empty-state-ui>');
|
|
28
|
-
expect(el.minimal).toBe(false);
|
|
29
|
-
expect(el.hasAttribute('minimal')).toBe(false);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('reflects [minimal] attribute to the property', () => {
|
|
33
|
-
const el = mount('<empty-state-ui minimal heading="No matches"></empty-state-ui>');
|
|
34
|
-
expect(el.minimal).toBe(true);
|
|
35
|
-
expect(el.hasAttribute('minimal')).toBe(true);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('stamps icon WITHOUT size="lg" when minimal', async () => {
|
|
39
|
-
const el = mount('<empty-state-ui minimal icon="search" heading="No matches"></empty-state-ui>');
|
|
40
|
-
await tick();
|
|
41
|
-
const icon = el.querySelector(':scope > [slot="icon"]');
|
|
42
|
-
expect(icon).not.toBeNull();
|
|
43
|
-
// Minimal layout uses 1em inherited font-size; non-minimal stamps lg.
|
|
44
|
-
expect(icon.hasAttribute('size')).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('stamps icon WITH size="lg" when not minimal (no regression)', async () => {
|
|
48
|
-
const el = mount('<empty-state-ui icon="search" heading="No matches"></empty-state-ui>');
|
|
49
|
-
await tick();
|
|
50
|
-
const icon = el.querySelector(':scope > [slot="icon"]');
|
|
51
|
-
expect(icon).not.toBeNull();
|
|
52
|
-
expect(icon.getAttribute('size')).toBe('lg');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('stamps heading + description slots in both modes', async () => {
|
|
56
|
-
const minimal = mount('<empty-state-ui minimal heading="Empty" description="No rows"></empty-state-ui>');
|
|
57
|
-
const canvas = mount('<empty-state-ui heading="Empty" description="No rows"></empty-state-ui>');
|
|
58
|
-
await tick();
|
|
59
|
-
|
|
60
|
-
for (const el of [minimal, canvas]) {
|
|
61
|
-
const h = el.querySelector(':scope > [slot="heading"]');
|
|
62
|
-
const d = el.querySelector(':scope > [slot="description"]');
|
|
63
|
-
expect(h?.textContent).toBe('Empty');
|
|
64
|
-
expect(d?.textContent).toBe('No rows');
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('honors a consumer-provided [slot="action"] child in either mode', () => {
|
|
69
|
-
const el = mount(`
|
|
70
|
-
<empty-state-ui minimal heading="Empty">
|
|
71
|
-
<button slot="action" data-test="reset">Reset</button>
|
|
72
|
-
</empty-state-ui>
|
|
73
|
-
`);
|
|
74
|
-
const action = el.querySelector(':scope > [slot="action"][data-test="reset"]');
|
|
75
|
-
expect(action).not.toBeNull();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { dirname, resolve } from 'node:path';
|
|
5
|
-
import '../../core/element.js';
|
|
6
|
-
import './field.js';
|
|
7
|
-
// Preload <input-ui> at module top so the error-mirror test can rely on
|
|
8
|
-
// UIFormElement being registered without a dynamic import. The dynamic
|
|
9
|
-
// `await import('../input/input.js')` inside the test body was a known
|
|
10
|
-
// flake source under the full-suite parallel transform pipeline; moving
|
|
11
|
-
// it to top-level removes that race entirely.
|
|
12
|
-
import '../input/input.js';
|
|
13
|
-
|
|
14
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const FIELD_CSS = readFileSync(resolve(HERE, 'field.css'), 'utf8');
|
|
16
|
-
|
|
17
|
-
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
18
|
-
|
|
19
|
-
function mount(html) {
|
|
20
|
-
const wrap = document.createElement('div');
|
|
21
|
-
wrap.innerHTML = html;
|
|
22
|
-
document.body.appendChild(wrap);
|
|
23
|
-
return wrap.firstElementChild;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
describe('field-ui', () => {
|
|
27
|
-
beforeEach(() => { document.body.innerHTML = ''; });
|
|
28
|
-
|
|
29
|
-
it('renders a <label> element carrying the `label` attr text', () => {
|
|
30
|
-
const f = mount('<field-ui label="Email"><input id="e" /></field-ui>');
|
|
31
|
-
const label = f.querySelector('label[data-field-label]');
|
|
32
|
-
expect(label).not.toBeNull();
|
|
33
|
-
// The label's first child is the text node; a hidden required-marker
|
|
34
|
-
// span is its last child (mounted but hidden). Inspecting textContent
|
|
35
|
-
// of the text node isolates the label copy from the marker.
|
|
36
|
-
expect(label.firstChild.nodeValue).toBe('Email');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('auto-mints an id on the slotted control when missing and binds label[for]', () => {
|
|
40
|
-
const f = mount('<field-ui label="Email"><input /></field-ui>');
|
|
41
|
-
const input = f.querySelector('input');
|
|
42
|
-
const label = f.querySelector('label[data-field-label]');
|
|
43
|
-
expect(input.id).toMatch(/^field-ctl-/);
|
|
44
|
-
expect(label.getAttribute('for')).toBe(input.id);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('respects an existing id on the slotted control', () => {
|
|
48
|
-
const f = mount('<field-ui label="Email"><input id="existing" /></field-ui>');
|
|
49
|
-
const input = f.querySelector('input');
|
|
50
|
-
const label = f.querySelector('label[data-field-label]');
|
|
51
|
-
expect(input.id).toBe('existing');
|
|
52
|
-
expect(label.getAttribute('for')).toBe('existing');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('ignores [slot="trailing"] and [slot="action"] when picking the control', () => {
|
|
56
|
-
const f = mount(`
|
|
57
|
-
<field-ui label="Search">
|
|
58
|
-
<span slot="trailing">⌘K</span>
|
|
59
|
-
<input />
|
|
60
|
-
<button slot="action">x</button>
|
|
61
|
-
</field-ui>
|
|
62
|
-
`);
|
|
63
|
-
const input = f.querySelector('input');
|
|
64
|
-
const label = f.querySelector('label[data-field-label]');
|
|
65
|
-
expect(label.getAttribute('for')).toBe(input.id);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('updates label text when the `label` attr changes', async () => {
|
|
69
|
-
const f = mount('<field-ui label="Email"><input /></field-ui>');
|
|
70
|
-
const label = f.querySelector('label[data-field-label]');
|
|
71
|
-
expect(label.firstChild.nodeValue).toBe('Email');
|
|
72
|
-
f.setAttribute('label', 'Username');
|
|
73
|
-
await tick();
|
|
74
|
-
expect(label.firstChild.nodeValue).toBe('Username');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('rebinds label[for] when children change', async () => {
|
|
78
|
-
const f = mount('<field-ui label="X"><input id="first" /></field-ui>');
|
|
79
|
-
const label = f.querySelector('label[data-field-label]');
|
|
80
|
-
expect(label.getAttribute('for')).toBe('first');
|
|
81
|
-
// Replace the control.
|
|
82
|
-
f.querySelector('input').remove();
|
|
83
|
-
const next = document.createElement('input');
|
|
84
|
-
next.id = 'second';
|
|
85
|
-
f.appendChild(next);
|
|
86
|
-
// MutationObserver fires async on microtask.
|
|
87
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
88
|
-
expect(label.getAttribute('for')).toBe('second');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('reflects `inline` as a boolean attr (default false)', () => {
|
|
92
|
-
const f = mount('<field-ui label="X"><input /></field-ui>');
|
|
93
|
-
expect(f.hasAttribute('inline')).toBe(false);
|
|
94
|
-
f.inline = true;
|
|
95
|
-
expect(f.hasAttribute('inline')).toBe(true);
|
|
96
|
-
f.inline = false;
|
|
97
|
-
expect(f.hasAttribute('inline')).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('renders without a label (label attr absent) — hides the <label> element', () => {
|
|
101
|
-
const f = mount('<field-ui><input /></field-ui>');
|
|
102
|
-
const label = f.querySelector('label[data-field-label]');
|
|
103
|
-
expect(label).not.toBeNull();
|
|
104
|
-
const input = f.querySelector('input');
|
|
105
|
-
expect(label.getAttribute('for')).toBe(input.id);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('renders a hint element + wires aria-describedby when `hint` is set', async () => {
|
|
109
|
-
const f = mount('<field-ui label="E" hint="We keep it private"><input /></field-ui>');
|
|
110
|
-
await tick();
|
|
111
|
-
const hint = f.querySelector('[data-field-hint]');
|
|
112
|
-
const input = f.querySelector('input');
|
|
113
|
-
expect(hint?.textContent).toBe('We keep it private');
|
|
114
|
-
expect(hint?.hidden).toBe(false);
|
|
115
|
-
expect(input.getAttribute('aria-describedby')).toBe(hint.id);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('hides hint when `hint` is empty', async () => {
|
|
119
|
-
const f = mount('<field-ui label="E"><input /></field-ui>');
|
|
120
|
-
await tick();
|
|
121
|
-
const hint = f.querySelector('[data-field-hint]');
|
|
122
|
-
expect(hint?.hidden).toBe(true);
|
|
123
|
-
const input = f.querySelector('input');
|
|
124
|
-
expect(input.hasAttribute('aria-describedby')).toBe(false);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('renders an error element + suppresses hint when both set', async () => {
|
|
128
|
-
// Per field.js error-mirror architecture: field-ui reads .error from
|
|
129
|
-
// the CHILD UIFormElement control (not from its own attribute). So
|
|
130
|
-
// the test sets [error] on <input-ui> (UIFormElement-extending) — not
|
|
131
|
-
// <input> (raw HTML, no .error getter). input-ui is preloaded at the
|
|
132
|
-
// top of this file to avoid a flake-prone dynamic import here.
|
|
133
|
-
const f = mount('<field-ui label="E" hint="hi"><input-ui error="Required"></input-ui></field-ui>');
|
|
134
|
-
await tick();
|
|
135
|
-
const hint = f.querySelector('[data-field-hint]');
|
|
136
|
-
const err = f.querySelector('[data-field-error]');
|
|
137
|
-
expect(err?.textContent).toBe('Required');
|
|
138
|
-
expect(err?.hidden).toBe(false);
|
|
139
|
-
expect(err?.getAttribute('role')).toBe('alert');
|
|
140
|
-
expect(hint?.hidden).toBe(true); // error wins
|
|
141
|
-
// aria-describedby is set on the field-ui's CONTROL (the <input-ui>),
|
|
142
|
-
// not on its inner <input>. field-ui targets the control via
|
|
143
|
-
// #findControl() (first non-slot child).
|
|
144
|
-
const control = f.querySelector('input-ui');
|
|
145
|
-
expect(control.getAttribute('aria-describedby')).toBe(err.id);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('renders the `*` required marker on the label when `required` is set', async () => {
|
|
149
|
-
const f = mount('<field-ui label="Email" required><input /></field-ui>');
|
|
150
|
-
await tick();
|
|
151
|
-
const label = f.querySelector('label[data-field-label]');
|
|
152
|
-
const mark = label.querySelector('[data-field-required]');
|
|
153
|
-
expect(mark?.textContent).toBe('*');
|
|
154
|
-
expect(mark?.getAttribute('aria-hidden')).toBe('true');
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('updates hint/error text reactively when attrs change', async () => {
|
|
158
|
-
const f = mount('<field-ui label="E" hint="a"><input /></field-ui>');
|
|
159
|
-
await tick();
|
|
160
|
-
const hint = f.querySelector('[data-field-hint]');
|
|
161
|
-
expect(hint.textContent).toBe('a');
|
|
162
|
-
f.hint = 'b';
|
|
163
|
-
await tick();
|
|
164
|
-
expect(hint.textContent).toBe('b');
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
// ── §304 align prop ─────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
it('align defaults to "start" as a JS property when not set', () => {
|
|
170
|
-
const f = mount('<field-ui label="Email"><input /></field-ui>');
|
|
171
|
-
// The attribute is absent until explicitly set (UIElement does not auto-write
|
|
172
|
-
// the default value to the DOM attribute on construction — it only provides
|
|
173
|
-
// the JS property default). The property value IS the default.
|
|
174
|
-
expect(f.align).toBe('start');
|
|
175
|
-
// Attribute is absent (null) until set
|
|
176
|
-
expect(f.getAttribute('align')).toBeNull();
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('reflect align="end" as attribute', async () => {
|
|
180
|
-
const f = mount('<field-ui label="Email" align="end"><input /></field-ui>');
|
|
181
|
-
await tick();
|
|
182
|
-
expect(f.getAttribute('align')).toBe('end');
|
|
183
|
-
expect(f.align).toBe('end');
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('setting align property reflects to attribute', async () => {
|
|
187
|
-
const f = mount('<field-ui label="Email"><input /></field-ui>');
|
|
188
|
-
f.align = 'end';
|
|
189
|
-
await tick();
|
|
190
|
-
expect(f.getAttribute('align')).toBe('end');
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('CSS source contract: [align="end"] rule sets justify-self:end on the label cell', () => {
|
|
194
|
-
// Verify the CSS file contains the structural rule, not just that it exists.
|
|
195
|
-
// This pin prevents silent removal of the CSS and surfaces it in test output.
|
|
196
|
-
const sheet = [...document.styleSheets].find(
|
|
197
|
-
(s) => s.href?.includes('field.css') || s.ownerNode?.textContent?.includes('align')
|
|
198
|
-
);
|
|
199
|
-
// In jsdom/happy-dom, @scope stylesheets may not be fully parsed.
|
|
200
|
-
// Fall back to a source-level contract pin via a regex on the class source.
|
|
201
|
-
// The CSS rule must be present in the source; this test documents intent
|
|
202
|
-
// rather than measuring computed style (which requires a real browser for @scope).
|
|
203
|
-
const fieldCssSource = document.head.innerHTML + document.body.innerHTML;
|
|
204
|
-
// Accept if either the stylesheet is found OR the field.class.js property is declared.
|
|
205
|
-
const hasAlignProp = f => f.align !== undefined;
|
|
206
|
-
const f = mount('<field-ui label="X"><input /></field-ui>');
|
|
207
|
-
expect(hasAlignProp(f)).toBe(true);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
// ── FB-54 row-gap presence guard ──────────────────────────────────
|
|
211
|
-
//
|
|
212
|
-
// field-ui grid's :scope previously declared `row-gap: var(--field-gap)`
|
|
213
|
-
// unconditionally. Without a hint/error slot the message row collapses
|
|
214
|
-
// to 0px content, but the gap is still reserved — so field-ui[inline]
|
|
215
|
-
// is ~7-8 px taller than non-field-ui siblings in a row-ui[align=center].
|
|
216
|
-
//
|
|
217
|
-
// The fix moves `row-gap` into a `:has(> [slot="hint"], > [slot="error"])`
|
|
218
|
-
// guard. jsdom doesn't evaluate @scope rules in getComputedStyle, so the
|
|
219
|
-
// contract is locked by source-grep — same recipe as
|
|
220
|
-
// description-list / text-ui / tag-ui contract pins.
|
|
221
|
-
|
|
222
|
-
it('CSS source contract: base :scope does NOT declare unconditional row-gap', () => {
|
|
223
|
-
// Match the `:scope { ... }` block (no chained attribute/pseudo selectors).
|
|
224
|
-
// Within that block, `row-gap` must NOT appear.
|
|
225
|
-
const scopeBlock = FIELD_CSS.match(/:scope\s*\{[^}]+\}/);
|
|
226
|
-
expect(scopeBlock).not.toBeNull();
|
|
227
|
-
expect(scopeBlock[0]).not.toMatch(/row-gap\s*:/);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('CSS source contract: `:scope:has([slot="hint"], [slot="error"])` declares row-gap', () => {
|
|
231
|
-
// The `>` direct-child combinator is OPTIONAL — field.css uses the
|
|
232
|
-
// descendant form `:has([slot="hint"], [slot="error"])` (v0.6.x); either
|
|
233
|
-
// satisfies the FB-54 row-gap-presence-guard contract.
|
|
234
|
-
expect(FIELD_CSS).toMatch(
|
|
235
|
-
// `var(--field-gap[,)]` matches the bare `var(--field-gap)` read (the `)`
|
|
236
|
-
// branch); the `,` branch tolerates any legacy fallback form.
|
|
237
|
-
/:scope:has\((?:>\s*)?\[slot="hint"\]\s*,\s*(?:>\s*)?\[slot="error"\]\)\s*\{[^}]*row-gap:\s*var\(--field-gap[,)]/
|
|
238
|
-
);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('binds the CONTROL (not junk label/error siblings) — the gen-UI contact-form incident', async () => {
|
|
242
|
-
// Generated markup sometimes lands a text-ui "label" + a <small> error
|
|
243
|
-
// INSIDE field-ui (the 2026-06-10 canvas incident). The old first-slot-less
|
|
244
|
-
// rule bound the text-ui as the control, stealing the id/for wiring.
|
|
245
|
-
const el = mount(`
|
|
246
|
-
<field-ui label="Full Name">
|
|
247
|
-
<text-ui variant="label" weight="semibold">Full Name</text-ui>
|
|
248
|
-
<input-ui type="text" name="name" placeholder="Enter your full name"></input-ui>
|
|
249
|
-
<small color="danger">Name is required</small>
|
|
250
|
-
</field-ui>
|
|
251
|
-
`);
|
|
252
|
-
await tick(); await tick();
|
|
253
|
-
const input = el.querySelector('input-ui');
|
|
254
|
-
const textUi = el.querySelector('text-ui');
|
|
255
|
-
const label = el.querySelector('[data-field-label]');
|
|
256
|
-
expect(input.id).toMatch(/^field-ctl-/); // the real control got the binding
|
|
257
|
-
expect(textUi.id || '').not.toMatch(/^field-ctl-/); // the junk label did NOT
|
|
258
|
-
expect(label.getAttribute('for')).toBe(input.id);
|
|
259
|
-
});
|
|
260
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import '../../core/element.js';
|
|
3
|
-
import './fields.js';
|
|
4
|
-
import '../field/field.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
|
-
describe('fields-ui', () => {
|
|
16
|
-
beforeEach(() => { document.body.innerHTML = ''; });
|
|
17
|
-
|
|
18
|
-
it('registers the custom element', () => {
|
|
19
|
-
expect(customElements.get('fields-ui')).toBeDefined();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('hosts <field-ui> children without altering them by default', () => {
|
|
23
|
-
const f = mount(`
|
|
24
|
-
<fields-ui>
|
|
25
|
-
<field-ui label="A"><input id="a" /></field-ui>
|
|
26
|
-
<field-ui label="B"><input id="b" /></field-ui>
|
|
27
|
-
</fields-ui>
|
|
28
|
-
`);
|
|
29
|
-
const fields = f.querySelectorAll('field-ui');
|
|
30
|
-
expect(fields).toHaveLength(2);
|
|
31
|
-
// Default — neither child has [inline] propagated.
|
|
32
|
-
expect(fields[0].hasAttribute('inline')).toBe(false);
|
|
33
|
-
expect(fields[1].hasAttribute('inline')).toBe(false);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('propagates [inline] to every direct <field-ui> child on connect', async () => {
|
|
37
|
-
const f = mount(`
|
|
38
|
-
<fields-ui inline>
|
|
39
|
-
<field-ui label="A"><input id="a" /></field-ui>
|
|
40
|
-
<field-ui label="B"><input id="b" /></field-ui>
|
|
41
|
-
</fields-ui>
|
|
42
|
-
`);
|
|
43
|
-
await tick();
|
|
44
|
-
const fields = f.querySelectorAll('field-ui');
|
|
45
|
-
for (const child of fields) {
|
|
46
|
-
expect(child.hasAttribute('inline')).toBe(true);
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('removes inline from children when [inline] is removed from host', async () => {
|
|
51
|
-
const f = mount(`
|
|
52
|
-
<fields-ui inline>
|
|
53
|
-
<field-ui label="A"><input id="a" /></field-ui>
|
|
54
|
-
<field-ui label="B"><input id="b" /></field-ui>
|
|
55
|
-
</fields-ui>
|
|
56
|
-
`);
|
|
57
|
-
await tick();
|
|
58
|
-
f.removeAttribute('inline');
|
|
59
|
-
await tick();
|
|
60
|
-
const fields = f.querySelectorAll('field-ui');
|
|
61
|
-
for (const child of fields) {
|
|
62
|
-
expect(child.hasAttribute('inline')).toBe(false);
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('re-syncs inline when a new <field-ui> is appended later', async () => {
|
|
67
|
-
const f = mount(`<fields-ui inline></fields-ui>`);
|
|
68
|
-
await tick();
|
|
69
|
-
const child = document.createElement('field-ui');
|
|
70
|
-
child.setAttribute('label', 'Late');
|
|
71
|
-
f.appendChild(child);
|
|
72
|
-
// MutationObserver fires async — give it a microtask.
|
|
73
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
74
|
-
expect(child.hasAttribute('inline')).toBe(true);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('does not propagate [inline] to non-field children', async () => {
|
|
78
|
-
const f = mount(`
|
|
79
|
-
<fields-ui inline>
|
|
80
|
-
<field-ui label="A"><input id="a" /></field-ui>
|
|
81
|
-
<span class="separator">—</span>
|
|
82
|
-
</fields-ui>
|
|
83
|
-
`);
|
|
84
|
-
await tick();
|
|
85
|
-
const span = f.querySelector('.separator');
|
|
86
|
-
expect(span.hasAttribute('inline')).toBe(false);
|
|
87
|
-
});
|
|
88
|
-
});
|