@leavepulse/ui 0.37.10 → 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.
@@ -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). Scoring ranks label >
10
- * keywords > description; matches are highlighted; the active row scrolls into
11
- * view. Open state is v-model:open; a global ⌘K/Ctrl-K toggle is on by default.
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
- // Abort or a failing provider: the static commands still stand.
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: () => (found.value = []),
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
- // No query → everything (grouped). With a query, score by where it hits:
273
- // label > keywords > description, then alphabetical. Mirrors the app palette.
274
- const ranked = computed<Command[]>(() => {
275
- const q = query.value.trim().toLowerCase()
276
- // Empty query: show everything EXCEPT hidden commands (they only surface once
277
- // typed). With a query, hidden commands rank like any other.
278
- if (!q) return source.value.filter((c) => !c.hidden)
279
- return source.value
280
- .map((c) => {
281
- const label = c.label.toLowerCase().includes(q)
282
- const kw = (c.keywords ?? []).some((k) => k.toLowerCase().includes(q))
283
- const desc = c.description?.toLowerCase().includes(q) ?? false
284
- const score = (label ? 4 : 0) + (kw ? 3 : 0) + (desc ? 2 : 0)
285
- return { c, score }
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(query.value.toLowerCase().trim().split(/\s+/).filter((t) => t.length >= 2))].sort(
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
- run(flat.value[selectedIndex.value])
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 bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
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
- <LpScrollArea ref="scrollRef" class="min-h-0 flex-1" content-class="p-2">
560
- <!-- While a request is out there is nothing to say yet: "no results"
561
- under a spinner reads as an answer, and it is not one. -->
562
- <p v-if="flat.length === 0" class="px-2 py-10 text-center text-sm text-muted">
563
- {{ searching ? text.searching : emptyText }}
564
- </p>
565
-
566
- <template v-for="group in grouped" :key="group.name">
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="group.name"
569
- class="px-2 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
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
- {{ group.name }}
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
- <button
574
- v-for="c in group.items"
575
- :key="c.id"
576
- type="button"
577
- :data-cmd-index="flatIndexOf(c)"
578
- :disabled="c.disabled"
579
- class="lp-cmd-item flex w-full items-center 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"
580
- :class="flatIndexOf(c) === selectedIndex ? 'bg-brand-soft text-brand' : 'text-ink hover:bg-surface-soft/60'"
581
- @click="run(c)"
582
- @pointerenter="selectedIndex = flatIndexOf(c)"
583
- >
584
- <LpIcon v-if="c.icon" :name="c.icon" :size="16" class="shrink-0" />
585
- <span class="min-w-0 flex-1">
586
- <span class="block truncate font-medium">
587
- <template v-for="(p, pi) in highlight(c.label)" :key="`l${pi}`">
588
- <mark v-if="p.hit" class="rounded-xs bg-brand/25 text-ink">{{ p.text }}</mark>
589
- <template v-else>{{ p.text }}</template>
590
- </template>
591
- </span>
592
- <span v-if="c.description" class="mt-0.5 block truncate text-xs text-muted">
593
- <template v-for="(p, pi) in highlight(c.description)" :key="`d${pi}`">
594
- <mark v-if="p.hit" class="rounded-xs bg-brand/20 text-ink">{{ p.text }}</mark>
595
- <template v-else>{{ p.text }}</template>
596
- </template>
597
- </span>
598
- </span>
599
- <kbd v-if="c.shortcut" class="shrink-0 text-xs text-muted">{{ c.shortcut }}</kbd>
600
- <!-- Drills in rather than runs: worth saying before the click,
601
- since the two look identical otherwise. -->
602
- <LpIcon
603
- v-else-if="c.children"
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;