@leavepulse/ui 0.45.1 → 0.47.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/HighlightText-BwC5xbqn.css +6 -0
- package/dist/assets/{LpCommandPalette-HzAA4gri.css → LpCommandPalette-DmQUsJqA.css} +21 -21
- package/dist/assets/{LpDropzone-i4BdMi1H.css → LpDropzone-Ev6X03zj.css} +12 -12
- package/dist/assets/LpKeys-BxRhfQda.css +36 -0
- package/dist/assets/LpPaletteFacts-B9FdVmIO.css +93 -0
- package/dist/assets/LpPaletteHeader-BVOu1UX5.css +70 -0
- package/dist/assets/LpPalettePreview-CJmAKgYW.css +43 -0
- package/dist/assets/LpPaletteStats-ClE-37MK.css +33 -0
- package/dist/assets/LpPaletteSwatches-CA0B8y2E.css +21 -0
- package/dist/assets/{LpShortcutsDialog-CqYX3yy6.css → LpShortcutsDialog-BUp_BX4s.css} +7 -28
- package/dist/assets/{LpTable-CPwWxTJS.css → LpTable-xWwI4KPZ.css} +17 -17
- package/dist/chunks/HighlightText-BvnYZkhi.js +27 -0
- package/dist/chunks/{LpAddressInput-CJOYGZlI.js → LpAddressInput-iOJhmRzy.js} +1 -1
- package/dist/chunks/{LpAppShell-B07Lf48e.js → LpAppShell-BmMYcEVx.js} +2 -2
- package/dist/chunks/{LpAvatar-C1XStdOo.js → LpAvatar-Cb1jtOVv.js} +22 -4
- package/dist/chunks/{LpCard-C2IvucmX.js → LpCard-BdxkMXjo.js} +1 -1
- package/dist/chunks/{LpColorPicker-zz4VGkG4.js → LpColorPicker-BLqPX5Vw.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-BTVAR4tF.js → LpConfirmDialog-Ca-CTlxT.js} +1 -1
- package/dist/chunks/{LpConfirmHost-Ci_uCHgQ.js → LpConfirmHost-BAaSaT2b.js} +1 -1
- package/dist/chunks/{LpContextMenu-BqkAYa9X.js → LpContextMenu-1cKew_Dc.js} +17 -4
- package/dist/chunks/{LpDrawer-CfRhHwmE.js → LpDrawer-CBFVD624.js} +1 -1
- package/dist/chunks/{LpDropdownMenu-DdJGQzPn.js → LpDropdownMenu-DrpJJBp0.js} +22 -8
- package/dist/chunks/{LpFileTree-CNurOvkV.js → LpFileTree-bk5VjBey.js} +2 -2
- package/dist/chunks/{LpFileTreeNode-D_qRRdsH.js → LpFileTreeNode-BvMSBM7X.js} +1 -1
- package/dist/chunks/{LpLightbox-DSDahF23.js → LpLightbox-EVXzAq62.js} +3 -3
- package/dist/chunks/{LpLogViewer-KypbgOyo.js → LpLogViewer-BDFXzvcl.js} +2 -2
- package/dist/chunks/{LpMediaGallery-YKr-cQPN.js → LpMediaGallery-BnlkN_1f.js} +1 -1
- package/dist/chunks/{LpModal-BzTVeNqe.js → LpModal-JNa3B_Lr.js} +12 -5
- package/dist/chunks/{LpModalNav-CjABSGCu.js → LpModalNav-BAxC9et7.js} +10 -3
- package/dist/chunks/{LpNotificationBell-CCwyJP5C.js → LpNotificationBell-DsqSyMwS.js} +2 -2
- package/dist/chunks/LpPalettePreview-CFav_7co.js +19 -0
- package/dist/chunks/{LpPie-BEJ2SfI6.js → LpPie-BdSnta11.js} +1 -1
- package/dist/chunks/{LpScrollArea-Bry1fdOM.js → LpScrollArea-Hz3-JWUG.js} +23 -5
- package/dist/chunks/{LpSidebar-Bwws_X29.js → LpSidebar-DpEiGi8L.js} +2 -2
- package/dist/chunks/{LpSidebarNav-kx-LdqNU.js → LpSidebarNav-BUuxipX-.js} +2 -2
- package/dist/chunks/{LpThemeSwitcher-B4aVPbNC.js → LpThemeSwitcher-BbYFFT5w.js} +1 -1
- package/dist/chunks/MenuTitle-J3ZDYjpV.js +56 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/HighlightText.vue.d.ts +7 -0
- package/dist/components/HighlightText.vue.js +4 -0
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAvatar.vue.d.ts +12 -0
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +152 -15
- package/dist/components/LpCommandPalette.vue.js +181 -92
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.d.ts +11 -0
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.d.ts +6 -0
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.d.ts +14 -0
- package/dist/components/LpDropzone.vue.js +9 -7
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpKeys.vue.d.ts +22 -0
- package/dist/components/LpKeys.vue.js +52 -0
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.d.ts +7 -5
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.d.ts +5 -3
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpPaletteFacts.vue.d.ts +25 -0
- package/dist/components/LpPaletteFacts.vue.js +85 -0
- package/dist/components/LpPaletteHeader.vue.d.ts +32 -0
- package/dist/components/LpPaletteHeader.vue.js +103 -0
- package/dist/components/LpPalettePreview.vue.d.ts +19 -0
- package/dist/components/LpPalettePreview.vue.js +4 -0
- package/dist/components/LpPaletteStats.vue.d.ts +11 -0
- package/dist/components/LpPaletteStats.vue.js +47 -0
- package/dist/components/LpPaletteSwatches.vue.d.ts +10 -0
- package/dist/components/LpPaletteSwatches.vue.js +34 -0
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpScrollArea.vue.d.ts +2 -2
- package/dist/components/LpScrollArea.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.js +9 -29
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTable.vue.js +8 -6
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/MenuTitle.vue.d.ts +9 -0
- package/dist/components/MenuTitle.vue.js +4 -0
- package/dist/components/commandPalette.d.ts +157 -0
- package/dist/components/commandPalette.js +103 -0
- package/dist/components/commandSearch.d.ts +32 -0
- package/dist/components/commandSearch.js +48 -0
- package/dist/components/keys.d.ts +2 -0
- package/dist/components/keys.js +11 -0
- package/dist/composables/useClipboard.js +35 -2
- package/dist/index.d.ts +12 -3
- package/dist/index.js +161 -138
- package/package.json +1 -1
- package/src/component-names.ts +6 -0
- package/src/components/HighlightText.vue +28 -0
- package/src/components/LpAvatar.vue +21 -2
- package/src/components/LpCommandPalette.vue +392 -86
- package/src/components/LpContextMenu.vue +19 -1
- package/src/components/LpDropdownMenu.vue +14 -2
- package/src/components/LpDropzone.vue +13 -5
- package/src/components/LpKeys.vue +79 -0
- package/src/components/LpLightbox.vue +4 -1
- package/src/components/LpModal.vue +7 -1
- package/src/components/LpModalNav.vue +4 -0
- package/src/components/LpPaletteFacts.vue +165 -0
- package/src/components/LpPaletteHeader.vue +155 -0
- package/src/components/LpPalettePreview.vue +77 -0
- package/src/components/LpPaletteStats.vue +81 -0
- package/src/components/LpPaletteSwatches.vue +56 -0
- package/src/components/LpScrollArea.vue +30 -2
- package/src/components/LpShortcutsDialog.vue +2 -45
- package/src/components/LpTable.vue +7 -2
- package/src/components/MenuTitle.vue +34 -0
- package/src/components/commandPalette.ts +264 -0
- package/src/components/commandSearch.ts +79 -0
- package/src/components/keys.ts +13 -0
- package/src/composables/useClipboard.ts +49 -7
- package/src/index.ts +12 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* A few figures in a palette preview, as tiles: mods and worlds in an
|
|
4
|
+
* instance, downloads and follows of a project, members online. A number
|
|
5
|
+
* turns to a new value through LpNumberFlow, text rolls through
|
|
6
|
+
* LpRollingText, so a figure that refreshes while the row is highlighted
|
|
7
|
+
* moves instead of blinking.
|
|
8
|
+
*
|
|
9
|
+
* <LpPaletteStats :items="[
|
|
10
|
+
* { label: 'Downloads', value: 1250000, compact: true },
|
|
11
|
+
* { label: 'Latest', value: '1.21.4' },
|
|
12
|
+
* ]" />
|
|
13
|
+
*/
|
|
14
|
+
import LpNumberFlow from "./LpNumberFlow.vue"
|
|
15
|
+
import LpRollingText from "./LpRollingText.vue"
|
|
16
|
+
import type { PaletteStat } from "./commandPalette"
|
|
17
|
+
|
|
18
|
+
withDefaults(
|
|
19
|
+
defineProps<{
|
|
20
|
+
items: PaletteStat[]
|
|
21
|
+
/** Tiles per row. */
|
|
22
|
+
columns?: 2 | 3 | 4
|
|
23
|
+
}>(),
|
|
24
|
+
{ columns: 2 },
|
|
25
|
+
)
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<dl class="lp-palette-stats" :style="{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }">
|
|
30
|
+
<div v-for="(item, index) in items" :key="`${item.label}-${index}`" class="lp-palette-stats__tile">
|
|
31
|
+
<dt>{{ item.label }}</dt>
|
|
32
|
+
<dd>
|
|
33
|
+
<LpNumberFlow
|
|
34
|
+
v-if="typeof item.value === 'number'"
|
|
35
|
+
:value="item.value"
|
|
36
|
+
:compact="item.compact"
|
|
37
|
+
:suffix="item.suffix"
|
|
38
|
+
:format="item.format"
|
|
39
|
+
:animate-on-mount="false"
|
|
40
|
+
grouped
|
|
41
|
+
/>
|
|
42
|
+
<LpRollingText v-else :value="item.value" align="start" />
|
|
43
|
+
</dd>
|
|
44
|
+
</div>
|
|
45
|
+
</dl>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<style scoped>
|
|
49
|
+
.lp-palette-stats {
|
|
50
|
+
display: grid;
|
|
51
|
+
gap: 0.5rem;
|
|
52
|
+
margin: 0;
|
|
53
|
+
}
|
|
54
|
+
.lp-palette-stats__tile {
|
|
55
|
+
min-width: 0;
|
|
56
|
+
padding: 0.5rem 0.65rem;
|
|
57
|
+
border: 1px solid var(--color-line);
|
|
58
|
+
border-radius: var(--radius-control);
|
|
59
|
+
background: color-mix(in srgb, var(--color-surface-soft) 55%, transparent);
|
|
60
|
+
}
|
|
61
|
+
dt {
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
color: var(--color-muted);
|
|
64
|
+
font-size: 11px;
|
|
65
|
+
text-overflow: ellipsis;
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
}
|
|
68
|
+
dd {
|
|
69
|
+
display: flex;
|
|
70
|
+
min-width: 0;
|
|
71
|
+
margin: 2px 0 0;
|
|
72
|
+
color: var(--color-ink);
|
|
73
|
+
font-size: 1.125rem;
|
|
74
|
+
font-weight: 650;
|
|
75
|
+
font-variant-numeric: tabular-nums;
|
|
76
|
+
line-height: 1.3;
|
|
77
|
+
}
|
|
78
|
+
dd :deep(.lp-roll) {
|
|
79
|
+
max-width: 100%;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* A theme's colours as a strip of swatches on its own surface, for a palette
|
|
4
|
+
* preview of a theme picker: the row says the name, this shows what picking
|
|
5
|
+
* it would look like. Any CSS colour works.
|
|
6
|
+
*
|
|
7
|
+
* <LpPaletteSwatches
|
|
8
|
+
* :background="theme.colors.surface"
|
|
9
|
+
* :colors="[theme.colors.ink, theme.colors.brand, theme.colors.accent]"
|
|
10
|
+
* />
|
|
11
|
+
*
|
|
12
|
+
* A swatch that changes (the same row after a keepOpen pick) eases to its
|
|
13
|
+
* new colour.
|
|
14
|
+
*/
|
|
15
|
+
defineProps<{
|
|
16
|
+
colors: string[]
|
|
17
|
+
/** The surface the swatches sit on, usually the theme's own. */
|
|
18
|
+
background?: string
|
|
19
|
+
/** Accessible name of the strip, e.g. the theme's name. */
|
|
20
|
+
label?: string
|
|
21
|
+
}>()
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div
|
|
26
|
+
class="lp-palette-swatches"
|
|
27
|
+
role="img"
|
|
28
|
+
:aria-label="label"
|
|
29
|
+
:style="{ background, gridTemplateColumns: `repeat(${Math.max(colors.length, 1)}, minmax(0, 1fr))` }"
|
|
30
|
+
>
|
|
31
|
+
<span v-for="(color, index) in colors" :key="index" :title="color" :style="{ background: color }" />
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<style scoped>
|
|
36
|
+
.lp-palette-swatches {
|
|
37
|
+
display: grid;
|
|
38
|
+
gap: 0.4rem;
|
|
39
|
+
padding: 0.7rem;
|
|
40
|
+
border: 1px solid var(--color-line);
|
|
41
|
+
border-radius: var(--radius-card);
|
|
42
|
+
transition: background-color var(--duration-medium, 240ms) ease;
|
|
43
|
+
}
|
|
44
|
+
.lp-palette-swatches span {
|
|
45
|
+
height: 2rem;
|
|
46
|
+
border-radius: var(--radius-control);
|
|
47
|
+
box-shadow: inset 0 0 0 1px rgb(128 128 128 / 0.25);
|
|
48
|
+
transition: background-color var(--duration-medium, 240ms) ease;
|
|
49
|
+
}
|
|
50
|
+
@media (prefers-reduced-motion: reduce) {
|
|
51
|
+
.lp-palette-swatches,
|
|
52
|
+
.lp-palette-swatches span {
|
|
53
|
+
transition: none;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ScrollAreaThumb,
|
|
14
14
|
ScrollAreaViewport,
|
|
15
15
|
} from "reka-ui"
|
|
16
|
+
import { prefersReducedMotion } from "../composables/easing"
|
|
16
17
|
|
|
17
18
|
// `fade` softens whichever of the top/bottom edges currently has content beyond
|
|
18
19
|
// it, so a clipped list reads as continuing rather than as cut off. On by
|
|
@@ -182,6 +183,29 @@ watch(
|
|
|
182
183
|
|
|
183
184
|
onBeforeUnmount(stopEdgeWatch)
|
|
184
185
|
|
|
186
|
+
/*
|
|
187
|
+
* A press on the bar beside the thumb. reka treats any press on the track as
|
|
188
|
+
* "put the thumb's middle under the pointer" and sets the scroll in one go,
|
|
189
|
+
* so a press that just missed the narrow thumb yanked the content by half a
|
|
190
|
+
* thumb, and a press further away teleported it. Like a native bar, a press
|
|
191
|
+
* off the thumb now pages one viewport towards the pointer, smoothly; the
|
|
192
|
+
* thumb itself has a hit area as wide as the bar (see its `before:`), so a
|
|
193
|
+
* press that meant to grab it does.
|
|
194
|
+
*/
|
|
195
|
+
function onTrackPointerDown(event: PointerEvent, axis: "x" | "y") {
|
|
196
|
+
const el = viewportEl.value
|
|
197
|
+
const bar = event.currentTarget as HTMLElement
|
|
198
|
+
const thumb = bar.querySelector<HTMLElement>("[data-lp-thumb]")
|
|
199
|
+
if (!el || !thumb || event.button !== 0 || thumb.contains(event.target as Node)) return
|
|
200
|
+
event.preventDefault()
|
|
201
|
+
event.stopImmediatePropagation()
|
|
202
|
+
const rect = thumb.getBoundingClientRect()
|
|
203
|
+
const before = axis === "y" ? event.clientY < rect.top : event.clientX < rect.left
|
|
204
|
+
const step = (axis === "y" ? el.clientHeight : el.clientWidth) * 0.85 * (before ? -1 : 1)
|
|
205
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth"
|
|
206
|
+
el.scrollBy(axis === "y" ? { top: step, behavior } : { left: step, behavior })
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
function onThumbPointerDown() {
|
|
186
210
|
dragging.value = true
|
|
187
211
|
const stop = () => {
|
|
@@ -230,6 +254,7 @@ function onThumbPointerDown() {
|
|
|
230
254
|
class="group z-1 flex w-3.5 touch-none select-none justify-end py-px pl-2.5 pr-px"
|
|
231
255
|
:class="barFade"
|
|
232
256
|
:style="barInsetTop ? { marginTop: barInsetTop } : undefined"
|
|
257
|
+
@pointerdown.capture="onTrackPointerDown($event, 'y')"
|
|
233
258
|
>
|
|
234
259
|
<!-- Sized with min-width, not width: reka sets an inline
|
|
235
260
|
`width: var(--reka-scroll-area-thumb-width)` on the thumb, and for a
|
|
@@ -238,7 +263,8 @@ function onThumbPointerDown() {
|
|
|
238
263
|
leaving an invisible thumb. A minimum wins over that without fighting
|
|
239
264
|
the positioning reka owns. -->
|
|
240
265
|
<ScrollAreaThumb
|
|
241
|
-
|
|
266
|
+
data-lp-thumb
|
|
267
|
+
class="relative min-w-1 rounded-l-pill bg-brand/40 before:absolute before:inset-y-0 before:right-0 before:-left-2.5 before:content-[''] transition-[min-width,background-color] duration-[var(--duration-fast)] group-hover:min-w-1.5 group-hover:bg-brand"
|
|
242
268
|
@pointerdown="onThumbPointerDown"
|
|
243
269
|
/>
|
|
244
270
|
</ScrollAreaScrollbar>
|
|
@@ -247,9 +273,11 @@ function onThumbPointerDown() {
|
|
|
247
273
|
orientation="horizontal"
|
|
248
274
|
class="group z-1 flex h-3.5 flex-col touch-none select-none justify-end px-px pb-px pt-2.5"
|
|
249
275
|
:class="barFade"
|
|
276
|
+
@pointerdown.capture="onTrackPointerDown($event, 'x')"
|
|
250
277
|
>
|
|
251
278
|
<ScrollAreaThumb
|
|
252
|
-
|
|
279
|
+
data-lp-thumb
|
|
280
|
+
class="relative min-h-1 rounded-t-pill bg-brand/40 before:absolute before:inset-x-0 before:bottom-0 before:-top-2.5 before:content-[''] transition-[min-height,background-color] duration-[var(--duration-fast)] group-hover:min-h-1.5 group-hover:bg-brand"
|
|
253
281
|
@pointerdown="onThumbPointerDown"
|
|
254
282
|
/>
|
|
255
283
|
</ScrollAreaScrollbar>
|
|
@@ -16,12 +16,11 @@
|
|
|
16
16
|
* look for keys rather than only in the console.
|
|
17
17
|
*/
|
|
18
18
|
import { computed } from "vue"
|
|
19
|
+
import LpKeys from "./LpKeys.vue"
|
|
19
20
|
import LpModal from "./LpModal.vue"
|
|
20
21
|
import {
|
|
21
22
|
activeShortcuts,
|
|
22
|
-
appleModifierLabel,
|
|
23
23
|
mergeShortcutGroups,
|
|
24
|
-
useAppleKeyboard,
|
|
25
24
|
useHotkeys,
|
|
26
25
|
type ShortcutGroup,
|
|
27
26
|
} from "../composables/useHotkeys"
|
|
@@ -49,23 +48,9 @@ const props = withDefaults(
|
|
|
49
48
|
|
|
50
49
|
const text = useLabels("shortcuts", {
|
|
51
50
|
title: "Keyboard shortcuts",
|
|
52
|
-
then: "then",
|
|
53
51
|
conflict: "Also bound elsewhere: only one of them runs",
|
|
54
52
|
group: "General",
|
|
55
53
|
})
|
|
56
|
-
const apple = useAppleKeyboard()
|
|
57
|
-
|
|
58
|
-
/*
|
|
59
|
-
* One key per "+", except a "+" that is the key itself: "Ctrl++" is Ctrl and
|
|
60
|
-
* plus, not Ctrl and two blanks.
|
|
61
|
-
*/
|
|
62
|
-
function keysOf(chord: string): string[] {
|
|
63
|
-
return chord.split(/\+(?!$)/)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function stepsOf(combo: string): string[] {
|
|
67
|
-
return combo.trim().split(/\s+/)
|
|
68
|
-
}
|
|
69
54
|
|
|
70
55
|
const shown = computed(() => (props.live ? mergeShortcutGroups(props.groups, activeShortcuts.value) : props.groups))
|
|
71
56
|
|
|
@@ -112,12 +97,7 @@ useHotkeys(() =>
|
|
|
112
97
|
>!</span>
|
|
113
98
|
</dt>
|
|
114
99
|
<dd>
|
|
115
|
-
<
|
|
116
|
-
<span v-if="stepIndex" class="shortcut-then">{{ text.then }}</span>
|
|
117
|
-
<kbd v-for="(key, index) in keysOf(step)" :key="`${key}-${index}`">
|
|
118
|
-
{{ appleModifierLabel(key.trim(), apple) }}
|
|
119
|
-
</kbd>
|
|
120
|
-
</template>
|
|
100
|
+
<LpKeys :combo="entry.combo" />
|
|
121
101
|
</dd>
|
|
122
102
|
</div>
|
|
123
103
|
</dl>
|
|
@@ -181,33 +161,10 @@ useHotkeys(() =>
|
|
|
181
161
|
cursor: help;
|
|
182
162
|
}
|
|
183
163
|
|
|
184
|
-
.shortcut-then {
|
|
185
|
-
align-self: center;
|
|
186
|
-
padding: 0 2px;
|
|
187
|
-
color: var(--color-muted);
|
|
188
|
-
font-size: 0.68rem;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
164
|
.shortcut-row dd {
|
|
192
165
|
display: flex;
|
|
193
166
|
flex-shrink: 0;
|
|
194
167
|
gap: 3px;
|
|
195
168
|
margin: 0;
|
|
196
169
|
}
|
|
197
|
-
|
|
198
|
-
kbd {
|
|
199
|
-
display: inline-flex;
|
|
200
|
-
min-width: 20px;
|
|
201
|
-
align-items: center;
|
|
202
|
-
justify-content: center;
|
|
203
|
-
padding: 2px 6px;
|
|
204
|
-
border: 1px solid var(--color-line-strong);
|
|
205
|
-
border-bottom-width: 2px;
|
|
206
|
-
border-radius: 5px;
|
|
207
|
-
background: color-mix(in srgb, var(--color-ink) 6%, transparent);
|
|
208
|
-
color: var(--color-muted-strong);
|
|
209
|
-
font-family: inherit;
|
|
210
|
-
font-size: 0.7rem;
|
|
211
|
-
line-height: 1.5;
|
|
212
|
-
}
|
|
213
170
|
</style>
|
|
@@ -909,7 +909,12 @@ const countText = computed(() => {
|
|
|
909
909
|
})
|
|
910
910
|
// A selectable table keeps the bar at all times (the row count until something
|
|
911
911
|
// is selected): ticking the first row must not push the table down.
|
|
912
|
-
|
|
912
|
+
// As documented on `summary`: the bar shows on its own while filters are
|
|
913
|
+
// active or rows are selected, not merely because rows can be selected, which
|
|
914
|
+
// left a lone "75 rows" over every selectable table.
|
|
915
|
+
const showBar = computed(
|
|
916
|
+
() => props.summary || (!!props.selectable && props.selected.length > 0) || activeFilters.value.length > 0,
|
|
917
|
+
)
|
|
913
918
|
const filteredOut = computed(() => {
|
|
914
919
|
if (loading.value || failed.value || displayRows.value.length) return false
|
|
915
920
|
if (props.load) return Object.values(hiddenValues.value).some((set) => set.size)
|
|
@@ -1098,7 +1103,7 @@ const barInsetTop = computed(() =>
|
|
|
1098
1103
|
<LpScrollArea
|
|
1099
1104
|
v-bind="scrollAttrs"
|
|
1100
1105
|
:fade="false"
|
|
1101
|
-
class="min-h-0 flex-1 rounded-card border border-line"
|
|
1106
|
+
class="min-h-0 flex-1 rounded-card border border-line bg-surface"
|
|
1102
1107
|
:style="load && floor ? { minHeight: `${floor + 2}px` } : undefined"
|
|
1103
1108
|
:bar-inset-top="barInsetTop"
|
|
1104
1109
|
>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* The heading row of a menu: what the menu acts on, so a right-click on one of
|
|
4
|
+
* many look-alike rows says which one it caught before anything is chosen.
|
|
5
|
+
* Not an item: it takes no focus or highlight, and long names truncate rather
|
|
6
|
+
* than widening the panel. Internal: LpContextMenu and LpDropdownMenu render it
|
|
7
|
+
* from their `title` props.
|
|
8
|
+
*/
|
|
9
|
+
import LpIcon from "./LpIcon.vue"
|
|
10
|
+
|
|
11
|
+
defineProps<{
|
|
12
|
+
title: string
|
|
13
|
+
subtitle?: string
|
|
14
|
+
icon?: string
|
|
15
|
+
image?: string
|
|
16
|
+
}>()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex max-w-72 min-w-0 items-center gap-2 px-2.5 pt-1.5 pb-2" aria-hidden="true">
|
|
21
|
+
<img v-if="image" :src="image" alt="" class="size-7 shrink-0 rounded-md object-cover" />
|
|
22
|
+
<span
|
|
23
|
+
v-else-if="icon"
|
|
24
|
+
class="grid size-7 shrink-0 place-items-center rounded-md bg-surface-soft text-muted"
|
|
25
|
+
>
|
|
26
|
+
<LpIcon :name="icon" :size="15" />
|
|
27
|
+
</span>
|
|
28
|
+
<span class="flex min-w-0 flex-col">
|
|
29
|
+
<span class="truncate text-sm font-medium text-ink">{{ title }}</span>
|
|
30
|
+
<span v-if="subtitle" class="truncate text-xs text-muted">{{ subtitle }}</span>
|
|
31
|
+
</span>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="mb-1 h-px bg-line" role="presentation" />
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { applyQueryFilters, parsePaletteQuery, type CommandSearchTerms, type HighlightPart, type SearchableCommand } from "./commandSearch"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The parts of LpCommandPalette that are plain logic, kept out of the SFC so
|
|
5
|
+
* they can be tested without mounting a dialog.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The fields of a command that decide whether the preview pane opens for it. */
|
|
9
|
+
export interface PreviewableCommand {
|
|
10
|
+
preview?: string
|
|
11
|
+
excerpt?: string
|
|
12
|
+
noPreview?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether the pane has something to show for a row. `noPreview` on the row
|
|
17
|
+
* wins, then the app's `hasPreview`; without either, a row qualifies when the
|
|
18
|
+
* app supplied a `#preview` slot or the row carries text of its own.
|
|
19
|
+
*/
|
|
20
|
+
export function commandHasPreview<T extends PreviewableCommand>(
|
|
21
|
+
command: T | undefined,
|
|
22
|
+
options: { slot: boolean; hasPreview?: (command: T) => boolean },
|
|
23
|
+
): boolean {
|
|
24
|
+
if (!command || command.noPreview) return false
|
|
25
|
+
if (options.hasPreview) return options.hasPreview(command)
|
|
26
|
+
return options.slot || !!(command.preview || command.excerpt)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One line of LpPaletteFacts: what it is, and what it is set to now. */
|
|
30
|
+
export interface PaletteFact {
|
|
31
|
+
/** Text, or the preview slot's `highlight(text)` to mark the query. */
|
|
32
|
+
label: string | HighlightPart[]
|
|
33
|
+
/** Right-aligned value; a change rolls in rather than swapping. */
|
|
34
|
+
value?: string | number
|
|
35
|
+
/** A second, quieter line under the label. */
|
|
36
|
+
hint?: string | HighlightPart[]
|
|
37
|
+
icon?: string
|
|
38
|
+
/** A colour dot in place of the icon: a role colour, a status. */
|
|
39
|
+
color?: string
|
|
40
|
+
/** Marks the row that is in effect now: the current theme, the open tab. */
|
|
41
|
+
current?: boolean
|
|
42
|
+
/** Draw the value in the monospace face: ids, paths, versions. */
|
|
43
|
+
mono?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One tile of LpPaletteStats. */
|
|
47
|
+
export interface PaletteStat {
|
|
48
|
+
label: string
|
|
49
|
+
/** A number counts to its new value; text rolls. */
|
|
50
|
+
value: number | string
|
|
51
|
+
/** Abbreviate a number: 1.2k, 3.4M. */
|
|
52
|
+
compact?: boolean
|
|
53
|
+
suffix?: string
|
|
54
|
+
format?: (value: number) => string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The fields of a command that let typing enter its mode. */
|
|
58
|
+
export interface PrefixedCommand {
|
|
59
|
+
prefix?: string
|
|
60
|
+
children?: unknown
|
|
61
|
+
disabled?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The mode a query opens by its prefix, and the query left after it. Only a
|
|
66
|
+
* command with `children` and a non-empty `prefix` qualifies; letters match
|
|
67
|
+
* in any case, and the longest prefix wins, so "mr " and "m" can live side by
|
|
68
|
+
* side. Leading spaces are ignored: they are never what someone meant.
|
|
69
|
+
*/
|
|
70
|
+
export function matchModePrefix<T extends PrefixedCommand>(
|
|
71
|
+
query: string,
|
|
72
|
+
commands: readonly T[],
|
|
73
|
+
): { command: T; rest: string } | null {
|
|
74
|
+
const typed = query.trimStart().toLowerCase()
|
|
75
|
+
let best: T | null = null
|
|
76
|
+
for (const command of commands) {
|
|
77
|
+
const prefix = command.prefix?.toLowerCase()
|
|
78
|
+
if (!prefix || !command.children || command.disabled || !typed.startsWith(prefix)) continue
|
|
79
|
+
if (!best || prefix.length > (best.prefix?.length ?? 0)) best = command
|
|
80
|
+
}
|
|
81
|
+
if (!best) return null
|
|
82
|
+
return { command: best, rest: query.trimStart().slice(best.prefix!.length) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The fields of a command that make it a "search elsewhere" row. */
|
|
86
|
+
export interface FallbackCommand extends SearchableCommand {
|
|
87
|
+
fallback?: boolean
|
|
88
|
+
hidden?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The fallback rows to show for a query, with their labels filled in. They
|
|
93
|
+
* appear only while positive words are typed, whatever those words are: the
|
|
94
|
+
* row offers to search somewhere else for them, so matching it against them
|
|
95
|
+
* would hide it exactly when it is needed. Exclusions and `in:` scopes still
|
|
96
|
+
* apply, to the row's own label, keywords and group. `{query}` in the label
|
|
97
|
+
* becomes the positive words, without the operators.
|
|
98
|
+
*/
|
|
99
|
+
export function fallbackRows<T extends FallbackCommand>(
|
|
100
|
+
items: readonly T[],
|
|
101
|
+
raw: string,
|
|
102
|
+
): { command: T; label: string }[] {
|
|
103
|
+
const parsed = parsePaletteQuery(raw.trim())
|
|
104
|
+
if (!parsed.text) return []
|
|
105
|
+
return applyQueryFilters(
|
|
106
|
+
items.filter((c) => c.fallback),
|
|
107
|
+
parsed,
|
|
108
|
+
).map((command) => ({ command, label: command.label.split("{query}").join(parsed.text) }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A remembered pick that is not a root command: one chosen inside a mode, or
|
|
113
|
+
* a result a search fetched. It keeps what the row needs to be drawn again
|
|
114
|
+
* and where it was found, so the palette can find the live command next time
|
|
115
|
+
* (`path` through static children, or the app's `resolveRecent`), and fall
|
|
116
|
+
* back to its `href` when neither can.
|
|
117
|
+
*/
|
|
118
|
+
export interface RecentEntry {
|
|
119
|
+
id: string
|
|
120
|
+
label: string
|
|
121
|
+
description?: string
|
|
122
|
+
icon?: string
|
|
123
|
+
image?: string | { src?: string | null; alt?: string; fallback?: string; icon?: string; shape?: "circle" | "soft" | "square" }
|
|
124
|
+
href?: string
|
|
125
|
+
/** Ids of the modes it was picked in, outermost first. */
|
|
126
|
+
path: string[]
|
|
127
|
+
/** The command's `recentData`, kept as given for `resolveRecent`. */
|
|
128
|
+
data?: unknown
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A stored history item: a root command id (the old format), or an entry. */
|
|
132
|
+
export type RecentItem = string | RecentEntry
|
|
133
|
+
|
|
134
|
+
/** The fields of a command a history entry is built from and resolved to. */
|
|
135
|
+
export interface RecentSource {
|
|
136
|
+
id: string
|
|
137
|
+
label: string
|
|
138
|
+
description?: string
|
|
139
|
+
icon?: string
|
|
140
|
+
image?: RecentEntry["image"]
|
|
141
|
+
href?: string
|
|
142
|
+
recentData?: unknown
|
|
143
|
+
disabled?: boolean
|
|
144
|
+
children?: unknown
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isEntry(value: unknown): value is RecentEntry {
|
|
148
|
+
if (!value || typeof value !== "object") return false
|
|
149
|
+
const v = value as Record<string, unknown>
|
|
150
|
+
return (
|
|
151
|
+
typeof v.id === "string"
|
|
152
|
+
&& typeof v.label === "string"
|
|
153
|
+
&& Array.isArray(v.path)
|
|
154
|
+
&& v.path.every((step) => typeof step === "string")
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Stored history, with anything malformed dropped rather than trusted. */
|
|
159
|
+
export function readRecent(stored: unknown): RecentItem[] {
|
|
160
|
+
if (!Array.isArray(stored)) return []
|
|
161
|
+
return stored.filter((v): v is RecentItem => typeof v === "string" || isEntry(v))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function recentKey(item: RecentItem): string {
|
|
165
|
+
return typeof item === "string" ? item : [...item.path, item.id].join("\u0000")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The history item for a pick. A root pick stays a bare id, the format older
|
|
170
|
+
* versions wrote and still read; anything deeper becomes an entry with only
|
|
171
|
+
* the fields that survive JSON.
|
|
172
|
+
*/
|
|
173
|
+
export function toRecentItem(command: RecentSource, path: readonly string[]): RecentItem {
|
|
174
|
+
if (!path.length) return command.id
|
|
175
|
+
const entry: RecentEntry = { id: command.id, label: command.label, path: [...path] }
|
|
176
|
+
if (command.description) entry.description = command.description
|
|
177
|
+
if (command.icon) entry.icon = command.icon
|
|
178
|
+
if (typeof command.image === "string") entry.image = command.image
|
|
179
|
+
else if (command.image) {
|
|
180
|
+
const { src, alt, fallback, icon, shape } = command.image
|
|
181
|
+
entry.image = { src, alt, fallback, icon, shape }
|
|
182
|
+
}
|
|
183
|
+
if (command.href) entry.href = command.href
|
|
184
|
+
if (command.recentData !== undefined) entry.data = command.recentData
|
|
185
|
+
return entry
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The history with `item` first and any earlier copy of it dropped. */
|
|
189
|
+
export function pushRecent(list: readonly RecentItem[], item: RecentItem, keep: number): RecentItem[] {
|
|
190
|
+
const key = recentKey(item)
|
|
191
|
+
return [item, ...list.filter((v) => recentKey(v) !== key)].slice(0, keep)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The live commands a history stands for, newest first. A bare id is looked
|
|
196
|
+
* up among the root commands; an entry is followed down its path through
|
|
197
|
+
* static children, then offered to `resolve` (for results a fetcher found),
|
|
198
|
+
* and last rebuilt from its own fields when it has an `href` to follow.
|
|
199
|
+
* Whatever none of these can find is skipped, so a stored history never
|
|
200
|
+
* resurrects something that cannot run.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveRecentItems<T extends RecentSource>(
|
|
203
|
+
items: readonly RecentItem[],
|
|
204
|
+
commands: readonly T[],
|
|
205
|
+
options: { limit: number; resolve?: (entry: RecentEntry) => T | null | undefined; fromEntry: (entry: RecentEntry) => T },
|
|
206
|
+
): { command: T; path: string[] }[] {
|
|
207
|
+
const out: { command: T; path: string[] }[] = []
|
|
208
|
+
const seen = new Set<string>()
|
|
209
|
+
for (const item of items) {
|
|
210
|
+
if (out.length >= options.limit) break
|
|
211
|
+
const key = recentKey(item)
|
|
212
|
+
if (seen.has(key)) continue
|
|
213
|
+
const found = typeof item === "string" ? commands.find((c) => c.id === item) : findEntry(item, commands, options)
|
|
214
|
+
if (!found || found.disabled || found.children) continue
|
|
215
|
+
seen.add(key)
|
|
216
|
+
out.push({ command: found, path: typeof item === "string" ? [] : [...item.path] })
|
|
217
|
+
}
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function findEntry<T extends RecentSource>(
|
|
222
|
+
entry: RecentEntry,
|
|
223
|
+
commands: readonly T[],
|
|
224
|
+
options: { resolve?: (entry: RecentEntry) => T | null | undefined; fromEntry: (entry: RecentEntry) => T },
|
|
225
|
+
): T | undefined {
|
|
226
|
+
let level: readonly T[] | null = commands
|
|
227
|
+
for (const step of entry.path) {
|
|
228
|
+
const mode: T | undefined = level?.find((c) => c.id === step)
|
|
229
|
+
level = mode && Array.isArray(mode.children) ? (mode.children as T[]) : null
|
|
230
|
+
}
|
|
231
|
+
const live = level?.find((c) => c.id === entry.id)
|
|
232
|
+
if (live) return live
|
|
233
|
+
const resolved = options.resolve?.(entry)
|
|
234
|
+
if (resolved) return resolved
|
|
235
|
+
return entry.href ? options.fromEntry(entry) : undefined
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Whether a recent row survives the operators typed so far. Recent rows show
|
|
240
|
+
* while no positive words are typed, so "-theme" or "in:recent" filters them
|
|
241
|
+
* like any other row; an `in:` scope matches the row's own group or the
|
|
242
|
+
* Recent heading it sits under.
|
|
243
|
+
*/
|
|
244
|
+
export function recentVisible(command: SearchableCommand, terms: CommandSearchTerms, recentLabel: string): boolean {
|
|
245
|
+
if (!applyQueryFilters([{ ...command, group: undefined }], { ...terms, groups: [] }).length) return false
|
|
246
|
+
return terms.groups.every((term) =>
|
|
247
|
+
[command.group, recentLabel].some((group) => group?.toLowerCase().includes(term)),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The name a mode's chip shows. `modeLabel` when given; otherwise the label,
|
|
253
|
+
* with a `{query}` placeholder and the quotes around it taken out, since the
|
|
254
|
+
* words it stood for are in the box now ("Search mods for "{query}"" reads
|
|
255
|
+
* "Search mods").
|
|
256
|
+
*/
|
|
257
|
+
export function modeChipLabel(command: { label: string; modeLabel?: string }): string {
|
|
258
|
+
if (command.modeLabel) return command.modeLabel
|
|
259
|
+
if (!command.label.includes("{query}")) return command.label
|
|
260
|
+
return command.label
|
|
261
|
+
.replace(/\s*["'“”«»]?\{query\}["'“”«»]?/g, "")
|
|
262
|
+
.replace(/\s+(for|in|on|:)$/i, "")
|
|
263
|
+
.trim()
|
|
264
|
+
}
|