@adia-ai/web-components 0.8.37 → 0.8.39

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 (48) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/MIGRATION.md +9 -7
  3. package/README.md +3 -3
  4. package/bin/doc.mjs +27 -5
  5. package/components/calendar-picker/calendar-picker.css +4 -1
  6. package/components/card/card.css +1 -1
  7. package/components/combobox/combobox.css +6 -0
  8. package/components/command/command.a2ui.json +3 -0
  9. package/components/command/command.class.js +28 -6
  10. package/components/command/command.css +14 -3
  11. package/components/command/command.yaml +5 -0
  12. package/components/date-range-picker/date-range-picker.css +6 -0
  13. package/components/datetime-picker/datetime-picker.css +4 -0
  14. package/components/drilldown/drilldown.a2ui.json +244 -0
  15. package/components/drilldown/drilldown.class.js +550 -0
  16. package/components/drilldown/drilldown.css +304 -0
  17. package/components/drilldown/drilldown.d.ts +68 -0
  18. package/components/drilldown/drilldown.examples.md +20 -0
  19. package/components/drilldown/drilldown.js +17 -0
  20. package/components/drilldown/drilldown.yaml +273 -0
  21. package/components/index.js +1 -0
  22. package/components/modal/modal.class.js +68 -4
  23. package/components/nav/nav.a2ui.json +5 -0
  24. package/components/nav/nav.class.js +35 -12
  25. package/components/nav/nav.d.ts +2 -0
  26. package/components/nav/nav.yaml +18 -0
  27. package/components/nav-item/nav-item.class.js +9 -6
  28. package/components/page/page.a2ui.json +13 -1
  29. package/components/page/page.css +113 -0
  30. package/components/page/page.yaml +30 -3
  31. package/components/select/select.class.js +30 -12
  32. package/components/select/select.css +11 -2
  33. package/components/swatch/swatch.css +6 -4
  34. package/components/toggle-group/toggle-group.class.js +21 -11
  35. package/components/toggle-group/toggle-group.css +16 -8
  36. package/components/toggle-group/toggle-group.d.ts +6 -0
  37. package/components/toggle-group/toggle-group.yaml +10 -0
  38. package/components/toggle-group/toggle-option.a2ui.json +5 -0
  39. package/components/toggle-group/toggle-option.yaml +18 -2
  40. package/custom-elements.json +277 -100
  41. package/dist/theme-provider.min.js +3 -3
  42. package/dist/web-components.min.css +1 -1
  43. package/dist/web-components.min.js +110 -88
  44. package/dist/web-components.sheet.js +1 -1
  45. package/package.json +1 -1
  46. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +1 -1
  47. package/styles/components.css +1 -0
  48. package/traits/view-transition/view-transition.js +7 -0
@@ -43,6 +43,7 @@
43
43
  */
44
44
 
45
45
  import { UIElement } from '../../core/element.js';
46
+ import { logicalSlotted } from '../../core/logical-children.js';
46
47
 
47
48
  export class UIModal extends UIElement {
48
49
  #bound = false;
@@ -51,6 +52,14 @@ export class UIModal extends UIElement {
51
52
  #closeTimer = null;
52
53
  #dialogRef = null;
53
54
 
55
+ // Guards `close` against firing twice for one dismissal — #animateClose's
56
+ // timer dispatches it directly (the authoritative path, gh#1295) and the
57
+ // native <dialog> 'close' listener also calls in as a redundant safety
58
+ // net for a consumer that closes the raw <dialog> element itself, and
59
+ // disconnected()'s teardown fallback (below) is a third path into the
60
+ // same guard. Reset whenever the dialog (re)opens.
61
+ #closeDispatched = false;
62
+
54
63
  // Monotonic, instance-agnostic counter for the fallback aria-labelledby
55
64
  // id stamped onto an author's [slot="heading"] (see render()) — multiple
56
65
  // <modal-ui> instances on one page must not collide on the same id.
@@ -93,6 +102,19 @@ export class UIModal extends UIElement {
93
102
  };
94
103
 
95
104
  #onDialogClose = () => {
105
+ // Native <dialog> 'close' — fires as a browser-QUEUED task (per the
106
+ // HTML spec, dialog.close() queues a task; it is not synchronous), so
107
+ // it can be lost if a consumer tears the listener down (removes this
108
+ // element) before that task runs. #animateClose's own timer is the
109
+ // authoritative dispatch path (gh#1295); this stays wired as a
110
+ // redundant net for a consumer calling the raw <dialog> element's
111
+ // own `.close()` directly.
112
+ this.#emitCloseOnce();
113
+ };
114
+
115
+ #emitCloseOnce = () => {
116
+ if (this.#closeDispatched) return;
117
+ this.#closeDispatched = true;
96
118
  this.open = false;
97
119
  this.#previousFocus?.focus();
98
120
  this.#previousFocus = null;
@@ -128,6 +150,32 @@ export class UIModal extends UIElement {
128
150
  clearTimeout(this.#closeTimer);
129
151
  this.#closeTimer = null;
130
152
  }
153
+ // Teardown fallback — the ROOT CAUSE of gh#1295 (adiav2 ADIA2-8581).
154
+ // Every internal close path (the native <dialog> 'close' listener,
155
+ // #animateClose's own timer) only ever runs from THIS element's own
156
+ // render effect (element.js's connectedCallback), which is set up as
157
+ // a signals.js effect that reacts to the `open` property write via a
158
+ // MICROTASK — the property setter itself only marks the signal dirty;
159
+ // it doesn't render synchronously. `disconnectedCallback()` disposes
160
+ // every effect (including that one) BEFORE disconnected() runs. A
161
+ // consumer whose own re-render removes <modal-ui> from the DOM in the
162
+ // SAME tick as setting `open = false` — an extremely common framework
163
+ // shape: a Confirm/Cancel handler flips reactive state that ALSO
164
+ // unmounts the modal on that same pass — tears down the render effect
165
+ // before it ever gets a chance to run, so #animateClose's timer is
166
+ // never even CREATED, let alone fired. No error, no warning: the
167
+ // ENTIRE close pipeline silently never starts, and any caller awaiting
168
+ // `close` (showConfirmDialog-style APIs) hangs forever. Confirmed via
169
+ // browser reproduction — vitest/happy-dom's synchronous effect
170
+ // scheduling masked this (see modal.test.js "parent-driven teardown").
171
+ // Disconnection always resolves an open-or-mid-close modal's `close`
172
+ // obligation as a last resort, regardless of which (if any) internal
173
+ // path got a chance to run first — #emitCloseOnce's guard makes this
174
+ // safe to call unconditionally alongside those other paths.
175
+ if (this.open || this.#closing || this.#dialogRef?.open) {
176
+ this.#emitCloseOnce();
177
+ }
178
+ this.#closing = false;
131
179
  this.#bound = false;
132
180
  this.#dialogRef = null;
133
181
  }
@@ -136,10 +184,15 @@ export class UIModal extends UIElement {
136
184
  // Read the computed `--modal-duration` (= --a-duration = 250ms unless a
137
185
  // consumer overrides) so the JS close timer matches the CSS exit animation
138
186
  // exactly — a stale/unset value clipped the animation ~50ms early
139
- // (bug class shared with swiper bug-29).
187
+ // (bug class shared with swiper bug-29). `parseFloat` alone silently
188
+ // drops a `s` vs `ms` unit (gh#1295 — a consumer-computed value observed
189
+ // as `.25s` parsed to 0.25, a 0.25ms timer instead of 250ms), so a
190
+ // trailing `s` (and not `ms`) is converted explicitly rather than
191
+ // trusted to already be milliseconds.
140
192
  const cs = getComputedStyle(this);
141
193
  const raw = cs.getPropertyValue('--modal-duration').trim();
142
- return parseFloat(raw) || 200;
194
+ const ms = /ms\s*$/.test(raw) ? parseFloat(raw) : /s\s*$/.test(raw) ? parseFloat(raw) * 1000 : parseFloat(raw);
195
+ return ms > 0 ? ms : 200;
143
196
  }
144
197
 
145
198
  render() {
@@ -195,8 +248,12 @@ export class UIModal extends UIElement {
195
248
  // dialog-modal pattern's accessible-name requirement. Re-evaluated
196
249
  // every render so a later `text=` set, or the heading being removed,
197
250
  // clears the reference instead of leaving it stale. (gh#947, from
198
- // PR #944.)
199
- const authoredHeading = header.querySelector(':scope > [slot="heading"]');
251
+ // PR #944.) `logicalSlotted` (not a `:scope >` direct-child query)
252
+ // pierces the template engine's display:contents/role=presentation
253
+ // wrapper spans, so a consumer that renders the heading conditionally
254
+ // (`${title ? html\`<span slot="heading">…\` : null}`) still gets an
255
+ // aria-labelledby — the wrapper-trap class, gh#1301.
256
+ const authoredHeading = logicalSlotted(header, 'heading')[0];
200
257
  if (!this.text && authoredHeading) {
201
258
  if (!authoredHeading.id) authoredHeading.id = `modal-heading-${++UIModal.#headingIdSeq}`;
202
259
  if (dialog.getAttribute('aria-labelledby') !== authoredHeading.id) {
@@ -261,6 +318,7 @@ export class UIModal extends UIElement {
261
318
  // See .claude/docs/BROWSER-COMPAT.md §3a (Flavor C).
262
319
  if (this.open && !dialog.open) {
263
320
  this.#closing = false;
321
+ this.#closeDispatched = false;
264
322
  this.#previousFocus = document.activeElement;
265
323
  dialog.showModal();
266
324
  void dialog.offsetHeight;
@@ -285,6 +343,12 @@ export class UIModal extends UIElement {
285
343
  this.#closing = false;
286
344
  dialog.removeAttribute('data-closing');
287
345
  if (dialog.open) dialog.close();
346
+ // Authoritative dispatch (gh#1295) — don't rely solely on the native
347
+ // <dialog> 'close' listener above, whose task can be lost if this
348
+ // element (or an ancestor) is torn down before it runs.
349
+ // #emitCloseOnce guards so a close event ALSO reaching
350
+ // #onDialogClose is a no-op.
351
+ this.#emitCloseOnce();
288
352
  }, this.#getDuration());
289
353
  }
290
354
 
@@ -31,6 +31,11 @@
31
31
  "type": "string",
32
32
  "default": ""
33
33
  },
34
+ "multiExpand": {
35
+ "description": "Opt out of the default single-expanded-group behavior. When unset, selecting a nav-item-ui collapses every nav-group-ui except the one containing the newly selected item (all, when the selection is ungrouped); manual multi-expansion still works up until the next selection.",
36
+ "type": "boolean",
37
+ "default": false
38
+ },
34
39
  "variant": {
35
40
  "description": "Visual treatment. primary = app sidebar; section = subnav rail.",
36
41
  "type": "string",
@@ -45,10 +45,14 @@ import { UIElement } from '../../core/element.js';
45
45
 
46
46
  export class UINav extends UIElement {
47
47
  static properties = {
48
- variant: { type: String, default: 'primary', reflect: true },
49
- collapsed: { type: Boolean, default: false, reflect: true },
50
- divider: { type: Boolean, default: false, reflect: true },
51
- heading: { type: String, default: '', reflect: true },
48
+ variant: { type: String, default: 'primary', reflect: true },
49
+ collapsed: { type: Boolean, default: false, reflect: true },
50
+ divider: { type: Boolean, default: false, reflect: true },
51
+ heading: { type: String, default: '', reflect: true },
52
+ // gh#1306: selection is the cleanup event for manually multi-expanded
53
+ // groups — default-on; opt out to let multiple groups stay open across
54
+ // selections (the operator's ruling, 2026-08-14).
55
+ multiExpand: { type: Boolean, default: false, reflect: true, attribute: 'multi-expand' },
52
56
  };
53
57
 
54
58
  static template = () => null;
@@ -91,9 +95,15 @@ export class UINav extends UIElement {
91
95
 
92
96
  select(item) {
93
97
  const prev = this.selectedItem;
94
- if (prev && prev !== item) prev.removeAttribute('selected');
98
+ // gh#1254: re-selecting the already-selected item is a no-op — no
99
+ // attribute churn, no hover flush, no event. Selection genuinely
100
+ // unchanged is not a selection event.
101
+ if (prev === item) return;
102
+ if (prev) prev.removeAttribute('selected');
95
103
  if (item) {
96
104
  item.setAttribute('selected', '');
105
+ this.#flushHoverState();
106
+ this.#collapseSiblingGroups(item);
97
107
  this.dispatchEvent(new CustomEvent('nav-select', {
98
108
  bubbles: true,
99
109
  detail: { item, text: item.text, value: item.value },
@@ -101,19 +111,32 @@ export class UINav extends UIElement {
101
111
  }
102
112
  }
103
113
 
114
+ // gh#1306: on selection, collapse every top-level group except the one
115
+ // containing the newly selected item (none, when the selection is
116
+ // ungrouped — every group collapses). Manual multi-expansion stays
117
+ // allowed right up until the next selection; [multi-expand] opts out
118
+ // entirely. Runs for every select() caller — click, keyboard, and the
119
+ // collapsed-rail popover path (nav-group.class.js's showPopover(),
120
+ // which also calls nav.select()).
121
+ #collapseSiblingGroups(item) {
122
+ if (this.multiExpand) return;
123
+ const activeGroup = item.closest('nav-group-ui');
124
+ for (const group of this.querySelectorAll(':scope > nav-group-ui')) {
125
+ if (group !== activeGroup) group.open = false;
126
+ }
127
+ }
128
+
104
129
  toggle() {
105
130
  if (this.variant === 'section') return; // no-op for section variant
106
131
  this.collapsed = !this.collapsed;
107
132
  }
108
133
 
109
134
  #onClick = (e) => {
110
- const item = e.target.closest('nav-item-ui');
111
- if (item && this.contains(item)) {
112
- if (item.disabled) return;
113
- this.select(item);
114
- this.#flushHoverState();
115
- return;
116
- }
135
+ // gh#1254: <nav-item-ui> owns its own click + keyboard activation
136
+ // (nav-item.class.js #onClick calls parent.select(this) directly)
137
+ // delegating the same selection here too double-invoked select() per
138
+ // click (the layered handlers the issue reports). Do NOT re-add an
139
+ // item branch here; this listener is for group expand/popover only.
117
140
 
118
141
  // Group expand/popover — primary variant only.
119
142
  if (this.variant === 'section') return;
@@ -38,6 +38,8 @@ export class UINav extends UIElement {
38
38
  divider: boolean;
39
39
  /** Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only. */
40
40
  heading: string;
41
+ /** Opt out of the default single-expanded-group behavior. When unset, selecting a nav-item-ui collapses every nav-group-ui except the one containing the newly selected item (all, when the selection is ungrouped); manual multi-expansion still works up until the next selection. */
42
+ multiExpand: boolean;
41
43
  /** Visual treatment. primary = app sidebar; section = subnav rail. */
42
44
  variant: 'primary' | 'section';
43
45
 
@@ -39,6 +39,12 @@ props:
39
39
  type: string
40
40
  default: ''
41
41
  description: "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only."
42
+ multiExpand:
43
+ type: boolean
44
+ default: false
45
+ reflect: true
46
+ attribute: multi-expand
47
+ description: "Opt out of the default single-expanded-group behavior. When unset, selecting a nav-item-ui collapses every nav-group-ui except the one containing the newly selected item (all, when the selection is ungrouped); manual multi-expansion still works up until the next selection."
42
48
 
43
49
  events:
44
50
  nav-select:
@@ -91,6 +97,18 @@ a2ui:
91
97
  or anchor) → <nav-ui>. If the user switches VIEWS within the same
92
98
  logical page → <tabs-ui>. Never use <nav-ui> as an in-page section
93
99
  switcher.
100
+ - >-
101
+ Group expansion on selection: selecting a <nav-item-ui> collapses
102
+ every <nav-group-ui> except the one containing the newly selected
103
+ item (all groups, when the selection is ungrouped). Manual
104
+ multi-expansion is allowed right up until the next selection. Set
105
+ [multi-expand] on <nav-ui> to opt out and keep multiple groups open
106
+ across selections. This clears the JS [open] state (and
107
+ aria-expanded) on every collapsed group in BOTH variants — primary
108
+ visually hides that group's children as a result; section variant's
109
+ CSS keeps children visible regardless of [open] (per the
110
+ section-variant cascade rule above), so only the accessibility
111
+ state resets there, with no visible change.
94
112
  - >-
95
113
  Anti-patterns: do NOT wrap <nav-ui> children in <col-ui> /
96
114
  <row-ui> — wrapping breaks selection bubbling + the variant
@@ -18,8 +18,10 @@
18
18
  *
19
19
  * Supports icon, label, optional badge, selected/disabled state, and
20
20
  * keyboard activation (Enter/Space). Selection is managed by the parent
21
- * <nav-ui>; clicking or keyboard-activating an item calls nav.select(this)
22
- * and dispatches `nav-select` (bubbles, detail: { item, text, value }).
21
+ * <nav-ui>; clicking or keyboard-activating an item calls nav.select(this),
22
+ * which dispatches `nav-select` (bubbles from <nav-ui>, detail: { item,
23
+ * text, value }) exactly once when selection actually changes — see
24
+ * nav.class.js#select. This class does not dispatch its own copy (gh#1254).
23
25
  */
24
26
 
25
27
  import { UIElement } from '../../core/element.js';
@@ -39,12 +41,13 @@ export class UINavItem extends UIElement {
39
41
 
40
42
  #onClick = (e) => {
41
43
  if (this.disabled) { e.preventDefault(); return; }
44
+ // gh#1254: nav.select() is the single source of `nav-select` — it
45
+ // dispatches (bubbling from <nav-ui>) only when selection actually
46
+ // changes, and is a no-op re-clicking the already-selected item.
47
+ // A second dispatch here duplicated the event on every click and
48
+ // fired again even on a no-op reselect.
42
49
  const parent = this.closest('nav-ui');
43
50
  parent?.select?.(this);
44
- this.dispatchEvent(new CustomEvent('nav-select', {
45
- bubbles: true,
46
- detail: { item: this, text: this.text, value: this.value },
47
- }));
48
51
  };
49
52
 
50
53
  #onKey = (e) => {
@@ -85,8 +85,20 @@
85
85
  "footer"
86
86
  ],
87
87
  "slots": {
88
+ "description": {
89
+ "description": "Secondary metadata — grid row 2 beneath the heading inside the page header. Also accepts bare `<p>` / `<small>` / body-variant `<text-ui>` as direct children without slot=\"description\"."
90
+ },
88
91
  "default": {
89
- "description": "Composes from the slot primitives — `<header-ui>` (page header),\n`<section-ui>` (main content), optional `<footer-ui>`. Native\n`<header>` / `<section>` / `<footer>` also work; the @scope rules\ntarget both via `:where(header, header-ui)`.\n"
92
+ "description": "Composes from the slot primitives — `<header-ui>` (page header),\n`<section-ui>` (main content), optional `<footer-ui>`. Native\n`<header>` / `<section>` / `<footer>` also work; the @scope rules\ntarget both via `:where(header, header-ui)`. The page header\nalso activates the named slot-gated grid (`slot=\"icon\"` /\n`slot=\"heading\"` / `slot=\"description\"` / `slot=\"action\"`) once\nany DIRECT header child carries a `slot` attribute — same\ncontract as `<card-ui>`'s header (gh#1253).\n"
93
+ },
94
+ "action": {
95
+ "description": "Trailing control cluster inside the page header (icon-buttons, menu trigger, scheme toggle, more-options). Aligns to the flex-end edge — the fix for gh#1253's dropped `<toggle-scheme-ui>`."
96
+ },
97
+ "heading": {
98
+ "description": "Primary title — grid row 1 inside the page header. Also accepts bare `<h1>`–`<h6>` tags (or A2UI's transpiled display/title/heading/subsection `<text-ui>` variants) as direct children without slot=\"heading\". A slot=\"heading\" wrapper can contain inline badges or metadata alongside the title text."
99
+ },
100
+ "icon": {
101
+ "description": "Leading icon for the page header — status, brand, or type marker. Placed in column 1 of the header grid when present; heading + description shift to column 2. Direct child of `<header>` / `<header-ui>` only, per the :has(> [slot=\"icon\"]) gate."
90
102
  }
91
103
  },
92
104
  "states": [
@@ -26,6 +26,12 @@
26
26
  --page-sticky-bg: var(--a-canvas-0);
27
27
  --page-sticky-border: 1px solid var(--md-sys-color-neutral-outline-variant);
28
28
  --page-sticky-shadow: var(--a-shadow-sm);
29
+
30
+ /* ── Header slot-gated grid (gh#1253) — matches card-ui's header pair ── */
31
+ --page-header-gap: var(--a-space-2);
32
+ --page-heading-fg: var(--a-fg-strong);
33
+ --page-heading-size: var(--a-ui-lg);
34
+ --page-heading-weight: var(--a-weight-semibold);
29
35
  }
30
36
 
31
37
  :scope {
@@ -118,4 +124,111 @@
118
124
  border-block-end: var(--page-sticky-border);
119
125
  box-shadow: var(--page-sticky-shadow);
120
126
  }
127
+
128
+ /* ═══════ Header slot-gated grid (gh#1253) ═══════
129
+ card-ui / drawer-ui / modal-ui already wire the slot vocabulary
130
+ (`slot="icon"` / `slot="heading"` / `slot="action"`) into a header grid
131
+ (ADR-0009: "the slot vocabulary unifies ... Card, Drawer, Modal, AND
132
+ app-shell-ui / page-ui"); page-ui never shipped its half — the docs
133
+ (page.examples.html's "Slot vocabulary" section) promised it, but the
134
+ page's own @scope had no rule keying on `[slot]`. Mirrors card.css's
135
+ `& > header` rules verbatim — same guard (`:has(> [slot])` on a DIRECT
136
+ child only, so a nested [slot="icon"] inside e.g. an <avatar-ui> can't
137
+ falsely activate the grid), same column templates, same row
138
+ placement. */
139
+ :scope > :where(header, header-ui):has(> [slot]) {
140
+ display: grid;
141
+ gap: var(--page-header-gap);
142
+ align-items: center;
143
+ }
144
+
145
+ :scope > :where(header, header-ui):has(> [slot="icon"]):has(> [slot="action"]) { grid-template-columns: max-content 1fr auto; }
146
+ :scope > :where(header, header-ui):has(> [slot="icon"]):not(:has(> [slot="action"])) { grid-template-columns: max-content 1fr; }
147
+ :scope > :where(header, header-ui):not(:has(> [slot="icon"])):has(> [slot="action"]) { grid-template-columns: 1fr auto; }
148
+ :scope > :where(header, header-ui):not(:has(> [slot="icon"])):not(:has(> [slot="action"])) { grid-template-columns: 1fr; }
149
+
150
+ /* Unslotted children stack above the heading/description/action grid,
151
+ full-width — same escape hatch as card.css (zettel fragment injection,
152
+ logos, banners) without needing to know the slot vocabulary. */
153
+ :scope > :where(header, header-ui):has(> [slot]) > *:not([slot]):not(h1):not(h2):not(h3):not(h4):not(h5):not(h6):not(p):not(small):not(text-ui) {
154
+ grid-column: 1 / -1;
155
+ justify-self: center;
156
+ }
157
+
158
+ /* Icon — first column. Spans both rows (anchored to start) once a
159
+ description row exists, matching card.css. */
160
+ :scope > :where(header, header-ui) > [slot="icon"] {
161
+ grid-column: 1;
162
+ grid-row: 1;
163
+ align-self: center;
164
+ display: flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ }
168
+
169
+ /* :not([slot]) on the bare-tag branches (gh#1253 review) — a native <p>/
170
+ <small>/body-caption text-ui carrying its OWN slot (e.g. slot="heading")
171
+ must not also count as "there's a description row" just because the tag
172
+ matches; only an actual [slot="description"] or an unslotted bare tag
173
+ does. */
174
+ :scope > :where(header, header-ui):has(> :is([slot="description"], p:not([slot]), small:not([slot]), text-ui[variant="body"]:not([slot]), text-ui[variant="caption"]:not([slot]))) > [slot="icon"] {
175
+ grid-row: 1 / span 2;
176
+ align-self: start;
177
+ }
178
+
179
+ /* Heading — row 1. Matches native h1-h6 and the A2UI-transpiled text-ui
180
+ heading variants, only when unslotted (an explicit slot="heading"
181
+ always wins) — same as card.css. */
182
+ :scope > :where(header, header-ui) > :is([slot="heading"], h1, h2, h3, h4, h5, h6),
183
+ :scope > :where(header, header-ui) > :is(text-ui[variant="display"], text-ui[variant="title"], text-ui[variant="heading"], text-ui[variant="subsection"]):not([slot]),
184
+ :scope > :where(header, header-ui) > [slot="heading"] :is(h1, h2, h3, h4, h5, h6) {
185
+ grid-row: 1;
186
+ line-height: 1.3;
187
+ margin: 0;
188
+ }
189
+
190
+ :scope > :where(header, header-ui) > [slot="heading"] {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: var(--page-header-gap);
194
+ color: var(--page-heading-fg);
195
+ }
196
+ :scope > :where(header, header-ui) > [slot="heading"]:not(:is(h1, h2, h3, h4, h5, h6, text-ui)) {
197
+ font-size: var(--page-heading-size);
198
+ font-weight: var(--page-heading-weight);
199
+ }
200
+ :scope > :where(header, header-ui):has(> [slot="icon"]) > :is([slot="heading"], h1, h2, h3, h4, h5, h6) { grid-column: 2; }
201
+ :scope > :where(header, header-ui):has(> [slot="icon"]) > :is(text-ui[variant="display"], text-ui[variant="title"], text-ui[variant="heading"], text-ui[variant="subsection"]):not([slot]) { grid-column: 2; }
202
+ :scope > :where(header, header-ui):not(:has(> [slot="icon"])) > :is([slot="heading"], h1, h2, h3, h4, h5, h6) { grid-column: 1; }
203
+ :scope > :where(header, header-ui):not(:has(> [slot="icon"])) > :is(text-ui[variant="display"], text-ui[variant="title"], text-ui[variant="heading"], text-ui[variant="subsection"]):not([slot]) { grid-column: 1; }
204
+
205
+ /* Description — row 2. :not([slot]) so a bare <p>/<small> carrying its
206
+ own slot (e.g. slot="heading") doesn't ALSO match the fallback here —
207
+ equal specificity + later source order would otherwise let this rule
208
+ win and drop it to row 2 (gh#1253 review). */
209
+ :scope > :where(header, header-ui) > :is([slot="description"], p:not([slot]), small:not([slot])),
210
+ :scope > :where(header, header-ui) > :is(text-ui[variant="body"], text-ui[variant="caption"]):not([slot]) {
211
+ grid-row: 2;
212
+ grid-column: 1 / -1;
213
+ line-height: 1.4;
214
+ margin: 0;
215
+ }
216
+ :scope > :where(header, header-ui):has(> [slot="icon"]) > :is([slot="description"], p:not([slot]), small:not([slot])) { grid-column: 2 / -1; }
217
+ :scope > :where(header, header-ui):has(> [slot="icon"]) > :is(text-ui[variant="body"], text-ui[variant="caption"]):not([slot]) { grid-column: 2 / -1; }
218
+
219
+ /* Action — row 1, last column. Flex container so it can hold badge +
220
+ button + anything inline (e.g. <toggle-scheme-ui> — gh#1253's probe).
221
+ Direct-child `>` (gh#1253 review) — a deeply-nested [slot="action"]
222
+ (e.g. inside an unrelated composite) must not pick up header-grid
223
+ flex/alignment styling; only a DIRECT header child activates it. */
224
+ :scope > :where(header, header-ui) > [slot="action"] {
225
+ justify-self: end;
226
+ align-self: center;
227
+ grid-row: 1;
228
+ grid-column: -2 / -1;
229
+ display: flex;
230
+ align-items: center;
231
+ gap: var(--page-header-gap);
232
+ min-width: 0;
233
+ }
121
234
  }
@@ -66,7 +66,34 @@ slots:
66
66
  Composes from the slot primitives — `<header-ui>` (page header),
67
67
  `<section-ui>` (main content), optional `<footer-ui>`. Native
68
68
  `<header>` / `<section>` / `<footer>` also work; the @scope rules
69
- target both via `:where(header, header-ui)`.
69
+ target both via `:where(header, header-ui)`. The page header
70
+ also activates the named slot-gated grid (`slot="icon"` /
71
+ `slot="heading"` / `slot="description"` / `slot="action"`) once
72
+ any DIRECT header child carries a `slot` attribute — same
73
+ contract as `<card-ui>`'s header (gh#1253).
74
+ icon:
75
+ description: >-
76
+ Leading icon for the page header — status, brand, or type marker.
77
+ Placed in column 1 of the header grid when present; heading +
78
+ description shift to column 2. Direct child of `<header>` /
79
+ `<header-ui>` only, per the :has(> [slot="icon"]) gate.
80
+ heading:
81
+ description: >-
82
+ Primary title — grid row 1 inside the page header. Also accepts
83
+ bare `<h1>`–`<h6>` tags (or A2UI's transpiled display/title/heading/subsection
84
+ `<text-ui>` variants) as direct children without slot="heading".
85
+ A slot="heading" wrapper can contain inline badges or metadata
86
+ alongside the title text.
87
+ description:
88
+ description: >-
89
+ Secondary metadata — grid row 2 beneath the heading inside the
90
+ page header. Also accepts bare `<p>` / `<small>` / body-variant
91
+ `<text-ui>` as direct children without slot="description".
92
+ action:
93
+ description: >-
94
+ Trailing control cluster inside the page header (icon-buttons,
95
+ menu trigger, scheme toggle, more-options). Aligns to the
96
+ flex-end edge — the fix for gh#1253's dropped `<toggle-scheme-ui>`.
70
97
  states:
71
98
  - name: idle
72
99
  description: Default, ready for interaction.
@@ -78,8 +105,8 @@ a2ui:
78
105
  rules:
79
106
  - rule: 'Top-level page container — wraps an entire route''s content surface.'
80
107
  reason: 'Page-level chrome primitive.'
81
- - rule: 'Inside <admin-shell-ui>, <chat-shell-ui>, or <editor-shell-ui>, use the shell''s own body slot instead; page-ui is for standalone routes without shell chrome.'
82
- reason: 'Shell hosting precedence.'
108
+ - rule: 'Inside <admin-shell-ui>, <chat-shell-ui>, or <editor-shell-ui>, page-ui still composes — nest it in the shell''s content column with [scroll] omitted (the shell already owns scrolling) to get page-ui''s max-width clamp, padding scale, and region rhythm. Skip page-ui only when the shell''s own header/body slots already give you everything you need.'
109
+ reason: 'Shell hosting precedence — the shell owns scroll; page-ui supplies layout, not a second scroll surface.'
83
110
  - rule: 'Hosts arbitrary children — no enforced child contract.'
84
111
  reason: 'Generic container.'
85
112
  anti_patterns: []
@@ -439,8 +439,14 @@ export class UISelect extends UIFormElement {
439
439
  : this.icon
440
440
  ? `<icon-ui slot="leading" data-select-leading name="${escapeHTML(this.icon)}"></icon-ui>`
441
441
  : '';
442
+ // No native <input> wrap (per ADR-0055 / ADR-0025): the searchable
443
+ // trigger is a `contenteditable="plaintext-only"` surface carrying
444
+ // `role="combobox"`, mirroring combobox-ui's editable-surface pattern.
445
+ // Placeholder text renders via the [data-empty]::before pseudo (see
446
+ // select.css), same mechanism as input-ui / combobox-ui.
447
+ const initialDisplayText = this.#displayText() === this.placeholder ? '' : this.#displayText();
442
448
  const displayMarkup = this.searchable
443
- ? `<input slot="display" type="text" role="combobox" aria-autocomplete="list" autocomplete="off" placeholder="${escapeHTML(this.placeholder || '')}" value="${escapeHTML(this.#displayText() === this.placeholder ? '' : this.#displayText())}" />`
449
+ ? `<span slot="display" contenteditable="plaintext-only" role="combobox" aria-autocomplete="list" tabindex="0" data-placeholder="${escapeHTML(this.placeholder || '')}"${initialDisplayText ? '' : ' data-empty'}>${escapeHTML(initialDisplayText)}</span>`
444
450
  : `<span slot="display">${escapeHTML(this.#displayText())}</span>`;
445
451
  // §184 (v0.5.5, FEEDBACK-08 §7): optional hint slot beneath the
446
452
  // trigger. Mirrors slider-ui's hint pattern + matches the
@@ -478,7 +484,7 @@ export class UISelect extends UIFormElement {
478
484
  this.#searchInput.removeEventListener('focus', this.#onSearchFocus);
479
485
  this.#searchInput.removeEventListener('click', this.#onSearchClick);
480
486
  }
481
- this.#searchInput = this.querySelector('input[slot="display"]');
487
+ this.#searchInput = this.querySelector('[slot="display"][contenteditable]');
482
488
  if (this.#searchInput) {
483
489
  this.#searchInput.addEventListener('input', this.#onSearchInput);
484
490
  this.#searchInput.addEventListener('focus', this.#onSearchFocus);
@@ -492,9 +498,13 @@ export class UISelect extends UIFormElement {
492
498
  const display = this.querySelector('[slot="display"]');
493
499
  if (display && !this.multiple) {
494
500
  // Single-select: keep the canonical text-rendering path.
495
- if (display.tagName === 'INPUT') {
496
- // Only update value when not actively editing (no active query)
497
- if (!this.#query) display.value = this.#displayText() === this.placeholder ? '' : this.#displayText();
501
+ if (display.hasAttribute('contenteditable')) {
502
+ // Only update text when not actively editing (no active query)
503
+ if (!this.#query) {
504
+ const text = this.#displayText() === this.placeholder ? '' : this.#displayText();
505
+ display.textContent = text;
506
+ display.toggleAttribute('data-empty', !text);
507
+ }
498
508
  } else {
499
509
  display.textContent = this.#displayText();
500
510
  }
@@ -502,10 +512,13 @@ export class UISelect extends UIFormElement {
502
512
  // Multi-select: display element holds the placeholder ONLY when
503
513
  // no chips are present (otherwise the chip row IS the display).
504
514
  const hasChips = this.#values().length > 0;
505
- if (display.tagName === 'INPUT') {
515
+ if (display.hasAttribute('contenteditable')) {
506
516
  // search input — keep placeholder; never inject the value text.
507
- display.placeholder = hasChips ? '' : (this.placeholder || '');
508
- if (!this.#query) display.value = '';
517
+ display.setAttribute('data-placeholder', hasChips ? '' : (this.placeholder || ''));
518
+ if (!this.#query) {
519
+ display.textContent = '';
520
+ display.toggleAttribute('data-empty', true);
521
+ }
509
522
  } else {
510
523
  display.textContent = hasChips ? '' : (this.placeholder || '');
511
524
  }
@@ -991,7 +1004,7 @@ export class UISelect extends UIFormElement {
991
1004
  // before it would start removing query characters.
992
1005
  if (e.key === 'Backspace' && this.multiple && !this.disabled && !this.readonly) {
993
1006
  const target = e.target;
994
- const fromInput = target && target.tagName === 'INPUT' && target.getAttribute('slot') === 'display';
1007
+ const fromInput = target && target.hasAttribute?.('contenteditable') && target.getAttribute('slot') === 'display';
995
1008
  const queryEmpty = !this.#query || this.#query.length === 0;
996
1009
  if (!fromInput || queryEmpty) {
997
1010
  if (this.#values().length > 0) {
@@ -1005,8 +1018,11 @@ export class UISelect extends UIFormElement {
1005
1018
  if (this.searchable && this.#query) {
1006
1019
  // First Escape: clear query
1007
1020
  this.#query = '';
1008
- const input = this.querySelector('input[slot="display"]');
1009
- if (input) input.value = '';
1021
+ const input = this.querySelector('[slot="display"][contenteditable]');
1022
+ if (input) {
1023
+ input.textContent = '';
1024
+ input.toggleAttribute('data-empty', true);
1025
+ }
1010
1026
  this.#applyFilter();
1011
1027
  return;
1012
1028
  }
@@ -1061,7 +1077,9 @@ export class UISelect extends UIFormElement {
1061
1077
  };
1062
1078
 
1063
1079
  #onSearchInput = (e) => {
1064
- this.#query = e.target.value || '';
1080
+ const text = e.target.textContent || '';
1081
+ this.#query = text;
1082
+ e.target.toggleAttribute('data-empty', !text);
1065
1083
  if (!this.open) this.open = true;
1066
1084
  this.#applyFilter();
1067
1085
  };
@@ -149,7 +149,7 @@
149
149
  white-space: nowrap;
150
150
  color: var(--select-fg);
151
151
  }
152
- input[slot="display"] {
152
+ [slot="display"][contenteditable] {
153
153
  border: none;
154
154
  outline: none;
155
155
  background: transparent;
@@ -159,9 +159,18 @@
159
159
  color: var(--select-fg);
160
160
  min-width: 0;
161
161
  width: 100%;
162
+ /* Positioning context for the [data-empty]::before placeholder pseudo
163
+ below — out-of-flow, same rationale as input-ui/combobox-ui: an
164
+ in-flow pseudo would render the caret after the placeholder text
165
+ instead of at content-start. */
166
+ position: relative;
162
167
  }
163
- input[slot="display"]::placeholder {
168
+ [slot="display"][contenteditable][data-empty]::before {
169
+ content: attr(data-placeholder);
164
170
  color: var(--select-placeholder-fg);
171
+ pointer-events: none;
172
+ position: absolute;
173
+ inset: 0;
165
174
  }
166
175
  :scope:not([value]) [slot="display"],
167
176
  :scope[value=""] [slot="display"] {
@@ -274,20 +274,22 @@
274
274
  z-index: 1;
275
275
  }
276
276
  :scope[shape="block"][auto-contrast] > [data-label][data-on-dark] {
277
- color: var(--a-chrome-light, #fafafa);
277
+ color: var(--a-chrome-light);
278
278
  }
279
279
  :scope[shape="block"][auto-contrast] > [data-label][data-on-light] {
280
- color: var(--a-chrome-dark, #111);
280
+ color: var(--a-chrome-dark);
281
281
  }
282
282
  /* Detail line follows the same auto-contrast choice. */
283
283
  :scope[shape="block"][auto-contrast] > [data-detail] {
284
284
  z-index: 1;
285
285
  }
286
286
  :scope[shape="block"][auto-contrast] > [data-label][data-on-dark] ~ [data-detail] {
287
- color: color-mix(in oklab, var(--a-chrome-light, #fafafa) 80%, transparent);
287
+ color: var(--a-chrome-light);
288
+ opacity: 0.8;
288
289
  }
289
290
  :scope[shape="block"][auto-contrast] > [data-label][data-on-light] ~ [data-detail] {
290
- color: color-mix(in oklab, var(--a-chrome-dark, #111) 70%, transparent);
291
+ color: var(--a-chrome-dark);
292
+ opacity: 0.7;
291
293
  }
292
294
 
293
295
  /* ═══════ Copy button — a stamped <button-ui variant="ghost" size="sm">