@adia-ai/web-components 0.0.15 → 0.0.17

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 (43) hide show
  1. package/components/alert/alert.css +5 -0
  2. package/components/alert/alert.js +4 -2
  3. package/components/button/button.js +4 -1
  4. package/components/chat/chat-input.js +13 -2
  5. package/components/description-list/description-list.js +4 -3
  6. package/components/drawer/drawer.css +13 -6
  7. package/components/field/field.css +113 -63
  8. package/components/field/field.js +44 -142
  9. package/components/icon/icon.a2ui.json +1 -1
  10. package/components/icon/icon.css +16 -0
  11. package/components/icon/icon.js +18 -0
  12. package/components/icon/icon.yaml +6 -2
  13. package/components/index.js +1 -0
  14. package/components/input/input.a2ui.json +1 -1
  15. package/components/input/input.css +29 -24
  16. package/components/input/input.js +36 -9
  17. package/components/input/input.yaml +3 -1
  18. package/components/option-card/option-card.a2ui.json +262 -0
  19. package/components/option-card/option-card.css +219 -0
  20. package/components/option-card/option-card.js +158 -0
  21. package/components/option-card/option-card.yaml +234 -0
  22. package/components/rating/rating.a2ui.json +10 -0
  23. package/components/rating/rating.yaml +8 -0
  24. package/components/segment/segment.a2ui.json +5 -0
  25. package/components/segment/segment.css +14 -0
  26. package/components/segment/segment.js +21 -1
  27. package/components/segment/segment.yaml +5 -0
  28. package/components/select/select.css +6 -2
  29. package/components/textarea/textarea.css +1 -1
  30. package/components/textarea/textarea.js +2 -2
  31. package/core/data-stream.js +21 -0
  32. package/core/form.js +5 -0
  33. package/core/index.js +2 -0
  34. package/core/streams-bridge.js +96 -0
  35. package/package.json +1 -1
  36. package/patterns/app-nav-group/app-nav-group.css +2 -2
  37. package/patterns/app-shell/css/app-shell.tokens.css +5 -1
  38. package/patterns/section-nav/section-nav.css +4 -3
  39. package/styles/colors/semantics.css +11 -6
  40. package/styles/components.css +1 -0
  41. package/styles/prose.css +3 -7
  42. package/styles/tokens.css +7 -4
  43. package/styles/typography.css +3 -3
@@ -65,6 +65,11 @@
65
65
  :scope [slot="leading"] {
66
66
  flex-shrink: 0;
67
67
  color: var(--alert-icon-fg);
68
+ /* `ensure()` appends the leading-slot icon to the host, which
69
+ puts it after any consumer-provided content in DOM order.
70
+ Force it to the visual lead via flex `order` so the icon
71
+ always reads first. */
72
+ order: -1;
68
73
  }
69
74
 
70
75
  :scope [slot="content"] {
@@ -66,9 +66,11 @@ class AdiaAlert extends AdiaElement {
66
66
  this.drop('leading');
67
67
  }
68
68
 
69
- // Text
69
+ // Text — only write from the `text` attribute when it's set, so
70
+ // consumers passing rich content via `<span slot="content">…</span>`
71
+ // (links, <strong>, etc.) aren't clobbered with empty textContent.
70
72
  const content = this.ensure('content');
71
- if (content) content.textContent = this.text;
73
+ if (content && this.text) content.textContent = this.text;
72
74
 
73
75
  // Close button
74
76
  if (this.closable) {
@@ -22,7 +22,10 @@ class AdiaButton extends AdiaElement {
22
22
  }
23
23
 
24
24
  render() {
25
- this.setAttribute('aria-label', this.text || '');
25
+ // Don't clobber a user-provided aria-label with an empty string when
26
+ // text is unset (e.g. icon-only button with author-set aria-label).
27
+ // Only auto-set when we have meaningful text to put there.
28
+ if (this.text) this.setAttribute('aria-label', this.text);
26
29
  if (this.icon) {
27
30
  const existing = this.querySelector('icon-ui');
28
31
  if (!existing || existing.name !== this.icon) {
@@ -85,8 +85,8 @@ class AdiaChatInput extends AdiaElement {
85
85
  this.innerHTML = `
86
86
  <textarea-ui placeholder="${this.placeholder}" rows="1"></textarea-ui>
87
87
  <div slot="toolbar">
88
- <select-ui slot="model" placeholder="Model" divider></select-ui>
89
- <button-ui icon="paper-plane-right" variant="ghost" slot="send"></button-ui>
88
+ <select-ui slot="model" placeholder="Model" aria-label="Select model" divider></select-ui>
89
+ <button-ui icon="paper-plane-right" variant="ghost" slot="send" aria-label="Send message"></button-ui>
90
90
  </div>
91
91
  `;
92
92
  }
@@ -95,6 +95,16 @@ class AdiaChatInput extends AdiaElement {
95
95
  this.#sendEl = this.querySelector('[slot="send"]');
96
96
  this.#modelEl = this.querySelector('[slot="model"]');
97
97
 
98
+ // Default aria-labels on author-provided send/model when not set —
99
+ // these elements are screen-reader-relevant and shouldn't fall back
100
+ // to the icon-name announcement.
101
+ if (this.#sendEl && !this.#sendEl.hasAttribute('aria-label')) {
102
+ this.#sendEl.setAttribute('aria-label', 'Send message');
103
+ }
104
+ if (this.#modelEl && !this.#modelEl.hasAttribute('aria-label')) {
105
+ this.#modelEl.setAttribute('aria-label', 'Select model');
106
+ }
107
+
98
108
  // Apply models if set before connected (options first, then value)
99
109
  if (this.#models.length && this.#modelEl) {
100
110
  this.#modelEl.options = this.#models;
@@ -116,6 +126,7 @@ class AdiaChatInput extends AdiaElement {
116
126
  this.#attachBtn.setAttribute('variant', 'ghost');
117
127
  this.#attachBtn.setAttribute('slot', 'attach');
118
128
  this.#attachBtn.setAttribute('size', 'sm');
129
+ this.#attachBtn.setAttribute('aria-label', 'Attach image');
119
130
  const sendBtn = toolbar.querySelector('[slot="send"]');
120
131
  toolbar.insertBefore(this.#attachBtn, sendBtn);
121
132
  this.#attachBtn.addEventListener('press', this.#onAttachPress);
@@ -30,9 +30,10 @@ class AdiaDescriptionList extends AdiaElement {
30
30
  static template = () => null;
31
31
 
32
32
  connected() {
33
- // Use the native <dl> role by wrapping or using the tag directly —
34
- // since we're a custom element, we set role explicitly.
35
- this.setAttribute('role', 'list');
33
+ // ARIA 1.2: list role requires listitem children, but <dt>/<dd>
34
+ // aren't listitems. group role is the accurate fit for a
35
+ // labeled-pairs grouping.
36
+ this.setAttribute('role', 'group');
36
37
  }
37
38
 
38
39
  render() {
@@ -296,14 +296,13 @@
296
296
  [slot="panel"] > [slot="body"][padding] { background: var(--a-canvas-0-scrim); }
297
297
 
298
298
  /* ═══════ Footer ═══════
299
- Flex row for actions. [slot="description"] + [slot="action"] = space-between.
300
- Mirrors card-ui footer semantics. */
299
+ Block default single non-slotted child (e.g. <grid-ui>, <col-ui>, raw
300
+ paragraph) stretches full-width naturally. Activate flex-row only when a
301
+ direct slotted child is present, or there are 2+ children — that's the
302
+ action-row case. Mirrors card-ui footer semantics. */
301
303
 
302
304
  [slot="panel"] > [slot="footer"] {
303
- display: flex;
304
- flex-wrap: wrap;
305
- align-items: center;
306
- gap: var(--drawer-footer-gap);
305
+ display: block;
307
306
  padding: var(--drawer-footer-pad);
308
307
  border-top: 1px solid var(--drawer-divider);
309
308
  flex-shrink: 0;
@@ -314,6 +313,14 @@
314
313
  z-index: 1;
315
314
  }
316
315
 
316
+ [slot="panel"] > [slot="footer"]:has(> [slot]),
317
+ [slot="panel"] > [slot="footer"]:has(> :nth-child(2)) {
318
+ display: flex;
319
+ flex-wrap: wrap;
320
+ align-items: center;
321
+ gap: var(--drawer-footer-gap);
322
+ }
323
+
317
324
  [slot="panel"] > [slot="footer"][justify="end"] {
318
325
  justify-content: flex-end;
319
326
  }
@@ -7,105 +7,155 @@
7
7
  --field-label-weight: var(--a-weight-medium);
8
8
  --field-required-color: var(--a-danger);
9
9
  --field-trailing-color: var(--a-fg-subtle);
10
- --field-trailing-size: var(--a-ui-tiny);
10
+ --field-trailing-size: var(--a-ui-sm);
11
11
  --field-hint-color: var(--a-fg-muted);
12
- --field-hint-size: var(--a-ui-tiny);
12
+ --field-hint-size: var(--a-ui-sm);
13
13
  --field-error-color: var(--a-danger);
14
- --field-error-size: var(--a-ui-tiny);
14
+ --field-error-size: var(--a-ui-sm);
15
+
16
+ /* In inline mode, the label column auto-sizes by default (each
17
+ field's label column is independent of its siblings). Consumers
18
+ that want shared label-column alignment across stacked inline
19
+ fields can raise this floor — e.g. 12rem in a multi-field form. */
20
+ --field-label-inline-min: 0;
15
21
  }
16
22
 
17
- /* ── Base — stacked: three flex rows stacked vertically.
18
- Each row is its own flex container so `trailing` and `action`
19
- size independently (the previous single-grid shared column 2
20
- across rows and made them co-sized). ── */
23
+ /* ── Base — single grid; children placed by [slot] attribute via
24
+ named grid-areas. No row wrappers; no DOM reparenting. The
25
+ template adapts via `:has()` to which slots are present so
26
+ empty tracks don't leak column-gap. ── */
21
27
  :scope {
22
28
  box-sizing: border-box;
23
- display: flex;
24
- flex-direction: column;
25
- gap: var(--field-gap);
29
+ display: grid;
30
+ grid-template-columns: minmax(0, 1fr);
31
+ grid-template-areas:
32
+ "label"
33
+ "control"
34
+ "message";
35
+ column-gap: var(--field-gap);
36
+ row-gap: var(--field-gap);
37
+ align-items: center;
26
38
  }
27
39
 
28
- :scope > [data-row] {
29
- display: flex;
30
- align-items: center;
31
- gap: var(--field-gap);
32
- min-width: 0;
40
+ /* Stacked + (trailing or action) → 2-col */
41
+ :scope:has(> :is([slot="trailing"], [slot="action"])) {
42
+ grid-template-columns: minmax(0, 1fr) auto;
43
+ grid-template-areas:
44
+ "label trailing"
45
+ "control action"
46
+ "message message";
47
+ }
48
+
49
+ /* Stacked + no label, no trailing → drop the empty top row. */
50
+ :scope:not([label]):not(:has(> [slot="trailing"])) {
51
+ grid-template-areas:
52
+ "control"
53
+ "message";
54
+ }
55
+ :scope:not([label]):not(:has(> [slot="trailing"])):has(> [slot="action"]) {
56
+ grid-template-columns: minmax(0, 1fr) auto;
57
+ grid-template-areas:
58
+ "control action"
59
+ "message message";
33
60
  }
34
61
 
35
- /* label-row label grows, trailing auto-sizes and right-aligns. */
36
- :scope > [data-row="label"] > [data-field-label] {
37
- flex: 1 1 auto;
62
+ /* Hide the label cell when the label attr is absent. */
63
+ :scope:not([label]) > [slot="label"] { display: none; }
64
+
65
+ /* ── Slot styling ── */
66
+ :scope > [slot="label"] {
67
+ grid-area: label;
38
68
  color: var(--field-label-color);
39
69
  font-size: var(--field-label-size);
40
70
  font-weight: var(--field-label-weight);
41
71
  cursor: pointer;
42
72
  min-width: 0;
43
73
  }
44
- :scope > [data-row="label"] > [data-field-label] > [data-field-required] {
74
+ :scope > [slot="label"] > [data-field-required] {
45
75
  color: var(--field-required-color);
46
76
  margin-inline-start: 0.15em;
47
77
  font-weight: var(--a-weight-bold);
48
78
  }
49
- :scope > [data-row="label"] > [slot="trailing"] {
50
- flex: 0 0 auto;
79
+ :scope > [slot="trailing"] {
80
+ grid-area: trailing;
81
+ justify-self: end;
51
82
  color: var(--field-trailing-color);
52
83
  font-size: var(--field-trailing-size);
53
84
  }
54
-
55
- /* control-row control grows, action auto-sizes. */
56
- :scope > [data-row="control"] > :not([slot="action"]) {
57
- flex: 1 1 auto;
85
+ :scope > :not([slot]) {
86
+ grid-area: control;
58
87
  min-width: 0;
59
88
  }
60
- :scope > [data-row="control"] > [slot="action"] {
61
- flex: 0 0 auto;
89
+ :scope > [slot="action"] {
90
+ grid-area: action;
91
+ justify-self: end;
62
92
  }
63
-
64
- /* message-row — hint or error; collapsed via [hidden] when both empty. */
65
- :scope > [data-row="message"] > [data-field-hint] {
93
+ :scope > [slot="hint"],
94
+ :scope > [slot="error"] {
95
+ grid-area: message;
96
+ line-height: 1.3;
97
+ }
98
+ :scope > [slot="hint"] {
66
99
  color: var(--field-hint-color);
67
100
  font-size: var(--field-hint-size);
68
- line-height: 1.3;
69
101
  }
70
- :scope > [data-row="message"] > [data-field-error] {
102
+ :scope > [slot="error"] {
71
103
  color: var(--field-error-color);
72
104
  font-size: var(--field-error-size);
73
- line-height: 1.3;
74
105
  font-weight: var(--a-weight-medium);
75
106
  }
76
107
 
77
- /* Hide the whole label-row when there's no label AND no trailing
78
- prevents an empty row stealing vertical gap. */
79
- :scope:not([label]) > [data-row="label"] > [data-field-label] {
80
- display: none;
108
+ /* ── Mode: inline content slots on row 1, message row below
109
+ (aligned with the control column so hint/error sit under the
110
+ input, not under the label). Templates branch by which
111
+ optional slots are present so we don't carry zero-width
112
+ tracks + their gaps. ── */
113
+ :scope[inline] {
114
+ grid-template-columns: minmax(var(--field-label-inline-min), auto) minmax(0, 1fr);
115
+ grid-template-areas:
116
+ "label control"
117
+ ". message";
81
118
  }
82
- :scope:not([label]) > [data-row="label"]:not(:has(> [slot="trailing"])) {
83
- display: none;
119
+ :scope[inline]:has(> [slot="trailing"]):not(:has(> [slot="action"])) {
120
+ grid-template-columns: minmax(var(--field-label-inline-min), auto) auto minmax(0, 1fr);
121
+ grid-template-areas:
122
+ "label trailing control"
123
+ ". . message";
84
124
  }
85
-
86
- /* ── Mode: inline — flatten label-row + control-row into a single
87
- grid row. `display: contents` promotes the row wrappers' children
88
- directly to :scope's grid, keeping per-cell independence. The
89
- message-row stays a block row below. ── */
90
- :scope[inline] {
91
- display: grid;
92
- grid-template-columns: auto auto 1fr auto;
93
- grid-template-rows: auto auto;
94
- gap: var(--field-gap);
95
- align-items: center;
125
+ :scope[inline]:not(:has(> [slot="trailing"])):has(> [slot="action"]) {
126
+ grid-template-columns: minmax(var(--field-label-inline-min), auto) minmax(0, 1fr) auto;
127
+ grid-template-areas:
128
+ "label control action"
129
+ ". message message";
130
+ }
131
+ :scope[inline]:has(> [slot="trailing"]):has(> [slot="action"]) {
132
+ grid-template-columns: minmax(var(--field-label-inline-min), auto) auto minmax(0, 1fr) auto;
133
+ grid-template-areas:
134
+ "label trailing control action"
135
+ ". . message message";
96
136
  }
97
- :scope[inline] > [data-row="label"],
98
- :scope[inline] > [data-row="control"] {
99
- display: contents;
100
- }
101
- :scope[inline] > [data-row="label"] > [data-field-label] { grid-column: 1; grid-row: 1; justify-self: start; }
102
- :scope[inline] > [data-row="label"] > [slot="trailing"] { grid-column: 2; grid-row: 1; justify-self: start; }
103
- :scope[inline] > [data-row="control"] > :not([slot="action"]) { grid-column: 3; grid-row: 1; }
104
- :scope[inline] > [data-row="control"] > [slot="action"] { grid-column: 4; grid-row: 1; justify-self: end; }
105
- :scope[inline] > [data-row="message"] {
106
- grid-column: 1 / -1;
107
- grid-row: 2;
108
- display: flex;
109
- gap: var(--field-gap);
137
+ :scope[inline]:not([label]):not(:has(> [slot="trailing"])):not(:has(> [slot="action"])) {
138
+ grid-template-columns: minmax(0, 1fr);
139
+ grid-template-areas:
140
+ "control"
141
+ "message";
142
+ }
143
+ :scope[inline]:not([label]):not(:has(> [slot="trailing"])):has(> [slot="action"]) {
144
+ grid-template-columns: minmax(0, 1fr) auto;
145
+ grid-template-areas:
146
+ "control action"
147
+ "message message";
148
+ }
149
+
150
+ /* In inline mode, push compact toggle controls (switch / check /
151
+ radio) to the row's end edge — settings rows then render label-
152
+ left, control-right regardless of label length. Wide controls
153
+ (input / textarea / select) keep their default stretch behavior
154
+ so they fill the trailing column. Already in HEAD as `356a39f`
155
+ against the row-wrapper model; the second selector here keeps
156
+ the rule alive once the v2 flat-DOM refactor (§18) lands. */
157
+ :scope[inline]:has(:is(switch-ui, check-ui, radio-ui)) > [data-row="control"] > :not([slot="action"]),
158
+ :scope[inline]:has(> :is(switch-ui, check-ui, radio-ui)) > :not([slot]) {
159
+ justify-self: end;
110
160
  }
111
161
  }
@@ -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
  },