@adia-ai/web-components 0.0.10 → 0.0.12

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,149 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/Field.json",
4
+ "title": "Field",
5
+ "description": "Labeled field wrapper. Composes a <label for=\"…\"> with a form control (input-ui, select-ui, textarea-ui, etc.) placed in the default slot, plus optional [slot=\"trailing\"] and [slot=\"action\"] regions. Auto-mints an id on the slotted control when missing so clicking the label focuses the control — an accessibility upgrade over setting label=\"…\" on the control directly, which has no [for] binding. Two layouts — stacked (default) and inline (the `inline` mode attribute collapses everything to a single row).",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "required": {
17
+ "description": "Renders a \"*\" marker on the label. Does not itself enforce validation — the slotted control's own `required` attr does that; this is a visual signal only.",
18
+ "type": "boolean",
19
+ "default": false
20
+ },
21
+ "component": {
22
+ "const": "Field"
23
+ },
24
+ "error": {
25
+ "description": "Validation error message rendered below the control in danger style. Takes precedence over `hint` in the same row, and carries role=\"alert\" so screen readers announce changes.",
26
+ "type": "string",
27
+ "default": ""
28
+ },
29
+ "hint": {
30
+ "description": "Help text rendered below the control in caption style. Wired into the slotted control's aria-describedby so screen readers announce it. Suppressed when `error` is set.",
31
+ "type": "string",
32
+ "default": ""
33
+ },
34
+ "inline": {
35
+ "description": "Lay out label, trailing, control, and action on a single row instead of the stacked default (mode attribute — changes grid geometry, not tokens). Hint/error still render on their own row below.",
36
+ "type": "boolean",
37
+ "default": false
38
+ },
39
+ "label": {
40
+ "description": "Label text rendered in the label row.",
41
+ "type": "string",
42
+ "default": ""
43
+ }
44
+ },
45
+ "required": [
46
+ "component"
47
+ ],
48
+ "unevaluatedProperties": false,
49
+ "x-adiaui": {
50
+ "anti_patterns": [],
51
+ "category": "form",
52
+ "events": {},
53
+ "examples": [
54
+ {
55
+ "description": "A simple stacked email field with a trailing \"Required\" hint and a clear-button action adjacent to the input.",
56
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Field\",\n \"label\": \"Email\",\n \"children\": [\"email\", \"hint\", \"clear\"]\n },\n { \"id\": \"email\", \"component\": \"Input\", \"type\": \"email\", \"value\": \"you@example.com\" },\n { \"id\": \"hint\", \"component\": \"Text\", \"slot\": \"trailing\", \"text\": \"Required\" },\n { \"id\": \"clear\", \"component\": \"Button\", \"slot\": \"action\", \"icon\": \"x\", \"variant\": \"ghost\" }\n]",
57
+ "name": "stacked-email-field"
58
+ },
59
+ {
60
+ "description": "An inline search field — label beside the input, with a trailing keyboard-shortcut hint.",
61
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Field\",\n \"label\": \"Search\",\n \"inline\": true,\n \"children\": [\"q\", \"kbd\"]\n },\n { \"id\": \"q\", \"component\": \"Input\", \"type\": \"search\", \"placeholder\": \"Type…\" },\n { \"id\": \"kbd\", \"component\": \"Kbd\", \"slot\": \"trailing\", \"text\": \"⌘K\" }\n]",
62
+ "name": "inline-search-field"
63
+ }
64
+ ],
65
+ "keywords": [
66
+ "field",
67
+ "form",
68
+ "label",
69
+ "input",
70
+ "wrapper"
71
+ ],
72
+ "name": "AdiaField",
73
+ "related": [
74
+ "input",
75
+ "select",
76
+ "textarea",
77
+ "check",
78
+ "radio",
79
+ "switch",
80
+ "slider"
81
+ ],
82
+ "slots": {
83
+ "default": {
84
+ "description": "The form control — input-ui, select-ui, textarea-ui, check-ui, switch-ui, radio-ui, slider-ui, etc. Auto-id'd for the label's [for] binding."
85
+ },
86
+ "action": {
87
+ "description": "Button adjacent to the control for inline actions (clear, reset, help popover)."
88
+ },
89
+ "trailing": {
90
+ "description": "Secondary text or badge aligned with the label in the stacked layout (right-aligned) or between label and control in the inline layout."
91
+ }
92
+ },
93
+ "states": [
94
+ {
95
+ "description": "Default, ready for interaction.",
96
+ "name": "idle"
97
+ }
98
+ ],
99
+ "synonyms": {
100
+ "form": [
101
+ "field",
102
+ "label",
103
+ "input"
104
+ ],
105
+ "label": [
106
+ "field",
107
+ "form"
108
+ ]
109
+ },
110
+ "tag": "field-ui",
111
+ "tokens": {
112
+ "--field-error-color": {
113
+ "description": "Error text color (also drives the required-marker color)."
114
+ },
115
+ "--field-error-size": {
116
+ "description": "Error text size."
117
+ },
118
+ "--field-gap": {
119
+ "description": "Gap between rows/cells of the field grid."
120
+ },
121
+ "--field-hint-color": {
122
+ "description": "Hint text color."
123
+ },
124
+ "--field-hint-size": {
125
+ "description": "Hint text size."
126
+ },
127
+ "--field-label-color": {
128
+ "description": "Label foreground color."
129
+ },
130
+ "--field-label-size": {
131
+ "description": "Label font size."
132
+ },
133
+ "--field-label-weight": {
134
+ "description": "Label font weight."
135
+ },
136
+ "--field-required-color": {
137
+ "description": "Color of the `*` required marker on the label."
138
+ },
139
+ "--field-trailing-color": {
140
+ "description": "Trailing text color."
141
+ },
142
+ "--field-trailing-size": {
143
+ "description": "Trailing text size."
144
+ }
145
+ },
146
+ "traits": [],
147
+ "version": 1
148
+ }
149
+ }
@@ -0,0 +1,111 @@
1
+ @scope (field-ui) {
2
+ :where(:scope) {
3
+ /* ── Tokens ── */
4
+ --field-gap: var(--a-space-2);
5
+ --field-label-color: var(--a-fg);
6
+ --field-label-size: var(--a-ui-sm);
7
+ --field-label-weight: var(--a-weight-medium);
8
+ --field-required-color: var(--a-danger);
9
+ --field-trailing-color: var(--a-fg-subtle);
10
+ --field-trailing-size: var(--a-ui-tiny);
11
+ --field-hint-color: var(--a-fg-muted);
12
+ --field-hint-size: var(--a-ui-tiny);
13
+ --field-error-color: var(--a-danger);
14
+ --field-error-size: var(--a-ui-tiny);
15
+ }
16
+
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). ── */
21
+ :scope {
22
+ box-sizing: border-box;
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: var(--field-gap);
26
+ }
27
+
28
+ :scope > [data-row] {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: var(--field-gap);
32
+ min-width: 0;
33
+ }
34
+
35
+ /* label-row — label grows, trailing auto-sizes and right-aligns. */
36
+ :scope > [data-row="label"] > [data-field-label] {
37
+ flex: 1 1 auto;
38
+ color: var(--field-label-color);
39
+ font-size: var(--field-label-size);
40
+ font-weight: var(--field-label-weight);
41
+ cursor: pointer;
42
+ min-width: 0;
43
+ }
44
+ :scope > [data-row="label"] > [data-field-label] > [data-field-required] {
45
+ color: var(--field-required-color);
46
+ margin-inline-start: 0.15em;
47
+ font-weight: var(--a-weight-bold);
48
+ }
49
+ :scope > [data-row="label"] > [slot="trailing"] {
50
+ flex: 0 0 auto;
51
+ color: var(--field-trailing-color);
52
+ font-size: var(--field-trailing-size);
53
+ }
54
+
55
+ /* control-row — control grows, action auto-sizes. */
56
+ :scope > [data-row="control"] > :not([slot="action"]) {
57
+ flex: 1 1 auto;
58
+ min-width: 0;
59
+ }
60
+ :scope > [data-row="control"] > [slot="action"] {
61
+ flex: 0 0 auto;
62
+ }
63
+
64
+ /* message-row — hint or error; collapsed via [hidden] when both empty. */
65
+ :scope > [data-row="message"] > [data-field-hint] {
66
+ color: var(--field-hint-color);
67
+ font-size: var(--field-hint-size);
68
+ line-height: 1.3;
69
+ }
70
+ :scope > [data-row="message"] > [data-field-error] {
71
+ color: var(--field-error-color);
72
+ font-size: var(--field-error-size);
73
+ line-height: 1.3;
74
+ font-weight: var(--a-weight-medium);
75
+ }
76
+
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;
81
+ }
82
+ :scope:not([label]) > [data-row="label"]:not(:has(> [slot="trailing"])) {
83
+ display: none;
84
+ }
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;
96
+ }
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);
110
+ }
111
+ }
@@ -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 };