@ponchia/ui 0.12.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 +91 -0
- package/README.md +4 -4
- package/behaviors/command.d.ts +34 -13
- package/behaviors/command.d.ts.map +1 -1
- package/behaviors/command.js +195 -129
- package/classes/classes.json +17 -2
- package/classes/index.d.ts +12 -0
- package/classes/index.js +14 -2
- package/classes/vscode.css-custom-data.json +64 -0
- package/css/base.css +2 -2
- package/css/blocknote.css +61 -0
- package/css/clamp.css +2 -2
- package/css/content.css +160 -1
- package/css/disclosure.css +10 -10
- package/css/feedback.css +95 -3
- package/css/fonts-inter.css +35 -0
- package/css/fonts-jetbrains-mono.css +49 -0
- package/css/forms.css +8 -8
- package/css/primitives.css +5 -6
- package/css/row.css +2 -2
- package/css/site.css +0 -31
- package/css/term.css +2 -2
- package/css/textref.css +2 -2
- package/css/toc.css +2 -2
- package/css/tokens.css +43 -0
- package/css/tool.css +29 -0
- package/css/workbench.css +2 -2
- package/dist/bronto.css +1 -1
- package/dist/css/base.css +1 -1
- package/dist/css/blocknote.css +1 -0
- package/dist/css/clamp.css +1 -1
- package/dist/css/content.css +1 -1
- package/dist/css/disclosure.css +1 -1
- package/dist/css/feedback.css +1 -1
- package/dist/css/fonts-inter.css +1 -0
- package/dist/css/fonts-jetbrains-mono.css +1 -0
- package/dist/css/forms.css +1 -1
- package/dist/css/report-kit.css +1 -1
- package/dist/css/row.css +1 -1
- package/dist/css/site.css +1 -1
- package/dist/css/term.css +1 -1
- package/dist/css/textref.css +1 -1
- package/dist/css/toc.css +1 -1
- package/dist/css/tokens.css +1 -1
- package/dist/css/tool.css +1 -0
- package/dist/css/workbench.css +1 -1
- package/docs/command.md +47 -7
- package/docs/compositions.md +2 -2
- package/docs/interop/blocknote.md +60 -0
- package/docs/package-contract.md +11 -2
- package/docs/reference.md +27 -1
- package/docs/reporting.md +8 -8
- package/docs/stability.md +5 -2
- package/docs/theming.md +56 -2
- package/docs/usage.md +55 -0
- package/fonts/OFL-Inter.txt +92 -0
- package/fonts/OFL-JetBrainsMono.txt +93 -0
- package/fonts/inter-variable-italic.woff2 +0 -0
- package/fonts/inter-variable.woff2 +0 -0
- package/fonts/jetbrains-mono-400-italic.woff2 +0 -0
- package/fonts/jetbrains-mono-400.woff2 +0 -0
- package/fonts/jetbrains-mono-700-italic.woff2 +0 -0
- package/fonts/jetbrains-mono-700.woff2 +0 -0
- package/llms.txt +1 -1
- package/package.json +18 -10
- package/tokens/figma.variables.json +240 -0
- package/tokens/index.d.ts +2 -2
- package/tokens/index.js +30 -2
- package/tokens/index.json +32 -0
- package/tokens/resolved.json +17 -1
- package/tokens/tokens.dtcg.json +190 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,97 @@
|
|
|
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
|
+
|
|
48
|
+
## 0.13.0 — 2026-10-01
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **`ui-prose--blocks`: prose on a block editor's geometry.** A read view that
|
|
53
|
+
must put every line where a block editor (BlockNote's model) draws it: 3px
|
|
54
|
+
block padding instead of flow margins, line-height 1.5, headings with an 18px
|
|
55
|
+
lead and a 1.6/1.3/1.15em scale at weight 600, a 24px list column with disc,
|
|
56
|
+
circle and square, and the editor's task-row geometry. The px are exposed as
|
|
57
|
+
`--prose-block`, `--prose-heading-lead` and `--prose-list-column`.
|
|
58
|
+
`recipes.prose({ blocks: true })` emits it.
|
|
59
|
+
- **`css/blocknote.css`: BlockNote interop.** Maps BlockNote's `--bn-*` theme
|
|
60
|
+
variables (editor, menus, tooltips, side menu, selection, border, font,
|
|
61
|
+
radius) to bronto tokens, and its nine text highlights to the categorical
|
|
62
|
+
identity inks and tints, so the editor follows theme, skins, contrast and the
|
|
63
|
+
OLED surface. Import the unlayered build after BlockNote's stylesheet. See
|
|
64
|
+
[BlockNote interop](docs/interop/blocknote.md).
|
|
65
|
+
- **Half spacing steps for dense tool chrome.** `--space-0-5`, `--space-0-75`,
|
|
66
|
+
`--space-1-5` and `--space-2-5` (2, 3, 6 and 10px at a 16px root), scaled by
|
|
67
|
+
the density presets with the rest of the scale.
|
|
68
|
+
- **Workspace layers.** `--z-canvas`, `--z-chrome`, `--z-panel`, `--z-modal`,
|
|
69
|
+
`--z-menu`, `--z-tooltip` and `--z-navigation` name the stack of a tool drawn
|
|
70
|
+
over a canvas, aliased to the page layers where they mean the same.
|
|
71
|
+
- **Zoom-aware hairlines and focus rings.** A host that scales bronto UI marks
|
|
72
|
+
the scaled element `[data-ui-zoom]` and sets `--ui-zoom`; inside it `--ui-px`
|
|
73
|
+
is one screen pixel and `--hairline`, `--focus-ring-width` and
|
|
74
|
+
`--focus-ring-offset` are re-declared in it.
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
|
|
78
|
+
- Every bronto focus ring reads `--focus-ring-width` and `--focus-ring-offset`
|
|
79
|
+
instead of literal px. At the default zoom they are the same 2px, so nothing
|
|
80
|
+
moves; inside a `[data-ui-zoom]` surface a ring keeps its on-screen width.
|
|
81
|
+
- The default bundle grows from 92.8 kB / 16.0 kB gzip to 95.8 kB / 16.6 kB:
|
|
82
|
+
1.9 kB is the block prose variant, the rest the new tokens and the focus-ring
|
|
83
|
+
variables. Notes are now the main reading surface, so block prose ships in
|
|
84
|
+
the core prose vocabulary rather than a leaf.
|
|
85
|
+
|
|
86
|
+
### Internal
|
|
87
|
+
|
|
88
|
+
- `npm run check` runs every gate and ends with one line naming each gate
|
|
89
|
+
that failed; `npm run check -- --bail` stops at the first. It was an `&&`
|
|
90
|
+
chain that stopped at the first failure: since 0.8, 13 of the 20 failed CI
|
|
91
|
+
runs were the Dependabot dev-dependency group stopping at `check:exports`
|
|
92
|
+
on TypeScript 7, so no run measured the rest of the group. The gate list is
|
|
93
|
+
the `check:*` scripts themselves, so `check:chain`, which checked that the
|
|
94
|
+
hand-kept chain named every gate, is gone.
|
|
95
|
+
- The package contract no longer lists `react/`, `solid/`, `qwik/`,
|
|
96
|
+
`svelte/` and `vue/` as authored JS directories; the package has none. The
|
|
97
|
+
list is now read from `tsconfig.dts.json`.
|
|
98
|
+
|
|
8
99
|
## 0.12.0 — 2026-10-01
|
|
9
100
|
|
|
10
101
|
### Changed
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@ponchia/ui)
|
|
4
4
|
[](https://www.npmjs.com/package/@ponchia/ui#provenance)
|
|
5
5
|
[](https://github.com/Ponchia/bronto-ui/blob/main/package.json)
|
|
6
|
-
[](https://github.com/Ponchia/bronto-ui/blob/main/scripts/check-dist.mjs)
|
|
7
7
|
[](https://github.com/Ponchia/bronto-ui/actions/workflows/ci.yml)
|
|
8
8
|
[](https://scorecard.dev/viewer/?uri=github.com/Ponchia/bronto-ui)
|
|
9
9
|
[](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.
|
|
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 (~
|
|
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
|
|
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`).
|
package/behaviors/command.d.ts
CHANGED
|
@@ -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,
|
|
19
|
-
* empty groups), keyboard list
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* instance; returns
|
|
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 {
|
|
24
|
+
* @param {CommandOpts} [opts]
|
|
28
25
|
* @returns {import('./internal.js').Cleanup}
|
|
29
26
|
*/
|
|
30
|
-
export function initCommand({ root }?:
|
|
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":"
|
|
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"}
|
package/behaviors/command.js
CHANGED
|
@@ -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,
|
|
49
|
-
* empty groups), keyboard list
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* instance; returns
|
|
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 {
|
|
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
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
167
|
-
const visible = () =>
|
|
187
|
+
let activeItem = null;
|
|
188
|
+
const visible = () => itemsNow().filter((it) => !it.hidden);
|
|
168
189
|
|
|
169
190
|
const setActive = (item) => {
|
|
170
|
-
|
|
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
|
|
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(
|
|
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
|
|
203
|
-
const
|
|
204
|
-
it.hidden = !
|
|
205
|
-
if (
|
|
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
|
-
|
|
213
|
-
|
|
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(
|
|
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 = () =>
|
|
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 (
|
|
250
|
-
choose(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
357
|
+
restore();
|
|
358
|
+
activeItem = null;
|
|
293
359
|
};
|
|
294
360
|
});
|
|
295
361
|
cleanups.push(bound);
|