@adia-ai/web-components 0.5.17 → 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 +22 -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 +109 -14
- package/components/swatch/swatch.test.js +242 -10
- 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,28 @@ 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
|
+
|
|
31
|
+
## [0.5.18] - 2026-05-16
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- §341 (FB-43; P1) — `<swatch-ui>` chrome destroyed when interpolated via parent `html\`\`` template `${chromeFrag}`. `template.js`'s `scan()` replaces `<!--p:N-->` comments with empty text nodes inside the swatch element when the parent template is cloned; `wrap()` later mutates those text nodes in-place via `replaceWith()`. Pre-§341: the swatch's `#stamp()` ran `innerHTML = ''` between the parent's `mount()` and `update()` (synchronous custom-element connectedCallback timing), wiping the text-node placeholders. Parent's `replaceWith()` then ran on detached nodes — silent no-op — leaving wrapper-spans detached and chrome stamped into a subtree that never reached the live DOM. Symptom: chrome interpolated as `${chromeFrag}` was completely invisible (queryable from neither swatch nor host). Blocked Tokens Studio's C1.3 main-palette dogfood migration (~80 swatches inside `repeat()`). **Fix:** preserve empty text nodes + comment nodes (template-engine placeholders) AND display:contents wrapper-spans through `innerHTML = ''`. Detach them before the wipe, re-attach at the canonical chrome position (between tile + badge) after the canonical structure is stamped. Wrapper-spans preserved in-place keep the template-engine's `part.n` reference stable across re-renders → chrome content updates land in the same DOM location automatically. `#absorbChromeSlot()`'s late-arriving wrapper handler also updated: MOVE the wrapper (not hoist chrome out of it) to canonical position — same identity-preservation rationale. **§331 contract refined:** previously chrome was hoisted to be a direct sibling of the swatch's internal elements (`parentElement === swatch`); §341 keeps chrome inside the wrapper-span which is a direct sibling. Visually identical (display:contents collapses the wrapper's box) but the DOM identity is now wrapper-bearing. 3 NEW vitest cases (`FB-43 / §341`) + 3 §331 (FB-39) tests updated for new contract. 15/15 swatch tests passing.
|
|
35
|
+
|
|
14
36
|
## [0.5.17] - 2026-05-16
|
|
15
37
|
|
|
16
38
|
### 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
|