@ponchia/ui 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@
5
5
  |> `^0` / `*` wildcard does **not** protect you. See README → Versioning, and
6
6
  |> the deprecation policy in CONTRIBUTING.md.
7
7
 
8
+ ## 0.14.0 — 2026-10-01
9
+
10
+ ### Added
11
+
12
+ - **`initCommand({ match })` and `initCommand({ headless: true })`.** `match(row,
13
+ query)` replaces the substring test, so a row can match on keywords it does
14
+ not show, or stay visible for every query (a "Search everything" row inside
15
+ the list, where the keyboard reaches it). Headless mode leaves the result set
16
+ to the host: Bronto hides nothing and reads the list live, so the host can
17
+ render new rows on every keystroke without re-running `initCommand`, and
18
+ Bronto keeps ids, roles, the active row, the keyboard and the empty state's
19
+ live region. Cleanup restores rows added after init as well.
20
+ - **`@ponchia/ui/css/tool.css`**, the default bundle for a tool, imported
21
+ instead of `@ponchia/ui`. It is core in core's order without the
22
+ `navigation`, `site`, `table` and `app` leaves, about 15 kB less.
23
+ - **`css/fonts-inter.css` and `css/fonts-jetbrains-mono.css`**, opt-in leaves
24
+ that ship the faces `--sans` and `--mono` name first. Before, the package
25
+ shipped Doto only and every OS drew its own fallback. Inter 4.1 is one
26
+ variable face per style, and JetBrains Mono 2.304 comes in regular, bold and
27
+ their italics. Both are unmodified upstream files under the SIL OFL 1.1, with
28
+ their licenses in `fonts/`.
29
+ - **`ui-body-state` and `ui-alert--band`** for node, panel and card bodies of
30
+ 200–400px. A body state replaces the content: it fills the body and centres
31
+ one sentence. Use `ui.bodyState()` when empty or loading (`aria-busy` says
32
+ loading) and `ui.bodyState({ state: 'error' | 'stale' })` for a tone dot. A
33
+ band is a full-bleed, one-line alert above content the body still shows
34
+ (`ui.alert({ tone, band: true })`). See usage.md → "Small bodies".
35
+
36
+ ### Changed
37
+
38
+ - The default bundle grows from 95.8 kB / 16.6 kB gzip to 97.1 kB / 16.8 kB,
39
+ for the body state, the band and `--alert-tone`. `css/tool.css` is 81.8 kB /
40
+ 14.7 kB.
41
+ - Each `ui-alert--<tone>` also sets `--alert-tone`, which the band variant
42
+ tints with.
43
+ - `.ui-meta` (the date · author line) moved from `css/site.css` to
44
+ `css/content.css`, so the tool entry keeps it. The default bundle is
45
+ unchanged, since content follows site in its cascade. A direct import of
46
+ `css/site.css` that used `.ui-meta` also needs `css/content.css`.
47
+
8
48
  ## 0.13.0 — 2026-10-01
9
49
 
10
50
  ### Added
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/@ponchia/ui?logo=npm)](https://www.npmjs.com/package/@ponchia/ui)
4
4
  [![npm provenance](https://img.shields.io/badge/npm-provenance-blue?logo=npm)](https://www.npmjs.com/package/@ponchia/ui#provenance)
5
5
  [![runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)](https://github.com/Ponchia/bronto-ui/blob/main/package.json)
6
- [![default CSS](https://img.shields.io/badge/default%20CSS-~96kB%20%2F%20~17kB%20gzip-informational)](https://github.com/Ponchia/bronto-ui/blob/main/scripts/check-dist.mjs)
6
+ [![default CSS](https://img.shields.io/badge/default%20CSS-~97kB%20%2F%20~17kB%20gzip-informational)](https://github.com/Ponchia/bronto-ui/blob/main/scripts/check-dist.mjs)
7
7
  [![CI](https://github.com/Ponchia/bronto-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/Ponchia/bronto-ui/actions/workflows/ci.yml)
8
8
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Ponchia/bronto-ui/badge)](https://scorecard.dev/viewer/?uri=github.com/Ponchia/bronto-ui)
9
9
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/Ponchia/bronto-ui/blob/main/LICENSE)
@@ -93,12 +93,12 @@ Or drop it in with no build step, straight from a CDN (replace the version only
93
93
  when deliberately upgrading across a breaking pre-1.0 minor):
94
94
 
95
95
  ```html
96
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ponchia/ui@0.13.0/dist/bronto.css">
96
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ponchia/ui@0.14.0/dist/bronto.css">
97
97
  ```
98
98
 
99
99
  ## Quick start
100
100
 
101
- **1. Load the CSS.** One flattened, minified default CSS bundle — the standard component set, one request (~96 kB raw / ~17 kB gzip) — that is `dist/bronto.css`, not the whole package tarball:
101
+ **1. Load the CSS.** One flattened, minified default CSS bundle — the standard component set, one request (~97 kB raw / ~17 kB gzip) — that is `dist/bronto.css`, not the whole package tarball:
102
102
 
103
103
  ```css
104
104
  @import '@ponchia/ui'; /* via a bundler */
@@ -256,4 +256,4 @@ Release candidates publish to the `next` dist-tag, never to `latest` — opt in
256
256
 
257
257
  [MIT](https://github.com/Ponchia/bronto-ui/blob/main/LICENSE) © Ponchia.
258
258
 
259
- The bundled **Doto** font (`fonts/*.woff2`) is © 2024 The Doto Project Authors and licensed separately under the [SIL Open Font License 1.1](https://github.com/Ponchia/bronto-ui/blob/main/fonts/OFL.txt) — see `fonts/OFL.txt`.
259
+ The bundled fonts are licensed separately under the [SIL Open Font License 1.1](https://github.com/Ponchia/bronto-ui/blob/main/fonts/OFL.txt): **Doto** (`fonts/doto-*.woff2`, © 2024 The Doto Project Authors, `fonts/OFL.txt`), and the opt-in **Inter** (`fonts/inter-*.woff2`, © 2016 The Inter Project Authors, `fonts/OFL-Inter.txt`) and **JetBrains Mono** (`fonts/jetbrains-mono-*.woff2`, © 2020 The JetBrains Mono Project Authors, `fonts/OFL-JetBrainsMono.txt`).
@@ -1,8 +1,3 @@
1
- /**
2
- * @typedef {object} CommandSelectDetail
3
- * @property {string} value The chosen command's value.
4
- * @property {string} label The chosen command's visible label.
5
- */
6
1
  /**
7
2
  * Command palette — filter + keyboard-navigate a DOM-authored command list.
8
3
  * The CSS shell (`.ui-command`) is opt-in; this wires the listbox behavior the
@@ -15,19 +10,21 @@
15
10
  * and a list (`.ui-command__list`) of `.ui-command__item` rows (optional
16
11
  * `data-value`), interleaved with `.ui-command__group` labels and an optional
17
12
  * `.ui-command__empty`. The behavior owns ids, `role=combobox/listbox/option`,
18
- * `aria-activedescendant`, a roving active item, substring filtering (hiding
19
- * empty groups), keyboard list navigation (Down/Up/Enter/Escape), and pointer
20
- * select. It emits `bronto:command:select` ({ detail: { value, label } }) on
21
- * choose and `bronto:command:close` on Escape. SSR-safe, idempotent per
22
- * instance; returns a cleanup function.
13
+ * `aria-activedescendant`, a roving active item, filtering (substring by
14
+ * default, `match` to replace it, hiding empty groups), keyboard list
15
+ * navigation (Down/Up/Enter/Escape), and pointer select. It emits
16
+ * `bronto:command:select` ({ detail: { value, label } }) on choose and
17
+ * `bronto:command:close` on Escape. SSR-safe, idempotent per instance; returns
18
+ * a cleanup function.
23
19
  *
24
20
  * Items are read from the DOM at init; re-run initCommand after replacing the
25
- * command list so filtering/navigation see the current nodes.
21
+ * command list so filtering/navigation see the current nodes — or pass
22
+ * `headless: true` and render the results yourself.
26
23
  *
27
- * @param {import('./internal.js').DelegateOpts} [opts]
24
+ * @param {CommandOpts} [opts]
28
25
  * @returns {import('./internal.js').Cleanup}
29
26
  */
30
- export function initCommand({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
27
+ export function initCommand({ root, match, headless }?: CommandOpts): import("./internal.js").Cleanup;
31
28
  export type CommandSelectDetail = {
32
29
  /**
33
30
  * The chosen command's value.
@@ -38,4 +35,28 @@ export type CommandSelectDetail = {
38
35
  */
39
36
  label: string;
40
37
  };
38
+ export type CommandOpts = {
39
+ /**
40
+ * Event-delegation root; also scopes which palettes are queried. Default: `document`.
41
+ * `null` means a scope was requested but is not ready yet, so the behavior no-ops.
42
+ */
43
+ root?: Element | Document | null | undefined;
44
+ /**
45
+ * Decides whether an item stays visible for a query. `query` is the trimmed
46
+ * input, lower-cased in the palette's locale; an empty query shows every item.
47
+ * Default: the item's text contains the query. Use it to match on keywords
48
+ * (`data-keywords`, say) or to keep a row such as "Search everything" visible
49
+ * for every query. Not called in headless mode.
50
+ */
51
+ match?: ((item: HTMLElement, query: string) => boolean) | undefined;
52
+ /**
53
+ * The host renders the result set. Bronto never hides an item or group; it
54
+ * reads the list live, so the host can replace rows on every keystroke
55
+ * without re-running `initCommand`. It still owns ids, roles, the roving
56
+ * active item, the keyboard, pointer select and the empty state's live
57
+ * region. After the query changes, the first row the host renders becomes
58
+ * active.
59
+ */
60
+ headless?: boolean | undefined;
61
+ };
41
62
  //# sourceMappingURL=command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["command.js"],"names":[],"mappings":"AA6BA;;;;GAIG;AAEH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAiP3C;;;;;WA3Qa,MAAM;;;;WACN,MAAM"}
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["command.js"],"names":[],"mappings":"AA6GA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wDAHW,WAAW,GACT,OAAO,eAAe,EAAE,OAAO,CAuO3C;;;;;WA7Ua,MAAM;;;;WACN,MAAM;;;;;;;;;;;;;;;oBAQC,WAAW,SAAS,MAAM,KAAK,OAAO"}
@@ -33,6 +33,80 @@ const lowerForSearch = (value, locale) => {
33
33
  * @property {string} label The chosen command's visible label.
34
34
  */
35
35
 
36
+ /**
37
+ * @typedef {object} CommandOpts
38
+ * @property {Document | Element | null} [root]
39
+ * Event-delegation root; also scopes which palettes are queried. Default: `document`.
40
+ * `null` means a scope was requested but is not ready yet, so the behavior no-ops.
41
+ * @property {(item: HTMLElement, query: string) => boolean} [match]
42
+ * Decides whether an item stays visible for a query. `query` is the trimmed
43
+ * input, lower-cased in the palette's locale; an empty query shows every item.
44
+ * Default: the item's text contains the query. Use it to match on keywords
45
+ * (`data-keywords`, say) or to keep a row such as "Search everything" visible
46
+ * for every query. Not called in headless mode.
47
+ * @property {boolean} [headless]
48
+ * The host renders the result set. Bronto never hides an item or group; it
49
+ * reads the list live, so the host can replace rows on every keystroke
50
+ * without re-running `initCommand`. It still owns ids, roles, the roving
51
+ * active item, the keyboard, pointer select and the empty state's live
52
+ * region. After the query changes, the first row the host renders becomes
53
+ * active.
54
+ */
55
+
56
+ const ITEM = '.ui-command__item, [role="option"]';
57
+
58
+ /** The first non-blank text node under `el`, depth first. */
59
+ function firstTextNode(el) {
60
+ for (const node of el.childNodes) {
61
+ if (node.nodeType === 3 && node.nodeValue.trim()) return node;
62
+ if (node.nodeType === 1) {
63
+ const child = firstTextNode(node);
64
+ if (child) return child;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Re-set an element's first text so a live region announces it again. */
71
+ function refreshLiveText(el) {
72
+ const node = firstTextNode(el);
73
+ if (!node) return;
74
+ const text = node.nodeValue;
75
+ node.nodeValue = '';
76
+ node.nodeValue = text;
77
+ }
78
+
79
+ /**
80
+ * Records everything a palette changes the first time it is touched, so
81
+ * cleanup restores rows the host added after init as well.
82
+ */
83
+ function changeRecorder() {
84
+ const touched = new Map();
85
+ const touch = (el, names) => {
86
+ let saved = touched.get(el);
87
+ if (!saved) {
88
+ saved = { hidden: el.hidden, active: el.classList.contains('is-active'), attrs: {} };
89
+ touched.set(el, saved);
90
+ }
91
+ for (const name of names) {
92
+ if (name in saved.attrs) continue;
93
+ saved.attrs[name] = { had: el.hasAttribute(name), value: el.getAttribute(name) };
94
+ }
95
+ };
96
+ const restore = () => {
97
+ for (const [el, saved] of touched) {
98
+ el.hidden = saved.hidden;
99
+ el.classList.toggle('is-active', saved.active);
100
+ for (const [name, attr] of Object.entries(saved.attrs)) {
101
+ if (attr.had) el.setAttribute(name, attr.value);
102
+ else el.removeAttribute(name);
103
+ }
104
+ }
105
+ touched.clear();
106
+ };
107
+ return { touch, restore };
108
+ }
109
+
36
110
  /**
37
111
  * Command palette — filter + keyboard-navigate a DOM-authored command list.
38
112
  * The CSS shell (`.ui-command`) is opt-in; this wires the listbox behavior the
@@ -45,178 +119,148 @@ const lowerForSearch = (value, locale) => {
45
119
  * and a list (`.ui-command__list`) of `.ui-command__item` rows (optional
46
120
  * `data-value`), interleaved with `.ui-command__group` labels and an optional
47
121
  * `.ui-command__empty`. The behavior owns ids, `role=combobox/listbox/option`,
48
- * `aria-activedescendant`, a roving active item, substring filtering (hiding
49
- * empty groups), keyboard list navigation (Down/Up/Enter/Escape), and pointer
50
- * select. It emits `bronto:command:select` ({ detail: { value, label } }) on
51
- * choose and `bronto:command:close` on Escape. SSR-safe, idempotent per
52
- * instance; returns a cleanup function.
122
+ * `aria-activedescendant`, a roving active item, filtering (substring by
123
+ * default, `match` to replace it, hiding empty groups), keyboard list
124
+ * navigation (Down/Up/Enter/Escape), and pointer select. It emits
125
+ * `bronto:command:select` ({ detail: { value, label } }) on choose and
126
+ * `bronto:command:close` on Escape. SSR-safe, idempotent per instance; returns
127
+ * a cleanup function.
53
128
  *
54
129
  * Items are read from the DOM at init; re-run initCommand after replacing the
55
- * command list so filtering/navigation see the current nodes.
130
+ * command list so filtering/navigation see the current nodes — or pass
131
+ * `headless: true` and render the results yourself.
56
132
  *
57
- * @param {import('./internal.js').DelegateOpts} [opts]
133
+ * @param {CommandOpts} [opts]
58
134
  * @returns {import('./internal.js').Cleanup}
59
135
  */
60
- export function initCommand({ root } = {}) {
136
+ export function initCommand({ root, match, headless = false } = {}) {
61
137
  if (!hasDom()) return noop;
62
138
  const host = resolveHost(root);
63
139
  if (!host) return noop;
64
140
  const palettes = collectHosts(host, '[data-bronto-command]');
65
141
  const cleanups = [];
66
142
 
67
- const snapshotAttrs = (el, names) => {
68
- const out = {};
69
- for (const name of names) {
70
- out[name] = {
71
- had: el.hasAttribute(name),
72
- value: el.getAttribute(name),
73
- };
74
- }
75
- return out;
76
- };
77
-
78
- const restoreAttrs = (el, attrs) => {
79
- for (const [name, attr] of Object.entries(attrs)) {
80
- if (attr.had) el.setAttribute(name, attr.value);
81
- else el.removeAttribute(name);
82
- }
83
- };
84
-
85
- const firstTextNode = (el) => {
86
- for (const node of el.childNodes) {
87
- if (node.nodeType === 3 && node.nodeValue.trim()) return node;
88
- if (node.nodeType === 1) {
89
- const child = firstTextNode(node);
90
- if (child) return child;
91
- }
92
- }
93
- return null;
94
- };
95
-
96
- const refreshLiveText = (el) => {
97
- const node = firstTextNode(el);
98
- if (!node) return;
99
- const text = node.nodeValue;
100
- node.nodeValue = '';
101
- node.nodeValue = text;
102
- };
103
-
104
- const prepareEmptyState = (el) => {
105
- if (!el) return;
106
- el.setAttribute('role', 'status');
107
- el.setAttribute('aria-live', 'polite');
108
- };
109
-
110
143
  for (const box of palettes) {
111
144
  const input = box.querySelector('.ui-command__input, input');
112
145
  const list = box.querySelector('.ui-command__list, [role="listbox"]');
113
146
  if (!input || !list) continue;
114
- const empty = box.querySelector('.ui-command__empty');
115
- const items = [...list.querySelectorAll('.ui-command__item, [role="option"]')];
116
- const groups = [...list.querySelectorAll('.ui-command__group')];
117
147
  const locale = localeOf(box);
148
+ const initialItems = [...list.querySelectorAll(ITEM)];
149
+ const initialGroups = [...list.querySelectorAll('.ui-command__group')];
150
+ // Headless reads the host's current rows; otherwise the rows at init.
151
+ const itemsNow = () => (headless ? [...list.querySelectorAll(ITEM)] : initialItems);
152
+ const groupsNow = () =>
153
+ headless ? [...list.querySelectorAll('.ui-command__group')] : initialGroups;
154
+ const emptyNow = () => box.querySelector('.ui-command__empty');
118
155
 
119
- const rememberState = () => ({
120
- input: snapshotAttrs(input, [
121
- 'role',
122
- 'aria-controls',
123
- 'aria-autocomplete',
124
- 'aria-expanded',
125
- 'aria-activedescendant',
126
- 'autocomplete',
127
- ]),
128
- list: snapshotAttrs(list, ['id', 'role']),
129
- empty: empty
130
- ? {
131
- hidden: empty.hidden,
132
- attrs: snapshotAttrs(empty, ['role', 'aria-live']),
133
- }
134
- : null,
135
- groups: groups.map((g) => ({
136
- el: g,
137
- hidden: g.hidden,
138
- attrs: snapshotAttrs(g, ['role']),
139
- })),
140
- items: items.map((it) => ({
141
- el: it,
142
- hidden: it.hidden,
143
- active: it.classList.contains('is-active'),
144
- attrs: snapshotAttrs(it, ['id', 'role', 'aria-selected']),
145
- })),
146
- });
156
+ const { touch, restore } = changeRecorder();
147
157
 
148
- const restoreState = (state) => {
149
- restoreAttrs(input, state.input);
150
- restoreAttrs(list, state.list);
151
- if (empty && state.empty) {
152
- empty.hidden = state.empty.hidden;
153
- restoreAttrs(empty, state.empty.attrs);
154
- }
155
- for (const group of state.groups) {
156
- group.el.hidden = group.hidden;
157
- restoreAttrs(group.el, group.attrs);
158
- }
159
- for (const item of state.items) {
160
- item.el.hidden = item.hidden;
161
- item.el.classList.toggle('is-active', item.active);
162
- restoreAttrs(item.el, item.attrs);
163
- }
158
+ const optionIdBase = `bronto-cmd-opt-${nextFieldUid()}`;
159
+ let nextOption = 0;
160
+ const prepared = new WeakSet();
161
+ const prepareItem = (it) => {
162
+ if (prepared.has(it)) return;
163
+ prepared.add(it);
164
+ touch(it, ['id', 'role', 'aria-selected']);
165
+ if (!it.id) it.id = `${optionIdBase}-${nextOption++}`;
166
+ it.setAttribute('role', 'option');
167
+ };
168
+ const prepareGroup = (g) => {
169
+ if (prepared.has(g)) return;
170
+ prepared.add(g);
171
+ touch(g, ['role']);
172
+ g.setAttribute('role', 'presentation');
173
+ };
174
+ const prepareEmpty = (el) => {
175
+ if (!el || prepared.has(el)) return;
176
+ prepared.add(el);
177
+ touch(el, ['role', 'aria-live']);
178
+ el.setAttribute('role', 'status');
179
+ el.setAttribute('aria-live', 'polite');
180
+ };
181
+ const prepareRows = () => {
182
+ itemsNow().forEach(prepareItem);
183
+ groupsNow().forEach(prepareGroup);
184
+ prepareEmpty(emptyNow());
164
185
  };
165
186
 
166
- let active = -1;
167
- const visible = () => items.filter((it) => !it.hidden);
187
+ let activeItem = null;
188
+ const visible = () => itemsNow().filter((it) => !it.hidden);
168
189
 
169
190
  const setActive = (item) => {
170
- items.forEach((it) => {
191
+ for (const it of itemsNow()) {
192
+ prepareItem(it);
171
193
  it.classList.toggle('is-active', it === item);
172
194
  it.setAttribute('aria-selected', String(it === item));
173
- });
195
+ }
196
+ activeItem = item;
174
197
  if (item) {
175
- active = items.indexOf(item);
176
198
  input.setAttribute('aria-activedescendant', item.id);
177
199
  scrollIntoViewSafe(item);
178
200
  } else {
179
- active = -1;
180
201
  input.removeAttribute('aria-activedescendant');
181
202
  }
182
203
  };
183
204
 
184
205
  // Hide a group whose items are all filtered out.
185
206
  const syncGroups = () => {
186
- for (const g of groups) {
207
+ for (const g of groupsNow()) {
187
208
  let any = false;
188
209
  for (
189
210
  let n = g.nextElementSibling;
190
211
  n && !n.matches('.ui-command__group');
191
212
  n = n.nextElementSibling
192
213
  ) {
193
- if (n.matches('.ui-command__item, [role="option"]') && !n.hidden) any = true;
214
+ if (n.matches(ITEM) && !n.hidden) any = true;
194
215
  }
195
216
  g.hidden = !any;
196
217
  }
197
218
  };
198
219
 
220
+ const matches = match
221
+ ? (it, q) => !q || Boolean(match(it, q))
222
+ : (it, q) => !q || lowerForSearch(it.textContent, locale).includes(q);
223
+
199
224
  const filter = () => {
200
225
  const q = lowerForSearch(input.value.trim(), locale);
201
226
  let any = false;
202
- for (const it of items) {
203
- const match = !q || lowerForSearch(it.textContent, locale).includes(q);
204
- it.hidden = !match;
205
- if (match) any = true;
227
+ for (const it of itemsNow()) {
228
+ const shown = matches(it, q);
229
+ it.hidden = !shown;
230
+ if (shown) any = true;
206
231
  }
207
232
  syncGroups();
233
+ const empty = emptyNow();
208
234
  if (empty) {
209
235
  empty.hidden = any;
210
236
  if (!any) refreshLiveText(empty);
211
237
  }
212
- const vis = visible();
213
- setActive(vis[0] || null);
238
+ setActive(visible()[0] || null);
239
+ };
240
+
241
+ // Headless: the host re-renders after the query changes, so the first row
242
+ // it renders becomes active then; a row that disappears hands its place to
243
+ // the first one left. An empty state the host reveals is announced.
244
+ let queryChanged = false;
245
+ let emptyShown = false;
246
+ const onRowsChanged = () => {
247
+ prepareRows();
248
+ const rows = visible();
249
+ if (queryChanged || !activeItem || !rows.includes(activeItem)) {
250
+ queryChanged = false;
251
+ setActive(rows[0] || null);
252
+ }
253
+ const empty = emptyNow();
254
+ const shown = Boolean(empty && !empty.hidden);
255
+ if (shown && !emptyShown) refreshLiveText(empty);
256
+ emptyShown = shown;
214
257
  };
215
258
 
216
259
  const move = (delta) => {
260
+ queryChanged = false;
217
261
  const vis = visible();
218
262
  if (!vis.length) return;
219
- setActive(vis[wrapIndex(vis.indexOf(items[active]), delta, vis.length)]);
263
+ setActive(vis[wrapIndex(vis.indexOf(activeItem), delta, vis.length)]);
220
264
  };
221
265
 
222
266
  const choose = (item) => {
@@ -234,7 +278,10 @@ export function initCommand({ root } = {}) {
234
278
  );
235
279
  };
236
280
 
237
- const onInput = () => filter();
281
+ const onInput = () => {
282
+ if (headless) queryChanged = true;
283
+ else filter();
284
+ };
238
285
  const onKey = (e) => {
239
286
  switch (e.key) {
240
287
  case 'ArrowDown':
@@ -246,8 +293,8 @@ export function initCommand({ root } = {}) {
246
293
  move(-1);
247
294
  break;
248
295
  case 'Enter':
249
- if (active >= 0 && !items[active].hidden) {
250
- choose(items[active]);
296
+ if (activeItem && activeItem.isConnected && !activeItem.hidden) {
297
+ choose(activeItem);
251
298
  e.preventDefault();
252
299
  }
253
300
  break;
@@ -259,21 +306,23 @@ export function initCommand({ root } = {}) {
259
306
  }
260
307
  };
261
308
  const onClick = (e) => {
262
- const item = closestSafe(e.target, '.ui-command__item, [role="option"]');
309
+ const item = closestSafe(e.target, ITEM);
263
310
  if (item && list.contains(item)) choose(item);
264
311
  };
265
312
 
266
313
  const bound = bindOnce(box, 'command', () => {
267
- const state = rememberState();
314
+ touch(input, [
315
+ 'role',
316
+ 'aria-controls',
317
+ 'aria-autocomplete',
318
+ 'aria-expanded',
319
+ 'aria-activedescendant',
320
+ 'autocomplete',
321
+ ]);
322
+ touch(list, ['id', 'role']);
268
323
  const listId = list.id || (list.id = `bronto-cmd-${nextFieldUid()}`);
269
- const optionIdBase = `bronto-cmd-opt-${nextFieldUid()}`;
270
- items.forEach((it, i) => {
271
- if (!it.id) it.id = `${optionIdBase}-${i}`;
272
- it.setAttribute('role', 'option');
273
- });
274
- groups.forEach((g) => g.setAttribute('role', 'presentation'));
324
+ prepareRows();
275
325
  list.setAttribute('role', 'listbox');
276
- prepareEmptyState(empty);
277
326
  input.setAttribute('role', 'combobox');
278
327
  input.setAttribute('aria-controls', listId);
279
328
  input.setAttribute('aria-autocomplete', 'list');
@@ -282,14 +331,31 @@ export function initCommand({ root } = {}) {
282
331
  input.addEventListener('input', onInput);
283
332
  input.addEventListener('keydown', onKey);
284
333
  list.addEventListener('click', onClick);
285
- // Seed the initial active item (first visible).
286
- filter();
334
+ let observer = null;
335
+ if (headless) {
336
+ // The palette's own window, so a palette in another realm still observes.
337
+ const Observer = box.ownerDocument?.defaultView?.MutationObserver;
338
+ observer = Observer ? new Observer(onRowsChanged) : null;
339
+ observer?.observe(box, {
340
+ childList: true,
341
+ subtree: true,
342
+ attributes: true,
343
+ attributeFilter: ['hidden'],
344
+ });
345
+ const empty = emptyNow();
346
+ emptyShown = Boolean(empty && !empty.hidden);
347
+ setActive(visible()[0] || null);
348
+ } else {
349
+ // Seed the initial active item (first visible).
350
+ filter();
351
+ }
287
352
  return () => {
353
+ observer?.disconnect();
288
354
  input.removeEventListener('input', onInput);
289
355
  input.removeEventListener('keydown', onKey);
290
356
  list.removeEventListener('click', onClick);
291
- restoreState(state);
292
- active = -1;
357
+ restore();
358
+ activeItem = null;
293
359
  };
294
360
  });
295
361
  cleanups.push(bound);
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$comment": "@ponchia/ui class vocabulary as language-neutral data — validate emitted markup without executing the ESM cls map or parsing the .d.ts. Generated from classes/index.js — do not edit by hand; run `npm run classes:json:build`. Drift-checked in CI. `groups[].base` is null for a parts-only namespace (no standalone base class — do NOT emit it). A modifier whose name contains `__` (e.g. `ui-spark__bar--pos`) attaches to THAT part, not the base host. `states` is the author-applied `is-*` hooks (runtime/behavior-managed hooks are excluded); `behaviorAttributes` are the `data-bronto-*` wiring hooks the optional behaviors delegate on; `requiredAria` is the role/aria a generator must emit per component. `states` + `customProperties` are documented in docs/reference.md and ship outside `cls` by design.",
3
3
  "counts": {
4
- "classes": 696,
5
- "groups": 187
4
+ "classes": 700,
5
+ "groups": 188
6
6
  },
7
7
  "groups": {
8
8
  "ui-accordion": {
@@ -23,6 +23,7 @@
23
23
  "base": "ui-alert",
24
24
  "modifiers": [
25
25
  "ui-alert--accent",
26
+ "ui-alert--band",
26
27
  "ui-alert--danger",
27
28
  "ui-alert--info",
28
29
  "ui-alert--success",
@@ -195,6 +196,14 @@
195
196
  ],
196
197
  "parts": []
197
198
  },
199
+ "ui-body-state": {
200
+ "base": "ui-body-state",
201
+ "modifiers": [
202
+ "ui-body-state--error",
203
+ "ui-body-state--stale"
204
+ ],
205
+ "parts": []
206
+ },
198
207
  "ui-bracket-note": {
199
208
  "base": "ui-bracket-note",
200
209
  "modifiers": [
@@ -1604,6 +1613,7 @@
1604
1613
  "ui-activity",
1605
1614
  "ui-alert",
1606
1615
  "ui-alert--accent",
1616
+ "ui-alert--band",
1607
1617
  "ui-alert--danger",
1608
1618
  "ui-alert--info",
1609
1619
  "ui-alert--success",
@@ -1685,6 +1695,9 @@
1685
1695
  "ui-badge--muted",
1686
1696
  "ui-badge--success",
1687
1697
  "ui-badge--warning",
1698
+ "ui-body-state",
1699
+ "ui-body-state--error",
1700
+ "ui-body-state--stale",
1688
1701
  "ui-bracket-note",
1689
1702
  "ui-bracket-note--accent",
1690
1703
  "ui-bracket-note--danger",
@@ -133,6 +133,10 @@ export declare const cls: {
133
133
  readonly alertWarning: 'ui-alert--warning';
134
134
  readonly alertDanger: 'ui-alert--danger';
135
135
  readonly alertInfo: 'ui-alert--info';
136
+ readonly alertBand: 'ui-alert--band';
137
+ readonly bodyState: 'ui-body-state';
138
+ readonly bodyStateError: 'ui-body-state--error';
139
+ readonly bodyStateStale: 'ui-body-state--stale';
136
140
  readonly toastStack: 'ui-toast-stack';
137
141
  readonly toastStackAssertive: 'ui-toast-stack--assertive';
138
142
  readonly toast: 'ui-toast';
@@ -723,6 +727,10 @@ export interface ButtonOpts {
723
727
  export interface EmptyStateOpts {
724
728
  invite?: boolean;
725
729
  }
730
+ export interface BodyStateOpts {
731
+ /** Omit for empty and loading (mark a loading region aria-busy). */
732
+ state?: 'error' | 'stale';
733
+ }
726
734
  export interface ToolstripOpts {
727
735
  variant?: 'floating' | 'compact' | 'pane';
728
736
  anchor?: 'block-start' | 'block-end';
@@ -796,6 +804,7 @@ export interface StaggerOpts {
796
804
  export type Tone = 'accent' | 'success' | 'warning' | 'danger' | 'info';
797
805
  export interface AlertOpts {
798
806
  tone?: Tone;
807
+ band?: boolean;
799
808
  }
800
809
  export interface ToastOpts {
801
810
  tone?: Tone;
@@ -1015,6 +1024,7 @@ export interface Ui {
1015
1024
  job(opts?: JobOpts): string;
1016
1025
  originLabel(opts?: OriginLabelOpts): string;
1017
1026
  emptyState(opts?: EmptyStateOpts): string;
1027
+ bodyState(opts?: BodyStateOpts): string;
1018
1028
  toolstrip(opts?: ToolstripOpts): string;
1019
1029
  selectionbar(opts?: SelectionbarOpts): string;
1020
1030
  }