@leavepulse/ui 0.37.11 → 0.38.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/dist/assets/LpCommandPalette-C0yStVNy.css +21 -0
- package/dist/components/LpCommandPalette.vue.d.ts +87 -1
- package/dist/components/LpCommandPalette.vue.js +410 -121
- package/package.json +1 -1
- package/src/components/LpCommandPalette.vue +492 -76
- package/src/tokens/tokens.css +21 -0
- package/dist/assets/LpCommandPalette-B-g1fa8r.css +0 -16
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* arrows move the highlight — the pattern the LeavePulse frontend palette uses.
|
|
7
7
|
*
|
|
8
8
|
* Presentation-only and data-agnostic: the consumer supplies `commands` (a
|
|
9
|
-
* composable can assemble them from routes/docs/API).
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* composable can assemble them from routes/docs/API). Every query word must
|
|
10
|
+
* hit, in any order; scoring ranks label > keywords > description > a typo in
|
|
11
|
+
* the label, with a keyboard-layout retry when nothing matches; matches are
|
|
12
|
+
* highlighted; the active row scrolls into view. Open state is v-model:open;
|
|
13
|
+
* a global ⌘K/Ctrl-K toggle is on by default.
|
|
12
14
|
*
|
|
13
15
|
* Three things a palette outgrows the moment it stops being a list of routes,
|
|
14
16
|
* all of them here rather than in each app that hit the wall:
|
|
@@ -34,10 +36,11 @@ import {
|
|
|
34
36
|
DialogRoot,
|
|
35
37
|
DialogTitle,
|
|
36
38
|
} from "reka-ui"
|
|
37
|
-
import { computed, nextTick, ref, shallowRef, watch } from "vue"
|
|
39
|
+
import { computed, nextTick, ref, shallowRef, useId, watch } from "vue"
|
|
38
40
|
import LpIcon from "./LpIcon.vue"
|
|
39
41
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
40
42
|
import { useAsyncTask } from "../composables/useAsyncTask"
|
|
43
|
+
import { useClipboard } from "../composables/useClipboard"
|
|
41
44
|
import { useHotkeys } from "../composables/useHotkeys"
|
|
42
45
|
|
|
43
46
|
export interface Command {
|
|
@@ -45,6 +48,19 @@ export interface Command {
|
|
|
45
48
|
label: string
|
|
46
49
|
/** Second line under the label. */
|
|
47
50
|
description?: string
|
|
51
|
+
/**
|
|
52
|
+
* A passage of the matched text, wrapped over several lines (clamped by
|
|
53
|
+
* `excerptLines`) instead of truncated to one. For results a server found
|
|
54
|
+
* inside long text — a docs section, a ticket, a message — where the reason
|
|
55
|
+
* the row matched is the part worth reading before opening it.
|
|
56
|
+
*/
|
|
57
|
+
excerpt?: string
|
|
58
|
+
/**
|
|
59
|
+
* Extra words to highlight besides the typed query. A server that matches
|
|
60
|
+
* word forms or typos knows which words actually matched; the query alone
|
|
61
|
+
* would leave those unmarked.
|
|
62
|
+
*/
|
|
63
|
+
highlight?: string[]
|
|
48
64
|
icon?: string
|
|
49
65
|
/** Right-aligned hint, e.g. "⌘P". */
|
|
50
66
|
shortcut?: string
|
|
@@ -77,6 +93,27 @@ export interface Command {
|
|
|
77
93
|
* reopening and retyping.
|
|
78
94
|
*/
|
|
79
95
|
keepOpen?: boolean
|
|
96
|
+
/**
|
|
97
|
+
* Where the command leads. The row renders as a real link, so Ctrl/⌘-click,
|
|
98
|
+
* Shift-click and middle-click open it the way the browser opens any link,
|
|
99
|
+
* and Ctrl/⌘+Enter opens it in a new tab from the keyboard. A plain pick
|
|
100
|
+
* still runs `onSelect` (a router push, say); without one it follows the
|
|
101
|
+
* link.
|
|
102
|
+
*/
|
|
103
|
+
href?: string
|
|
104
|
+
/**
|
|
105
|
+
* Further things to do with this result besides opening it — copy its id,
|
|
106
|
+
* open it elsewhere, share it. Tab on the highlighted row lists them as a
|
|
107
|
+
* mode named after the row. A command with `href` gets "open in new tab"
|
|
108
|
+
* and "copy link" after its own.
|
|
109
|
+
*/
|
|
110
|
+
actions?: Command[]
|
|
111
|
+
/**
|
|
112
|
+
* The whole text behind this result, for the preview pane (`previewPane`):
|
|
113
|
+
* the section a docs hit points into, the ticket body, the message. Line
|
|
114
|
+
* breaks are kept. Without it the pane shows the `excerpt`.
|
|
115
|
+
*/
|
|
116
|
+
preview?: string
|
|
80
117
|
onSelect?: () => void
|
|
81
118
|
}
|
|
82
119
|
|
|
@@ -88,6 +125,19 @@ export interface PaletteLabels {
|
|
|
88
125
|
/** Hint for leaving a mode, shown only while inside one. */
|
|
89
126
|
back: string
|
|
90
127
|
searching: string
|
|
128
|
+
/** Shown when `search` (or a fetching mode) threw. */
|
|
129
|
+
searchFailed: string
|
|
130
|
+
retry: string
|
|
131
|
+
/** The row that unfolds a group cut by `groupLimit`; `{count}` is how many are hidden. */
|
|
132
|
+
showMore: string
|
|
133
|
+
/** Footer hint for Tab while the highlighted row has actions. */
|
|
134
|
+
actions: string
|
|
135
|
+
openInNewTab: string
|
|
136
|
+
copyLink: string
|
|
137
|
+
/** Toast after "copy link"; empty for none. */
|
|
138
|
+
linkCopied: string
|
|
139
|
+
/** The preview pane when the highlighted row has nothing to show. */
|
|
140
|
+
noPreview: string
|
|
91
141
|
}
|
|
92
142
|
|
|
93
143
|
const props = withDefaults(
|
|
@@ -126,6 +176,29 @@ const props = withDefaults(
|
|
|
126
176
|
resultsLabel?: string
|
|
127
177
|
/** Footer hints and the mode chip, spelled out for translation. */
|
|
128
178
|
labels?: Partial<PaletteLabels>
|
|
179
|
+
/** Lines an `excerpt` may wrap to before it is clamped. */
|
|
180
|
+
excerptLines?: number
|
|
181
|
+
/**
|
|
182
|
+
* Rows a group shows before folding the rest behind a "Show N more" row.
|
|
183
|
+
* One provider answering with thirty people would otherwise push every
|
|
184
|
+
* other group off the screen. 0 shows everything.
|
|
185
|
+
*/
|
|
186
|
+
groupLimit?: number
|
|
187
|
+
/**
|
|
188
|
+
* When the query matches nothing, retry it as if typed in the other
|
|
189
|
+
* keyboard layout (QWERTY ↔ ЙЦУКЕН). Off for an app whose commands are
|
|
190
|
+
* meant to be typed in one layout only.
|
|
191
|
+
*/
|
|
192
|
+
layoutFallback?: boolean
|
|
193
|
+
/**
|
|
194
|
+
* A pane beside the list showing the highlighted result in full — its
|
|
195
|
+
* `preview` text, or the `preview` slot. For palettes that search
|
|
196
|
+
* content rather than commands: the excerpt says why a row matched, the
|
|
197
|
+
* pane lets it be read without leaving the page. Opt-in and fixed while
|
|
198
|
+
* open, so the dialog does not change width as results come and go.
|
|
199
|
+
* Hidden below the md breakpoint, where there is no room beside the list.
|
|
200
|
+
*/
|
|
201
|
+
previewPane?: boolean
|
|
129
202
|
}>(),
|
|
130
203
|
{
|
|
131
204
|
placeholder: "Type a command or search…",
|
|
@@ -136,6 +209,10 @@ const props = withDefaults(
|
|
|
136
209
|
recentLimit: 5,
|
|
137
210
|
recentLabel: "Recent",
|
|
138
211
|
resultsLabel: "Results",
|
|
212
|
+
excerptLines: 4,
|
|
213
|
+
groupLimit: 0,
|
|
214
|
+
layoutFallback: true,
|
|
215
|
+
previewPane: false,
|
|
139
216
|
},
|
|
140
217
|
)
|
|
141
218
|
|
|
@@ -145,6 +222,14 @@ const DEFAULT_LABELS: PaletteLabels = {
|
|
|
145
222
|
close: "close",
|
|
146
223
|
back: "back",
|
|
147
224
|
searching: "Searching…",
|
|
225
|
+
searchFailed: "Search is unavailable right now.",
|
|
226
|
+
retry: "Retry",
|
|
227
|
+
showMore: "Show {count} more",
|
|
228
|
+
actions: "actions",
|
|
229
|
+
openInNewTab: "Open in new tab",
|
|
230
|
+
copyLink: "Copy link",
|
|
231
|
+
linkCopied: "Link copied",
|
|
232
|
+
noPreview: "No preview",
|
|
148
233
|
}
|
|
149
234
|
|
|
150
235
|
const text = computed<PaletteLabels>(() => ({ ...DEFAULT_LABELS, ...props.labels }))
|
|
@@ -161,6 +246,16 @@ const emit = defineEmits<{
|
|
|
161
246
|
}>()
|
|
162
247
|
|
|
163
248
|
const query = ref("")
|
|
249
|
+
/*
|
|
250
|
+
* The input is a combobox over a listbox: focus never leaves the box, so the
|
|
251
|
+
* highlighted row has to be announced through aria-activedescendant — without
|
|
252
|
+
* it a screen reader hears the query and nothing as the arrows move.
|
|
253
|
+
*/
|
|
254
|
+
const listId = `lp-cmd-${useId()}`
|
|
255
|
+
function optionId(index: number): string {
|
|
256
|
+
return `${listId}-${index}`
|
|
257
|
+
}
|
|
258
|
+
const activeId = computed(() => (flat.value.length ? optionId(selectedIndex.value) : undefined))
|
|
164
259
|
const selectedIndex = ref(0)
|
|
165
260
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
166
261
|
const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
|
|
@@ -185,6 +280,50 @@ const mode = computed<Command | null>(() => trail.value[trail.value.length - 1]
|
|
|
185
280
|
*/
|
|
186
281
|
const modeItems = shallowRef<Command[]>([])
|
|
187
282
|
|
|
283
|
+
const { copy } = useClipboard()
|
|
284
|
+
|
|
285
|
+
function actionsOf(c: Command | undefined): Command[] {
|
|
286
|
+
if (!c || c.disabled || moreOf.has(c)) return []
|
|
287
|
+
const own = c.actions ?? []
|
|
288
|
+
const href = c.href
|
|
289
|
+
if (!href) return own
|
|
290
|
+
const link = new URL(href, window.location.href).href
|
|
291
|
+
return [
|
|
292
|
+
...own,
|
|
293
|
+
{
|
|
294
|
+
id: `${c.id}:open-in-new-tab`,
|
|
295
|
+
label: text.value.openInNewTab,
|
|
296
|
+
icon: "lucide:external-link",
|
|
297
|
+
onSelect: () => window.open(href, "_blank", "noopener"),
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: `${c.id}:copy-link`,
|
|
301
|
+
label: text.value.copyLink,
|
|
302
|
+
icon: "lucide:link",
|
|
303
|
+
onSelect: () => void copy(link, { toast: text.value.linkCopied || false }),
|
|
304
|
+
},
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const selectedActions = computed(() => actionsOf(flat.value[selectedIndex.value]))
|
|
309
|
+
|
|
310
|
+
/*
|
|
311
|
+
* Tab is free for this: the rows are out of the tab order and the box is the
|
|
312
|
+
* only field, so Tab otherwise just walks off to the dialog's edge. Without
|
|
313
|
+
* actions it keeps that default.
|
|
314
|
+
*/
|
|
315
|
+
function onTab(event: KeyboardEvent) {
|
|
316
|
+
if (event.shiftKey) return
|
|
317
|
+
const c = flat.value[selectedIndex.value]
|
|
318
|
+
const actions = selectedActions.value
|
|
319
|
+
if (!c || !actions.length) return
|
|
320
|
+
// The dialog's focus trap handles Tab too, and would hand focus to the
|
|
321
|
+
// mode chip this is about to render.
|
|
322
|
+
event.preventDefault()
|
|
323
|
+
event.stopPropagation()
|
|
324
|
+
enter({ id: `${c.id}:actions`, label: c.label, icon: c.icon, children: actions })
|
|
325
|
+
}
|
|
326
|
+
|
|
188
327
|
function enter(c: Command) {
|
|
189
328
|
trail.value = [...trail.value, c]
|
|
190
329
|
query.value = ""
|
|
@@ -241,9 +380,18 @@ function cancel() {
|
|
|
241
380
|
task.cancel()
|
|
242
381
|
}
|
|
243
382
|
|
|
383
|
+
/*
|
|
384
|
+
* A provider that threw used to leave the list as if it had answered "nothing
|
|
385
|
+
* matches", which is a claim about the data the palette cannot make. A failure
|
|
386
|
+
* says so and offers the same request again. Aborts never land here: the task
|
|
387
|
+
* drops a superseded request before `fail` runs.
|
|
388
|
+
*/
|
|
389
|
+
const failed = ref(false)
|
|
390
|
+
|
|
244
391
|
function refresh() {
|
|
245
392
|
cancel()
|
|
246
393
|
found.value = []
|
|
394
|
+
failed.value = false
|
|
247
395
|
const run = fetcher()
|
|
248
396
|
if (!run) return
|
|
249
397
|
const q = query.value.trim()
|
|
@@ -252,11 +400,15 @@ function refresh() {
|
|
|
252
400
|
// not — ⌘K would fire a request at every open.
|
|
253
401
|
const inMode = mode.value !== null
|
|
254
402
|
if (!inMode && q.length < Math.max(1, props.minChars)) return
|
|
255
|
-
//
|
|
403
|
+
// A failing provider: the static commands still stand, with a line saying
|
|
404
|
+
// that the rest could not be fetched.
|
|
256
405
|
task.run((signal) => run(q, signal), {
|
|
257
406
|
delay: props.debounce,
|
|
258
407
|
apply: (hits) => (found.value = hits),
|
|
259
|
-
fail: () =>
|
|
408
|
+
fail: () => {
|
|
409
|
+
found.value = []
|
|
410
|
+
failed.value = true
|
|
411
|
+
},
|
|
260
412
|
})
|
|
261
413
|
}
|
|
262
414
|
|
|
@@ -269,26 +421,98 @@ function refresh() {
|
|
|
269
421
|
*/
|
|
270
422
|
const source = computed<Command[]>(() => (mode.value ? modeItems.value : props.commands))
|
|
271
423
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
424
|
+
/*
|
|
425
|
+
* Keyboard layouts typed through each other: the query "cthdth" is "сервер"
|
|
426
|
+
* typed with the wrong layout on. Only the QWERTY ↔ ЙЦУКЕН pair is mapped —
|
|
427
|
+
* the one bilingual setup common enough to be worth a table — and it is a
|
|
428
|
+
* fallback, tried only when the query as typed matches nothing.
|
|
429
|
+
*/
|
|
430
|
+
const LATIN = "qwertyuiop[]asdfghjkl;'zxcvbnm,.`"
|
|
431
|
+
const CYRILLIC = "йцукенгшщзхъфывапролджэячсмитьбюё"
|
|
432
|
+
const SWAP = new Map<string, string>()
|
|
433
|
+
for (let i = 0; i < LATIN.length; i++) {
|
|
434
|
+
SWAP.set(LATIN[i]!, CYRILLIC[i]!)
|
|
435
|
+
SWAP.set(CYRILLIC[i]!, LATIN[i]!)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function swapLayout(term: string): string {
|
|
439
|
+
return [...term].map((ch) => SWAP.get(ch) ?? ch).join("")
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Damerau-Levenshtein distance, giving up past 1: all a typo check needs. */
|
|
443
|
+
function withinOne(a: string, b: string): boolean {
|
|
444
|
+
if (Math.abs(a.length - b.length) > 1) return false
|
|
445
|
+
let i = 0
|
|
446
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
|
447
|
+
if (i === a.length || i === b.length) return true
|
|
448
|
+
const rest = (x: number, y: number) => a.slice(i + x) === b.slice(i + y)
|
|
449
|
+
return rest(1, 1) || rest(1, 0) || rest(0, 1) || (a[i] === b[i + 1] && a[i + 1] === b[i] && rest(2, 2))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/*
|
|
453
|
+
* A typo in a word long enough to have one: a slip in a three-letter word
|
|
454
|
+
* makes it a different word. Compared against the word and its start, so
|
|
455
|
+
* "deplyo" finds "deploy" and "deployment" alike.
|
|
456
|
+
*/
|
|
457
|
+
const TYPO_MIN = 5
|
|
458
|
+
function nearWord(term: string, label: string): boolean {
|
|
459
|
+
if (term.length < TYPO_MIN) return false
|
|
460
|
+
return label
|
|
461
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
462
|
+
.some((word) => withinOne(term, word) || (word.length > term.length && withinOne(term, word.slice(0, term.length))))
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/*
|
|
466
|
+
* Every word of the query has to land somewhere, in any order: "branch deploy"
|
|
467
|
+
* finds "Deploy current branch". Per word, label > keywords > description,
|
|
468
|
+
* the same weights the palette always had; a typo match ranks below all of
|
|
469
|
+
* them, so an exact hit is never outranked by a near one.
|
|
470
|
+
*/
|
|
471
|
+
function score(c: Command, terms: string[]): number {
|
|
472
|
+
const label = c.label.toLowerCase()
|
|
473
|
+
const keywords = (c.keywords ?? []).map((k) => k.toLowerCase())
|
|
474
|
+
const description = c.description?.toLowerCase() ?? ""
|
|
475
|
+
let total = 0
|
|
476
|
+
for (const term of terms) {
|
|
477
|
+
let hit =
|
|
478
|
+
(label.includes(term) ? 4 : 0) +
|
|
479
|
+
(keywords.some((k) => k.includes(term)) ? 3 : 0) +
|
|
480
|
+
(description.includes(term) ? 2 : 0)
|
|
481
|
+
if (!hit && nearWord(term, label)) hit = 1
|
|
482
|
+
if (!hit) return 0
|
|
483
|
+
total += hit
|
|
484
|
+
}
|
|
485
|
+
return total
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function rank(items: Command[], terms: string[]): Command[] {
|
|
489
|
+
return items
|
|
490
|
+
.map((c) => ({ c, score: score(c, terms) }))
|
|
287
491
|
.filter((e) => e.score > 0)
|
|
288
492
|
.sort((a, b) => b.score - a.score || a.c.label.localeCompare(b.c.label))
|
|
289
493
|
.map((e) => e.c)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const typedTerms = computed(() => query.value.trim().toLowerCase().split(/\s+/).filter(Boolean))
|
|
497
|
+
|
|
498
|
+
/*
|
|
499
|
+
* The local match and the terms it matched with — the swapped ones when the
|
|
500
|
+
* layout fallback is what found it, so the highlight marks what actually hit.
|
|
501
|
+
*/
|
|
502
|
+
const local = computed<{ items: Command[]; terms: string[] }>(() => {
|
|
503
|
+
const terms = typedTerms.value
|
|
504
|
+
// Empty query: show everything EXCEPT hidden commands (they only surface once
|
|
505
|
+
// typed). With a query, hidden commands rank like any other.
|
|
506
|
+
if (!terms.length) return { items: source.value.filter((c) => !c.hidden), terms }
|
|
507
|
+
const items = rank(source.value, terms)
|
|
508
|
+
if (items.length || !props.layoutFallback) return { items, terms }
|
|
509
|
+
const swapped = terms.map(swapLayout)
|
|
510
|
+
const other = rank(source.value, swapped)
|
|
511
|
+
return other.length ? { items: other, terms: swapped } : { items, terms }
|
|
290
512
|
})
|
|
291
513
|
|
|
514
|
+
const ranked = computed<Command[]>(() => local.value.items)
|
|
515
|
+
|
|
292
516
|
/*
|
|
293
517
|
* Fetched results sit under their own heading unless they brought a group of
|
|
294
518
|
* their own, so they never look like they were part of the static list. An id
|
|
@@ -339,11 +563,64 @@ const grouped = computed<{ name: string; items: Command[] }[]>(() => {
|
|
|
339
563
|
// the same row on screen twice with nothing to tell them apart.
|
|
340
564
|
for (const c of ranked.value) if (!recent.includes(c)) push(c, c.group ?? "")
|
|
341
565
|
for (const c of fetched.value) push(c, c.group ?? "")
|
|
342
|
-
return order.map((name) => ({ name, items: map.get(name)! }))
|
|
566
|
+
return order.map((name) => ({ name, items: folded(name, map.get(name)!) }))
|
|
343
567
|
})
|
|
344
568
|
|
|
569
|
+
/*
|
|
570
|
+
* Groups unfolded by their "Show more" row. Reset with the list it applies
|
|
571
|
+
* to: a new query or another mode is a new list, and its groups start folded.
|
|
572
|
+
*/
|
|
573
|
+
const expanded = ref(new Set<string>())
|
|
574
|
+
const moreOf = new WeakMap<Command, string>()
|
|
575
|
+
|
|
576
|
+
/*
|
|
577
|
+
* Folding hides at least two rows: a "Show 1 more" row takes the same space
|
|
578
|
+
* as the row it hides.
|
|
579
|
+
*/
|
|
580
|
+
function folded(name: string, items: Command[]): Command[] {
|
|
581
|
+
const limit = props.groupLimit
|
|
582
|
+
if (!limit || items.length <= limit + 1 || expanded.value.has(name)) return items
|
|
583
|
+
const more: Command = {
|
|
584
|
+
id: `lp-cmd-more:${name}`,
|
|
585
|
+
label: text.value.showMore.replace("{count}", String(items.length - limit)),
|
|
586
|
+
icon: "lucide:chevron-down",
|
|
587
|
+
keepOpen: true,
|
|
588
|
+
}
|
|
589
|
+
moreOf.set(more, name)
|
|
590
|
+
return [...items.slice(0, limit), more]
|
|
591
|
+
}
|
|
592
|
+
|
|
345
593
|
// Flat list in render order — what arrow keys walk through.
|
|
346
594
|
const flat = computed(() => grouped.value.flatMap((g) => g.items))
|
|
595
|
+
|
|
596
|
+
defineSlots<{
|
|
597
|
+
/** Body of the preview pane for the highlighted command. */
|
|
598
|
+
preview?: (props: { command: Command }) => unknown
|
|
599
|
+
}>()
|
|
600
|
+
|
|
601
|
+
const active = computed<Command | undefined>(() => {
|
|
602
|
+
const c = flat.value[selectedIndex.value]
|
|
603
|
+
return c && !moreOf.has(c) ? c : undefined
|
|
604
|
+
})
|
|
605
|
+
const previewRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
|
|
606
|
+
|
|
607
|
+
/*
|
|
608
|
+
* A preview is opened for the match, so the pane lands on it: the first
|
|
609
|
+
* highlighted word is brought into view, and a result without one starts
|
|
610
|
+
* from the top rather than wherever the previous result was scrolled to.
|
|
611
|
+
*/
|
|
612
|
+
watch(
|
|
613
|
+
() => active.value?.id,
|
|
614
|
+
async () => {
|
|
615
|
+
if (!props.previewPane) return
|
|
616
|
+
await nextTick()
|
|
617
|
+
const viewport = previewRef.value?.viewportEl
|
|
618
|
+
if (!viewport) return
|
|
619
|
+
const mark = viewport.querySelector("mark")
|
|
620
|
+
if (mark) mark.scrollIntoView({ block: "center" })
|
|
621
|
+
else viewport.scrollTop = 0
|
|
622
|
+
},
|
|
623
|
+
)
|
|
347
624
|
function flatIndexOf(c: Command): number {
|
|
348
625
|
return flat.value.indexOf(c)
|
|
349
626
|
}
|
|
@@ -355,8 +632,8 @@ interface Part {
|
|
|
355
632
|
text: string
|
|
356
633
|
hit: boolean
|
|
357
634
|
}
|
|
358
|
-
function highlight(value: string): Part[] {
|
|
359
|
-
const terms = [...new Set(
|
|
635
|
+
function highlight(value: string, extra: string[] = []): Part[] {
|
|
636
|
+
const terms = [...new Set([...local.value.terms, ...extra.map((t) => t.toLowerCase())].filter((t) => t.length >= 2))].sort(
|
|
360
637
|
(a, b) => b.length - a.length,
|
|
361
638
|
)
|
|
362
639
|
if (!value || terms.length === 0) return value ? [{ text: value, hit: false }] : []
|
|
@@ -389,6 +666,9 @@ watch(query, () => {
|
|
|
389
666
|
selectedIndex.value = 0
|
|
390
667
|
refresh()
|
|
391
668
|
})
|
|
669
|
+
watch([query, trail, () => props.open], () => {
|
|
670
|
+
expanded.value = new Set()
|
|
671
|
+
})
|
|
392
672
|
watch(
|
|
393
673
|
() => flat.value.length,
|
|
394
674
|
(len) => {
|
|
@@ -415,14 +695,46 @@ function moveUp() {
|
|
|
415
695
|
const len = flat.value.length
|
|
416
696
|
if (len) selectedIndex.value = (selectedIndex.value - 1 + len) % len
|
|
417
697
|
}
|
|
418
|
-
function runSelected() {
|
|
419
|
-
|
|
698
|
+
function runSelected(event?: KeyboardEvent) {
|
|
699
|
+
const c = flat.value[selectedIndex.value]
|
|
700
|
+
if (c?.href && !c.disabled && (event?.ctrlKey || event?.metaKey)) {
|
|
701
|
+
openInNewTab(c)
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
run(c)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function openInNewTab(c: Command) {
|
|
708
|
+
if (!c.href) return
|
|
709
|
+
window.open(c.href, "_blank", "noopener")
|
|
710
|
+
emit("run", c.id, c)
|
|
711
|
+
emit("update:open", false)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/*
|
|
715
|
+
* A modified click on a link is the browser's to handle: it opens a tab or a
|
|
716
|
+
* window and this page stays where it is, so the palette neither runs the
|
|
717
|
+
* command nor closes. Only the plain click is taken over.
|
|
718
|
+
*/
|
|
719
|
+
function onRowClick(event: MouseEvent, c: Command) {
|
|
720
|
+
if (c.href && (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0)) return
|
|
721
|
+
event.preventDefault()
|
|
722
|
+
run(c)
|
|
420
723
|
}
|
|
421
724
|
|
|
422
725
|
function run(c: Command | undefined) {
|
|
423
726
|
if (!c || c.disabled) return
|
|
727
|
+
// The highlight stays on the same index, which is now the first row that
|
|
728
|
+
// was hidden: the arrows carry on from where the fold was.
|
|
729
|
+
const group = moreOf.get(c)
|
|
730
|
+
if (group !== undefined) {
|
|
731
|
+
expanded.value = new Set([...expanded.value, group])
|
|
732
|
+
inputRef.value?.focus()
|
|
733
|
+
return
|
|
734
|
+
}
|
|
424
735
|
emit("select", c)
|
|
425
736
|
c.onSelect?.()
|
|
737
|
+
if (!c.onSelect && c.href && !c.children) window.location.assign(c.href)
|
|
426
738
|
// A command with children drills in instead of closing, and is navigation
|
|
427
739
|
// rather than a pick — so it stays out of the recency history.
|
|
428
740
|
if (c.children) {
|
|
@@ -477,6 +789,7 @@ watch(
|
|
|
477
789
|
trail.value = []
|
|
478
790
|
modeItems.value = []
|
|
479
791
|
found.value = []
|
|
792
|
+
failed.value = false
|
|
480
793
|
if (o) {
|
|
481
794
|
await nextTick()
|
|
482
795
|
inputRef.value?.focus()
|
|
@@ -509,7 +822,8 @@ useHotkeys(() =>
|
|
|
509
822
|
class="lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
|
|
510
823
|
/>
|
|
511
824
|
<DialogContent
|
|
512
|
-
class="fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] w-[min(92vw,40rem)] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line
|
|
825
|
+
class="fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] w-[min(92vw,40rem)] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line lp-glass-overlay shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
|
|
826
|
+
:class="previewPane && 'md:h-[min(76vh,36rem)] md:w-[min(92vw,64rem)]'"
|
|
513
827
|
@open-auto-focus.prevent
|
|
514
828
|
>
|
|
515
829
|
<DialogTitle class="sr-only">Command palette</DialogTitle>
|
|
@@ -544,6 +858,11 @@ useHotkeys(() =>
|
|
|
544
858
|
ref="inputRef"
|
|
545
859
|
v-model="query"
|
|
546
860
|
type="text"
|
|
861
|
+
role="combobox"
|
|
862
|
+
aria-autocomplete="list"
|
|
863
|
+
aria-expanded="true"
|
|
864
|
+
:aria-controls="listId"
|
|
865
|
+
:aria-activedescendant="activeId"
|
|
547
866
|
:placeholder="mode?.childPlaceholder ?? placeholder"
|
|
548
867
|
class="h-12 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-muted"
|
|
549
868
|
@keydown.down.prevent="moveDown"
|
|
@@ -551,63 +870,151 @@ useHotkeys(() =>
|
|
|
551
870
|
@keydown.enter.prevent="runSelected"
|
|
552
871
|
@keydown.backspace="onBackspace"
|
|
553
872
|
@keydown.esc="onEscape"
|
|
873
|
+
@keydown.tab="onTab"
|
|
554
874
|
/>
|
|
555
875
|
<kbd class="shrink-0 rounded border border-line px-1.5 py-0.5 text-[10px] text-muted">esc</kbd>
|
|
556
876
|
</div>
|
|
557
877
|
|
|
558
878
|
<!-- Results -->
|
|
559
|
-
<
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
879
|
+
<div class="flex min-h-0 flex-1">
|
|
880
|
+
<LpScrollArea ref="scrollRef" class="min-h-0 min-w-0 flex-1" content-class="p-2">
|
|
881
|
+
<!-- While a request is out there is nothing to say yet: "no results"
|
|
882
|
+
under a spinner reads as an answer, and it is not one. -->
|
|
883
|
+
<p v-if="flat.length === 0 && !failed" class="px-2 py-10 text-center text-sm text-muted">
|
|
884
|
+
{{ searching ? text.searching : emptyText }}
|
|
885
|
+
</p>
|
|
886
|
+
|
|
887
|
+
<div :id="listId" role="listbox">
|
|
888
|
+
<div
|
|
889
|
+
v-for="(group, gi) in grouped"
|
|
890
|
+
:key="group.name"
|
|
891
|
+
role="group"
|
|
892
|
+
:aria-labelledby="group.name ? `${listId}-g${gi}` : undefined"
|
|
893
|
+
>
|
|
894
|
+
<div
|
|
895
|
+
v-if="group.name"
|
|
896
|
+
:id="`${listId}-g${gi}`"
|
|
897
|
+
class="px-2 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
|
|
898
|
+
>
|
|
899
|
+
{{ group.name }}
|
|
900
|
+
</div>
|
|
901
|
+
<!-- tabindex -1: the rows are reached with the arrows from the box,
|
|
902
|
+
so Tab should not walk through every one of them. -->
|
|
903
|
+
<component
|
|
904
|
+
:is="c.href ? 'a' : 'button'"
|
|
905
|
+
v-for="c in group.items"
|
|
906
|
+
:id="optionId(flatIndexOf(c))"
|
|
907
|
+
:key="c.id"
|
|
908
|
+
:href="c.disabled ? undefined : c.href"
|
|
909
|
+
:type="c.href ? undefined : 'button'"
|
|
910
|
+
role="option"
|
|
911
|
+
tabindex="-1"
|
|
912
|
+
:aria-selected="flatIndexOf(c) === selectedIndex"
|
|
913
|
+
:aria-disabled="c.disabled || undefined"
|
|
914
|
+
:data-cmd-index="flatIndexOf(c)"
|
|
915
|
+
:disabled="c.href ? undefined : c.disabled"
|
|
916
|
+
class="lp-cmd-item flex w-full gap-3 rounded-md px-2.5 py-2 text-left text-sm outline-none transition-colors duration-[var(--duration-fast)] disabled:pointer-events-none disabled:opacity-45 aria-disabled:pointer-events-none aria-disabled:opacity-45"
|
|
917
|
+
:class="[
|
|
918
|
+
c.excerpt ? 'items-start' : 'items-center',
|
|
919
|
+
flatIndexOf(c) === selectedIndex
|
|
920
|
+
? 'bg-brand-soft text-brand'
|
|
921
|
+
: moreOf.has(c)
|
|
922
|
+
? 'text-muted hover:bg-surface-soft/60'
|
|
923
|
+
: 'text-ink hover:bg-surface-soft/60',
|
|
924
|
+
]"
|
|
925
|
+
@click="onRowClick($event, c)"
|
|
926
|
+
@pointerenter="selectedIndex = flatIndexOf(c)"
|
|
927
|
+
>
|
|
928
|
+
<LpIcon v-if="c.icon" :name="c.icon" :size="16" class="shrink-0" :class="{ 'mt-0.5': c.excerpt }" />
|
|
929
|
+
<span class="min-w-0 flex-1">
|
|
930
|
+
<span class="block truncate font-medium">
|
|
931
|
+
<template v-for="(p, pi) in highlight(c.label, c.highlight)" :key="`l${pi}`">
|
|
932
|
+
<mark v-if="p.hit" class="rounded-xs bg-brand/25 text-ink">{{ p.text }}</mark>
|
|
933
|
+
<template v-else>{{ p.text }}</template>
|
|
934
|
+
</template>
|
|
935
|
+
</span>
|
|
936
|
+
<span v-if="c.description" class="mt-0.5 block truncate text-xs text-muted">
|
|
937
|
+
<template v-for="(p, pi) in highlight(c.description, c.highlight)" :key="`d${pi}`">
|
|
938
|
+
<mark v-if="p.hit" class="rounded-xs bg-brand/20 text-ink">{{ p.text }}</mark>
|
|
939
|
+
<template v-else>{{ p.text }}</template>
|
|
940
|
+
</template>
|
|
941
|
+
</span>
|
|
942
|
+
<!-- Wraps rather than truncates: a passage cut to one line
|
|
943
|
+
stops before the part that explains the match. Ink, not
|
|
944
|
+
brand, on the active row: a paragraph in accent colour is
|
|
945
|
+
hard to read. -->
|
|
946
|
+
<span
|
|
947
|
+
v-if="c.excerpt"
|
|
948
|
+
class="lp-cmd-excerpt mt-1 block text-[13px] leading-snug text-ink/80"
|
|
949
|
+
:style="{ '-webkit-line-clamp': excerptLines }"
|
|
950
|
+
>
|
|
951
|
+
<template v-for="(p, pi) in highlight(c.excerpt, c.highlight)" :key="`e${pi}`">
|
|
952
|
+
<mark v-if="p.hit" class="rounded-xs bg-brand/20 text-ink">{{ p.text }}</mark>
|
|
953
|
+
<template v-else>{{ p.text }}</template>
|
|
954
|
+
</template>
|
|
955
|
+
</span>
|
|
956
|
+
</span>
|
|
957
|
+
<kbd v-if="c.shortcut" class="shrink-0 text-xs text-muted">{{ c.shortcut }}</kbd>
|
|
958
|
+
<!-- Drills in rather than runs: worth saying before the click,
|
|
959
|
+
since the two look identical otherwise. -->
|
|
960
|
+
<LpIcon
|
|
961
|
+
v-else-if="c.children"
|
|
962
|
+
name="lucide:chevron-right"
|
|
963
|
+
:size="14"
|
|
964
|
+
class="shrink-0 text-muted"
|
|
965
|
+
/>
|
|
966
|
+
</component>
|
|
967
|
+
</div>
|
|
968
|
+
</div>
|
|
969
|
+
<!-- Below what was found: the static matches are still worth
|
|
970
|
+
picking, and the failure is about the part that is missing. -->
|
|
567
971
|
<div
|
|
568
|
-
v-if="
|
|
569
|
-
|
|
972
|
+
v-if="failed"
|
|
973
|
+
role="alert"
|
|
974
|
+
class="flex items-center justify-center gap-2 px-2 text-sm text-muted"
|
|
975
|
+
:class="flat.length ? 'pb-1 pt-3' : 'py-10'"
|
|
570
976
|
>
|
|
571
|
-
|
|
977
|
+
<LpIcon name="lucide:circle-alert" :size="15" class="shrink-0" />
|
|
978
|
+
<span>{{ text.searchFailed }}</span>
|
|
979
|
+
<button
|
|
980
|
+
type="button"
|
|
981
|
+
class="rounded-md px-1.5 py-0.5 font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:ring-2 focus-visible:ring-ring"
|
|
982
|
+
@click="refresh(); inputRef?.focus()"
|
|
983
|
+
>
|
|
984
|
+
{{ text.retry }}
|
|
985
|
+
</button>
|
|
572
986
|
</div>
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
name="lucide:chevron-right"
|
|
605
|
-
:size="14"
|
|
606
|
-
class="shrink-0 text-muted"
|
|
607
|
-
/>
|
|
608
|
-
</button>
|
|
609
|
-
</template>
|
|
610
|
-
</LpScrollArea>
|
|
987
|
+
</LpScrollArea>
|
|
988
|
+
<aside
|
|
989
|
+
v-if="previewPane"
|
|
990
|
+
class="hidden min-h-0 w-[56%] shrink-0 flex-col border-l border-line md:flex"
|
|
991
|
+
>
|
|
992
|
+
<LpScrollArea ref="previewRef" class="min-h-0 flex-1" content-class="px-5 py-4">
|
|
993
|
+
<template v-if="active">
|
|
994
|
+
<slot name="preview" :command="active">
|
|
995
|
+
<div class="flex items-center gap-2 text-ink">
|
|
996
|
+
<LpIcon v-if="active.icon" :name="active.icon" :size="16" class="shrink-0 text-muted" />
|
|
997
|
+
<h3 class="min-w-0 text-base font-semibold">{{ active.label }}</h3>
|
|
998
|
+
</div>
|
|
999
|
+
<p v-if="active.description" class="mt-0.5 text-xs text-muted">{{ active.description }}</p>
|
|
1000
|
+
<p
|
|
1001
|
+
v-if="active.preview || active.excerpt"
|
|
1002
|
+
class="mt-3 whitespace-pre-line text-sm leading-relaxed text-ink/90"
|
|
1003
|
+
>
|
|
1004
|
+
<template
|
|
1005
|
+
v-for="(p, pi) in highlight(active.preview ?? active.excerpt ?? '', active.highlight)"
|
|
1006
|
+
:key="`p${pi}`"
|
|
1007
|
+
>
|
|
1008
|
+
<mark v-if="p.hit" class="rounded-xs bg-brand/25 text-ink">{{ p.text }}</mark>
|
|
1009
|
+
<template v-else>{{ p.text }}</template>
|
|
1010
|
+
</template>
|
|
1011
|
+
</p>
|
|
1012
|
+
<p v-else class="mt-3 text-sm text-muted">{{ text.noPreview }}</p>
|
|
1013
|
+
</slot>
|
|
1014
|
+
</template>
|
|
1015
|
+
</LpScrollArea>
|
|
1016
|
+
</aside>
|
|
1017
|
+
</div>
|
|
611
1018
|
|
|
612
1019
|
<!-- Footer hints -->
|
|
613
1020
|
<div
|
|
@@ -624,6 +1031,10 @@ useHotkeys(() =>
|
|
|
624
1031
|
</span>
|
|
625
1032
|
<!-- Only inside a mode: at the root Backspace just edits the query,
|
|
626
1033
|
and advertising it there would be a lie. -->
|
|
1034
|
+
<span v-if="selectedActions.length" class="flex items-center gap-1">
|
|
1035
|
+
<kbd class="rounded border border-line px-1 text-[10px]">tab</kbd>
|
|
1036
|
+
{{ text.actions }}
|
|
1037
|
+
</span>
|
|
627
1038
|
<span v-if="trail.length" class="flex items-center gap-1">
|
|
628
1039
|
<kbd class="rounded border border-line px-1 text-[10px]">⌫</kbd>
|
|
629
1040
|
{{ text.back }}
|
|
@@ -639,6 +1050,11 @@ useHotkeys(() =>
|
|
|
639
1050
|
</template>
|
|
640
1051
|
|
|
641
1052
|
<style scoped>
|
|
1053
|
+
.lp-cmd-excerpt {
|
|
1054
|
+
display: -webkit-box;
|
|
1055
|
+
-webkit-box-orient: vertical;
|
|
1056
|
+
overflow: hidden;
|
|
1057
|
+
}
|
|
642
1058
|
/* Each result eases in; honours reduced motion. */
|
|
643
1059
|
.lp-cmd-item {
|
|
644
1060
|
animation: lp-cmd-in 140ms ease-out both;
|