@leavepulse/ui 0.41.0 → 0.42.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.
@@ -5,81 +5,81 @@
5
5
  * pane from its right edge as it grows. Animating the columns themselves made
6
6
  * every line of the list and the pane re-wrap on every frame.
7
7
  */
8
- .lp-cmd-dialog[data-v-19283172] {
8
+ .lp-cmd-dialog[data-v-7bcd1140] {
9
9
  --lp-cmd-list: min(92vw, 40rem);
10
10
  --lp-cmd-pane: max(0px, min(26rem, 96vw - var(--lp-cmd-list)));
11
11
  width: var(--lp-cmd-list);
12
12
  }
13
- .lp-cmd-split[data-v-19283172] {
13
+ .lp-cmd-split[data-v-7bcd1140] {
14
14
  transition:
15
15
  width 300ms var(--ease-emphasized, ease),
16
16
  min-height 300ms var(--ease-emphasized, ease);
17
17
  }
18
18
  @media (min-width: 768px) {
19
- .lp-cmd-split .lp-cmd-list[data-v-19283172] {
19
+ .lp-cmd-split .lp-cmd-list[data-v-7bcd1140] {
20
20
  flex: 0 0 auto;
21
21
  /* the dialog's 1px border on each side */
22
22
  width: calc(var(--lp-cmd-list) - 2px);
23
23
  }
24
- .lp-cmd-dialog[data-pane="open"][data-v-19283172] {
24
+ .lp-cmd-dialog[data-pane="open"][data-v-7bcd1140] {
25
25
  width: calc(var(--lp-cmd-list) + var(--lp-cmd-pane));
26
26
  min-height: min(76vh, 36rem);
27
27
  }
28
28
  }
29
- .lp-cmd-pane[data-v-19283172] {
29
+ .lp-cmd-pane[data-v-7bcd1140] {
30
30
  width: var(--lp-cmd-pane);
31
31
  }
32
32
  /*
33
33
  * The pane rides in from the right edge while the dialog opens up to it, and
34
34
  * leaves the same way, for as long as the dialog takes to close over it.
35
35
  */
36
- .lp-cmd-pane-enter-active[data-v-19283172],
37
- .lp-cmd-pane-leave-active[data-v-19283172] {
36
+ .lp-cmd-pane-enter-active[data-v-7bcd1140],
37
+ .lp-cmd-pane-leave-active[data-v-7bcd1140] {
38
38
  transition:
39
39
  transform 300ms var(--ease-emphasized, ease),
40
40
  opacity 300ms ease;
41
41
  }
42
- .lp-cmd-pane-enter-from[data-v-19283172],
43
- .lp-cmd-pane-leave-to[data-v-19283172] {
42
+ .lp-cmd-pane-enter-from[data-v-7bcd1140],
43
+ .lp-cmd-pane-leave-to[data-v-7bcd1140] {
44
44
  opacity: 0;
45
45
  transform: translateX(40%);
46
46
  }
47
- .lp-cmd-para[data-v-19283172] {
48
- animation: lp-cmd-para-in-19283172 260ms ease-out both;
47
+ .lp-cmd-para[data-v-7bcd1140] {
48
+ animation: lp-cmd-para-in-7bcd1140 260ms ease-out both;
49
49
  }
50
- @keyframes lp-cmd-para-in-19283172 {
50
+ @keyframes lp-cmd-para-in-7bcd1140 {
51
51
  from {
52
52
  opacity: 0;
53
53
  transform: translateY(4px);
54
54
  }
55
55
  }
56
56
  @media (prefers-reduced-motion: reduce) {
57
- .lp-cmd-split[data-v-19283172],
58
- .lp-cmd-pane-enter-active[data-v-19283172],
59
- .lp-cmd-pane-leave-active[data-v-19283172] {
57
+ .lp-cmd-split[data-v-7bcd1140],
58
+ .lp-cmd-pane-enter-active[data-v-7bcd1140],
59
+ .lp-cmd-pane-leave-active[data-v-7bcd1140] {
60
60
  transition: none;
61
61
  }
62
- .lp-cmd-para[data-v-19283172] {
62
+ .lp-cmd-para[data-v-7bcd1140] {
63
63
  animation: none;
64
64
  }
65
65
  }
66
- .lp-cmd-excerpt[data-v-19283172] {
66
+ .lp-cmd-excerpt[data-v-7bcd1140] {
67
67
  display: -webkit-box;
68
68
  -webkit-box-orient: vertical;
69
69
  overflow: hidden;
70
70
  }
71
71
  /* Each result eases in; honours reduced motion. */
72
- .lp-cmd-item[data-v-19283172] {
73
- animation: lp-cmd-in-19283172 140ms ease-out both;
72
+ .lp-cmd-item[data-v-7bcd1140] {
73
+ animation: lp-cmd-in-7bcd1140 140ms ease-out both;
74
74
  }
75
- @keyframes lp-cmd-in-19283172 {
75
+ @keyframes lp-cmd-in-7bcd1140 {
76
76
  from {
77
77
  opacity: 0;
78
78
  transform: translateY(3px);
79
79
  }
80
80
  }
81
81
  @media (prefers-reduced-motion: reduce) {
82
- .lp-cmd-item[data-v-19283172] {
82
+ .lp-cmd-item[data-v-7bcd1140] {
83
83
  animation: none;
84
84
  }
85
85
  }
@@ -7,64 +7,63 @@ import { useGlass } from "../composables/useGlass.js";
7
7
  import { useClipboard } from "../composables/useClipboard.js";
8
8
  import { useHotkeys } from "../composables/useHotkeys.js";
9
9
  import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
10
- import '../assets/LpCommandPalette-DyT6aGdW.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
10
+ import '../assets/LpCommandPalette-Esv0a-gS.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
11
11
  const _hoisted_2 = ["title", "onClick"];
12
12
  const _hoisted_3 = ["aria-activedescendant", "placeholder", "onKeydown"];
13
- const _hoisted_4 = { class: "flex min-h-0 flex-1" };
14
- const _hoisted_5 = {
13
+ const _hoisted_4 = {
15
14
  key: 0,
16
15
  class: "px-2 py-10 text-center text-sm text-muted"
17
16
  };
18
- const _hoisted_6 = ["aria-labelledby"];
19
- const _hoisted_7 = ["id"];
20
- const _hoisted_8 = { class: "min-w-0 flex-1" };
21
- const _hoisted_9 = { class: "block truncate font-medium" };
22
- const _hoisted_10 = {
17
+ const _hoisted_5 = ["aria-labelledby"];
18
+ const _hoisted_6 = ["id"];
19
+ const _hoisted_7 = { class: "min-w-0 flex-1" };
20
+ const _hoisted_8 = { class: "block truncate font-medium" };
21
+ const _hoisted_9 = {
23
22
  key: 0,
24
23
  class: "rounded-xs bg-brand/25 text-ink"
25
24
  };
26
- const _hoisted_11 = {
25
+ const _hoisted_10 = {
27
26
  key: 0,
28
27
  class: "mt-0.5 block truncate text-xs text-muted"
29
28
  };
30
- const _hoisted_12 = {
29
+ const _hoisted_11 = {
31
30
  key: 0,
32
31
  class: "rounded-xs bg-brand/20 text-ink"
33
32
  };
34
- const _hoisted_13 = {
33
+ const _hoisted_12 = {
35
34
  key: 0,
36
35
  class: "rounded-xs bg-brand/20 text-ink"
37
36
  };
38
- const _hoisted_14 = {
37
+ const _hoisted_13 = {
39
38
  key: 1,
40
39
  class: "shrink-0 text-xs text-muted"
41
40
  };
42
- const _hoisted_15 = {
41
+ const _hoisted_14 = {
43
42
  key: 0,
44
43
  class: "lp-cmd-pane hidden min-h-0 shrink-0 flex-col border-l border-line md:flex"
45
44
  };
46
- const _hoisted_16 = { class: "lp-cmd-para flex items-center gap-2 text-ink" };
47
- const _hoisted_17 = { class: "min-w-0 text-base font-semibold" };
48
- const _hoisted_18 = {
45
+ const _hoisted_15 = { class: "lp-cmd-para flex items-center gap-2 text-ink" };
46
+ const _hoisted_16 = { class: "min-w-0 text-base font-semibold" };
47
+ const _hoisted_17 = {
49
48
  key: 0,
50
49
  class: "lp-cmd-para mt-0.5 text-xs text-muted"
51
50
  };
52
- const _hoisted_19 = {
51
+ const _hoisted_18 = {
53
52
  key: 0,
54
53
  class: "rounded-xs bg-brand/25 text-ink"
55
54
  };
56
- const _hoisted_20 = { class: "flex shrink-0 items-center gap-4 border-t border-line px-4 py-2.5 text-[11px] text-muted" };
55
+ const _hoisted_19 = { class: "flex shrink-0 items-center gap-4 border-t border-line px-4 py-2.5 text-[11px] text-muted" };
56
+ const _hoisted_20 = { class: "flex items-center gap-1" };
57
57
  const _hoisted_21 = { class: "flex items-center gap-1" };
58
- const _hoisted_22 = { class: "flex items-center gap-1" };
59
- const _hoisted_23 = {
58
+ const _hoisted_22 = {
60
59
  key: 0,
61
60
  class: "flex items-center gap-1"
62
61
  };
63
- const _hoisted_24 = {
62
+ const _hoisted_23 = {
64
63
  key: 1,
65
64
  class: "flex items-center gap-1"
66
65
  };
67
- const _hoisted_25 = { class: "ml-auto flex items-center gap-1" };
66
+ const _hoisted_24 = { class: "ml-auto flex items-center gap-1" };
68
67
  const LATIN = "qwertyuiop[]asdfghjkl;'zxcvbnm,.`";
69
68
  const CYRILLIC = "йцукенгшщзхъфывапролджэячсмитьбюё";
70
69
  const TYPO_MIN = 5;
@@ -120,9 +119,11 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
120
119
  }
121
120
  const activeId = computed(() => flat.value.length ? optionId(selectedIndex.value) : void 0);
122
121
  const selectedIndex = ref(0);
122
+ const selectedId = ref(null);
123
123
  const inputRef = ref(null);
124
124
  const scrollRef = ref(null);
125
125
  const trail = shallowRef([]);
126
+ const trailState = shallowRef([]);
126
127
  const mode = computed(() => trail.value[trail.value.length - 1] ?? null);
127
128
  const modeItems = shallowRef([]);
128
129
  const { copy } = useClipboard();
@@ -150,30 +151,37 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
150
151
  }
151
152
  const selectedActions = computed(() => actionsOf(flat.value[selectedIndex.value]));
152
153
  function onTab(event) {
154
+ event.preventDefault();
155
+ event.stopPropagation();
153
156
  if (event.shiftKey) return;
154
157
  const c = flat.value[selectedIndex.value];
155
158
  const actions = selectedActions.value;
156
159
  if (!c || !actions.length) return;
157
- event.preventDefault();
158
- event.stopPropagation();
159
160
  enter({ id: `${c.id}:actions`, label: c.label, icon: c.icon, children: actions });
160
161
  }
161
162
  function enter(c) {
163
+ trailState.value = [...trailState.value, { query: query.value, selectedId: selectedId.value }];
162
164
  trail.value = [...trail.value, c];
163
165
  query.value = "";
164
- selectedIndex.value = 0;
166
+ select(0);
165
167
  modeItems.value = Array.isArray(c.children) ? c.children : [];
166
168
  refresh();
167
169
  inputRef.value?.focus();
168
170
  }
169
171
  function popTo(depth) {
172
+ const state = trailState.value[depth] || { query: "", selectedId: null };
170
173
  const next = trail.value.slice(0, Math.max(0, depth));
174
+ trailState.value = trailState.value.slice(0, Math.max(0, depth));
171
175
  trail.value = next;
172
- query.value = "";
173
- selectedIndex.value = 0;
174
176
  const parent = next[next.length - 1];
175
177
  modeItems.value = parent && Array.isArray(parent.children) ? parent.children : [];
178
+ query.value = state.query;
179
+ select(0);
176
180
  refresh();
181
+ void nextTick(() => {
182
+ const index = state.selectedId ? flat.value.findIndex((c) => c.id === state.selectedId) : -1;
183
+ select(index >= 0 ? index : 0);
184
+ });
177
185
  inputRef.value?.focus();
178
186
  }
179
187
  function back() {
@@ -234,12 +242,30 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
234
242
  if (term.length < TYPO_MIN) return false;
235
243
  return label.split(/[^\p{L}\p{N}]+/u).some((word) => withinOne(term, word) || word.length > term.length && withinOne(term, word.slice(0, term.length)));
236
244
  }
245
+ const QUERY_TOKEN = /(-?)(?:(in):)?(?:"([^"]*)"|(\S+))/giu;
246
+ function searchTerms(value) {
247
+ const out = { include: [], exclude: [], groups: [] };
248
+ QUERY_TOKEN.lastIndex = 0;
249
+ for (let match; match = QUERY_TOKEN.exec(value); ) {
250
+ const text2 = (match[3] ?? match[4] ?? "").trim().toLowerCase();
251
+ if (!text2) continue;
252
+ if (match[2] && !match[1]) out.groups.push(text2);
253
+ else if (match[1]) out.exclude.push(text2);
254
+ else out.include.push(text2);
255
+ }
256
+ return out;
257
+ }
258
+ function matches(c, term) {
259
+ return c.label.toLowerCase().includes(term) || (c.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(term)) || (c.description?.toLowerCase().includes(term) ?? false);
260
+ }
237
261
  function score(c, terms) {
238
262
  const label = c.label.toLowerCase();
239
263
  const keywords = (c.keywords ?? []).map((k) => k.toLowerCase());
240
264
  const description = c.description?.toLowerCase() ?? "";
241
- let total = 0;
242
- for (const term of terms) {
265
+ const group = c.group?.toLowerCase() ?? "";
266
+ if (terms.exclude.some((term) => matches(c, term)) || terms.groups.some((term) => !group.includes(term))) return 0;
267
+ let total = terms.include.length ? 0 : 1;
268
+ for (const term of terms.include) {
243
269
  let hit = (label.includes(term) ? 4 : 0) + (keywords.some((k) => k.includes(term)) ? 3 : 0) + (description.includes(term) ? 2 : 0);
244
270
  if (!hit && nearWord(term, label)) hit = 1;
245
271
  if (!hit) return 0;
@@ -250,15 +276,19 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
250
276
  function rank(items, terms) {
251
277
  return items.map((c) => ({ c, score: score(c, terms) })).filter((e) => e.score > 0).sort((a, b) => b.score - a.score || a.c.label.localeCompare(b.c.label)).map((e) => e.c);
252
278
  }
253
- const typedTerms = computed(() => query.value.trim().toLowerCase().split(/\s+/).filter(Boolean));
254
279
  const local = computed(() => {
255
- const terms = typedTerms.value;
256
- if (!terms.length) return { items: source.value.filter((c) => !c.hidden), terms };
257
- const items = rank(source.value, terms);
258
- if (items.length || !props.layoutFallback) return { items, terms };
259
- const swapped = terms.map(swapLayout);
260
- const other = rank(source.value, swapped);
261
- return other.length ? { items: other, terms: swapped } : { items, terms };
280
+ const terms = searchTerms(query.value);
281
+ const filtered = query.value.trim() ? source.value : source.value.filter((c) => !c.hidden);
282
+ if (!terms.include.length && !terms.exclude.length && !terms.groups.length) return { items: filtered, terms: [] };
283
+ const items = rank(filtered, terms);
284
+ if (items.length || !props.layoutFallback) return { items, terms: terms.include };
285
+ const swapped = {
286
+ include: terms.include.map(swapLayout),
287
+ exclude: terms.exclude.map(swapLayout),
288
+ groups: terms.groups.map(swapLayout)
289
+ };
290
+ const other = rank(filtered, swapped);
291
+ return other.length ? { items: other, terms: swapped.include } : { items, terms: terms.include };
262
292
  });
263
293
  const ranked = computed(() => local.value.items);
264
294
  const fetched = computed(() => {
@@ -364,16 +394,17 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
364
394
  return parts;
365
395
  }
366
396
  watch(query, () => {
367
- selectedIndex.value = 0;
397
+ select(0);
368
398
  refresh();
369
399
  });
370
400
  watch([query, trail, () => props.open], () => {
371
401
  expanded.value = /* @__PURE__ */ new Set();
372
402
  });
373
403
  watch(
374
- () => flat.value.length,
375
- (len) => {
376
- if (selectedIndex.value >= len) selectedIndex.value = Math.max(0, len - 1);
404
+ () => flat.value.map((c) => c.id).join("|"),
405
+ () => {
406
+ const index = selectedId.value ? flat.value.findIndex((c) => c.id === selectedId.value) : -1;
407
+ select(index >= 0 ? index : 0);
377
408
  }
378
409
  );
379
410
  watch([selectedIndex, () => flat.value.map((c) => c.id).join("|")], async () => {
@@ -386,13 +417,40 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
386
417
  );
387
418
  el?.scrollIntoView({ block: "nearest" });
388
419
  }
420
+ const keyboardSelection = ref(false);
421
+ function select(index, keyboard = false) {
422
+ const len = flat.value.length;
423
+ selectedIndex.value = len ? Math.max(0, Math.min(index, len - 1)) : 0;
424
+ selectedId.value = flat.value[selectedIndex.value]?.id ?? null;
425
+ if (keyboard) keyboardSelection.value = true;
426
+ }
389
427
  function moveDown() {
390
428
  const len = flat.value.length;
391
- if (len) selectedIndex.value = (selectedIndex.value + 1) % len;
429
+ if (len) select((selectedIndex.value + 1) % len, true);
392
430
  }
393
431
  function moveUp() {
394
432
  const len = flat.value.length;
395
- if (len) selectedIndex.value = (selectedIndex.value - 1 + len) % len;
433
+ if (len) select((selectedIndex.value - 1 + len) % len, true);
434
+ }
435
+ function movePage(direction) {
436
+ select(selectedIndex.value + direction * 8, true);
437
+ }
438
+ function onPaletteKeydown(event) {
439
+ if (event.isComposing) return;
440
+ if (!["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(event.key)) return;
441
+ event.preventDefault();
442
+ event.stopPropagation();
443
+ if (event.key === "ArrowDown") moveDown();
444
+ else if (event.key === "ArrowUp") moveUp();
445
+ else if (event.key === "Home") select(0, true);
446
+ else if (event.key === "End") select(flat.value.length - 1, true);
447
+ else movePage(event.key === "PageDown" ? 1 : -1);
448
+ }
449
+ function onPointerMove() {
450
+ keyboardSelection.value = false;
451
+ }
452
+ function onPointerEnter(c) {
453
+ if (!keyboardSelection.value) select(flatIndexOf(c));
396
454
  }
397
455
  function runSelected(event) {
398
456
  const c = flat.value[selectedIndex.value];
@@ -431,7 +489,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
431
489
  emit("run", c.id, c);
432
490
  if (c.keepOpen) {
433
491
  query.value = "";
434
- selectedIndex.value = 0;
492
+ select(0);
435
493
  inputRef.value?.focus();
436
494
  return;
437
495
  }
@@ -452,8 +510,10 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
452
510
  async (o) => {
453
511
  cancel();
454
512
  query.value = "";
455
- selectedIndex.value = 0;
513
+ select(0);
514
+ selectedId.value = null;
456
515
  trail.value = [];
516
+ trailState.value = [];
457
517
  modeItems.value = [];
458
518
  found.value = [];
459
519
  failed.value = false;
@@ -490,7 +550,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
490
550
  class: normalizeClass(["lp-cmd-dialog fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]", [unref(frosted) ? "lp-glass-overlay" : "bg-surface-raised", __props.previewPane && "lp-cmd-split"]]),
491
551
  "data-pane": paneOpen.value ? "open" : void 0,
492
552
  onOpenAutoFocus: _cache[2] || (_cache[2] = withModifiers(() => {
493
- }, ["prevent"]))
553
+ }, ["prevent"])),
554
+ onKeydownCapture: onPaletteKeydown
494
555
  }, {
495
556
  default: withCtx(() => [
496
557
  createVNode(unref(DialogTitle), { class: "sr-only" }, {
@@ -551,8 +612,6 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
551
612
  placeholder: mode.value?.childPlaceholder ?? __props.placeholder,
552
613
  class: "h-12 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-muted",
553
614
  onKeydown: [
554
- withKeys(withModifiers(moveDown, ["prevent"]), ["down"]),
555
- withKeys(withModifiers(moveUp, ["prevent"]), ["up"]),
556
615
  withKeys(withModifiers(runSelected, ["prevent"]), ["enter"]),
557
616
  withKeys(onBackspace, ["backspace"]),
558
617
  withKeys(onEscape, ["esc"]),
@@ -563,7 +622,10 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
563
622
  ]),
564
623
  _cache[6] || (_cache[6] = createElementVNode("kbd", { class: "shrink-0 rounded border border-line px-1.5 py-0.5 text-[10px] text-muted" }, "esc", -1))
565
624
  ]),
566
- createElementVNode("div", _hoisted_4, [
625
+ createElementVNode("div", {
626
+ class: "flex min-h-0 flex-1",
627
+ onPointermove: onPointerMove
628
+ }, [
567
629
  createVNode(_sfc_main$2, {
568
630
  ref_key: "scrollRef",
569
631
  ref: scrollRef,
@@ -571,7 +633,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
571
633
  "content-class": "p-2"
572
634
  }, {
573
635
  default: withCtx(() => [
574
- flat.value.length === 0 && !failed.value ? (openBlock(), createElementBlock("p", _hoisted_5, toDisplayString(unref(searching) ? text.value.searching : __props.emptyText), 1)) : createCommentVNode("", true),
636
+ flat.value.length === 0 && !failed.value ? (openBlock(), createElementBlock("p", _hoisted_4, toDisplayString(unref(searching) ? text.value.searching : __props.emptyText), 1)) : createCommentVNode("", true),
575
637
  createElementVNode("div", {
576
638
  id: listId,
577
639
  role: "listbox"
@@ -586,7 +648,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
586
648
  key: 0,
587
649
  id: `${listId}-g${gi}`,
588
650
  class: "px-2 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
589
- }, toDisplayString(group.name), 9, _hoisted_7)) : createCommentVNode("", true),
651
+ }, toDisplayString(group.name), 9, _hoisted_6)) : createCommentVNode("", true),
590
652
  (openBlock(true), createElementBlock(Fragment, null, renderList(group.items, (c) => {
591
653
  return openBlock(), createBlock(resolveDynamicComponent(c.href ? "a" : "button"), {
592
654
  id: optionId(flatIndexOf(c)),
@@ -604,7 +666,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
604
666
  flatIndexOf(c) === selectedIndex.value ? "bg-brand-soft text-brand" : unref(moreOf).has(c) ? "text-muted hover:bg-surface-soft/60" : "text-ink hover:bg-surface-soft/60"
605
667
  ]]),
606
668
  onClick: ($event) => onRowClick($event, c),
607
- onPointerenter: ($event) => selectedIndex.value = flatIndexOf(c)
669
+ onPointerenter: ($event) => onPointerEnter(c)
608
670
  }, {
609
671
  default: withCtx(() => [
610
672
  c.icon ? (openBlock(), createBlock(_sfc_main$1, {
@@ -613,24 +675,24 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
613
675
  size: 16,
614
676
  class: normalizeClass(["shrink-0", { "mt-0.5": c.excerpt }])
615
677
  }, null, 8, ["name", "class"])) : createCommentVNode("", true),
616
- createElementVNode("span", _hoisted_8, [
617
- createElementVNode("span", _hoisted_9, [
678
+ createElementVNode("span", _hoisted_7, [
679
+ createElementVNode("span", _hoisted_8, [
618
680
  (openBlock(true), createElementBlock(Fragment, null, renderList(highlight(c.label, c.highlight), (p, pi) => {
619
681
  return openBlock(), createElementBlock(Fragment, {
620
682
  key: `l${pi}`
621
683
  }, [
622
- p.hit ? (openBlock(), createElementBlock("mark", _hoisted_10, toDisplayString(p.text), 1)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
684
+ p.hit ? (openBlock(), createElementBlock("mark", _hoisted_9, toDisplayString(p.text), 1)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
623
685
  createTextVNode(toDisplayString(p.text), 1)
624
686
  ], 64))
625
687
  ], 64);
626
688
  }), 128))
627
689
  ]),
628
- c.description ? (openBlock(), createElementBlock("span", _hoisted_11, [
690
+ c.description ? (openBlock(), createElementBlock("span", _hoisted_10, [
629
691
  (openBlock(true), createElementBlock(Fragment, null, renderList(highlight(c.description, c.highlight), (p, pi) => {
630
692
  return openBlock(), createElementBlock(Fragment, {
631
693
  key: `d${pi}`
632
694
  }, [
633
- p.hit ? (openBlock(), createElementBlock("mark", _hoisted_12, toDisplayString(p.text), 1)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
695
+ p.hit ? (openBlock(), createElementBlock("mark", _hoisted_11, toDisplayString(p.text), 1)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
634
696
  createTextVNode(toDisplayString(p.text), 1)
635
697
  ], 64))
636
698
  ], 64);
@@ -645,14 +707,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
645
707
  return openBlock(), createElementBlock(Fragment, {
646
708
  key: `e${pi}`
647
709
  }, [
648
- p.hit ? (openBlock(), createElementBlock("mark", _hoisted_13, toDisplayString(p.text), 1)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
710
+ p.hit ? (openBlock(), createElementBlock("mark", _hoisted_12, toDisplayString(p.text), 1)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
649
711
  createTextVNode(toDisplayString(p.text), 1)
650
712
  ], 64))
651
713
  ], 64);
652
714
  }), 128))
653
715
  ], 4)) : createCommentVNode("", true)
654
716
  ]),
655
- c.shortcut ? (openBlock(), createElementBlock("kbd", _hoisted_14, toDisplayString(c.shortcut), 1)) : c.children ? (openBlock(), createBlock(_sfc_main$1, {
717
+ c.shortcut ? (openBlock(), createElementBlock("kbd", _hoisted_13, toDisplayString(c.shortcut), 1)) : c.children ? (openBlock(), createBlock(_sfc_main$1, {
656
718
  key: 2,
657
719
  name: "lucide:chevron-right",
658
720
  size: 14,
@@ -662,7 +724,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
662
724
  _: 2
663
725
  }, 1064, ["id", "href", "type", "aria-selected", "aria-disabled", "data-cmd-index", "disabled", "class", "onClick", "onPointerenter"]);
664
726
  }), 128))
665
- ], 8, _hoisted_6);
727
+ ], 8, _hoisted_5);
666
728
  }), 128))
667
729
  ]),
668
730
  failed.value ? (openBlock(), createElementBlock("div", {
@@ -690,7 +752,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
690
752
  }, 512),
691
753
  createVNode(Transition, { name: "lp-cmd-pane" }, {
692
754
  default: withCtx(() => [
693
- paneOpen.value ? (openBlock(), createElementBlock("aside", _hoisted_15, [
755
+ paneOpen.value ? (openBlock(), createElementBlock("aside", _hoisted_14, [
694
756
  createVNode(_sfc_main$2, {
695
757
  ref_key: "previewRef",
696
758
  ref: previewRef,
@@ -702,16 +764,16 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
702
764
  key: active.value.id
703
765
  }, [
704
766
  renderSlot(_ctx.$slots, "preview", { command: active.value }, () => [
705
- createElementVNode("div", _hoisted_16, [
767
+ createElementVNode("div", _hoisted_15, [
706
768
  active.value.icon ? (openBlock(), createBlock(_sfc_main$1, {
707
769
  key: 0,
708
770
  name: active.value.icon,
709
771
  size: 16,
710
772
  class: "shrink-0 text-muted"
711
773
  }, null, 8, ["name"])) : createCommentVNode("", true),
712
- createElementVNode("h3", _hoisted_17, toDisplayString(active.value.label), 1)
774
+ createElementVNode("h3", _hoisted_16, toDisplayString(active.value.label), 1)
713
775
  ]),
714
- active.value.description ? (openBlock(), createElementBlock("p", _hoisted_18, toDisplayString(active.value.description), 1)) : createCommentVNode("", true),
776
+ active.value.description ? (openBlock(), createElementBlock("p", _hoisted_17, toDisplayString(active.value.description), 1)) : createCommentVNode("", true),
715
777
  (openBlock(true), createElementBlock(Fragment, null, renderList(paragraphs.value, (para, i) => {
716
778
  return openBlock(), createElementBlock("p", {
717
779
  key: i,
@@ -722,7 +784,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
722
784
  return openBlock(), createElementBlock(Fragment, {
723
785
  key: `p${pi}`
724
786
  }, [
725
- p.hit ? (openBlock(), createElementBlock("mark", _hoisted_19, toDisplayString(p.text), 1)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
787
+ p.hit ? (openBlock(), createElementBlock("mark", _hoisted_18, toDisplayString(p.text), 1)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
726
788
  createTextVNode(toDisplayString(p.text), 1)
727
789
  ], 64))
728
790
  ], 64);
@@ -738,26 +800,26 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
738
800
  ]),
739
801
  _: 3
740
802
  })
741
- ]),
742
- createElementVNode("div", _hoisted_20, [
743
- createElementVNode("span", _hoisted_21, [
803
+ ], 32),
804
+ createElementVNode("div", _hoisted_19, [
805
+ createElementVNode("span", _hoisted_20, [
744
806
  _cache[7] || (_cache[7] = createElementVNode("kbd", { class: "rounded border border-line px-1 text-[10px]" }, "↑", -1)),
745
807
  _cache[8] || (_cache[8] = createElementVNode("kbd", { class: "rounded border border-line px-1 text-[10px]" }, "↓", -1)),
746
808
  createTextVNode(" " + toDisplayString(text.value.navigate), 1)
747
809
  ]),
748
- createElementVNode("span", _hoisted_22, [
810
+ createElementVNode("span", _hoisted_21, [
749
811
  _cache[9] || (_cache[9] = createElementVNode("kbd", { class: "rounded border border-line px-1 text-[10px]" }, "↵", -1)),
750
812
  createTextVNode(" " + toDisplayString(text.value.select), 1)
751
813
  ]),
752
- selectedActions.value.length ? (openBlock(), createElementBlock("span", _hoisted_23, [
814
+ selectedActions.value.length ? (openBlock(), createElementBlock("span", _hoisted_22, [
753
815
  _cache[10] || (_cache[10] = createElementVNode("kbd", { class: "rounded border border-line px-1 text-[10px]" }, "tab", -1)),
754
816
  createTextVNode(" " + toDisplayString(text.value.actions), 1)
755
817
  ])) : createCommentVNode("", true),
756
- trail.value.length ? (openBlock(), createElementBlock("span", _hoisted_24, [
818
+ trail.value.length ? (openBlock(), createElementBlock("span", _hoisted_23, [
757
819
  _cache[11] || (_cache[11] = createElementVNode("kbd", { class: "rounded border border-line px-1 text-[10px]" }, "⌫", -1)),
758
820
  createTextVNode(" " + toDisplayString(text.value.back), 1)
759
821
  ])) : createCommentVNode("", true),
760
- createElementVNode("span", _hoisted_25, [
822
+ createElementVNode("span", _hoisted_24, [
761
823
  _cache[12] || (_cache[12] = createElementVNode("kbd", { class: "rounded border border-line px-1 text-[10px]" }, "esc", -1)),
762
824
  createTextVNode(" " + toDisplayString(text.value.close), 1)
763
825
  ])
@@ -774,7 +836,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
774
836
  };
775
837
  }
776
838
  });
777
- const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-19283172"]]);
839
+ const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-7bcd1140"]]);
778
840
  export {
779
841
  LpCommandPalette as default
780
842
  };
@@ -70,6 +70,8 @@ export declare function appleModifierLabel(key: string): string;
70
70
  * formality.
71
71
  */
72
72
  export interface UseHotkeysOptions {
73
+ enabled?: MaybeRefOrGetter<boolean>;
74
+ priority?: MaybeRefOrGetter<number>;
73
75
  /**
74
76
  * Only act while the focus is inside this element.
75
77
  *
@@ -1,4 +1,4 @@
1
- import { shallowReactive, computed, onMounted, onBeforeUnmount, toValue } from "vue";
1
+ import { shallowReactive, computed, toValue, onMounted, onBeforeUnmount } from "vue";
2
2
  const isAppleKeyboard = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || "");
3
3
  function appleModifierLabel(key) {
4
4
  if (!isAppleKeyboard) return key;
@@ -31,18 +31,24 @@ function matches(event, binding) {
31
31
  if (event.altKey !== Boolean(binding.alt)) return false;
32
32
  return event.key.toLowerCase() === binding.key.toLowerCase() || binding.code !== void 0 && event.code === binding.code;
33
33
  }
34
- function useHotkeys(bindings, options = {}) {
35
- function inScope(target) {
36
- const el = toValue(options.scope);
37
- if (!el) return true;
38
- const node = target instanceof Node ? target : null;
39
- return !!node && (el === node || el.contains(node));
40
- }
41
- function onKeydown(event) {
42
- if (event.isComposing) return;
43
- if (!inScope(event.target)) return;
44
- const inTextEntry = isTextEntry(event.target);
45
- for (const binding of toValue(bindings)) {
34
+ const mounted = shallowReactive(/* @__PURE__ */ new Set());
35
+ let mountOrder = 0;
36
+ let listening = false;
37
+ function inScope(target, scope) {
38
+ const el = toValue(scope);
39
+ if (!el) return true;
40
+ const node = target instanceof Node ? target : null;
41
+ return !!node && (el === node || el.contains(node));
42
+ }
43
+ function onKeydown(event) {
44
+ if (event.isComposing) return;
45
+ const inTextEntry = isTextEntry(event.target);
46
+ const entries = [...mounted].sort(
47
+ (a, b) => Number(toValue(b.priority) || 0) - Number(toValue(a.priority) || 0) || b.order - a.order
48
+ );
49
+ for (const entry of entries) {
50
+ if (toValue(entry.enabled) === false || !inScope(event.target, entry.scope)) continue;
51
+ for (const binding of entry.read()) {
46
52
  if (inTextEntry && !binding.allowInInput) continue;
47
53
  if (!matches(event, binding)) continue;
48
54
  if (binding.handler(event) === false) continue;
@@ -50,17 +56,30 @@ function useHotkeys(bindings, options = {}) {
50
56
  return;
51
57
  }
52
58
  }
53
- const read = () => toValue(bindings);
59
+ }
60
+ function useHotkeys(bindings, options = {}) {
61
+ const entry = {
62
+ read: () => toValue(bindings),
63
+ scope: options.scope,
64
+ enabled: options.enabled,
65
+ priority: options.priority,
66
+ order: ++mountOrder
67
+ };
54
68
  onMounted(() => {
55
- window.addEventListener("keydown", onKeydown);
56
- mounted.add(read);
69
+ mounted.add(entry);
70
+ if (!listening) {
71
+ window.addEventListener("keydown", onKeydown);
72
+ listening = true;
73
+ }
57
74
  });
58
75
  onBeforeUnmount(() => {
59
- window.removeEventListener("keydown", onKeydown);
60
- mounted.delete(read);
76
+ mounted.delete(entry);
77
+ if (!mounted.size && listening) {
78
+ window.removeEventListener("keydown", onKeydown);
79
+ listening = false;
80
+ }
61
81
  });
62
82
  }
63
- const mounted = shallowReactive(/* @__PURE__ */ new Set());
64
83
  const KEY_NAMES = {
65
84
  escape: "Esc",
66
85
  enter: "Enter",
@@ -80,8 +99,9 @@ function comboOf(binding) {
80
99
  }
81
100
  const activeShortcuts = computed(() => {
82
101
  const groups = /* @__PURE__ */ new Map();
83
- for (const read of mounted) {
84
- for (const binding of read()) {
102
+ for (const entry of mounted) {
103
+ if (toValue(entry.enabled) === false) continue;
104
+ for (const binding of entry.read()) {
85
105
  if (!binding.help) continue;
86
106
  const combo = binding.help.combo ?? comboOf(binding);
87
107
  const entries = groups.get(binding.help.group) ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -276,6 +276,7 @@ function optionId(index: number): string {
276
276
  }
277
277
  const activeId = computed(() => (flat.value.length ? optionId(selectedIndex.value) : undefined))
278
278
  const selectedIndex = ref(0)
279
+ const selectedId = ref<string | null>(null)
279
280
  const inputRef = ref<HTMLInputElement | null>(null)
280
281
  const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
281
282
 
@@ -290,6 +291,7 @@ const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
290
291
  * a row of data), and deep-tracking them would walk it on every keystroke.
291
292
  */
292
293
  const trail = shallowRef<Command[]>([])
294
+ const trailState = shallowRef<{ query: string; selectedId: string | null }[]>([])
293
295
  const mode = computed<Command | null>(() => trail.value[trail.value.length - 1] ?? null)
294
296
 
295
297
  /*
@@ -332,21 +334,20 @@ const selectedActions = computed(() => actionsOf(flat.value[selectedIndex.value]
332
334
  * actions it keeps that default.
333
335
  */
334
336
  function onTab(event: KeyboardEvent) {
337
+ event.preventDefault()
338
+ event.stopPropagation()
335
339
  if (event.shiftKey) return
336
340
  const c = flat.value[selectedIndex.value]
337
341
  const actions = selectedActions.value
338
342
  if (!c || !actions.length) return
339
- // The dialog's focus trap handles Tab too, and would hand focus to the
340
- // mode chip this is about to render.
341
- event.preventDefault()
342
- event.stopPropagation()
343
343
  enter({ id: `${c.id}:actions`, label: c.label, icon: c.icon, children: actions })
344
344
  }
345
345
 
346
346
  function enter(c: Command) {
347
+ trailState.value = [...trailState.value, { query: query.value, selectedId: selectedId.value }]
347
348
  trail.value = [...trail.value, c]
348
349
  query.value = ""
349
- selectedIndex.value = 0
350
+ select(0)
350
351
  modeItems.value = Array.isArray(c.children) ? c.children : []
351
352
  refresh()
352
353
  // Entering by click leaves the focus on the button that no longer exists,
@@ -357,13 +358,19 @@ function enter(c: Command) {
357
358
 
358
359
  /** Unwind the trail to `depth` entries — 0 is the root. */
359
360
  function popTo(depth: number) {
361
+ const state = trailState.value[depth] || { query: "", selectedId: null }
360
362
  const next = trail.value.slice(0, Math.max(0, depth))
363
+ trailState.value = trailState.value.slice(0, Math.max(0, depth))
361
364
  trail.value = next
362
- query.value = ""
363
- selectedIndex.value = 0
364
365
  const parent = next[next.length - 1]
365
366
  modeItems.value = parent && Array.isArray(parent.children) ? parent.children : []
367
+ query.value = state.query
368
+ select(0)
366
369
  refresh()
370
+ void nextTick(() => {
371
+ const index = state.selectedId ? flat.value.findIndex((c) => c.id === state.selectedId) : -1
372
+ select(index >= 0 ? index : 0)
373
+ })
367
374
  inputRef.value?.focus()
368
375
  }
369
376
 
@@ -487,12 +494,43 @@ function nearWord(term: string, label: string): boolean {
487
494
  * the same weights the palette always had; a typo match ranks below all of
488
495
  * them, so an exact hit is never outranked by a near one.
489
496
  */
490
- function score(c: Command, terms: string[]): number {
497
+ interface SearchTerms {
498
+ include: string[]
499
+ exclude: string[]
500
+ groups: string[]
501
+ }
502
+
503
+ const QUERY_TOKEN = /(-?)(?:(in):)?(?:"([^"]*)"|(\S+))/giu
504
+
505
+ function searchTerms(value: string): SearchTerms {
506
+ const out: SearchTerms = { include: [], exclude: [], groups: [] }
507
+ QUERY_TOKEN.lastIndex = 0
508
+ for (let match; (match = QUERY_TOKEN.exec(value)); ) {
509
+ const text = (match[3] ?? match[4] ?? "").trim().toLowerCase()
510
+ if (!text) continue
511
+ if (match[2] && !match[1]) out.groups.push(text)
512
+ else if (match[1]) out.exclude.push(text)
513
+ else out.include.push(text)
514
+ }
515
+ return out
516
+ }
517
+
518
+ function matches(c: Command, term: string): boolean {
519
+ return (
520
+ c.label.toLowerCase().includes(term) ||
521
+ (c.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(term)) ||
522
+ (c.description?.toLowerCase().includes(term) ?? false)
523
+ )
524
+ }
525
+
526
+ function score(c: Command, terms: SearchTerms): number {
491
527
  const label = c.label.toLowerCase()
492
528
  const keywords = (c.keywords ?? []).map((k) => k.toLowerCase())
493
529
  const description = c.description?.toLowerCase() ?? ""
494
- let total = 0
495
- for (const term of terms) {
530
+ const group = c.group?.toLowerCase() ?? ""
531
+ if (terms.exclude.some((term) => matches(c, term)) || terms.groups.some((term) => !group.includes(term))) return 0
532
+ let total = terms.include.length ? 0 : 1
533
+ for (const term of terms.include) {
496
534
  let hit =
497
535
  (label.includes(term) ? 4 : 0) +
498
536
  (keywords.some((k) => k.includes(term)) ? 3 : 0) +
@@ -504,7 +542,7 @@ function score(c: Command, terms: string[]): number {
504
542
  return total
505
543
  }
506
544
 
507
- function rank(items: Command[], terms: string[]): Command[] {
545
+ function rank(items: Command[], terms: SearchTerms): Command[] {
508
546
  return items
509
547
  .map((c) => ({ c, score: score(c, terms) }))
510
548
  .filter((e) => e.score > 0)
@@ -512,22 +550,27 @@ function rank(items: Command[], terms: string[]): Command[] {
512
550
  .map((e) => e.c)
513
551
  }
514
552
 
515
- const typedTerms = computed(() => query.value.trim().toLowerCase().split(/\s+/).filter(Boolean))
516
-
517
553
  /*
518
554
  * The local match and the terms it matched with — the swapped ones when the
519
555
  * layout fallback is what found it, so the highlight marks what actually hit.
520
556
  */
521
557
  const local = computed<{ items: Command[]; terms: string[] }>(() => {
522
- const terms = typedTerms.value
558
+ const terms = searchTerms(query.value)
559
+ const filtered = query.value.trim()
560
+ ? source.value
561
+ : source.value.filter((c) => !c.hidden)
523
562
  // Empty query: show everything EXCEPT hidden commands (they only surface once
524
563
  // typed). With a query, hidden commands rank like any other.
525
- if (!terms.length) return { items: source.value.filter((c) => !c.hidden), terms }
526
- const items = rank(source.value, terms)
527
- if (items.length || !props.layoutFallback) return { items, terms }
528
- const swapped = terms.map(swapLayout)
529
- const other = rank(source.value, swapped)
530
- return other.length ? { items: other, terms: swapped } : { items, terms }
564
+ if (!terms.include.length && !terms.exclude.length && !terms.groups.length) return { items: filtered, terms: [] }
565
+ const items = rank(filtered, terms)
566
+ if (items.length || !props.layoutFallback) return { items, terms: terms.include }
567
+ const swapped: SearchTerms = {
568
+ include: terms.include.map(swapLayout),
569
+ exclude: terms.exclude.map(swapLayout),
570
+ groups: terms.groups.map(swapLayout),
571
+ }
572
+ const other = rank(filtered, swapped)
573
+ return other.length ? { items: other, terms: swapped.include } : { items, terms: terms.include }
531
574
  })
532
575
 
533
576
  const ranked = computed<Command[]>(() => local.value.items)
@@ -706,16 +749,17 @@ function highlight(value: string, extra: string[] = []): Part[] {
706
749
 
707
750
  // ── selection + keyboard ─────────────────────────────────────
708
751
  watch(query, () => {
709
- selectedIndex.value = 0
752
+ select(0)
710
753
  refresh()
711
754
  })
712
755
  watch([query, trail, () => props.open], () => {
713
756
  expanded.value = new Set()
714
757
  })
715
758
  watch(
716
- () => flat.value.length,
717
- (len) => {
718
- if (selectedIndex.value >= len) selectedIndex.value = Math.max(0, len - 1)
759
+ () => flat.value.map((c) => c.id).join("|"),
760
+ () => {
761
+ const index = selectedId.value ? flat.value.findIndex((c) => c.id === selectedId.value) : -1
762
+ select(index >= 0 ? index : 0)
719
763
  },
720
764
  )
721
765
  watch([selectedIndex, () => flat.value.map((c) => c.id).join("|")], async () => {
@@ -730,13 +774,42 @@ function scrollSelectedIntoView() {
730
774
  el?.scrollIntoView({ block: "nearest" })
731
775
  }
732
776
 
777
+ const keyboardSelection = ref(false)
778
+
779
+ function select(index: number, keyboard = false) {
780
+ const len = flat.value.length
781
+ selectedIndex.value = len ? Math.max(0, Math.min(index, len - 1)) : 0
782
+ selectedId.value = flat.value[selectedIndex.value]?.id ?? null
783
+ if (keyboard) keyboardSelection.value = true
784
+ }
785
+
733
786
  function moveDown() {
734
787
  const len = flat.value.length
735
- if (len) selectedIndex.value = (selectedIndex.value + 1) % len
788
+ if (len) select((selectedIndex.value + 1) % len, true)
736
789
  }
737
790
  function moveUp() {
738
791
  const len = flat.value.length
739
- if (len) selectedIndex.value = (selectedIndex.value - 1 + len) % len
792
+ if (len) select((selectedIndex.value - 1 + len) % len, true)
793
+ }
794
+ function movePage(direction: -1 | 1) {
795
+ select(selectedIndex.value + direction * 8, true)
796
+ }
797
+ function onPaletteKeydown(event: KeyboardEvent) {
798
+ if (event.isComposing) return
799
+ if (!(["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"] as string[]).includes(event.key)) return
800
+ event.preventDefault()
801
+ event.stopPropagation()
802
+ if (event.key === "ArrowDown") moveDown()
803
+ else if (event.key === "ArrowUp") moveUp()
804
+ else if (event.key === "Home") select(0, true)
805
+ else if (event.key === "End") select(flat.value.length - 1, true)
806
+ else movePage(event.key === "PageDown" ? 1 : -1)
807
+ }
808
+ function onPointerMove() {
809
+ keyboardSelection.value = false
810
+ }
811
+ function onPointerEnter(c: Command) {
812
+ if (!keyboardSelection.value) select(flatIndexOf(c))
740
813
  }
741
814
  function runSelected(event?: KeyboardEvent) {
742
815
  const c = flat.value[selectedIndex.value]
@@ -789,7 +862,7 @@ function run(c: Command | undefined) {
789
862
  // Back to the top of a fresh list: the query that found this command is
790
863
  // spent, and leaving it would hide whatever the action just changed.
791
864
  query.value = ""
792
- selectedIndex.value = 0
865
+ select(0)
793
866
  inputRef.value?.focus()
794
867
  return
795
868
  }
@@ -833,8 +906,10 @@ watch(
833
906
  async (o) => {
834
907
  cancel()
835
908
  query.value = ""
836
- selectedIndex.value = 0
909
+ select(0)
910
+ selectedId.value = null
837
911
  trail.value = []
912
+ trailState.value = []
838
913
  modeItems.value = []
839
914
  found.value = []
840
915
  failed.value = false
@@ -876,6 +951,7 @@ useHotkeys(() =>
876
951
  :class="[frosted ? 'lp-glass-overlay' : 'bg-surface-raised', previewPane && 'lp-cmd-split']"
877
952
  :data-pane="paneOpen ? 'open' : undefined"
878
953
  @open-auto-focus.prevent
954
+ @keydown.capture="onPaletteKeydown"
879
955
  >
880
956
  <DialogTitle class="sr-only">Command palette</DialogTitle>
881
957
  <DialogDescription class="sr-only">Search and run a command.</DialogDescription>
@@ -916,8 +992,6 @@ useHotkeys(() =>
916
992
  :aria-activedescendant="activeId"
917
993
  :placeholder="mode?.childPlaceholder ?? placeholder"
918
994
  class="h-12 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-muted"
919
- @keydown.down.prevent="moveDown"
920
- @keydown.up.prevent="moveUp"
921
995
  @keydown.enter.prevent="runSelected"
922
996
  @keydown.backspace="onBackspace"
923
997
  @keydown.esc="onEscape"
@@ -927,7 +1001,7 @@ useHotkeys(() =>
927
1001
  </div>
928
1002
 
929
1003
  <!-- Results -->
930
- <div class="flex min-h-0 flex-1">
1004
+ <div class="flex min-h-0 flex-1" @pointermove="onPointerMove">
931
1005
  <LpScrollArea ref="scrollRef" class="lp-cmd-list min-h-0 min-w-0 flex-1" content-class="p-2">
932
1006
  <!-- While a request is out there is nothing to say yet: "no results"
933
1007
  under a spinner reads as an answer, and it is not one. -->
@@ -974,7 +1048,7 @@ useHotkeys(() =>
974
1048
  : 'text-ink hover:bg-surface-soft/60',
975
1049
  ]"
976
1050
  @click="onRowClick($event, c)"
977
- @pointerenter="selectedIndex = flatIndexOf(c)"
1051
+ @pointerenter="onPointerEnter(c)"
978
1052
  >
979
1053
  <LpIcon v-if="c.icon" :name="c.icon" :size="16" class="shrink-0" :class="{ 'mt-0.5': c.excerpt }" />
980
1054
  <span class="min-w-0 flex-1">
@@ -142,6 +142,8 @@ function matches(event: KeyboardEvent, binding: HotkeyBinding): boolean {
142
142
  * formality.
143
143
  */
144
144
  export interface UseHotkeysOptions {
145
+ enabled?: MaybeRefOrGetter<boolean>
146
+ priority?: MaybeRefOrGetter<number>
145
147
  /**
146
148
  * Only act while the focus is inside this element.
147
149
  *
@@ -157,52 +159,70 @@ export interface UseHotkeysOptions {
157
159
  scope?: MaybeRefOrGetter<HTMLElement | null | undefined>
158
160
  }
159
161
 
160
- export function useHotkeys(
161
- bindings: MaybeRefOrGetter<HotkeyBinding[]>,
162
- options: UseHotkeysOptions = {},
163
- ) {
164
- function inScope(target: EventTarget | null): boolean {
165
- const el = toValue(options.scope)
166
- if (!el) return true
167
- const node = target instanceof Node ? target : null
168
- // The element itself counts: a focusable canvas holds the focus without
169
- // containing anything that could.
170
- return !!node && (el === node || el.contains(node))
171
- }
162
+ interface MountedHotkeys {
163
+ read: () => HotkeyBinding[]
164
+ scope: MaybeRefOrGetter<HTMLElement | null | undefined> | undefined
165
+ enabled: MaybeRefOrGetter<boolean> | undefined
166
+ priority: MaybeRefOrGetter<number> | undefined
167
+ order: number
168
+ }
172
169
 
173
- function onKeydown(event: KeyboardEvent) {
174
- if (event.isComposing) return
175
- if (!inScope(event.target)) return
176
- const inTextEntry = isTextEntry(event.target)
177
- for (const binding of toValue(bindings)) {
170
+ const mounted = shallowReactive(new Set<MountedHotkeys>())
171
+ let mountOrder = 0
172
+ let listening = false
173
+
174
+ function inScope(target: EventTarget | null, scope: MountedHotkeys["scope"]): boolean {
175
+ const el = toValue(scope)
176
+ if (!el) return true
177
+ const node = target instanceof Node ? target : null
178
+ return !!node && (el === node || el.contains(node))
179
+ }
180
+
181
+ function onKeydown(event: KeyboardEvent) {
182
+ if (event.isComposing) return
183
+ const inTextEntry = isTextEntry(event.target)
184
+ const entries = [...mounted].sort(
185
+ (a, b) => Number(toValue(b.priority) || 0) - Number(toValue(a.priority) || 0) || b.order - a.order,
186
+ )
187
+ for (const entry of entries) {
188
+ if (toValue(entry.enabled) === false || !inScope(event.target, entry.scope)) continue
189
+ for (const binding of entry.read()) {
178
190
  if (inTextEntry && !binding.allowInInput) continue
179
191
  if (!matches(event, binding)) continue
180
- // Claimed only once the handler has run and not declined, so a binding
181
- // that stands down leaves the key untouched for whoever handles it next.
182
192
  if (binding.handler(event) === false) continue
183
193
  event.preventDefault()
184
194
  return
185
195
  }
186
196
  }
197
+ }
187
198
 
188
- const read = () => toValue(bindings)
199
+ export function useHotkeys(
200
+ bindings: MaybeRefOrGetter<HotkeyBinding[]>,
201
+ options: UseHotkeysOptions = {},
202
+ ) {
203
+ const entry: MountedHotkeys = {
204
+ read: () => toValue(bindings),
205
+ scope: options.scope,
206
+ enabled: options.enabled,
207
+ priority: options.priority,
208
+ order: ++mountOrder,
209
+ }
189
210
  onMounted(() => {
190
- window.addEventListener("keydown", onKeydown)
191
- mounted.add(read)
211
+ mounted.add(entry)
212
+ if (!listening) {
213
+ window.addEventListener("keydown", onKeydown)
214
+ listening = true
215
+ }
192
216
  })
193
217
  onBeforeUnmount(() => {
194
- window.removeEventListener("keydown", onKeydown)
195
- mounted.delete(read)
218
+ mounted.delete(entry)
219
+ if (!mounted.size && listening) {
220
+ window.removeEventListener("keydown", onKeydown)
221
+ listening = false
222
+ }
196
223
  })
197
224
  }
198
225
 
199
- /*
200
- * Every mounted binding list, in mount order. The lists are getters, so the
201
- * reference below follows the state behind them too: a binding that only
202
- * exists while a mode is on shows up and goes away with that mode.
203
- */
204
- const mounted = shallowReactive(new Set<() => HotkeyBinding[]>())
205
-
206
226
  const KEY_NAMES: Record<string, string> = {
207
227
  escape: "Esc",
208
228
  enter: "Enter",
@@ -230,8 +250,9 @@ function comboOf(binding: HotkeyBinding): string {
230
250
  */
231
251
  export const activeShortcuts = computed<ShortcutGroup[]>(() => {
232
252
  const groups = new Map<string, ShortcutEntry[]>()
233
- for (const read of mounted) {
234
- for (const binding of read()) {
253
+ for (const entry of mounted) {
254
+ if (toValue(entry.enabled) === false) continue
255
+ for (const binding of entry.read()) {
235
256
  if (!binding.help) continue
236
257
  const combo = binding.help.combo ?? comboOf(binding)
237
258
  const entries = groups.get(binding.help.group) ?? []