@adia-ai/web-components 0.6.37 → 0.6.38

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/components/accordion/accordion-item.a2ui.json +3 -0
  3. package/components/accordion/accordion-item.yaml +5 -0
  4. package/components/action-list/action-item.a2ui.json +5 -1
  5. package/components/action-list/action-item.yaml +7 -0
  6. package/components/card/card.a2ui.json +17 -1
  7. package/components/card/card.yaml +24 -1
  8. package/components/empty-state/empty-state.a2ui.json +9 -0
  9. package/components/empty-state/empty-state.yaml +15 -0
  10. package/components/feed/feed-item.a2ui.json +5 -0
  11. package/components/feed/feed-item.yaml +10 -0
  12. package/components/field/field.a2ui.json +6 -0
  13. package/components/field/field.yaml +10 -0
  14. package/components/index.js +2 -0
  15. package/components/inline-edit/inline-edit.a2ui.json +159 -0
  16. package/components/inline-edit/inline-edit.class.js +184 -0
  17. package/components/inline-edit/inline-edit.css +62 -0
  18. package/components/inline-edit/inline-edit.d.ts +52 -0
  19. package/components/inline-edit/inline-edit.js +12 -0
  20. package/components/inline-edit/inline-edit.yaml +125 -0
  21. package/components/list/list-item.a2ui.json +8 -1
  22. package/components/list/list-item.yaml +12 -0
  23. package/components/list/list.css +36 -6
  24. package/components/mark/mark.a2ui.json +109 -0
  25. package/components/mark/mark.class.js +22 -0
  26. package/components/mark/mark.css +39 -0
  27. package/components/mark/mark.d.ts +27 -0
  28. package/components/mark/mark.js +12 -0
  29. package/components/mark/mark.yaml +87 -0
  30. package/components/modal/modal.a2ui.json +9 -0
  31. package/components/modal/modal.yaml +14 -0
  32. package/components/nav-group/nav-group.a2ui.json +3 -0
  33. package/components/nav-group/nav-group.yaml +5 -0
  34. package/components/nav-item/nav-item.a2ui.json +3 -0
  35. package/components/nav-item/nav-item.yaml +5 -0
  36. package/components/segmented/segmented.class.js +10 -2
  37. package/components/select/select.a2ui.json +3 -0
  38. package/components/select/select.yaml +5 -0
  39. package/components/slider/slider.a2ui.json +6 -0
  40. package/components/slider/slider.yaml +10 -0
  41. package/components/stat/stat.css +18 -14
  42. package/components/stepper/stepper-item.a2ui.json +3 -0
  43. package/components/stepper/stepper-item.yaml +5 -0
  44. package/components/timeline/timeline-item.a2ui.json +8 -1
  45. package/components/timeline/timeline-item.yaml +12 -0
  46. package/components/tree/tree-item.a2ui.json +5 -1
  47. package/components/tree/tree-item.yaml +7 -0
  48. package/components/tree/tree.a2ui.json +3 -0
  49. package/components/tree/tree.yaml +5 -0
  50. package/dist/web-components.min.css +1 -1
  51. package/dist/web-components.min.js +74 -74
  52. package/package.json +1 -1
  53. package/styles/components.css +2 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.6.38] — 2026-05-26
4
+
5
+ ### Added — Wave 5e cohort: 3 new primitives + slot-vocab-vs-css backlog closed
6
+
7
+ - **`<mark-ui>`** — inline highlighted text primitive (#357)
8
+ - **`<inline-edit-ui>`** — click-to-edit text in place (#212)
9
+ - **Notification deep-link demo + Mark All Read verify** for `<feed-ui>` (#344/#343)
10
+
11
+ ### Fixed — substrate polish across the v0.6.38 window
12
+
13
+ - **`<list-item-ui>`**: slot selectors changed from direct-child to descendant — description row now lands on row 2 as intended
14
+ - **`<segmented-ui>`**: stale indicator stripping — duplicate pills on re-render eliminated
15
+ - **`<stat-ui>`**: chart slot bottom-aligns to value baseline; aspect 4:3
16
+ - **`<toolbar-ui>`** mixed-controls demo: `<segment-ui>` (not `<button-ui>`) inside `<segmented-ui>`
17
+
18
+ ### Maintenance — slot-vocab-vs-css audit reached 0 critical findings
19
+
20
+ - `audit-slot-vocab-vs-css` substrate backlog closed → gate now enforced. Multi-yaml + @scope-aware pairing landed in the audit script.
21
+ - 5 Wave 5a/b/c/d gap-analysis flips landed (substrate-verified as covered; no new code).
22
+
3
23
  ## [0.6.37] — 2026-05-25
4
24
 
5
25
  ### Changed — `--a-warning-bg` redirected from `-strong` to `-20-tint` (bright caution-tape amber, scheme-independent)
@@ -63,6 +63,9 @@
63
63
  "action": {
64
64
  "description": "§206 (v0.5.7): action buttons inside a custom header (e.g. Copy /\nDownload / settings). Children placed at `[slot=\"action\"]` (or\n`[slot=\"actions\"]`, or marked `[data-no-toggle]`) are excluded from\nthe toggle-on-click cascade — clicking them fires their own handler\nwithout also toggling the section."
65
65
  },
66
+ "body": {
67
+ "description": "The section's collapsible content. Renders below the header; hidden when `[open]` is unset. Author-fills with prose, lists, embedded forms, or any rich markup the panel needs."
68
+ },
66
69
  "header": {
67
70
  "description": "Custom header content. By default `[text]` renders as a plain header\nlabel, but a `[slot=\"header\"]` override lets consumers author rich\nheaders (icon + title + action buttons + caret)."
68
71
  }
@@ -39,6 +39,11 @@ slots:
39
39
  `[slot="actions"]`, or marked `[data-no-toggle]`) are excluded from
40
40
  the toggle-on-click cascade — clicking them fires their own handler
41
41
  without also toggling the section.
42
+ body:
43
+ description: >-
44
+ The section's collapsible content. Renders below the header; hidden
45
+ when `[open]` is unset. Author-fills with prose, lists, embedded
46
+ forms, or any rich markup the panel needs.
42
47
  events:
43
48
  toggle:
44
49
  description: Fired when the section opens or closes.
@@ -62,7 +62,11 @@
62
62
  "MenuItem",
63
63
  "Button"
64
64
  ],
65
- "slots": {},
65
+ "slots": {
66
+ "icon": {
67
+ "description": "Override the [icon] glyph with a custom slotted element (custom icon-ui, image, avatar). Mutually exclusive with the [icon] attribute — slot child wins."
68
+ }
69
+ },
66
70
  "states": [],
67
71
  "status": "stable",
68
72
  "synonyms": {
@@ -37,6 +37,13 @@ props:
37
37
  type: boolean
38
38
  default: false
39
39
 
40
+ slots:
41
+ icon:
42
+ description: >-
43
+ Override the [icon] glyph with a custom slotted element (custom
44
+ icon-ui, image, avatar). Mutually exclusive with the [icon]
45
+ attribute — slot child wins.
46
+
40
47
  keywords:
41
48
  - action-item
42
49
  - command-row
@@ -113,7 +113,23 @@
113
113
  "alert",
114
114
  "skeleton"
115
115
  ],
116
- "slots": {},
116
+ "slots": {
117
+ "description": {
118
+ "description": "Optional descriptive text beneath the heading. Renders in the header slot at body-subtle typography. Use for short metadata lines (timestamp, author, status sentence)."
119
+ },
120
+ "action": {
121
+ "description": "Trailing action cluster in the header (e.g. icon-buttons, menu trigger, more-options). Aligns to the header's flex-end edge."
122
+ },
123
+ "action-leading": {
124
+ "description": "Leading action cluster in the header (e.g. back button, switcher, breadcrumb-context). Aligns to the header's flex-start edge, before the icon/heading column."
125
+ },
126
+ "heading": {
127
+ "description": "Card title. Renders in the header slot with title typography. Typically a short noun phrase or document/object name."
128
+ },
129
+ "icon": {
130
+ "description": "Optional leading icon for the card header (status / brand / type marker). Renders next to the heading. Use `<icon-ui name=\"…\">` or any inline icon element."
131
+ }
132
+ },
117
133
  "states": [
118
134
  {
119
135
  "description": "Default, ready for interaction.",
@@ -58,7 +58,30 @@ props:
58
58
  - soft
59
59
  - primary
60
60
  events: {}
61
- slots: {}
61
+ slots:
62
+ icon:
63
+ description: >-
64
+ Optional leading icon for the card header (status / brand / type
65
+ marker). Renders next to the heading. Use `<icon-ui name="…">` or
66
+ any inline icon element.
67
+ heading:
68
+ description: >-
69
+ Card title. Renders in the header slot with title typography.
70
+ Typically a short noun phrase or document/object name.
71
+ description:
72
+ description: >-
73
+ Optional descriptive text beneath the heading. Renders in the
74
+ header slot at body-subtle typography. Use for short metadata
75
+ lines (timestamp, author, status sentence).
76
+ action:
77
+ description: >-
78
+ Trailing action cluster in the header (e.g. icon-buttons, menu
79
+ trigger, more-options). Aligns to the header's flex-end edge.
80
+ action-leading:
81
+ description: >-
82
+ Leading action cluster in the header (e.g. back button, switcher,
83
+ breadcrumb-context). Aligns to the header's flex-start edge,
84
+ before the icon/heading column.
62
85
  states:
63
86
  - name: idle
64
87
  description: Default, ready for interaction.
@@ -85,8 +85,17 @@
85
85
  "select"
86
86
  ],
87
87
  "slots": {
88
+ "description": {
89
+ "description": "Override slot for the description text. Use for multi-paragraph or richly-marked-up explanatory copy."
90
+ },
88
91
  "action": {
89
92
  "description": "User-provided action element (e.g. button) displayed below the description"
93
+ },
94
+ "heading": {
95
+ "description": "Override slot for the heading text. Use when richer markup than the plain [heading] attribute is needed (line breaks, inline links, bold emphasis)."
96
+ },
97
+ "icon": {
98
+ "description": "Override slot for the [icon] glyph. Use when the attribute-driven Phosphor icon isn't enough (e.g., custom SVG, illustration, or a styled icon-ui with non-default weight). Mutually exclusive with [icon]."
90
99
  }
91
100
  },
92
101
  "states": [
@@ -53,6 +53,21 @@ props:
53
53
  reflect: true
54
54
  events: {}
55
55
  slots:
56
+ icon:
57
+ description: >-
58
+ Override slot for the [icon] glyph. Use when the attribute-driven
59
+ Phosphor icon isn't enough (e.g., custom SVG, illustration, or
60
+ a styled icon-ui with non-default weight). Mutually exclusive
61
+ with [icon].
62
+ heading:
63
+ description: >-
64
+ Override slot for the heading text. Use when richer markup than
65
+ the plain [heading] attribute is needed (line breaks, inline
66
+ links, bold emphasis).
67
+ description:
68
+ description: >-
69
+ Override slot for the description text. Use for multi-paragraph
70
+ or richly-marked-up explanatory copy.
56
71
  action:
57
72
  description: User-provided action element (e.g. button) displayed below the description
58
73
  states:
@@ -13,6 +13,11 @@
13
13
  }
14
14
  ],
15
15
  "properties": {
16
+ "action": {
17
+ "description": "Label of an inline action button (the \"deep-link\" / \"undo\" pattern).\nPair with `onAction` callback in `UIFeed.post()` — click invokes the\ncallback then auto-dismisses. Empty string renders no action button.\n",
18
+ "type": "string",
19
+ "default": ""
20
+ },
16
21
  "component": {
17
22
  "const": "FeedItem"
18
23
  },
@@ -42,6 +42,14 @@ props:
42
42
  description: Render an x close button (default true for sticky, false for auto-fade)
43
43
  type: boolean
44
44
  default: false
45
+ action:
46
+ description: |
47
+ Label of an inline action button (the "deep-link" / "undo" pattern).
48
+ Pair with `onAction` callback in `UIFeed.post()` — click invokes the
49
+ callback then auto-dismisses. Empty string renders no action button.
50
+ type: string
51
+ default: ""
52
+ reflect: true
45
53
  events:
46
54
  close:
47
55
  description: Fired after the item finishes its exit animation
@@ -74,3 +82,5 @@ a2ui:
74
82
  reason: 'Imperative ownership.'
75
83
  - rule: 'Different from <alert-ui> (inline persistent) and <toast-ui> (standalone ephemeral); feed-item is feed-scoped.'
76
84
  reason: 'Surface boundary.'
85
+ - rule: 'For "notification deep-link" pattern: post with action + onAction. Click navigates and auto-dismisses (router.push() / location.href / api.markRead()).'
86
+ reason: 'Deep-link pattern lives in the onAction callback, not in markup.'
@@ -103,6 +103,12 @@
103
103
  "action": {
104
104
  "description": "Button adjacent to the control for inline actions (clear, reset, help popover)."
105
105
  },
106
+ "error": {
107
+ "description": "Override slot for error markup richer than the plain [error] attribute string. Rendered below the control with danger-text styling. Mutually exclusive with [error]."
108
+ },
109
+ "hint": {
110
+ "description": "Override slot for hint markup richer than the plain [hint] attribute string (inline links, code spans, abbreviations). Rendered below the control at body-subtle typography. Mutually exclusive with [hint]."
111
+ },
106
112
  "trailing": {
107
113
  "description": "Secondary text or badge aligned with the label in the stacked layout (right-aligned) or between label and control in the inline layout."
108
114
  }
@@ -89,6 +89,16 @@ slots:
89
89
  description: >-
90
90
  Button adjacent to the control for inline actions (clear,
91
91
  reset, help popover).
92
+ hint:
93
+ description: >-
94
+ Override slot for hint markup richer than the plain [hint] attribute
95
+ string (inline links, code spans, abbreviations). Rendered below
96
+ the control at body-subtle typography. Mutually exclusive with [hint].
97
+ error:
98
+ description: >-
99
+ Override slot for error markup richer than the plain [error] attribute
100
+ string. Rendered below the control with danger-text styling. Mutually
101
+ exclusive with [error].
92
102
  states:
93
103
  - name: idle
94
104
  description: Default, ready for interaction.
@@ -93,6 +93,8 @@ export { UIMenu, UIMenuItem, UIMenuDivider } from './menu/menu.js';
93
93
  export { UIContextMenu } from './context-menu/context-menu.js';
94
94
  export { UIVisuallyHidden } from './visually-hidden/visually-hidden.js';
95
95
  export { UISkipNav } from './skip-nav/skip-nav.js';
96
+ export { UIMark } from './mark/mark.js';
97
+ export { UIInlineEdit } from './inline-edit/inline-edit.js';
96
98
  export { UIToolbar, UIToolbarGroup } from './toolbar/toolbar.js';
97
99
  export { UINav } from './nav/nav.js';
98
100
  export { UINavGroup } from './nav-group/nav-group.js';
@@ -0,0 +1,159 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/InlineEdit.json",
4
+ "title": "InlineEdit",
5
+ "description": "Click-to-edit text in place. Renders as static text until clicked /\nfocused + Enter, then becomes editable. Enter or blur commits; Escape\ncancels and restores the original value. Form-participating.\n\nUse for editable titles, breadcrumb labels, table-cell text fields,\ndraft document names — any case where a user expects to edit text\nwithout opening a separate dialog or input. Distinct from `<input-ui>`\n(always-editable chrome) and from `<field-ui>` (stacked label + input\ncomposition). inline-edit reads as text in static state.\n",
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
+ "commit": {
17
+ "description": "When to commit pending edits. `blur` (default) — saves on focusout\nor Enter. `enter` — Enter saves, blur cancels (returns to original).\n`manual` — only programmatic `commitEdit()` saves.\n",
18
+ "type": "string",
19
+ "enum": [
20
+ "blur",
21
+ "enter",
22
+ "manual"
23
+ ],
24
+ "default": "blur"
25
+ },
26
+ "component": {
27
+ "const": "InlineEdit"
28
+ },
29
+ "editing": {
30
+ "description": "Reflected state — `true` when the element is actively being edited.\nToggles automatically on click / Enter / blur / Escape; rarely set\nby consumers. Listen to `edit-start` / `edit-end` events instead.\n",
31
+ "type": "boolean",
32
+ "default": false
33
+ },
34
+ "placeholder": {
35
+ "description": "Hint text shown when the value is empty (inline-edit reads this in the static state).",
36
+ "type": "string",
37
+ "default": "Click to edit"
38
+ }
39
+ },
40
+ "required": [
41
+ "component"
42
+ ],
43
+ "unevaluatedProperties": false,
44
+ "x-adiaui": {
45
+ "anti_patterns": [
46
+ {
47
+ "fix": "<inline-edit-ui value=\"Title\" name=\"title\"> — reads as text in static state; chrome only appears while editing.",
48
+ "why": "Ghost variant still renders input chrome (border on focus). Looks like a control, not text.",
49
+ "wrong": "<input-ui value=\"Title\" name=\"title\" variant=\"ghost\">"
50
+ },
51
+ {
52
+ "fix": "<inline-edit-ui value=\"Title\">",
53
+ "why": "No state machine — no commit/cancel semantics, no form participation, no a11y wiring.",
54
+ "wrong": "<text-ui contenteditable>Title</text-ui>"
55
+ }
56
+ ],
57
+ "category": "form",
58
+ "composes": [],
59
+ "events": {
60
+ "cancel": {
61
+ "description": "Fired when Escape (or blur with commit=enter) restores the original. Bubbles."
62
+ },
63
+ "change": {
64
+ "description": "Fired after a successful commit. detail = { value, oldValue }. Bubbles."
65
+ },
66
+ "edit-end": {
67
+ "description": "Fired when leaving edit mode. detail = { committed: boolean }. Bubbles."
68
+ },
69
+ "edit-start": {
70
+ "description": "Fired when entering edit mode. Bubbles."
71
+ }
72
+ },
73
+ "examples": [
74
+ {
75
+ "description": "A draft document title that becomes editable on click.",
76
+ "a2ui": "[{ \"id\": \"title\", \"component\": \"InlineEdit\", \"value\": \"Untitled draft\" }]\n",
77
+ "name": "editable-title"
78
+ }
79
+ ],
80
+ "keywords": [
81
+ "inline-edit",
82
+ "editable",
83
+ "click-to-edit",
84
+ "rename",
85
+ "edit-in-place"
86
+ ],
87
+ "name": "UIInlineEdit",
88
+ "related": [
89
+ "input",
90
+ "field",
91
+ "text"
92
+ ],
93
+ "slots": {},
94
+ "states": [
95
+ {
96
+ "description": "Static — text reads as plain text with a hover hint.",
97
+ "name": "idle"
98
+ },
99
+ {
100
+ "description": "Hover affordance — subtle background tint signals editability.",
101
+ "name": "hover"
102
+ },
103
+ {
104
+ "description": "contenteditable — host is the input surface; outline + caret.",
105
+ "name": "editing"
106
+ },
107
+ {
108
+ "description": "Value is empty and not editing — placeholder text renders.",
109
+ "name": "empty"
110
+ },
111
+ {
112
+ "description": "Locked; click + keyboard activation are no-ops.",
113
+ "name": "disabled"
114
+ }
115
+ ],
116
+ "status": "stable",
117
+ "synonyms": {
118
+ "inline-edit": [
119
+ "editable",
120
+ "click-to-edit",
121
+ "edit-in-place",
122
+ "rename-in-place"
123
+ ]
124
+ },
125
+ "tag": "inline-edit-ui",
126
+ "tokens": {
127
+ "--inline-edit-bg-edit": {
128
+ "description": "Background while editing.",
129
+ "default": "var(--a-bg)"
130
+ },
131
+ "--inline-edit-bg-hover": {
132
+ "description": "Background tint on hover (idle state).",
133
+ "default": "var(--a-bg-muted)"
134
+ },
135
+ "--inline-edit-outline": {
136
+ "description": "Outline color in the editing state.",
137
+ "default": "var(--a-accent-strong)"
138
+ },
139
+ "--inline-edit-placeholder": {
140
+ "description": "Placeholder text color in empty-static state.",
141
+ "default": "var(--a-fg-subtle)"
142
+ },
143
+ "--inline-edit-px": {
144
+ "description": "Horizontal padding (inner gutter so hover tint reads as a pill, not a flush block).",
145
+ "default": "var(--a-space-1)"
146
+ },
147
+ "--inline-edit-py": {
148
+ "description": "Vertical padding.",
149
+ "default": "var(--a-space-0-5)"
150
+ },
151
+ "--inline-edit-radius": {
152
+ "description": "Border-radius for the hover / editing chrome.",
153
+ "default": "var(--a-radius-sm)"
154
+ }
155
+ },
156
+ "traits": [],
157
+ "version": 1
158
+ }
159
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `<inline-edit-ui>` — click-to-edit text in place.
3
+ *
4
+ * Static state: textContent reads as plain text with a hover affordance.
5
+ * Editing state: host becomes `contenteditable=plaintext-only`. Enter or
6
+ * blur commits (configurable via `commit`); Escape always cancels.
7
+ *
8
+ * Form-participating via UIFormElement (name/value/required/disabled/
9
+ * readonly inherited). Pair with a `<form>` + `name=` to submit edits
10
+ * as a field.
11
+ *
12
+ * Architecture: no template (stamp-nothing). The element manages its
13
+ * own DOM imperatively — textContent IS the value, contenteditable
14
+ * attribute IS the editing state. This keeps the element trivial and
15
+ * lets the inherited UIFormElement value sync stay authoritative.
16
+ */
17
+
18
+ import { UIFormElement } from '../../core/form.js';
19
+
20
+ export class UIInlineEdit extends UIFormElement {
21
+ static properties = {
22
+ ...UIFormElement.properties,
23
+ placeholder: { type: String, default: 'Click to edit', reflect: true },
24
+ editing: { type: Boolean, default: false, reflect: true },
25
+ commit: { type: String, default: 'blur', reflect: true }, // blur | enter | manual
26
+ };
27
+
28
+ static template = () => null;
29
+
30
+ #originalValue = '';
31
+ #bound = false;
32
+ #suppressBlur = false;
33
+
34
+ connected() {
35
+ super.connected();
36
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'textbox');
37
+ if (!this.hasAttribute('tabindex') && !this.disabled) {
38
+ this.setAttribute('tabindex', '0');
39
+ }
40
+ // Reflect the initial value to textContent (the source of truth for
41
+ // display while not editing). Setting via property triggers the
42
+ // reflection but not the textContent sync — do it explicitly.
43
+ if (this.value && this.textContent.trim() === '') {
44
+ this.textContent = this.value;
45
+ } else if (!this.value && this.textContent.trim()) {
46
+ // Author supplied text in the slot — treat it as the initial value.
47
+ this.value = this.textContent.trim();
48
+ this.syncValue();
49
+ }
50
+
51
+ if (!this.#bound) {
52
+ this.#bound = true;
53
+ this.addEventListener('click', this.#onClick);
54
+ this.addEventListener('keydown', this.#onKeydown);
55
+ this.addEventListener('focus', this.#onFocus);
56
+ this.addEventListener('blur', this.#onBlur);
57
+ }
58
+ }
59
+
60
+ disconnected() {
61
+ super.disconnected();
62
+ this.removeEventListener('click', this.#onClick);
63
+ this.removeEventListener('keydown', this.#onKeydown);
64
+ this.removeEventListener('focus', this.#onFocus);
65
+ this.removeEventListener('blur', this.#onBlur);
66
+ this.#bound = false;
67
+ }
68
+
69
+ /* ── Public API ──────────────────────────────────────────────────── */
70
+
71
+ startEdit() {
72
+ if (this.editing || this.disabled || this.readonly) return;
73
+ this.#originalValue = this.value || '';
74
+ this.editing = true;
75
+ this.setAttribute('contenteditable', 'plaintext-only');
76
+ this.dispatchEvent(new CustomEvent('edit-start', { bubbles: true }));
77
+ // Defer focus/selection — setting contenteditable in the same tick
78
+ // as a click can race with the browser's own caret placement.
79
+ queueMicrotask(() => {
80
+ this.focus();
81
+ this.#selectAll();
82
+ });
83
+ }
84
+
85
+ commitEdit() {
86
+ if (!this.editing) return;
87
+ const newValue = (this.textContent || '').trim();
88
+ const oldValue = this.#originalValue;
89
+ this.editing = false;
90
+ this.removeAttribute('contenteditable');
91
+ // Keep the visible text in sync with the canonical value (normalizes
92
+ // trailing whitespace + restores the source-of-truth string).
93
+ if (this.textContent !== newValue) this.textContent = newValue;
94
+ if (newValue !== oldValue) {
95
+ this.value = newValue;
96
+ this.syncValue(newValue);
97
+ this.dispatchEvent(new CustomEvent('change', {
98
+ bubbles: true,
99
+ detail: { value: newValue, oldValue },
100
+ }));
101
+ }
102
+ this.dispatchEvent(new CustomEvent('edit-end', {
103
+ bubbles: true,
104
+ detail: { committed: true },
105
+ }));
106
+ }
107
+
108
+ cancelEdit() {
109
+ if (!this.editing) return;
110
+ this.editing = false;
111
+ this.removeAttribute('contenteditable');
112
+ this.textContent = this.#originalValue;
113
+ this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
114
+ this.dispatchEvent(new CustomEvent('edit-end', {
115
+ bubbles: true,
116
+ detail: { committed: false },
117
+ }));
118
+ }
119
+
120
+ /* ── Event handlers ──────────────────────────────────────────────── */
121
+
122
+ #onClick = (e) => {
123
+ if (this.disabled || this.readonly) return;
124
+ if (this.editing) return; // already editing, let caret behavior pass through
125
+ e.preventDefault();
126
+ this.startEdit();
127
+ };
128
+
129
+ #onKeydown = (e) => {
130
+ if (this.editing) {
131
+ if (e.key === 'Enter') {
132
+ e.preventDefault();
133
+ this.#suppressBlur = true; // commitEdit blurs us; don't recurse
134
+ this.commitEdit();
135
+ this.blur();
136
+ } else if (e.key === 'Escape') {
137
+ e.preventDefault();
138
+ this.#suppressBlur = true;
139
+ this.cancelEdit();
140
+ this.blur();
141
+ }
142
+ } else {
143
+ // Activate edit from keyboard (Enter or Space)
144
+ if (e.key === 'Enter' || e.key === ' ') {
145
+ if (this.disabled || this.readonly) return;
146
+ e.preventDefault();
147
+ this.startEdit();
148
+ }
149
+ }
150
+ };
151
+
152
+ #onFocus = () => {
153
+ // No-op — keyboard activation handled in #onKeydown to distinguish
154
+ // focus-via-Tab (don't edit) from focus-via-click (also don't edit
155
+ // here; the click handler does that). Focus alone never starts an
156
+ // edit — that would surprise tab-traversers.
157
+ };
158
+
159
+ #onBlur = () => {
160
+ if (!this.editing) return;
161
+ if (this.#suppressBlur) {
162
+ this.#suppressBlur = false;
163
+ return;
164
+ }
165
+ if (this.commit === 'blur') {
166
+ this.commitEdit();
167
+ } else if (this.commit === 'enter') {
168
+ // blur cancels under `commit=enter` semantics
169
+ this.cancelEdit();
170
+ }
171
+ // commit=manual: do nothing on blur; consumer must call commitEdit()
172
+ };
173
+
174
+ /* ── Helpers ─────────────────────────────────────────────────────── */
175
+
176
+ #selectAll() {
177
+ const range = document.createRange();
178
+ range.selectNodeContents(this);
179
+ const sel = window.getSelection();
180
+ if (!sel) return;
181
+ sel.removeAllRanges();
182
+ sel.addRange(range);
183
+ }
184
+ }
@@ -0,0 +1,62 @@
1
+ @scope (inline-edit-ui) {
2
+ :where(:scope) {
3
+ --inline-edit-bg-hover-default: var(--a-bg-muted);
4
+ --inline-edit-bg-edit-default: var(--a-bg);
5
+ --inline-edit-outline-default: var(--a-accent-strong);
6
+ --inline-edit-placeholder-default: var(--a-fg-subtle);
7
+ --inline-edit-px-default: var(--a-space-1);
8
+ --inline-edit-py-default: var(--a-space-0-5);
9
+ --inline-edit-radius-default: var(--a-radius-sm);
10
+ }
11
+
12
+ :scope {
13
+ /* Inline-block so padding works without breaking text flow when
14
+ embedded inside a paragraph or heading. Falls back to inline for
15
+ table cells (where the cell already contains the box). */
16
+ display: inline-block;
17
+ box-sizing: border-box;
18
+ padding-inline: var(--inline-edit-px, var(--inline-edit-px-default));
19
+ padding-block: var(--inline-edit-py, var(--inline-edit-py-default));
20
+ margin-inline: calc(-1 * var(--inline-edit-px, var(--inline-edit-px-default)));
21
+ margin-block: calc(-1 * var(--inline-edit-py, var(--inline-edit-py-default)));
22
+ border-radius: var(--inline-edit-radius, var(--inline-edit-radius-default));
23
+ cursor: text;
24
+ color: inherit;
25
+ font: inherit;
26
+ transition: background-color var(--a-duration-fast) var(--a-easing-out);
27
+ /* Make sure focus-visible outline can land on us (we set tabindex=0) */
28
+ outline-offset: 2px;
29
+ }
30
+
31
+ /* Hover affordance — subtle bg tint so the user sees this IS editable.
32
+ Suppressed when disabled / readonly / already editing. */
33
+ :scope:not([editing]):not([disabled]):not([readonly]):hover {
34
+ background: var(--inline-edit-bg-hover, var(--inline-edit-bg-hover-default));
35
+ }
36
+
37
+ /* Editing state — flat background + accent outline for focus clarity */
38
+ :scope[editing] {
39
+ background: var(--inline-edit-bg-edit, var(--inline-edit-bg-edit-default));
40
+ outline: 2px solid var(--inline-edit-outline, var(--inline-edit-outline-default));
41
+ outline-offset: 0;
42
+ cursor: text;
43
+ }
44
+
45
+ /* Empty-value placeholder — show the [placeholder] attr as faint text
46
+ when the host has no text content + isn't being edited. */
47
+ :scope:empty:not([editing])::before {
48
+ content: attr(placeholder);
49
+ color: var(--inline-edit-placeholder, var(--inline-edit-placeholder-default));
50
+ font-style: italic;
51
+ }
52
+
53
+ :scope[disabled],
54
+ :scope[readonly] {
55
+ cursor: default;
56
+ opacity: 0.6;
57
+ }
58
+
59
+ :scope[disabled] {
60
+ pointer-events: none;
61
+ }
62
+ }