@adia-ai/web-components 0.0.13 → 0.0.15

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.
@@ -31,6 +31,7 @@ import {
31
31
  import {
32
32
  HighlightStyle, syntaxHighlighting, LanguageSupport, defaultHighlightStyle,
33
33
  } from '@codemirror/language';
34
+ import { linter, lintGutter } from '@codemirror/lint';
34
35
  import { tags as t } from '@lezer/highlight';
35
36
 
36
37
  // ── Base theme (structural only — colors live in code.css) ───────────
@@ -91,6 +92,62 @@ export function importWithTimeout(loader, label) {
91
92
  ]);
92
93
  }
93
94
 
95
+ // ── Language-specific linters ────────────────────────────────────────
96
+ //
97
+ // Kept here (not in code.js) so code.js doesn't depend on the lint
98
+ // primitives directly. `code.js` picks the right linter by language
99
+ // name when editable mode is on. Phase 3 of SPEC-CODE-EDITOR-001 —
100
+ // JSON only for now; extend per-language as need arises.
101
+
102
+ /** JSON linter. Parses the full document; emits one error Diagnostic
103
+ * at the reported position on `SyntaxError`, else clean. The error
104
+ * position is parsed from the engine's message (V8 / Node ≥20 /
105
+ * Firefox / JavaScriptCore all shape these slightly differently) and
106
+ * falls back to the last character so a marker is always visible. */
107
+ export const jsonLinter = linter((view) => {
108
+ const text = view.state.doc.toString();
109
+ if (text.trim() === '') return [];
110
+ try {
111
+ JSON.parse(text);
112
+ return [];
113
+ } catch (err) {
114
+ const msg = err instanceof Error ? err.message : String(err);
115
+ const { from, to } = extractJsonErrorPos(msg, text);
116
+ return [{
117
+ from, to,
118
+ severity: 'error',
119
+ message: msg.replace(/^SyntaxError:\s*/, ''),
120
+ source: 'json',
121
+ }];
122
+ }
123
+ });
124
+
125
+ function extractJsonErrorPos(msg, text) {
126
+ const docLen = text.length;
127
+ // "at position N" — V8 / Chromium / pre-Node-20
128
+ const posMatch = msg.match(/at position (\d+)/);
129
+ if (posMatch) {
130
+ const from = Math.min(docLen, Number(posMatch[1]));
131
+ return { from, to: Math.min(docLen, from + 1) };
132
+ }
133
+ // "line N column M" — Firefox / Node ≥20
134
+ const lineColMatch = msg.match(/line (\d+) column (\d+)/);
135
+ if (lineColMatch) {
136
+ const line = Number(lineColMatch[1]);
137
+ const col = Number(lineColMatch[2]);
138
+ let lineStart = 0;
139
+ let currentLine = 1;
140
+ for (let i = 0; i < text.length && currentLine < line; i++) {
141
+ if (text[i] === '\n') { currentLine++; lineStart = i + 1; }
142
+ }
143
+ const from = Math.min(docLen, lineStart + Math.max(0, col - 1));
144
+ return { from, to: Math.min(docLen, from + 1) };
145
+ }
146
+ // Fallback: point at the last character so the marker is visible.
147
+ const end = Math.max(0, docLen);
148
+ return { from: Math.max(0, end - 1), to: end };
149
+ }
150
+
94
151
  // ── Re-exports (so code.js gets everything from one import) ─────────
95
152
 
96
153
  export {
@@ -100,4 +157,5 @@ export {
100
157
  placeholder, drawSelection, keymap,
101
158
  defaultKeymap, history, historyKeymap, indentWithTab,
102
159
  HighlightStyle, syntaxHighlighting, LanguageSupport, defaultHighlightStyle,
160
+ linter, lintGutter,
103
161
  };
@@ -13,9 +13,29 @@
13
13
  }
14
14
  ],
15
15
  "properties": {
16
+ "required": {
17
+ "description": "Form constraint — submission blocks if the editor is empty",
18
+ "type": "boolean",
19
+ "default": false
20
+ },
21
+ "bare": {
22
+ "description": "Omit the header chrome (label + copy button); for embedded editor surfaces",
23
+ "type": "boolean",
24
+ "default": false
25
+ },
16
26
  "component": {
17
27
  "const": "Code"
18
28
  },
29
+ "disabled": {
30
+ "description": "Disables input and excludes from form submission",
31
+ "type": "boolean",
32
+ "default": false
33
+ },
34
+ "editable": {
35
+ "description": "Editable CodeMirror instance (vs read-only display)",
36
+ "type": "boolean",
37
+ "default": false
38
+ },
19
39
  "inline": {
20
40
  "description": "Inline code (vs block)",
21
41
  "type": "boolean",
@@ -31,6 +51,21 @@
31
51
  "type": "boolean",
32
52
  "default": false
33
53
  },
54
+ "name": {
55
+ "description": "Form-control name — submitted with the enclosing form when `editable` is set",
56
+ "type": "string",
57
+ "default": ""
58
+ },
59
+ "placeholder": {
60
+ "description": "Placeholder text shown when the editor is empty (editable mode only)",
61
+ "type": "string",
62
+ "default": ""
63
+ },
64
+ "readonly": {
65
+ "description": "Blocks input but still submits with the form",
66
+ "type": "boolean",
67
+ "default": false
68
+ },
34
69
  "text": {
35
70
  "description": "Code text content",
36
71
  "type": "string",
@@ -45,8 +80,20 @@
45
80
  "anti_patterns": [],
46
81
  "category": "display",
47
82
  "events": {
83
+ "change": {
84
+ "description": "Fired on blur if the buffer changed since focus (editable mode); detail.value is the final buffer"
85
+ },
48
86
  "copy": {
49
87
  "description": "Fired when text is copied to clipboard"
88
+ },
89
+ "input": {
90
+ "description": "Fired on every doc change (editable mode); detail.value is the current buffer"
91
+ },
92
+ "language-load-error": {
93
+ "description": "Fired when the language pack or CodeMirror bundle fails to load; detail.phase is 'core' or 'language'"
94
+ },
95
+ "save": {
96
+ "description": "Fired on Mod+S keybind (editable mode); detail.value is the current buffer. Cancelable."
50
97
  }
51
98
  },
52
99
  "examples": [
@@ -82,6 +129,18 @@
82
129
  {
83
130
  "description": "Default, ready for interaction.",
84
131
  "name": "idle"
132
+ },
133
+ {
134
+ "description": "Editor is disabled — blocks input and excludes from form submission.",
135
+ "name": "disabled"
136
+ },
137
+ {
138
+ "description": "Editor blocks input but still submits with the form.",
139
+ "name": "readonly"
140
+ },
141
+ {
142
+ "description": "Editor has a validation error (e.g. `required` and empty). Reflects as `aria-invalid=\"true\"`.",
143
+ "name": "invalid"
85
144
  }
86
145
  ],
87
146
  "synonyms": {
@@ -8,10 +8,10 @@
8
8
  --code-copy-px: var(--a-space-1);
9
9
 
10
10
  /* ── Colors ── */
11
- --code-bg: var(--a-bg-muted);
11
+ --code-bg: var(--a-bg);
12
12
  --code-fg: var(--a-fg);
13
13
  --code-border: var(--a-border-subtle);
14
- --code-header-bg: var(--a-bg-subtle);
14
+ --code-header-bg: var(--a-bg-scrim);
15
15
  --code-header-fg: var(--a-fg-muted);
16
16
  --code-copy-hover-bg: var(--a-bg-muted);
17
17
  --code-copy-hover-fg: var(--a-fg);
@@ -57,6 +57,18 @@
57
57
  --code-tok-attribute: var(--a-warning-strong);
58
58
  --code-tok-url: var(--a-info-strong);
59
59
  --code-tok-invalid: var(--a-danger-strong);
60
+
61
+ /* ── Lint markers (Phase 3 — editable JSON) ──
62
+ `@codemirror/lint` paints gutter markers + diagnostic tooltips
63
+ + inline underlines. Only fires in editable mode with a linter
64
+ attached; read-only instances never see these selectors. */
65
+ --code-lint-gutter-bg: var(--a-bg-subtle);
66
+ --code-lint-error: var(--a-danger-strong);
67
+ --code-lint-warning: var(--a-warning-strong);
68
+ --code-lint-info: var(--a-info-strong);
69
+ --code-lint-tooltip-bg: var(--a-surface-floating);
70
+ --code-lint-tooltip-fg: var(--a-fg);
71
+ --code-lint-tooltip-border: var(--a-border-subtle);
60
72
  }
61
73
 
62
74
  /* ── Block (default) ── */
@@ -196,6 +208,30 @@
196
208
  background: transparent;
197
209
  }
198
210
 
211
+ /* ── Form states (editable-only surfaces) ──
212
+ `[disabled]` — visually muted, pointer-events disabled so the user
213
+ can't click into the editor. CM's readonly compartment handles the
214
+ actual edit-blocking; these rules are the visual cue.
215
+ `[readonly]` — muted but clickable (user can still select + copy).
216
+ `[aria-invalid="true"]` — error ring for form validation failures. */
217
+ :scope[disabled] {
218
+ opacity: 0.6;
219
+ cursor: not-allowed;
220
+ }
221
+ :scope[disabled] .cm-editor {
222
+ pointer-events: none;
223
+ }
224
+ :scope[readonly] .cm-editor {
225
+ background: var(--a-bg-subtle);
226
+ }
227
+ :scope[aria-invalid="true"] {
228
+ border-color: var(--a-danger-strong);
229
+ }
230
+ :scope[aria-invalid="true"] .cm-focused {
231
+ outline: 2px solid var(--a-danger-strong);
232
+ outline-offset: -1px;
233
+ }
234
+
199
235
  /* ── CodeMirror mount ──
200
236
  `<code-ui>` inserts `<div data-cm-mount>` in place of `<pre><code>`
201
237
  when `[language]` triggers the CM lazy-load. Styles below apply to
@@ -278,4 +314,44 @@
278
314
  :scope .tok-attribute { color: var(--code-tok-attribute); }
279
315
  :scope .tok-url { color: var(--code-tok-url); text-decoration: underline; }
280
316
  :scope .tok-invalid { color: var(--code-tok-invalid); text-decoration: wavy underline; }
317
+
318
+ /* ── Lint gutter + diagnostics ──
319
+ Styles the `@codemirror/lint` markers in the gutter, the inline
320
+ wavy underline on the diagnostic range, and the tooltip panel. */
321
+ :scope .cm-gutter-lint {
322
+ background: var(--code-lint-gutter-bg);
323
+ width: 1em;
324
+ }
325
+ :scope .cm-lint-marker {
326
+ width: 0.7em;
327
+ height: 0.7em;
328
+ margin: 0.15em 0.15em;
329
+ }
330
+ :scope .cm-lint-marker-error { color: var(--code-lint-error); }
331
+ :scope .cm-lint-marker-warning { color: var(--code-lint-warning); }
332
+ :scope .cm-lint-marker-info { color: var(--code-lint-info); }
333
+
334
+ :scope .cm-lintRange-error { background: none; text-decoration: wavy underline var(--code-lint-error); }
335
+ :scope .cm-lintRange-warning { background: none; text-decoration: wavy underline var(--code-lint-warning); }
336
+ :scope .cm-lintRange-info { background: none; text-decoration: wavy underline var(--code-lint-info); }
337
+
338
+ :scope .cm-tooltip.cm-tooltip-lint {
339
+ background: var(--code-lint-tooltip-bg);
340
+ color: var(--code-lint-tooltip-fg);
341
+ border: 1px solid var(--code-lint-tooltip-border);
342
+ border-radius: var(--code-radius-sm);
343
+ padding: var(--a-space-1) var(--a-space-2);
344
+ font-size: var(--a-ui-sm);
345
+ font-family: var(--a-font-family);
346
+ max-inline-size: 40ch;
347
+ }
348
+ :scope .cm-diagnostic {
349
+ padding: 0;
350
+ border-inline-start: 3px solid transparent;
351
+ padding-inline-start: var(--a-space-2);
352
+ }
353
+ :scope .cm-diagnostic-error { border-inline-start-color: var(--code-lint-error); }
354
+ :scope .cm-diagnostic-warning { border-inline-start-color: var(--code-lint-warning); }
355
+ :scope .cm-diagnostic-info { border-inline-start-color: var(--code-lint-info); }
356
+ :scope .cm-diagnosticText { white-space: pre-wrap; }
281
357
  }
@@ -2,6 +2,7 @@
2
2
  * <code-ui language="js">const x = 42;</code-ui>
3
3
  * <code-ui language="css" inline>color: red</code-ui>
4
4
  * <code-ui language="json" text='{ "hello": "world" }'></code-ui>
5
+ * <code-ui editable language="json" name="schema" required></code-ui>
5
6
  *
6
7
  * Inline or block code display with optional language label and copy
7
8
  * button. The body comes from (a) declarative children, or (b) the
@@ -10,8 +11,16 @@
10
11
  *
11
12
  * When `[language]` names a supported language (see SUPPORTED_LANGUAGES),
12
13
  * 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).
14
+ * syntax-highlighted rendering. Phase 1 shipped read-only; Phase 2 added
15
+ * `[editable]`; Phase 3 added lint + form-association (see
16
+ * `docs/specs/code-editor.md §12`).
17
+ *
18
+ * Form participation — editable instances are Form-Associated Custom
19
+ * Elements (FACE). Set `name`, `required`, `disabled`, `readonly` to
20
+ * opt into standard form behavior; the editor's live document
21
+ * becomes the form value. Non-editable instances declare
22
+ * `formAssociated = true` at the class level (required for FACE) but
23
+ * contribute nothing to form submission when `[editable]` is absent.
15
24
  */
16
25
 
17
26
  import { AdiaElement } from '../../core/element.js';
@@ -34,6 +43,8 @@ function canonicalLanguage(name) {
34
43
  }
35
44
 
36
45
  class AdiaCode extends AdiaElement {
46
+ static formAssociated = true;
47
+
37
48
  static properties = {
38
49
  language: { type: String, default: '', reflect: true },
39
50
  inline: { type: Boolean, default: false, reflect: true },
@@ -42,6 +53,14 @@ class AdiaCode extends AdiaElement {
42
53
  editable: { type: Boolean, default: false, reflect: true },
43
54
  bare: { type: Boolean, default: false, reflect: true },
44
55
  placeholder: { type: String, default: '', reflect: true },
56
+
57
+ // Form-association (active only when `editable` is set; non-editable
58
+ // instances are FACE-declared for class consistency but contribute
59
+ // nothing to form submission).
60
+ name: { type: String, default: '', reflect: true },
61
+ required: { type: Boolean, default: false, reflect: true },
62
+ disabled: { type: Boolean, default: false, reflect: true },
63
+ readonly: { type: Boolean, default: false, reflect: true },
45
64
  };
46
65
 
47
66
  static template = () => null;
@@ -52,6 +71,8 @@ class AdiaCode extends AdiaElement {
52
71
  #mountGen = 0; // bumps on every connect/disconnect — guards against stale mounts
53
72
  #pendingMount = false; // true while bundle/lang pack is loading
54
73
  #focusSnapshot = null; // doc text captured on focus; used to diff for `change` event
74
+ #initialDoc = ''; // captured at mount; used by formResetCallback
75
+ #editableCompartment = null; // CM Compartment — reconfigures readonly on disabled/readonly change
55
76
 
56
77
  #onCopyClick = () => this.#copy();
57
78
 
@@ -156,7 +177,7 @@ class AdiaCode extends AdiaElement {
156
177
  // we were loading. Discard.
157
178
  if (gen !== this.#mountGen || !this.isConnected) return;
158
179
 
159
- this.#attachEditor(bundle, extension);
180
+ this.#attachEditor(bundle, extension, lang);
160
181
  } catch (err) {
161
182
  this.dispatchEvent(new CustomEvent('language-load-error', {
162
183
  bubbles: true, detail: { phase: 'core', error: err },
@@ -168,21 +189,24 @@ class AdiaCode extends AdiaElement {
168
189
  }
169
190
  }
170
191
 
171
- #attachEditor(bundle, languageExtension) {
192
+ #attachEditor(bundle, languageExtension, lang) {
172
193
  const {
173
194
  EditorState, EditorView,
195
+ Compartment,
174
196
  lineNumbers,
175
197
  placeholder,
176
198
  syntaxHighlighting,
177
199
  history, historyKeymap, defaultKeymap, indentWithTab,
178
200
  keymap,
179
201
  adiaBaseTheme, adiaHighlightStyle,
202
+ lintGutter, jsonLinter,
180
203
  } = bundle;
181
204
 
182
205
  // Replace <pre><code> with a mount target. Keep <header> where it is.
183
206
  const pre = this.querySelector(':scope > pre');
184
207
  const prior = pre ? pre.querySelector('code')?.textContent ?? '' : '';
185
208
  const initial = this.text || prior || '';
209
+ this.#initialDoc = initial;
186
210
 
187
211
  const mount = document.createElement('div');
188
212
  mount.setAttribute('data-cm-mount', '');
@@ -205,9 +229,11 @@ class AdiaCode extends AdiaElement {
205
229
  ]),
206
230
  EditorView.updateListener.of((update) => {
207
231
  if (!update.docChanged) return;
232
+ const value = update.state.doc.toString();
233
+ this.#syncFormValue(value);
208
234
  this.dispatchEvent(new CustomEvent('input', {
209
235
  bubbles: true,
210
- detail: { value: update.state.doc.toString() },
236
+ detail: { value },
211
237
  }));
212
238
  }),
213
239
  EditorView.domEventHandlers({
@@ -228,19 +254,126 @@ class AdiaCode extends AdiaElement {
228
254
  },
229
255
  }),
230
256
  );
231
- } else {
232
- extensions.push(EditorView.editable.of(false));
233
- extensions.push(EditorState.readOnly.of(true));
234
257
  }
235
258
 
259
+ // Editable/readonly compartment — respects editable + disabled + readonly.
260
+ // Dynamic because `disabled` / `readonly` can change at runtime (e.g.
261
+ // `formDisabledCallback` from the owning form). Starts with the
262
+ // correct initial config; `#updateEditableState()` reconfigures on
263
+ // attribute change.
264
+ this.#editableCompartment = new Compartment();
265
+ extensions.push(
266
+ this.#editableCompartment.of(this.#editableExtensions(EditorView, EditorState)),
267
+ );
268
+
236
269
  if (languageExtension) extensions.push(languageExtension);
237
270
  if (this.lineNumbers) extensions.push(lineNumbers());
238
271
  if (this.placeholder) extensions.push(placeholder(this.placeholder));
239
272
 
273
+ // Lint: editable-only, language-specific. JSON for now; extend per
274
+ // language as need arises. The gutter shows the aggregate marker
275
+ // per line; the diagnostics themselves produce the tooltip + error
276
+ // underline on the offending range.
277
+ if (this.editable && lang === 'json') {
278
+ extensions.push(jsonLinter, lintGutter());
279
+ }
280
+
240
281
  this.#cmView = new EditorView({
241
282
  parent: mount,
242
283
  state: EditorState.create({ doc: initial, extensions }),
243
284
  });
285
+
286
+ // Seed form value + constraints now that the editor is live.
287
+ if (this.editable) this.#syncFormValue(initial);
288
+ }
289
+
290
+ /** Returns the CM extensions that enforce editable vs readonly state
291
+ * as a function of (editable && !disabled && !readonly). Called at
292
+ * mount time and on any runtime change via the compartment. */
293
+ #editableExtensions(EditorView, EditorState) {
294
+ const canEdit = this.editable && !this.disabled && !this.readonly;
295
+ if (canEdit) return [];
296
+ return [
297
+ EditorView.editable.of(false),
298
+ EditorState.readOnly.of(true),
299
+ ];
300
+ }
301
+
302
+ /** Re-evaluate the editable/readonly state against current attributes.
303
+ * Called from `render()` on property change. No-op if the editor
304
+ * hasn't mounted yet. */
305
+ async #updateEditableState() {
306
+ if (!this.#cmView || !this.#editableCompartment) return;
307
+ const bundle = await import('./code-editor.js');
308
+ this.#cmView.dispatch({
309
+ effects: this.#editableCompartment.reconfigure(
310
+ this.#editableExtensions(bundle.EditorView, bundle.EditorState),
311
+ ),
312
+ });
313
+ }
314
+
315
+ // ── Form-association (FACE) ───────────────────────────────────────
316
+
317
+ get form() { return this.internals.form; }
318
+ get labels() { return this.internals.labels; }
319
+ get validity() { return this.internals.validity; }
320
+ get validationMessage() { return this.internals.validationMessage; }
321
+ get willValidate() { return this.internals.willValidate; }
322
+
323
+ checkValidity() { return this.internals.checkValidity(); }
324
+ reportValidity() { return this.internals.reportValidity(); }
325
+
326
+ /** Push the current editor value to the form + re-run constraints.
327
+ * No-op when `editable` is false. Called on every CM doc change and
328
+ * from the initial mount + `render()` when `text` is driven externally. */
329
+ #syncFormValue(val) {
330
+ if (!this.editable) return;
331
+ val = val ?? this.value ?? '';
332
+ this.internals.setFormValue(val);
333
+ this.#runConstraints(val);
334
+ }
335
+
336
+ #runConstraints(val) {
337
+ val = val ?? '';
338
+ if (this.required && !val.trim()) {
339
+ this.internals.setValidity(
340
+ { valueMissing: true },
341
+ this.getAttribute('data-msg-required') || 'This field is required.',
342
+ this,
343
+ );
344
+ this.setAttribute('aria-invalid', 'true');
345
+ return false;
346
+ }
347
+ this.internals.setValidity({});
348
+ this.removeAttribute('aria-invalid');
349
+ return true;
350
+ }
351
+
352
+ formResetCallback() {
353
+ if (this.#cmView) {
354
+ this.#cmView.dispatch({
355
+ changes: { from: 0, to: this.#cmView.state.doc.length, insert: this.#initialDoc },
356
+ });
357
+ } else {
358
+ this.text = this.#initialDoc;
359
+ }
360
+ }
361
+
362
+ formDisabledCallback(disabled) {
363
+ this.disabled = disabled;
364
+ // `disabled` is reflected; the render() hook picks up the change
365
+ // and calls #updateEditableState().
366
+ }
367
+
368
+ formStateRestoreCallback(state, _reason) {
369
+ if (typeof state !== 'string') return;
370
+ if (this.#cmView) {
371
+ this.#cmView.dispatch({
372
+ changes: { from: 0, to: this.#cmView.state.doc.length, insert: state },
373
+ });
374
+ } else {
375
+ this.text = state;
376
+ }
244
377
  }
245
378
 
246
379
  #emitSave(view) {
@@ -284,6 +417,9 @@ class AdiaCode extends AdiaElement {
284
417
  const label = this.querySelector(':scope > header [slot="label"]');
285
418
  if (label && this.language) label.textContent = this.language;
286
419
 
420
+ // React to disabled/readonly toggles on a live editor.
421
+ this.#updateEditableState();
422
+
287
423
  // Keep the rendered code in sync with the reactive `text` property so
288
424
  // callers can drive updates via setAttribute('text', ...).
289
425
  if (!this.inline && this.text !== '') {
@@ -296,6 +432,8 @@ class AdiaCode extends AdiaElement {
296
432
  this.#cmView.dispatch({
297
433
  changes: { from: 0, to: current.length, insert: this.text },
298
434
  });
435
+ // CM's updateListener catches the dispatch and re-syncs the
436
+ // form value; nothing extra to do here.
299
437
  }
300
438
  } else {
301
439
  const code = this.querySelector(':scope > pre > code');
@@ -25,15 +25,57 @@ props:
25
25
  description: Code text content
26
26
  type: string
27
27
  default: ""
28
+ editable:
29
+ description: Editable CodeMirror instance (vs read-only display)
30
+ type: boolean
31
+ default: false
32
+ bare:
33
+ description: Omit the header chrome (label + copy button); for embedded editor surfaces
34
+ type: boolean
35
+ default: false
36
+ placeholder:
37
+ description: Placeholder text shown when the editor is empty (editable mode only)
38
+ type: string
39
+ default: ""
40
+ name:
41
+ description: Form-control name — submitted with the enclosing form when `editable` is set
42
+ type: string
43
+ default: ""
44
+ required:
45
+ description: Form constraint — submission blocks if the editor is empty
46
+ type: boolean
47
+ default: false
48
+ disabled:
49
+ description: Disables input and excludes from form submission
50
+ type: boolean
51
+ default: false
52
+ readonly:
53
+ description: Blocks input but still submits with the form
54
+ type: boolean
55
+ default: false
28
56
  events:
29
57
  copy:
30
58
  description: Fired when text is copied to clipboard
59
+ input:
60
+ description: Fired on every doc change (editable mode); detail.value is the current buffer
61
+ change:
62
+ description: Fired on blur if the buffer changed since focus (editable mode); detail.value is the final buffer
63
+ save:
64
+ description: Fired on Mod+S keybind (editable mode); detail.value is the current buffer. Cancelable.
65
+ language-load-error:
66
+ description: Fired when the language pack or CodeMirror bundle fails to load; detail.phase is 'core' or 'language'
31
67
  slots:
32
68
  default:
33
69
  description: Raw text fallback when the text property is not set
34
70
  states:
35
71
  - name: idle
36
72
  description: Default, ready for interaction.
73
+ - name: disabled
74
+ description: Editor is disabled — blocks input and excludes from form submission.
75
+ - name: readonly
76
+ description: Editor blocks input but still submits with the form.
77
+ - name: invalid
78
+ description: Editor has a validation error (e.g. `required` and empty). Reflects as `aria-invalid="true"`.
37
79
  traits: []
38
80
  tokens: {}
39
81
  a2ui:
@@ -61,12 +61,16 @@ class AdiaHeatmap extends AdiaElement {
61
61
  if (!this.#bound) {
62
62
  this.#bound = true;
63
63
  this.addEventListener('pointerover', this.#onHover);
64
+ this.addEventListener('pointermove', this.#onMove);
65
+ this.addEventListener('pointerleave', this.#onLeave);
64
66
  this.addEventListener('click', this.#onClick);
65
67
  }
66
68
  }
67
69
 
68
70
  disconnected() {
69
71
  this.removeEventListener('pointerover', this.#onHover);
72
+ this.removeEventListener('pointermove', this.#onMove);
73
+ this.removeEventListener('pointerleave', this.#onLeave);
70
74
  this.removeEventListener('click', this.#onClick);
71
75
  this.#bound = false;
72
76
  }
@@ -217,28 +221,73 @@ class AdiaHeatmap extends AdiaElement {
217
221
  return target?.closest?.('[data-cell]');
218
222
  }
219
223
 
220
- #onHover = (e) => {
221
- const cell = this.#findCell(e.target);
222
- if (!cell || !cell.dataset.v) return;
224
+ /* Compose the cell-level event with the canonical chart-* shape so that
225
+ tooltip-ui[follows=pointer][for=this-heatmap] can render without caring
226
+ whether the source is a chart or a heatmap. */
227
+ #chartDetail(cell, event) {
223
228
  const r = Number(cell.dataset.r);
224
229
  const c = Number(cell.dataset.c);
225
230
  const v = Number(cell.dataset.v);
226
231
  const label = cell.getAttribute('aria-label') || '';
227
- this.dispatchEvent(new CustomEvent('cell-hover', {
228
- detail: { r, c, v, label }, bubbles: true,
229
- }));
232
+ return {
233
+ r, c,
234
+ label,
235
+ value: Number.isFinite(v) ? v : null,
236
+ pct: null,
237
+ series: null,
238
+ slot: 0,
239
+ pointerX: event?.clientX ?? null,
240
+ pointerY: event?.clientY ?? null,
241
+ };
242
+ }
243
+
244
+ #hoveredCell = null;
245
+
246
+ #onHover = (e) => {
247
+ const cell = this.#findCell(e.target);
248
+ if (!cell || !cell.dataset.v) return;
249
+ this.#hoveredCell = cell;
250
+ const detail = this.#chartDetail(cell, e);
251
+ /* Legacy cell-specific shape — kept for back-compat. */
252
+ this.dispatchEvent(new CustomEvent('cell-hover', { detail, bubbles: true }));
253
+ /* Canonical chart-hover shape for tooltip-ui[follows=pointer]. */
254
+ this.dispatchEvent(new CustomEvent('chart-hover', { detail, bubbles: true }));
255
+ };
256
+
257
+ #onMove = (e) => {
258
+ const cell = this.#findCell(e.target);
259
+ if (!cell || !cell.dataset.v) {
260
+ if (this.#hoveredCell) {
261
+ this.#hoveredCell = null;
262
+ this.dispatchEvent(new CustomEvent('chart-leave', { bubbles: true }));
263
+ }
264
+ return;
265
+ }
266
+ if (cell !== this.#hoveredCell) {
267
+ this.#hoveredCell = cell;
268
+ const detail = this.#chartDetail(cell, e);
269
+ this.dispatchEvent(new CustomEvent('cell-hover', { detail, bubbles: true }));
270
+ this.dispatchEvent(new CustomEvent('chart-hover', { detail, bubbles: true }));
271
+ } else {
272
+ /* Pointer still inside the same cell — re-fire chart-hover so the
273
+ pointer-follow tooltip can reposition without re-painting content. */
274
+ const detail = this.#chartDetail(cell, e);
275
+ this.dispatchEvent(new CustomEvent('chart-hover', { detail, bubbles: true }));
276
+ }
277
+ };
278
+
279
+ #onLeave = () => {
280
+ if (!this.#hoveredCell) return;
281
+ this.#hoveredCell = null;
282
+ this.dispatchEvent(new CustomEvent('chart-leave', { bubbles: true }));
230
283
  };
231
284
 
232
285
  #onClick = (e) => {
233
286
  const cell = this.#findCell(e.target);
234
287
  if (!cell || !cell.dataset.v) return;
235
- const r = Number(cell.dataset.r);
236
- const c = Number(cell.dataset.c);
237
- const v = Number(cell.dataset.v);
238
- const label = cell.getAttribute('aria-label') || '';
239
- this.dispatchEvent(new CustomEvent('cell-click', {
240
- detail: { r, c, v, label }, bubbles: true,
241
- }));
288
+ const detail = this.#chartDetail(cell, e);
289
+ this.dispatchEvent(new CustomEvent('cell-click', { detail, bubbles: true }));
290
+ this.dispatchEvent(new CustomEvent('chart-select', { detail, bubbles: true }));
242
291
  };
243
292
  }
244
293