@leavepulse/ui 0.12.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/component-names.d.ts +1 -0
- package/dist/components/LayoutCanvas.vue.d.ts +28 -0
- package/dist/components/LayoutNode.vue.d.ts +40 -0
- package/dist/components/LpAlert.vue.d.ts +44 -0
- package/dist/components/LpAppShell.vue.d.ts +83 -0
- package/dist/components/LpAutocomplete.vue.d.ts +50 -0
- package/dist/components/LpAvatar.vue.d.ts +20 -0
- package/dist/components/LpBadge.vue.d.ts +47 -0
- package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
- package/dist/components/LpButton.vue.d.ts +113 -0
- package/dist/components/LpCalendar.vue.d.ts +20 -0
- package/dist/components/LpCard.vue.d.ts +29 -0
- package/dist/components/LpCheckbox.vue.d.ts +28 -0
- package/dist/components/LpCodeBlock.vue.d.ts +39 -0
- package/dist/components/LpCommandPalette.vue.d.ts +42 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
- package/dist/components/LpContextMenu.vue.d.ts +30 -0
- package/dist/components/LpDatePicker.vue.d.ts +25 -0
- package/dist/components/LpDisclosure.vue.d.ts +37 -0
- package/dist/components/LpDivider.vue.d.ts +17 -0
- package/dist/components/LpDrawer.vue.d.ts +88 -0
- package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
- package/dist/components/LpEmptyState.vue.d.ts +18 -0
- package/dist/components/LpFormField.vue.d.ts +21 -0
- package/dist/components/LpIcon.vue.d.ts +8 -0
- package/dist/components/LpInfraNode.vue.d.ts +21 -0
- package/dist/components/LpInput.vue.d.ts +68 -0
- package/dist/components/LpLaneNode.vue.d.ts +24 -0
- package/dist/components/LpLink.vue.d.ts +27 -0
- package/dist/components/LpLogViewer.vue.d.ts +92 -0
- package/dist/components/LpModal.vue.d.ts +43 -0
- package/dist/components/LpNotificationBell.vue.d.ts +51 -0
- package/dist/components/LpNumberField.vue.d.ts +14 -0
- package/dist/components/LpOtpInput.vue.d.ts +27 -0
- package/dist/components/LpPagination.vue.d.ts +25 -0
- package/dist/components/LpPasswordInput.vue.d.ts +28 -0
- package/dist/components/LpPhoneInput.vue.d.ts +37 -0
- package/dist/components/LpPopover.vue.d.ts +34 -0
- package/dist/components/LpProgress.vue.d.ts +12 -0
- package/dist/components/LpRadio.vue.d.ts +18 -0
- package/dist/components/LpRadioGroup.vue.d.ts +26 -0
- package/dist/components/LpScrollArea.vue.d.ts +24 -0
- package/dist/components/LpSegmented.vue.d.ts +24 -0
- package/dist/components/LpSelect.vue.d.ts +45 -0
- package/dist/components/LpServiceNode.vue.d.ts +14 -0
- package/dist/components/LpSidebar.vue.d.ts +85 -0
- package/dist/components/LpSidebarNav.vue.d.ts +34 -0
- package/dist/components/LpSkeleton.vue.d.ts +6 -0
- package/dist/components/LpSlider.vue.d.ts +23 -0
- package/dist/components/LpStat.vue.d.ts +22 -0
- package/dist/components/LpStepper.vue.d.ts +10 -0
- package/dist/components/LpSwitch.vue.d.ts +11 -0
- package/dist/components/LpTable.vue.d.ts +63 -0
- package/dist/components/LpTableOfContents.vue.d.ts +19 -0
- package/dist/components/LpTabs.vue.d.ts +45 -0
- package/dist/components/LpTextarea.vue.d.ts +17 -0
- package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
- package/dist/components/LpToaster.vue.d.ts +3 -0
- package/dist/components/LpTooltip.vue.d.ts +19 -0
- package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
- package/dist/components/LpUptimeBar.vue.d.ts +30 -0
- package/dist/components/codeHighlight.d.ts +8 -0
- package/dist/components/countries-data.d.ts +2 -0
- package/dist/components/countries.d.ts +20 -0
- package/dist/components/dropdown.d.ts +29 -0
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/composables/useInputFilter.d.ts +14 -0
- package/dist/composables/usePillTransition.d.ts +9 -0
- package/dist/composables/useToast.d.ts +39 -0
- package/dist/countries-data-9rgqJWHo.js +203 -0
- package/dist/icons.d.ts +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +17600 -0
- package/dist/layout/registry.d.ts +26 -0
- package/dist/layout/tree.d.ts +75 -0
- package/dist/layout/useLayout.d.ts +39 -0
- package/dist/theme/config.d.ts +16 -0
- package/dist/theme/define.d.ts +12 -0
- package/dist/theme/presets.d.ts +20 -0
- package/dist/theme/tokens.d.ts +72 -0
- package/dist/theme/useTheme.d.ts +36 -0
- package/package.json +93 -0
- package/src/canvas.css +67 -0
- package/src/component-names.ts +65 -0
- package/src/components/LayoutCanvas.vue +76 -0
- package/src/components/LayoutNode.vue +248 -0
- package/src/components/LpAlert.vue +59 -0
- package/src/components/LpAppShell.vue +210 -0
- package/src/components/LpAutocomplete.vue +244 -0
- package/src/components/LpAvatar.vue +67 -0
- package/src/components/LpBadge.vue +32 -0
- package/src/components/LpBreadcrumbs.vue +35 -0
- package/src/components/LpButton.vue +111 -0
- package/src/components/LpCalendar.vue +134 -0
- package/src/components/LpCard.vue +71 -0
- package/src/components/LpCheckbox.vue +44 -0
- package/src/components/LpCodeBlock.vue +298 -0
- package/src/components/LpCommandPalette.vue +323 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpContextMenu.vue +111 -0
- package/src/components/LpDatePicker.vue +117 -0
- package/src/components/LpDisclosure.vue +92 -0
- package/src/components/LpDivider.vue +20 -0
- package/src/components/LpDrawer.vue +402 -0
- package/src/components/LpDropdownMenu.vue +54 -0
- package/src/components/LpEmptyState.vue +21 -0
- package/src/components/LpFormField.vue +31 -0
- package/src/components/LpIcon.vue +49 -0
- package/src/components/LpInfraNode.vue +108 -0
- package/src/components/LpInput.vue +147 -0
- package/src/components/LpLaneNode.vue +48 -0
- package/src/components/LpLink.vue +48 -0
- package/src/components/LpLogViewer.vue +569 -0
- package/src/components/LpModal.vue +123 -0
- package/src/components/LpNotificationBell.vue +238 -0
- package/src/components/LpNumberField.vue +56 -0
- package/src/components/LpOtpInput.vue +81 -0
- package/src/components/LpPagination.vue +165 -0
- package/src/components/LpPasswordInput.vue +50 -0
- package/src/components/LpPhoneInput.vue +241 -0
- package/src/components/LpPopover.vue +42 -0
- package/src/components/LpProgress.vue +30 -0
- package/src/components/LpRadio.vue +28 -0
- package/src/components/LpRadioGroup.vue +43 -0
- package/src/components/LpScrollArea.vue +82 -0
- package/src/components/LpSegmented.vue +83 -0
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpServiceNode.vue +57 -0
- package/src/components/LpSidebar.vue +200 -0
- package/src/components/LpSidebarNav.vue +118 -0
- package/src/components/LpSkeleton.vue +16 -0
- package/src/components/LpSlider.vue +53 -0
- package/src/components/LpStat.vue +90 -0
- package/src/components/LpStepper.vue +46 -0
- package/src/components/LpSwitch.vue +19 -0
- package/src/components/LpTable.vue +252 -0
- package/src/components/LpTableOfContents.vue +183 -0
- package/src/components/LpTabs.vue +120 -0
- package/src/components/LpTextarea.vue +39 -0
- package/src/components/LpThemeSwitcher.vue +168 -0
- package/src/components/LpToaster.vue +216 -0
- package/src/components/LpTooltip.vue +30 -0
- package/src/components/LpTopologyCanvas.vue +370 -0
- package/src/components/LpUptimeBar.vue +126 -0
- package/src/components/codeHighlight.ts +137 -0
- package/src/components/countries-data.ts +207 -0
- package/src/components/countries.ts +67 -0
- package/src/components/dropdown.ts +64 -0
- package/src/components/sidebar.ts +26 -0
- package/src/composables/useInputFilter.ts +89 -0
- package/src/composables/usePillTransition.ts +19 -0
- package/src/composables/useToast.ts +92 -0
- package/src/icons.ts +11 -0
- package/src/index.css +21 -0
- package/src/index.ts +146 -0
- package/src/layout/registry.ts +44 -0
- package/src/layout/tree.ts +288 -0
- package/src/layout/useLayout.ts +134 -0
- package/src/nuxt.ts +22 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/define.ts +50 -0
- package/src/theme/presets.ts +279 -0
- package/src/theme/tokens.ts +151 -0
- package/src/theme/useTheme.ts +288 -0
- package/src/tokens/scrollbar.css +138 -0
- package/src/tokens/tokens.css +333 -0
- package/src/tokens/utilities.css +23 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Horizontal/vertical rule, optionally with a centered label.
|
|
3
|
+
defineProps<{ vertical?: boolean; label?: string }>()
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div
|
|
8
|
+
v-if="vertical"
|
|
9
|
+
class="mx-1 w-px shrink-0 self-stretch bg-line"
|
|
10
|
+
role="separator"
|
|
11
|
+
aria-orientation="vertical"
|
|
12
|
+
/>
|
|
13
|
+
<div v-else class="flex items-center gap-3" role="separator">
|
|
14
|
+
<span class="h-px flex-1 bg-line" />
|
|
15
|
+
<span v-if="label || $slots.default" class="text-xs text-muted">
|
|
16
|
+
<slot>{{ label }}</slot>
|
|
17
|
+
</span>
|
|
18
|
+
<span v-if="label || $slots.default" class="h-px flex-1 bg-line" />
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Side / bottom panel built on vaul-vue (drag-driven drawer over reka's Dialog
|
|
4
|
+
* primitive — same base as the rest of the kit, so accessibility and the token
|
|
5
|
+
* theming carry over). Over the old reka-Dialog version this adds drag-to-
|
|
6
|
+
* dismiss with inertia, snap points, a draggable handle and an optional
|
|
7
|
+
* scale-background effect. Slot/prop surface stays backwards compatible: `side`
|
|
8
|
+
* still works (mapped onto `direction`), plus `size`/`width`/title/footer.
|
|
9
|
+
*
|
|
10
|
+
* scale-background note: the effect scales the page behind the drawer, so the
|
|
11
|
+
* host app must wrap its root in an element with `[vaul-drawer-wrapper]` (and a
|
|
12
|
+
* solid background) for vaul to find and transform. Without it the drawer still
|
|
13
|
+
* works — the background just doesn't scale.
|
|
14
|
+
*/
|
|
15
|
+
import { usePointerSwipe } from "@vueuse/core"
|
|
16
|
+
import {
|
|
17
|
+
DrawerClose,
|
|
18
|
+
DrawerContent,
|
|
19
|
+
DrawerDescription,
|
|
20
|
+
DrawerHandle,
|
|
21
|
+
DrawerOverlay,
|
|
22
|
+
DrawerPortal,
|
|
23
|
+
DrawerRoot,
|
|
24
|
+
DrawerTitle,
|
|
25
|
+
} from "vaul-vue"
|
|
26
|
+
import { computed, ref, useSlots } from "vue"
|
|
27
|
+
import { CLOSE_ICON } from "./dropdown"
|
|
28
|
+
import LpIcon from "./LpIcon.vue"
|
|
29
|
+
import LpScrollArea from "./LpScrollArea.vue"
|
|
30
|
+
|
|
31
|
+
type Direction = "top" | "bottom" | "left" | "right"
|
|
32
|
+
|
|
33
|
+
const props = withDefaults(
|
|
34
|
+
defineProps<{
|
|
35
|
+
open?: boolean
|
|
36
|
+
/** Edge the drawer attaches to. `side` is a back-compat alias (left/right). */
|
|
37
|
+
direction?: Direction
|
|
38
|
+
side?: "left" | "right"
|
|
39
|
+
title?: string
|
|
40
|
+
description?: string
|
|
41
|
+
/** Size preset along the drawer's main axis (width for left/right, height for top/bottom). */
|
|
42
|
+
size?: "sm" | "md" | "lg" | "xl"
|
|
43
|
+
/** Explicit size override (any CSS length), wins over `size`. */
|
|
44
|
+
width?: string
|
|
45
|
+
/** Drag-to-dismiss + click-outside. Set false to force a controlled close. */
|
|
46
|
+
dismissible?: boolean
|
|
47
|
+
/** Fractions (0–1) or px strings the drawer snaps between, nearest edge first. */
|
|
48
|
+
snapPoints?: (number | string)[]
|
|
49
|
+
/** Show the drag handle (auto-on for bottom/top sheets). */
|
|
50
|
+
handle?: boolean
|
|
51
|
+
/** Only the handle initiates a drag — frees inner content to scroll. */
|
|
52
|
+
handleOnly?: boolean
|
|
53
|
+
/** Scale the [vaul-drawer-wrapper] background while open. */
|
|
54
|
+
scaleBackground?: boolean
|
|
55
|
+
/** Drag fraction (0–1) past which a release dismisses. */
|
|
56
|
+
closeThreshold?: number
|
|
57
|
+
/**
|
|
58
|
+
* Auto-tag interactive descendants (input, textarea, select, button, links,
|
|
59
|
+
* [contenteditable]) with data-vaul-no-drag so a drag can't start on them —
|
|
60
|
+
* you can select text / use controls without dragging the drawer. On by
|
|
61
|
+
* default; the drawer still drags from empty space, the handle and header.
|
|
62
|
+
*/
|
|
63
|
+
noDragControls?: boolean
|
|
64
|
+
/**
|
|
65
|
+
* Mark the entire body as non-draggable: the drawer then only drags from the
|
|
66
|
+
* handle/header. Stronger than noDragControls — use for form-heavy drawers.
|
|
67
|
+
*/
|
|
68
|
+
noDragContent?: boolean
|
|
69
|
+
/**
|
|
70
|
+
* Open by dragging in from the screen edge (Discord-style). Renders an
|
|
71
|
+
* invisible edge strip; a pull from it drags a preview panel that follows
|
|
72
|
+
* the finger, and a release past `edgeOpenThreshold` opens the drawer. Only
|
|
73
|
+
* meaningful for left/right drawers.
|
|
74
|
+
*/
|
|
75
|
+
edgeOpen?: boolean
|
|
76
|
+
/** Width (px) of the edge grab strip. */
|
|
77
|
+
edgeSize?: number
|
|
78
|
+
/** Fraction (0–1) of the panel width the pull must reach to open on release. */
|
|
79
|
+
edgeOpenThreshold?: number
|
|
80
|
+
/**
|
|
81
|
+
* Let the user drag the inner edge to resize the panel (horizontal drawers
|
|
82
|
+
* only — e.g. a logs panel whose long lines overflow). The new size is kept
|
|
83
|
+
* for the session. Min/max bound the drag.
|
|
84
|
+
*/
|
|
85
|
+
resizable?: boolean
|
|
86
|
+
/** Min main-axis size while resizing (any CSS length). Default 22rem. */
|
|
87
|
+
minSize?: string
|
|
88
|
+
/** Max main-axis size while resizing (any CSS length). Default 96vw. */
|
|
89
|
+
maxSize?: string
|
|
90
|
+
}>(),
|
|
91
|
+
{
|
|
92
|
+
side: "right",
|
|
93
|
+
size: "sm",
|
|
94
|
+
dismissible: true,
|
|
95
|
+
noDragControls: true,
|
|
96
|
+
edgeSize: 44,
|
|
97
|
+
edgeOpenThreshold: 0.3,
|
|
98
|
+
minSize: "22rem",
|
|
99
|
+
maxSize: "96vw",
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const emit = defineEmits<{
|
|
104
|
+
(e: "update:open", value: boolean): void
|
|
105
|
+
}>()
|
|
106
|
+
|
|
107
|
+
// `direction` wins; otherwise fall back to the legacy `side`.
|
|
108
|
+
const dir = computed<Direction>(() => props.direction ?? props.side ?? "right")
|
|
109
|
+
const isHorizontal = computed(() => dir.value === "left" || dir.value === "right")
|
|
110
|
+
const isSheet = computed(() => dir.value === "top" || dir.value === "bottom")
|
|
111
|
+
|
|
112
|
+
// Handle defaults on for top/bottom sheets (where dragging a handle is the norm).
|
|
113
|
+
const showHandle = computed(() => props.handle ?? isSheet.value)
|
|
114
|
+
|
|
115
|
+
const SIZES = { sm: 22, md: 28, lg: 36, xl: 48 } // rem along the main axis
|
|
116
|
+
|
|
117
|
+
// Geometry per edge. The cross-axis is pinned to the viewport; the main axis
|
|
118
|
+
// takes the size preset. vaul drives the open/close transform itself, so we
|
|
119
|
+
// don't add slide keyframes — only the resting box + rounding toward the centre.
|
|
120
|
+
const contentClass = computed(() => {
|
|
121
|
+
const base = "fixed z-(--z-modal) flex flex-col border-line bg-surface-raised shadow-panel outline-none"
|
|
122
|
+
const map: Record<Direction, string> = {
|
|
123
|
+
left: "inset-y-0 left-0 border-r rounded-r-card",
|
|
124
|
+
right: "inset-y-0 right-0 border-l rounded-l-card",
|
|
125
|
+
top: "inset-x-0 top-0 border-b rounded-b-card",
|
|
126
|
+
bottom: "inset-x-0 bottom-0 border-t rounded-t-card",
|
|
127
|
+
}
|
|
128
|
+
return [base, map[dir.value]]
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// User-resized main-axis size (px), null until the handle is dragged. Persists
|
|
132
|
+
// for the component's lifetime so reopening keeps the chosen width.
|
|
133
|
+
const resizedPx = ref<number | null>(null)
|
|
134
|
+
|
|
135
|
+
const sizeStyle = computed(() => {
|
|
136
|
+
// When snapPoints drive the height, let vaul own the main-axis size.
|
|
137
|
+
if (props.snapPoints?.length && isSheet.value) return undefined
|
|
138
|
+
// A live resize wins over the preset/width.
|
|
139
|
+
if (props.resizable && isHorizontal.value && resizedPx.value != null) {
|
|
140
|
+
return { width: `${resizedPx.value}px` }
|
|
141
|
+
}
|
|
142
|
+
const len = props.width ?? `min(${isHorizontal.value ? "94vw" : "94vh"}, ${SIZES[props.size]}rem)`
|
|
143
|
+
return isHorizontal.value ? { width: len } : { height: len }
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Drag the inner edge to resize. For a right drawer the panel grows as the
|
|
147
|
+
// pointer moves left (width = viewportRight − clientX); mirror for left.
|
|
148
|
+
function startResize(e: PointerEvent) {
|
|
149
|
+
if (!props.resizable || !isHorizontal.value) return
|
|
150
|
+
e.preventDefault()
|
|
151
|
+
const fromRight = dir.value === "right"
|
|
152
|
+
const toPx = (css: string) => {
|
|
153
|
+
// Resolve rem/vw to px against the viewport for clamping.
|
|
154
|
+
if (css.endsWith("rem")) return Number.parseFloat(css) * 16
|
|
155
|
+
if (css.endsWith("vw")) return (Number.parseFloat(css) / 100) * window.innerWidth
|
|
156
|
+
return Number.parseFloat(css)
|
|
157
|
+
}
|
|
158
|
+
const min = toPx(props.minSize)
|
|
159
|
+
const max = toPx(props.maxSize)
|
|
160
|
+
const onMove = (ev: PointerEvent) => {
|
|
161
|
+
const raw = fromRight ? window.innerWidth - ev.clientX : ev.clientX
|
|
162
|
+
resizedPx.value = Math.max(min, Math.min(max, raw))
|
|
163
|
+
}
|
|
164
|
+
const onUp = () => {
|
|
165
|
+
window.removeEventListener("pointermove", onMove)
|
|
166
|
+
window.removeEventListener("pointerup", onUp)
|
|
167
|
+
document.body.style.userSelect = ""
|
|
168
|
+
document.body.style.cursor = ""
|
|
169
|
+
}
|
|
170
|
+
document.body.style.userSelect = "none"
|
|
171
|
+
document.body.style.cursor = "ew-resize"
|
|
172
|
+
window.addEventListener("pointermove", onMove)
|
|
173
|
+
window.addEventListener("pointerup", onUp)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const slots = useSlots()
|
|
177
|
+
const hasHeader = computed(() => Boolean(props.title || slots.title))
|
|
178
|
+
|
|
179
|
+
// Body padding. The handle or the header already own the top edge, so drop the
|
|
180
|
+
// body's top padding when either is present; otherwise pad evenly.
|
|
181
|
+
const padClass = computed(() => {
|
|
182
|
+
const horizontal = "px-5 pb-5"
|
|
183
|
+
if (showHandle.value || hasHeader.value) return `${horizontal} pt-0`
|
|
184
|
+
return "p-5"
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Header padding is edge-independent (the side-drawer bug was deriving it from
|
|
188
|
+
// padClass, which left side drawers with no horizontal inset). Always inset
|
|
189
|
+
// horizontally + separate from the body; trim the top when a handle sits above.
|
|
190
|
+
const headerClass = computed(() => (showHandle.value ? "px-5 pb-4 pt-1" : "px-5 pb-4 pt-5"))
|
|
191
|
+
|
|
192
|
+
// vaul aborts a drag whose pointerdown lands on (or inside) a [data-vaul-no-drag]
|
|
193
|
+
// element. We tag interactive descendants so the drawer can't be dragged off a
|
|
194
|
+
// field/control — text selection and clicks work, drag still starts on blank
|
|
195
|
+
// space. Re-runs on update so it covers slot content that mounts/changes later.
|
|
196
|
+
const NO_DRAG_SELECTOR =
|
|
197
|
+
"input, textarea, select, button, a[href], label, [contenteditable=''], [contenteditable='true']"
|
|
198
|
+
function tagNoDrag(el: HTMLElement, { value }: { value: boolean }) {
|
|
199
|
+
if (!value) return
|
|
200
|
+
for (const node of el.querySelectorAll<HTMLElement>(NO_DRAG_SELECTOR)) {
|
|
201
|
+
node.setAttribute("data-vaul-no-drag", "")
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const vNoDragControls = {
|
|
205
|
+
mounted: tagNoDrag,
|
|
206
|
+
updated: tagNoDrag,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Edge-drag-to-open (Discord-style) ──────────────────────────────────────
|
|
210
|
+
// vaul opens/closes programmatically and only drags an *already-open* drawer,
|
|
211
|
+
// so the pull-from-edge gesture is ours: an invisible edge strip captures the
|
|
212
|
+
// pointer, we translate a preview panel that follows the finger, and a release
|
|
213
|
+
// past the threshold flips `open` (vaul then animates the rest). Horizontal
|
|
214
|
+
// drawers only.
|
|
215
|
+
const edgeStrip = ref<HTMLElement | null>(null)
|
|
216
|
+
// 0 (closed) … 1 (fully pulled in). Drives the preview transform + overlay.
|
|
217
|
+
const peek = ref(0)
|
|
218
|
+
const peeking = ref(false)
|
|
219
|
+
// True while we hand off from the finger-preview to the real drawer, so vaul's
|
|
220
|
+
// own open animation is suppressed — the preview has already slid it in, a
|
|
221
|
+
// second slide would look like a double-open.
|
|
222
|
+
const handingOff = ref(false)
|
|
223
|
+
|
|
224
|
+
// Resolve the panel's main-axis size in px to map drag distance → progress.
|
|
225
|
+
function panelPx(): number {
|
|
226
|
+
const vw = typeof window === "undefined" ? 0 : window.innerWidth
|
|
227
|
+
// mirror sizeStyle's min(94vw, SIZES[size]rem); 1rem≈16px.
|
|
228
|
+
const cap = props.width ? Number.parseFloat(props.width) : SIZES[props.size] * 16
|
|
229
|
+
return Math.min(vw * 0.94, Number.isFinite(cap) ? cap : SIZES[props.size] * 16)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
usePointerSwipe(edgeStrip, {
|
|
233
|
+
threshold: 0,
|
|
234
|
+
pointerTypes: ["touch", "pen"],
|
|
235
|
+
onSwipeStart() {
|
|
236
|
+
if (!props.edgeOpen || props.open) return
|
|
237
|
+
peeking.value = true
|
|
238
|
+
},
|
|
239
|
+
onSwipe(e) {
|
|
240
|
+
if (!peeking.value) return
|
|
241
|
+
// Inward pull distance: from the right edge we move left (−x), from the
|
|
242
|
+
// left edge we move right (+x). Normalise to 0…1 of the panel width.
|
|
243
|
+
const fromRight = dir.value === "right"
|
|
244
|
+
const dx = fromRight ? window.innerWidth - e.clientX : e.clientX
|
|
245
|
+
peek.value = Math.max(0, Math.min(1, dx / panelPx()))
|
|
246
|
+
},
|
|
247
|
+
onSwipeEnd() {
|
|
248
|
+
if (!peeking.value) return
|
|
249
|
+
peeking.value = false
|
|
250
|
+
if (peek.value >= props.edgeOpenThreshold) {
|
|
251
|
+
// Open: let the preview finish sliding to fully-in, then swap in the real
|
|
252
|
+
// drawer *without* a vaul slide (handingOff) so there's no second anim.
|
|
253
|
+
peek.value = 1
|
|
254
|
+
handingOff.value = true
|
|
255
|
+
window.setTimeout(() => {
|
|
256
|
+
emit("update:open", true)
|
|
257
|
+
// Clear after the real drawer has mounted in place.
|
|
258
|
+
window.setTimeout(() => {
|
|
259
|
+
handingOff.value = false
|
|
260
|
+
peek.value = 0
|
|
261
|
+
}, 60)
|
|
262
|
+
}, 160)
|
|
263
|
+
} else {
|
|
264
|
+
// Below threshold: snap the preview back out.
|
|
265
|
+
peek.value = 0
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// The preview panel slides with the finger: translate from fully-off (100%)
|
|
271
|
+
// toward 0 as peek → 1. Kept visible through the hand-off so the real drawer
|
|
272
|
+
// can appear underneath without a flash.
|
|
273
|
+
const previewStyle = computed(() => {
|
|
274
|
+
if (!props.edgeOpen || (props.open && !handingOff.value)) return { display: "none" }
|
|
275
|
+
const off = (1 - peek.value) * 100
|
|
276
|
+
const axis = dir.value === "right" ? off : -off
|
|
277
|
+
return {
|
|
278
|
+
transform: `translateX(${axis}%)`,
|
|
279
|
+
transition: peeking.value ? "none" : "transform 0.16s ease-out",
|
|
280
|
+
pointerEvents: "none" as const,
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const edgeStripStyle = computed(() => {
|
|
285
|
+
const w = `${props.edgeSize}px`
|
|
286
|
+
return dir.value === "right"
|
|
287
|
+
? { right: "0", width: w }
|
|
288
|
+
: { left: "0", width: w }
|
|
289
|
+
})
|
|
290
|
+
</script>
|
|
291
|
+
|
|
292
|
+
<template>
|
|
293
|
+
<!-- Edge-drag-to-open: an invisible grab strip + a finger-following preview
|
|
294
|
+
of the panel, shown only while closed. Horizontal drawers only. -->
|
|
295
|
+
<template v-if="edgeOpen && isHorizontal">
|
|
296
|
+
<div
|
|
297
|
+
ref="edgeStrip"
|
|
298
|
+
class="fixed inset-y-0 z-(--z-overlay) touch-none"
|
|
299
|
+
:style="edgeStripStyle"
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
/>
|
|
302
|
+
<div
|
|
303
|
+
v-show="peeking || peek > 0"
|
|
304
|
+
class="pointer-events-none fixed inset-0 z-(--z-overlay) bg-black/50"
|
|
305
|
+
:style="{ opacity: peek }"
|
|
306
|
+
/>
|
|
307
|
+
<div :class="contentClass" :style="[sizeStyle, previewStyle]" aria-hidden="true">
|
|
308
|
+
<div v-if="hasHeader" class="flex items-start justify-between gap-4" :class="headerClass">
|
|
309
|
+
<span class="text-base font-semibold text-ink">{{ title }}</span>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
</template>
|
|
313
|
+
|
|
314
|
+
<DrawerRoot
|
|
315
|
+
:open="open"
|
|
316
|
+
:direction="dir"
|
|
317
|
+
:dismissible="dismissible"
|
|
318
|
+
:snap-points="snapPoints"
|
|
319
|
+
:handle-only="handleOnly"
|
|
320
|
+
:should-scale-background="scaleBackground"
|
|
321
|
+
:close-threshold="closeThreshold"
|
|
322
|
+
@update:open="(v: boolean) => emit('update:open', v)"
|
|
323
|
+
>
|
|
324
|
+
<DrawerPortal>
|
|
325
|
+
<DrawerOverlay
|
|
326
|
+
class="fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
|
|
327
|
+
/>
|
|
328
|
+
<DrawerContent
|
|
329
|
+
:class="contentClass"
|
|
330
|
+
:style="sizeStyle"
|
|
331
|
+
:data-vaul-animate="handingOff ? 'false' : undefined"
|
|
332
|
+
>
|
|
333
|
+
<!-- Resize grip on the inner edge (horizontal drawers). data-vaul-no-drag
|
|
334
|
+
so grabbing it resizes the panel instead of starting a vaul drag. -->
|
|
335
|
+
<div
|
|
336
|
+
v-if="resizable && isHorizontal"
|
|
337
|
+
data-vaul-no-drag
|
|
338
|
+
class="group/resize absolute inset-y-0 z-10 w-1.5 cursor-ew-resize"
|
|
339
|
+
:class="dir === 'right' ? 'left-0' : 'right-0'"
|
|
340
|
+
@pointerdown="startResize"
|
|
341
|
+
>
|
|
342
|
+
<span
|
|
343
|
+
class="absolute inset-y-0 w-px bg-line transition-colors group-hover/resize:bg-brand"
|
|
344
|
+
:class="dir === 'right' ? 'left-0' : 'right-0'"
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<!-- Drag handle: a pill the user grabs; vaul wires the drag to it. -->
|
|
349
|
+
<DrawerHandle
|
|
350
|
+
v-if="showHandle"
|
|
351
|
+
class="mx-auto my-2 h-1.5 w-12 shrink-0 cursor-grab rounded-pill bg-line-strong transition-colors hover:bg-muted active:cursor-grabbing"
|
|
352
|
+
/>
|
|
353
|
+
|
|
354
|
+
<header
|
|
355
|
+
v-if="hasHeader"
|
|
356
|
+
class="flex items-start justify-between gap-4"
|
|
357
|
+
:class="headerClass"
|
|
358
|
+
>
|
|
359
|
+
<div class="flex flex-col gap-1">
|
|
360
|
+
<DrawerTitle class="text-base font-semibold text-ink">
|
|
361
|
+
<slot name="title">{{ title }}</slot>
|
|
362
|
+
</DrawerTitle>
|
|
363
|
+
<DrawerDescription v-if="description" class="text-sm text-muted">
|
|
364
|
+
{{ description }}
|
|
365
|
+
</DrawerDescription>
|
|
366
|
+
</div>
|
|
367
|
+
<DrawerClose
|
|
368
|
+
class="group flex shrink-0 items-center rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
369
|
+
aria-label="Close"
|
|
370
|
+
>
|
|
371
|
+
<LpIcon
|
|
372
|
+
name="lucide:x"
|
|
373
|
+
:size="18"
|
|
374
|
+
:class="CLOSE_ICON"
|
|
375
|
+
/>
|
|
376
|
+
</DrawerClose>
|
|
377
|
+
</header>
|
|
378
|
+
|
|
379
|
+
<!-- Body scrolls via LpScrollArea's overlay bar. The wrapper keeps the
|
|
380
|
+
no-drag wiring on the OUTER element so vaul still sees one content
|
|
381
|
+
container (overlay scroll lives one level in); padding moves to the
|
|
382
|
+
scroll content so the bar floats at the panel edge. -->
|
|
383
|
+
<div
|
|
384
|
+
v-no-drag-controls="noDragControls"
|
|
385
|
+
class="flex min-h-0 min-w-0 flex-1 flex-col"
|
|
386
|
+
:data-vaul-no-drag="noDragContent ? '' : undefined"
|
|
387
|
+
>
|
|
388
|
+
<!-- min-w-0 on the body + scroll content so a wide child (e.g. a log
|
|
389
|
+
viewer's min-w-max rows with wrap off) scrolls INSIDE itself
|
|
390
|
+
instead of stretching the whole panel into a horizontal scroll. -->
|
|
391
|
+
<LpScrollArea class="min-h-0 min-w-0 flex-1" :content-class="`min-w-0 ${padClass}`">
|
|
392
|
+
<slot />
|
|
393
|
+
</LpScrollArea>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<footer v-if="$slots.footer" class="flex justify-end gap-2 px-5 pb-5 pt-4">
|
|
397
|
+
<slot name="footer" />
|
|
398
|
+
</footer>
|
|
399
|
+
</DrawerContent>
|
|
400
|
+
</DrawerPortal>
|
|
401
|
+
</DrawerRoot>
|
|
402
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenuContent,
|
|
4
|
+
DropdownMenuItem,
|
|
5
|
+
DropdownMenuPortal,
|
|
6
|
+
DropdownMenuRoot,
|
|
7
|
+
DropdownMenuSeparator,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "reka-ui"
|
|
10
|
+
import { POPOVER_PANEL } from "./dropdown"
|
|
11
|
+
import LpIcon from "./LpIcon.vue"
|
|
12
|
+
|
|
13
|
+
export interface MenuItem {
|
|
14
|
+
label: string
|
|
15
|
+
icon?: string
|
|
16
|
+
danger?: boolean
|
|
17
|
+
separatorBefore?: boolean
|
|
18
|
+
onSelect?: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
defineProps<{ items: MenuItem[] }>()
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<DropdownMenuRoot>
|
|
26
|
+
<DropdownMenuTrigger as-child>
|
|
27
|
+
<slot name="trigger" />
|
|
28
|
+
</DropdownMenuTrigger>
|
|
29
|
+
<DropdownMenuPortal>
|
|
30
|
+
<DropdownMenuContent
|
|
31
|
+
:side-offset="6"
|
|
32
|
+
align="end"
|
|
33
|
+
:class="[POPOVER_PANEL, 'z-(--z-popover) min-w-44 p-1 outline-none']"
|
|
34
|
+
>
|
|
35
|
+
<template v-for="(item, i) in items" :key="i">
|
|
36
|
+
<DropdownMenuSeparator v-if="item.separatorBefore" class="my-1 h-px bg-line" />
|
|
37
|
+
<DropdownMenuItem
|
|
38
|
+
class="group/item flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-[background-color,color] duration-[var(--duration-fast)] data-[highlighted]:bg-brand-soft"
|
|
39
|
+
:class="item.danger ? 'text-danger data-[highlighted]:bg-danger-soft data-[highlighted]:text-danger' : 'text-ink data-[highlighted]:text-brand'"
|
|
40
|
+
@select="item.onSelect?.()"
|
|
41
|
+
>
|
|
42
|
+
<LpIcon
|
|
43
|
+
v-if="item.icon"
|
|
44
|
+
:name="item.icon"
|
|
45
|
+
:size="15"
|
|
46
|
+
class="transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-data-[highlighted]/item:translate-x-0.5"
|
|
47
|
+
/>
|
|
48
|
+
{{ item.label }}
|
|
49
|
+
</DropdownMenuItem>
|
|
50
|
+
</template>
|
|
51
|
+
</DropdownMenuContent>
|
|
52
|
+
</DropdownMenuPortal>
|
|
53
|
+
</DropdownMenuRoot>
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import LpIcon from "./LpIcon.vue"
|
|
3
|
+
|
|
4
|
+
defineProps<{ icon?: string; title: string; description?: string }>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div class="flex flex-col items-center gap-3 px-6 py-12 text-center">
|
|
9
|
+
<div
|
|
10
|
+
v-if="icon"
|
|
11
|
+
class="flex size-12 items-center justify-center rounded-pill bg-surface-soft text-muted"
|
|
12
|
+
>
|
|
13
|
+
<LpIcon :name="icon" :size="22" />
|
|
14
|
+
</div>
|
|
15
|
+
<div class="flex flex-col gap-1">
|
|
16
|
+
<p class="font-semibold text-ink">{{ title }}</p>
|
|
17
|
+
<p v-if="description" class="max-w-sm text-sm text-muted">{{ description }}</p>
|
|
18
|
+
</div>
|
|
19
|
+
<div v-if="$slots.default" class="mt-1"><slot /></div>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Form-control wrapper: label + the control (default slot) + hint/error.
|
|
4
|
+
* Error takes priority over hint. Uses reka Label for proper association.
|
|
5
|
+
*/
|
|
6
|
+
import { Label } from "reka-ui"
|
|
7
|
+
import { useId } from "vue"
|
|
8
|
+
|
|
9
|
+
defineProps<{
|
|
10
|
+
label?: string
|
|
11
|
+
hint?: string
|
|
12
|
+
error?: string
|
|
13
|
+
required?: boolean
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const id = useId()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex flex-col gap-1.5">
|
|
21
|
+
<Label v-if="label" :for="id" class="text-sm font-medium text-ink">
|
|
22
|
+
{{ label }}
|
|
23
|
+
<span v-if="required" class="text-danger">*</span>
|
|
24
|
+
</Label>
|
|
25
|
+
|
|
26
|
+
<slot :id="id" />
|
|
27
|
+
|
|
28
|
+
<p v-if="error" class="text-xs text-danger">{{ error }}</p>
|
|
29
|
+
<p v-else-if="hint" class="text-xs text-muted">{{ hint }}</p>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Icon — wrapper over @iconify/vue. Names follow iconify convention
|
|
4
|
+
* (e.g. "lucide:check"), matching the frontend's icon usage. lucide is bundled
|
|
5
|
+
* offline (see icons.ts) so it works without network — important for Tauri.
|
|
6
|
+
*
|
|
7
|
+
* SSR-safe: when the icon is already registered (it is, offline), we render the
|
|
8
|
+
* inline <svg> synchronously from getIcon() so the server and client produce
|
|
9
|
+
* identical markup. The <Icon> component only resolves icons after mount, which
|
|
10
|
+
* left empty SSR placeholders and caused hydration mismatches. We fall back to
|
|
11
|
+
* <Icon> only for icons that aren't registered yet (e.g. fetched on demand).
|
|
12
|
+
*/
|
|
13
|
+
import { computed } from "vue"
|
|
14
|
+
import { Icon, getIcon } from "@iconify/vue"
|
|
15
|
+
import "../icons" // registers the lucide collection offline
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
/** iconify name, e.g. "lucide:check". Bare names assume the lucide set. */
|
|
19
|
+
name: string
|
|
20
|
+
size?: number | string
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
function resolve(name: string): string {
|
|
24
|
+
return name.includes(":") ? name : `lucide:${name}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const fullName = computed(() => resolve(props.name))
|
|
28
|
+
const data = computed(() => getIcon(fullName.value))
|
|
29
|
+
const dim = computed(() => props.size ?? "1em")
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<svg
|
|
34
|
+
v-if="data"
|
|
35
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
36
|
+
:width="dim"
|
|
37
|
+
:height="dim"
|
|
38
|
+
:viewBox="`0 0 ${data.width} ${data.height}`"
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
v-html="data.body"
|
|
41
|
+
/>
|
|
42
|
+
<Icon
|
|
43
|
+
v-else
|
|
44
|
+
:icon="fullName"
|
|
45
|
+
:width="dim"
|
|
46
|
+
:height="dim"
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
/>
|
|
49
|
+
</template>
|