@adia-ai/web-components 0.8.38 → 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 (34) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/MIGRATION.md +3 -2
  3. package/components/calendar-picker/calendar-picker.css +4 -1
  4. package/components/combobox/combobox.css +6 -0
  5. package/components/command/command.a2ui.json +3 -0
  6. package/components/command/command.class.js +28 -6
  7. package/components/command/command.css +14 -3
  8. package/components/command/command.yaml +5 -0
  9. package/components/date-range-picker/date-range-picker.css +6 -0
  10. package/components/datetime-picker/datetime-picker.css +4 -0
  11. package/components/drilldown/drilldown.a2ui.json +3 -1
  12. package/components/drilldown/drilldown.class.js +12 -4
  13. package/components/drilldown/drilldown.css +7 -8
  14. package/components/drilldown/drilldown.yaml +2 -0
  15. package/components/modal/modal.class.js +68 -4
  16. package/components/nav/nav.a2ui.json +5 -0
  17. package/components/nav/nav.class.js +24 -4
  18. package/components/nav/nav.d.ts +2 -0
  19. package/components/nav/nav.yaml +18 -0
  20. package/components/select/select.class.js +30 -12
  21. package/components/select/select.css +11 -2
  22. package/components/swatch/swatch.css +6 -4
  23. package/components/toggle-group/toggle-group.class.js +21 -11
  24. package/components/toggle-group/toggle-group.css +16 -8
  25. package/components/toggle-group/toggle-group.d.ts +6 -0
  26. package/components/toggle-group/toggle-group.yaml +10 -0
  27. package/components/toggle-group/toggle-option.a2ui.json +5 -0
  28. package/components/toggle-group/toggle-option.yaml +18 -2
  29. package/custom-elements.json +95 -75
  30. package/dist/theme-provider.min.js +3 -3
  31. package/dist/web-components.min.css +1 -1
  32. package/dist/web-components.min.js +56 -53
  33. package/dist/web-components.sheet.js +1 -1
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,11 +1,29 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.8.39] — 2026-08-15
4
+
5
+ ### Changed
6
+ - **`toggle-option-ui` converges onto `segment-ui`'s declared, parent-managed, reflected `[selected]` prop (ADR-0056, gh#1303).** Found by `framework-alignment-agent`'s corpus-wide component-state-naming sweep: the two same-role selection-item primitives named their "currently selected" state differently — `segment-ui` declared a reflected `selected: Boolean` prop (yaml → catalog → generative grammar), while `toggle-option-ui` had no declared state at all; the parent (`toggle-group-ui`) instead stamped a private `data-selected` attribute invisible to the yaml SoT, catalog, and A2UI grammar. `toggle-option-ui` gains `selected` (`Boolean`, default `false`, reflected) in `toggle-option.yaml` + `toggle-group.class.js`; `toggle-group-ui` now sets `selected` on its children (the `UIToggleOption` render pass stamps `aria-pressed` from it, mirroring `segment-ui`'s `aria-checked`-from-`selected` pattern); `toggle-group.css`'s `:scope[data-selected]` style rule becomes `toggle-option-ui[selected]` (moved outside `@scope`, same Safari-17.x attribute-restyle workaround `segment.css` already uses). Behavior, keyboard model, and the group's `value` as the single source of truth are unchanged. **SCOPING RULING (additive only, not the full ADR):** `toggle-group-ui` keeps stamping the legacy `data-selected` attribute on options in parallel, alongside the new `selected` prop — marked **DEPRECATED** in `toggle-option.yaml`/`toggle-group.yaml` — because it was an undocumented hook external CSS may already target; removing it is the breaking half of ADR-0056 and rides the ratified **0.9.0** breaking wave (alongside ADR-0052/0053/0054) rather than landing in this patch. `toggle-group.test.js` (new) covers the reflected `[selected]` + `aria-pressed` pairing, group-managed single-selection, multi-select, and the `data-selected` parallel-stamp deprecation case (named so it's obvious which test flips at 0.9.0 removal); `segmented.test.js` gains a regression check that `segment-ui`'s reference `[selected]`/`aria-checked` pairing is unchanged.
7
+ ### Fixed
8
+ - **Trigger-caret sizing — the universal caret-size ruling (`--a-icon-size: var(--a-caret-size)`, encoded on `select-ui`'s trigger caret) is now applied consistently across the input/picker family (gh#1314).** `date-range-picker-ui`'s trigger caret never applied the rule at all, so it rode the ambient icon size and rendered visibly larger than `select-ui`'s caret in mixed control rows (e.g. the admin Finance Batches filter bar; consumer defect ADIA2-8729). Sweeping the rest of the trigger-caret family for the same class of miss found three more members: `datetime-picker-ui`'s trigger caret was missing the rule the same way; `combobox-ui`'s default caret-down suffix icon was sized off `--combobox-font-size` (the same value its leading/prefix icon correctly uses) instead of `--a-caret-size`; `calendar-picker-ui`'s `[slot="caret"]` was likewise sized off its own `--calendar-picker-font-size`. All four now resolve `--a-icon-size` to `--a-caret-size` on the caret glyph specifically (name-guarded on `combobox-ui`/date-picker family so a consumer-slotted non-caret glyph is unaffected), so every trigger caret in the family reads as one size. `time-picker-ui` was audited and excluded — it has no chevron/trigger caret (segmented inline input only). New/updated regression coverage in `date-range-picker.test.js`, `datetime-picker.test.js`, `combobox.test.js`, and a new `calendar-picker.test.js` asserts each caret rule's declared `--a-icon-size` computed value (CSS-source assertion — happy-dom doesn't evaluate `@scope` via `getComputedStyle`, same documented limitation as `badge.test.js`/`tag.test.js`).
9
+ - **ADR-0055 — `command-ui`, `select-ui`'s searchable trigger, and `drilldown-ui`'s filter row no longer stamp a native `<input>` with no recorded ADR-0025 exception (gh#1302).** A corpus sweep (gh#1302) found three uncatalogued native `<input>` stamps: `command-ui`'s palette search field, `select-ui`'s searchable-trigger branch (ADR-0025's own compliant-list entry predated/overlooked this branch), and `drilldown-ui`'s filter row (found while decomposing the issue, not in the original report). ADR-0055 rules no new exceptions get added — all three are replaced with patterns already in-tree: `command-ui` and `select-ui`'s searchable branch now use a `contenteditable="plaintext-only"` span carrying `role="combobox"` (the `combobox-ui` pattern — placeholder via the `[data-empty]::before` pseudo, same mechanism as `input-ui`/`combobox-ui`); `drilldown-ui`'s filter row now composes `<input-ui raw>` (the `search-ui` precedent). Behavior parity verified by a per-surface browser probe (typing, filtering, Escape-clears-query, Backspace-doesn't-navigate-back for drilldown) — not tests alone. `agent-trace`'s native `<details>`/`<summary>` disclosure toggle is ruled explicitly out of ADR-0025's scope by the same ADR (carries no submittable value). `command.yaml` gains the `input` slot entry the CSS rewrite now positions (`audit-slot-vocab-vs-css`). `drilldown.yaml`'s `composes:` list gains `input-ui` (+ `button-ui`, previously undeclared for the back affordance).
10
+ - **`scripts/dev/audit-stamped-native-buttons.mjs` extended to cover native `<input>` stamps, not just `<button>` (ADR-0055 follow-up, gh#1302).** Mirrors the existing button gate: no `createElement('input')` / `<input` in component source outside a `SANCTIONED_INPUT` map with a per-file reason. `input.class.js`'s `type="password"` branch is the one ADR-0025-named exception. Widening the scan surfaced pre-existing native `<input>` stamps outside gh#1302's audited scope — three `type="file"` OS-file-picker triggers (`chat-input.js`, `upload.class.js`, `admin-roster.js`) and two text-gathering surfaces (`otp-input.class.js`'s digit boxes, `table.class.js`'s filter-popover inputs) — all recorded as `PENDING` findings for a follow-up maintainer call, not fixed here (outside this issue's named scope).
11
+ - **`modal-ui` — `close` now fires reliably on every programmatic dismissal, including a parent-driven teardown race that was the actual root cause (gh#1295, adiav2 ADIA2-8581).** `close` previously depended entirely on the native `<dialog>` element's own queued `close` task re-dispatching it, which a consumer's own re-render could tear down before it ran. Real-browser reproduction (a vitest/happy-dom harness alone can't see this) traced the true root cause further: EVERY internal close path only ever runs from `<modal-ui>`'s own render effect, which reacts to the `open` property write via a microtask (signals.js) — `disconnectedCallback()` disposes that effect unconditionally. A consumer whose Confirm/Cancel handler flips `open = false` and, on the SAME tick, unmounts the modal (an ordinary framework re-render shape) tears the effect down before `#animateClose`'s exit-animation timer is ever even created, so `close` never fires and any caller awaiting it (`showConfirmDialog`-style APIs) hangs forever — confirmed reproducing, and NOT fixed, against PR #1296's authoritative-timer-dispatch approach, since that timer is one of the things the teardown race skips creating. `disconnected()` now emits `close` as a last-resort teardown fallback whenever the modal was open or mid-close at disconnect time, guarded by a once-only dispatch flag shared with the two other paths (the native `<dialog>` `close` listener, kept as a redundant net; `#animateClose`'s own timer, now also an authoritative direct dispatch — both absorbed from PR #1296, which correctly diagnosed the duration/unit bug below but couldn't reproduce this teardown race). `#getDuration()` also no longer silently drops a `s` vs `ms` unit on `--modal-duration` (`parseFloat('.25s')` read as `0.25`, a 0.25ms timer instead of 250ms — PR #1296's fix, kept as-is). `modal.test.js` gains per-dismissal-path close-emission coverage (button, Escape, backdrop, direct `.open=false`, raw `dialog.close()`, no-double-fire, re-open reset) plus the teardown-race repro itself.
12
+
13
+ ### Added
14
+ - **`nav-ui` — selecting a page now collapses every expanded group except the one containing the newly selected item (gh#1306).** Previously, `select()` only moved `[selected]` between items and never touched group `[open]` state, so any group a consumer or the collapsed-rail popover had expanded stayed expanded indefinitely, even after navigating elsewhere. Default-on: `select()` now closes every top-level `<nav-group-ui>` except the selected item's ancestor group (all groups, when the selection is ungrouped) — manual multi-expansion is still allowed right up until the next selection. New `[multi-expand]` attribute opts a consumer out, keeping every manually expanded group open across selections. Applies to every `select()` entrypoint: click, keyboard, programmatic, and the collapsed-rail popover option click (`nav-group.class.js`'s `showPopover()`, which itself calls `nav.select()`).
15
+
16
+ ### Maintenance
17
+ - **`components/` touched in this release window** (39 file(s), e.g. `calendar-picker/calendar-picker.css`) — carried by the entries above.
18
+ - **`dist/` bundles rebuilt** in this cut's window (4 file(s)) — regenerated from the source changes described above, not independent edits.
19
+
3
20
  ## [0.8.38] — 2026-08-15
4
21
 
5
22
  ### Added
6
23
  - **`drilldown-ui` — a new single-panel, multi-level drill-in menu primitive (gh#1285).** Replaces the hand-rolled miller-columns shape (two stacked list columns) that adiav2's Schema Manager had to build because the kit had no drill-down primitive — cleared the pre-build audit against `tree-ui` (inline expand/collapse, wrong for wide/deep >2-level data), `segmented-ui` (no overflow behavior for 18 namespaces), `tag-ui`-as-menu (no keyboard path), `list-ui`/`nav-ui`/`menu-ui`/`context-menu`/`breadcrumb-ui` (flat, not hierarchical drill-in) — none provide single-panel level-swap navigation with back/focus-restoration. Data-driven (`.items` tree of `{value, label, meta?, dot?, counts?, children?}` nodes; `children` may be sync or an async `(item) => array | Promise<array>` lazy loader, rendering a `<skeleton-ui>` loading level while pending). Full keyboard path (roving-tabindex listbox per level; ArrowRight/Enter drills in, ArrowLeft/Backspace goes back with focus restored to the item drilled from), `select` + `navigate` events, a `path` property that reflects to a JSON-encoded attribute both ways (hand-managed, not the generic `reflect:` mechanism — array values don't round-trip through `String(v)`) for deep-link/URL restore, `[filterable]` filter input, `[select-on-drill]` mode flag, a `breadcrumb` slot, row-native trailing count chips (not full `<badge-ui>`, which reads oversized at row density), and a horizontal slide transition that honors `prefers-reduced-motion: reduce` automatically (also suppressible via `[static]`).
7
24
 
8
25
  ### Fixed
26
+ - **`swatch-ui` — auto-contrast label/detail colors no longer fall back to raw hex, and detail-line dimming no longer uses `color-mix()`.** Found by a new `framework-alignment-agent` cohesion sweep: `--a-chrome-light`/`--a-chrome-dark` are always-defined foundational tokens (`styles/colors/semantics/features.css:124-125`), so the `var(--a-chrome-light, #fafafa)` / `var(--a-chrome-dark, #111)` fallbacks on the `[auto-contrast]` label rules were dead weight and a raw-CSS-color violation. The detail-line dimming also swapped `color-mix(in oklab, …, transparent)` for the plain token color plus `opacity` — not a browser-support fix (`color-mix()` is this repo's documented, Baseline-supported convention per `ADR-0007`/`BROWSER-COMPAT.md`), but a deliberate preference for a literal token over a computed color derivation.
9
27
  - **`page-ui` header now ships the slot-gated grid its docs promised (gh#1253).** ADR-0009 recorded the named-slot vocabulary (`slot="icon"` / `slot="heading"` / `slot="action"`) as wired into four containers — Card, Drawer, Modal, and Page — but `page.css` never shipped its half; the slot attributes on a page header's children were dead metadata, so a block-level action child (e.g. `<toggle-scheme-ui>`) dropped to its own row instead of clustering. `page.css` gains the same `:has(> [slot])`-gated header grid as `card.css` (icon/heading/action columns, direct-child guard against nested false-positives), plus the `--page-header-gap` / `--page-heading-*` tokens `card-ui`'s pair already had. `page.yaml`'s `slots.default` and `page.examples.html` (new "Header slot-gated grid" demo) now describe the contract that's actually implemented.
10
28
  - **`nav-ui` / `nav-item-ui` — `nav-select` now fires exactly once per click, and zero times re-clicking the already-selected item (gh#1254).** Root cause: two layered click handlers both drove selection for the same physical click — `nav-item-ui`'s own click handler called `nav.select(this)` *and* dispatched its own `nav-select`, while `nav-ui`'s click-delegation listener matched the same item on bubble and called `select()` again — three dispatches per click. `nav.select()` is now the single source of the event (idempotent: a no-op, including no hover-flush, when the item is already selected); `nav-item-ui` no longer dispatches its own copy, and `nav-ui`'s click listener no longer delegates item selection (it now only handles group expand/popover).
11
29
  - **`view-transition` trait — no longer leaks unhandled `AbortError` rejections when a transition is superseded (gh#1255).** Only `transition.finished` had a `.catch()`; `ready` and `updateCallbackDone` are distinct promises that also reject (with `AbortError`) when a newer transition supersedes this one, and were previously uncaught. All three are now swallowed on abort — expected behavior for rapid navigation, not an error — with `fireEnd()` still driven solely by `finished`.
package/MIGRATION.md CHANGED
@@ -79,8 +79,9 @@ The split is by concept, ruled in `docs/adr/adr-0048-a2ui-gen-ui-estate-split.md
79
79
  `@adia-ai/a2ui-protocol-mcp`, before either ever published) ships one bin,
80
80
  `adia-mcp`, dispatching two subcommands as separate processes:
81
81
  `adia-mcp gen-ui` (30 tools — the generation surface, direct successor of
82
- `@adia-ai/a2ui-mcp`) and `adia-mcp protocol` (4 tools — `validate_document`,
83
- `get_registry_map`, `get_wiring_registry`, `protocol_status`; renamed off
82
+ `@adia-ai/a2ui-mcp`) and `adia-mcp protocol` (4 tools at the fold — `validate_document`,
83
+ `get_registry_map`, `get_wiring_registry`, `protocol_status`, joined since by
84
+ `get_catalog_ladder`; the first four renamed off
84
85
  their original same-named-as-`gen-ui`-surface forms by gh#1248 before this
85
86
  server ever published). The name `@adia-ai/a2ui-mcp` is **burned, never
86
87
  reused**: it means "the generation MCP" to every consumer that has it
@@ -178,7 +178,10 @@
178
178
 
179
179
  [slot="caret"] {
180
180
  flex-shrink: 0;
181
- --a-icon-size: var(--calendar-picker-font-size);
181
+ /* Universal caret size (gh#1314 sweep) — was riding the trigger's
182
+ font-size instead of the shared --a-caret-size token select-ui's
183
+ trigger caret already reads. */
184
+ --a-icon-size: var(--a-caret-size);
182
185
  }
183
186
  }
184
187
 
@@ -127,6 +127,12 @@
127
127
  cursor: pointer;
128
128
  }
129
129
  [data-suffix] icon-ui[name="caret-down"] {
130
+ /* Universal caret size (gh#1314 sweep) — overrides the shared
131
+ [data-prefix]/[data-suffix] --a-icon-size (font-size) above so the
132
+ default caret matches select-ui's trigger caret instead of riding
133
+ the field's text size. Name-guarded: a consumer-slotted non-caret
134
+ suffix glyph keeps the font-size default. */
135
+ --a-icon-size: var(--a-caret-size);
130
136
  transition: rotate var(--a-duration-fast) var(--a-easing);
131
137
  }
132
138
  /* Clear OPEN state: the default caret flips while the listbox is open.
@@ -105,6 +105,9 @@
105
105
  "footer": {
106
106
  "description": "Keyboard hint bar"
107
107
  },
108
+ "input": {
109
+ "description": "The auto-stamped search field — a contenteditable=\"plaintext-only\" span carrying role=\"combobox\" (per ADR-0025/ADR-0055; no native <input>). Internal only; not consumer-slottable."
110
+ },
108
111
  "list": {
109
112
  "description": "Container for command items and groups"
110
113
  }
@@ -14,6 +14,12 @@
14
14
  import { UIElement } from '../../core/element.js';
15
15
  import { logicalSlotted } from '../../core/logical-children.js';
16
16
 
17
+ function escapeHTML(s) {
18
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
+ }
20
+
21
+ let cmdInstanceSeq = 0;
22
+
17
23
  /**
18
24
  * <command-ui> — Searchable command palette.
19
25
  *
@@ -58,6 +64,7 @@ export class UICommand extends UIElement {
58
64
  #bound = false;
59
65
  #itemByEl = new WeakMap();
60
66
  #emptyNodes = [];
67
+ #instanceId = `command-${++cmdInstanceSeq}`;
61
68
 
62
69
  static #RECENTS_MAX = 3;
63
70
 
@@ -96,12 +103,22 @@ export class UICommand extends UIElement {
96
103
  }
97
104
  this.#emptyNodes = logicalSlotted(this, 'empty');
98
105
 
106
+ const listId = `${this.#instanceId}-list`;
107
+
108
+ // No native <input> wrap (per ADR-0055 / ADR-0025): the search field
109
+ // is a `contenteditable="plaintext-only"` surface carrying
110
+ // `role="combobox"`, matching combobox-ui's editable-surface pattern.
111
+ // Placeholder text renders via the [data-empty]::before pseudo (see
112
+ // command.css), same mechanism as input-ui / combobox-ui.
99
113
  this.innerHTML = `
100
114
  <header>
101
115
  <icon-ui name="magnifying-glass" slot="icon"></icon-ui>
102
- <input type="text" placeholder="${this.placeholder}" slot="input" />
116
+ <span contenteditable="plaintext-only" role="combobox" tabindex="0"
117
+ aria-autocomplete="list" aria-expanded="false" aria-controls="${listId}"
118
+ data-placeholder="${escapeHTML(this.placeholder)}" data-empty
119
+ slot="input"></span>
103
120
  </header>
104
- <section slot="list"></section>
121
+ <section slot="list" id="${listId}"></section>
105
122
  <footer>
106
123
  <span data-hint><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
107
124
  <span data-hint><kbd>↵</kbd> Select</span>
@@ -109,7 +126,7 @@ export class UICommand extends UIElement {
109
126
  </footer>
110
127
  `;
111
128
 
112
- this.#inputEl = this.querySelector('input');
129
+ this.#inputEl = this.querySelector('[slot="input"]');
113
130
  this.#listEl = this.querySelector('[slot="list"]');
114
131
  this.#footerEl = this.querySelector('footer');
115
132
 
@@ -120,6 +137,8 @@ export class UICommand extends UIElement {
120
137
  this.#renderItems();
121
138
  }
122
139
 
140
+ if (this.#inputEl) this.#inputEl.setAttribute('aria-expanded', String(this.open));
141
+
123
142
  if (this.open) {
124
143
  this.#focusRaf = requestAnimationFrame(() => {
125
144
  this.#focusRaf = null;
@@ -130,11 +149,12 @@ export class UICommand extends UIElement {
130
149
 
131
150
  // ── Public API ──
132
151
 
133
- get value() { return this.#inputEl?.value || ''; }
152
+ get value() { return this.#inputEl?.textContent || ''; }
134
153
  set value(v) {
135
154
  if (!this.#inputEl) return;
136
155
  const next = v ?? '';
137
- this.#inputEl.value = next;
156
+ this.#inputEl.textContent = next;
157
+ this.#inputEl.toggleAttribute('data-empty', !next);
138
158
  // Keep the rendered list in sync with the visible input — the host
139
159
  // commonly clears value on (re)open, and consumers expect to see the
140
160
  // full list (plus recents) rather than the previous filter's residue.
@@ -421,7 +441,9 @@ export class UICommand extends UIElement {
421
441
  // ── Events ──
422
442
 
423
443
  #onInput = () => {
424
- this.#renderItems(this.#inputEl.value);
444
+ const text = this.#inputEl.textContent || '';
445
+ this.#inputEl.toggleAttribute('data-empty', !text);
446
+ this.#renderItems(text);
425
447
  };
426
448
 
427
449
  #onKeydown = (e) => {
@@ -102,7 +102,7 @@
102
102
  --a-icon-size: 1rem;
103
103
  }
104
104
 
105
- & > header input {
105
+ & > header [slot="input"] {
106
106
  flex: 1;
107
107
  min-width: 0;
108
108
  border: none;
@@ -113,14 +113,25 @@
113
113
  font-size: var(--command-input-font-size);
114
114
  line-height: 1.4;
115
115
  padding: 0;
116
+ white-space: nowrap;
117
+ overflow: hidden;
118
+ /* Positioning context for the [data-empty]::before placeholder pseudo
119
+ below — out-of-flow, same rationale as input-ui/combobox-ui: an
120
+ in-flow pseudo would render the caret after the placeholder text
121
+ instead of at content-start. */
122
+ position: relative;
116
123
  }
117
124
 
118
- & > header input::placeholder {
125
+ & > header [slot="input"][data-empty]::before {
126
+ content: attr(data-placeholder);
119
127
  color: var(--command-fg-muted);
128
+ pointer-events: none;
129
+ position: absolute;
130
+ inset: 0;
120
131
  }
121
132
 
122
133
  /* Suppress focus ring on the input — the palette itself is the focused surface */
123
- & > header input:focus-visible {
134
+ & > header [slot="input"]:focus-visible {
124
135
  outline: none;
125
136
  box-shadow: none;
126
137
  }
@@ -67,6 +67,11 @@ slots:
67
67
  description: Empty state shown when no items match
68
68
  footer:
69
69
  description: Keyboard hint bar
70
+ input:
71
+ description: >-
72
+ The auto-stamped search field — a contenteditable="plaintext-only"
73
+ span carrying role="combobox" (per ADR-0025/ADR-0055; no native
74
+ <input>). Internal only; not consumer-slottable.
70
75
  list:
71
76
  description: Container for command items and groups
72
77
  states:
@@ -90,6 +90,12 @@
90
90
  equal specificity so the picker trigger reads as part of the
91
91
  input/picker family rather than a generic button. */
92
92
  :scope [slot="trigger"] icon-ui[name="caret-down"] {
93
+ /* Universal caret size (gh#1314) — without this the caret rides the
94
+ ambient icon size instead of the shared --a-caret-size token that
95
+ select-ui's trigger caret already reads, so mixed control rows
96
+ (select-ui next to date-range-picker-ui) showed visibly different
97
+ chevron sizes. */
98
+ --a-icon-size: var(--a-caret-size);
93
99
  transition: rotate var(--a-duration-fast) var(--a-easing);
94
100
  }
95
101
  /* Clear OPEN state: the default trigger's trailing caret flips while the
@@ -62,6 +62,10 @@
62
62
  left-aligned with the caret pushed to the trailing edge, not centered
63
63
  like a default button. */
64
64
  :scope [slot="trigger"] icon-ui[name="caret-down"] {
65
+ /* Universal caret size (gh#1314 sweep) — mirrors select-ui's trigger
66
+ caret so mixed control rows read as one family instead of showing
67
+ the ambient (larger) icon size. */
68
+ --a-icon-size: var(--a-caret-size);
65
69
  transition: rotate var(--a-duration-fast) var(--a-easing);
66
70
  }
67
71
  /* Clear OPEN state: the default trigger's trailing caret flips while the
@@ -60,7 +60,9 @@
60
60
  "composes": [
61
61
  "icon-ui",
62
62
  "skeleton-ui",
63
- "empty-state-ui"
63
+ "empty-state-ui",
64
+ "input-ui",
65
+ "button-ui"
64
66
  ],
65
67
  "events": {
66
68
  "navigate": {
@@ -181,6 +181,10 @@ export class UIDrilldown extends UIElement {
181
181
  // ── Structural stamp (once) ──
182
182
 
183
183
  #stamp() {
184
+ // No native <input> wrap (per ADR-0055 / ADR-0025): the filter field
185
+ // composes <input-ui> — the search-ui precedent — instead of stamping
186
+ // a raw <input>. `raw` strips input-ui's own chrome so it blends into
187
+ // this row's own icon + border-bottom (drilldown.css owns the layout).
184
188
  this.innerHTML = `
185
189
  <div slot="header">
186
190
  <button-ui slot="back" icon="caret-left" variant="ghost" size="sm" hidden>
@@ -194,7 +198,7 @@ export class UIDrilldown extends UIElement {
194
198
  </div>
195
199
  <div slot="filter" hidden>
196
200
  <icon-ui name="magnifying-glass"></icon-ui>
197
- <input type="text" />
201
+ <input-ui raw></input-ui>
198
202
  </div>
199
203
  <div slot="viewport">
200
204
  <div slot="panel" role="listbox"></div>
@@ -207,7 +211,7 @@ export class UIDrilldown extends UIElement {
207
211
  this.#titleEl = this.querySelector('[data-level-title]');
208
212
  this.#countEl = this.querySelector('[data-level-count]');
209
213
  this.#filterRowEl = this.querySelector('[slot="filter"]');
210
- this.#filterInputEl = this.querySelector('[slot="filter"] input');
214
+ this.#filterInputEl = this.querySelector('[slot="filter"] input-ui');
211
215
  this.#panelEl = this.querySelector('[slot="panel"]');
212
216
  this.#liveEl = this.querySelector('[slot="live"]');
213
217
  this.#breadcrumbEl = this.querySelector('[slot="breadcrumb"]');
@@ -233,7 +237,7 @@ export class UIDrilldown extends UIElement {
233
237
 
234
238
  #syncChrome() {
235
239
  this.#filterRowEl.hidden = !this.filterable;
236
- this.#filterInputEl.placeholder = this.placeholder;
240
+ this.#filterInputEl.setAttribute('placeholder', this.placeholder);
237
241
  }
238
242
 
239
243
  // ── Tree walking ──
@@ -524,7 +528,11 @@ export class UIDrilldown extends UIElement {
524
528
  }
525
529
  case 'ArrowLeft':
526
530
  case 'Backspace':
527
- if (e.key === 'Backspace' && e.target === this.#filterInputEl) break;
531
+ // input-ui's editable surface is a light-DOM contenteditable span
532
+ // INSIDE the composed <input-ui> — e.target on a keydown fired while
533
+ // typing is that inner span, not the <input-ui> host itself (unlike
534
+ // the former native <input>, where target === the field element).
535
+ if (e.key === 'Backspace' && this.#filterInputEl?.contains(e.target)) break;
528
536
  e.preventDefault();
529
537
  this.back();
530
538
  break;
@@ -144,18 +144,17 @@
144
144
  color: var(--drilldown-fg-muted);
145
145
  flex-shrink: 0;
146
146
  }
147
- [slot="filter"] input {
147
+ /* Composed <input-ui raw> (per ADR-0055 / ADR-0025 — no native <input>
148
+ wrap; the search-ui precedent). `raw` already strips input-ui's own
149
+ border/background/padding (input.css); re-point its own tokens so the
150
+ text inherits this row's chrome instead of input-ui's defaults. */
151
+ [slot="filter"] input-ui {
148
152
  flex: 1;
149
153
  min-width: 0;
150
- border: none;
151
- outline: none;
152
- background: transparent;
153
- color: var(--drilldown-fg);
154
+ --input-fg: var(--drilldown-fg);
155
+ --input-placeholder-fg: var(--drilldown-fg-muted);
154
156
  font: inherit;
155
157
  }
156
- [slot="filter"] input::placeholder {
157
- color: var(--drilldown-fg-muted);
158
- }
159
158
 
160
159
  /* ── Viewport / panel ── */
161
160
  [slot="viewport"] {
@@ -27,6 +27,8 @@ composes:
27
27
  - icon-ui
28
28
  - skeleton-ui
29
29
  - empty-state-ui
30
+ - input-ui # filter row's text field (ADR-0055 — no native <input> in stamped DOM)
31
+ - button-ui # back-navigation affordance
30
32
  props:
31
33
  items:
32
34
  description: >-
@@ -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;
@@ -99,6 +103,7 @@ export class UINav extends UIElement {
99
103
  if (item) {
100
104
  item.setAttribute('selected', '');
101
105
  this.#flushHoverState();
106
+ this.#collapseSiblingGroups(item);
102
107
  this.dispatchEvent(new CustomEvent('nav-select', {
103
108
  bubbles: true,
104
109
  detail: { item, text: item.text, value: item.value },
@@ -106,6 +111,21 @@ export class UINav extends UIElement {
106
111
  }
107
112
  }
108
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
+
109
129
  toggle() {
110
130
  if (this.variant === 'section') return; // no-op for section variant
111
131
  this.collapsed = !this.collapsed;
@@ -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