@adia-ai/web-components 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/components/accordion/accordion-item.a2ui.json +19 -2
  2. package/components/accordion/accordion-item.yaml +20 -0
  3. package/components/accordion/accordion.a2ui.json +1 -1
  4. package/components/accordion/accordion.yaml +1 -1
  5. package/components/accordion/class.js +5 -0
  6. package/components/agent-artifact/agent-artifact.yaml +3 -0
  7. package/components/agent-artifact/class.js +5 -0
  8. package/components/calendar-picker/calendar-picker.d.ts +10 -0
  9. package/components/code/code.d.ts +8 -0
  10. package/components/color-picker/class.js +42 -4
  11. package/components/color-picker/color-picker.test.js +96 -0
  12. package/components/color-picker/color-picker.yaml +2 -0
  13. package/components/demo-toggle/demo-toggle.a2ui.json +3 -1
  14. package/components/demo-toggle/demo-toggle.yaml +2 -0
  15. package/components/feed/feed.css +1 -1
  16. package/components/fields/fields.a2ui.json +3 -1
  17. package/components/fields/fields.yaml +2 -0
  18. package/components/input/class.js +101 -1
  19. package/components/input/input.a2ui.json +2 -2
  20. package/components/input/input.css +57 -0
  21. package/components/input/input.d.ts +2 -0
  22. package/components/input/input.test.js +123 -0
  23. package/components/input/input.yaml +15 -2
  24. package/components/nav/nav.a2ui.json +6 -1
  25. package/components/nav/nav.yaml +6 -0
  26. package/components/nav-group/nav-group.a2ui.json +5 -1
  27. package/components/nav-group/nav-group.css +1 -1
  28. package/components/nav-group/nav-group.yaml +5 -0
  29. package/components/nav-item/nav-item.a2ui.json +4 -1
  30. package/components/nav-item/nav-item.css +1 -1
  31. package/components/nav-item/nav-item.yaml +4 -0
  32. package/components/select/select.d.ts +2 -0
  33. package/components/slider/slider.d.ts +4 -0
  34. package/components/switch/switch.d.ts +2 -0
  35. package/components/table/class.js +9 -1
  36. package/components/table/table.yaml +4 -0
  37. package/components/table-toolbar/class.js +5 -0
  38. package/components/table-toolbar/table-toolbar.yaml +4 -0
  39. package/components/text/text.a2ui.json +1 -8
  40. package/components/text/text.css +13 -0
  41. package/components/text/text.d.ts +1 -1
  42. package/components/text/text.test.js +106 -0
  43. package/components/text/text.yaml +0 -7
  44. package/components/timeline/class.js +5 -0
  45. package/components/timeline/timeline.yaml +3 -0
  46. package/components/toggle-scheme/class.js +31 -0
  47. package/components/toggle-scheme/toggle-scheme.test.js +110 -0
  48. package/components/upload/upload.d.ts +6 -0
  49. package/package.json +4 -2
  50. package/styles/components.css +2 -0
  51. package/styles/typography.css +3 -1
@@ -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.
@@ -76,7 +76,7 @@
76
76
  ],
77
77
  "slots": {
78
78
  "default": {
79
- "description": "pane-ui children"
79
+ "description": "accordion-item-ui children"
80
80
  }
81
81
  },
82
82
  "states": [
@@ -25,7 +25,7 @@ events:
25
25
  description: New open state of the panel.
26
26
  slots:
27
27
  default:
28
- description: pane-ui children
28
+ description: accordion-item-ui children
29
29
  states:
30
30
  - name: idle
31
31
  description: Default, ready for interaction.
@@ -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', {
@@ -52,6 +52,9 @@ states:
52
52
  description: Default, ready for interaction.
53
53
  traits: []
54
54
  tokens: {}
55
+ requiredIcons:
56
+ - caret-right
57
+ - caret-down
55
58
  a2ui:
56
59
  rules: []
57
60
  anti_patterns: []
@@ -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;
@@ -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
- } else if (val.startsWith('oklch(')) {
422
- const m = val.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
423
- if (m) { this.#L = +m[1]; this.#C = +m[2]; this.#H = +m[3]; }
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
+ });
@@ -97,6 +97,8 @@ traits: []
97
97
  tokens: {}
98
98
  requiredIcons:
99
99
  - copy
100
+ - check
101
+ - warning
100
102
  a2ui:
101
103
  rules: []
102
104
  anti_patterns: []
@@ -53,7 +53,9 @@
53
53
  "x-adiaui": {
54
54
  "anti_patterns": [],
55
55
  "category": "container",
56
- "composes": [],
56
+ "composes": [
57
+ "switch-ui"
58
+ ],
57
59
  "events": {
58
60
  "change": {
59
61
  "description": "Fired when the toggle flips. detail contains { state }.",
@@ -11,6 +11,8 @@ description: >-
11
11
  Used on trait detail pages to show "with trait" vs "without trait" on
12
12
  the same chrome. data-mode="overlay" stacks the slots on the same
13
13
  coordinates so layout never shifts.
14
+ composes:
15
+ - switch-ui
14
16
  props:
15
17
  labelOn:
16
18
  description: Header label rendered when state is "on".
@@ -133,7 +133,7 @@ feed-item-ui[data-closing] {
133
133
  gap: 0.125rem;
134
134
  }
135
135
  :scope > [slot="body"] strong {
136
- font-weight: var(--a-font-weight-strong, 600);
136
+ font-weight: var(--a-weight-semibold);
137
137
  }
138
138
  :scope > [data-feed-close] {
139
139
  flex-shrink: 0;
@@ -43,7 +43,9 @@
43
43
  }
44
44
  ],
45
45
  "category": "form",
46
- "composes": [],
46
+ "composes": [
47
+ "field-ui"
48
+ ],
47
49
  "events": {},
48
50
  "examples": [
49
51
  {
@@ -14,6 +14,8 @@ description: >-
14
14
  The grid alignment lets siblings on the same row line up cleanly —
15
15
  consistent label columns + consistent control columns — without
16
16
  the wrap-flex jitter of <row-ui wrap>.
17
+ composes:
18
+ - field-ui
17
19
  props:
18
20
  inline:
19
21
  description: >-
@@ -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 icon slot, sized to --content-height. Collapses text inline padding when present."
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 icon slot, sized to --content-height. Collapses text inline padding when present."
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
  }