@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.
- package/dist/assets/{LpCommandPalette-DDGFwOom.css → LpCommandPalette-Esv0a-gS.css} +21 -21
- package/dist/assets/{LpShortcutsDialog-Cap_ALQM.css → LpShortcutsDialog-C6YoQ8_M.css} +7 -7
- package/dist/chunks/{LpAddressInput-H5NdhK6c.js → LpAddressInput-CMjaPQVw.js} +1 -1
- package/dist/chunks/{LpAppShell-BfqSA1ba.js → LpAppShell-Bcv6EhqU.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-BBRnMkV4.js → LpConfirmDialog-BNAvOR24.js} +2 -2
- package/dist/chunks/{LpDrawer-B8vBnCWF.js → LpDrawer-DgaS2s2i.js} +6 -4
- package/dist/chunks/{LpModal-EyEdRuqF.js → LpModal-8bja5bWS.js} +5 -3
- package/dist/chunks/{LpModalNav-BrDtP7b_.js → LpModalNav-w9kfwHqO.js} +3 -3
- package/dist/chunks/{LpSidebar-DZ20iBxY.js → LpSidebar-DlTn5nXc.js} +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +2 -1
- package/dist/components/LpCommandPalette.vue.js +140 -76
- package/dist/components/LpConfirmDialog.vue.d.ts +2 -1
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpDrawer.vue.d.ts +4 -2
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpModal.vue.d.ts +2 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.d.ts +2 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +8 -11
- package/dist/components/LpShortcutsDialog.vue.js +14 -9
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/composables/useGlass.d.ts +9 -0
- package/dist/composables/useGlass.js +13 -0
- package/dist/composables/useHotkeys.d.ts +43 -2
- package/dist/composables/useHotkeys.js +89 -16
- package/dist/index.d.ts +3 -3
- package/dist/index.js +13 -8
- package/package.json +1 -1
- package/src/components/LpCommandPalette.vue +114 -36
- package/src/components/LpConfirmDialog.vue +2 -2
- package/src/components/LpDrawer.vue +9 -5
- package/src/components/LpModal.vue +8 -4
- package/src/components/LpModalNav.vue +2 -1
- package/src/components/LpShortcutsDialog.vue +32 -23
- package/src/composables/useGlass.ts +25 -0
- package/src/composables/useHotkeys.ts +146 -24
- 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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
522
|
-
const items = rank(
|
|
523
|
-
if (items.length || !props.layoutFallback) return { items, terms }
|
|
524
|
-
const swapped =
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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.
|
|
713
|
-
(
|
|
714
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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':
|
|
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="[
|
|
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="
|
|
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.
|
|
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
|
|
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:
|
|
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 =
|
|
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':
|
|
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':
|
|
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
|
|
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:
|
|
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':
|
|
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="[
|
|
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.
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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 {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
}
|