@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.
- package/README.md +8 -8
- package/components/calendar-picker/calendar-picker.css +8 -2
- package/components/chat/chat-input.css +41 -5
- package/components/code/code-editor.js +103 -0
- package/components/code/code.css +146 -0
- package/components/code/code.js +221 -24
- package/components/field/field.a2ui.json +149 -0
- package/components/field/field.css +111 -0
- package/components/field/field.js +306 -0
- package/components/field/field.test.js +146 -0
- package/components/field/field.yaml +155 -0
- package/components/index.js +1 -0
- package/components/input/input.css +10 -3
- package/components/range/range.css +10 -3
- package/components/select/select.css +9 -4
- package/components/slider/slider.js +8 -3
- package/components/textarea/textarea.css +12 -2
- package/components/upload/upload.css +5 -2
- package/core/element.test.js +234 -0
- package/core/form.js +26 -0
- package/core/index.js +25 -0
- package/core/markdown.js +8 -2
- package/index.css +26 -0
- package/index.js +18 -0
- package/package.json +14 -6
- package/patterns/adia-chat/adia-chat.js +1 -1
- package/styles/colors/semantics.css +41 -14
- package/styles/{styles.css → components.css} +9 -111
- package/styles/resets.css +116 -0
- package/styles/tokens.css +8 -2
|
@@ -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
|
package/components/index.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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);
|