@adia-ai/web-components 0.5.5 → 0.5.7
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/components/accordion/accordion-item.a2ui.json +19 -2
- package/components/accordion/accordion-item.yaml +20 -0
- package/components/accordion/accordion.a2ui.json +1 -1
- package/components/accordion/accordion.yaml +1 -1
- package/components/accordion/class.js +5 -0
- package/components/agent-artifact/agent-artifact.yaml +3 -0
- package/components/agent-artifact/class.js +5 -0
- package/components/agent-feedback-bar/class.js +9 -3
- package/components/calendar-picker/calendar-picker.d.ts +10 -0
- package/components/code/code.d.ts +8 -0
- package/components/color-picker/class.js +42 -4
- package/components/color-picker/color-picker.test.js +96 -0
- package/components/color-picker/color-picker.yaml +2 -0
- package/components/input/class.js +101 -1
- package/components/input/input.a2ui.json +2 -2
- package/components/input/input.css +57 -0
- package/components/input/input.d.ts +2 -0
- package/components/input/input.test.js +123 -0
- package/components/input/input.yaml +15 -2
- package/components/segmented/segmented.d.ts +3 -3
- package/components/select/select.d.ts +5 -3
- package/components/slider/slider.d.ts +4 -0
- package/components/switch/switch.d.ts +5 -3
- package/components/table/class.js +9 -1
- package/components/table/table.yaml +4 -0
- package/components/table-toolbar/class.js +5 -0
- package/components/table-toolbar/table-toolbar.yaml +4 -0
- package/components/text/text.a2ui.json +1 -8
- package/components/text/text.css +13 -0
- package/components/text/text.d.ts +1 -1
- package/components/text/text.test.js +106 -0
- package/components/text/text.yaml +0 -7
- package/components/timeline/class.js +5 -0
- package/components/timeline/timeline.yaml +3 -0
- package/components/toast/toast.d.ts +35 -0
- package/components/toggle-scheme/class.js +31 -0
- package/components/toggle-scheme/toggle-scheme.test.js +110 -0
- package/components/upload/upload.d.ts +6 -0
- package/core/anchor.d.ts +71 -0
- package/core/controller.d.ts +171 -0
- package/core/markdown.d.ts +26 -0
- package/core/polyfills.d.ts +31 -0
- package/core/provider.d.ts +82 -0
- package/core/streams-bridge.d.ts +78 -0
- package/core/transport.d.ts +78 -0
- package/package.json +4 -2
- package/styles/components.css +2 -0
|
@@ -34,12 +34,29 @@
|
|
|
34
34
|
"anti_patterns": [],
|
|
35
35
|
"category": "layout",
|
|
36
36
|
"composes": [],
|
|
37
|
-
"events": {
|
|
37
|
+
"events": {
|
|
38
|
+
"toggle": {
|
|
39
|
+
"description": "Fired when the section opens or closes.",
|
|
40
|
+
"detail": {
|
|
41
|
+
"open": {
|
|
42
|
+
"description": "New open state.",
|
|
43
|
+
"type": "boolean"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
38
48
|
"examples": [],
|
|
39
49
|
"keywords": [],
|
|
40
50
|
"name": "UIAccordionItem",
|
|
41
51
|
"related": [],
|
|
42
|
-
"slots": {
|
|
52
|
+
"slots": {
|
|
53
|
+
"action": {
|
|
54
|
+
"description": "§206 (v0.5.7): action buttons inside a custom header (e.g. Copy /\nDownload / settings). Children placed at `[slot=\"action\"]` (or\n`[slot=\"actions\"]`, or marked `[data-no-toggle]`) are excluded from\nthe toggle-on-click cascade — clicking them fires their own handler\nwithout also toggling the section."
|
|
55
|
+
},
|
|
56
|
+
"header": {
|
|
57
|
+
"description": "Custom header content. By default `[text]` renders as a plain header\nlabel, but a `[slot=\"header\"]` override lets consumers author rich\nheaders (icon + title + action buttons + caret)."
|
|
58
|
+
}
|
|
59
|
+
},
|
|
43
60
|
"states": [],
|
|
44
61
|
"synonyms": {},
|
|
45
62
|
"tag": "accordion-item-ui",
|
|
@@ -25,3 +25,23 @@ props:
|
|
|
25
25
|
description: Whether the section is expanded.
|
|
26
26
|
type: boolean
|
|
27
27
|
default: false
|
|
28
|
+
slots:
|
|
29
|
+
header:
|
|
30
|
+
description: |-
|
|
31
|
+
Custom header content. By default `[text]` renders as a plain header
|
|
32
|
+
label, but a `[slot="header"]` override lets consumers author rich
|
|
33
|
+
headers (icon + title + action buttons + caret).
|
|
34
|
+
action:
|
|
35
|
+
description: |-
|
|
36
|
+
§206 (v0.5.7): action buttons inside a custom header (e.g. Copy /
|
|
37
|
+
Download / settings). Children placed at `[slot="action"]` (or
|
|
38
|
+
`[slot="actions"]`, or marked `[data-no-toggle]`) are excluded from
|
|
39
|
+
the toggle-on-click cascade — clicking them fires their own handler
|
|
40
|
+
without also toggling the section.
|
|
41
|
+
events:
|
|
42
|
+
toggle:
|
|
43
|
+
description: Fired when the section opens or closes.
|
|
44
|
+
detail:
|
|
45
|
+
open:
|
|
46
|
+
type: boolean
|
|
47
|
+
description: New open state.
|
|
@@ -128,6 +128,11 @@ export class UIAccordionItem extends UIElement {
|
|
|
128
128
|
#onClick = (e) => {
|
|
129
129
|
const header = this.querySelector('[slot="header"]');
|
|
130
130
|
if (!header || !header.contains(e.target)) return;
|
|
131
|
+
// FEEDBACK-16 §2 (v0.5.7 §206): skip toggle when click originates inside an
|
|
132
|
+
// action slot or an opt-out marker. Lets consumers author action buttons in
|
|
133
|
+
// custom headers without bubbling-toggle UX. Slot vocabulary matches
|
|
134
|
+
// drawer-ui + pane-ui.
|
|
135
|
+
if (e.target.closest('[slot="action"], [slot="actions"], [data-no-toggle]')) return;
|
|
131
136
|
|
|
132
137
|
this.open = !this.open;
|
|
133
138
|
this.dispatchEvent(new CustomEvent('toggle', {
|
|
@@ -58,6 +58,11 @@ export class UIAgentArtifact extends UIElement {
|
|
|
58
58
|
tone: { type: String, default: 'neutral', reflect: true },
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// §205 (v0.5.7): dynamic chevron icons stamped on collapse/expand state
|
|
62
|
+
// transition (class.js:119+188). Per FEEDBACK-16 §1 + §209 slot-11 ternary-
|
|
63
|
+
// walker discovery. Note: `this.icon` consumer-supplied — not declared here.
|
|
64
|
+
static requiredIcons = ['caret-right', 'caret-down'];
|
|
65
|
+
|
|
61
66
|
static template = () => null;
|
|
62
67
|
|
|
63
68
|
#headerEl = null;
|
|
@@ -45,12 +45,17 @@
|
|
|
45
45
|
|
|
46
46
|
import { UIElement } from '../../core/element.js';
|
|
47
47
|
|
|
48
|
-
function makeButton({ icon, text = '' }) {
|
|
48
|
+
function makeButton({ icon, text = '', title = '' }) {
|
|
49
49
|
const btn = document.createElement('button-ui');
|
|
50
50
|
btn.setAttribute('icon', icon);
|
|
51
51
|
btn.setAttribute('variant', 'ghost');
|
|
52
52
|
btn.setAttribute('size', 'sm');
|
|
53
53
|
if (text) btn.setAttribute('text', text);
|
|
54
|
+
// §190a (v0.5.6): supply title= for icon-only buttons. button-ui's §184
|
|
55
|
+
// FEEDBACK-08 §8 a11y safety-net mirrors title → aria-label automatically
|
|
56
|
+
// when the button is icon-only. Closes the per-render console.warn class
|
|
57
|
+
// captured 2026-05-14 ("Icon-only button is missing an accessible name").
|
|
58
|
+
if (!text && title) btn.setAttribute('title', title);
|
|
54
59
|
return btn;
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -142,11 +147,12 @@ export class UIAgentFeedbackBar extends UIElement {
|
|
|
142
147
|
#build() {
|
|
143
148
|
this.innerHTML = '';
|
|
144
149
|
|
|
145
|
-
this.#upEl = makeButton({ icon: 'thumbs-up' });
|
|
146
|
-
this.#downEl = makeButton({ icon: 'thumbs-down' });
|
|
150
|
+
this.#upEl = makeButton({ icon: 'thumbs-up', title: 'Rate this response positively' });
|
|
151
|
+
this.#downEl = makeButton({ icon: 'thumbs-down', title: 'Rate this response negatively' });
|
|
147
152
|
this.#saveEl = makeButton({
|
|
148
153
|
icon: this.saveIcon || 'bookmark-simple',
|
|
149
154
|
text: this.saveLabel || '',
|
|
155
|
+
title: this.saveLabel ? '' : 'Save this response',
|
|
150
156
|
});
|
|
151
157
|
if (!this.saveLabel) this.#saveEl.hidden = true;
|
|
152
158
|
|
|
@@ -17,6 +17,16 @@ export class UICalendarPicker extends UIFormElement {
|
|
|
17
17
|
value: string;
|
|
18
18
|
/** Open/closed reflected state. */
|
|
19
19
|
open: boolean;
|
|
20
|
+
/** §207 (v0.5.7): label rendered above the trigger. */
|
|
21
|
+
label: string;
|
|
22
|
+
/** §207 (v0.5.7): placeholder text when no date is selected. */
|
|
23
|
+
placeholder: string;
|
|
24
|
+
/** §207 (v0.5.7): display format for the trigger label (e.g. `"YYYY-MM-DD"`). */
|
|
25
|
+
format: string;
|
|
26
|
+
/** §207 (v0.5.7): earliest selectable ISO-date; `null` to disable. */
|
|
27
|
+
min: string | null;
|
|
28
|
+
/** §207 (v0.5.7): latest selectable ISO-date; `null` to disable. */
|
|
29
|
+
max: string | null;
|
|
20
30
|
|
|
21
31
|
addEventListener<K extends keyof HTMLElementEventMap>(
|
|
22
32
|
type: K,
|
|
@@ -56,6 +56,14 @@ export class UICode extends UIFormElement {
|
|
|
56
56
|
/** Drop chrome (border, background) for inline composition. */
|
|
57
57
|
bare: boolean;
|
|
58
58
|
placeholder: string;
|
|
59
|
+
/** §207 (v0.5.7): native form-element name for `<form>` submission. */
|
|
60
|
+
name: string;
|
|
61
|
+
/** §207 (v0.5.7): form-validity `required` flag. */
|
|
62
|
+
required: boolean;
|
|
63
|
+
/** §207 (v0.5.7): native `disabled` flag — suppresses input + form submission. */
|
|
64
|
+
disabled: boolean;
|
|
65
|
+
/** §207 (v0.5.7): readonly mode — user cannot type but value is form-submitted. */
|
|
66
|
+
readonly: boolean;
|
|
59
67
|
|
|
60
68
|
addEventListener<K extends keyof HTMLElementEventMap>(
|
|
61
69
|
type: K,
|
|
@@ -127,7 +127,13 @@ export class UIColorPicker extends UIFormElement {
|
|
|
127
127
|
// consumer markup). Aggregated by installIconLoadersForRegistered()
|
|
128
128
|
// across all defined elements. Audited by check-required-icons.mjs
|
|
129
129
|
// (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
|
|
130
|
-
static requiredIcons = ['copy'];
|
|
130
|
+
static requiredIcons = ['copy', 'check', 'warning'];
|
|
131
|
+
|
|
132
|
+
// §201 (v0.5.7): once-per-element warn dedup for #parseValue malformed
|
|
133
|
+
// input. Matches the v0.5.5 §184 §8 button-ui icon-only safety-net pattern
|
|
134
|
+
// + v0.5.7 §215 select-ui parseOptions pattern. Per FEEDBACK-13 §1 +
|
|
135
|
+
// FEEDBACK-14 §2 (co-credited).
|
|
136
|
+
static #warnedBadParse = new WeakSet();
|
|
131
137
|
|
|
132
138
|
static properties = {
|
|
133
139
|
...UIFormElement.properties,
|
|
@@ -418,9 +424,41 @@ export class UIColorPicker extends UIFormElement {
|
|
|
418
424
|
if (val.startsWith('#')) {
|
|
419
425
|
const o = hexToOklch(val);
|
|
420
426
|
this.#L = o.L; this.#C = o.C; this.#H = o.H;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (val.startsWith('oklch(')) {
|
|
430
|
+
// §201 (v0.5.7, FEEDBACK-13 §1 + FEEDBACK-14 §2): accept floats, percent
|
|
431
|
+
// on L, NaN (culori chromaless convention), and 'none' (CSS Color L4
|
|
432
|
+
// powerless-component syntax). Channel-level normalization: NaN/none → 0;
|
|
433
|
+
// percent on L → divide by 100. Per the CSS Color L4 "powerless" cascade
|
|
434
|
+
// (https://www.w3.org/TR/css-color-4/#powerless), zeroing the hue channel
|
|
435
|
+
// is the spec-defined resolution when chroma is 0 or near-0.
|
|
436
|
+
const m = val.match(
|
|
437
|
+
/oklch\(\s*([\d.]+%?|NaN|none)\s+([\d.]+%?|NaN|none)\s+([\d.]+|NaN|none)/i,
|
|
438
|
+
);
|
|
439
|
+
if (m) {
|
|
440
|
+
const parseChan = (s, isL) => {
|
|
441
|
+
if (s === 'none' || /^NaN$/i.test(s)) return 0;
|
|
442
|
+
if (isL && s.endsWith('%')) return +s.slice(0, -1) / 100;
|
|
443
|
+
if (s.endsWith('%')) return +s.slice(0, -1) / 100;
|
|
444
|
+
return +s;
|
|
445
|
+
};
|
|
446
|
+
this.#L = parseChan(m[1], true);
|
|
447
|
+
this.#C = parseChan(m[2], false);
|
|
448
|
+
this.#H = parseChan(m[3], false);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// No regex match — warn once per element so consumers can correct.
|
|
452
|
+
// WeakSet dedup matches v0.5.5 §184 §8 button-ui icon-only pattern.
|
|
453
|
+
if (!UIColorPicker.#warnedBadParse.has(this)) {
|
|
454
|
+
UIColorPicker.#warnedBadParse.add(this);
|
|
455
|
+
// eslint-disable-next-line no-console
|
|
456
|
+
console.warn(
|
|
457
|
+
`<color-picker-ui>: could not parse value=${JSON.stringify(val)}. ` +
|
|
458
|
+
`Expected #rrggbb or oklch(L C H) with numeric, NaN, 'none' (CSS L4 ` +
|
|
459
|
+
`powerless), or % channels. Picker is keeping prior state.`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
424
462
|
}
|
|
425
463
|
}
|
|
426
464
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* color-picker-ui #parseValue tests — §201 (v0.5.7, FEEDBACK-13 §1 +
|
|
3
|
+
* FEEDBACK-14 §2 co-credited).
|
|
4
|
+
*
|
|
5
|
+
* Verifies the relaxed `oklch(L C H)` parser accepts:
|
|
6
|
+
* - Numeric channels (regression — the v0.5.6 baseline)
|
|
7
|
+
* - `NaN` on any channel (culori chromaless convention; coerces to 0)
|
|
8
|
+
* - `none` on any channel (CSS Color L4 powerless syntax; coerces to 0)
|
|
9
|
+
* - Percent on L (e.g. `oklch(53% 0.05 240)`; divides by 100)
|
|
10
|
+
*
|
|
11
|
+
* Plus:
|
|
12
|
+
* - Truly malformed input emits ONE console.warn per element (WeakSet dedup),
|
|
13
|
+
* not one per #parseValue call.
|
|
14
|
+
* - Hex pass-through remains intact.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
18
|
+
import '../../core/element.js';
|
|
19
|
+
import './color-picker.js';
|
|
20
|
+
|
|
21
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
22
|
+
|
|
23
|
+
function mount(html) {
|
|
24
|
+
const wrap = document.createElement('div');
|
|
25
|
+
wrap.innerHTML = html;
|
|
26
|
+
document.body.appendChild(wrap);
|
|
27
|
+
return wrap.firstElementChild;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('color-picker-ui #parseValue (§201)', () => {
|
|
31
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
32
|
+
|
|
33
|
+
it('accepts numeric oklch (baseline regression)', async () => {
|
|
34
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(0.6 0.15 240)"></color-picker-ui>');
|
|
35
|
+
await tick();
|
|
36
|
+
// Read back via the public value getter — round-tripped through the parser.
|
|
37
|
+
expect(p.value).toMatch(/oklch/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('accepts NaN hue (culori chromaless convention)', async () => {
|
|
41
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
42
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(0.53 0.01 NaN)"></color-picker-ui>');
|
|
43
|
+
await tick();
|
|
44
|
+
// No warn — NaN is accepted, coerced to 0.
|
|
45
|
+
expect(warn).not.toHaveBeenCalled();
|
|
46
|
+
warn.mockRestore();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('accepts `none` (CSS Color L4 powerless)', async () => {
|
|
50
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
51
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(0.5 0 none)"></color-picker-ui>');
|
|
52
|
+
await tick();
|
|
53
|
+
expect(warn).not.toHaveBeenCalled();
|
|
54
|
+
warn.mockRestore();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('accepts percent on L', async () => {
|
|
58
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
59
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(53% 0.05 240)"></color-picker-ui>');
|
|
60
|
+
await tick();
|
|
61
|
+
expect(warn).not.toHaveBeenCalled();
|
|
62
|
+
warn.mockRestore();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('accepts hex (regression)', async () => {
|
|
66
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
67
|
+
const p = mount('<color-picker-ui format="hex" value="#3b82f6"></color-picker-ui>');
|
|
68
|
+
await tick();
|
|
69
|
+
expect(warn).not.toHaveBeenCalled();
|
|
70
|
+
warn.mockRestore();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('warns once per element on malformed input', async () => {
|
|
74
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
75
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(banana)"></color-picker-ui>');
|
|
76
|
+
await tick();
|
|
77
|
+
// Drive multiple re-parses via setAttribute — should NOT compound the warn count.
|
|
78
|
+
p.setAttribute('value', 'oklch(orange juice)');
|
|
79
|
+
await tick();
|
|
80
|
+
p.setAttribute('value', 'oklch(still broken)');
|
|
81
|
+
await tick();
|
|
82
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(warn.mock.calls[0][0]).toMatch(/could not parse value/);
|
|
84
|
+
warn.mockRestore();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('two independent pickers each get their own warn budget', async () => {
|
|
88
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
89
|
+
const p1 = mount('<color-picker-ui format="oklch" value="oklch(broken-1)"></color-picker-ui>');
|
|
90
|
+
const p2 = mount('<color-picker-ui format="oklch" value="oklch(broken-2)"></color-picker-ui>');
|
|
91
|
+
await tick();
|
|
92
|
+
// Two separate instances — each gets their own first-warn.
|
|
93
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
94
|
+
warn.mockRestore();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -16,13 +16,25 @@
|
|
|
16
16
|
* Uses contenteditable for text entry, ElementInternals for form participation.
|
|
17
17
|
*
|
|
18
18
|
* Slots inside [slot="field"]:
|
|
19
|
-
* prefix → label → text → suffix → controls (number mode)
|
|
19
|
+
* prefix → [leading] → label → text → suffix → [trailing] → controls (number mode)
|
|
20
20
|
*
|
|
21
21
|
* <input-ui label="Email" placeholder="you@acme.com"></input-ui>
|
|
22
22
|
* <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
|
|
23
23
|
* <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
|
|
24
24
|
* <input-ui prefix="@" value="kim"></input-ui>
|
|
25
25
|
*
|
|
26
|
+
* <!-- Trailing buttons inside the input chrome (§199 v0.5.7) -->
|
|
27
|
+
* <input-ui value="Theme 1" suffix="Light">
|
|
28
|
+
* <button-ui slot="trailing" icon="arrow-square-out"
|
|
29
|
+
* variant="ghost" size="sm"
|
|
30
|
+
* aria-label="Open theme browser"></button-ui>
|
|
31
|
+
* </input-ui>
|
|
32
|
+
*
|
|
33
|
+
* <!-- Leading button for inline actions before the value -->
|
|
34
|
+
* <input-ui value="https://...">
|
|
35
|
+
* <button-ui slot="leading" icon="link" variant="ghost" size="sm"></button-ui>
|
|
36
|
+
* </input-ui>
|
|
37
|
+
*
|
|
26
38
|
* <input-ui type="number" value="42" min="0" max="100" step="1"></input-ui>
|
|
27
39
|
* <input-ui type="number" value="9.99" step="0.01" precision="2" prefix="$"></input-ui>
|
|
28
40
|
*
|
|
@@ -118,9 +130,22 @@ export class UIInput extends UIFormElement {
|
|
|
118
130
|
super.connected();
|
|
119
131
|
this.setAttribute('role', this.#isNumberMode ? 'spinbutton' : 'textbox');
|
|
120
132
|
|
|
133
|
+
// §199 (v0.5.7): consumer-supplied leading/trailing buttons live as
|
|
134
|
+
// direct children of <input-ui> at author time. Capture references
|
|
135
|
+
// BEFORE innerHTML wipes them, so we can move them into [slot="field"]
|
|
136
|
+
// after the shell is built. Querying `:scope > [slot="leading|trailing"]`
|
|
137
|
+
// (direct-child) avoids matching anything we already moved on a prior
|
|
138
|
+
// re-render.
|
|
139
|
+
const leadingNodes = Array.from(this.querySelectorAll(':scope > [slot="leading"]'));
|
|
140
|
+
const trailingNodes = Array.from(this.querySelectorAll(':scope > [slot="trailing"]'));
|
|
141
|
+
|
|
121
142
|
if (!this.querySelector('[slot="text"]')) {
|
|
122
143
|
const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
|
|
123
144
|
this.innerHTML = this.#shellHTML(labelId);
|
|
145
|
+
// innerHTML wiped the consumer-supplied leading/trailing nodes; re-attach
|
|
146
|
+
// them into the field at the right positions. Order matches the JSDoc:
|
|
147
|
+
// prefix → leading → label → text → suffix → trailing → controls
|
|
148
|
+
this.#installAffordances(leadingNodes, trailingNodes);
|
|
124
149
|
}
|
|
125
150
|
|
|
126
151
|
this.#textEl = this.querySelector('[slot="text"]');
|
|
@@ -208,6 +233,81 @@ export class UIInput extends UIFormElement {
|
|
|
208
233
|
`;
|
|
209
234
|
}
|
|
210
235
|
|
|
236
|
+
/**
|
|
237
|
+
* §199 (v0.5.7) — Move consumer-supplied [slot="leading"] +
|
|
238
|
+
* [slot="trailing"] nodes into the field at the right insertion points.
|
|
239
|
+
*
|
|
240
|
+
* Yaml has declared `leading` + `trailing` slots since v1 ("Leading/
|
|
241
|
+
* Trailing icon slot, sized to --content-height. Collapses text inline
|
|
242
|
+
* padding when present.") but the shell never rendered them, so any
|
|
243
|
+
* consumer-authored `<button-ui slot="trailing">` sat OUTSIDE the
|
|
244
|
+
* field chrome. §199 closes the schema-vs-impl gap by:
|
|
245
|
+
*
|
|
246
|
+
* 1. Capturing the consumer's [slot="leading|trailing"] direct
|
|
247
|
+
* children BEFORE `innerHTML = shellHTML` wipes them.
|
|
248
|
+
* 2. Re-inserting each into `[slot="field"]` at the canonical
|
|
249
|
+
* position: leading goes right after [slot="label"] (or after
|
|
250
|
+
* [slot="prefix"] when no label), trailing goes right after
|
|
251
|
+
* [slot="suffix"] (or after [slot="text"] when no suffix), but
|
|
252
|
+
* always BEFORE [slot="controls"] (number-mode stepper column).
|
|
253
|
+
*
|
|
254
|
+
* Consumers typically pass `<button-ui slot="trailing">` for inline
|
|
255
|
+
* actions (copy / clear / open-in-modal); CSS handles the chrome
|
|
256
|
+
* inheritance (border continuity + size token wiring) via
|
|
257
|
+
* input.css §199 rules.
|
|
258
|
+
*/
|
|
259
|
+
#installAffordances(leadingNodes, trailingNodes) {
|
|
260
|
+
const field = this.querySelector(':scope > [slot="field"]');
|
|
261
|
+
if (!field) return;
|
|
262
|
+
|
|
263
|
+
if (leadingNodes.length) {
|
|
264
|
+
// Insertion point: after [slot="label"] if present, else after
|
|
265
|
+
// [slot="prefix"] if present, else as the first child. Walk the
|
|
266
|
+
// anchor forward so author-order is preserved (first node lands
|
|
267
|
+
// earliest in DOM).
|
|
268
|
+
const start =
|
|
269
|
+
field.querySelector(':scope > [slot="label"]') ||
|
|
270
|
+
field.querySelector(':scope > [slot="prefix"]');
|
|
271
|
+
if (start) {
|
|
272
|
+
let anchor = start;
|
|
273
|
+
for (const node of leadingNodes) {
|
|
274
|
+
anchor.after(node);
|
|
275
|
+
anchor = node;
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
// No prefix/label — prepend in reverse so the FIRST author-order
|
|
279
|
+
// node ends up first after all prepends complete.
|
|
280
|
+
for (const node of leadingNodes.slice().reverse()) field.prepend(node);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (trailingNodes.length) {
|
|
285
|
+
// Insertion point: after [slot="suffix"] if present, else after
|
|
286
|
+
// [slot="text"], but always before [slot="controls"] (number-mode
|
|
287
|
+
// stepper column). Insert in author order: walk the anchor forward
|
|
288
|
+
// for each node so the first author-order node lands first in DOM.
|
|
289
|
+
const controls = field.querySelector(':scope > [slot="controls"]');
|
|
290
|
+
const after =
|
|
291
|
+
field.querySelector(':scope > [slot="suffix"]') ||
|
|
292
|
+
field.querySelector(':scope > [slot="text"]');
|
|
293
|
+
if (after) {
|
|
294
|
+
let anchor = after;
|
|
295
|
+
for (const node of trailingNodes) {
|
|
296
|
+
anchor.after(node);
|
|
297
|
+
anchor = node;
|
|
298
|
+
}
|
|
299
|
+
} else if (controls) {
|
|
300
|
+
let anchor = controls.previousElementSibling || controls;
|
|
301
|
+
for (const node of trailingNodes) {
|
|
302
|
+
anchor.after(node);
|
|
303
|
+
anchor = node;
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
for (const node of trailingNodes) field.appendChild(node);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
211
311
|
#promoteAffixes() {
|
|
212
312
|
if (!this.isConnected) return;
|
|
213
313
|
for (const which of ['prefix', 'suffix']) {
|
|
@@ -227,13 +227,13 @@
|
|
|
227
227
|
],
|
|
228
228
|
"slots": {
|
|
229
229
|
"leading": {
|
|
230
|
-
"description": "Leading
|
|
230
|
+
"description": "Leading affordance slot, inside the field chrome, before the\nvalue. Sized to chrome height. Author `<button-ui slot=\"leading\"\nicon=\"...\" variant=\"ghost\" size=\"sm\">` (or any inline element)\nfor inline actions before the value — e.g. a link-icon button\nnext to a URL input. Wired §199 v0.5.7."
|
|
231
231
|
},
|
|
232
232
|
"text": {
|
|
233
233
|
"description": "Contenteditable text surface for user input"
|
|
234
234
|
},
|
|
235
235
|
"trailing": {
|
|
236
|
-
"description": "Trailing
|
|
236
|
+
"description": "Trailing affordance slot, inside the field chrome, after the\nvalue (and after [slot=\"suffix\"] if present). Sized to chrome\nheight. Author `<button-ui slot=\"trailing\" icon=\"...\"\nvariant=\"ghost\" size=\"sm\" aria-label=\"...\">` for inline actions\nlike copy / clear / open-in-modal. For trailing text/icon\nlabels (e.g. \"Light\" in a theme picker), use the `suffix` prop\ninstead — affordance slots are for interactive buttons, not\ntext. Wired §199 v0.5.7."
|
|
237
237
|
}
|
|
238
238
|
},
|
|
239
239
|
"states": [
|
|
@@ -268,6 +268,63 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
|
|
|
268
268
|
margin-inline-start: auto;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/* §199 (v0.5.7) — Leading + trailing affordance slots inside the field.
|
|
272
|
+
Consumer authors `<button-ui slot="leading|trailing">` as a direct
|
|
273
|
+
child of <input-ui>; class.js#installAffordances moves it into
|
|
274
|
+
[slot="field"] at the right insertion point on connected(). CSS
|
|
275
|
+
normalizes the chrome here.
|
|
276
|
+
|
|
277
|
+
The yaml declared these slots since v1 ("sized to --content-height,
|
|
278
|
+
collapses text inline padding when present") but no shell rule ever
|
|
279
|
+
wired them — the canonical schema-vs-impl gap that motivated audit
|
|
280
|
+
slot 19 (§192 v0.5.6). §199 closes the gap for input-ui specifically. */
|
|
281
|
+
|
|
282
|
+
[slot="field"] > [slot="leading"],
|
|
283
|
+
[slot="field"] > [slot="trailing"] {
|
|
284
|
+
flex-shrink: 0;
|
|
285
|
+
/* Sized to chrome height per yaml contract. The button-ui or icon-ui
|
|
286
|
+
child receives the sizing tokens; we just constrain the slot box
|
|
287
|
+
and align it to the field's baseline. */
|
|
288
|
+
align-self: stretch;
|
|
289
|
+
display: inline-flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
/* Inline padding moves from [slot="text"] (handled by the field's px)
|
|
292
|
+
to the slot wrapper, so the button-ui sits flush with the field
|
|
293
|
+
chrome edge instead of inheriting the field's px gap. Authors who
|
|
294
|
+
want a gap between value and trailing affordance use [slot="suffix"]
|
|
295
|
+
for the text — the auto-margin on suffix pushes everything after it
|
|
296
|
+
to the inline-end edge naturally. */
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* Default sizing for `<button-ui>` children of the affordance slots.
|
|
300
|
+
Token-driven so consumers can override via standard --button-* hooks.
|
|
301
|
+
Without these defaults, a vanilla `<button-ui slot="trailing" icon="...">`
|
|
302
|
+
would render at button-ui's default --button-height (40px md) and
|
|
303
|
+
blow out the input chrome. We bind to the field's content height
|
|
304
|
+
instead so the button visually matches the input's intrinsic size. */
|
|
305
|
+
[slot="field"] > [slot="leading"] button-ui,
|
|
306
|
+
[slot="field"] > [slot="trailing"] button-ui {
|
|
307
|
+
--button-height: calc(var(--input-height) - 4px);
|
|
308
|
+
--button-bg: transparent;
|
|
309
|
+
--button-border: transparent;
|
|
310
|
+
--button-fg: var(--input-affix-fg);
|
|
311
|
+
--button-px: var(--a-space-1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
[slot="field"] > [slot="leading"] button-ui:hover,
|
|
315
|
+
[slot="field"] > [slot="trailing"] button-ui:hover {
|
|
316
|
+
--button-bg: var(--a-ui-bg-hover);
|
|
317
|
+
--button-fg: var(--a-fg);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* When a trailing affordance is present in number mode, reserve room
|
|
321
|
+
for the stepper column so it doesn't overlap. (controls is
|
|
322
|
+
absolutely positioned at inset-inline-end: 0; trailing sits in the
|
|
323
|
+
flex flow.) Same pattern as [slot="suffix"] in number mode. */
|
|
324
|
+
[data-number]:has(> [slot="trailing"]) > [slot="trailing"] {
|
|
325
|
+
margin-inline-end: var(--input-controls-width, calc(var(--input-height) * 0.7));
|
|
326
|
+
}
|
|
327
|
+
|
|
271
328
|
/* Disabled */
|
|
272
329
|
:scope[disabled] [slot="field"] {
|
|
273
330
|
background: var(--input-bg-disabled);
|
|
@@ -58,4 +58,6 @@ export class UIInput extends UIFormElement {
|
|
|
58
58
|
): void;
|
|
59
59
|
addEventListener(type: 'change', listener: (ev: InputChangeEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
|
|
60
60
|
addEventListener(type: 'input', listener: (ev: InputInputEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
|
|
61
|
+
/** §207 (v0.5.7): Enter-key + native form-submit semantics; dispatched bubbling. */
|
|
62
|
+
addEventListener(type: 'submit', listener: (ev: Event) => unknown, options?: boolean | AddEventListenerOptions): void;
|
|
61
63
|
}
|