@adia-ai/web-components 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/calendar-picker/calendar-picker.css +8 -2
- package/components/chat/chat-input.css +41 -5
- 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 +8 -3
- package/components/textarea/textarea.css +12 -2
- package/components/upload/upload.css +5 -2
- package/core/_cm-core.js +38 -0
- package/core/_cm-theme.js +58 -0
- package/core/_lang-css.js +2 -0
- package/core/_lang-html.js +2 -0
- package/core/_lang-javascript.js +2 -0
- package/core/_lang-json.js +2 -0
- package/core/_lang-markdown.js +2 -0
- package/core/_lang-yaml.js +2 -0
- package/core/code-editor-bundle.js +63 -0
- package/core/element.test.js +234 -0
- package/core/form.js +26 -0
- package/core/markdown.js +8 -2
- package/core/template.js +2 -11
- package/package.json +1 -1
- package/styles/colors/semantics.css +39 -12
- package/styles/styles.css +1 -0
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
--calendar-picker-trigger-fg: var(--a-ui-text-subtle);
|
|
20
20
|
--calendar-picker-trigger-border: var(--a-ui-border);
|
|
21
21
|
--calendar-picker-trigger-border-hover: var(--a-ui-border-hover);
|
|
22
|
-
--calendar-picker-trigger-
|
|
22
|
+
--calendar-picker-trigger-focus-ring: var(--a-focus-ring);
|
|
23
|
+
--calendar-picker-trigger-focus-ring-invalid: var(--a-focus-ring-invalid);
|
|
23
24
|
--calendar-picker-trigger-placeholder-fg: var(--a-ui-text-placeholder);
|
|
24
25
|
--calendar-picker-trigger-fg-disabled: var(--a-ui-text-disabled);
|
|
25
26
|
|
|
@@ -138,7 +139,12 @@
|
|
|
138
139
|
|
|
139
140
|
:scope:focus-visible { outline: none; }
|
|
140
141
|
:scope:focus-visible [slot="trigger"] {
|
|
141
|
-
|
|
142
|
+
/* Canonical ring via L3 token (see semantics.css FOCUS block). */
|
|
143
|
+
box-shadow: var(--calendar-picker-trigger-focus-ring);
|
|
144
|
+
}
|
|
145
|
+
:scope[aria-invalid="true"]:focus-visible [slot="trigger"],
|
|
146
|
+
:scope[error]:focus-visible [slot="trigger"] {
|
|
147
|
+
box-shadow: var(--calendar-picker-trigger-focus-ring-invalid);
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
[slot="display"] {
|
|
@@ -11,10 +11,18 @@
|
|
|
11
11
|
/* ── Colors ── */
|
|
12
12
|
--chat-input-bg: var(--a-canvas-0);
|
|
13
13
|
--chat-input-border: var(--a-border-subtle);
|
|
14
|
-
--chat-input-border-focus: var(--a-fg);
|
|
15
14
|
--chat-input-caret-color: var(--a-fg-subtle);
|
|
16
15
|
--chat-input-border-disabled: var(--a-border-subtle);
|
|
17
16
|
|
|
17
|
+
/* Canonical focus ring — chat-input is a *nested-control host*.
|
|
18
|
+
See semantics.css FOCUS block + the nested-control pattern
|
|
19
|
+
note below. The inner textarea-ui suppresses its own ring
|
|
20
|
+
inside this @scope; the host paints the ring via
|
|
21
|
+
:focus-within so it wraps BOTH the editable area and the
|
|
22
|
+
toolbar as a single affordance. */
|
|
23
|
+
--chat-input-focus-ring: var(--a-focus-ring);
|
|
24
|
+
--chat-input-focus-ring-invalid: var(--a-focus-ring-invalid);
|
|
25
|
+
|
|
18
26
|
/* ── Image preview ── */
|
|
19
27
|
--chat-input-image-size: 3rem; /* 48px at d=1 */
|
|
20
28
|
--chat-input-image-radius: var(--a-radius-sm);
|
|
@@ -42,11 +50,30 @@
|
|
|
42
50
|
border: 1px solid var(--chat-input-border);
|
|
43
51
|
border-radius: var(--chat-input-radius);
|
|
44
52
|
background: var(--chat-input-bg);
|
|
45
|
-
transition: border-color var(--chat-input-duration) var(--chat-input-easing);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
/* ── Nested-control focus pattern ────────────────────────────────────
|
|
56
|
+
chat-input-ui is a composite whose "primary surface" is the
|
|
57
|
+
combined textarea + toolbar, not the textarea alone. Focus should
|
|
58
|
+
wrap the whole composite, not just the inner input. Two rules
|
|
59
|
+
enforce that:
|
|
60
|
+
|
|
61
|
+
1. `:scope:focus-within` paints the canonical ring around the
|
|
62
|
+
composite via box-shadow (matches every other form control).
|
|
63
|
+
2. The inner textarea-ui's own focus treatments are suppressed
|
|
64
|
+
inside this @scope — the host owns the affordance.
|
|
65
|
+
|
|
66
|
+
The @scope block's containment is the signal: no data attribute
|
|
67
|
+
or explicit opt-in required. Any future composite that wants the
|
|
68
|
+
same pattern drops its inner control(s) into its @scope and
|
|
69
|
+
suppresses their focus rules with equivalent rules below.
|
|
70
|
+
─────────────────────────────────────────────────────────────── */
|
|
71
|
+
:scope:focus-within {
|
|
72
|
+
box-shadow: var(--chat-input-focus-ring);
|
|
73
|
+
}
|
|
74
|
+
:scope[aria-invalid="true"]:focus-within,
|
|
75
|
+
:scope[error]:focus-within {
|
|
76
|
+
box-shadow: var(--chat-input-focus-ring-invalid);
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
/* Textarea: no border/bg of its own — container handles it. The
|
|
@@ -66,7 +93,16 @@
|
|
|
66
93
|
padding: var(--chat-input-textarea-pt) var(--chat-input-textarea-px) 0;
|
|
67
94
|
}
|
|
68
95
|
|
|
69
|
-
textarea-ui
|
|
96
|
+
/* Suppress the nested textarea-ui's own focus ring — the host (this
|
|
97
|
+
scope) owns the affordance. Required because textarea.css's default
|
|
98
|
+
rule paints a ring via box-shadow; without this override, the
|
|
99
|
+
composite would show both the host's outer ring AND the inner
|
|
100
|
+
control's ring simultaneously.
|
|
101
|
+
|
|
102
|
+
textarea.css's rule is `:scope:not([disabled]) [slot="text"]:focus`
|
|
103
|
+
with specificity (0,4,0); we need to beat that — using the same
|
|
104
|
+
`:not([disabled])` guard on the host lifts ours to (0,4,1). */
|
|
105
|
+
:scope textarea-ui:not([disabled]) [slot="text"]:focus {
|
|
70
106
|
border: none;
|
|
71
107
|
box-shadow: none;
|
|
72
108
|
}
|
package/components/code/code.css
CHANGED
|
@@ -24,6 +24,39 @@
|
|
|
24
24
|
/* ── Transition ── */
|
|
25
25
|
--code-duration: var(--a-duration-fast);
|
|
26
26
|
--code-easing: var(--a-easing);
|
|
27
|
+
|
|
28
|
+
/* ── Editor chrome (CodeMirror 6) ──
|
|
29
|
+
Active only when `[language]` triggers the CM mount. Still safe
|
|
30
|
+
to emit in read-only mode; unused until `.cm-editor` is in the
|
|
31
|
+
DOM. See docs/specs/code-editor.md §6. */
|
|
32
|
+
--code-gutter-bg: var(--a-bg-subtle);
|
|
33
|
+
--code-gutter-fg: var(--a-fg-muted);
|
|
34
|
+
--code-active-line-bg: color-mix(in oklch, var(--a-accent-muted) 40%, transparent);
|
|
35
|
+
--code-selection-bg: color-mix(in oklch, var(--a-accent-muted) 60%, transparent);
|
|
36
|
+
--code-selection-match: color-mix(in oklch, var(--a-accent-muted) 30%, transparent);
|
|
37
|
+
--code-cursor: var(--a-accent-strong);
|
|
38
|
+
--code-focus-ring: var(--a-focus-ring);
|
|
39
|
+
|
|
40
|
+
/* ── Syntax highlight tokens ──
|
|
41
|
+
Map Lezer/CodeMirror highlight tag names to AdiaUI semantic
|
|
42
|
+
tokens. Overridable per-instance by setting `--code-tok-*` on
|
|
43
|
+
any ancestor. See docs/specs/code-editor.md §6.3 for the tag →
|
|
44
|
+
class → role map. */
|
|
45
|
+
--code-tok-comment: var(--a-fg-subtle);
|
|
46
|
+
--code-tok-keyword: var(--a-accent-strong);
|
|
47
|
+
--code-tok-string: var(--a-success-strong);
|
|
48
|
+
--code-tok-number: var(--a-info-strong);
|
|
49
|
+
--code-tok-boolean: var(--a-info-strong);
|
|
50
|
+
--code-tok-operator: var(--a-fg);
|
|
51
|
+
--code-tok-punctuation: var(--a-fg-muted);
|
|
52
|
+
--code-tok-function: var(--a-brand-strong);
|
|
53
|
+
--code-tok-variable: var(--a-fg);
|
|
54
|
+
--code-tok-type: var(--a-warning-strong);
|
|
55
|
+
--code-tok-property: var(--a-fg);
|
|
56
|
+
--code-tok-tag: var(--a-accent-strong);
|
|
57
|
+
--code-tok-attribute: var(--a-warning-strong);
|
|
58
|
+
--code-tok-url: var(--a-info-strong);
|
|
59
|
+
--code-tok-invalid: var(--a-danger-strong);
|
|
27
60
|
}
|
|
28
61
|
|
|
29
62
|
/* ── Block (default) ── */
|
|
@@ -132,4 +165,117 @@
|
|
|
132
165
|
font-size: 0.9em;
|
|
133
166
|
overflow: visible;
|
|
134
167
|
}
|
|
168
|
+
|
|
169
|
+
/* ── Bare mode — no chrome ──
|
|
170
|
+
Strips border, background, radius, and padding. Header is hidden
|
|
171
|
+
via #stampBlock (it's never added in bare). Used by the A2UI Editor
|
|
172
|
+
and any pane-level context where <code-ui> is framed by a parent
|
|
173
|
+
surface and should not carry its own. Height flows from the parent
|
|
174
|
+
container, so CM's internal scroll handles overflow. */
|
|
175
|
+
:scope[bare] {
|
|
176
|
+
border: none;
|
|
177
|
+
border-radius: 0;
|
|
178
|
+
background: transparent;
|
|
179
|
+
overflow: visible;
|
|
180
|
+
height: 100%;
|
|
181
|
+
}
|
|
182
|
+
:scope[bare] > pre {
|
|
183
|
+
padding: 0;
|
|
184
|
+
height: 100%;
|
|
185
|
+
}
|
|
186
|
+
:scope[bare] > [data-cm-mount] {
|
|
187
|
+
height: 100%;
|
|
188
|
+
}
|
|
189
|
+
:scope[bare] .cm-editor {
|
|
190
|
+
height: 100%;
|
|
191
|
+
}
|
|
192
|
+
:scope[bare] .cm-scroller {
|
|
193
|
+
padding: 0;
|
|
194
|
+
}
|
|
195
|
+
:scope[bare] .cm-gutters {
|
|
196
|
+
background: transparent;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ── CodeMirror mount ──
|
|
200
|
+
`<code-ui>` inserts `<div data-cm-mount>` in place of `<pre><code>`
|
|
201
|
+
when `[language]` triggers the CM lazy-load. Styles below apply to
|
|
202
|
+
the CodeMirror DOM that CM renders inside that mount.
|
|
203
|
+
|
|
204
|
+
Selector discipline: `.cm-*` classes are CodeMirror's; `.tok-*`
|
|
205
|
+
classes come from our `adiaHighlightStyle` (see core/_cm-theme.js).
|
|
206
|
+
CM's CSS-in-JS stylesheets load first + at lower specificity, so
|
|
207
|
+
these rules override without !important. */
|
|
208
|
+
|
|
209
|
+
> [data-cm-mount] {
|
|
210
|
+
display: block;
|
|
211
|
+
overflow: hidden;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
:scope .cm-editor {
|
|
215
|
+
background: transparent;
|
|
216
|
+
color: var(--code-fg);
|
|
217
|
+
font-family: var(--code-font);
|
|
218
|
+
font-size: var(--code-font-size);
|
|
219
|
+
line-height: 1.5;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
:scope .cm-scroller {
|
|
223
|
+
font-family: inherit;
|
|
224
|
+
line-height: inherit;
|
|
225
|
+
padding: var(--code-py) var(--code-px);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
:scope .cm-content {
|
|
229
|
+
padding: 0;
|
|
230
|
+
caret-color: var(--code-cursor);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
:scope .cm-editor.cm-focused {
|
|
234
|
+
outline: 2px solid var(--code-focus-ring);
|
|
235
|
+
outline-offset: -2px;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
:scope .cm-gutters {
|
|
239
|
+
background: var(--code-gutter-bg);
|
|
240
|
+
color: var(--code-gutter-fg);
|
|
241
|
+
border-right: 1px solid var(--code-border);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
:scope .cm-activeLine {
|
|
245
|
+
background: var(--code-active-line-bg);
|
|
246
|
+
}
|
|
247
|
+
:scope .cm-activeLineGutter {
|
|
248
|
+
background: var(--code-active-line-bg);
|
|
249
|
+
color: var(--code-fg);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
:scope .cm-selectionBackground,
|
|
253
|
+
:scope ::selection {
|
|
254
|
+
background: var(--code-selection-bg);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
:scope .cm-selectionMatch {
|
|
258
|
+
background: var(--code-selection-match);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
:scope .cm-cursor {
|
|
262
|
+
border-left-color: var(--code-cursor);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* Syntax highlight — one rule per token role (see core/_cm-theme.js) */
|
|
266
|
+
:scope .tok-comment { color: var(--code-tok-comment); font-style: italic; }
|
|
267
|
+
:scope .tok-keyword { color: var(--code-tok-keyword); font-weight: 500; }
|
|
268
|
+
:scope .tok-string { color: var(--code-tok-string); }
|
|
269
|
+
:scope .tok-number { color: var(--code-tok-number); }
|
|
270
|
+
:scope .tok-boolean { color: var(--code-tok-boolean); }
|
|
271
|
+
:scope .tok-operator { color: var(--code-tok-operator); }
|
|
272
|
+
:scope .tok-punctuation { color: var(--code-tok-punctuation); }
|
|
273
|
+
:scope .tok-function { color: var(--code-tok-function); }
|
|
274
|
+
:scope .tok-variable { color: var(--code-tok-variable); }
|
|
275
|
+
:scope .tok-type { color: var(--code-tok-type); font-weight: 500; }
|
|
276
|
+
:scope .tok-property { color: var(--code-tok-property); }
|
|
277
|
+
:scope .tok-tag { color: var(--code-tok-tag); }
|
|
278
|
+
:scope .tok-attribute { color: var(--code-tok-attribute); }
|
|
279
|
+
:scope .tok-url { color: var(--code-tok-url); text-decoration: underline; }
|
|
280
|
+
:scope .tok-invalid { color: var(--code-tok-invalid); text-decoration: wavy underline; }
|
|
135
281
|
}
|
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('../../core/code-editor-bundle.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
|
}
|