@adia-ai/web-components 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -1
- package/components/alert/alert.css +5 -0
- package/components/alert/alert.js +4 -2
- package/components/button/button.js +4 -1
- package/components/chart/chart.js +7 -4
- package/components/chat/chat-input.js +13 -2
- package/components/description-list/description-list.js +4 -3
- package/components/field/field.css +113 -63
- package/components/field/field.js +44 -142
- package/components/icon/icon.a2ui.json +1 -1
- package/components/icon/icon.css +16 -0
- package/components/icon/icon.js +18 -0
- package/components/icon/icon.yaml +6 -2
- package/components/index.js +7 -0
- package/components/input/input.a2ui.json +1 -1
- package/components/input/input.css +21 -23
- package/components/input/input.js +36 -9
- package/components/input/input.yaml +3 -1
- package/components/option-card/option-card.a2ui.json +262 -0
- package/components/option-card/option-card.css +215 -0
- package/components/option-card/option-card.js +158 -0
- package/components/option-card/option-card.yaml +234 -0
- package/components/rating/rating.a2ui.json +10 -0
- package/components/rating/rating.yaml +8 -0
- package/components/segment/segment.a2ui.json +5 -0
- package/components/segment/segment.css +2 -0
- package/components/segment/segment.js +21 -1
- package/components/segment/segment.yaml +5 -0
- package/components/textarea/textarea.css +3 -1
- package/components/textarea/textarea.js +2 -2
- package/components/tooltip/tooltip.js +10 -3
- package/core/data-stream.js +486 -0
- package/core/form.js +5 -0
- package/core/index.js +2 -0
- package/core/streams-bridge.js +96 -0
- package/package.json +1 -1
- package/styles/colors/semantics.css +21 -3
- package/styles/components.css +1 -0
- package/styles/prose.css +3 -7
- package/styles/tokens.css +7 -4
- package/styles/typography.css +6 -1
|
@@ -11,37 +11,26 @@
|
|
|
11
11
|
* and action regions. The host renders a real `<label for="…">` bound
|
|
12
12
|
* to the slotted control's id (auto-minted when missing) so clicking
|
|
13
13
|
* the label focuses the control — an affordance the previous
|
|
14
|
-
* `<input-ui label="…">` pattern lacked
|
|
15
|
-
* slot).
|
|
14
|
+
* `<input-ui label="…">` pattern lacked.
|
|
16
15
|
*
|
|
17
|
-
* ### Layout —
|
|
16
|
+
* ### Layout — single grid, slot-positioned
|
|
18
17
|
*
|
|
19
|
-
* field-ui
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
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
|
|
70
|
-
// .
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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();
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
274
|
-
* assigned to `[slot="action"]`. */
|
|
184
|
+
/** The default-slot control — first child without a `slot` attribute. */
|
|
275
185
|
#findControl() {
|
|
276
|
-
|
|
277
|
-
|
|
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.
|
|
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 (\"
|
|
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
|
},
|
package/components/icon/icon.css
CHANGED
|
@@ -17,4 +17,20 @@
|
|
|
17
17
|
width: 100%;
|
|
18
18
|
height: 100%;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/* ── Named size scale ─────────────────────────────────────────────
|
|
22
|
+
sm/md/lg are also driven by the universal `[size]` token system
|
|
23
|
+
on ancestors (tokens.css §SIZE PRESETS) — these rules let an
|
|
24
|
+
icon-ui set its own size locally. xs / xl / 2xl / 3xl / 4xl
|
|
25
|
+
extend the scale beyond the universal range for hero placeholder
|
|
26
|
+
contexts. `fill` matches the parent box. */
|
|
27
|
+
:scope[size="xs"] { --icon-size: 0.75rem; } /* 12px */
|
|
28
|
+
:scope[size="sm"] { --icon-size: 0.875rem; } /* 14px */
|
|
29
|
+
:scope[size="md"] { --icon-size: 1rem; } /* 16px */
|
|
30
|
+
:scope[size="lg"] { --icon-size: 1.25rem; } /* 20px */
|
|
31
|
+
:scope[size="xl"] { --icon-size: 2rem; } /* 32px */
|
|
32
|
+
:scope[size="2xl"] { --icon-size: 3rem; } /* 48px */
|
|
33
|
+
:scope[size="3xl"] { --icon-size: 4rem; } /* 64px */
|
|
34
|
+
:scope[size="4xl"] { --icon-size: 6rem; } /* 96px */
|
|
35
|
+
:scope[size="fill"] { --icon-size: 100%; }
|
|
20
36
|
}
|
package/components/icon/icon.js
CHANGED
|
@@ -19,6 +19,24 @@ class AdiaIcon extends AdiaElement {
|
|
|
19
19
|
if (this.label) this.setAttribute('aria-label', this.label);
|
|
20
20
|
const svg = getIcon(this.name, this.weight || 'regular');
|
|
21
21
|
if (svg && this.innerHTML !== svg) this.innerHTML = svg;
|
|
22
|
+
|
|
23
|
+
// Pixel / rem / em passthrough — `size="48"` → 48px,
|
|
24
|
+
// `size="3.5rem"` → 3.5rem. Named-scale values (xs/sm/md/lg/xl/
|
|
25
|
+
// 2xl/3xl/4xl/fill) are handled by `:scope[size="…"]` rules in
|
|
26
|
+
// icon.css; we only set --icon-size inline for free-form values.
|
|
27
|
+
if (this.size && this.#isFreeFormSize(this.size)) {
|
|
28
|
+
this.style.setProperty('--icon-size', this.#normalizeSize(this.size));
|
|
29
|
+
} else if (this.style.getPropertyValue('--icon-size')) {
|
|
30
|
+
this.style.removeProperty('--icon-size');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#isFreeFormSize(s) {
|
|
35
|
+
return /^\d+(\.\d+)?(px|rem|em)?$/.test(s);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#normalizeSize(s) {
|
|
39
|
+
return /^\d+(\.\d+)?$/.test(s) ? `${s}px` : s;
|
|
22
40
|
}
|
|
23
41
|
}
|
|
24
42
|
customElements.define('icon-ui', AdiaIcon);
|
|
@@ -18,8 +18,12 @@ props:
|
|
|
18
18
|
default: ""
|
|
19
19
|
size:
|
|
20
20
|
description: >-
|
|
21
|
-
Icon size. Accepts the named scale
|
|
22
|
-
|
|
21
|
+
Icon size. Accepts the named scale
|
|
22
|
+
(`xs` 12px / `sm` 14px / `md` 16px / `lg` 20px / `xl` 32px /
|
|
23
|
+
`2xl` 48px / `3xl` 64px / `4xl` 96px / `fill` 100% of parent)
|
|
24
|
+
or a free-form pixel / rem / em value as a string ("48", "3rem",
|
|
25
|
+
"1.25em"). Overrides the inherited `--a-icon-size` from the
|
|
26
|
+
universal `[size]` system on ancestors.
|
|
23
27
|
type: string
|
|
24
28
|
default: ""
|
|
25
29
|
weight:
|
package/components/index.js
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
* Import this single file to register all custom elements.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/* Side-effect import — auto-attaches the document-level MutationObserver
|
|
7
|
+
that drives `data-stream-*` attribute behavior on any element with a
|
|
8
|
+
settable `.data` property (chart-ui, table-ui, heatmap-ui, stat-ui,
|
|
9
|
+
list-ui). See packages/web-components/core/data-stream.js. */
|
|
10
|
+
import '../core/data-stream.js';
|
|
11
|
+
|
|
6
12
|
export { AdiaIcon } from './icon/icon.js';
|
|
7
13
|
export { AdiaButton } from './button/button.js';
|
|
8
14
|
export { AdiaInput } from './input/input.js';
|
|
9
15
|
export { AdiaTextarea } from './textarea/textarea.js';
|
|
10
16
|
export { AdiaCheck } from './check/check.js';
|
|
11
17
|
export { AdiaRadio } from './radio/radio.js';
|
|
18
|
+
export { AdiaOptionCard } from './option-card/option-card.js';
|
|
12
19
|
export { AdiaSwitch } from './switch/switch.js';
|
|
13
20
|
export { AdiaSlider } from './slider/slider.js';
|
|
14
21
|
export { AdiaSelect } from './select/select.js';
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"default": ""
|
|
53
53
|
},
|
|
54
54
|
"label": {
|
|
55
|
-
"description": "
|
|
55
|
+
"description": "Inline label rendered as a leading caption inside the input chrome, between any prefix and the value. Wires aria-labelledby on the editable surface. For stacked label / hint / error compositions, wrap with field-ui.",
|
|
56
56
|
"type": "string",
|
|
57
57
|
"default": ""
|
|
58
58
|
},
|
|
@@ -11,11 +11,8 @@
|
|
|
11
11
|
--input-height: var(--a-size);
|
|
12
12
|
--input-px: var(--a-ui-px);
|
|
13
13
|
--input-font-size: var(--a-ui-size);
|
|
14
|
-
--input-label-size: var(--a-label-size);
|
|
15
|
-
--input-label-fg: var(--a-label-color);
|
|
16
14
|
--input-placeholder-fg: var(--a-ui-text-placeholder);
|
|
17
15
|
--input-affix-fg: var(--a-ui-text-placeholder);
|
|
18
|
-
--input-gap: var(--a-ui-py);
|
|
19
16
|
--input-field-gap: var(--a-space-1);
|
|
20
17
|
|
|
21
18
|
/* ── Transitions ── */
|
|
@@ -23,10 +20,10 @@
|
|
|
23
20
|
--input-easing: var(--a-easing);
|
|
24
21
|
|
|
25
22
|
/* ── State: hover/focus ── */
|
|
23
|
+
--input-bg-hover: var(--a-ui-bg-hover);
|
|
26
24
|
--input-fg-hover: var(--a-fg);
|
|
27
25
|
--input-affix-fg-hover: var(--a-fg-subtle);
|
|
28
|
-
--input-fg-focus: var(--a-fg);
|
|
29
|
-
--input-label-fg-focus: var(--a-fg-subtle);
|
|
26
|
+
--input-fg-focus: var(--a-fg-strong);
|
|
30
27
|
|
|
31
28
|
/* ── State: disabled ── */
|
|
32
29
|
--input-bg-disabled: var(--a-ui-bg-disabled);
|
|
@@ -36,23 +33,20 @@
|
|
|
36
33
|
:scope {
|
|
37
34
|
/* ── Base ── */
|
|
38
35
|
box-sizing: border-box;
|
|
39
|
-
display:
|
|
40
|
-
flex-direction: column;
|
|
41
|
-
gap: var(--input-gap);
|
|
36
|
+
display: block;
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/* Label */
|
|
39
|
+
/* Inline label — sits inside [slot="field"] as a leading caption,
|
|
40
|
+
between [slot="prefix"] and [slot="text"]. Dim by default; shares
|
|
41
|
+
the input chrome border. For stacked label / hint / error, wrap
|
|
42
|
+
with field-ui. */
|
|
51
43
|
[slot="label"] {
|
|
52
|
-
|
|
53
|
-
color: var(--input-
|
|
44
|
+
flex-shrink: 0;
|
|
45
|
+
color: var(--input-affix-fg);
|
|
46
|
+
font-size: var(--input-font-size);
|
|
47
|
+
user-select: none;
|
|
48
|
+
pointer-events: none;
|
|
54
49
|
}
|
|
55
|
-
[slot="label"][label]::after { content: attr(label); }
|
|
56
50
|
|
|
57
51
|
/* Field container */
|
|
58
52
|
[slot="field"] {
|
|
@@ -67,11 +61,18 @@
|
|
|
67
61
|
color: var(--input-fg);
|
|
68
62
|
font: inherit;
|
|
69
63
|
font-size: var(--input-font-size);
|
|
70
|
-
line-height: 1
|
|
64
|
+
/* line-height: 1.4 (not 1) so descender-bearing glyphs (g, j, p, q, y)
|
|
65
|
+
have room inside [slot="text"]'s line box. With line-height: 1 the
|
|
66
|
+
line box equals the em-square and descenders extend below it; the
|
|
67
|
+
overflow: hidden on [slot="text"] then clips them. The min-height
|
|
68
|
+
still controls overall chrome height — align-items: center keeps the
|
|
69
|
+
baseline centered regardless of line-height. */
|
|
70
|
+
line-height: 1.4;
|
|
71
71
|
cursor: text;
|
|
72
72
|
transition: border-color var(--input-duration) var(--input-easing);
|
|
73
73
|
}
|
|
74
74
|
:scope:not([disabled]) [slot="field"]:hover {
|
|
75
|
+
background: var(--input-bg-hover);
|
|
75
76
|
border-color: var(--input-border-hover);
|
|
76
77
|
color: var(--input-fg-hover);
|
|
77
78
|
}
|
|
@@ -90,9 +91,6 @@
|
|
|
90
91
|
:scope[error]:not([disabled]):focus-within [slot="field"] {
|
|
91
92
|
box-shadow: var(--input-focus-ring-invalid);
|
|
92
93
|
}
|
|
93
|
-
:scope:not([disabled]):focus-within [slot="label"] {
|
|
94
|
-
color: var(--input-label-fg-focus);
|
|
95
|
-
}
|
|
96
94
|
|
|
97
95
|
/* Text (contenteditable span) */
|
|
98
96
|
[slot="text"] {
|
|
@@ -120,7 +118,7 @@
|
|
|
120
118
|
|
|
121
119
|
/* Placeholder (contenteditable only) */
|
|
122
120
|
span[slot="text"][data-empty]::before {
|
|
123
|
-
content: attr(
|
|
121
|
+
content: attr(data-placeholder);
|
|
124
122
|
color: var(--input-placeholder-fg);
|
|
125
123
|
pointer-events: none;
|
|
126
124
|
}
|
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
* <input-ui> — Text input. The host IS the interactive surface.
|
|
3
3
|
* Uses contenteditable for text entry, ElementInternals for form participation.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Slots inside [slot="field"]:
|
|
6
|
+
* prefix → label → text → suffix
|
|
7
|
+
*
|
|
8
|
+
* <input-ui label="Email" placeholder="you@acme.com"></input-ui>
|
|
9
|
+
* <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
|
|
10
|
+
* <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
|
|
11
|
+
* <input-ui prefix="@" value="kim"></input-ui>
|
|
12
|
+
*
|
|
13
|
+
* label renders as a dim leading caption inside the chrome (next to the
|
|
14
|
+
* value, sharing the input's border) — for stacked label / hint / error
|
|
15
|
+
* compositions, wrap with field-ui.
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
18
|
import { AdiaFormElement } from '../../core/form.js';
|
|
@@ -15,6 +23,13 @@ const renderAffix = (v) => isIconName(v)
|
|
|
15
23
|
: v;
|
|
16
24
|
|
|
17
25
|
class AdiaInput extends AdiaFormElement {
|
|
26
|
+
// Opt out of AdiaFormElement's per-control `label` deprecation warning.
|
|
27
|
+
// input-ui's `label` is a first-class API rendering an inline-leading
|
|
28
|
+
// caption inside the chrome with `aria-labelledby` wiring on the
|
|
29
|
+
// editable surface — not the inert above-the-field rendering that
|
|
30
|
+
// motivated the deprecation.
|
|
31
|
+
static labelDeprecated = false;
|
|
32
|
+
|
|
18
33
|
static properties = {
|
|
19
34
|
...AdiaFormElement.properties,
|
|
20
35
|
placeholder: { type: String, default: '', reflect: true },
|
|
@@ -28,6 +43,8 @@ class AdiaInput extends AdiaFormElement {
|
|
|
28
43
|
static template = () => null;
|
|
29
44
|
|
|
30
45
|
#textEl = null;
|
|
46
|
+
#labelEl = null;
|
|
47
|
+
static #labelSeq = 0;
|
|
31
48
|
|
|
32
49
|
get #isNativeInput() {
|
|
33
50
|
return this.type === 'password' || this.type === 'number';
|
|
@@ -39,24 +56,28 @@ class AdiaInput extends AdiaFormElement {
|
|
|
39
56
|
|
|
40
57
|
if (!this.querySelector('[slot="text"]')) {
|
|
41
58
|
const useNative = this.#isNativeInput;
|
|
59
|
+
const labelId = this.label ? `input-label-${++AdiaInput.#labelSeq}` : '';
|
|
42
60
|
this.innerHTML = `
|
|
43
|
-
${this.label ? `<label slot="label" label="${this.label}"></label>` : ''}
|
|
44
61
|
<div slot="field">
|
|
45
62
|
${this.prefix ? `<span slot="prefix">${renderAffix(this.prefix)}</span>` : ''}
|
|
63
|
+
${this.label ? `<span slot="label" id="${labelId}">${this.label}</span>` : ''}
|
|
46
64
|
${useNative
|
|
47
65
|
? `<input slot="text" type="${this.type}" tabindex="0"
|
|
48
66
|
placeholder="${this.placeholder}" value="${this.value || ''}"
|
|
49
67
|
autocomplete="${this.type === 'password' ? 'current-password' : 'off'}"
|
|
68
|
+
${labelId ? `aria-labelledby="${labelId}"` : ''}
|
|
50
69
|
${this.disabled ? 'disabled' : ''} ${this.readonly ? 'readonly' : ''} />`
|
|
51
70
|
: `<span slot="text" contenteditable="plaintext-only" tabindex="0"
|
|
52
71
|
${this.value ? '' : 'data-empty=""'}
|
|
53
|
-
aria-
|
|
72
|
+
${labelId ? `aria-labelledby="${labelId}"` : ''}
|
|
73
|
+
data-placeholder="${this.placeholder}"></span>`}
|
|
54
74
|
${this.suffix ? `<span slot="suffix">${renderAffix(this.suffix)}</span>` : ''}
|
|
55
75
|
</div>
|
|
56
76
|
`;
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
this.#textEl = this.querySelector('[slot="text"]');
|
|
80
|
+
this.#labelEl = this.querySelector('[slot="label"]');
|
|
60
81
|
if (!this.#isNativeInput && this.value) this.#textEl.textContent = this.value;
|
|
61
82
|
|
|
62
83
|
if (this.#textEl) {
|
|
@@ -75,7 +96,7 @@ class AdiaInput extends AdiaFormElement {
|
|
|
75
96
|
this.#textEl.disabled = this.disabled;
|
|
76
97
|
this.#textEl.readOnly = this.readonly;
|
|
77
98
|
} else {
|
|
78
|
-
this.#textEl.setAttribute('
|
|
99
|
+
this.#textEl.setAttribute('data-placeholder', this.placeholder);
|
|
79
100
|
if (this.disabled || this.readonly) {
|
|
80
101
|
this.#textEl.contentEditable = 'false';
|
|
81
102
|
} else {
|
|
@@ -83,10 +104,15 @@ class AdiaInput extends AdiaFormElement {
|
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
|
|
87
|
-
if (label && this.label) label.setAttribute('label', this.label);
|
|
107
|
+
if (this.#labelEl) this.#labelEl.textContent = this.label || '';
|
|
88
108
|
|
|
89
|
-
|
|
109
|
+
if (this.label) {
|
|
110
|
+
this.removeAttribute('aria-label');
|
|
111
|
+
} else if (this.placeholder) {
|
|
112
|
+
this.setAttribute('aria-label', this.placeholder);
|
|
113
|
+
} else {
|
|
114
|
+
this.removeAttribute('aria-label');
|
|
115
|
+
}
|
|
90
116
|
}
|
|
91
117
|
|
|
92
118
|
#onInput = () => {
|
|
@@ -141,6 +167,7 @@ class AdiaInput extends AdiaFormElement {
|
|
|
141
167
|
this.#textEl.removeEventListener('paste', this.#onPaste);
|
|
142
168
|
}
|
|
143
169
|
this.#textEl = null;
|
|
170
|
+
this.#labelEl = null;
|
|
144
171
|
}
|
|
145
172
|
}
|
|
146
173
|
customElements.define('input-ui', AdiaInput);
|
|
@@ -47,7 +47,9 @@ props:
|
|
|
47
47
|
type: string
|
|
48
48
|
default: ""
|
|
49
49
|
label:
|
|
50
|
-
description:
|
|
50
|
+
description: Inline label rendered as a leading caption inside the input chrome,
|
|
51
|
+
between any prefix and the value. Wires aria-labelledby on the editable
|
|
52
|
+
surface. For stacked label / hint / error compositions, wrap with field-ui.
|
|
51
53
|
type: string
|
|
52
54
|
default: ""
|
|
53
55
|
maxlength:
|