@apliteni/apliteni-ui 0.27.0 → 0.31.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/package.json +1 -1
  2. package/react/README.md +49 -1
  3. package/react/dist/index.css +11 -1
  4. package/react/dist/index.d.ts +61 -2
  5. package/react/dist/index.js +514 -121
  6. package/src/components/back.js +57 -0
  7. package/src/components/command-palette.js +597 -0
  8. package/src/components/confirm.js +3 -2
  9. package/src/components/drawer.js +31 -2
  10. package/src/components/dropdown.js +192 -17
  11. package/src/components/feedback.js +2 -2
  12. package/src/components/index.js +5 -2
  13. package/src/components/loading.js +3 -2
  14. package/src/components/nav.js +15 -7
  15. package/src/components/overlay.js +67 -14
  16. package/src/components/shell.js +11 -2
  17. package/src/components/tabs.js +7 -1
  18. package/src/components/tooltip.js +237 -0
  19. package/src/components/topbar.js +9 -4
  20. package/src/index.css +3 -0
  21. package/src/index.js +3 -0
  22. package/src/inline.js +6 -0
  23. package/src/motion.js +43 -2
  24. package/src/styles/back.css +42 -0
  25. package/src/styles/badge.css +5 -7
  26. package/src/styles/base.css +8 -7
  27. package/src/styles/card.css +12 -8
  28. package/src/styles/code.css +3 -4
  29. package/src/styles/command-palette.css +321 -0
  30. package/src/styles/confirm.css +11 -11
  31. package/src/styles/drawer.css +57 -15
  32. package/src/styles/dropdown.css +70 -11
  33. package/src/styles/feedback.css +2 -0
  34. package/src/styles/footer.css +3 -4
  35. package/src/styles/input.css +1 -1
  36. package/src/styles/layout.css +3 -2
  37. package/src/styles/loading.css +5 -0
  38. package/src/styles/nav.css +12 -8
  39. package/src/styles/success.css +3 -2
  40. package/src/styles/table.css +4 -5
  41. package/src/styles/tabs.css +3 -0
  42. package/src/styles/tooltip.css +65 -0
  43. package/src/styles/topbar.css +3 -4
  44. package/src/tokens/tokens.css +8 -8
@@ -0,0 +1,57 @@
1
+ // Back link — the control that takes a reader from a page up to the page it sits
2
+ // under, as an HTML string.
3
+ //
4
+ // It is a link to an address the caller names, never a script that walks the
5
+ // browser's history. A page opened in a new tab, from a bookmark or from a shared
6
+ // address has no history to walk, and the browser's own Back button already does
7
+ // that job; an <a href> is also the one shape a reader can open in a new tab.
8
+ //
9
+ // It has one treatment and takes no variant: a chevron and the destination's
10
+ // name in dim ink, with no box. Four were rendered side by side on
11
+ // docs/reviews/270-back-control.html and the owner chose this one, so the other
12
+ // three — a bordered button above the title, an icon-only arrow beside it, and
13
+ // the breadcrumb trail alone — are not built here.
14
+ // why: docs/specification.md#the-back-link
15
+ import { esc, icon } from './index.js';
16
+
17
+ // "Back" names a direction rather than a place. It is what a caller who names no
18
+ // destination gets, and the one label that is not spelled out as "Back to …".
19
+ const BARE = 'Back';
20
+
21
+ // A `javascript:` address is the history walk this component replaces, arriving
22
+ // through the one parameter it has. Before a browser reads the scheme it strips
23
+ // C0 controls and spaces from the front and removes every tab, LF and CR wherever
24
+ // they sit, so "java\tscript:" is still javascript:. The check reads the address the
25
+ // same way (WHATWG URL Standard, basic URL parser).
26
+ const SCRIPTED = /^javascript:/i;
27
+ const LEADING = /^[\u0000-\u0020]+/;
28
+ const TAB_OR_NEWLINE = /[\t\n\r]/g;
29
+
30
+ // A label that already says "Back to Invoices" names the place after those words, or the
31
+ // link would be read as "Back to Back to Invoices". The whole phrase, since "Backups" and
32
+ // "Back office" are places too. why: docs/specification.md#the-back-link
33
+ const SAID = /^back\s+to(?:\s+|$)/i;
34
+
35
+ const text = (v) => (typeof v === 'string' || typeof v === 'number' ? String(v).trim() : '');
36
+
37
+ /**
38
+ * backLink({ href, label }) → the link a page under another page puts above its
39
+ * title.
40
+ *
41
+ * `label` is the destination's name, spelled the way the sidebar or the trail
42
+ * spells it. The arrow says "back" on screen and is aria-hidden, so the link's
43
+ * accessible name says it in words: "Back to Invoices". That name still contains
44
+ * the visible text, which is what WCAG 2.5.3 asks of a named control.
45
+ *
46
+ * No address, no link: a back control with nowhere to go renders nothing.
47
+ */
48
+ export function backLink({ href, label } = {}) {
49
+ const to = text(href);
50
+ if (!to || SCRIPTED.test(to.replace(LEADING, '').replace(TAB_OR_NEWLINE, ''))) return '';
51
+ const name = text(label).replace(SAID, '');
52
+ const bare = !name || name.toLowerCase() === BARE.toLowerCase();
53
+ const shown = bare ? BARE : name;
54
+ const named = bare ? '' : ` aria-label="${esc(`${BARE} to ${name}`)}"`;
55
+ return `<a class="ui-back" href="${esc(to)}"${named}>${icon('chevronLeft')}`
56
+ + `<span class="ui-back__label">${esc(shown)}</span></a>`;
57
+ }
@@ -0,0 +1,597 @@
1
+ // Command palette — one overlay, a text box and a ranked list of things to run
2
+ // or go to.
3
+ //
4
+ // container.innerHTML = commandPalette({ groups });
5
+ // wireCommandPalette(container); // hotkey, ranking, keys, Esc, focus
6
+ //
7
+ // The kit ships the shell and the ranking and names no result kinds; a row goes
8
+ // somewhere, runs something, or asks a confirm first. Inertness, Escape and the
9
+ // focus trap come from ./overlay.js, the stack the drawer and the confirm share.
10
+ // why: docs/specification.md#the-command-palette
11
+ import { esc, icon } from './index.js';
12
+ import { OVERLAY_LAYER, adoptOverlay, popOverlay, pushOverlay, returnFocus, syncOverlays } from './overlay.js';
13
+
14
+ const cx = (...a) => a.filter(Boolean).join(' ');
15
+
16
+ let _uid = 0;
17
+ const nextId = (p = 'cmdk') => `${p}-${++_uid}`;
18
+
19
+ /**
20
+ * How a match is worth more than another match.
21
+ *
22
+ * Six ways a query can meet an item, scored so that the whole beats the part
23
+ * and the name beats the note. The numbers are a ladder and not a measurement —
24
+ * what matters is the order, which is why src/components/command-palette.test.js
25
+ * asserts the ORDER of the results rather than any of these values.
26
+ *
27
+ * cmdk's command-score is the other published answer: one continuous score
28
+ * built out of SCORE_CONTINUE_MATCH 1, SCORE_SPACE_WORD_JUMP 0.9,
29
+ * SCORE_NON_SPACE_WORD_JUMP 0.8 and SCORE_CHARACTER_JUMP 0.17. It ranks
30
+ * beautifully and cannot be explained to a reader who asks why their item is
31
+ * third. A ladder can: the kit's palettes hold tens of items, not thousands.
32
+ */
33
+ export const SCORE = {
34
+ exact: 100,
35
+ prefix: 90,
36
+ wordStart: 80,
37
+ contains: 70,
38
+ keyword: 60,
39
+ description: 40,
40
+ subsequence: 20,
41
+ };
42
+
43
+ const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
44
+
45
+ // Every character of `q`, in order, somewhere in `s` — "nc" finding "New
46
+ // campaign". The weakest match the kit accepts, and the only one that can pair
47
+ // a two-letter query with a twenty-letter label.
48
+ function subsequence(s, q) {
49
+ let at = 0;
50
+ for (const ch of q) {
51
+ at = s.indexOf(ch, at);
52
+ if (at === -1) return false;
53
+ at += 1;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ // A word here starts after a space or one of the separators a product name uses,
59
+ // so "camp" reaches the second word of "New campaign" and the "utm" in
60
+ // "url-utm_source".
61
+ const wordStarts = (s) => s.split(/[\s/\-_.:,]+/).filter(Boolean);
62
+
63
+ function scoreField({ label, description, keywords }, q) {
64
+ const name = norm(label);
65
+ if (!name && !description && !(keywords || []).length) return 0;
66
+ if (name === q) return SCORE.exact;
67
+ if (name.startsWith(q)) return SCORE.prefix;
68
+ if (wordStarts(name).some((w) => w.startsWith(q))) return SCORE.wordStart;
69
+ if (name.includes(q)) return SCORE.contains;
70
+ for (const k of keywords || []) {
71
+ const key = norm(k);
72
+ if (key === q || key.startsWith(q)) return SCORE.keyword;
73
+ }
74
+ if (norm(description).includes(q)) return SCORE.description;
75
+ if (subsequence(name, q)) return SCORE.subsequence;
76
+ return 0;
77
+ }
78
+
79
+ /**
80
+ * What one item is worth against one query. 0 means it is not a result.
81
+ *
82
+ * An empty query scores everything the same, so a palette nobody has typed into
83
+ * shows what the caller passed, in the caller's order — which is where a
84
+ * product puts the four things somebody actually does here.
85
+ *
86
+ * A query with a space in it has to match as a whole OR token by token, every
87
+ * token landing somewhere: "new camp" reaches "New campaign" as a whole, and
88
+ * "camp new" reaches it one token at a time. The weakest token decides, because
89
+ * a result is only as good as the part of the query it answers worst.
90
+ */
91
+ export function scoreCommand(item, query) {
92
+ const q = norm(query);
93
+ if (!q) return 1;
94
+ const whole = scoreField(item, q);
95
+ const tokens = q.split(' ');
96
+ if (tokens.length < 2) return whole;
97
+ let weakest = Infinity;
98
+ for (const t of tokens) {
99
+ const s = scoreField(item, t);
100
+ if (!s) { weakest = 0; break; }
101
+ weakest = Math.min(weakest, s);
102
+ }
103
+ return Math.max(whole, weakest === Infinity ? 0 : weakest);
104
+ }
105
+
106
+ /**
107
+ * The items that answer `query`, best first.
108
+ *
109
+ * Ties keep the caller's order — that is the whole of the kit's ordering
110
+ * opinion, and it is what lets a product put its four most-used commands at the
111
+ * top of the list it passes and have them stay there.
112
+ */
113
+ export function rankCommands(items, query) {
114
+ return (items || [])
115
+ .map((item, i) => ({ item, i, score: scoreCommand(item, query) }))
116
+ .filter((r) => r.score > 0)
117
+ .sort((a, b) => b.score - a.score || a.i - b.i)
118
+ .map((r) => r.item);
119
+ }
120
+
121
+ /**
122
+ * The groups that answer `query`, best group first, each holding its own ranked
123
+ * rows and nothing that scored zero.
124
+ *
125
+ * A group is carried by its best row, and the caller's order breaks the tie at
126
+ * both levels — so a palette nobody has typed into is exactly the list that was
127
+ * passed in. cmdk ranks groups the same way; the tie-break is the kit's, and it
128
+ * is what keeps a heading from moving under a reader who has typed nothing.
129
+ */
130
+ export function rankGroups(groups, query) {
131
+ return (groups || [])
132
+ .map((g, i) => {
133
+ const items = rankCommands(g.items || [], query);
134
+ const best = items.length ? scoreCommand(items[0], query) : 0;
135
+ return { group: { ...g, items }, i, best };
136
+ })
137
+ .filter((g) => g.group.items.length > 0)
138
+ .sort((a, b) => b.best - a.best || a.i - b.i)
139
+ .map((g) => g.group);
140
+ }
141
+
142
+ /** The label for the key that opens it, on the platform the caller is on. */
143
+ export function paletteHotkey(platform = typeof navigator === 'undefined' ? '' : navigator.platform) {
144
+ return /mac|iphone|ipad|ipod/i.test(String(platform || '')) ? '⌘K' : 'Ctrl K';
145
+ }
146
+
147
+ // The three fields a query is matched against, written onto the row so the
148
+ // wiring can re-rank what is already rendered without being handed the data a
149
+ // second time. Three attributes and not one blob: the ranking weighs a name
150
+ // above a note, and a row carrying `label description keywords` in one string
151
+ // would have its description scored as if it were its name.
152
+ function searchAttrs(it) {
153
+ const keywords = (it.keywords || []).map(norm).filter(Boolean).join('|');
154
+ return [
155
+ `data-label="${esc(norm(it.label))}"`,
156
+ keywords ? `data-keywords="${esc(keywords)}"` : '',
157
+ it.description ? `data-desc="${esc(norm(it.description))}"` : '',
158
+ ].filter(Boolean).join(' ');
159
+ }
160
+
161
+ /** The fields back out of a rendered row, in the shape scoreCommand() takes. */
162
+ const fieldsOf = (el) => ({
163
+ label: el.dataset.label || '',
164
+ keywords: el.dataset.keywords ? el.dataset.keywords.split('|') : [],
165
+ description: el.dataset.desc || '',
166
+ });
167
+
168
+ // A shortcut hint: 'g then i' or ['⌘', 'K'] or '⌘K'. Rendered as <kbd>, which is
169
+ // what it is, and never as part of the item's accessible name — the row already
170
+ // says what it does, and a screen reader reading "G then I" after every label is
171
+ // noise a sighted reader can simply skip.
172
+ function keysFor(shortcut) {
173
+ if (!shortcut) return '';
174
+ const keys = Array.isArray(shortcut) ? shortcut : [shortcut];
175
+ return `<span class="ui-cmdk__keys" aria-hidden="true">`
176
+ + keys.map((k) => `<kbd class="ui-cmdk__key">${esc(k)}</kbd>`).join('')
177
+ + '</span>';
178
+ }
179
+
180
+ /**
181
+ * One result row.
182
+ *
183
+ * Always a <div role="option">, never a link or a button: an option in a listbox
184
+ * is reached with an arrow key and never with Tab, and both of the other tags
185
+ * put every row into the page's tab order, where forty of them stand between a
186
+ * reader and the rest of the palette. `href` is carried in data- and followed by
187
+ * the wiring, which is also what makes Cmd+Enter open it in a tab.
188
+ *
189
+ * A destructive item that names no confirm is rendered DISABLED. The kit's rule
190
+ * is that a delete asks first (docs/specification.md#the-command-palette), and a
191
+ * palette is the one surface where a reader is typing fast and choosing from a
192
+ * list that reorders under them.
193
+ */
194
+ function paletteItem(it, uid, index, active) {
195
+ const disabled = isRefused(it);
196
+ const lead = it.icon ? `<span class="ui-cmdk__ic">${icon(it.icon)}</span>` : '';
197
+ const desc = it.description
198
+ ? `<span class="ui-cmdk__desc">${esc(it.description)}</span>` : '';
199
+ const attrs = [
200
+ // `is-danger` only while the row is actually offered: a destructive command
201
+ // the palette refuses to run is an unavailable row, not a dangerous one, and
202
+ // painting it in the danger signal would spend that signal on something
203
+ // nothing can press.
204
+ `class="${cx('ui-cmdk__item', it.danger && !disabled && 'is-danger', disabled && 'is-disabled', active && 'is-active')}"`,
205
+ 'role="option"',
206
+ 'tabindex="-1"',
207
+ `id="${uid}-o${index}"`,
208
+ 'data-cmdk-item',
209
+ `data-i="${index}"`,
210
+ searchAttrs(it),
211
+ it.id != null ? `data-id="${esc(it.id)}"` : '',
212
+ it.href && !disabled ? `data-href="${esc(it.href)}"` : '',
213
+ it.confirm && !disabled ? `data-confirm-open="${esc(it.confirm)}"` : '',
214
+ it.confirm && !disabled ? 'aria-haspopup="dialog"' : '',
215
+ disabled ? 'aria-disabled="true"' : '',
216
+ `aria-selected="${active ? 'true' : 'false'}"`,
217
+ ].filter(Boolean).join(' ');
218
+ const badge = it.badge ? `<span class="ui-cmdk__badge">${esc(it.badge)}</span>` : '';
219
+ return `<div ${attrs}>${lead}`
220
+ + `<span class="ui-cmdk__main"><span class="ui-cmdk__label">${esc(it.label)}</span>${desc}</span>`
221
+ + `${badge}${keysFor(it.shortcut)}</div>`;
222
+ }
223
+
224
+ /**
225
+ * A destructive item that names no confirm is refused: the kit's rule is that a
226
+ * delete asks first, and the palette is the one surface where a reader is
227
+ * typing fast and choosing from a list that reorders under them.
228
+ */
229
+ const isRefused = (it) => !!it.disabled || (!!it.danger && !it.confirm);
230
+
231
+ /**
232
+ * Where the active row starts: the first row Enter could run.
233
+ *
234
+ * The markup carries it, rather than waiting for the wiring to add it, so a
235
+ * palette rendered open by a server already says which row Enter answers — and
236
+ * the React component, which has no wiring step at all, renders the same thing.
237
+ */
238
+ function firstEnabledIndex(groups) {
239
+ let n = 0;
240
+ for (const g of groups || []) {
241
+ for (const it of g.items || []) {
242
+ if (!isRefused(it)) return n;
243
+ n += 1;
244
+ }
245
+ }
246
+ return -1;
247
+ }
248
+
249
+ /**
250
+ * The inside of the listbox: the groups, in the caller's order.
251
+ *
252
+ * Exported on its own because a palette fed by a server re-renders THIS and not
253
+ * the shell around it — the input keeps its value, its focus and its caret.
254
+ */
255
+ export function commandPaletteList(groups = [], { uid = nextId(), from = 0 } = {}) {
256
+ let n = from;
257
+ const activeAt = firstEnabledIndex(groups) + from;
258
+ return groups.map((g, gi) => {
259
+ const items = (g.items || []);
260
+ if (!items.length) return '';
261
+ const headId = g.label ? `${uid}-g${gi}` : null;
262
+ const head = headId
263
+ ? `<div class="ui-cmdk__group-head" id="${headId}">${esc(g.label)}</div>` : '';
264
+ const rows = items.map((it) => paletteItem(it, uid, n, n++ === activeAt)).join('');
265
+ return `<div class="ui-cmdk__group" role="group" data-cmdk-group data-i="${gi}"`
266
+ + `${headId ? ` aria-labelledby="${headId}"` : ''}>${head}${rows}</div>`;
267
+ }).join('');
268
+ }
269
+
270
+ /**
271
+ * The public factory. Returns an HTML string; wire it with wireCommandPalette().
272
+ *
273
+ * `specimen` renders it open as a PICTURE — same markup, minus the data-cmdk
274
+ * hook and aria-modal — so a documentation page can show three at once without
275
+ * any of them owning the page's keyboard. Use `open` when it is real.
276
+ *
277
+ * @param {object} [o]
278
+ * @param {Array} [o.groups] [{ label, items: [{ id, label, description, icon,
279
+ * keywords, shortcut, badge, href, confirm, danger,
280
+ * disabled }] }]
281
+ * @param {Array} [o.items] a flat list — the same thing as one unlabelled group
282
+ * @param {string} [o.label] accessible name of the dialog and the text box
283
+ * @param {string} [o.placeholder] the text box's placeholder
284
+ * @param {string} [o.query] the query it renders with
285
+ * @param {string} [o.empty] what it says when nothing matches
286
+ * @param {string} [o.density] 'compact' (default) | 'roomy'
287
+ * @param {boolean} [o.hint] draw the key legend along the bottom
288
+ * @param {boolean} [o.rank] false → the caller ranks, and is asked for results
289
+ * @param {boolean} [o.hotkey] false → this one does not answer Cmd/Ctrl+K
290
+ * @param {boolean} [o.open] render already-open, as a real dialog
291
+ * @param {boolean} [o.specimen] render open as a picture of the dialog
292
+ * @param {string} [o.id] root id a [data-cmdk-open] trigger targets
293
+ * @returns {string} html
294
+ */
295
+ export function commandPalette({
296
+ groups, items, label = 'Command palette',
297
+ placeholder = 'Search or run a command…', query = '',
298
+ empty = 'No matches', density = 'compact', hint = true,
299
+ rank = true, hotkey = true, open = false, specimen = false, id,
300
+ } = {}) {
301
+ const uid = nextId();
302
+ const given = groups && groups.length ? groups : [{ items: items || [] }];
303
+ // Rendered with a query in it, the factory ranks — so a palette a server drew
304
+ // against `?q=inv` and the same palette after a keystroke are the same list in
305
+ // the same order, and a specimen of a ranked palette is not a hand-arrangement.
306
+ const list = rank && query ? rankGroups(given, query) : given;
307
+ const rows = list.reduce((n, g) => n + (g.items || []).length, 0);
308
+ const activeAt = firstEnabledIndex(list);
309
+ const inputId = `${uid}-q`;
310
+ const listId = `${uid}-list`;
311
+
312
+ const inputAttrs = [
313
+ 'class="ui-cmdk__input"', 'type="text"', `id="${inputId}"`,
314
+ 'role="combobox"', 'aria-autocomplete="list"', 'aria-expanded="true"',
315
+ `aria-controls="${listId}"`, `aria-label="${esc(label)}"`,
316
+ `placeholder="${esc(placeholder)}"`, `value="${esc(query)}"`,
317
+ 'autocomplete="off"', 'autocorrect="off"', 'spellcheck="false"',
318
+ activeAt >= 0 ? `aria-activedescendant="${uid}-o${activeAt}"` : '',
319
+ 'data-cmdk-input',
320
+ ].filter(Boolean).join(' ');
321
+
322
+ const legend = hint
323
+ ? '<div class="ui-cmdk__foot" aria-hidden="true">'
324
+ + '<span><kbd class="ui-cmdk__key">↑</kbd><kbd class="ui-cmdk__key">↓</kbd> move</span>'
325
+ + '<span><kbd class="ui-cmdk__key">↵</kbd> run</span>'
326
+ + '<span><kbd class="ui-cmdk__key">esc</kbd> close</span>'
327
+ + '</div>'
328
+ : '';
329
+
330
+ const rootCls = cx('ui-cmdk', density === 'roomy' && 'ui-cmdk--roomy', (open || specimen) && 'is-open');
331
+ const rootAttrs = [
332
+ `class="${rootCls}"`,
333
+ specimen ? '' : 'data-cmdk',
334
+ rank ? '' : 'data-cmdk-rank="off"',
335
+ hotkey && !specimen ? 'data-cmdk-hotkey' : '',
336
+ id ? `id="${esc(id)}"` : '',
337
+ ].filter(Boolean).join(' ');
338
+
339
+ return `<div ${rootAttrs}>`
340
+ + '<div class="ui-cmdk__scrim" data-cmdk-scrim></div>'
341
+ + `<div class="ui-cmdk__panel" role="dialog"${specimen ? '' : ' aria-modal="true"'}`
342
+ + ` aria-label="${esc(label)}" tabindex="-1" data-cmdk-panel>`
343
+ + '<div class="ui-cmdk__search">'
344
+ + `<span class="ui-cmdk__search-ic" aria-hidden="true">${icon('search')}</span>`
345
+ + `<input ${inputAttrs}>`
346
+ + '</div>'
347
+ + `<div class="ui-cmdk__list" id="${listId}" role="listbox" aria-label="${esc(label)} results" data-cmdk-list>`
348
+ + commandPaletteList(list, { uid })
349
+ + '</div>'
350
+ + `<p class="ui-cmdk__empty" data-cmdk-empty${rows ? ' hidden' : ''}>${esc(empty)}</p>`
351
+ + '<p class="ui-sr" role="status" aria-live="polite" data-cmdk-status></p>'
352
+ + legend
353
+ + '</div></div>';
354
+ }
355
+
356
+ // ---- Shared behaviour ----------------------------------------------------
357
+ // Per-instance handlers attach once (guarded by a flag on the node); the
358
+ // document-level hotkey and [data-cmdk-open] delegation attach once per
359
+ // document. Safe to call repeatedly (Storybook re-renders).
360
+
361
+ const inputOf = (root) => root.querySelector('[data-cmdk-input]');
362
+ const listOf = (root) => root.querySelector('[data-cmdk-list]');
363
+ const optionsOf = (root) => Array.from(root.querySelectorAll('[data-cmdk-item]'));
364
+ const visibleOptions = (root) => optionsOf(root)
365
+ .filter((el) => !el.hidden && el.getAttribute('aria-disabled') !== 'true');
366
+
367
+ /** Mark one option active. DOM focus never moves: the text box keeps it. */
368
+ function setActive(root, el) {
369
+ for (const opt of optionsOf(root)) {
370
+ const on = opt === el;
371
+ opt.classList.toggle('is-active', on);
372
+ opt.setAttribute('aria-selected', on ? 'true' : 'false');
373
+ }
374
+ const input = inputOf(root);
375
+ if (!input) return;
376
+ if (el) input.setAttribute('aria-activedescendant', el.id);
377
+ else input.removeAttribute('aria-activedescendant');
378
+ // A row scrolled out of the list is a row the reader cannot see they are on.
379
+ if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
380
+ }
381
+
382
+ const activeOf = (root) => root.querySelector('[data-cmdk-item].is-active');
383
+
384
+ /**
385
+ * Say how many results there are, once, politely.
386
+ *
387
+ * The count and not the rows: a palette re-ranks on every keystroke, and a live
388
+ * region holding the rows would read the whole list out again on each one. The
389
+ * row the reader is on is announced by aria-activedescendant instead, which is
390
+ * the combobox pattern's own answer.
391
+ */
392
+ function announce(root, n) {
393
+ const status = root.querySelector('[data-cmdk-status]');
394
+ if (!status) return;
395
+ const next = n === 0 ? 'No results' : `${n} result${n === 1 ? '' : 's'}`;
396
+ if (status.textContent !== next) status.textContent = next;
397
+ }
398
+
399
+ /**
400
+ * Re-rank what is rendered against what is typed.
401
+ *
402
+ * The rows are scored where they stand — the wiring reads each row's
403
+ * `data-label`, `data-keywords` and `data-desc` rather than being handed the
404
+ * items a second time, so a palette rendered by a server and one built in the
405
+ * browser behave the same. Groups are carried by their best row, and the
406
+ * caller's order breaks every tie, at both levels.
407
+ */
408
+ function applyQuery(root) {
409
+ const list = listOf(root);
410
+ const input = inputOf(root);
411
+ if (!list || !input) return;
412
+ const q = input.value;
413
+ const ranking = root.getAttribute('data-cmdk-rank') !== 'off';
414
+
415
+ let shown = 0;
416
+ const groups = Array.from(list.querySelectorAll('[data-cmdk-group]'));
417
+ for (const group of groups) {
418
+ const rows = Array.from(group.querySelectorAll('[data-cmdk-item]'));
419
+ const scored = rows.map((el, i) => ({
420
+ el,
421
+ i: Number(el.dataset.i ?? i),
422
+ score: ranking ? scoreCommand(fieldsOf(el), q) : 1,
423
+ }));
424
+ for (const r of scored) {
425
+ r.el.hidden = r.score === 0;
426
+ if (r.score > 0) shown += 1;
427
+ }
428
+ const live = scored.filter((r) => r.score > 0);
429
+ if (ranking) {
430
+ live.sort((a, b) => b.score - a.score || a.i - b.i);
431
+ for (const r of live) group.append(r.el);
432
+ }
433
+ group.hidden = live.length === 0;
434
+ group.dataset.best = String(live.length ? live[0].score : 0);
435
+ }
436
+ if (ranking) {
437
+ const live = groups.filter((g) => !g.hidden);
438
+ live.sort((a, b) => Number(b.dataset.best) - Number(a.dataset.best)
439
+ || Number(a.dataset.i) - Number(b.dataset.i));
440
+ for (const g of live) list.append(g);
441
+ }
442
+
443
+ const emptyMsg = root.querySelector('[data-cmdk-empty]');
444
+ if (emptyMsg) emptyMsg.hidden = shown > 0;
445
+ announce(root, shown);
446
+ setActive(root, visibleOptions(root)[0] || null);
447
+ }
448
+
449
+ /**
450
+ * Hand the palette a new set of results.
451
+ *
452
+ * This is the seam a product feeds. Called with groups it renders them; called
453
+ * with a string it takes the markup as it is, which is how a server-rendered
454
+ * page answers its own `ui-command-query`. Either way the text box is untouched.
455
+ */
456
+ export function setPaletteResults(root, groups) {
457
+ const list = listOf(root);
458
+ if (!list) return;
459
+ list.innerHTML = typeof groups === 'string' ? groups : commandPaletteList(groups, { uid: list.id.replace(/-list$/, '') });
460
+ applyQuery(root);
461
+ }
462
+
463
+ /** Open it, remembering what to give focus back to. */
464
+ export function openCommandPalette(root, returnFocusTo) {
465
+ if (!root || root.classList.contains('is-open')) return;
466
+ root.__cmdkReturn = returnFocusTo
467
+ || (document.activeElement instanceof HTMLElement ? document.activeElement : null);
468
+ root.classList.add('is-open');
469
+ const panel = root.querySelector('[data-cmdk-panel]');
470
+ pushOverlay(root, panel, () => closeCommandPalette(root), OVERLAY_LAYER.palette);
471
+ const input = inputOf(root);
472
+ // It opens empty. A palette that comes back holding the last query shows a
473
+ // list that answers a question the reader has already finished asking, and
474
+ // the first keystroke then appends to it.
475
+ if (input) { input.value = ''; input.focus(); }
476
+ applyQuery(root);
477
+ }
478
+
479
+ export function closeCommandPalette(root) {
480
+ if (!root || !root.classList.contains('is-open')) return;
481
+ root.classList.remove('is-open');
482
+ const back = root.__cmdkReturn;
483
+ root.__cmdkReturn = null;
484
+ // A palette row can open a drawer, and the palette then closes over a drawer
485
+ // that is holding the page inert — including whatever summoned the palette.
486
+ // popOverlay is passed `back` so it can see that and open the drawer's panel.
487
+ popOverlay(root, back);
488
+ returnFocus(back, root.ownerDocument);
489
+ }
490
+
491
+ /**
492
+ * Run what the reader chose.
493
+ *
494
+ * An item that names a confirm is left alone: its `data-confirm-open` is the
495
+ * kit's own trigger, and the confirm opens ABOVE the palette on the shared
496
+ * overlay stack, so Escape answers the question rather than closing the palette
497
+ * underneath it.
498
+ */
499
+ function activate(root, el, e) {
500
+ if (!el || el.getAttribute('aria-disabled') === 'true') return;
501
+ const detail = { id: el.dataset.id, href: el.dataset.href || null, item: el };
502
+ const newTab = !!(e && (e.metaKey || e.ctrlKey));
503
+ const view = root.ownerDocument.defaultView;
504
+ // The document's own constructor, not the global one: a page under test is a
505
+ // second document, and an Event built by another realm's class is refused by it.
506
+ root.dispatchEvent(new view.CustomEvent('ui-command', { detail, bubbles: true }));
507
+ if (el.hasAttribute('data-confirm-open')) return; // confirm() takes it from here
508
+ closeCommandPalette(root);
509
+ const href = el.dataset.href;
510
+ if (!href) return;
511
+ if (newTab) view?.open(href, '_blank', 'noopener');
512
+ else if (view) view.location.href = href;
513
+ }
514
+
515
+ function onKeydown(root, e) {
516
+ const options = visibleOptions(root);
517
+ const at = options.indexOf(activeOf(root));
518
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
519
+ if (!options.length) return;
520
+ e.preventDefault();
521
+ const step = e.key === 'ArrowDown' ? 1 : -1;
522
+ // Wrapping, because the list is short and a reader holding Down to see what
523
+ // is at the end should not have to let go to get back to the top.
524
+ const next = (at + step + options.length) % options.length;
525
+ setActive(root, options[next]);
526
+ } else if (e.key === 'Enter') {
527
+ if (at === -1) return;
528
+ e.preventDefault();
529
+ activate(root, options[at], e);
530
+ }
531
+ // Escape is not handled here. overlay.js owns it for every overlay on the
532
+ // page, so one Escape closes the one on top — the confirm this palette opened
533
+ // before the palette itself.
534
+ }
535
+
536
+ export function wireCommandPalette(scope = document) {
537
+ const root = scope === document ? document : scope;
538
+ root.querySelectorAll('[data-cmdk]').forEach((cmdk) => {
539
+ if (cmdk.__cmdkWired) return;
540
+ cmdk.__cmdkWired = true;
541
+
542
+ cmdk.querySelector('[data-cmdk-scrim]')?.addEventListener('click', () => closeCommandPalette(cmdk));
543
+ const input = inputOf(cmdk);
544
+ input?.addEventListener('input', () => {
545
+ applyQuery(cmdk);
546
+ if (cmdk.getAttribute('data-cmdk-rank') === 'off') {
547
+ const view = cmdk.ownerDocument.defaultView;
548
+ cmdk.dispatchEvent(new view.CustomEvent('ui-command-query', {
549
+ detail: { query: input.value }, bubbles: true,
550
+ }));
551
+ }
552
+ });
553
+ cmdk.addEventListener('keydown', (e) => onKeydown(cmdk, e));
554
+ // Pointer: hovering moves the active row the way every palette does, and the
555
+ // click lands on mouseup like a menu item rather than on mousedown.
556
+ listOf(cmdk)?.addEventListener('mousemove', (e) => {
557
+ const row = e.target.closest?.('[data-cmdk-item]');
558
+ if (row && !row.hidden && row !== activeOf(cmdk)) setActive(cmdk, row);
559
+ });
560
+ listOf(cmdk)?.addEventListener('click', (e) => {
561
+ const row = e.target.closest?.('[data-cmdk-item]');
562
+ if (row) activate(cmdk, row, e);
563
+ });
564
+
565
+ applyQuery(cmdk);
566
+ adoptOverlay(cmdk, cmdk.querySelector('[data-cmdk-panel]'), () => closeCommandPalette(cmdk), OVERLAY_LAYER.palette);
567
+ });
568
+
569
+ const doc = scope === document ? document : (scope.ownerDocument || document);
570
+ if (!doc.__cmdkGlobalWired) {
571
+ doc.__cmdkGlobalWired = true;
572
+ doc.addEventListener('click', (e) => {
573
+ const opener = e.target.closest?.('[data-cmdk-open]');
574
+ if (!opener) return;
575
+ e.preventDefault();
576
+ const target = doc.getElementById(opener.getAttribute('data-cmdk-open'));
577
+ if (target) openCommandPalette(target, opener);
578
+ });
579
+ // Cmd/Ctrl+K opens the first palette on the page that asked for the key.
580
+ // Ctrl+K alone is left to a text box the reader is typing in — it is
581
+ // kill-to-end-of-line there on every platform — while Cmd+K is answered
582
+ // wherever focus is, because it is nothing else's key.
583
+ doc.addEventListener('keydown', (e) => {
584
+ if (e.key !== 'k' && e.key !== 'K') return;
585
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
586
+ const cmdk = doc.querySelector('[data-cmdk][data-cmdk-hotkey]');
587
+ if (!cmdk) return;
588
+ const typing = doc.activeElement;
589
+ const inField = typing && /^(INPUT|TEXTAREA)$/.test(typing.tagName) && !cmdk.contains(typing);
590
+ if (inField && !e.metaKey) return;
591
+ e.preventDefault();
592
+ if (cmdk.classList.contains('is-open')) closeCommandPalette(cmdk);
593
+ else openCommandPalette(cmdk, typing instanceof HTMLElement ? typing : null);
594
+ });
595
+ }
596
+ syncOverlays(doc);
597
+ }
@@ -88,12 +88,13 @@ export function openConfirm(root, returnFocusTo) {
88
88
  export function closeConfirm(root) {
89
89
  if (!root || !root.classList.contains('is-open')) return;
90
90
  root.classList.remove('is-open');
91
- popOverlay(root);
92
91
  const back = root.__confirmReturn;
93
92
  root.__confirmReturn = null;
94
93
  // The destructive work a caller hangs off [data-confirm-accept] usually deletes
95
94
  // the row the trigger stood in, so the trigger can be detached by now — and
96
- // focus() on a detached node is a silent no-op that strands the reader.
95
+ // focus() on a detached node is a silent no-op that strands the reader. So is
96
+ // one that is still there but inert, which popOverlay is passed `back` to see.
97
+ popOverlay(root, back);
97
98
  returnFocus(back, root.ownerDocument);
98
99
  }
99
100