@adia-ai/web-components 0.0.11 → 0.0.13

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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * field-ui — Labeled field wrapper.
3
+ *
4
+ * <field-ui label="Email" hint="We'll never share" required>
5
+ * <input-ui value="you@example.com" type="email"></input-ui>
6
+ * <span slot="trailing">Optional</span>
7
+ * <button-ui slot="action" icon="x" aria-label="Clear"></button-ui>
8
+ * </field-ui>
9
+ *
10
+ * Wraps a form control with its label + optional hint, error, trailing,
11
+ * and action regions. The host renders a real `<label for="…">` bound
12
+ * to the slotted control's id (auto-minted when missing) so clicking
13
+ * the label focuses the control — an affordance the previous
14
+ * `<input-ui label="…">` pattern lacked (no `for` attr, just a shadow
15
+ * slot).
16
+ *
17
+ * ### Layout — three internal flex rows
18
+ *
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:
22
+ *
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>
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.
32
+ *
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.
45
+ */
46
+ import { AdiaElement } from '../../core/element.js';
47
+
48
+ class AdiaField extends AdiaElement {
49
+ static properties = {
50
+ label: { type: String, default: '', reflect: true },
51
+ hint: { type: String, default: '', reflect: true },
52
+ error: { type: String, default: '', reflect: true },
53
+ required: { type: Boolean, default: false, reflect: true },
54
+ inline: { type: Boolean, default: false, reflect: true },
55
+ };
56
+
57
+ static template = () => null;
58
+
59
+ #labelEl = null;
60
+ #labelMark = null;
61
+ #hintEl = null;
62
+ #errorEl = null;
63
+ #labelRow = null;
64
+ #controlRow = null;
65
+ #messageRow = null;
66
+ #mo = null;
67
+ #restructuring = false; // guard against MutationObserver self-trigger
68
+
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.
75
+ #onLabelClick = (e) => {
76
+ const ctrl = this.#findControl();
77
+ if (!ctrl) return;
78
+ const focusable = this.#firstFocusable(ctrl);
79
+ if (!focusable) return;
80
+ e.preventDefault(); // prevent the native <label for> focus-host fallback
81
+ focusable.focus();
82
+ };
83
+
84
+ connected() {
85
+ this.#ensureRowWrappers();
86
+ this.#ensureLabelElement();
87
+ this.#ensureHintElement();
88
+ this.#ensureErrorElement();
89
+ this.#restructure();
90
+ this.#bindForToControl();
91
+ this.#mo = new MutationObserver(() => {
92
+ if (this.#restructuring) return;
93
+ this.#restructure();
94
+ this.#bindForToControl();
95
+ });
96
+ this.#mo.observe(this, { childList: true, subtree: false });
97
+ this.#labelEl?.addEventListener('click', this.#onLabelClick);
98
+ }
99
+
100
+ disconnected() {
101
+ this.#mo?.disconnect();
102
+ this.#mo = null;
103
+ this.#labelEl?.removeEventListener('click', this.#onLabelClick);
104
+ this.#labelEl = null;
105
+ this.#labelMark = null;
106
+ this.#hintEl = null;
107
+ this.#errorEl = null;
108
+ this.#labelRow = null;
109
+ this.#controlRow = null;
110
+ this.#messageRow = null;
111
+ }
112
+
113
+ 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 || '');
116
+ if (this.#labelMark) this.#labelMark.hidden = !this.required;
117
+ if (this.#hintEl) {
118
+ this.#hintEl.textContent = this.hint || '';
119
+ this.#hintEl.hidden = !this.hint || Boolean(this.error);
120
+ }
121
+ if (this.#errorEl) {
122
+ this.#errorEl.textContent = this.error || '';
123
+ this.#errorEl.hidden = !this.error;
124
+ }
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
+ this.#wireAriaDescribedBy();
131
+ }
132
+
133
+ // ── Private ───────────────────────────────────────────────────────
134
+
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
+ #ensureLabelElement() {
160
+ this.#labelEl = this.querySelector(':scope [data-field-label]');
161
+ if (!this.#labelEl) {
162
+ const el = document.createElement('label');
163
+ el.setAttribute('data-field-label', '');
164
+ el.appendChild(document.createTextNode(this.label || ''));
165
+ this.#labelEl = el;
166
+ }
167
+ // Persistent required marker — hidden when !required. Lives as the
168
+ // label's last child so the stream reads "<label>*</label>".
169
+ this.#labelMark = this.#labelEl.querySelector('[data-field-required]');
170
+ if (!this.#labelMark) {
171
+ const mark = document.createElement('span');
172
+ mark.setAttribute('data-field-required', '');
173
+ mark.setAttribute('aria-hidden', 'true');
174
+ mark.textContent = '*';
175
+ mark.hidden = true;
176
+ this.#labelEl.appendChild(mark);
177
+ this.#labelMark = mark;
178
+ }
179
+ }
180
+
181
+ #ensureHintElement() {
182
+ this.#hintEl = this.querySelector(':scope [data-field-hint]');
183
+ if (this.#hintEl) return;
184
+ const el = document.createElement('div');
185
+ el.setAttribute('data-field-hint', '');
186
+ el.setAttribute('id', AdiaField.#nextMsgId('hint'));
187
+ el.hidden = true;
188
+ this.#hintEl = el;
189
+ }
190
+
191
+ #ensureErrorElement() {
192
+ this.#errorEl = this.querySelector(':scope [data-field-error]');
193
+ if (this.#errorEl) return;
194
+ const el = document.createElement('div');
195
+ el.setAttribute('data-field-error', '');
196
+ el.setAttribute('id', AdiaField.#nextMsgId('err'));
197
+ el.setAttribute('role', 'alert');
198
+ el.hidden = true;
199
+ this.#errorEl = el;
200
+ }
201
+
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
+ #bindForToControl() {
248
+ if (!this.#labelEl) return;
249
+ const control = this.#findControl();
250
+ if (!control) {
251
+ this.#labelEl.removeAttribute('for');
252
+ return;
253
+ }
254
+ if (!control.id) control.id = AdiaField.#nextId();
255
+ this.#labelEl.setAttribute('for', control.id);
256
+ }
257
+
258
+ /** Wire `aria-describedby` on the slotted control to the hint/error
259
+ * ids. Keeps any consumer-set describedby ids; appends ours. */
260
+ #wireAriaDescribedBy() {
261
+ const ctrl = this.#findControl();
262
+ if (!ctrl) return;
263
+ const ours = new Set();
264
+ if (this.#hintEl && !this.#hintEl.hidden) ours.add(this.#hintEl.id);
265
+ if (this.#errorEl && !this.#errorEl.hidden) ours.add(this.#errorEl.id);
266
+ const existing = (ctrl.getAttribute('aria-describedby') || '')
267
+ .split(/\s+/).filter((s) => s && !s.startsWith('field-hint-') && !s.startsWith('field-err-'));
268
+ const merged = [...existing, ...ours].join(' ').trim();
269
+ if (merged) ctrl.setAttribute('aria-describedby', merged);
270
+ else ctrl.removeAttribute('aria-describedby');
271
+ }
272
+
273
+ /** The default-slot control — first child of control-row that isn't
274
+ * assigned to `[slot="action"]`. */
275
+ #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;
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /** First focusable descendant (or self) of `root`. Looks for native
285
+ * `<input>`, `<textarea>`, `<select>`, `<button>`, `[contenteditable]`,
286
+ * and any `[tabindex]` ≥ 0. Traverses light DOM only. */
287
+ #firstFocusable(root) {
288
+ const SEL = 'input, textarea, select, button, [contenteditable], [tabindex]:not([tabindex="-1"])';
289
+ if (root.matches?.(SEL)) return root;
290
+ return root.querySelector?.(SEL) ?? null;
291
+ }
292
+
293
+ static #idSeq = 0;
294
+ static #nextId() {
295
+ AdiaField.#idSeq += 1;
296
+ return `field-ctl-${AdiaField.#idSeq}`;
297
+ }
298
+ static #msgSeq = 0;
299
+ static #nextMsgId(kind) {
300
+ AdiaField.#msgSeq += 1;
301
+ return `field-${kind}-${AdiaField.#msgSeq}`;
302
+ }
303
+ }
304
+
305
+ customElements.define('field-ui', AdiaField);
306
+ export { AdiaField };
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import '../../core/element.js';
3
+ import './field.js';
4
+
5
+ const tick = () => new Promise((r) => queueMicrotask(r));
6
+
7
+ function mount(html) {
8
+ const wrap = document.createElement('div');
9
+ wrap.innerHTML = html;
10
+ document.body.appendChild(wrap);
11
+ return wrap.firstElementChild;
12
+ }
13
+
14
+ describe('field-ui', () => {
15
+ beforeEach(() => { document.body.innerHTML = ''; });
16
+
17
+ it('renders a <label> element carrying the `label` attr text', () => {
18
+ const f = mount('<field-ui label="Email"><input id="e" /></field-ui>');
19
+ const label = f.querySelector('label[data-field-label]');
20
+ expect(label).not.toBeNull();
21
+ // The label's first child is the text node; a hidden required-marker
22
+ // span is its last child (mounted but hidden). Inspecting textContent
23
+ // of the text node isolates the label copy from the marker.
24
+ expect(label.firstChild.nodeValue).toBe('Email');
25
+ });
26
+
27
+ it('auto-mints an id on the slotted control when missing and binds label[for]', () => {
28
+ const f = mount('<field-ui label="Email"><input /></field-ui>');
29
+ const input = f.querySelector('input');
30
+ const label = f.querySelector('label[data-field-label]');
31
+ expect(input.id).toMatch(/^field-ctl-/);
32
+ expect(label.getAttribute('for')).toBe(input.id);
33
+ });
34
+
35
+ it('respects an existing id on the slotted control', () => {
36
+ const f = mount('<field-ui label="Email"><input id="existing" /></field-ui>');
37
+ const input = f.querySelector('input');
38
+ const label = f.querySelector('label[data-field-label]');
39
+ expect(input.id).toBe('existing');
40
+ expect(label.getAttribute('for')).toBe('existing');
41
+ });
42
+
43
+ it('ignores [slot="trailing"] and [slot="action"] when picking the control', () => {
44
+ const f = mount(`
45
+ <field-ui label="Search">
46
+ <span slot="trailing">⌘K</span>
47
+ <input />
48
+ <button slot="action">x</button>
49
+ </field-ui>
50
+ `);
51
+ const input = f.querySelector('input');
52
+ const label = f.querySelector('label[data-field-label]');
53
+ expect(label.getAttribute('for')).toBe(input.id);
54
+ });
55
+
56
+ it('updates label text when the `label` attr changes', async () => {
57
+ const f = mount('<field-ui label="Email"><input /></field-ui>');
58
+ const label = f.querySelector('label[data-field-label]');
59
+ expect(label.firstChild.nodeValue).toBe('Email');
60
+ f.setAttribute('label', 'Username');
61
+ await tick();
62
+ expect(label.firstChild.nodeValue).toBe('Username');
63
+ });
64
+
65
+ it('rebinds label[for] when children change', async () => {
66
+ const f = mount('<field-ui label="X"><input id="first" /></field-ui>');
67
+ const label = f.querySelector('label[data-field-label]');
68
+ expect(label.getAttribute('for')).toBe('first');
69
+ // Replace the control.
70
+ f.querySelector('input').remove();
71
+ const next = document.createElement('input');
72
+ next.id = 'second';
73
+ f.appendChild(next);
74
+ // MutationObserver fires async on microtask.
75
+ await new Promise((r) => setTimeout(r, 10));
76
+ expect(label.getAttribute('for')).toBe('second');
77
+ });
78
+
79
+ it('reflects `inline` as a boolean attr (default false)', () => {
80
+ const f = mount('<field-ui label="X"><input /></field-ui>');
81
+ expect(f.hasAttribute('inline')).toBe(false);
82
+ f.inline = true;
83
+ expect(f.hasAttribute('inline')).toBe(true);
84
+ f.inline = false;
85
+ expect(f.hasAttribute('inline')).toBe(false);
86
+ });
87
+
88
+ it('renders without a label (label attr absent) — hides the <label> element', () => {
89
+ const f = mount('<field-ui><input /></field-ui>');
90
+ const label = f.querySelector('label[data-field-label]');
91
+ expect(label).not.toBeNull();
92
+ const input = f.querySelector('input');
93
+ expect(label.getAttribute('for')).toBe(input.id);
94
+ });
95
+
96
+ it('renders a hint element + wires aria-describedby when `hint` is set', async () => {
97
+ const f = mount('<field-ui label="E" hint="We keep it private"><input /></field-ui>');
98
+ await tick();
99
+ const hint = f.querySelector('[data-field-hint]');
100
+ const input = f.querySelector('input');
101
+ expect(hint?.textContent).toBe('We keep it private');
102
+ expect(hint?.hidden).toBe(false);
103
+ expect(input.getAttribute('aria-describedby')).toBe(hint.id);
104
+ });
105
+
106
+ it('hides hint when `hint` is empty', async () => {
107
+ const f = mount('<field-ui label="E"><input /></field-ui>');
108
+ await tick();
109
+ const hint = f.querySelector('[data-field-hint]');
110
+ expect(hint?.hidden).toBe(true);
111
+ const input = f.querySelector('input');
112
+ expect(input.hasAttribute('aria-describedby')).toBe(false);
113
+ });
114
+
115
+ it('renders an error element + suppresses hint when both set', async () => {
116
+ const f = mount('<field-ui label="E" hint="hi" error="Required"><input /></field-ui>');
117
+ await tick();
118
+ const hint = f.querySelector('[data-field-hint]');
119
+ const err = f.querySelector('[data-field-error]');
120
+ expect(err?.textContent).toBe('Required');
121
+ expect(err?.hidden).toBe(false);
122
+ expect(err?.getAttribute('role')).toBe('alert');
123
+ expect(hint?.hidden).toBe(true); // error wins
124
+ const input = f.querySelector('input');
125
+ expect(input.getAttribute('aria-describedby')).toBe(err.id);
126
+ });
127
+
128
+ it('renders the `*` required marker on the label when `required` is set', async () => {
129
+ const f = mount('<field-ui label="Email" required><input /></field-ui>');
130
+ await tick();
131
+ const label = f.querySelector('label[data-field-label]');
132
+ const mark = label.querySelector('[data-field-required]');
133
+ expect(mark?.textContent).toBe('*');
134
+ expect(mark?.getAttribute('aria-hidden')).toBe('true');
135
+ });
136
+
137
+ it('updates hint/error text reactively when attrs change', async () => {
138
+ const f = mount('<field-ui label="E" hint="a"><input /></field-ui>');
139
+ await tick();
140
+ const hint = f.querySelector('[data-field-hint]');
141
+ expect(hint.textContent).toBe('a');
142
+ f.hint = 'b';
143
+ await tick();
144
+ expect(hint.textContent).toBe('b');
145
+ });
146
+ });
@@ -0,0 +1,155 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
3
+ name: AdiaField
4
+ tag: field-ui
5
+ component: Field
6
+ category: form
7
+ version: 1
8
+ description: >-
9
+ Labeled field wrapper. Composes a <label for="…"> with a form
10
+ control (input-ui, select-ui, textarea-ui, etc.) placed in the
11
+ default slot, plus optional [slot="trailing"] and [slot="action"]
12
+ regions. Auto-mints an id on the slotted control when missing so
13
+ clicking the label focuses the control — an accessibility upgrade
14
+ over setting label="…" on the control directly, which has no
15
+ [for] binding. Two layouts — stacked (default) and inline (the
16
+ `inline` mode attribute collapses everything to a single row).
17
+ props:
18
+ label:
19
+ description: Label text rendered in the label row.
20
+ type: string
21
+ default: ""
22
+ reflect: true
23
+ hint:
24
+ description: >-
25
+ Help text rendered below the control in caption style. Wired
26
+ into the slotted control's aria-describedby so screen readers
27
+ announce it. Suppressed when `error` is set.
28
+ type: string
29
+ default: ""
30
+ reflect: true
31
+ error:
32
+ description: >-
33
+ Validation error message rendered below the control in danger
34
+ style. Takes precedence over `hint` in the same row, and
35
+ carries role="alert" so screen readers announce changes.
36
+ type: string
37
+ default: ""
38
+ reflect: true
39
+ required:
40
+ description: >-
41
+ Renders a "*" marker on the label. Does not itself enforce
42
+ validation — the slotted control's own `required` attr does
43
+ that; this is a visual signal only.
44
+ type: boolean
45
+ default: false
46
+ reflect: true
47
+ inline:
48
+ description: >-
49
+ Lay out label, trailing, control, and action on a single row
50
+ instead of the stacked default (mode attribute — changes grid
51
+ geometry, not tokens). Hint/error still render on their own
52
+ row below.
53
+ type: boolean
54
+ default: false
55
+ reflect: true
56
+ slots:
57
+ default:
58
+ description: >-
59
+ The form control — input-ui, select-ui, textarea-ui,
60
+ check-ui, switch-ui, radio-ui, slider-ui, etc. Auto-id'd for
61
+ the label's [for] binding.
62
+ trailing:
63
+ description: >-
64
+ Secondary text or badge aligned with the label in the stacked
65
+ layout (right-aligned) or between label and control in the
66
+ inline layout.
67
+ action:
68
+ description: >-
69
+ Button adjacent to the control for inline actions (clear,
70
+ reset, help popover).
71
+ states:
72
+ - name: idle
73
+ description: Default, ready for interaction.
74
+ traits: []
75
+ tokens:
76
+ --field-gap:
77
+ description: Gap between rows/cells of the field grid.
78
+ --field-label-color:
79
+ description: Label foreground color.
80
+ --field-label-size:
81
+ description: Label font size.
82
+ --field-label-weight:
83
+ description: Label font weight.
84
+ --field-required-color:
85
+ description: Color of the `*` required marker on the label.
86
+ --field-trailing-color:
87
+ description: Trailing text color.
88
+ --field-trailing-size:
89
+ description: Trailing text size.
90
+ --field-hint-color:
91
+ description: Hint text color.
92
+ --field-hint-size:
93
+ description: Hint text size.
94
+ --field-error-color:
95
+ description: Error text color (also drives the required-marker color).
96
+ --field-error-size:
97
+ description: Error text size.
98
+ a2ui:
99
+ rules: []
100
+ anti_patterns: []
101
+ examples:
102
+ - name: stacked-email-field
103
+ description: >-
104
+ A simple stacked email field with a trailing "Required" hint
105
+ and a clear-button action adjacent to the input.
106
+ a2ui: >-
107
+ [
108
+ {
109
+ "id": "root",
110
+ "component": "Field",
111
+ "label": "Email",
112
+ "children": ["email", "hint", "clear"]
113
+ },
114
+ { "id": "email", "component": "Input", "type": "email", "value": "you@example.com" },
115
+ { "id": "hint", "component": "Text", "slot": "trailing", "text": "Required" },
116
+ { "id": "clear", "component": "Button", "slot": "action", "icon": "x", "variant": "ghost" }
117
+ ]
118
+ - name: inline-search-field
119
+ description: >-
120
+ An inline search field — label beside the input, with a
121
+ trailing keyboard-shortcut hint.
122
+ a2ui: >-
123
+ [
124
+ {
125
+ "id": "root",
126
+ "component": "Field",
127
+ "label": "Search",
128
+ "inline": true,
129
+ "children": ["q", "kbd"]
130
+ },
131
+ { "id": "q", "component": "Input", "type": "search", "placeholder": "Type…" },
132
+ { "id": "kbd", "component": "Kbd", "slot": "trailing", "text": "⌘K" }
133
+ ]
134
+ keywords:
135
+ - field
136
+ - form
137
+ - label
138
+ - input
139
+ - wrapper
140
+ synonyms:
141
+ form:
142
+ - field
143
+ - label
144
+ - input
145
+ label:
146
+ - field
147
+ - form
148
+ related:
149
+ - input
150
+ - select
151
+ - textarea
152
+ - check
153
+ - radio
154
+ - switch
155
+ - slider
@@ -43,6 +43,7 @@ export { AdiaAlert } from './alert/alert.js';
43
43
  export { AdiaKbd } from './kbd/kbd.js';
44
44
  export { AdiaTag } from './tag/tag.js';
45
45
  export { AdiaCol } from './col/col.js';
46
+ export { AdiaField } from './field/field.js';
46
47
  export { AdiaRow } from './row/row.js';
47
48
  export { AdiaGrid } from './grid/grid.js';
48
49
  export { AdiaStack } from './stack/stack.js';
@@ -5,7 +5,8 @@
5
5
  --input-fg: var(--a-ui-text);
6
6
  --input-border: var(--a-ui-border);
7
7
  --input-border-hover: var(--a-ui-border-hover);
8
- --input-border-focus: var(--a-ui-border-active);
8
+ --input-focus-ring: var(--a-focus-ring);
9
+ --input-focus-ring-invalid: var(--a-focus-ring-invalid);
9
10
  --input-radius: var(--a-radius);
10
11
  --input-height: var(--a-size);
11
12
  --input-px: var(--a-ui-px);
@@ -79,9 +80,16 @@
79
80
  color: var(--input-affix-fg-hover);
80
81
  }
81
82
  :scope:not([disabled]):focus-within [slot="field"] {
82
- border-color: var(--input-border-focus);
83
+ /* Canonical ring — consumes the L3 --input-focus-ring token
84
+ which aliases --a-focus-ring. Border stays stable; the ring
85
+ is the focus affordance (WCAG 2.2 SC 2.4.11/2.4.13). */
86
+ box-shadow: var(--input-focus-ring);
83
87
  color: var(--input-fg-focus);
84
88
  }
89
+ :scope[aria-invalid="true"]:not([disabled]):focus-within [slot="field"],
90
+ :scope[error]:not([disabled]):focus-within [slot="field"] {
91
+ box-shadow: var(--input-focus-ring-invalid);
92
+ }
85
93
  :scope:not([disabled]):focus-within [slot="label"] {
86
94
  color: var(--input-label-fg-focus);
87
95
  }
@@ -154,7 +162,6 @@
154
162
  --input-bg: transparent;
155
163
  --input-border: transparent;
156
164
  --input-border-hover: transparent;
157
- --input-border-focus: transparent;
158
165
  }
159
166
  :scope[variant="ghost"]:hover {
160
167
  --input-bg: var(--a-bg-muted);
@@ -11,7 +11,9 @@
11
11
  --range-fill-label-fg: var(--a-ui-text-subtle);
12
12
  --range-border: var(--a-ui-border);
13
13
  --range-border-hover: var(--a-ui-border-hover);
14
- --range-border-focus: var(--a-ui-border-active);
14
+ --range-border-dragging: var(--a-ui-border-active);
15
+ --range-focus-ring: var(--a-focus-ring);
16
+ --range-focus-ring-invalid: var(--a-focus-ring-invalid);
15
17
  --range-radius: var(--a-radius);
16
18
  --range-height: var(--a-size);
17
19
  --range-px: var(--a-ui-px);
@@ -64,7 +66,12 @@
64
66
  }
65
67
  :scope:focus-visible { outline: none; }
66
68
  :scope:focus-visible [slot="field"] {
67
- border-color: var(--range-border-focus);
69
+ /* Canonical ring via L3 token (see semantics.css FOCUS block). */
70
+ box-shadow: var(--range-focus-ring);
71
+ }
72
+ :scope[aria-invalid="true"]:focus-visible [slot="field"],
73
+ :scope[error]:focus-visible [slot="field"] {
74
+ box-shadow: var(--range-focus-ring-invalid);
68
75
  }
69
76
 
70
77
  /* ── Dual-layer fill: identical layouts, overlay clipped to fill % ── */
@@ -127,7 +134,7 @@
127
134
 
128
135
  /* Dragging: deepest fill, sharper border, instant (no transition lag on the clip) */
129
136
  :scope[data-dragging] [slot="field"] {
130
- border-color: var(--range-border-focus);
137
+ border-color: var(--range-border-dragging);
131
138
  }
132
139
  :scope[data-dragging] [data-layer="fill"] {
133
140
  background: var(--range-fill-bg-active);