@adia-ai/web-components 0.5.18 → 0.5.19
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 +17 -0
- package/USAGE.md +68 -0
- package/components/command/USAGE.md +160 -0
- package/components/command/class.js +23 -6
- package/components/command/command.a2ui.json +5 -1
- package/components/command/command.d.ts +2 -0
- package/components/command/command.test.js +99 -0
- package/components/command/command.yaml +9 -1
- package/components/description-list/description-list.a2ui.json +3 -2
- package/components/description-list/description-list.css +16 -0
- package/components/description-list/description-list.d.ts +3 -2
- package/components/description-list/description-list.test.js +72 -0
- package/components/description-list/description-list.yaml +10 -1
- package/components/swatch/class.js +41 -0
- package/components/swatch/swatch.test.js +113 -0
- package/components/tree/class.js +15 -4
- package/components/tree/tree.a2ui.json +13 -1
- package/components/tree/tree.d.ts +6 -0
- package/components/tree/tree.test.js +103 -0
- package/components/tree/tree.yaml +14 -1
- package/core/template.test.js +101 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,23 @@ runtime ships in the sibling `@adia-ai/a2ui-runtime` package
|
|
|
11
11
|
|
|
12
12
|
_No pending changes._
|
|
13
13
|
|
|
14
|
+
## [0.5.19] - 2026-05-17
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- §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).
|
|
18
|
+
- §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.
|
|
19
|
+
- §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.
|
|
20
|
+
- §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.
|
|
21
|
+
|
|
22
|
+
### Tests
|
|
23
|
+
- §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.
|
|
24
|
+
- §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.
|
|
25
|
+
|
|
26
|
+
### Docs
|
|
27
|
+
- 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).
|
|
28
|
+
- 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.
|
|
29
|
+
- §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.
|
|
30
|
+
|
|
14
31
|
## [0.5.18] - 2026-05-16
|
|
15
32
|
|
|
16
33
|
### Fixed
|
package/USAGE.md
CHANGED
|
@@ -1086,6 +1086,74 @@ 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
|
+
### §-TBD (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
|
+
### §-TBD (v0.5.19) — Event handling inside reactive lists (FB-49)
|
|
1122
|
+
|
|
1123
|
+
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.
|
|
1124
|
+
|
|
1125
|
+
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:
|
|
1126
|
+
|
|
1127
|
+
```js
|
|
1128
|
+
// ✅ Bubble-phase listener on or inside the list — closest() works fine
|
|
1129
|
+
tree.addEventListener('tree-select', (e) => {
|
|
1130
|
+
const item = e.detail.item; // tree-ui already does the right walk for you
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// ✅ Bubble-phase listener on a parent — closest() walks past display:contents wrappers
|
|
1134
|
+
host.addEventListener('click', (e) => {
|
|
1135
|
+
const item = e.target.closest('tree-item-ui');
|
|
1136
|
+
if (item && host.contains(item)) { /* … */ }
|
|
1137
|
+
});
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
Reach for `e.composedPath()` only when one of these specific cases applies:
|
|
1141
|
+
|
|
1142
|
+
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.
|
|
1143
|
+
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.
|
|
1144
|
+
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.
|
|
1145
|
+
|
|
1146
|
+
```js
|
|
1147
|
+
// ✅ For capture-phase / cross-subtree / shadow-DOM cases
|
|
1148
|
+
host.addEventListener('click', (e) => {
|
|
1149
|
+
const item = e.composedPath().find(
|
|
1150
|
+
(el) => el?.tagName?.toLowerCase() === 'tree-item-ui' && host.contains(el)
|
|
1151
|
+
);
|
|
1152
|
+
}, { capture: true });
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
**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.
|
|
1156
|
+
|
|
1089
1157
|
### §221j — Typography token cheatsheet
|
|
1090
1158
|
|
|
1091
1159
|
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
|
|
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
|
-
#
|
|
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: {
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
19
|
-
|
|
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:
|
|
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
|
|
@@ -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
|
+
});
|
package/components/tree/class.js
CHANGED
|
@@ -53,13 +53,24 @@ export class UITree extends UIElement {
|
|
|
53
53
|
return this.querySelector('tree-item-ui[selected]');
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
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: {
|
|
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:
|
|
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."
|
package/core/template.test.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.5.19",
|
|
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",
|