@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
package/components/code/code.js
CHANGED
|
@@ -3,30 +3,61 @@
|
|
|
3
3
|
* <code-ui language="css" inline>color: red</code-ui>
|
|
4
4
|
* <code-ui language="json" text='{ "hello": "world" }'></code-ui>
|
|
5
5
|
*
|
|
6
|
-
* Inline or block code display with optional language label and copy
|
|
7
|
-
* The body comes from (a) declarative children, or (b) the
|
|
8
|
-
* the `text` path is reactive, so setting
|
|
9
|
-
* updates the rendered code in place.
|
|
6
|
+
* Inline or block code display with optional language label and copy
|
|
7
|
+
* button. The body comes from (a) declarative children, or (b) the
|
|
8
|
+
* `text` attribute — the `text` path is reactive, so setting
|
|
9
|
+
* `code.setAttribute('text', newText)` updates the rendered code in place.
|
|
10
|
+
*
|
|
11
|
+
* When `[language]` names a supported language (see SUPPORTED_LANGUAGES),
|
|
12
|
+
* CodeMirror 6 is lazy-loaded and mounted in place of `<pre><code>` for
|
|
13
|
+
* syntax-highlighted read-only rendering. Phase 1 ships read-only only;
|
|
14
|
+
* `[editable]` lands in phase 2 (see docs/specs/code-editor.md §12).
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import { AdiaElement } from '../../core/element.js';
|
|
13
18
|
|
|
19
|
+
const SUPPORTED_LANGUAGES = new Set([
|
|
20
|
+
'json', 'html', 'javascript', 'js', 'css', 'markdown', 'md', 'yaml', 'yml',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
// Normalize common language aliases to their canonical pack name.
|
|
24
|
+
const LANGUAGE_ALIAS = {
|
|
25
|
+
js: 'javascript',
|
|
26
|
+
md: 'markdown',
|
|
27
|
+
yml: 'yaml',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function canonicalLanguage(name) {
|
|
31
|
+
if (!name) return '';
|
|
32
|
+
const lower = String(name).toLowerCase();
|
|
33
|
+
return LANGUAGE_ALIAS[lower] ?? lower;
|
|
34
|
+
}
|
|
35
|
+
|
|
14
36
|
class AdiaCode extends AdiaElement {
|
|
15
37
|
static properties = {
|
|
16
38
|
language: { type: String, default: '', reflect: true },
|
|
17
39
|
inline: { type: Boolean, default: false, reflect: true },
|
|
18
40
|
text: { type: String, default: '', reflect: true },
|
|
19
41
|
lineNumbers: { type: Boolean, default: false, reflect: true, attribute: 'line-numbers' },
|
|
42
|
+
editable: { type: Boolean, default: false, reflect: true },
|
|
43
|
+
bare: { type: Boolean, default: false, reflect: true },
|
|
44
|
+
placeholder: { type: String, default: '', reflect: true },
|
|
20
45
|
};
|
|
21
46
|
|
|
22
47
|
static template = () => null;
|
|
23
48
|
|
|
24
49
|
#copyBtn = null;
|
|
25
50
|
#copyTimer = null;
|
|
51
|
+
#cmView = null; // EditorView instance when mounted
|
|
52
|
+
#mountGen = 0; // bumps on every connect/disconnect — guards against stale mounts
|
|
53
|
+
#pendingMount = false; // true while bundle/lang pack is loading
|
|
54
|
+
#focusSnapshot = null; // doc text captured on focus; used to diff for `change` event
|
|
26
55
|
|
|
27
56
|
#onCopyClick = () => this.#copy();
|
|
28
57
|
|
|
29
58
|
connected() {
|
|
59
|
+
this.#mountGen += 1;
|
|
60
|
+
|
|
30
61
|
if (!this.inline && !this.querySelector(':scope > pre')) {
|
|
31
62
|
this.#stampBlock();
|
|
32
63
|
}
|
|
@@ -34,9 +65,20 @@ class AdiaCode extends AdiaElement {
|
|
|
34
65
|
if (this.#copyBtn) {
|
|
35
66
|
this.#copyBtn.addEventListener('click', this.#onCopyClick);
|
|
36
67
|
}
|
|
68
|
+
|
|
69
|
+
// Mount CodeMirror when the language is supported OR the element is
|
|
70
|
+
// editable (editable plain-text is still useful). Inline instances
|
|
71
|
+
// stay on the static <code> path. Mount failures leave the static
|
|
72
|
+
// fallback in place — it's already visible.
|
|
73
|
+
const lang = canonicalLanguage(this.language);
|
|
74
|
+
const shouldMount = !this.inline && (SUPPORTED_LANGUAGES.has(lang) || this.editable);
|
|
75
|
+
if (shouldMount) {
|
|
76
|
+
this.#mountEditor();
|
|
77
|
+
}
|
|
37
78
|
}
|
|
38
79
|
|
|
39
80
|
disconnected() {
|
|
81
|
+
this.#mountGen += 1; // invalidate any in-flight mounts
|
|
40
82
|
if (this.#copyTimer != null) {
|
|
41
83
|
clearTimeout(this.#copyTimer);
|
|
42
84
|
this.#copyTimer = null;
|
|
@@ -45,6 +87,10 @@ class AdiaCode extends AdiaElement {
|
|
|
45
87
|
this.#copyBtn.removeEventListener('click', this.#onCopyClick);
|
|
46
88
|
this.#copyBtn = null;
|
|
47
89
|
}
|
|
90
|
+
if (this.#cmView) {
|
|
91
|
+
this.#cmView.destroy();
|
|
92
|
+
this.#cmView = null;
|
|
93
|
+
}
|
|
48
94
|
}
|
|
49
95
|
|
|
50
96
|
#stampBlock() {
|
|
@@ -54,20 +100,26 @@ class AdiaCode extends AdiaElement {
|
|
|
54
100
|
const raw = (this.text || this.textContent || '').trim();
|
|
55
101
|
this.textContent = '';
|
|
56
102
|
|
|
57
|
-
// Header —
|
|
58
|
-
|
|
103
|
+
// Header — omitted in [bare] mode. Consumers that need zero chrome
|
|
104
|
+
// (the A2UI Editor's Schema + DOM views, an embedded editable surface
|
|
105
|
+
// in a pane, etc.) set [bare] to opt out.
|
|
106
|
+
if (!this.bare) {
|
|
107
|
+
const header = document.createElement('header');
|
|
59
108
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
109
|
+
const label = document.createElement('span');
|
|
110
|
+
label.setAttribute('slot', 'label');
|
|
111
|
+
label.textContent = this.language || 'code';
|
|
112
|
+
header.appendChild(label);
|
|
64
113
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
114
|
+
const copyBtn = document.createElement('div');
|
|
115
|
+
copyBtn.setAttribute('role', 'button');
|
|
116
|
+
copyBtn.setAttribute('tabindex', '0');
|
|
117
|
+
copyBtn.setAttribute('slot', 'copy');
|
|
118
|
+
copyBtn.textContent = 'Copy';
|
|
119
|
+
header.appendChild(copyBtn);
|
|
120
|
+
|
|
121
|
+
this.appendChild(header);
|
|
122
|
+
}
|
|
71
123
|
|
|
72
124
|
// Pre > Code — direct semantic elements, no slot attr
|
|
73
125
|
const pre = document.createElement('pre');
|
|
@@ -75,14 +127,147 @@ class AdiaCode extends AdiaElement {
|
|
|
75
127
|
code.textContent = raw;
|
|
76
128
|
pre.appendChild(code);
|
|
77
129
|
|
|
78
|
-
this.appendChild(header);
|
|
79
130
|
this.appendChild(pre);
|
|
80
131
|
}
|
|
81
132
|
|
|
82
|
-
#
|
|
133
|
+
async #mountEditor() {
|
|
134
|
+
if (this.#pendingMount || this.#cmView) return;
|
|
135
|
+
const gen = this.#mountGen;
|
|
136
|
+
const lang = canonicalLanguage(this.language);
|
|
137
|
+
this.#pendingMount = true;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const bundle = await import('./code-editor.js');
|
|
141
|
+
|
|
142
|
+
// Load the language pack with a timeout. If it fails, continue with
|
|
143
|
+
// core-only (editor still renders; no syntax highlight). Emit an
|
|
144
|
+
// event so consumers can observe.
|
|
145
|
+
let extension = null;
|
|
146
|
+
try {
|
|
147
|
+
const mod = await bundle.importWithTimeout(bundle.languages[lang], `lang-${lang}`);
|
|
148
|
+
extension = mod?.extension ?? null;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
this.dispatchEvent(new CustomEvent('language-load-error', {
|
|
151
|
+
bubbles: true, detail: { phase: 'language', language: lang, error: err },
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Stale — component was disconnected (or reconnected elsewhere) while
|
|
156
|
+
// we were loading. Discard.
|
|
157
|
+
if (gen !== this.#mountGen || !this.isConnected) return;
|
|
158
|
+
|
|
159
|
+
this.#attachEditor(bundle, extension);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
this.dispatchEvent(new CustomEvent('language-load-error', {
|
|
162
|
+
bubbles: true, detail: { phase: 'core', error: err },
|
|
163
|
+
}));
|
|
164
|
+
// Fallback <pre><code> stays visible — nothing to revert.
|
|
165
|
+
console.warn('[code-ui] CodeMirror failed to load; staying on static fallback.', err);
|
|
166
|
+
} finally {
|
|
167
|
+
this.#pendingMount = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#attachEditor(bundle, languageExtension) {
|
|
172
|
+
const {
|
|
173
|
+
EditorState, EditorView,
|
|
174
|
+
lineNumbers,
|
|
175
|
+
placeholder,
|
|
176
|
+
syntaxHighlighting,
|
|
177
|
+
history, historyKeymap, defaultKeymap, indentWithTab,
|
|
178
|
+
keymap,
|
|
179
|
+
adiaBaseTheme, adiaHighlightStyle,
|
|
180
|
+
} = bundle;
|
|
181
|
+
|
|
182
|
+
// Replace <pre><code> with a mount target. Keep <header> where it is.
|
|
183
|
+
const pre = this.querySelector(':scope > pre');
|
|
184
|
+
const prior = pre ? pre.querySelector('code')?.textContent ?? '' : '';
|
|
185
|
+
const initial = this.text || prior || '';
|
|
186
|
+
|
|
187
|
+
const mount = document.createElement('div');
|
|
188
|
+
mount.setAttribute('data-cm-mount', '');
|
|
189
|
+
if (pre) pre.replaceWith(mount);
|
|
190
|
+
else this.appendChild(mount);
|
|
191
|
+
|
|
192
|
+
const extensions = [
|
|
193
|
+
adiaBaseTheme,
|
|
194
|
+
syntaxHighlighting(adiaHighlightStyle),
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
if (this.editable) {
|
|
198
|
+
extensions.push(
|
|
199
|
+
history(),
|
|
200
|
+
keymap.of([
|
|
201
|
+
{ key: 'Mod-s', run: (v) => { this.#emitSave(v); return true; } },
|
|
202
|
+
...defaultKeymap,
|
|
203
|
+
...historyKeymap,
|
|
204
|
+
indentWithTab,
|
|
205
|
+
]),
|
|
206
|
+
EditorView.updateListener.of((update) => {
|
|
207
|
+
if (!update.docChanged) return;
|
|
208
|
+
this.dispatchEvent(new CustomEvent('input', {
|
|
209
|
+
bubbles: true,
|
|
210
|
+
detail: { value: update.state.doc.toString() },
|
|
211
|
+
}));
|
|
212
|
+
}),
|
|
213
|
+
EditorView.domEventHandlers({
|
|
214
|
+
focus: (_, view) => {
|
|
215
|
+
this.#focusSnapshot = view.state.doc.toString();
|
|
216
|
+
return false;
|
|
217
|
+
},
|
|
218
|
+
blur: (_, view) => {
|
|
219
|
+
const current = view.state.doc.toString();
|
|
220
|
+
if (this.#focusSnapshot !== null && current !== this.#focusSnapshot) {
|
|
221
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
222
|
+
bubbles: true,
|
|
223
|
+
detail: { value: current },
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
this.#focusSnapshot = null;
|
|
227
|
+
return false;
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
} else {
|
|
232
|
+
extensions.push(EditorView.editable.of(false));
|
|
233
|
+
extensions.push(EditorState.readOnly.of(true));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (languageExtension) extensions.push(languageExtension);
|
|
237
|
+
if (this.lineNumbers) extensions.push(lineNumbers());
|
|
238
|
+
if (this.placeholder) extensions.push(placeholder(this.placeholder));
|
|
239
|
+
|
|
240
|
+
this.#cmView = new EditorView({
|
|
241
|
+
parent: mount,
|
|
242
|
+
state: EditorState.create({ doc: initial, extensions }),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#emitSave(view) {
|
|
247
|
+
this.dispatchEvent(new CustomEvent('save', {
|
|
248
|
+
bubbles: true, cancelable: true,
|
|
249
|
+
detail: { value: view.state.doc.toString() },
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Live buffer. Reads from CodeMirror when mounted, else the static <code>. */
|
|
254
|
+
get value() {
|
|
255
|
+
if (this.#cmView) return this.#cmView.state.doc.toString();
|
|
83
256
|
const code = this.querySelector(':scope > pre > code');
|
|
84
|
-
|
|
85
|
-
|
|
257
|
+
return code?.textContent ?? this.text ?? '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Convenience setter — identical to `text` but matches the form-control idiom. */
|
|
261
|
+
set value(v) {
|
|
262
|
+
this.text = String(v ?? '');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#copy() {
|
|
266
|
+
const text = this.#cmView
|
|
267
|
+
? this.#cmView.state.doc.toString()
|
|
268
|
+
: (this.querySelector(':scope > pre > code')?.textContent ?? '');
|
|
269
|
+
if (!text) return;
|
|
270
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
86
271
|
const btn = this.querySelector(':scope > header [slot="copy"]');
|
|
87
272
|
if (!btn) return;
|
|
88
273
|
const prev = btn.textContent;
|
|
@@ -101,10 +286,22 @@ class AdiaCode extends AdiaElement {
|
|
|
101
286
|
|
|
102
287
|
// Keep the rendered code in sync with the reactive `text` property so
|
|
103
288
|
// callers can drive updates via setAttribute('text', ...).
|
|
104
|
-
if (!this.inline) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
289
|
+
if (!this.inline && this.text !== '') {
|
|
290
|
+
if (this.#cmView) {
|
|
291
|
+
// Don't clobber in-flight edits while the editor has keyboard focus.
|
|
292
|
+
// Callers that want to force-replace can blur the editor first.
|
|
293
|
+
if (this.editable && this.#cmView.hasFocus) return;
|
|
294
|
+
const current = this.#cmView.state.doc.toString();
|
|
295
|
+
if (current !== this.text) {
|
|
296
|
+
this.#cmView.dispatch({
|
|
297
|
+
changes: { from: 0, to: current.length, insert: this.text },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
const code = this.querySelector(':scope > pre > code');
|
|
302
|
+
if (code && code.textContent !== this.text) {
|
|
303
|
+
code.textContent = this.text;
|
|
304
|
+
}
|
|
108
305
|
}
|
|
109
306
|
}
|
|
110
307
|
}
|
|
@@ -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
|
+
}
|