@leavepulse/ui 0.33.1 → 0.34.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-CEY-pajO.css → LpCommandPalette-BERCHiev.css} +4 -4
- package/dist/components/LpActionBar.vue.d.ts +1 -1
- package/dist/components/LpAddressInput.vue.d.ts +3 -3
- package/dist/components/LpAppShell.vue.d.ts +1 -4
- package/dist/components/LpAutocomplete.vue.d.ts +1 -1
- package/dist/components/LpBadge.vue.d.ts +2 -2
- package/dist/components/LpCodeBlock.vue.d.ts +1 -1
- package/dist/components/LpColorPicker.vue.d.ts +4 -9
- package/dist/components/LpCommandPalette.vue.d.ts +62 -0
- package/dist/components/LpCommandPalette.vue.js +237 -56
- package/dist/components/LpDropzone.vue.d.ts +4 -4
- package/dist/components/LpFileTree.vue.d.ts +4 -4
- package/dist/components/LpFormField.vue.d.ts +1 -1
- package/dist/components/LpLaneNode.vue.d.ts +0 -11
- package/dist/components/LpLightbox.vue.d.ts +2 -2
- package/dist/components/LpLogViewer.vue.d.ts +1 -1
- package/dist/components/LpMapPicker.vue.d.ts +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +1 -1
- package/dist/components/LpPie.vue.d.ts +4 -22
- package/dist/components/LpPopover.vue.d.ts +1 -1
- package/dist/components/LpProgress.vue.d.ts +2 -2
- package/dist/components/LpRollingText.vue.d.ts +3 -3
- package/dist/components/LpSelect.vue.d.ts +6 -6
- package/dist/components/LpShift.vue.d.ts +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +1 -1
- package/dist/components/LpSidebar.vue.d.ts +1 -7
- package/dist/components/LpSidebarNav.vue.d.ts +2 -2
- package/dist/components/LpSlider.vue.d.ts +2 -2
- package/dist/components/LpSparkline.vue.d.ts +2 -2
- package/dist/components/LpTable.vue.d.ts +9 -9
- package/dist/components/LpTabs.vue.d.ts +1 -1
- package/dist/components/LpThemeSwitcher.vue.d.ts +1 -1
- package/dist/components/LpTilt.vue.d.ts +1 -1
- package/dist/components/LpTooltip.vue.d.ts +1 -1
- package/dist/components/LpTopologyCanvas.vue.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/LpCommandPalette.vue +369 -21
- package/src/index.ts +1 -1
|
@@ -9,6 +9,22 @@
|
|
|
9
9
|
* composable can assemble them from routes/docs/API). Scoring ranks label >
|
|
10
10
|
* keywords > description; matches are highlighted; the active row scrolls into
|
|
11
11
|
* view. Open state is v-model:open; a global ⌘K/Ctrl-K toggle is on by default.
|
|
12
|
+
*
|
|
13
|
+
* Three things a palette outgrows the moment it stops being a list of routes,
|
|
14
|
+
* all of them here rather than in each app that hit the wall:
|
|
15
|
+
*
|
|
16
|
+
* search — `commands` is everything the palette knows, so anything living
|
|
17
|
+
* behind an API (people, files, tickets) had no way in. `search` is an
|
|
18
|
+
* async provider: it runs on the typed query, debounced, with the previous
|
|
19
|
+
* request aborted, and its results join the static ones.
|
|
20
|
+
*
|
|
21
|
+
* modes — a flat list cannot ask a second question. A command returning
|
|
22
|
+
* `children` (or a provider of them) pushes a mode: the query resets, a
|
|
23
|
+
* chip names where you are, and Backspace on an empty query pops back out.
|
|
24
|
+
*
|
|
25
|
+
* recency — the list is ordered the same on every open, so the command
|
|
26
|
+
* someone runs hourly sits wherever the alphabet put it. With `recent`
|
|
27
|
+
* bound, picks float to a group of their own.
|
|
12
28
|
*/
|
|
13
29
|
import {
|
|
14
30
|
DialogContent,
|
|
@@ -18,7 +34,7 @@ import {
|
|
|
18
34
|
DialogRoot,
|
|
19
35
|
DialogTitle,
|
|
20
36
|
} from "reka-ui"
|
|
21
|
-
import { computed, nextTick, ref, watch } from "vue"
|
|
37
|
+
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue"
|
|
22
38
|
import LpIcon from "./LpIcon.vue"
|
|
23
39
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
24
40
|
import { useHotkeys } from "../composables/useHotkeys"
|
|
@@ -42,9 +58,37 @@ export interface Command {
|
|
|
42
58
|
* decommission, …) that shouldn't clutter the palette but stay reachable.
|
|
43
59
|
*/
|
|
44
60
|
hidden?: boolean
|
|
61
|
+
/**
|
|
62
|
+
* Picking this opens a mode instead of closing: a nested list, either given
|
|
63
|
+
* outright or fetched from the query typed inside it. This is what keeps a
|
|
64
|
+
* palette from flattening "restart <service>" into one row per service —
|
|
65
|
+
* and what lets the second step search a list too big to enumerate.
|
|
66
|
+
*
|
|
67
|
+
* `onSelect` still runs when the mode opens, so a command can both act and
|
|
68
|
+
* drill in.
|
|
69
|
+
*/
|
|
70
|
+
children?: Command[] | ((query: string) => Command[] | Promise<Command[]>)
|
|
71
|
+
/** Placeholder while inside this command's mode. */
|
|
72
|
+
childPlaceholder?: string
|
|
73
|
+
/**
|
|
74
|
+
* Keep the palette open after this runs. For a command that toggles
|
|
75
|
+
* something the user may want to toggle again (a theme, a filter) without
|
|
76
|
+
* reopening and retyping.
|
|
77
|
+
*/
|
|
78
|
+
keepOpen?: boolean
|
|
45
79
|
onSelect?: () => void
|
|
46
80
|
}
|
|
47
81
|
|
|
82
|
+
/** Footer hints and the mode chip, spelled out so an app can translate them. */
|
|
83
|
+
export interface PaletteLabels {
|
|
84
|
+
navigate: string
|
|
85
|
+
select: string
|
|
86
|
+
close: string
|
|
87
|
+
/** Hint for leaving a mode, shown only while inside one. */
|
|
88
|
+
back: string
|
|
89
|
+
searching: string
|
|
90
|
+
}
|
|
91
|
+
|
|
48
92
|
const props = withDefaults(
|
|
49
93
|
defineProps<{
|
|
50
94
|
open?: boolean
|
|
@@ -53,13 +97,66 @@ const props = withDefaults(
|
|
|
53
97
|
emptyText?: string
|
|
54
98
|
/** Bind a global ⌘K / Ctrl-K toggle. */
|
|
55
99
|
hotkey?: boolean
|
|
100
|
+
/**
|
|
101
|
+
* Commands fetched from the typed query — what reaches the things too many
|
|
102
|
+
* (or too remote) to hand over as a list: people, files, tickets. Called
|
|
103
|
+
* debounced, never for a query shorter than `minChars`, and only its
|
|
104
|
+
* newest call counts: an earlier one that lands late is dropped, so a slow
|
|
105
|
+
* response can't overwrite a fresher list.
|
|
106
|
+
*
|
|
107
|
+
* Results join the static commands, so a palette can do both.
|
|
108
|
+
*/
|
|
109
|
+
search?: (query: string, signal: AbortSignal) => Command[] | Promise<Command[]>
|
|
110
|
+
/** Wait after the last keystroke before `search` runs. */
|
|
111
|
+
debounce?: number
|
|
112
|
+
/** Shortest query `search` runs for. */
|
|
113
|
+
minChars?: number
|
|
114
|
+
/**
|
|
115
|
+
* Ids of recently picked commands, newest first — bind it to storage and
|
|
116
|
+
* the palette opens on what this person actually uses. It only reorders
|
|
117
|
+
* commands already in the list; an id that no longer resolves is skipped,
|
|
118
|
+
* so a stored history never resurrects a command that has gone away.
|
|
119
|
+
*/
|
|
120
|
+
recent?: string[]
|
|
121
|
+
/** How many recent commands to surface. */
|
|
122
|
+
recentLimit?: number
|
|
123
|
+
/** Headings the palette adds itself. */
|
|
124
|
+
recentLabel?: string
|
|
125
|
+
resultsLabel?: string
|
|
126
|
+
/** Footer hints and the mode chip, spelled out for translation. */
|
|
127
|
+
labels?: Partial<PaletteLabels>
|
|
56
128
|
}>(),
|
|
57
|
-
{
|
|
129
|
+
{
|
|
130
|
+
placeholder: "Type a command or search…",
|
|
131
|
+
emptyText: "No results",
|
|
132
|
+
hotkey: true,
|
|
133
|
+
debounce: 250,
|
|
134
|
+
minChars: 1,
|
|
135
|
+
recentLimit: 5,
|
|
136
|
+
recentLabel: "Recent",
|
|
137
|
+
resultsLabel: "Results",
|
|
138
|
+
},
|
|
58
139
|
)
|
|
59
140
|
|
|
141
|
+
const DEFAULT_LABELS: PaletteLabels = {
|
|
142
|
+
navigate: "navigate",
|
|
143
|
+
select: "select",
|
|
144
|
+
close: "close",
|
|
145
|
+
back: "back",
|
|
146
|
+
searching: "Searching…",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const text = computed<PaletteLabels>(() => ({ ...DEFAULT_LABELS, ...props.labels }))
|
|
150
|
+
|
|
60
151
|
const emit = defineEmits<{
|
|
61
152
|
(e: "update:open", value: boolean): void
|
|
62
153
|
(e: "select", command: Command): void
|
|
154
|
+
/**
|
|
155
|
+
* A command ran — id first, because the common use is appending it to a
|
|
156
|
+
* stored recency list. Fires for leaf commands only: opening a mode is
|
|
157
|
+
* navigation, not a pick.
|
|
158
|
+
*/
|
|
159
|
+
(e: "run", id: string, command: Command): void
|
|
63
160
|
}>()
|
|
64
161
|
|
|
65
162
|
const query = ref("")
|
|
@@ -67,15 +164,140 @@ const selectedIndex = ref(0)
|
|
|
67
164
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
68
165
|
const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
|
|
69
166
|
|
|
167
|
+
// ── modes ────────────────────────────────────────────────────
|
|
168
|
+
/*
|
|
169
|
+
* The trail of commands drilled into, outermost first. Empty is the root list.
|
|
170
|
+
* A stack rather than one level because nesting costs nothing here and a
|
|
171
|
+
* two-step action ("container → restart → confirm") is the shape that shows up
|
|
172
|
+
* as soon as a palette does more than navigate.
|
|
173
|
+
*
|
|
174
|
+
* shallowRef: entries are consumer objects that may carry anything (a fetcher,
|
|
175
|
+
* a row of data), and deep-tracking them would walk it on every keystroke.
|
|
176
|
+
*/
|
|
177
|
+
const trail = shallowRef<Command[]>([])
|
|
178
|
+
const mode = computed<Command | null>(() => trail.value[trail.value.length - 1] ?? null)
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* Commands of the open mode. A static `children` array is used as given; a
|
|
182
|
+
* function is a fetcher and goes through the same async path as `search`,
|
|
183
|
+
* which is what lets the second step be a lookup rather than a list.
|
|
184
|
+
*/
|
|
185
|
+
const modeItems = shallowRef<Command[]>([])
|
|
186
|
+
|
|
187
|
+
function enter(c: Command) {
|
|
188
|
+
trail.value = [...trail.value, c]
|
|
189
|
+
query.value = ""
|
|
190
|
+
selectedIndex.value = 0
|
|
191
|
+
modeItems.value = Array.isArray(c.children) ? c.children : []
|
|
192
|
+
refresh()
|
|
193
|
+
// Entering by click leaves the focus on the button that no longer exists,
|
|
194
|
+
// and the dialog takes it — from there the next keystroke goes nowhere. The
|
|
195
|
+
// mode exists to be typed into, so the caret goes back in the box.
|
|
196
|
+
inputRef.value?.focus()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Unwind the trail to `depth` entries — 0 is the root. */
|
|
200
|
+
function popTo(depth: number) {
|
|
201
|
+
const next = trail.value.slice(0, Math.max(0, depth))
|
|
202
|
+
trail.value = next
|
|
203
|
+
query.value = ""
|
|
204
|
+
selectedIndex.value = 0
|
|
205
|
+
const parent = next[next.length - 1]
|
|
206
|
+
modeItems.value = parent && Array.isArray(parent.children) ? parent.children : []
|
|
207
|
+
refresh()
|
|
208
|
+
inputRef.value?.focus()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Leave the innermost mode. Returns false at the root, so Escape can close. */
|
|
212
|
+
function back(): boolean {
|
|
213
|
+
if (!trail.value.length) return false
|
|
214
|
+
popTo(trail.value.length - 1)
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── async results ────────────────────────────────────────────
|
|
219
|
+
/*
|
|
220
|
+
* One in-flight request, shared by `search` at the root and by a fetching
|
|
221
|
+
* `children` inside a mode — they differ only in which function to call.
|
|
222
|
+
*
|
|
223
|
+
* Two races to lose here, and both are lost silently: a stale response
|
|
224
|
+
* overwriting a fresher one, and a response arriving after the palette moved
|
|
225
|
+
* on (closed, or into another mode). The token guards both — the abort signal
|
|
226
|
+
* only asks the consumer to stop, it cannot promise they did.
|
|
227
|
+
*/
|
|
228
|
+
const found = shallowRef<Command[]>([])
|
|
229
|
+
const searching = ref(false)
|
|
230
|
+
|
|
231
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
232
|
+
let controller: AbortController | undefined
|
|
233
|
+
let token = 0
|
|
234
|
+
|
|
235
|
+
function fetcher(): ((q: string, signal: AbortSignal) => Command[] | Promise<Command[]>) | null {
|
|
236
|
+
const current = mode.value
|
|
237
|
+
if (current) {
|
|
238
|
+
return typeof current.children === "function"
|
|
239
|
+
? (q: string) => (current.children as (q: string) => Command[] | Promise<Command[]>)(q)
|
|
240
|
+
: null
|
|
241
|
+
}
|
|
242
|
+
return props.search ?? null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function cancel() {
|
|
246
|
+
if (timer) clearTimeout(timer)
|
|
247
|
+
timer = undefined
|
|
248
|
+
controller?.abort()
|
|
249
|
+
controller = undefined
|
|
250
|
+
token += 1
|
|
251
|
+
searching.value = false
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function refresh() {
|
|
255
|
+
cancel()
|
|
256
|
+
found.value = []
|
|
257
|
+
const run = fetcher()
|
|
258
|
+
if (!run) return
|
|
259
|
+
const q = query.value.trim()
|
|
260
|
+
// A fetching mode runs on an empty query too: opening it should show the
|
|
261
|
+
// first page, not an empty screen waiting for a keystroke. Root search does
|
|
262
|
+
// not — ⌘K would fire a request at every open.
|
|
263
|
+
const inMode = mode.value !== null
|
|
264
|
+
if (!inMode && q.length < Math.max(1, props.minChars)) return
|
|
265
|
+
searching.value = true
|
|
266
|
+
const mine = ++token
|
|
267
|
+
timer = setTimeout(async () => {
|
|
268
|
+
controller = new AbortController()
|
|
269
|
+
try {
|
|
270
|
+
const hits = await run(q, controller.signal)
|
|
271
|
+
if (mine !== token) return
|
|
272
|
+
found.value = hits
|
|
273
|
+
} catch {
|
|
274
|
+
// Abort or a failing provider: the static commands still stand.
|
|
275
|
+
if (mine === token) found.value = []
|
|
276
|
+
} finally {
|
|
277
|
+
if (mine === token) searching.value = false
|
|
278
|
+
}
|
|
279
|
+
}, props.debounce)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
onBeforeUnmount(cancel)
|
|
283
|
+
|
|
70
284
|
// ── filtering + scoring ──────────────────────────────────────
|
|
285
|
+
/*
|
|
286
|
+
* What the query filters: the open mode's own children, or the root commands.
|
|
287
|
+
* A mode is a context, not a filter over the parent list — showing the root
|
|
288
|
+
* next to "which service?" is how a palette gets someone to restart the wrong
|
|
289
|
+
* thing.
|
|
290
|
+
*/
|
|
291
|
+
const source = computed<Command[]>(() => (mode.value ? modeItems.value : props.commands))
|
|
292
|
+
|
|
71
293
|
// No query → everything (grouped). With a query, score by where it hits:
|
|
72
294
|
// label > keywords > description, then alphabetical. Mirrors the app palette.
|
|
73
295
|
const ranked = computed<Command[]>(() => {
|
|
74
296
|
const q = query.value.trim().toLowerCase()
|
|
75
297
|
// Empty query: show everything EXCEPT hidden commands (they only surface once
|
|
76
298
|
// typed). With a query, hidden commands rank like any other.
|
|
77
|
-
if (!q) return
|
|
78
|
-
return
|
|
299
|
+
if (!q) return source.value.filter((c) => !c.hidden)
|
|
300
|
+
return source.value
|
|
79
301
|
.map((c) => {
|
|
80
302
|
const label = c.label.toLowerCase().includes(q)
|
|
81
303
|
const kw = (c.keywords ?? []).some((k) => k.toLowerCase().includes(q))
|
|
@@ -88,18 +310,56 @@ const ranked = computed<Command[]>(() => {
|
|
|
88
310
|
.map((e) => e.c)
|
|
89
311
|
})
|
|
90
312
|
|
|
313
|
+
/*
|
|
314
|
+
* Fetched results sit under their own heading unless they brought a group of
|
|
315
|
+
* their own, so they never look like they were part of the static list. An id
|
|
316
|
+
* already matched locally is dropped: the same command listed twice is a
|
|
317
|
+
* misclick waiting to happen.
|
|
318
|
+
*/
|
|
319
|
+
const fetched = computed<Command[]>(() => {
|
|
320
|
+
if (!found.value.length) return []
|
|
321
|
+
const seen = new Set(ranked.value.map((c) => c.id))
|
|
322
|
+
return found.value
|
|
323
|
+
.filter((c) => !seen.has(c.id))
|
|
324
|
+
.map((c) => (c.group ? c : { ...c, group: props.resultsLabel }))
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
/*
|
|
328
|
+
* Recent picks, newest first, lifted out of the list they already belong to.
|
|
329
|
+
* Only on the root's empty query: once someone types, or drills into a mode,
|
|
330
|
+
* relevance is the better order and a duplicated row is just noise.
|
|
331
|
+
*/
|
|
332
|
+
const recentItems = computed<Command[]>(() => {
|
|
333
|
+
if (mode.value || query.value.trim() || !props.recent?.length) return []
|
|
334
|
+
const byId = new Map(props.commands.map((c) => [c.id, c]))
|
|
335
|
+
const out: Command[] = []
|
|
336
|
+
for (const id of props.recent) {
|
|
337
|
+
const c = byId.get(id)
|
|
338
|
+
if (c && !c.disabled && !out.includes(c)) out.push(c)
|
|
339
|
+
if (out.length >= props.recentLimit) break
|
|
340
|
+
}
|
|
341
|
+
return out
|
|
342
|
+
})
|
|
343
|
+
|
|
91
344
|
// Group preserving first-seen order; ungrouped commands sit under "".
|
|
345
|
+
// Recent first (it answers "the thing I always run"), then the matched list,
|
|
346
|
+
// then whatever the provider found.
|
|
92
347
|
const grouped = computed<{ name: string; items: Command[] }[]>(() => {
|
|
93
348
|
const order: string[] = []
|
|
94
349
|
const map = new Map<string, Command[]>()
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
order.push(g)
|
|
350
|
+
const push = (c: Command, group: string) => {
|
|
351
|
+
if (!map.has(group)) {
|
|
352
|
+
map.set(group, [])
|
|
353
|
+
order.push(group)
|
|
100
354
|
}
|
|
101
|
-
map.get(
|
|
355
|
+
map.get(group)!.push(c)
|
|
102
356
|
}
|
|
357
|
+
const recent = recentItems.value
|
|
358
|
+
for (const c of recent) push(c, props.recentLabel)
|
|
359
|
+
// A recent entry is shown once: leaving the copy in its own group would put
|
|
360
|
+
// the same row on screen twice with nothing to tell them apart.
|
|
361
|
+
for (const c of ranked.value) if (!recent.includes(c)) push(c, c.group ?? "")
|
|
362
|
+
for (const c of fetched.value) push(c, c.group ?? "")
|
|
103
363
|
return order.map((name) => ({ name, items: map.get(name)! }))
|
|
104
364
|
})
|
|
105
365
|
|
|
@@ -146,7 +406,10 @@ function highlight(value: string): Part[] {
|
|
|
146
406
|
}
|
|
147
407
|
|
|
148
408
|
// ── selection + keyboard ─────────────────────────────────────
|
|
149
|
-
watch(query, () =>
|
|
409
|
+
watch(query, () => {
|
|
410
|
+
selectedIndex.value = 0
|
|
411
|
+
refresh()
|
|
412
|
+
})
|
|
150
413
|
watch(
|
|
151
414
|
() => flat.value.length,
|
|
152
415
|
(len) => {
|
|
@@ -181,16 +444,61 @@ function run(c: Command | undefined) {
|
|
|
181
444
|
if (!c || c.disabled) return
|
|
182
445
|
emit("select", c)
|
|
183
446
|
c.onSelect?.()
|
|
447
|
+
// A command with children drills in instead of closing, and is navigation
|
|
448
|
+
// rather than a pick — so it stays out of the recency history.
|
|
449
|
+
if (c.children) {
|
|
450
|
+
enter(c)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
emit("run", c.id, c)
|
|
454
|
+
if (c.keepOpen) {
|
|
455
|
+
// Back to the top of a fresh list: the query that found this command is
|
|
456
|
+
// spent, and leaving it would hide whatever the action just changed.
|
|
457
|
+
query.value = ""
|
|
458
|
+
selectedIndex.value = 0
|
|
459
|
+
inputRef.value?.focus()
|
|
460
|
+
return
|
|
461
|
+
}
|
|
184
462
|
emit("update:open", false)
|
|
185
463
|
}
|
|
186
464
|
|
|
187
|
-
|
|
465
|
+
/*
|
|
466
|
+
* Backspace on an empty query leaves the mode. Mirrors how the Slack and
|
|
467
|
+
* Linear palettes behave, and means the way out is the key already under the
|
|
468
|
+
* finger — Escape stays the way out of the palette itself.
|
|
469
|
+
*/
|
|
470
|
+
function onBackspace(event: KeyboardEvent) {
|
|
471
|
+
if (query.value) return
|
|
472
|
+
if (back()) event.preventDefault()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/*
|
|
476
|
+
* Escape unwinds one mode at a time and only closes at the root, so drilling
|
|
477
|
+
* in three levels does not make leaving one an all-or-nothing choice. reka's
|
|
478
|
+
* Dialog would close on its own, so the key is handled here and declined
|
|
479
|
+
* (stopPropagation) only while there is a mode to pop.
|
|
480
|
+
*/
|
|
481
|
+
function onEscape(event: KeyboardEvent) {
|
|
482
|
+
if (!trail.value.length) return
|
|
483
|
+
event.preventDefault()
|
|
484
|
+
event.stopPropagation()
|
|
485
|
+
back()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Reset + focus when opening; clear on close. The trail resets too: reopening
|
|
489
|
+
// into the mode someone left three screens deep is never what they meant by
|
|
490
|
+
// ⌘K, and a pending request from last time is dropped rather than landing in
|
|
491
|
+
// the new list.
|
|
188
492
|
watch(
|
|
189
493
|
() => props.open,
|
|
190
494
|
async (o) => {
|
|
495
|
+
cancel()
|
|
496
|
+
query.value = ""
|
|
497
|
+
selectedIndex.value = 0
|
|
498
|
+
trail.value = []
|
|
499
|
+
modeItems.value = []
|
|
500
|
+
found.value = []
|
|
191
501
|
if (o) {
|
|
192
|
-
query.value = ""
|
|
193
|
-
selectedIndex.value = 0
|
|
194
502
|
await nextTick()
|
|
195
503
|
inputRef.value?.focus()
|
|
196
504
|
}
|
|
@@ -228,26 +536,52 @@ useHotkeys(() =>
|
|
|
228
536
|
<DialogTitle class="sr-only">Command palette</DialogTitle>
|
|
229
537
|
<DialogDescription class="sr-only">Search and run a command.</DialogDescription>
|
|
230
538
|
|
|
231
|
-
<!-- Filter
|
|
539
|
+
<!-- Filter. The trail sits inside the box as chips, the way a scoped
|
|
540
|
+
search reads everywhere else: what you type is filtered by what is
|
|
541
|
+
to its left. -->
|
|
232
542
|
<div class="flex shrink-0 items-center gap-2.5 border-b border-line px-4">
|
|
233
|
-
<LpIcon
|
|
543
|
+
<LpIcon
|
|
544
|
+
v-if="!searching"
|
|
545
|
+
name="lucide:search"
|
|
546
|
+
:size="18"
|
|
547
|
+
class="shrink-0 text-muted"
|
|
548
|
+
/>
|
|
549
|
+
<LpIcon v-else name="lucide:loader-circle" :size="18" class="shrink-0 animate-spin text-muted" />
|
|
550
|
+
|
|
551
|
+
<button
|
|
552
|
+
v-for="(step, i) in trail"
|
|
553
|
+
:key="step.id"
|
|
554
|
+
type="button"
|
|
555
|
+
class="flex shrink-0 items-center gap-1.5 rounded-md bg-brand-soft px-2 py-1 text-xs font-medium text-brand transition-opacity hover:opacity-80"
|
|
556
|
+
:title="text.back"
|
|
557
|
+
@click="popTo(i)"
|
|
558
|
+
>
|
|
559
|
+
<LpIcon v-if="step.icon" :name="step.icon" :size="13" />
|
|
560
|
+
{{ step.label }}
|
|
561
|
+
<LpIcon name="lucide:x" :size="12" class="opacity-60" />
|
|
562
|
+
</button>
|
|
563
|
+
|
|
234
564
|
<input
|
|
235
565
|
ref="inputRef"
|
|
236
566
|
v-model="query"
|
|
237
567
|
type="text"
|
|
238
|
-
:placeholder="placeholder"
|
|
568
|
+
:placeholder="mode?.childPlaceholder ?? placeholder"
|
|
239
569
|
class="h-12 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-muted"
|
|
240
570
|
@keydown.down.prevent="moveDown"
|
|
241
571
|
@keydown.up.prevent="moveUp"
|
|
242
572
|
@keydown.enter.prevent="runSelected"
|
|
573
|
+
@keydown.backspace="onBackspace"
|
|
574
|
+
@keydown.esc="onEscape"
|
|
243
575
|
/>
|
|
244
576
|
<kbd class="shrink-0 rounded border border-line px-1.5 py-0.5 text-[10px] text-muted">esc</kbd>
|
|
245
577
|
</div>
|
|
246
578
|
|
|
247
579
|
<!-- Results -->
|
|
248
580
|
<LpScrollArea ref="scrollRef" class="min-h-0 flex-1" content-class="p-2">
|
|
581
|
+
<!-- While a request is out there is nothing to say yet: "no results"
|
|
582
|
+
under a spinner reads as an answer, and it is not one. -->
|
|
249
583
|
<p v-if="flat.length === 0" class="px-2 py-10 text-center text-sm text-muted">
|
|
250
|
-
{{ emptyText }}
|
|
584
|
+
{{ searching ? text.searching : emptyText }}
|
|
251
585
|
</p>
|
|
252
586
|
|
|
253
587
|
<template v-for="group in grouped" :key="group.name">
|
|
@@ -284,6 +618,14 @@ useHotkeys(() =>
|
|
|
284
618
|
</span>
|
|
285
619
|
</span>
|
|
286
620
|
<kbd v-if="c.shortcut" class="shrink-0 text-xs text-muted">{{ c.shortcut }}</kbd>
|
|
621
|
+
<!-- Drills in rather than runs: worth saying before the click,
|
|
622
|
+
since the two look identical otherwise. -->
|
|
623
|
+
<LpIcon
|
|
624
|
+
v-else-if="c.children"
|
|
625
|
+
name="lucide:chevron-right"
|
|
626
|
+
:size="14"
|
|
627
|
+
class="shrink-0 text-muted"
|
|
628
|
+
/>
|
|
287
629
|
</button>
|
|
288
630
|
</template>
|
|
289
631
|
</LpScrollArea>
|
|
@@ -295,15 +637,21 @@ useHotkeys(() =>
|
|
|
295
637
|
<span class="flex items-center gap-1">
|
|
296
638
|
<kbd class="rounded border border-line px-1 text-[10px]">↑</kbd>
|
|
297
639
|
<kbd class="rounded border border-line px-1 text-[10px]">↓</kbd>
|
|
298
|
-
navigate
|
|
640
|
+
{{ text.navigate }}
|
|
299
641
|
</span>
|
|
300
642
|
<span class="flex items-center gap-1">
|
|
301
643
|
<kbd class="rounded border border-line px-1 text-[10px]">↵</kbd>
|
|
302
|
-
select
|
|
644
|
+
{{ text.select }}
|
|
645
|
+
</span>
|
|
646
|
+
<!-- Only inside a mode: at the root Backspace just edits the query,
|
|
647
|
+
and advertising it there would be a lie. -->
|
|
648
|
+
<span v-if="trail.length" class="flex items-center gap-1">
|
|
649
|
+
<kbd class="rounded border border-line px-1 text-[10px]">⌫</kbd>
|
|
650
|
+
{{ text.back }}
|
|
303
651
|
</span>
|
|
304
652
|
<span class="ml-auto flex items-center gap-1">
|
|
305
653
|
<kbd class="rounded border border-line px-1 text-[10px]">esc</kbd>
|
|
306
|
-
close
|
|
654
|
+
{{ text.close }}
|
|
307
655
|
</span>
|
|
308
656
|
</div>
|
|
309
657
|
</DialogContent>
|
package/src/index.ts
CHANGED
|
@@ -64,7 +64,7 @@ export {
|
|
|
64
64
|
} from "./components/color"
|
|
65
65
|
export type { ColorFormat, Hsv, Rgb } from "./components/color"
|
|
66
66
|
export { default as LpCommandPalette } from "./components/LpCommandPalette.vue"
|
|
67
|
-
export type { Command } from "./components/LpCommandPalette.vue"
|
|
67
|
+
export type { Command, PaletteLabels } from "./components/LpCommandPalette.vue"
|
|
68
68
|
export { default as LpConfirmDialog } from "./components/LpConfirmDialog.vue"
|
|
69
69
|
export { default as LpContextMenu } from "./components/LpContextMenu.vue"
|
|
70
70
|
export type { ContextMenuItemDef } from "./components/LpContextMenu.vue"
|