@adia-ai/web-components 0.0.14 → 0.0.16

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 (41) hide show
  1. package/README.md +43 -1
  2. package/components/alert/alert.css +5 -0
  3. package/components/alert/alert.js +4 -2
  4. package/components/button/button.js +4 -1
  5. package/components/chart/chart.js +7 -4
  6. package/components/chat/chat-input.js +13 -2
  7. package/components/description-list/description-list.js +4 -3
  8. package/components/field/field.css +113 -63
  9. package/components/field/field.js +44 -142
  10. package/components/icon/icon.a2ui.json +1 -1
  11. package/components/icon/icon.css +16 -0
  12. package/components/icon/icon.js +18 -0
  13. package/components/icon/icon.yaml +6 -2
  14. package/components/index.js +7 -0
  15. package/components/input/input.a2ui.json +1 -1
  16. package/components/input/input.css +21 -23
  17. package/components/input/input.js +36 -9
  18. package/components/input/input.yaml +3 -1
  19. package/components/option-card/option-card.a2ui.json +262 -0
  20. package/components/option-card/option-card.css +215 -0
  21. package/components/option-card/option-card.js +158 -0
  22. package/components/option-card/option-card.yaml +234 -0
  23. package/components/rating/rating.a2ui.json +10 -0
  24. package/components/rating/rating.yaml +8 -0
  25. package/components/segment/segment.a2ui.json +5 -0
  26. package/components/segment/segment.css +2 -0
  27. package/components/segment/segment.js +21 -1
  28. package/components/segment/segment.yaml +5 -0
  29. package/components/textarea/textarea.css +3 -1
  30. package/components/textarea/textarea.js +2 -2
  31. package/components/tooltip/tooltip.js +10 -3
  32. package/core/data-stream.js +486 -0
  33. package/core/form.js +5 -0
  34. package/core/index.js +2 -0
  35. package/core/streams-bridge.js +96 -0
  36. package/package.json +1 -1
  37. package/styles/colors/semantics.css +21 -3
  38. package/styles/components.css +1 -0
  39. package/styles/prose.css +3 -7
  40. package/styles/tokens.css +7 -4
  41. package/styles/typography.css +6 -1
@@ -11,37 +11,26 @@
11
11
  * and action regions. The host renders a real `<label for="…">` bound
12
12
  * to the slotted control's id (auto-minted when missing) so clicking
13
13
  * the label focuses the control — an affordance the previous
14
- * `<input-ui label="…">` pattern lacked (no `for` attr, just a shadow
15
- * slot).
14
+ * `<input-ui label="…">` pattern lacked.
16
15
  *
17
- * ### Layout — three internal flex rows
16
+ * ### Layout — single grid, slot-positioned
18
17
  *
19
- * field-ui owns three row wrappers inside its light DOM. Children are
20
- * reparented into the row that matches their role so each row's cell
21
- * widths are independent:
18
+ * field-ui is a CSS grid; children are placed by `[slot]` attribute:
22
19
  *
23
- * <div data-row="label"> label [+required marker] [+trailing] </div>
24
- * <div data-row="control"> control [+action] </div>
25
- * <div data-row="message"> hint or error </div>
20
+ * slot="label" label cell (mounted by field-ui)
21
+ * slot="trailing" → row 1, right
22
+ * (no slot) → control cell
23
+ * slot="action" → adjacent to control
24
+ * slot="hint" → message row (mounted by field-ui)
25
+ * slot="error" → message row (mounted by field-ui)
26
26
  *
27
- * Per-row flex means `trailing` no longer dictates the width of
28
- * `action` (or vice versa). In the previous 2-column grid, column 2
29
- * was shared across rows, so `trailing="Required"` pushed the
30
- * `action` cell wider than the button needed. The three-row split
31
- * fixes that.
27
+ * The template adapts (via `:has()`) to which slots are present so empty
28
+ * tracks don't leak column-gap. Inline mode flattens the content slots
29
+ * onto a single row by retuning columns; message stays below.
32
30
  *
33
- * ### Inline mode
34
- *
35
- * `inline` collapses the three rows into a grid where label, trailing,
36
- * control, and action share one row and the message row sits below.
37
- * CSS uses `display: contents` on the label-row and control-row
38
- * wrappers in inline mode so their children flow directly into the
39
- * single grid row; no DOM restructure needed for the mode switch.
40
- *
41
- * A MutationObserver re-categorizes and re-parents children when the
42
- * slotted tree changes (e.g. the A2UI renderer swapping the control
43
- * during doc updates). Self-triggered mutations from our own
44
- * reparenting are debounced via a guard flag to prevent a loop.
31
+ * No DOM reparenting; CSS positions children directly. A small
32
+ * MutationObserver watches childList so a swapped-in control gets its
33
+ * `for=` and `aria-describedby` rebound.
45
34
  */
46
35
  import { AdiaElement } from '../../core/element.js';
47
36
 
@@ -60,41 +49,32 @@ class AdiaField extends AdiaElement {
60
49
  #labelMark = null;
61
50
  #hintEl = null;
62
51
  #errorEl = null;
63
- #labelRow = null;
64
- #controlRow = null;
65
- #messageRow = null;
66
52
  #mo = null;
67
- #restructuring = false; // guard against MutationObserver self-trigger
68
53
 
69
- // Label click handler the native <label for="x"> affordance calls
70
- // .focus() on the element with id "x". For a custom-element control
71
- // like input-ui, that focuses the shadow/light host (tabindex=-1 by
72
- // default), so focus falls through to body. Work around by finding
73
- // the first focusable descendant inside the slotted control and
74
- // focusing it explicitly.
54
+ // Label click focus the first focusable descendant of the slotted
55
+ // control. The native `<label for="x">` affordance focuses the host
56
+ // by id, which for a custom-element control falls through to body
57
+ // (host typically tabindex=-1).
75
58
  #onLabelClick = (e) => {
76
59
  const ctrl = this.#findControl();
77
60
  if (!ctrl) return;
78
61
  const focusable = this.#firstFocusable(ctrl);
79
62
  if (!focusable) return;
80
- e.preventDefault(); // prevent the native <label for> focus-host fallback
63
+ e.preventDefault();
81
64
  focusable.focus();
82
65
  };
83
66
 
84
67
  connected() {
85
- this.#ensureRowWrappers();
86
68
  this.#ensureLabelElement();
87
69
  this.#ensureHintElement();
88
70
  this.#ensureErrorElement();
89
- this.#restructure();
90
71
  this.#bindForToControl();
72
+ this.#labelEl?.addEventListener('click', this.#onLabelClick);
91
73
  this.#mo = new MutationObserver(() => {
92
- if (this.#restructuring) return;
93
- this.#restructure();
94
74
  this.#bindForToControl();
75
+ this.#wireAriaDescribedBy();
95
76
  });
96
- this.#mo.observe(this, { childList: true, subtree: false });
97
- this.#labelEl?.addEventListener('click', this.#onLabelClick);
77
+ this.#mo.observe(this, { childList: true });
98
78
  }
99
79
 
100
80
  disconnected() {
@@ -105,14 +85,12 @@ class AdiaField extends AdiaElement {
105
85
  this.#labelMark = null;
106
86
  this.#hintEl = null;
107
87
  this.#errorEl = null;
108
- this.#labelRow = null;
109
- this.#controlRow = null;
110
- this.#messageRow = null;
111
88
  }
112
89
 
113
90
  render() {
114
- // Keep label / hint / error text in sync with reactive props.
115
- if (this.#labelEl) this.#labelEl.childNodes[0] && (this.#labelEl.childNodes[0].nodeValue = this.label || '');
91
+ if (this.#labelEl) {
92
+ this.#labelEl.childNodes[0] && (this.#labelEl.childNodes[0].nodeValue = this.label || '');
93
+ }
116
94
  if (this.#labelMark) this.#labelMark.hidden = !this.required;
117
95
  if (this.#hintEl) {
118
96
  this.#hintEl.textContent = this.hint || '';
@@ -122,50 +100,24 @@ class AdiaField extends AdiaElement {
122
100
  this.#errorEl.textContent = this.error || '';
123
101
  this.#errorEl.hidden = !this.error;
124
102
  }
125
- // Collapse the message row entirely when both are empty so the
126
- // field's trailing gap doesn't stick out.
127
- if (this.#messageRow) {
128
- this.#messageRow.hidden = !this.hint && !this.error;
129
- }
130
103
  this.#wireAriaDescribedBy();
131
104
  }
132
105
 
133
106
  // ── Private ───────────────────────────────────────────────────────
134
107
 
135
- #ensureRowWrappers() {
136
- this.#labelRow = this.querySelector(':scope > [data-row="label"]');
137
- this.#controlRow = this.querySelector(':scope > [data-row="control"]');
138
- this.#messageRow = this.querySelector(':scope > [data-row="message"]');
139
- if (!this.#labelRow) {
140
- this.#labelRow = this.#mkRow('label');
141
- this.appendChild(this.#labelRow);
142
- }
143
- if (!this.#controlRow) {
144
- this.#controlRow = this.#mkRow('control');
145
- this.appendChild(this.#controlRow);
146
- }
147
- if (!this.#messageRow) {
148
- this.#messageRow = this.#mkRow('message');
149
- this.appendChild(this.#messageRow);
150
- }
151
- }
152
-
153
- #mkRow(name) {
154
- const d = document.createElement('div');
155
- d.setAttribute('data-row', name);
156
- return d;
157
- }
158
-
159
108
  #ensureLabelElement() {
160
- this.#labelEl = this.querySelector(':scope [data-field-label]');
109
+ this.#labelEl = this.querySelector(':scope > [data-field-label]');
161
110
  if (!this.#labelEl) {
162
111
  const el = document.createElement('label');
112
+ el.setAttribute('slot', 'label');
163
113
  el.setAttribute('data-field-label', '');
164
114
  el.appendChild(document.createTextNode(this.label || ''));
115
+ // Prepend so source-order matches visual order (label before
116
+ // control). Not strictly required since CSS uses grid-area, but
117
+ // it keeps DevTools / a11y tree readings sensible.
118
+ this.prepend(el);
165
119
  this.#labelEl = el;
166
120
  }
167
- // Persistent required marker — hidden when !required. Lives as the
168
- // label's last child so the stream reads "<label>*</label>".
169
121
  this.#labelMark = this.#labelEl.querySelector('[data-field-required]');
170
122
  if (!this.#labelMark) {
171
123
  const mark = document.createElement('span');
@@ -179,71 +131,30 @@ class AdiaField extends AdiaElement {
179
131
  }
180
132
 
181
133
  #ensureHintElement() {
182
- this.#hintEl = this.querySelector(':scope [data-field-hint]');
134
+ this.#hintEl = this.querySelector(':scope > [data-field-hint]');
183
135
  if (this.#hintEl) return;
184
136
  const el = document.createElement('div');
137
+ el.setAttribute('slot', 'hint');
185
138
  el.setAttribute('data-field-hint', '');
186
139
  el.setAttribute('id', AdiaField.#nextMsgId('hint'));
187
140
  el.hidden = true;
141
+ this.appendChild(el);
188
142
  this.#hintEl = el;
189
143
  }
190
144
 
191
145
  #ensureErrorElement() {
192
- this.#errorEl = this.querySelector(':scope [data-field-error]');
146
+ this.#errorEl = this.querySelector(':scope > [data-field-error]');
193
147
  if (this.#errorEl) return;
194
148
  const el = document.createElement('div');
149
+ el.setAttribute('slot', 'error');
195
150
  el.setAttribute('data-field-error', '');
196
151
  el.setAttribute('id', AdiaField.#nextMsgId('err'));
197
152
  el.setAttribute('role', 'alert');
198
153
  el.hidden = true;
154
+ this.appendChild(el);
199
155
  this.#errorEl = el;
200
156
  }
201
157
 
202
- /** Sort light-DOM children into the appropriate row wrapper.
203
- * Idempotent — no-ops when a child already sits in its target row. */
204
- #restructure() {
205
- this.#restructuring = true;
206
- try {
207
- // Collect any children that are not already row wrappers or our
208
- // managed elements. `Array.from` is snapshotted — moves during
209
- // iteration don't re-index.
210
- const loose = [...this.children].filter(
211
- (ch) => ch !== this.#labelRow && ch !== this.#controlRow && ch !== this.#messageRow,
212
- );
213
- for (const ch of loose) {
214
- this.#moveToRow(ch);
215
- }
216
- // Then relocate managed elements (they may have been created in
217
- // a previous tick and still sit at :scope level).
218
- if (this.#labelEl && this.#labelEl.parentElement !== this.#labelRow) {
219
- this.#labelRow.prepend(this.#labelEl);
220
- }
221
- if (this.#hintEl && this.#hintEl.parentElement !== this.#messageRow) {
222
- this.#messageRow.appendChild(this.#hintEl);
223
- }
224
- if (this.#errorEl && this.#errorEl.parentElement !== this.#messageRow) {
225
- this.#messageRow.appendChild(this.#errorEl);
226
- }
227
- // Ensure the row order is label → control → message even after
228
- // mutations re-append things.
229
- if (this.lastElementChild !== this.#messageRow) {
230
- this.appendChild(this.#messageRow);
231
- }
232
- } finally {
233
- this.#restructuring = false;
234
- }
235
- }
236
-
237
- #moveToRow(ch) {
238
- const slot = ch.getAttribute?.('slot');
239
- const target =
240
- ch.matches?.('[data-field-label]') ? this.#labelRow :
241
- slot === 'trailing' ? this.#labelRow :
242
- ch.matches?.('[data-field-hint], [data-field-error]') ? this.#messageRow :
243
- /* everything else (control, slot=action) */ this.#controlRow;
244
- if (ch.parentElement !== target) target.appendChild(ch);
245
- }
246
-
247
158
  #bindForToControl() {
248
159
  if (!this.#labelEl) return;
249
160
  const control = this.#findControl();
@@ -270,20 +181,17 @@ class AdiaField extends AdiaElement {
270
181
  else ctrl.removeAttribute('aria-describedby');
271
182
  }
272
183
 
273
- /** The default-slot control — first child of control-row that isn't
274
- * assigned to `[slot="action"]`. */
184
+ /** The default-slot control — first child without a `slot` attribute. */
275
185
  #findControl() {
276
- if (!this.#controlRow) return null;
277
- for (const ch of this.#controlRow.children) {
278
- if (ch.getAttribute('slot') === 'action') continue;
279
- return ch;
186
+ for (const ch of this.children) {
187
+ if (!ch.hasAttribute('slot')) return ch;
280
188
  }
281
189
  return null;
282
190
  }
283
191
 
284
192
  /** First focusable descendant (or self) of `root`. Looks for native
285
193
  * `<input>`, `<textarea>`, `<select>`, `<button>`, `[contenteditable]`,
286
- * and any `[tabindex]` ≥ 0. Traverses light DOM only. */
194
+ * and any `[tabindex]` ≥ 0. Light DOM only. */
287
195
  #firstFocusable(root) {
288
196
  const SEL = 'input, textarea, select, button, [contenteditable], [tabindex]:not([tabindex="-1"])';
289
197
  if (root.matches?.(SEL)) return root;
@@ -291,15 +199,9 @@ class AdiaField extends AdiaElement {
291
199
  }
292
200
 
293
201
  static #idSeq = 0;
294
- static #nextId() {
295
- AdiaField.#idSeq += 1;
296
- return `field-ctl-${AdiaField.#idSeq}`;
297
- }
202
+ static #nextId() { return `field-ctl-${++AdiaField.#idSeq}`; }
298
203
  static #msgSeq = 0;
299
- static #nextMsgId(kind) {
300
- AdiaField.#msgSeq += 1;
301
- return `field-${kind}-${AdiaField.#msgSeq}`;
302
- }
204
+ static #nextMsgId(kind) { return `field-${kind}-${++AdiaField.#msgSeq}`; }
303
205
  }
304
206
 
305
207
  customElements.define('field-ui', AdiaField);
@@ -27,7 +27,7 @@
27
27
  "default": ""
28
28
  },
29
29
  "size": {
30
- "description": "Icon size. Accepts the named scale (xs/sm/md/lg/xl) or a pixel value as a string (\"12\", \"16\", \"24\", \"32\", \"48\"). Overrides --a-icon-size.",
30
+ "description": "Icon size. Accepts the named scale (`xs` 12px / `sm` 14px / `md` 16px / `lg` 20px / `xl` 32px / `2xl` 48px / `3xl` 64px / `4xl` 96px / `fill` 100% of parent) or a free-form pixel / rem / em value as a string (\"48\", \"3rem\", \"1.25em\"). Overrides the inherited `--a-icon-size` from the universal `[size]` system on ancestors.",
31
31
  "type": "string",
32
32
  "default": ""
33
33
  },
@@ -17,4 +17,20 @@
17
17
  width: 100%;
18
18
  height: 100%;
19
19
  }
20
+
21
+ /* ── Named size scale ─────────────────────────────────────────────
22
+ sm/md/lg are also driven by the universal `[size]` token system
23
+ on ancestors (tokens.css §SIZE PRESETS) — these rules let an
24
+ icon-ui set its own size locally. xs / xl / 2xl / 3xl / 4xl
25
+ extend the scale beyond the universal range for hero placeholder
26
+ contexts. `fill` matches the parent box. */
27
+ :scope[size="xs"] { --icon-size: 0.75rem; } /* 12px */
28
+ :scope[size="sm"] { --icon-size: 0.875rem; } /* 14px */
29
+ :scope[size="md"] { --icon-size: 1rem; } /* 16px */
30
+ :scope[size="lg"] { --icon-size: 1.25rem; } /* 20px */
31
+ :scope[size="xl"] { --icon-size: 2rem; } /* 32px */
32
+ :scope[size="2xl"] { --icon-size: 3rem; } /* 48px */
33
+ :scope[size="3xl"] { --icon-size: 4rem; } /* 64px */
34
+ :scope[size="4xl"] { --icon-size: 6rem; } /* 96px */
35
+ :scope[size="fill"] { --icon-size: 100%; }
20
36
  }
@@ -19,6 +19,24 @@ class AdiaIcon extends AdiaElement {
19
19
  if (this.label) this.setAttribute('aria-label', this.label);
20
20
  const svg = getIcon(this.name, this.weight || 'regular');
21
21
  if (svg && this.innerHTML !== svg) this.innerHTML = svg;
22
+
23
+ // Pixel / rem / em passthrough — `size="48"` → 48px,
24
+ // `size="3.5rem"` → 3.5rem. Named-scale values (xs/sm/md/lg/xl/
25
+ // 2xl/3xl/4xl/fill) are handled by `:scope[size="…"]` rules in
26
+ // icon.css; we only set --icon-size inline for free-form values.
27
+ if (this.size && this.#isFreeFormSize(this.size)) {
28
+ this.style.setProperty('--icon-size', this.#normalizeSize(this.size));
29
+ } else if (this.style.getPropertyValue('--icon-size')) {
30
+ this.style.removeProperty('--icon-size');
31
+ }
32
+ }
33
+
34
+ #isFreeFormSize(s) {
35
+ return /^\d+(\.\d+)?(px|rem|em)?$/.test(s);
36
+ }
37
+
38
+ #normalizeSize(s) {
39
+ return /^\d+(\.\d+)?$/.test(s) ? `${s}px` : s;
22
40
  }
23
41
  }
24
42
  customElements.define('icon-ui', AdiaIcon);
@@ -18,8 +18,12 @@ props:
18
18
  default: ""
19
19
  size:
20
20
  description: >-
21
- Icon size. Accepts the named scale (xs/sm/md/lg/xl) or a pixel value
22
- as a string ("12", "16", "24", "32", "48"). Overrides --a-icon-size.
21
+ Icon size. Accepts the named scale
22
+ (`xs` 12px / `sm` 14px / `md` 16px / `lg` 20px / `xl` 32px /
23
+ `2xl` 48px / `3xl` 64px / `4xl` 96px / `fill` 100% of parent)
24
+ or a free-form pixel / rem / em value as a string ("48", "3rem",
25
+ "1.25em"). Overrides the inherited `--a-icon-size` from the
26
+ universal `[size]` system on ancestors.
23
27
  type: string
24
28
  default: ""
25
29
  weight:
@@ -3,12 +3,19 @@
3
3
  * Import this single file to register all custom elements.
4
4
  */
5
5
 
6
+ /* Side-effect import — auto-attaches the document-level MutationObserver
7
+ that drives `data-stream-*` attribute behavior on any element with a
8
+ settable `.data` property (chart-ui, table-ui, heatmap-ui, stat-ui,
9
+ list-ui). See packages/web-components/core/data-stream.js. */
10
+ import '../core/data-stream.js';
11
+
6
12
  export { AdiaIcon } from './icon/icon.js';
7
13
  export { AdiaButton } from './button/button.js';
8
14
  export { AdiaInput } from './input/input.js';
9
15
  export { AdiaTextarea } from './textarea/textarea.js';
10
16
  export { AdiaCheck } from './check/check.js';
11
17
  export { AdiaRadio } from './radio/radio.js';
18
+ export { AdiaOptionCard } from './option-card/option-card.js';
12
19
  export { AdiaSwitch } from './switch/switch.js';
13
20
  export { AdiaSlider } from './slider/slider.js';
14
21
  export { AdiaSelect } from './select/select.js';
@@ -52,7 +52,7 @@
52
52
  "default": ""
53
53
  },
54
54
  "label": {
55
- "description": "Label text rendered above the input field",
55
+ "description": "Inline label rendered as a leading caption inside the input chrome, between any prefix and the value. Wires aria-labelledby on the editable surface. For stacked label / hint / error compositions, wrap with field-ui.",
56
56
  "type": "string",
57
57
  "default": ""
58
58
  },
@@ -11,11 +11,8 @@
11
11
  --input-height: var(--a-size);
12
12
  --input-px: var(--a-ui-px);
13
13
  --input-font-size: var(--a-ui-size);
14
- --input-label-size: var(--a-label-size);
15
- --input-label-fg: var(--a-label-color);
16
14
  --input-placeholder-fg: var(--a-ui-text-placeholder);
17
15
  --input-affix-fg: var(--a-ui-text-placeholder);
18
- --input-gap: var(--a-ui-py);
19
16
  --input-field-gap: var(--a-space-1);
20
17
 
21
18
  /* ── Transitions ── */
@@ -23,10 +20,10 @@
23
20
  --input-easing: var(--a-easing);
24
21
 
25
22
  /* ── State: hover/focus ── */
23
+ --input-bg-hover: var(--a-ui-bg-hover);
26
24
  --input-fg-hover: var(--a-fg);
27
25
  --input-affix-fg-hover: var(--a-fg-subtle);
28
- --input-fg-focus: var(--a-fg);
29
- --input-label-fg-focus: var(--a-fg-subtle);
26
+ --input-fg-focus: var(--a-fg-strong);
30
27
 
31
28
  /* ── State: disabled ── */
32
29
  --input-bg-disabled: var(--a-ui-bg-disabled);
@@ -36,23 +33,20 @@
36
33
  :scope {
37
34
  /* ── Base ── */
38
35
  box-sizing: border-box;
39
- display: flex;
40
- flex-direction: column;
41
- gap: var(--input-gap);
36
+ display: block;
42
37
  }
43
38
 
44
- :scope[data-direction="row"] {
45
- display: grid;
46
- grid-template-columns: 1fr 1fr;
47
- align-items: center;
48
- }
49
-
50
- /* Label */
39
+ /* Inline label — sits inside [slot="field"] as a leading caption,
40
+ between [slot="prefix"] and [slot="text"]. Dim by default; shares
41
+ the input chrome border. For stacked label / hint / error, wrap
42
+ with field-ui. */
51
43
  [slot="label"] {
52
- font-size: var(--input-label-size);
53
- color: var(--input-label-fg);
44
+ flex-shrink: 0;
45
+ color: var(--input-affix-fg);
46
+ font-size: var(--input-font-size);
47
+ user-select: none;
48
+ pointer-events: none;
54
49
  }
55
- [slot="label"][label]::after { content: attr(label); }
56
50
 
57
51
  /* Field container */
58
52
  [slot="field"] {
@@ -67,11 +61,18 @@
67
61
  color: var(--input-fg);
68
62
  font: inherit;
69
63
  font-size: var(--input-font-size);
70
- line-height: 1;
64
+ /* line-height: 1.4 (not 1) so descender-bearing glyphs (g, j, p, q, y)
65
+ have room inside [slot="text"]'s line box. With line-height: 1 the
66
+ line box equals the em-square and descenders extend below it; the
67
+ overflow: hidden on [slot="text"] then clips them. The min-height
68
+ still controls overall chrome height — align-items: center keeps the
69
+ baseline centered regardless of line-height. */
70
+ line-height: 1.4;
71
71
  cursor: text;
72
72
  transition: border-color var(--input-duration) var(--input-easing);
73
73
  }
74
74
  :scope:not([disabled]) [slot="field"]:hover {
75
+ background: var(--input-bg-hover);
75
76
  border-color: var(--input-border-hover);
76
77
  color: var(--input-fg-hover);
77
78
  }
@@ -90,9 +91,6 @@
90
91
  :scope[error]:not([disabled]):focus-within [slot="field"] {
91
92
  box-shadow: var(--input-focus-ring-invalid);
92
93
  }
93
- :scope:not([disabled]):focus-within [slot="label"] {
94
- color: var(--input-label-fg-focus);
95
- }
96
94
 
97
95
  /* Text (contenteditable span) */
98
96
  [slot="text"] {
@@ -120,7 +118,7 @@
120
118
 
121
119
  /* Placeholder (contenteditable only) */
122
120
  span[slot="text"][data-empty]::before {
123
- content: attr(aria-placeholder);
121
+ content: attr(data-placeholder);
124
122
  color: var(--input-placeholder-fg);
125
123
  pointer-events: none;
126
124
  }
@@ -2,9 +2,17 @@
2
2
  * <input-ui> — Text input. The host IS the interactive surface.
3
3
  * Uses contenteditable for text entry, ElementInternals for form participation.
4
4
  *
5
- * Supports prefix/suffix slots:
6
- * <input-ui label="Weight" suffix="kg"></input-ui>
7
- * <input-ui placeholder="Search" prefix="search"></input-ui>
5
+ * Slots inside [slot="field"]:
6
+ * prefix label → text → suffix
7
+ *
8
+ * <input-ui label="Email" placeholder="you@acme.com"></input-ui>
9
+ * <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
10
+ * <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
11
+ * <input-ui prefix="@" value="kim"></input-ui>
12
+ *
13
+ * label renders as a dim leading caption inside the chrome (next to the
14
+ * value, sharing the input's border) — for stacked label / hint / error
15
+ * compositions, wrap with field-ui.
8
16
  */
9
17
 
10
18
  import { AdiaFormElement } from '../../core/form.js';
@@ -15,6 +23,13 @@ const renderAffix = (v) => isIconName(v)
15
23
  : v;
16
24
 
17
25
  class AdiaInput extends AdiaFormElement {
26
+ // Opt out of AdiaFormElement's per-control `label` deprecation warning.
27
+ // input-ui's `label` is a first-class API rendering an inline-leading
28
+ // caption inside the chrome with `aria-labelledby` wiring on the
29
+ // editable surface — not the inert above-the-field rendering that
30
+ // motivated the deprecation.
31
+ static labelDeprecated = false;
32
+
18
33
  static properties = {
19
34
  ...AdiaFormElement.properties,
20
35
  placeholder: { type: String, default: '', reflect: true },
@@ -28,6 +43,8 @@ class AdiaInput extends AdiaFormElement {
28
43
  static template = () => null;
29
44
 
30
45
  #textEl = null;
46
+ #labelEl = null;
47
+ static #labelSeq = 0;
31
48
 
32
49
  get #isNativeInput() {
33
50
  return this.type === 'password' || this.type === 'number';
@@ -39,24 +56,28 @@ class AdiaInput extends AdiaFormElement {
39
56
 
40
57
  if (!this.querySelector('[slot="text"]')) {
41
58
  const useNative = this.#isNativeInput;
59
+ const labelId = this.label ? `input-label-${++AdiaInput.#labelSeq}` : '';
42
60
  this.innerHTML = `
43
- ${this.label ? `<label slot="label" label="${this.label}"></label>` : ''}
44
61
  <div slot="field">
45
62
  ${this.prefix ? `<span slot="prefix">${renderAffix(this.prefix)}</span>` : ''}
63
+ ${this.label ? `<span slot="label" id="${labelId}">${this.label}</span>` : ''}
46
64
  ${useNative
47
65
  ? `<input slot="text" type="${this.type}" tabindex="0"
48
66
  placeholder="${this.placeholder}" value="${this.value || ''}"
49
67
  autocomplete="${this.type === 'password' ? 'current-password' : 'off'}"
68
+ ${labelId ? `aria-labelledby="${labelId}"` : ''}
50
69
  ${this.disabled ? 'disabled' : ''} ${this.readonly ? 'readonly' : ''} />`
51
70
  : `<span slot="text" contenteditable="plaintext-only" tabindex="0"
52
71
  ${this.value ? '' : 'data-empty=""'}
53
- aria-placeholder="${this.placeholder}"></span>`}
72
+ ${labelId ? `aria-labelledby="${labelId}"` : ''}
73
+ data-placeholder="${this.placeholder}"></span>`}
54
74
  ${this.suffix ? `<span slot="suffix">${renderAffix(this.suffix)}</span>` : ''}
55
75
  </div>
56
76
  `;
57
77
  }
58
78
 
59
79
  this.#textEl = this.querySelector('[slot="text"]');
80
+ this.#labelEl = this.querySelector('[slot="label"]');
60
81
  if (!this.#isNativeInput && this.value) this.#textEl.textContent = this.value;
61
82
 
62
83
  if (this.#textEl) {
@@ -75,7 +96,7 @@ class AdiaInput extends AdiaFormElement {
75
96
  this.#textEl.disabled = this.disabled;
76
97
  this.#textEl.readOnly = this.readonly;
77
98
  } else {
78
- this.#textEl.setAttribute('aria-placeholder', this.placeholder);
99
+ this.#textEl.setAttribute('data-placeholder', this.placeholder);
79
100
  if (this.disabled || this.readonly) {
80
101
  this.#textEl.contentEditable = 'false';
81
102
  } else {
@@ -83,10 +104,15 @@ class AdiaInput extends AdiaFormElement {
83
104
  }
84
105
  }
85
106
 
86
- const label = this.querySelector('[slot="label"]');
87
- if (label && this.label) label.setAttribute('label', this.label);
107
+ if (this.#labelEl) this.#labelEl.textContent = this.label || '';
88
108
 
89
- this.setAttribute('aria-label', this.label || this.placeholder || '');
109
+ if (this.label) {
110
+ this.removeAttribute('aria-label');
111
+ } else if (this.placeholder) {
112
+ this.setAttribute('aria-label', this.placeholder);
113
+ } else {
114
+ this.removeAttribute('aria-label');
115
+ }
90
116
  }
91
117
 
92
118
  #onInput = () => {
@@ -141,6 +167,7 @@ class AdiaInput extends AdiaFormElement {
141
167
  this.#textEl.removeEventListener('paste', this.#onPaste);
142
168
  }
143
169
  this.#textEl = null;
170
+ this.#labelEl = null;
144
171
  }
145
172
  }
146
173
  customElements.define('input-ui', AdiaInput);
@@ -47,7 +47,9 @@ props:
47
47
  type: string
48
48
  default: ""
49
49
  label:
50
- description: Label text rendered above the input field
50
+ description: Inline label rendered as a leading caption inside the input chrome,
51
+ between any prefix and the value. Wires aria-labelledby on the editable
52
+ surface. For stacked label / hint / error compositions, wrap with field-ui.
51
53
  type: string
52
54
  default: ""
53
55
  maxlength: