@ponchia/ui 0.13.0 → 0.15.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +4 -4
  3. package/behaviors/command.d.ts +34 -13
  4. package/behaviors/command.d.ts.map +1 -1
  5. package/behaviors/command.js +195 -129
  6. package/classes/classes.json +19 -2
  7. package/classes/index.d.ts +16 -0
  8. package/classes/index.js +21 -2
  9. package/css/blocknote.css +14 -0
  10. package/css/content.css +34 -1
  11. package/css/feedback.css +95 -3
  12. package/css/fonts-inter.css +35 -0
  13. package/css/fonts-jetbrains-mono.css +49 -0
  14. package/css/primitives.css +13 -0
  15. package/css/site.css +0 -31
  16. package/css/tool.css +29 -0
  17. package/dist/bronto.css +1 -1
  18. package/dist/css/blocknote.css +1 -1
  19. package/dist/css/content.css +1 -1
  20. package/dist/css/feedback.css +1 -1
  21. package/dist/css/fonts-inter.css +1 -0
  22. package/dist/css/fonts-jetbrains-mono.css +1 -0
  23. package/dist/css/primitives.css +1 -1
  24. package/dist/css/site.css +1 -1
  25. package/dist/css/tool.css +1 -0
  26. package/docs/command.md +47 -7
  27. package/docs/compositions.md +2 -2
  28. package/docs/interop/blocknote.md +5 -0
  29. package/docs/package-contract.md +6 -1
  30. package/docs/reference.md +12 -1
  31. package/docs/reporting.md +8 -8
  32. package/docs/stability.md +4 -2
  33. package/docs/theming.md +8 -1
  34. package/docs/usage.md +55 -0
  35. package/fonts/OFL-Inter.txt +92 -0
  36. package/fonts/OFL-JetBrainsMono.txt +93 -0
  37. package/fonts/inter-variable-italic.woff2 +0 -0
  38. package/fonts/inter-variable.woff2 +0 -0
  39. package/fonts/jetbrains-mono-400-italic.woff2 +0 -0
  40. package/fonts/jetbrains-mono-400.woff2 +0 -0
  41. package/fonts/jetbrains-mono-700-italic.woff2 +0 -0
  42. package/fonts/jetbrains-mono-700.woff2 +0 -0
  43. package/llms.txt +1 -1
  44. package/package.json +7 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,66 @@
5
5
  |> `^0` / `*` wildcard does **not** protect you. See README → Versioning, and
6
6
  |> the deprecation policy in CONTRIBUTING.md.
7
7
 
8
+ ## 0.15.0 — 2026-10-02
9
+
10
+ ### Added
11
+
12
+ - **`ui-eyebrow--caps` and `ui-eyebrow--mono`**, the label voice of a tool
13
+ surface, taken from Spatial. `--caps` sets capitals on the wide tracking step,
14
+ for a label that names a region (a panel, a section, a gate); `--mono` sets the
15
+ mono face, for a label over data, code or a readout. Both compose with
16
+ `--muted` and `--sm`, and `ui.eyebrow({ caps, mono })` emits them. A tool that
17
+ restated this voice by hand can use the base eyebrow and keep only its spacing.
18
+
19
+ ### Changed
20
+
21
+ - **`css/blocknote.css` draws the nested-block guide in `--line`.** BlockNote
22
+ draws the rule beside a nested block in its side-menu colour, which the leaf
23
+ maps to `--text-dim` for the drag handle. In 0.13 that made the guide as dark as
24
+ a control: light mode went from BlockNote's `#cfcfcf` to `#686863`. The guide
25
+ now takes the border token and the handle keeps `--text-dim`. The rule mirrors
26
+ BlockNote 0.54's selector under `.bn-root`, and a test pins that shape.
27
+
28
+ ## 0.14.0 — 2026-10-01
29
+
30
+ ### Added
31
+
32
+ - **`initCommand({ match })` and `initCommand({ headless: true })`.** `match(row,
33
+ query)` replaces the substring test, so a row can match on keywords it does
34
+ not show, or stay visible for every query (a "Search everything" row inside
35
+ the list, where the keyboard reaches it). Headless mode leaves the result set
36
+ to the host: Bronto hides nothing and reads the list live, so the host can
37
+ render new rows on every keystroke without re-running `initCommand`, and
38
+ Bronto keeps ids, roles, the active row, the keyboard and the empty state's
39
+ live region. Cleanup restores rows added after init as well.
40
+ - **`@ponchia/ui/css/tool.css`**, the default bundle for a tool, imported
41
+ instead of `@ponchia/ui`. It is core in core's order without the
42
+ `navigation`, `site`, `table` and `app` leaves, about 15 kB less.
43
+ - **`css/fonts-inter.css` and `css/fonts-jetbrains-mono.css`**, opt-in leaves
44
+ that ship the faces `--sans` and `--mono` name first. Before, the package
45
+ shipped Doto only and every OS drew its own fallback. Inter 4.1 is one
46
+ variable face per style, and JetBrains Mono 2.304 comes in regular, bold and
47
+ their italics. Both are unmodified upstream files under the SIL OFL 1.1, with
48
+ their licenses in `fonts/`.
49
+ - **`ui-body-state` and `ui-alert--band`** for node, panel and card bodies of
50
+ 200–400px. A body state replaces the content: it fills the body and centres
51
+ one sentence. Use `ui.bodyState()` when empty or loading (`aria-busy` says
52
+ loading) and `ui.bodyState({ state: 'error' | 'stale' })` for a tone dot. A
53
+ band is a full-bleed, one-line alert above content the body still shows
54
+ (`ui.alert({ tone, band: true })`). See usage.md → "Small bodies".
55
+
56
+ ### Changed
57
+
58
+ - The default bundle grows from 95.8 kB / 16.6 kB gzip to 97.1 kB / 16.8 kB,
59
+ for the body state, the band and `--alert-tone`. `css/tool.css` is 81.8 kB /
60
+ 14.7 kB.
61
+ - Each `ui-alert--<tone>` also sets `--alert-tone`, which the band variant
62
+ tints with.
63
+ - `.ui-meta` (the date · author line) moved from `css/site.css` to
64
+ `css/content.css`, so the tool entry keeps it. The default bundle is
65
+ unchanged, since content follows site in its cascade. A direct import of
66
+ `css/site.css` that used `.ui-meta` also needs `css/content.css`.
67
+
8
68
  ## 0.13.0 — 2026-10-01
9
69
 
10
70
  ### 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.15.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": 702,
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": [
@@ -611,6 +620,8 @@
611
620
  "ui-eyebrow": {
612
621
  "base": "ui-eyebrow",
613
622
  "modifiers": [
623
+ "ui-eyebrow--caps",
624
+ "ui-eyebrow--mono",
614
625
  "ui-eyebrow--muted",
615
626
  "ui-eyebrow--sm"
616
627
  ],
@@ -1604,6 +1615,7 @@
1604
1615
  "ui-activity",
1605
1616
  "ui-alert",
1606
1617
  "ui-alert--accent",
1618
+ "ui-alert--band",
1607
1619
  "ui-alert--danger",
1608
1620
  "ui-alert--info",
1609
1621
  "ui-alert--success",
@@ -1685,6 +1697,9 @@
1685
1697
  "ui-badge--muted",
1686
1698
  "ui-badge--success",
1687
1699
  "ui-badge--warning",
1700
+ "ui-body-state",
1701
+ "ui-body-state--error",
1702
+ "ui-body-state--stale",
1688
1703
  "ui-bracket-note",
1689
1704
  "ui-bracket-note--accent",
1690
1705
  "ui-bracket-note--danger",
@@ -1878,6 +1893,8 @@
1878
1893
  "ui-evidence-item__window",
1879
1894
  "ui-evidence-ledger",
1880
1895
  "ui-eyebrow",
1896
+ "ui-eyebrow--caps",
1897
+ "ui-eyebrow--mono",
1881
1898
  "ui-eyebrow--muted",
1882
1899
  "ui-eyebrow--sm",
1883
1900
  "ui-field",