@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.
- package/package.json +1 -1
- package/react/README.md +49 -1
- package/react/dist/index.css +11 -1
- package/react/dist/index.d.ts +61 -2
- package/react/dist/index.js +514 -121
- package/src/components/back.js +57 -0
- package/src/components/command-palette.js +597 -0
- package/src/components/confirm.js +3 -2
- package/src/components/drawer.js +31 -2
- package/src/components/dropdown.js +192 -17
- package/src/components/feedback.js +2 -2
- package/src/components/index.js +5 -2
- package/src/components/loading.js +3 -2
- package/src/components/nav.js +15 -7
- package/src/components/overlay.js +67 -14
- package/src/components/shell.js +11 -2
- package/src/components/tabs.js +7 -1
- package/src/components/tooltip.js +237 -0
- package/src/components/topbar.js +9 -4
- package/src/index.css +3 -0
- package/src/index.js +3 -0
- package/src/inline.js +6 -0
- package/src/motion.js +43 -2
- package/src/styles/back.css +42 -0
- package/src/styles/badge.css +5 -7
- package/src/styles/base.css +8 -7
- package/src/styles/card.css +12 -8
- package/src/styles/code.css +3 -4
- package/src/styles/command-palette.css +321 -0
- package/src/styles/confirm.css +11 -11
- package/src/styles/drawer.css +57 -15
- package/src/styles/dropdown.css +70 -11
- package/src/styles/feedback.css +2 -0
- package/src/styles/footer.css +3 -4
- package/src/styles/input.css +1 -1
- package/src/styles/layout.css +3 -2
- package/src/styles/loading.css +5 -0
- package/src/styles/nav.css +12 -8
- package/src/styles/success.css +3 -2
- package/src/styles/table.css +4 -5
- package/src/styles/tabs.css +3 -0
- package/src/styles/tooltip.css +65 -0
- package/src/styles/topbar.css +3 -4
- 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
|
|