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