@adia-ai/web-components 0.5.18 → 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,36 @@ runtime ships in the sibling `@adia-ai/a2ui-runtime` package
11
11
 
12
12
  _No pending changes._
13
13
 
14
+ ## [0.5.20] - 2026-05-18
15
+
16
+ ### Added — §353 (v0.5.20) — `<pane-ui>` + `<tabs-ui>` bar-rule alignment
17
+
18
+ Both primitives' header/tablist bars converted from `border-bottom` to `box-shadow: inset 0 -1px 0`. Header height fixed at `--pane-bar-height` / `--tabs-button-height` (was `min-height` + the border added a pixel). Pane header gains `box-sizing: border-box` + `justify-content: flex-start`. Collapsed pane header drops the box-shadow. Visually identical to consumers; measurable change in computed height (1px shorter than border-bearing version). Brings these two primitives in line with the rest of the catalog using box-shadow for bar rules.
19
+
20
+ ### Docs
21
+ - §351 (FB-53 #2) — USAGE.md `§349 Event handling inside reactive lists` section gains a `CSS child-combinator caveat` paragraph. Calls out that `display:contents` wrapper-spans ARE in the DOM tree, so the CSS child combinator (`>`) walks past them: `.parent > .my-item` will NOT match items inside `repeat()` because the wrapper-span sits between. Recommends descendant combinator (single space) instead. Same caveat extended to `:nth-child` / positional pseudo-classes. Closes the post-Lane-AA1 migration docs gap surfaced by Tokens Studio.
22
+ - §352 (FB-53 #3) — USAGE.md `§345 .map() vs repeat() for reactive lists` section gains a `Sizing heuristic` paragraph. Two cases where `repeat()` pays for itself: (a) ≥20 items re-rendering on every signal change, OR (b) any-size list whose items host custom elements with internal state (chrome slots, focus, scroll). Discourages reflexive `.map()` → `repeat()` migration when neither threshold applies.
23
+
24
+ ### Fixed
25
+ - §-headers (v0.5.20) — USAGE.md headers for `§345` and `§349` renumbered from `§-TBD (v0.5.19)` to `§345 (v0.5.19)` / `§349 (v0.5.19)` — peer-agent's v0.5.19 pre-cut docs sweep renumbered the CHANGELOG but missed the USAGE.md section headers. Sync restored so the section anchors match between CHANGELOG and USAGE.md.
26
+
27
+ ## [0.5.19] - 2026-05-17
28
+
29
+ ### Added
30
+ - §348 (FB-51 #3; P3) — `<description-list-ui>` `align="stretch"` enum value (joins `start`, `between`). For `layout="inline"`, sets `justify-self: stretch` + `width: 100%` on `<dd>` (and `width: 100%` on its direct children) so block-level form controls — `<slider-ui>`, `<select-ui>`, `<color-picker-ui>` — reach the column edge rather than shrink-wrapping to content. Closes the Tokens Studio dts-right-panel migration path for property-row pairs where the value is a control, not text. Schema + generated `.d.ts` + `a2ui.json` updated. NEW `description-list.test.js` with 3 cases (CSS source contract, host attribute precondition, yaml enum shape).
31
+ - §347 (FB-50 #3; P3) — `<command-ui>` `select` event detail now carries `category` from the parent `<optgroup label="…">`. Items without a group surface `category = ''` so destructuring stays safe. Schema (`command.yaml`) + generated `.d.ts` + `a2ui.json` updated. Closes Tokens Studio's consumer pattern of routing selected commands by section (Navigation / Edit / etc.) without re-walking the DOM to find the parent optgroup label.
32
+ - §342 (FB-44; P1) — `<swatch-ui>` `MutationObserver` for late-arriving `[slot="chrome"]` children. `#absorbChromeSlot()` runs from `#syncCore()` which only fires on attribute / property changes; `scan()` walks `element.attributes` BEFORE descending to child nodes, so the parent template's `update()` loop assigns properties (→ `render()` → `#syncCore()` → `#absorbChromeSlot()`) BEFORE interpolating child node parts. The once-only absorb pass therefore sees an empty children set and chrome — arriving moments later — sits at its arrival position rather than the canonical post-tile region. Fix: arm a `MutationObserver` on host `childList` in `connected()` that re-runs `#absorbChromeSlot()` whenever a direct `[slot="chrome"]` child OR a `display:contents` wrapper-span containing chrome is added. Observer is torn down in `disconnected()`. Closes the post-§341 follow-up gap in Tokens Studio's C1.3 main-palette dogfood (gamut badges + override / tracked dots invisible despite §341 placeholder-preservation). 3 NEW vitest cases in `swatch.test.js`'s `FB-44 / §-TBD` describe block.
33
+ - §343 (FB-46; P2) — `<tree-ui>` `tree-select` event detail now forwards `ctrlKey` / `metaKey` / `shiftKey` from the originating `MouseEvent` or `KeyboardEvent`. Unblocks consumer-side Ctrl/Cmd+click multi-select implementations (Tokens Studio token-tree). `select(item, originatingEvent = null)` is the new public signature; programmatic calls (no event) surface all three modifiers as `false`. Schema (`tree.yaml`) + generated `.d.ts` + `a2ui.json` updated. 4 NEW vitest cases in new `tree.test.js`. Backward-compatible: existing `detail.{item,text,value}` shape unchanged; new fields are additive.
34
+
35
+ ### Tests
36
+ - §344 (FB-47; partial / misdiagnosed) — 3 NEW vitest cases in `core/template.test.js`'s `FB-47` describe block lock the actual contract: (1) `stamp()` with same template strings reuses the mounted instance (no `replaceChildren()`); (2) `repeat()` with same key preserves wrapper children across re-renders; (3) the `Array.isArray(v)` branch in `applyValue()` DOES rebuild on every re-render (the actual `.map()` destruction surface, documented behavior). The FB-47 author's "`stamp()` is unconditional" + "`repeat()` keyed diffing defeated" claims are empirically false; the tests pin current behavior to catch future drift. See RESPONSE-47 for the consumer-side `.map()` → `repeat()` migration recommendation.
37
+ - §347 (FB-50 #3) — NEW `command.test.js` (first dedicated test file for `<command-ui>`) with 3 cases: emits `category=""` for ungrouped options, emits `category="<optgroup label>"` for items inside an optgroup, backward-compat `{ value, label }` destructuring still works. 3/3 passing.
38
+
39
+ ### Docs
40
+ - USAGE.md — new `§345 (v0.5.19) — .map() vs repeat() for reactive lists (FB-47)` section under the template parser invariants. Documents the keyed-reuse contract, when `repeat()` matters, and the key-selection guidance (use a stable consumer-supplied identifier, NOT array index).
41
+ - USAGE.md — new `§349 (v0.5.19) — Event handling inside reactive lists (FB-49)` section. Explains that `closest()` works through `display:contents` wrapper-spans (DOM walk, not box walk) for typical bubble-phase handlers; reserves `composedPath()` for cross-subtree / shadow-DOM / whitespace-retarget cases with explicit examples. Closes the documentation gap that caused three consumer `composedPath()` workarounds in Tokens Studio to ship without rationale.
42
+ - §346 — NEW `components/command/USAGE.md` (FB-50 #1) — first dedicated USAGE doc for `<command-ui>`. Documents the native `<option data-shortcut data-icon>` content API (the actual shape; there is no `command-item-ui` custom element), the `<optgroup label>` grouping pattern, the new `category` field in `select` detail (per FB-50 #3 above), the Cmd+K wiring pattern, `setItems()` imperative API, recent-items behavior, and the `empty` + `footer` slots. Closes Tokens Studio's discoverability gap that drove ~350 LOC of hand-rolled `<dts-command-palette>` fallback.
43
+
14
44
  ## [0.5.18] - 2026-05-16
15
45
 
16
46
  ### Fixed
package/USAGE.md CHANGED
@@ -1086,6 +1086,102 @@ Why AdiaUI doesn't implement `?attr=`: custom elements declare their reflective
1086
1086
  - HTML comments containing quoted attributes (`<!-- attr="value" -->`) — same fix (v0.5.3 §155).
1087
1087
  - Backticks inside HTML comments — see §221i above (JS-spec footgun; not a parser bug).
1088
1088
 
1089
+ ### §345 (v0.5.19) — `.map()` vs `repeat()` for reactive lists (FB-47)
1090
+
1091
+ Use **`repeat(items, keyFn, tplFn)`** for any list whose items are signal-driven and should preserve identity across re-renders. Plain `.map()` returns an `Array` of template results, which the template engine materializes via `container.replaceChildren()` on every parent update — correct, but per-item DOM is re-created from scratch.
1092
+
1093
+ ```js
1094
+ import { html, repeat } from '@adia-ai/web-components/core/element';
1095
+
1096
+ // ❌ .map() — every re-render destroys and re-creates each wrapper-span
1097
+ html`
1098
+ <div class="row">
1099
+ ${stops.map(stop => renderSwatch(stop))}
1100
+ </div>
1101
+ `;
1102
+
1103
+ // ✅ repeat() — wrapper-spans + child custom elements preserved across re-renders
1104
+ html`
1105
+ <div class="row">
1106
+ ${repeat(stops, stop => stop.key, stop => renderSwatch(stop))}
1107
+ </div>
1108
+ `;
1109
+ ```
1110
+
1111
+ **When `repeat()` matters:**
1112
+
1113
+ - Lists of custom elements with internal state (`<swatch-ui>` with chrome slots, `<chat-thread>` with scroll position, `<select-ui>` with focus).
1114
+ - Lists where re-creation causes flicker (animations restart, transitions interrupt, IntersectionObservers re-fire).
1115
+ - Long lists (≥10 items) where per-item DOM allocation costs add up under signal-driven update churn.
1116
+
1117
+ **Mechanism:** `repeat()` keys each item by `keyFn(item)` and stores wrapper-spans in a key-indexed map on the container. On re-render with the same key, the wrapper is reused; `stamp()` falls through to its same-strings cache hit and only `update()`s the parts in-place. With `.map()`, there is no key channel — the runtime cannot tell which result corresponds to which prior item, so it conservatively rebuilds every wrapper.
1118
+
1119
+ **Key selection:** pick a stable consumer-supplied identifier (database id, slot.key, etc.), NOT array index. Array indices are unstable under insert/remove and defeat the keyed-reuse contract.
1120
+
1121
+ **Sizing heuristic (FB-53 #3):** `.map()` is fine for static lists of ≤10 items that don't re-render or only re-render at navigation boundaries. `repeat()` adds per-render bookkeeping (Map lookups, key comparison, DOM reuse) that pays for itself in two cases: (a) lists of ≥20 items that re-render on every signal change (e.g. a palette grid with 66 swatches updating on every slider drag), or (b) any-size list whose items host custom elements with internal state (chrome slots, focus position, scroll offset). Don't migrate every `.map()` reflexively — migrate when one of those two thresholds applies.
1122
+
1123
+ ### §349 (v0.5.19) — Event handling inside reactive lists (FB-49)
1124
+
1125
+ Custom-element children rendered inside `repeat()` (or as an `${array}` interpolation) are each wrapped in a `<span style="display:contents">` by the template engine. These wrapper spans generate no boxes (`display:contents`) and cannot be click targets, but they ARE in the DOM tree.
1126
+
1127
+ For most consumer event handlers, this is transparent. `Element.closest()` walks the **DOM tree** (not the box tree), so `display:contents` ancestors are walked through normally:
1128
+
1129
+ ```js
1130
+ // ✅ Bubble-phase listener on or inside the list — closest() works fine
1131
+ tree.addEventListener('tree-select', (e) => {
1132
+ const item = e.detail.item; // tree-ui already does the right walk for you
1133
+ });
1134
+
1135
+ // ✅ Bubble-phase listener on a parent — closest() walks past display:contents wrappers
1136
+ host.addEventListener('click', (e) => {
1137
+ const item = e.target.closest('tree-item-ui');
1138
+ if (item && host.contains(item)) { /* … */ }
1139
+ });
1140
+ ```
1141
+
1142
+ Reach for `e.composedPath()` only when one of these specific cases applies:
1143
+
1144
+ 1. **Cross-subtree listeners.** If the listener's host is in a different subtree than the items (capture-phase on an ancestor far above), `closest()` walks UP from `e.target` but doesn't tell you which descendant of the listener's host was clicked. `composedPath()` returns the full leaf-to-document chain — filter that for the descendant tag you want.
1145
+ 2. **Crossing shadow-DOM boundaries.** If `e.target` is retargeted to a shadow host (per the shadow DOM spec), `closest()` walks the light-DOM ancestor chain from that host. `composedPath()` exposes the actual leaf inside the shadow root.
1146
+ 3. **Click on whitespace between list items.** Text nodes sometimes retarget to their parent element. If the parent is a `display:contents` wrapper-span, `closest('your-tag')` from that wrapper returns null (the wrapper's siblings include your tag, but not its descendants). `composedPath()` gives sibling context to walk.
1147
+
1148
+ ```js
1149
+ // ✅ For capture-phase / cross-subtree / shadow-DOM cases
1150
+ host.addEventListener('click', (e) => {
1151
+ const item = e.composedPath().find(
1152
+ (el) => el?.tagName?.toLowerCase() === 'tree-item-ui' && host.contains(el)
1153
+ );
1154
+ }, { capture: true });
1155
+ ```
1156
+
1157
+ **Don't reach for `composedPath()` reflexively** — it rebuilds the full path on every call and is slower than `closest()`. Default to `closest()` for direct bubble-phase handlers; use `composedPath()` only when one of the three cases above applies.
1158
+
1159
+ **CSS child-combinator caveat (FB-53 #2):** `display:contents` wrapper-spans ARE in the DOM tree even though they generate no boxes. The CSS child combinator (`>`) walks the DOM tree (not the box tree), so consumer rules of the shape `.parent > .my-item` **will not match** items inside a `repeat()` because the wrapper-span sits between `.parent` and the items:
1160
+
1161
+ ```html
1162
+ <!-- Actual DOM produced by repeat(): -->
1163
+ <div class="parent">
1164
+ <span style="display:contents"> <!-- ← repeat() per-item wrapper -->
1165
+ <my-item class="my-item">…</my-item>
1166
+ </span>
1167
+ </div>
1168
+ ```
1169
+
1170
+ Use the descendant combinator (a single space) instead, which traverses any depth:
1171
+
1172
+ ```css
1173
+ /* ❌ Won't match — wrapper-span sits between .parent and .my-item */
1174
+ .parent > .my-item { … }
1175
+
1176
+ /* ✅ Descendant combinator pierces through the wrapper-spans */
1177
+ .parent .my-item { … }
1178
+
1179
+ /* ✅ Or use :scope/has() if you need precision: */
1180
+ .parent > :is(.my-item, span > .my-item) { … }
1181
+ ```
1182
+
1183
+ Same caveat applies to `:nth-child()` / `:nth-of-type()` / `:first-child` / `:last-child` against the parent — those count actual children (the wrapper-spans), not the items inside. If you need nth-item styling, key it via a data attribute on the item itself rather than relying on positional pseudo-classes against the wrapper layer.
1184
+
1089
1185
  ### §221j — Typography token cheatsheet
1090
1186
 
1091
1187
  Quick-reference for component-CSS authoring. Cross-reference [`styles/typography.css`](./styles/typography.css):
@@ -0,0 +1,160 @@
1
+ # `<command-ui>` — Searchable command palette
2
+
3
+ `<command-ui>` is a searchable command palette with keyboard navigation, recent-items memory, and grouped sections. It accepts **native `<option>` and `<optgroup>` children** as its content API — no custom element wrappers around items.
4
+
5
+ ```html
6
+ <command-ui open placeholder="Type a command…">
7
+ <optgroup label="Navigation">
8
+ <option value="home" data-icon="house" data-shortcut="⌘H">Go Home</option>
9
+ <option value="settings" data-icon="gear" data-shortcut="⌘,">Settings</option>
10
+ </optgroup>
11
+ <optgroup label="Edit">
12
+ <option value="undo" data-icon="arrow-counter-clockwise" data-shortcut="⌘Z">Undo</option>
13
+ <option value="redo" data-icon="arrow-clockwise" data-shortcut="⌘⇧Z">Redo</option>
14
+ </optgroup>
15
+ <option value="logout" data-icon="sign-out" data-shortcut="⌘⇧Q">Log out</option>
16
+ </command-ui>
17
+ ```
18
+
19
+ ## Why native `<option>`
20
+
21
+ The native option semantics carry through to screen readers automatically, mirror the form-control patterns consumers already know, and inherit standard `disabled` handling. The alternative — a `<command-item-ui>` custom element — would duplicate the surface without adding capability.
22
+
23
+ If you're coming from a primitive that uses `*-item-ui` children (e.g. `<tree-item-ui>` inside `<tree-ui>`, `<menu-item-ui>` inside `<menu-ui>`), the `<option>`-based API here is the deliberate exception. Other primitives ship custom-element children because their item state (open / selected / expanded) is rich; commands are stateless declarative entries.
24
+
25
+ ## Attributes
26
+
27
+ | On `<command-ui>` | Type | Default | Description |
28
+ |---|---|---|---|
29
+ | `open` | boolean | `false` | Whether the palette is visible (reflected — set via property or attribute). |
30
+ | `placeholder` | string | `Type a command…` | Search input placeholder. |
31
+
32
+ | On each `<option>` | Description |
33
+ |---|---|
34
+ | `value` | Identifier emitted in the `select` event detail. |
35
+ | `data-icon` | Phosphor icon name (rendered as `<icon-ui>` to the left of the label). |
36
+ | `data-shortcut` | Keyboard shortcut hint string (rendered to the right, e.g. `⌘K`, `⇧E`). Display-only — wiring the actual shortcut is the consumer's responsibility (see "Wiring a global Cmd+K trigger" below). |
37
+ | `data-keywords` | Extra space-separated keywords folded into the search index alongside the label. |
38
+ | `disabled` | Boolean — render as inactive, skipped during keyboard navigation. |
39
+
40
+ | On each `<optgroup>` | Description |
41
+ |---|---|
42
+ | `label` | Section heading. Propagates to `event.detail.category` on selection (§-TBD / FB-50 #3, v0.5.19+). |
43
+
44
+ ## Events
45
+
46
+ ```ts
47
+ type CommandSelectDetail = {
48
+ value: string; // option's [value]
49
+ label: string; // option's text content
50
+ category: string; // parent <optgroup label="…"> label, or '' if the option has no group
51
+ };
52
+ ```
53
+
54
+ ```js
55
+ palette.addEventListener('select', (e) => {
56
+ const { value, label, category } = e.detail;
57
+ switch (category) {
58
+ case 'Navigation': router.navigate(value); break;
59
+ case 'Edit': editor.invoke(value); break;
60
+ default: runCommand(value);
61
+ }
62
+ });
63
+
64
+ palette.addEventListener('dismiss', () => {
65
+ // User pressed Escape — close it
66
+ palette.open = false;
67
+ });
68
+ ```
69
+
70
+ The palette doesn't auto-close on selection — the consumer decides whether to stay open (multi-action flow) or dismiss (typical single-action flow):
71
+
72
+ ```js
73
+ palette.addEventListener('select', () => { palette.open = false; });
74
+ ```
75
+
76
+ ## Slots
77
+
78
+ | Slot | What goes inside |
79
+ |---|---|
80
+ | `empty` | Shown when the filter has no matches. Use for a "No commands match" affordance or a "Create new" call-to-action. |
81
+ | `footer` | Persistent footer band — usually a `<kbd>` hint bar like `↑↓ to navigate · ↵ to select · esc to close`. |
82
+
83
+ ```html
84
+ <command-ui open placeholder="Type a command…">
85
+ <option value="…">…</option>
86
+
87
+ <div slot="empty">
88
+ <text-ui variant="caption" muted>No commands match — try a different search.</text-ui>
89
+ </div>
90
+
91
+ <div slot="footer">
92
+ <kbd>↑</kbd> <kbd>↓</kbd> navigate · <kbd>↵</kbd> select · <kbd>esc</kbd> close
93
+ </div>
94
+ </command-ui>
95
+ ```
96
+
97
+ ## Wiring a global Cmd+K trigger
98
+
99
+ Common pattern — global keyboard listener flips the `[open]` property:
100
+
101
+ ```js
102
+ const palette = document.querySelector('command-ui');
103
+
104
+ window.addEventListener('keydown', (e) => {
105
+ // Cmd+K on Mac, Ctrl+K elsewhere
106
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
107
+ e.preventDefault();
108
+ palette.open = true;
109
+ }
110
+ });
111
+
112
+ palette.addEventListener('dismiss', () => { palette.open = false; });
113
+ palette.addEventListener('select', () => { palette.open = false; });
114
+ ```
115
+
116
+ Once `open` flips to true, the input auto-focuses and Escape / outside-click fire `dismiss`. The palette handles arrow-key navigation, Enter to select, and recent-items recall on its own.
117
+
118
+ ## Dynamic command lists
119
+
120
+ If your commands change reactively (signal-bound, fetched, etc.), use `palette.setItems([…])` to swap the entire item set in one call. Each item is `{ value, label, icon?, shortcut?, keywords?, disabled?, category? }`:
121
+
122
+ ```js
123
+ palette.setItems([
124
+ // ungrouped items
125
+ { value: 'new', label: 'New file', icon: 'plus', shortcut: '⌘N' },
126
+
127
+ // a group — pass items inline, the palette renders the group header
128
+ { label: 'Recent', items: [
129
+ { value: 'open:foo.ts', label: 'foo.ts' },
130
+ { value: 'open:bar.ts', label: 'bar.ts' },
131
+ ]},
132
+ ]);
133
+ ```
134
+
135
+ Or stay declarative and update the `<option>` / `<optgroup>` children directly — the palette re-parses on every `open` flip and when `setItems()` is called.
136
+
137
+ ## Recent items
138
+
139
+ The palette automatically tracks the last 10 selected values and shows them as a "Recent" section when the search input is empty. Items appear in MRU order; selections promote items to the top. Recent items are filtered against the current item set, so a stale recent (item removed via `setItems()`) is silently dropped.
140
+
141
+ This is a built-in behavior — no consumer wiring needed. To disable, override with an explicit empty group at the top, or skip and use the section-based grouping pattern instead.
142
+
143
+ ## Required icons
144
+
145
+ `<command-ui>` auto-stamps the `magnifying-glass` icon in the search field. Per `command.yaml` `requiredIcons:`, this is aggregated into the global icon-load manifest by `installIconLoadersForRegistered()`. Any additional `data-icon="<name>"` on options must be in the Phosphor icon set (or registered via `IconRegistry.register(name, svgString)`).
146
+
147
+ ## Accessibility
148
+
149
+ - The search input has `role="combobox"`, `aria-expanded`, `aria-controls`, and `aria-activedescendant` wiring per WAI-ARIA combobox pattern.
150
+ - Each rendered option has `role="option"` and reflects `aria-disabled` from the source `<option>`.
151
+ - The active item (keyboard-highlighted) gets `data-active` for CSS targeting + `aria-activedescendant` reference from the input.
152
+ - Escape fires `dismiss` (not `close`) — the consumer decides whether to actually close.
153
+
154
+ ## Related primitives
155
+
156
+ - `<menu-ui>` — for static actionable lists tied to a trigger (e.g. context menu). Not searchable.
157
+ - `<select-ui>` — single-value form input with options. Not searchable.
158
+ - `<tree-ui>` — hierarchical navigation with expand/collapse. Carries selection state.
159
+
160
+ `<command-ui>` is the right choice when: (a) the user is typing to filter from a list, (b) the action set is broad (10+ items), (c) keyboard-first navigation is the primary affordance, OR (d) you need a Cmd+K-style global launcher.
@@ -144,19 +144,24 @@ export class UICommand extends UIElement {
144
144
  const items = [];
145
145
  for (const child of this.children) {
146
146
  if (child.tagName === 'OPTGROUP') {
147
- const group = { label: child.label, items: [] };
147
+ const groupLabel = child.label;
148
+ const group = { label: groupLabel, items: [] };
148
149
  for (const opt of child.querySelectorAll('option')) {
149
- group.items.push(this.#optionToItem(opt));
150
+ group.items.push(this.#optionToItem(opt, groupLabel));
150
151
  }
151
152
  items.push(group);
152
153
  } else if (child.tagName === 'OPTION') {
153
- items.push(this.#optionToItem(child));
154
+ items.push(this.#optionToItem(child, ''));
154
155
  }
155
156
  }
156
157
  this.#items = items;
157
158
  }
158
159
 
159
- #optionToItem(opt) {
160
+ // §-TBD (v0.5.19, FB-50 #3): forward the parent <optgroup label> as
161
+ // `category` on each item so the `select` event detail can surface
162
+ // which group the chosen item came from. Items without a group
163
+ // (direct <option> children of <command-ui>) get category = ''.
164
+ #optionToItem(opt, category = '') {
160
165
  return {
161
166
  value: opt.value,
162
167
  label: opt.textContent.trim(),
@@ -164,6 +169,7 @@ export class UICommand extends UIElement {
164
169
  shortcut: opt.dataset.shortcut || '',
165
170
  keywords: opt.dataset.keywords || '',
166
171
  disabled: opt.disabled,
172
+ category,
167
173
  };
168
174
  }
169
175
 
@@ -302,11 +308,18 @@ export class UICommand extends UIElement {
302
308
  this.#activate(parseInt(options[next].dataset.idx));
303
309
  }
304
310
 
311
+ // §-TBD (v0.5.19, FB-50 #3): emit `category` (from parent <optgroup
312
+ // label>) in the select detail so consumers can route on group.
313
+ // Items without a group surface category = ''.
305
314
  #select(item) {
306
315
  this.#pushRecent(item.value);
307
316
  this.dispatchEvent(new CustomEvent('select', {
308
317
  bubbles: true,
309
- detail: { value: item.value, label: item.label },
318
+ detail: {
319
+ value: item.value,
320
+ label: item.label,
321
+ category: item.category || '',
322
+ },
310
323
  }));
311
324
  }
312
325
 
@@ -315,10 +328,14 @@ export class UICommand extends UIElement {
315
328
  if (!active) return;
316
329
  const value = active.dataset.value;
317
330
  const label = active.querySelector('[data-text]')?.textContent || '';
331
+ // Look up the item to surface category — the rendered DOM doesn't
332
+ // carry the group label, but #items / #itemByEl do.
333
+ const item = this.#itemByEl.get(active) || this.#findItem(value);
334
+ const category = item?.category || '';
318
335
  this.#pushRecent(value);
319
336
  this.dispatchEvent(new CustomEvent('select', {
320
337
  bubbles: true,
321
- detail: { value, label },
338
+ detail: { value, label, category },
322
339
  }));
323
340
  }
324
341
 
@@ -40,8 +40,12 @@
40
40
  "description": "Fired when Escape is pressed"
41
41
  },
42
42
  "select": {
43
- "description": "Fired when an item is selected. Detail contains { value, label, item }.",
43
+ "description": "Fired when an item is selected. detail: { value, label, category }. `category` is the parent `<optgroup label=\"…\">` label, or empty string when the item is a direct child of `<command-ui>` with no group.\n",
44
44
  "detail": {
45
+ "category": {
46
+ "description": "Parent optgroup label (empty string when item has no group).",
47
+ "type": "string"
48
+ },
45
49
  "label": {
46
50
  "description": "Item label text.",
47
51
  "type": "string"
@@ -14,6 +14,8 @@ import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export type CommandDismissEvent = CustomEvent<unknown>;
16
16
  export interface CommandSelectEventDetail {
17
+ /** Parent optgroup label (empty string when item has no group). */
18
+ category: string;
17
19
  /** Item label text. */
18
20
  label: string;
19
21
  /** Item value attribute. */
@@ -0,0 +1,99 @@
1
+ /**
2
+ * <command-ui> behavioral tests.
3
+ *
4
+ * FB-50 #3 (v0.5.19): the `select` event detail now carries `category`
5
+ * derived from the parent <optgroup label> (or '' when the item has
6
+ * no group).
7
+ */
8
+
9
+ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
10
+
11
+ beforeAll(async () => {
12
+ await import('./command.js');
13
+ });
14
+
15
+ describe('<command-ui> select event detail includes category (FB-50 #3)', () => {
16
+ let host;
17
+ let palette;
18
+
19
+ beforeEach(async () => {
20
+ host = document.createElement('div');
21
+ document.body.appendChild(host);
22
+
23
+ palette = document.createElement('command-ui');
24
+ palette.setAttribute('open', '');
25
+
26
+ // Mixed: one ungrouped option, one optgroup with two options.
27
+ const ungrouped = document.createElement('option');
28
+ ungrouped.setAttribute('value', 'logout');
29
+ ungrouped.textContent = 'Log out';
30
+
31
+ const optgroup = document.createElement('optgroup');
32
+ optgroup.setAttribute('label', 'Navigation');
33
+ const opt1 = document.createElement('option');
34
+ opt1.setAttribute('value', 'home');
35
+ opt1.textContent = 'Go Home';
36
+ const opt2 = document.createElement('option');
37
+ opt2.setAttribute('value', 'settings');
38
+ opt2.textContent = 'Settings';
39
+ optgroup.appendChild(opt1);
40
+ optgroup.appendChild(opt2);
41
+
42
+ palette.appendChild(ungrouped);
43
+ palette.appendChild(optgroup);
44
+ host.appendChild(palette);
45
+
46
+ await new Promise((r) => setTimeout(r, 50));
47
+ });
48
+
49
+ afterEach(() => {
50
+ host.remove();
51
+ });
52
+
53
+ it('emits category="" for an ungrouped option', () => {
54
+ let received = null;
55
+ palette.addEventListener('select', (e) => { received = e.detail; });
56
+
57
+ // Use the imperative setItems() path to bypass DOM-walk ordering;
58
+ // the optgroup path is exercised in the next test.
59
+ palette.setItems([{ value: 'logout', label: 'Log out' }]);
60
+
61
+ // Simulate user clicking the logout row.
62
+ const row = palette.querySelector('[role="option"][data-value="logout"]');
63
+ expect(row).not.toBeNull();
64
+ row.click();
65
+
66
+ expect(received).not.toBeNull();
67
+ expect(received.value).toBe('logout');
68
+ expect(received.label).toBe('Log out');
69
+ expect(received.category).toBe('');
70
+ });
71
+
72
+ it('emits category="<optgroup label>" for an item inside an optgroup', () => {
73
+ let received = null;
74
+ palette.addEventListener('select', (e) => { received = e.detail; });
75
+
76
+ const homeRow = palette.querySelector('[role="option"][data-value="home"]');
77
+ expect(homeRow).not.toBeNull();
78
+ homeRow.click();
79
+
80
+ expect(received).not.toBeNull();
81
+ expect(received.value).toBe('home');
82
+ expect(received.label).toBe('Go Home');
83
+ expect(received.category).toBe('Navigation');
84
+ });
85
+
86
+ it('detail shape stays backward-compatible (existing destructuring works)', () => {
87
+ let received = null;
88
+ palette.addEventListener('select', (e) => {
89
+ const { value, label } = e.detail; // pre-FB-50 destructuring shape
90
+ received = { value, label };
91
+ });
92
+
93
+ const settingsRow = palette.querySelector('[role="option"][data-value="settings"]');
94
+ settingsRow.click();
95
+
96
+ expect(received.value).toBe('settings');
97
+ expect(received.label).toBe('Settings');
98
+ });
99
+ });
@@ -19,7 +19,12 @@ events:
19
19
  dismiss:
20
20
  description: Fired when Escape is pressed
21
21
  select:
22
- description: Fired when an item is selected. Detail contains { value, label, item }.
22
+ description: >
23
+ Fired when an item is selected.
24
+ detail: { value, label, category }.
25
+ `category` is the parent `<optgroup label="…">` label, or empty
26
+ string when the item is a direct child of `<command-ui>` with no
27
+ group.
23
28
  detail:
24
29
  value:
25
30
  type: string
@@ -27,6 +32,9 @@ events:
27
32
  label:
28
33
  type: string
29
34
  description: Item label text.
35
+ category:
36
+ type: string
37
+ description: Parent optgroup label (empty string when item has no group).
30
38
  slots:
31
39
  empty:
32
40
  description: Empty state shown when no items match
@@ -19,11 +19,12 @@
19
19
  "default": []
20
20
  },
21
21
  "align": {
22
- "description": "Alignment for inline layout between = term left, value right.",
22
+ "description": "Alignment for inline layout. `start` (default): term and value pack to the term column edge. `between`: term left, value right-aligned with `text-align: end`. `stretch`: value fills the remaining track width (e.g. for a `<slider-ui>` / `<select-ui>` / `<color-picker-ui>` as `<dd>` that should span the full row). Sets `justify-self: stretch` and `width: 100%` on `dd` so block-level form controls inside reach the column edge rather than shrink-wrapping to content.\n",
23
23
  "type": "string",
24
24
  "enum": [
25
25
  "start",
26
- "between"
26
+ "between",
27
+ "stretch"
27
28
  ],
28
29
  "default": "start"
29
30
  },
@@ -65,6 +65,22 @@
65
65
  text-align: end;
66
66
  }
67
67
 
68
+ /* §-TBD (v0.5.19, FB-51 #3): align="stretch" lets dd fill the
69
+ remaining inline-grid track width. Useful when dd hosts a block-
70
+ level form control (slider-ui / select-ui / color-picker-ui) that
71
+ would otherwise shrink-wrap to content. width:100% on direct
72
+ children reaches block controls that have their own inline-flex
73
+ :scope rule (e.g. <slider-ui>) so they stretch to track width. */
74
+ :scope[layout="inline"][align="stretch"] > [data-dl-desc],
75
+ :scope[layout="inline"][align="stretch"] > dd {
76
+ justify-self: stretch;
77
+ width: 100%;
78
+ }
79
+ :scope[layout="inline"][align="stretch"] > [data-dl-desc] > *,
80
+ :scope[layout="inline"][align="stretch"] > dd > * {
81
+ width: 100%;
82
+ }
83
+
68
84
  /* Size handled by the universal [size] attribute scaling --a-ui-size;
69
85
  components pick up font-size from token chain automatically. */
70
86
  }
@@ -15,8 +15,9 @@ import { UIElement } from '../../core/element.js';
15
15
  export class UIDescriptionList extends UIElement {
16
16
  /** Optional JSON array of {term, description} — alternative to declarative <dt>/<dd> children */
17
17
  items: unknown[];
18
- /** Alignment for inline layout between = term left, value right. */
19
- align: 'start' | 'between';
18
+ /** Alignment for inline layout. `start` (default): term and value pack to the term column edge. `between`: term left, value right-aligned with `text-align: end`. `stretch`: value fills the remaining track width (e.g. for a `<slider-ui>` / `<select-ui>` / `<color-picker-ui>` as `<dd>` that should span the full row). Sets `justify-self: stretch` and `width: 100%` on `dd` so block-level form controls inside reach the column edge rather than shrink-wrapping to content.
19
+ */
20
+ align: 'start' | 'between' | 'stretch';
20
21
  /** stacked: term above description. inline: term and description on one row. */
21
22
  layout: 'stacked' | 'inline';
22
23
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * <description-list-ui> behavioral tests.
3
+ *
4
+ * FB-51 #3 (v0.5.19): align="stretch" lets <dd> fill the remaining
5
+ * inline-grid track width so block-level form controls inside (e.g.
6
+ * <slider-ui>, <select-ui>, <color-picker-ui>) reach the column edge
7
+ * rather than shrink-wrapping to content.
8
+ */
9
+
10
+ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
11
+ import { readFileSync } from 'node:fs';
12
+ import { resolve, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+
17
+ beforeAll(async () => {
18
+ await import('./description-list.js');
19
+ });
20
+
21
+ describe('<description-list-ui> align="stretch" (FB-51 #3)', () => {
22
+ let host;
23
+ let dl;
24
+
25
+ beforeEach(async () => {
26
+ host = document.createElement('div');
27
+ host.style.width = '600px';
28
+ document.body.appendChild(host);
29
+
30
+ dl = document.createElement('description-list-ui');
31
+ dl.setAttribute('layout', 'inline');
32
+ dl.setAttribute('align', 'stretch');
33
+
34
+ const dt = document.createElement('dt');
35
+ dt.textContent = 'Source hue';
36
+ const dd = document.createElement('dd');
37
+ dd.id = 'fb51-stretch-dd';
38
+ const probe = document.createElement('div');
39
+ probe.id = 'fb51-stretch-probe';
40
+ probe.textContent = 'inner';
41
+ dd.appendChild(probe);
42
+ dl.appendChild(dt);
43
+ dl.appendChild(dd);
44
+
45
+ host.appendChild(dl);
46
+ await new Promise((r) => setTimeout(r, 50));
47
+ });
48
+
49
+ afterEach(() => {
50
+ host.remove();
51
+ });
52
+
53
+ it('CSS source declares justify-self: stretch + width: 100% for align="stretch" inline layout', () => {
54
+ const cssText = readFileSync(resolve(HERE, 'description-list.css'), 'utf8');
55
+ expect(cssText).toMatch(/\[layout="inline"\]\[align="stretch"\][^{]*\{[^}]*justify-self:\s*stretch/);
56
+ expect(cssText).toMatch(/\[layout="inline"\]\[align="stretch"\][^{]*\{[^}]*width:\s*100%/);
57
+ });
58
+
59
+ it('host carries the align="stretch" attribute (CSS selector match precondition)', () => {
60
+ expect(dl.getAttribute('align')).toBe('stretch');
61
+ expect(dl.getAttribute('layout')).toBe('inline');
62
+ });
63
+
64
+ it('yaml enum includes "stretch" (schema contract)', () => {
65
+ const yamlText = readFileSync(resolve(HERE, 'description-list.yaml'), 'utf8');
66
+ // Match the align enum block specifically (between `align:` and the next prop).
67
+ const alignBlock = yamlText.match(/align:[\s\S]*?(?=\n \w+:)/)?.[0] || '';
68
+ expect(alignBlock).toMatch(/enum:[\s\S]*-\s+start/);
69
+ expect(alignBlock).toMatch(/enum:[\s\S]*-\s+between/);
70
+ expect(alignBlock).toMatch(/enum:[\s\S]*-\s+stretch/);
71
+ });
72
+ });
@@ -10,12 +10,21 @@ description: Semantic key-value list (dl/dt/dd). Preserves native HTML semantics
10
10
  and SSR. Layout supports stacked (default) or inline.
11
11
  props:
12
12
  align:
13
- description: Alignment for inline layout — between = term left, value right.
13
+ description: >
14
+ Alignment for inline layout.
15
+ `start` (default): term and value pack to the term column edge.
16
+ `between`: term left, value right-aligned with `text-align: end`.
17
+ `stretch`: value fills the remaining track width (e.g. for a
18
+ `<slider-ui>` / `<select-ui>` / `<color-picker-ui>` as `<dd>`
19
+ that should span the full row). Sets `justify-self: stretch` and
20
+ `width: 100%` on `dd` so block-level form controls inside reach
21
+ the column edge rather than shrink-wrapping to content.
14
22
  type: string
15
23
  default: start
16
24
  enum:
17
25
  - start
18
26
  - between
27
+ - stretch
19
28
  items:
20
29
  description: Optional JSON array of {term, description} — alternative to declarative <dt>/<dd> children
21
30
  type: array
@@ -82,17 +82,19 @@
82
82
 
83
83
  /* ── Pane header ── */
84
84
  > header {
85
+ box-sizing: border-box;
85
86
  display: flex;
86
87
  align-items: center;
88
+ justify-content: flex-start;
87
89
  gap: var(--pane-gap-sm);
88
- min-height: var(--pane-bar-height);
89
- padding: var(--pane-header-py) var(--pane-header-px);
90
+ height: var(--pane-bar-height);
91
+ padding: 0 var(--pane-header-px);
90
92
  font-size: var(--pane-header-size);
91
93
  font-weight: var(--pane-header-weight);
92
94
  color: var(--pane-header-fg);
93
95
  cursor: pointer;
94
96
  user-select: none;
95
- border-bottom: 1px solid var(--pane-border);
97
+ box-shadow: inset 0 -1px 0 var(--pane-border);
96
98
  transition: background var(--pane-duration) var(--pane-easing);
97
99
  }
98
100
 
@@ -178,7 +180,7 @@
178
180
  }
179
181
 
180
182
  :scope[collapsed] > header {
181
- border-bottom: none;
183
+ box-shadow: none;
182
184
  }
183
185
 
184
186
  /* ── Resize handle ──
@@ -192,10 +192,47 @@ export class UISwatch extends UIElement {
192
192
  #onHostKey = null;
193
193
  #onCopyClick = null;
194
194
  #copyResetTimer = null;
195
+ #chromeObserver = null;
195
196
 
196
197
  connected() {
197
198
  this.#stamp();
198
199
  this.#wireInteraction();
200
+ // §-TBD (v0.5.19, FB-44): observe late-arriving slot="chrome" children
201
+ // and re-run #absorbChromeSlot(). The parent template's update() walks
202
+ // attribute/property parts BEFORE child node parts (per core/template.js
203
+ // scan(): TreeWalker visits element.attributes before descending), so
204
+ // property assignments fire render() → #syncCore() → #absorbChromeSlot()
205
+ // while chrome interpolations are still empty placeholder text nodes.
206
+ // The once-only absorb pass therefore sees an empty set and the chrome
207
+ // — arriving moments later — sits at its arrival position rather than
208
+ // the canonical post-tile region. MutationObserver closes this gap for
209
+ // every arrival path: single-template ordering skew, separate render
210
+ // cycle, or imperative host.appendChild().
211
+ this.#chromeObserver = new MutationObserver((mutations) => {
212
+ let hasChrome = false;
213
+ for (const m of mutations) {
214
+ for (const n of m.addedNodes) {
215
+ if (n.nodeType !== 1) continue;
216
+ // Direct chrome child OR a display:contents wrapper-span carrying chrome.
217
+ if (n.getAttribute?.('slot') === 'chrome') { hasChrome = true; break; }
218
+ if (
219
+ n.tagName === 'SPAN' &&
220
+ n.style?.display === 'contents' &&
221
+ n.querySelector?.('[slot="chrome"]')
222
+ ) { hasChrome = true; break; }
223
+ }
224
+ if (hasChrome) break;
225
+ }
226
+ if (!hasChrome) return;
227
+ // Re-entrancy guard: #absorbChromeSlot() calls insertBefore() which itself
228
+ // generates childList mutations on the host. Without this guard, every
229
+ // absorb pass would re-trigger the observer indefinitely. takeRecords()
230
+ // drains the queue without dispatch so the next observation cycle starts
231
+ // clean.
232
+ this.#absorbChromeSlot();
233
+ this.#chromeObserver.takeRecords();
234
+ });
235
+ this.#chromeObserver.observe(this, { childList: true });
199
236
  }
200
237
 
201
238
  render() {
@@ -638,6 +675,10 @@ export class UISwatch extends UIElement {
638
675
  clearTimeout(this.#copyResetTimer);
639
676
  this.#copyResetTimer = null;
640
677
  }
678
+ if (this.#chromeObserver) {
679
+ this.#chromeObserver.disconnect();
680
+ this.#chromeObserver = null;
681
+ }
641
682
  if (this.#onHostClick) this.removeEventListener('click', this.#onHostClick);
642
683
  if (this.#onHostKey) this.removeEventListener('keydown', this.#onHostKey);
643
684
  if (this.#copyEl && this.#onCopyClick) this.#copyEl.removeEventListener('click', this.#onCopyClick);
@@ -448,3 +448,116 @@ describe('<swatch-ui> chrome survives parent template re-render (FB-43 / §341)'
448
448
  assertChromeCanonical(swatch, chrome);
449
449
  });
450
450
  });
451
+
452
+ describe('<swatch-ui> chrome absorbed on late arrival (FB-44 / §-TBD)', () => {
453
+ // FB-44 (P1): #absorbChromeSlot() runs from #syncCore() which only fires
454
+ // on attribute/property changes. scan() walks element.attributes BEFORE
455
+ // descending to child nodes, so the parent template's update() loop
456
+ // assigns properties (→ render() → #syncCore() → #absorbChromeSlot())
457
+ // BEFORE interpolating child node parts. The once-only absorb pass sees
458
+ // an empty children set; chrome lands moments later at the arrival
459
+ // position, not the canonical post-tile region.
460
+ //
461
+ // §-TBD adds a MutationObserver on host childList that re-runs
462
+ // #absorbChromeSlot() when chrome (direct or wrapper-spanned) arrives.
463
+ let host;
464
+
465
+ beforeEach(() => {
466
+ host = document.createElement('div');
467
+ document.body.appendChild(host);
468
+ });
469
+
470
+ // Inline a minimal canonical-position assertion (same shape as the
471
+ // FB-43 helper but local to this describe block).
472
+ function assertChromePositionedBeforeBadge(swatch, chrome) {
473
+ expect(chrome).not.toBeNull();
474
+ const badge = swatch.querySelector('[data-badge]');
475
+ expect(badge).not.toBeNull();
476
+ // The chrome (or its wrapper-span ancestor) should be a child of swatch
477
+ // that appears before the badge.
478
+ let chromeChild = chrome;
479
+ while (chromeChild.parentElement !== swatch && chromeChild.parentElement) {
480
+ chromeChild = chromeChild.parentElement;
481
+ }
482
+ expect(chromeChild.parentElement).toBe(swatch);
483
+ // Walk siblings from chromeChild forward — badge must be reachable.
484
+ let cursor = chromeChild;
485
+ let foundBadge = false;
486
+ while (cursor) {
487
+ if (cursor === badge) { foundBadge = true; break; }
488
+ cursor = cursor.nextElementSibling;
489
+ }
490
+ expect(foundBadge).toBe(true);
491
+ }
492
+
493
+ it('FB-44: chrome appended via host.appendChild() after connect lands at canonical position', async () => {
494
+ const swatch = document.createElement('swatch-ui');
495
+ swatch.setAttribute('shape', 'block');
496
+ swatch.setAttribute('color', '#3b82f6');
497
+ host.appendChild(swatch);
498
+ await new Promise((r) => setTimeout(r, 30));
499
+
500
+ // Append chrome AFTER initial #stamp() + #syncCore() pass completed.
501
+ const badge = document.createElement('span');
502
+ badge.setAttribute('slot', 'chrome');
503
+ badge.id = 'fb44-late-chrome';
504
+ badge.textContent = 'P3';
505
+ swatch.appendChild(badge);
506
+ // Wait a microtask for the MutationObserver to fire.
507
+ await new Promise((r) => setTimeout(r, 30));
508
+
509
+ const found = swatch.querySelector('#fb44-late-chrome');
510
+ assertChromePositionedBeforeBadge(swatch, found);
511
+ });
512
+
513
+ it('FB-44: chrome appended via display:contents wrapper-span after connect lands at canonical position', async () => {
514
+ const swatch = document.createElement('swatch-ui');
515
+ swatch.setAttribute('shape', 'block');
516
+ swatch.setAttribute('color', '#3b82f6');
517
+ host.appendChild(swatch);
518
+ await new Promise((r) => setTimeout(r, 30));
519
+
520
+ // Simulate the template engine's wrap() output: display:contents span
521
+ // carrying the chrome child as its only descendant.
522
+ const wrapper = document.createElement('span');
523
+ wrapper.style.display = 'contents';
524
+ const badge = document.createElement('span');
525
+ badge.setAttribute('slot', 'chrome');
526
+ badge.id = 'fb44-wrapped-chrome';
527
+ badge.textContent = 'OOG';
528
+ wrapper.appendChild(badge);
529
+ swatch.appendChild(wrapper);
530
+ await new Promise((r) => setTimeout(r, 30));
531
+
532
+ const found = swatch.querySelector('#fb44-wrapped-chrome');
533
+ assertChromePositionedBeforeBadge(swatch, found);
534
+ });
535
+
536
+ it('FB-44: chrome observer is disconnected when host leaves the DOM (leak regression)', async () => {
537
+ const swatch = document.createElement('swatch-ui');
538
+ swatch.setAttribute('shape', 'block');
539
+ swatch.setAttribute('color', '#3b82f6');
540
+ host.appendChild(swatch);
541
+ await new Promise((r) => setTimeout(r, 30));
542
+
543
+ // Remove from DOM — disconnected() should disconnect the observer.
544
+ swatch.remove();
545
+ await new Promise((r) => setTimeout(r, 30));
546
+
547
+ // Subsequent appendChild on the now-orphaned host should not throw,
548
+ // and re-attaching the swatch should re-arm a fresh observer.
549
+ document.body.appendChild(swatch);
550
+ await new Promise((r) => setTimeout(r, 30));
551
+
552
+ const badge = document.createElement('span');
553
+ badge.setAttribute('slot', 'chrome');
554
+ badge.id = 'fb44-reattach-chrome';
555
+ swatch.appendChild(badge);
556
+ await new Promise((r) => setTimeout(r, 30));
557
+
558
+ const found = swatch.querySelector('#fb44-reattach-chrome');
559
+ expect(found).not.toBeNull();
560
+ assertChromePositionedBeforeBadge(swatch, found);
561
+ swatch.remove();
562
+ });
563
+ });
@@ -61,15 +61,16 @@
61
61
  box-sizing: border-box;
62
62
  display: flex;
63
63
  gap: var(--tabs-gap);
64
- border-bottom: 1px solid var(--tabs-border);
64
+ height: var(--tabs-button-height);
65
+ box-shadow: inset 0 -1px 0 var(--tabs-border);
65
66
  position: relative;
66
67
  }
67
68
 
68
69
  :scope[orientation="vertical"] > [slot="strip"] {
69
70
  flex-direction: column;
70
71
  align-items: stretch;
71
- border-bottom: none;
72
- border-inline-start: 1px solid var(--tabs-border);
72
+ height: auto;
73
+ box-shadow: inset 1px 0 0 var(--tabs-border);
73
74
  flex-shrink: 0;
74
75
  min-width: var(--tabs-vertical-strip-min-width);
75
76
  }
@@ -77,7 +78,7 @@
77
78
  /* ── Sliding indicator ── */
78
79
  [slot="indicator"] {
79
80
  position: absolute;
80
- bottom: -1px;
81
+ bottom: 0;
81
82
  left: 0;
82
83
  height: var(--tabs-indicator-height);
83
84
  background: var(--tabs-indicator-color);
@@ -53,13 +53,24 @@ export class UITree extends UIElement {
53
53
  return this.querySelector('tree-item-ui[selected]');
54
54
  }
55
55
 
56
- select(item) {
56
+ // §-TBD (v0.5.19, FB-46): originatingEvent forwards Mouse/KeyboardEvent
57
+ // modifier state into the dispatched tree-select detail so consumers can
58
+ // implement Ctrl/Cmd+click multi-select cleanly. Programmatic select()
59
+ // calls (no event) surface ctrlKey/metaKey/shiftKey as false.
60
+ select(item, originatingEvent = null) {
57
61
  const prev = this.selectedItem;
58
62
  if (prev && prev !== item) prev.removeAttribute('selected');
59
63
  item.setAttribute('selected', '');
60
64
  this.dispatchEvent(new CustomEvent('tree-select', {
61
65
  bubbles: true,
62
- detail: { item, text: item.text, value: item.value },
66
+ detail: {
67
+ item,
68
+ text: item.text,
69
+ value: item.value,
70
+ ctrlKey: !!originatingEvent?.ctrlKey,
71
+ metaKey: !!originatingEvent?.metaKey,
72
+ shiftKey: !!originatingEvent?.shiftKey,
73
+ },
63
74
  }));
64
75
  }
65
76
 
@@ -150,7 +161,7 @@ export class UITree extends UIElement {
150
161
  if (!row || !(e.target === row || row.contains(e.target))) return;
151
162
 
152
163
  // Single click: select + toggle if has children
153
- this.select(item);
164
+ this.select(item, e);
154
165
  if (item.hasChildren) {
155
166
  item.open = !item.open;
156
167
  }
@@ -198,7 +209,7 @@ export class UITree extends UIElement {
198
209
  case 'Enter':
199
210
  case ' ':
200
211
  e.preventDefault();
201
- this.select(item);
212
+ this.select(item, e);
202
213
  break;
203
214
  }
204
215
  };
@@ -29,12 +29,24 @@
29
29
  ],
30
30
  "events": {
31
31
  "tree-select": {
32
- "description": "Fired when an item is selected. detail: { item, text, value }",
32
+ "description": "Fired when an item is selected. detail: { item, text, value, ctrlKey, metaKey, shiftKey }. Modifier flags mirror the originating MouseEvent / KeyboardEvent; all default false for programmatic select() calls.\n",
33
33
  "detail": {
34
+ "ctrlKey": {
35
+ "description": "Ctrl key held during activation (false for programmatic select()).",
36
+ "type": "boolean"
37
+ },
34
38
  "item": {
35
39
  "description": "Selected tree-item element.",
36
40
  "type": "object"
37
41
  },
42
+ "metaKey": {
43
+ "description": "Meta (Cmd) key held during activation (false for programmatic select()).",
44
+ "type": "boolean"
45
+ },
46
+ "shiftKey": {
47
+ "description": "Shift key held during activation (false for programmatic select()).",
48
+ "type": "boolean"
49
+ },
38
50
  "text": {
39
51
  "description": "Item text content.",
40
52
  "type": "string"
@@ -13,8 +13,14 @@
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export interface TreeSelectEventDetail {
16
+ /** Ctrl key held during activation (false for programmatic select()). */
17
+ ctrlKey: boolean;
16
18
  /** Selected tree-item element. */
17
19
  item: Record<string, unknown>;
20
+ /** Meta (Cmd) key held during activation (false for programmatic select()). */
21
+ metaKey: boolean;
22
+ /** Shift key held during activation (false for programmatic select()). */
23
+ shiftKey: boolean;
18
24
  /** Item text content. */
19
25
  text: string;
20
26
  /** Item value attribute. */
@@ -0,0 +1,103 @@
1
+ /**
2
+ * <tree-ui> behavioral tests.
3
+ *
4
+ * FB-46 (v0.5.19): tree-select detail now forwards ctrlKey/metaKey/shiftKey
5
+ * from the originating MouseEvent or KeyboardEvent. Programmatic select()
6
+ * calls (no event) surface all three as false.
7
+ */
8
+
9
+ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
10
+
11
+ beforeAll(async () => {
12
+ await import('./tree.js');
13
+ });
14
+
15
+ describe('<tree-ui> tree-select forwards modifier keys (FB-46)', () => {
16
+ let host;
17
+ let tree;
18
+
19
+ beforeEach(async () => {
20
+ host = document.createElement('div');
21
+ document.body.appendChild(host);
22
+
23
+ tree = document.createElement('tree-ui');
24
+ const a = document.createElement('tree-item-ui');
25
+ a.setAttribute('text', 'Alpha');
26
+ a.setAttribute('value', 'a');
27
+ const b = document.createElement('tree-item-ui');
28
+ b.setAttribute('text', 'Beta');
29
+ b.setAttribute('value', 'b');
30
+ tree.appendChild(a);
31
+ tree.appendChild(b);
32
+ host.appendChild(tree);
33
+
34
+ // Let connectedCallback + #stamp() settle.
35
+ await new Promise((r) => setTimeout(r, 30));
36
+ });
37
+
38
+ afterEach(() => {
39
+ host.remove();
40
+ });
41
+
42
+ it('programmatic select() emits ctrlKey/metaKey/shiftKey = false', () => {
43
+ let received = null;
44
+ tree.addEventListener('tree-select', (e) => { received = e.detail; });
45
+
46
+ const alpha = tree.querySelector('tree-item-ui[value="a"]');
47
+ tree.select(alpha);
48
+
49
+ expect(received).not.toBeNull();
50
+ expect(received.item).toBe(alpha);
51
+ expect(received.text).toBe('Alpha');
52
+ expect(received.value).toBe('a');
53
+ expect(received.ctrlKey).toBe(false);
54
+ expect(received.metaKey).toBe(false);
55
+ expect(received.shiftKey).toBe(false);
56
+ });
57
+
58
+ it('select(item, event) forwards Mouse/KeyboardEvent modifier flags into detail', () => {
59
+ let received = null;
60
+ tree.addEventListener('tree-select', (e) => { received = e.detail; });
61
+
62
+ const alpha = tree.querySelector('tree-item-ui[value="a"]');
63
+ // Synthesize an event with all three modifiers held.
64
+ const fakeEvent = new MouseEvent('click', {
65
+ ctrlKey: true,
66
+ metaKey: true,
67
+ shiftKey: true,
68
+ });
69
+ tree.select(alpha, fakeEvent);
70
+
71
+ expect(received.ctrlKey).toBe(true);
72
+ expect(received.metaKey).toBe(true);
73
+ expect(received.shiftKey).toBe(true);
74
+ });
75
+
76
+ it('detail shape stays backward-compatible (existing fields unchanged)', () => {
77
+ let received = null;
78
+ tree.addEventListener('tree-select', (e) => { received = e.detail; });
79
+
80
+ const beta = tree.querySelector('tree-item-ui[value="b"]');
81
+ tree.select(beta);
82
+
83
+ // Existing consumers destructure { item, text, value } — those keys
84
+ // MUST remain present + carry the expected values.
85
+ const { item, text, value } = received;
86
+ expect(item).toBe(beta);
87
+ expect(text).toBe('Beta');
88
+ expect(value).toBe('b');
89
+ });
90
+
91
+ it('partial modifier state passes through (single key held)', () => {
92
+ let received = null;
93
+ tree.addEventListener('tree-select', (e) => { received = e.detail; });
94
+
95
+ const alpha = tree.querySelector('tree-item-ui[value="a"]');
96
+ const onlyMeta = new MouseEvent('click', { metaKey: true });
97
+ tree.select(alpha, onlyMeta);
98
+
99
+ expect(received.ctrlKey).toBe(false);
100
+ expect(received.metaKey).toBe(true);
101
+ expect(received.shiftKey).toBe(false);
102
+ });
103
+ });
@@ -15,7 +15,11 @@ composes:
15
15
  props: {}
16
16
  events:
17
17
  tree-select:
18
- description: "Fired when an item is selected. detail: { item, text, value }"
18
+ description: >
19
+ Fired when an item is selected.
20
+ detail: { item, text, value, ctrlKey, metaKey, shiftKey }.
21
+ Modifier flags mirror the originating MouseEvent / KeyboardEvent;
22
+ all default false for programmatic select() calls.
19
23
  detail:
20
24
  item:
21
25
  type: object
@@ -26,6 +30,15 @@ events:
26
30
  value:
27
31
  type: string
28
32
  description: Item value attribute.
33
+ ctrlKey:
34
+ type: boolean
35
+ description: Ctrl key held during activation (false for programmatic select()).
36
+ metaKey:
37
+ type: boolean
38
+ description: Meta (Cmd) key held during activation (false for programmatic select()).
39
+ shiftKey:
40
+ type: boolean
41
+ description: Shift key held during activation (false for programmatic select()).
29
42
  slots:
30
43
  default (tree-item-ui children):
31
44
  description: "Child content region for the `default (tree-item-ui children)` slot."
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
16
- import { html, stamp } from './template.js';
16
+ import { html, stamp, repeat } from './template.js';
17
17
 
18
18
  describe('html template — §250 (v0.5.11) ?attr=${bool} silent-failure trap', () => {
19
19
  let container;
@@ -179,3 +179,103 @@ describe('html template — FB-40 (v0.5.16) apostrophe in HTML comments does NOT
179
179
  expect(received).not.toBeNull();
180
180
  });
181
181
  });
182
+
183
+ describe('html template — FB-47 (v0.5.19) stamp() cache + repeat() keyed reuse', () => {
184
+ // FEEDBACK-47 claimed `stamp()` unconditionally calls `mount()` →
185
+ // `replaceChildren()`, defeating `repeat()` keyed reconciliation. Source
186
+ // inspection (template.js:94-102) shows `stamp()` has a per-container
187
+ // cache keyed on `result.strings`; `mount()` runs ONLY on cache miss
188
+ // (first stamp on this container OR new template strings). These tests
189
+ // verify the cache behavior empirically against the same-template
190
+ // re-stamp + repeat()-same-key paths.
191
+
192
+ let container;
193
+
194
+ beforeEach(() => {
195
+ container = document.createElement('div');
196
+ document.body.appendChild(container);
197
+ });
198
+
199
+ afterEach(() => {
200
+ container.remove();
201
+ });
202
+
203
+ it('stamp() with same template strings reuses the mounted instance (no replaceChildren)', () => {
204
+ // Build a template factory whose strings literal is reference-stable
205
+ // (the tagged-template caller — closure captures `strings` once).
206
+ const make = (value) => html`<div class="probe">${value}</div>`;
207
+
208
+ stamp(make('first'), container);
209
+ const firstDiv = container.querySelector('.probe');
210
+ expect(firstDiv).not.toBeNull();
211
+ expect(firstDiv.textContent).toBe('first');
212
+
213
+ // Mark the existing node so we can prove it survives the re-stamp.
214
+ firstDiv.dataset.marker = 'preserved';
215
+
216
+ stamp(make('second'), container);
217
+ const secondDiv = container.querySelector('.probe');
218
+ expect(secondDiv).toBe(firstDiv); // ✅ same node
219
+ expect(secondDiv.dataset.marker).toBe('preserved'); // ✅ no replaceChildren
220
+ expect(secondDiv.textContent).toBe('second'); // ✅ value updated in-place
221
+ });
222
+
223
+ it('repeat() with same key preserves wrapper children across re-renders', () => {
224
+ // FB-47's central reproduction: same-key repeat() should preserve the
225
+ // per-item wrapper-span AND the child element stamped into it on the
226
+ // prior render. Both the outer template AND the per-item template must
227
+ // share strings references across renders (single tagged-template-literal
228
+ // sites) — JS spec gives each source site its own strings array, so
229
+ // inline-duplicating the html`` between stamp calls would force a
230
+ // cache miss and defeat preservation. Real consumers naturally satisfy
231
+ // this by having one render function called repeatedly.
232
+ const items = [{ id: 'a', label: 'first' }];
233
+ const tplFn = (item) => html`<div class="probe" data-id=${item.id}>${item.label}</div>`;
234
+ const renderAll = (its) => html`${repeat(its, (x) => x.id, tplFn)}`;
235
+
236
+ stamp(renderAll(items), container);
237
+ const firstDiv = container.querySelector('.probe[data-id="a"]');
238
+ expect(firstDiv).not.toBeNull();
239
+
240
+ // Append a manually-added child that the framework should NOT destroy.
241
+ const probe = document.createElement('span');
242
+ probe.id = 'fb47-manual-child';
243
+ probe.textContent = 'sentinel';
244
+ firstDiv.appendChild(probe);
245
+
246
+ // Re-stamp with the same items array (same key 'a').
247
+ items[0] = { id: 'a', label: 'second' };
248
+ stamp(renderAll(items), container);
249
+
250
+ const secondDiv = container.querySelector('.probe[data-id="a"]');
251
+ expect(secondDiv).toBe(firstDiv); // ✅ wrapper reused
252
+ expect(secondDiv.querySelector('#fb47-manual-child')).not.toBeNull();
253
+ expect(secondDiv.firstChild.nodeValue).toBe('second'); // text updated in-place
254
+ });
255
+
256
+ it('Array.isArray() branch in applyValue does replace children on every re-render (the actual root cause)', () => {
257
+ // FB-47's empirical observation IS valid for `.map()` — the
258
+ // Array.isArray() branch in applyValue() (template.js:232-244) does
259
+ // unconditional `container.replaceChildren()` + allocates fresh wrapper
260
+ // spans per item. Switching consumers to repeat() avoids this. This
261
+ // test pins the current behavior so any future "make .map() smart"
262
+ // change is intentional, not accidental. Use a single render function
263
+ // (one tagged-template-literal site for the outer template) so the
264
+ // observed re-creation is isolated to the .map() Array branch, not
265
+ // a cache miss from inline-duplicated source sites.
266
+ const items = [{ id: 'a', label: 'first' }];
267
+ const tplFn = (item) => html`<div class="probe" data-id=${item.id}>${item.label}</div>`;
268
+ const renderAll = (its) => html`${its.map(tplFn)}`;
269
+
270
+ stamp(renderAll(items), container);
271
+ const firstDiv = container.querySelector('.probe[data-id="a"]');
272
+ expect(firstDiv).not.toBeNull();
273
+
274
+ items[0] = { id: 'a', label: 'second' };
275
+ stamp(renderAll(items), container);
276
+
277
+ const secondDiv = container.querySelector('.probe[data-id="a"]');
278
+ expect(secondDiv).not.toBe(firstDiv); // ✅ NEW node — .map() re-creates
279
+ expect(secondDiv.textContent).toBe('second');
280
+ });
281
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",