@leavepulse/ui 0.40.1 → 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.
Files changed (40) hide show
  1. package/dist/assets/{LpCommandPalette-DDGFwOom.css → LpCommandPalette-Esv0a-gS.css} +21 -21
  2. package/dist/assets/{LpShortcutsDialog-Cap_ALQM.css → LpShortcutsDialog-C6YoQ8_M.css} +7 -7
  3. package/dist/chunks/{LpAddressInput-H5NdhK6c.js → LpAddressInput-CMjaPQVw.js} +1 -1
  4. package/dist/chunks/{LpAppShell-BfqSA1ba.js → LpAppShell-Bcv6EhqU.js} +1 -1
  5. package/dist/chunks/{LpConfirmDialog-BBRnMkV4.js → LpConfirmDialog-BNAvOR24.js} +2 -2
  6. package/dist/chunks/{LpDrawer-B8vBnCWF.js → LpDrawer-DgaS2s2i.js} +6 -4
  7. package/dist/chunks/{LpModal-EyEdRuqF.js → LpModal-8bja5bWS.js} +5 -3
  8. package/dist/chunks/{LpModalNav-BrDtP7b_.js → LpModalNav-w9kfwHqO.js} +3 -3
  9. package/dist/chunks/{LpSidebar-DZ20iBxY.js → LpSidebar-DlTn5nXc.js} +1 -1
  10. package/dist/components/LpAddressInput.vue.js +1 -1
  11. package/dist/components/LpAppShell.vue.js +1 -1
  12. package/dist/components/LpCommandPalette.vue.d.ts +2 -1
  13. package/dist/components/LpCommandPalette.vue.js +140 -76
  14. package/dist/components/LpConfirmDialog.vue.d.ts +2 -1
  15. package/dist/components/LpConfirmDialog.vue.js +1 -1
  16. package/dist/components/LpDrawer.vue.d.ts +4 -2
  17. package/dist/components/LpDrawer.vue.js +1 -1
  18. package/dist/components/LpModal.vue.d.ts +2 -1
  19. package/dist/components/LpModal.vue.js +1 -1
  20. package/dist/components/LpModalNav.vue.d.ts +2 -1
  21. package/dist/components/LpModalNav.vue.js +1 -1
  22. package/dist/components/LpShortcutsDialog.vue.d.ts +8 -11
  23. package/dist/components/LpShortcutsDialog.vue.js +14 -9
  24. package/dist/components/LpSidebar.vue.js +1 -1
  25. package/dist/composables/useGlass.d.ts +9 -0
  26. package/dist/composables/useGlass.js +13 -0
  27. package/dist/composables/useHotkeys.d.ts +43 -2
  28. package/dist/composables/useHotkeys.js +89 -16
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +13 -8
  31. package/package.json +1 -1
  32. package/src/components/LpCommandPalette.vue +114 -36
  33. package/src/components/LpConfirmDialog.vue +2 -2
  34. package/src/components/LpDrawer.vue +9 -5
  35. package/src/components/LpModal.vue +8 -4
  36. package/src/components/LpModalNav.vue +2 -1
  37. package/src/components/LpShortcutsDialog.vue +32 -23
  38. package/src/composables/useGlass.ts +25 -0
  39. package/src/composables/useHotkeys.ts +146 -24
  40. package/src/index.ts +9 -3
@@ -40,6 +40,7 @@ import { computed, nextTick, ref, shallowRef, useId, watch } from "vue"
40
40
  import LpIcon from "./LpIcon.vue"
41
41
  import LpScrollArea from "./LpScrollArea.vue"
42
42
  import { useAsyncTask } from "../composables/useAsyncTask"
43
+ import { useGlass } from "../composables/useGlass"
43
44
  import { useClipboard } from "../composables/useClipboard"
44
45
  import { useHotkeys } from "../composables/useHotkeys"
45
46
 
@@ -210,7 +211,8 @@ const props = withDefaults(
210
211
  /**
211
212
  * Frosted glass instead of a solid panel: the page shows through,
212
213
  * blurred, under a lighter scrim (.lp-glass-overlay, .lp-scrim--light).
213
- * A look, not a behaviour, so it is the app's call and off by default.
214
+ * A look, not a behaviour, so it is the app's call: unset follows
215
+ * `provideGlass`, and without one it is off.
214
216
  */
215
217
  glass?: boolean
216
218
  }>(),
@@ -227,10 +229,12 @@ const props = withDefaults(
227
229
  groupLimit: 0,
228
230
  layoutFallback: true,
229
231
  previewPane: false,
230
- glass: false,
232
+ glass: undefined,
231
233
  },
232
234
  )
233
235
 
236
+ const frosted = useGlass(() => props.glass)
237
+
234
238
  const DEFAULT_LABELS: PaletteLabels = {
235
239
  navigate: "navigate",
236
240
  select: "select",
@@ -272,6 +276,7 @@ function optionId(index: number): string {
272
276
  }
273
277
  const activeId = computed(() => (flat.value.length ? optionId(selectedIndex.value) : undefined))
274
278
  const selectedIndex = ref(0)
279
+ const selectedId = ref<string | null>(null)
275
280
  const inputRef = ref<HTMLInputElement | null>(null)
276
281
  const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
277
282
 
@@ -286,6 +291,7 @@ const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
286
291
  * a row of data), and deep-tracking them would walk it on every keystroke.
287
292
  */
288
293
  const trail = shallowRef<Command[]>([])
294
+ const trailState = shallowRef<{ query: string; selectedId: string | null }[]>([])
289
295
  const mode = computed<Command | null>(() => trail.value[trail.value.length - 1] ?? null)
290
296
 
291
297
  /*
@@ -328,21 +334,20 @@ const selectedActions = computed(() => actionsOf(flat.value[selectedIndex.value]
328
334
  * actions it keeps that default.
329
335
  */
330
336
  function onTab(event: KeyboardEvent) {
337
+ event.preventDefault()
338
+ event.stopPropagation()
331
339
  if (event.shiftKey) return
332
340
  const c = flat.value[selectedIndex.value]
333
341
  const actions = selectedActions.value
334
342
  if (!c || !actions.length) return
335
- // The dialog's focus trap handles Tab too, and would hand focus to the
336
- // mode chip this is about to render.
337
- event.preventDefault()
338
- event.stopPropagation()
339
343
  enter({ id: `${c.id}:actions`, label: c.label, icon: c.icon, children: actions })
340
344
  }
341
345
 
342
346
  function enter(c: Command) {
347
+ trailState.value = [...trailState.value, { query: query.value, selectedId: selectedId.value }]
343
348
  trail.value = [...trail.value, c]
344
349
  query.value = ""
345
- selectedIndex.value = 0
350
+ select(0)
346
351
  modeItems.value = Array.isArray(c.children) ? c.children : []
347
352
  refresh()
348
353
  // Entering by click leaves the focus on the button that no longer exists,
@@ -353,13 +358,19 @@ function enter(c: Command) {
353
358
 
354
359
  /** Unwind the trail to `depth` entries — 0 is the root. */
355
360
  function popTo(depth: number) {
361
+ const state = trailState.value[depth] || { query: "", selectedId: null }
356
362
  const next = trail.value.slice(0, Math.max(0, depth))
363
+ trailState.value = trailState.value.slice(0, Math.max(0, depth))
357
364
  trail.value = next
358
- query.value = ""
359
- selectedIndex.value = 0
360
365
  const parent = next[next.length - 1]
361
366
  modeItems.value = parent && Array.isArray(parent.children) ? parent.children : []
367
+ query.value = state.query
368
+ select(0)
362
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
+ })
363
374
  inputRef.value?.focus()
364
375
  }
365
376
 
@@ -483,12 +494,43 @@ function nearWord(term: string, label: string): boolean {
483
494
  * the same weights the palette always had; a typo match ranks below all of
484
495
  * them, so an exact hit is never outranked by a near one.
485
496
  */
486
- 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 {
487
527
  const label = c.label.toLowerCase()
488
528
  const keywords = (c.keywords ?? []).map((k) => k.toLowerCase())
489
529
  const description = c.description?.toLowerCase() ?? ""
490
- let total = 0
491
- 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) {
492
534
  let hit =
493
535
  (label.includes(term) ? 4 : 0) +
494
536
  (keywords.some((k) => k.includes(term)) ? 3 : 0) +
@@ -500,7 +542,7 @@ function score(c: Command, terms: string[]): number {
500
542
  return total
501
543
  }
502
544
 
503
- function rank(items: Command[], terms: string[]): Command[] {
545
+ function rank(items: Command[], terms: SearchTerms): Command[] {
504
546
  return items
505
547
  .map((c) => ({ c, score: score(c, terms) }))
506
548
  .filter((e) => e.score > 0)
@@ -508,22 +550,27 @@ function rank(items: Command[], terms: string[]): Command[] {
508
550
  .map((e) => e.c)
509
551
  }
510
552
 
511
- const typedTerms = computed(() => query.value.trim().toLowerCase().split(/\s+/).filter(Boolean))
512
-
513
553
  /*
514
554
  * The local match and the terms it matched with — the swapped ones when the
515
555
  * layout fallback is what found it, so the highlight marks what actually hit.
516
556
  */
517
557
  const local = computed<{ items: Command[]; terms: string[] }>(() => {
518
- 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)
519
562
  // Empty query: show everything EXCEPT hidden commands (they only surface once
520
563
  // typed). With a query, hidden commands rank like any other.
521
- if (!terms.length) return { items: source.value.filter((c) => !c.hidden), terms }
522
- const items = rank(source.value, terms)
523
- if (items.length || !props.layoutFallback) return { items, terms }
524
- const swapped = terms.map(swapLayout)
525
- const other = rank(source.value, swapped)
526
- 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 }
527
574
  })
528
575
 
529
576
  const ranked = computed<Command[]>(() => local.value.items)
@@ -702,16 +749,17 @@ function highlight(value: string, extra: string[] = []): Part[] {
702
749
 
703
750
  // ── selection + keyboard ─────────────────────────────────────
704
751
  watch(query, () => {
705
- selectedIndex.value = 0
752
+ select(0)
706
753
  refresh()
707
754
  })
708
755
  watch([query, trail, () => props.open], () => {
709
756
  expanded.value = new Set()
710
757
  })
711
758
  watch(
712
- () => flat.value.length,
713
- (len) => {
714
- 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)
715
763
  },
716
764
  )
717
765
  watch([selectedIndex, () => flat.value.map((c) => c.id).join("|")], async () => {
@@ -726,13 +774,42 @@ function scrollSelectedIntoView() {
726
774
  el?.scrollIntoView({ block: "nearest" })
727
775
  }
728
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
+
729
786
  function moveDown() {
730
787
  const len = flat.value.length
731
- if (len) selectedIndex.value = (selectedIndex.value + 1) % len
788
+ if (len) select((selectedIndex.value + 1) % len, true)
732
789
  }
733
790
  function moveUp() {
734
791
  const len = flat.value.length
735
- 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))
736
813
  }
737
814
  function runSelected(event?: KeyboardEvent) {
738
815
  const c = flat.value[selectedIndex.value]
@@ -785,7 +862,7 @@ function run(c: Command | undefined) {
785
862
  // Back to the top of a fresh list: the query that found this command is
786
863
  // spent, and leaving it would hide whatever the action just changed.
787
864
  query.value = ""
788
- selectedIndex.value = 0
865
+ select(0)
789
866
  inputRef.value?.focus()
790
867
  return
791
868
  }
@@ -829,8 +906,10 @@ watch(
829
906
  async (o) => {
830
907
  cancel()
831
908
  query.value = ""
832
- selectedIndex.value = 0
909
+ select(0)
910
+ selectedId.value = null
833
911
  trail.value = []
912
+ trailState.value = []
834
913
  modeItems.value = []
835
914
  found.value = []
836
915
  failed.value = false
@@ -864,14 +943,15 @@ useHotkeys(() =>
864
943
  <DialogRoot :open="open" @update:open="(v) => emit('update:open', v)">
865
944
  <DialogPortal>
866
945
  <DialogOverlay
867
- :class="{ 'lp-scrim--light': glass }"
946
+ :class="{ 'lp-scrim--light': frosted }"
868
947
  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]"
869
948
  />
870
949
  <DialogContent
871
950
  class="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]"
872
- :class="[glass ? 'lp-glass-overlay' : 'bg-surface-raised', previewPane && 'lp-cmd-split']"
951
+ :class="[frosted ? 'lp-glass-overlay' : 'bg-surface-raised', previewPane && 'lp-cmd-split']"
873
952
  :data-pane="paneOpen ? 'open' : undefined"
874
953
  @open-auto-focus.prevent
954
+ @keydown.capture="onPaletteKeydown"
875
955
  >
876
956
  <DialogTitle class="sr-only">Command palette</DialogTitle>
877
957
  <DialogDescription class="sr-only">Search and run a command.</DialogDescription>
@@ -912,8 +992,6 @@ useHotkeys(() =>
912
992
  :aria-activedescendant="activeId"
913
993
  :placeholder="mode?.childPlaceholder ?? placeholder"
914
994
  class="h-12 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-muted"
915
- @keydown.down.prevent="moveDown"
916
- @keydown.up.prevent="moveUp"
917
995
  @keydown.enter.prevent="runSelected"
918
996
  @keydown.backspace="onBackspace"
919
997
  @keydown.esc="onEscape"
@@ -923,7 +1001,7 @@ useHotkeys(() =>
923
1001
  </div>
924
1002
 
925
1003
  <!-- Results -->
926
- <div class="flex min-h-0 flex-1">
1004
+ <div class="flex min-h-0 flex-1" @pointermove="onPointerMove">
927
1005
  <LpScrollArea ref="scrollRef" class="lp-cmd-list min-h-0 min-w-0 flex-1" content-class="p-2">
928
1006
  <!-- While a request is out there is nothing to say yet: "no results"
929
1007
  under a spinner reads as an answer, and it is not one. -->
@@ -970,7 +1048,7 @@ useHotkeys(() =>
970
1048
  : 'text-ink hover:bg-surface-soft/60',
971
1049
  ]"
972
1050
  @click="onRowClick($event, c)"
973
- @pointerenter="selectedIndex = flatIndexOf(c)"
1051
+ @pointerenter="onPointerEnter(c)"
974
1052
  >
975
1053
  <LpIcon v-if="c.icon" :name="c.icon" :size="16" class="shrink-0" :class="{ 'mt-0.5': c.excerpt }" />
976
1054
  <span class="min-w-0 flex-1">
@@ -36,10 +36,10 @@ const props = withDefaults(
36
36
  */
37
37
  loading?: boolean
38
38
  loadingLabel?: string
39
- /** Frosted glass panel, passed to LpModal. Off by default. */
39
+ /** Frosted glass panel, passed to LpModal. Follows `provideGlass` when unset. */
40
40
  glass?: boolean
41
41
  }>(),
42
- { confirmLabel: "Confirm", cancelLabel: "Cancel", loadingLabel: "Working…" },
42
+ { confirmLabel: "Confirm", cancelLabel: "Cancel", loadingLabel: "Working…", glass: undefined },
43
43
  )
44
44
 
45
45
  const emit = defineEmits<{
@@ -31,6 +31,7 @@ import {
31
31
  } from "vaul-vue"
32
32
  import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
33
33
  import { CLOSE_ICON } from "./dropdown"
34
+ import { useGlass } from "../composables/useGlass"
34
35
  import LpIcon from "./LpIcon.vue"
35
36
  import LpScrollArea from "./LpScrollArea.vue"
36
37
  import { anyModalOpen, useModalLayer } from "./modalDepth"
@@ -104,7 +105,8 @@ const props = withDefaults(
104
105
  /**
105
106
  * Frosted glass instead of a solid panel: the page shows through,
106
107
  * blurred, under a lighter scrim (.lp-glass-overlay, .lp-scrim--light).
107
- * A look, not a behaviour, so it is the app's call and off by default.
108
+ * A look, not a behaviour, so it is the app's call: unset follows
109
+ * `provideGlass`, and without one it is off.
108
110
  */
109
111
  glass?: boolean
110
112
  }>(),
@@ -117,10 +119,12 @@ const props = withDefaults(
117
119
  edgeOpenThreshold: 0.3,
118
120
  minSize: "22rem",
119
121
  maxSize: "96vw",
120
- glass: false,
122
+ glass: undefined,
121
123
  },
122
124
  )
123
125
 
126
+ const frosted = useGlass(() => props.glass)
127
+
124
128
  const emit = defineEmits<{
125
129
  (e: "update:open", value: boolean): void
126
130
  }>()
@@ -149,7 +153,7 @@ watch(() => props.open, (open) => (open ? claim() : release()), { immediate: tru
149
153
  onBeforeUnmount(release)
150
154
 
151
155
  const contentClass = computed(() => {
152
- const surface = props.glass ? "lp-glass-overlay" : "bg-surface-raised"
156
+ const surface = frosted.value ? "lp-glass-overlay" : "bg-surface-raised"
153
157
  const base = `fixed z-(--z-modal) flex flex-col border-line ${surface} shadow-panel outline-none`
154
158
  const map: Record<Direction, string> = {
155
159
  left: "inset-y-0 left-0 border-r rounded-r-card",
@@ -439,7 +443,7 @@ const previewStyle = computed(() => {
439
443
  <div
440
444
  v-show="peeking || peek > 0"
441
445
  class="lp-scrim pointer-events-none fixed inset-0"
442
- :class="{ 'lp-scrim--light': glass }"
446
+ :class="{ 'lp-scrim--light': frosted }"
443
447
  :style="{ zIndex: layer.scrim, opacity: peek, transition: peeking || reducedMotion() ? 'none' : 'opacity 0.18s ease-out' }"
444
448
  />
445
449
  <div
@@ -472,7 +476,7 @@ const previewStyle = computed(() => {
472
476
  <DrawerOverlay
473
477
  :style="{ zIndex: layer.scrim }"
474
478
  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]"
475
- :class="{ 'lp-scrim--light': glass }"
479
+ :class="{ 'lp-scrim--light': frosted }"
476
480
  />
477
481
  <DrawerContent
478
482
  :class="contentClass"
@@ -9,6 +9,7 @@ import {
9
9
  DialogTitle,
10
10
  } from "reka-ui"
11
11
  import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
12
+ import { useGlass } from "../composables/useGlass"
12
13
  import { useShift } from "../composables/useShift"
13
14
  import { useModalLayer } from "./modalDepth"
14
15
  import { CLOSE_ICON } from "./dropdown"
@@ -65,13 +66,16 @@ const props = withDefaults(
65
66
  /**
66
67
  * Frosted glass instead of a solid panel: the page shows through,
67
68
  * blurred, under a lighter scrim (.lp-glass-overlay, .lp-scrim--light).
68
- * A look, not a behaviour, so it is the app's call and off by default.
69
+ * A look, not a behaviour, so it is the app's call: unset follows
70
+ * `provideGlass`, and without one it is off.
69
71
  */
70
72
  glass?: boolean
71
73
  }>(),
72
- { size: "md", autoFocus: "auto", glass: false },
74
+ { size: "md", autoFocus: "auto", glass: undefined },
73
75
  )
74
76
 
77
+ const frosted = useGlass(() => props.glass)
78
+
75
79
  defineEmits<{ (e: "update:open", value: boolean): void }>()
76
80
 
77
81
  // Spread rather than a plain `:aria-describedby` binding: with a description
@@ -228,7 +232,7 @@ watch(
228
232
  <DialogRoot :open="open" @update:open="(v) => $emit('update:open', v)">
229
233
  <DialogPortal>
230
234
  <DialogOverlay
231
- :class="{ 'lp-scrim--light': glass }"
235
+ :class="{ 'lp-scrim--light': frosted }"
232
236
  class="lp-scrim fixed inset-0 data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
233
237
  :style="{ zIndex: layer.scrim }"
234
238
  />
@@ -298,7 +302,7 @@ watch(
298
302
  <div
299
303
  ref="panelRef"
300
304
  class="flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line shadow-panel"
301
- :class="[glass ? 'lp-glass-overlay' : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
305
+ :class="[frosted ? 'lp-glass-overlay' : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
302
306
  :style="{ ...(width ? { width } : {}), ...(hasAside ? { alignSelf: 'stretch' } : {}) }"
303
307
  >
304
308
  <header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
@@ -55,7 +55,7 @@ const props = withDefaults(
55
55
  * column inside the body, and the rail is no longer in the body.
56
56
  */
57
57
  fillBody?: boolean
58
- /** Frosted glass panel, passed to LpModal. Off by default. */
58
+ /** Frosted glass panel, passed to LpModal. Follows `provideGlass` when unset. */
59
59
  glass?: boolean
60
60
  }>(),
61
61
  {
@@ -64,6 +64,7 @@ const props = withDefaults(
64
64
  mobileBreakpoint: "lg",
65
65
  navLabel: "Sections",
66
66
  navSide: "start",
67
+ glass: undefined,
67
68
  },
68
69
  )
69
70
 
@@ -2,40 +2,49 @@
2
2
  /*
3
3
  * A keyboard reference, conventionally opened with F1.
4
4
  *
5
- * Shortcuts are passed in as data rather than read off a live binding list: a
6
- * binding knows its key but not what to call it, and in most apps half of them
7
- * are only mounted in the section they belong to — a reference that lists what
8
- * happens to be active is not a reference.
5
+ * Two sources, used together or apart. `groups` is the written list: keys
6
+ * that are not bindings at all (arrows inside a list, Backspace in a palette)
7
+ * and the ones worth knowing before their screen is open. `live` adds every
8
+ * mounted `useHotkeys` binding that carries `help`, so a screen with its own
9
+ * keys documents them while it is open and drops them when it closes. Groups
10
+ * of the same title merge, and a combo is listed once.
9
11
  *
10
- * Combos are written the way they read ("Ctrl+Shift+M", "←→↑↓") and split on
11
- * "+" into one <kbd> each.
12
+ * Combos are written the way they read ("Ctrl+Shift+M", "Ctrl++", "←→↑↓") and
13
+ * split on "+" into one <kbd> each.
12
14
  */
15
+ import { computed } from "vue"
13
16
  import LpModal from "./LpModal.vue"
14
- import { appleModifierLabel } from "../composables/useHotkeys"
15
-
16
- export interface ShortcutEntry {
17
- /** Rendered one <kbd> per "+"-separated key, so write it the way it reads. */
18
- combo: string
19
- label: string
20
- }
21
-
22
- export interface ShortcutGroup {
23
- title: string
24
- entries: ShortcutEntry[]
25
- }
17
+ import {
18
+ activeShortcuts,
19
+ appleModifierLabel,
20
+ mergeShortcutGroups,
21
+ type ShortcutGroup,
22
+ } from "../composables/useHotkeys"
26
23
 
27
24
  const props = withDefaults(
28
25
  defineProps<{
29
26
  open?: boolean
30
- groups: ShortcutGroup[]
27
+ groups?: ShortcutGroup[]
28
+ /** Also list the bindings mounted right now (`useHotkeys` with `help`). */
29
+ live?: boolean
31
30
  title?: string
32
31
  description?: string
33
- /** Frosted glass panel, passed to LpModal. Off by default. */
32
+ /** Frosted glass panel, passed to LpModal. Follows `provideGlass` when unset. */
34
33
  glass?: boolean
35
34
  }>(),
36
- { open: false, title: "Keyboard shortcuts" },
35
+ { open: false, groups: () => [], live: false, title: "Keyboard shortcuts", glass: undefined },
37
36
  )
38
37
 
38
+ /*
39
+ * One key per "+", except a "+" that is the key itself: "Ctrl++" is Ctrl and
40
+ * plus, not Ctrl and two blanks.
41
+ */
42
+ function keysOf(combo: string): string[] {
43
+ return combo.split(/\+(?!$)/)
44
+ }
45
+
46
+ const shown = computed(() => (props.live ? mergeShortcutGroups(props.groups, activeShortcuts.value) : props.groups))
47
+
39
48
  const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
40
49
  </script>
41
50
 
@@ -49,13 +58,13 @@ const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
49
58
  @update:open="(value: boolean) => emit('update:open', value)"
50
59
  >
51
60
  <div class="shortcut-groups">
52
- <section v-for="group in props.groups" :key="group.title" class="shortcut-group">
61
+ <section v-for="group in shown" :key="group.title" class="shortcut-group">
53
62
  <h3>{{ group.title }}</h3>
54
63
  <dl>
55
64
  <div v-for="entry in group.entries" :key="entry.combo" class="shortcut-row">
56
65
  <dt>{{ entry.label }}</dt>
57
66
  <dd>
58
- <kbd v-for="(key, index) in entry.combo.split('+')" :key="`${key}-${index}`">
67
+ <kbd v-for="(key, index) in keysOf(entry.combo)" :key="`${key}-${index}`">
59
68
  {{ appleModifierLabel(key.trim()) }}
60
69
  </kbd>
61
70
  </dd>
@@ -0,0 +1,25 @@
1
+ import { computed, inject, provide, toValue, type ComputedRef, type InjectionKey, type MaybeRefOrGetter } from "vue"
2
+
3
+ /*
4
+ * Frosted glass as an app-wide default for overlays (modals, drawers, dialogs,
5
+ * the command palette). An app that wants every overlay frosted says so once
6
+ * at its root instead of on each of dozens of call sites, where the next new
7
+ * dialog would be the one that forgets. An explicit `glass` prop on a single
8
+ * overlay still wins, either way.
9
+ */
10
+ const GLASS: InjectionKey<MaybeRefOrGetter<boolean>> = Symbol("lp-glass")
11
+
12
+ /** Make `glass` the default for every overlay below the calling component. */
13
+ export function provideGlass(enabled: MaybeRefOrGetter<boolean> = true): void {
14
+ provide(GLASS, enabled)
15
+ }
16
+
17
+ /**
18
+ * The glass flag an overlay renders with: its own prop when set, otherwise the
19
+ * nearest `provideGlass`, otherwise off. The prop must default to `undefined`,
20
+ * not `false`, or an unset prop could not be told apart from an explicit off.
21
+ */
22
+ export function useGlass(own: () => boolean | undefined): ComputedRef<boolean> {
23
+ const inherited = inject(GLASS, false)
24
+ return computed(() => own() ?? toValue(inherited))
25
+ }