@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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/component-names.d.ts +1 -0
  4. package/dist/components/LayoutCanvas.vue.d.ts +28 -0
  5. package/dist/components/LayoutNode.vue.d.ts +40 -0
  6. package/dist/components/LpAlert.vue.d.ts +44 -0
  7. package/dist/components/LpAppShell.vue.d.ts +83 -0
  8. package/dist/components/LpAutocomplete.vue.d.ts +50 -0
  9. package/dist/components/LpAvatar.vue.d.ts +20 -0
  10. package/dist/components/LpBadge.vue.d.ts +47 -0
  11. package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
  12. package/dist/components/LpButton.vue.d.ts +113 -0
  13. package/dist/components/LpCalendar.vue.d.ts +20 -0
  14. package/dist/components/LpCard.vue.d.ts +29 -0
  15. package/dist/components/LpCheckbox.vue.d.ts +28 -0
  16. package/dist/components/LpCodeBlock.vue.d.ts +39 -0
  17. package/dist/components/LpCommandPalette.vue.d.ts +42 -0
  18. package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
  19. package/dist/components/LpContextMenu.vue.d.ts +30 -0
  20. package/dist/components/LpDatePicker.vue.d.ts +25 -0
  21. package/dist/components/LpDisclosure.vue.d.ts +37 -0
  22. package/dist/components/LpDivider.vue.d.ts +17 -0
  23. package/dist/components/LpDrawer.vue.d.ts +88 -0
  24. package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
  25. package/dist/components/LpEmptyState.vue.d.ts +18 -0
  26. package/dist/components/LpFormField.vue.d.ts +21 -0
  27. package/dist/components/LpIcon.vue.d.ts +8 -0
  28. package/dist/components/LpInfraNode.vue.d.ts +21 -0
  29. package/dist/components/LpInput.vue.d.ts +68 -0
  30. package/dist/components/LpLaneNode.vue.d.ts +24 -0
  31. package/dist/components/LpLink.vue.d.ts +27 -0
  32. package/dist/components/LpLogViewer.vue.d.ts +92 -0
  33. package/dist/components/LpModal.vue.d.ts +43 -0
  34. package/dist/components/LpNotificationBell.vue.d.ts +51 -0
  35. package/dist/components/LpNumberField.vue.d.ts +14 -0
  36. package/dist/components/LpOtpInput.vue.d.ts +27 -0
  37. package/dist/components/LpPagination.vue.d.ts +25 -0
  38. package/dist/components/LpPasswordInput.vue.d.ts +28 -0
  39. package/dist/components/LpPhoneInput.vue.d.ts +37 -0
  40. package/dist/components/LpPopover.vue.d.ts +34 -0
  41. package/dist/components/LpProgress.vue.d.ts +12 -0
  42. package/dist/components/LpRadio.vue.d.ts +18 -0
  43. package/dist/components/LpRadioGroup.vue.d.ts +26 -0
  44. package/dist/components/LpScrollArea.vue.d.ts +24 -0
  45. package/dist/components/LpSegmented.vue.d.ts +24 -0
  46. package/dist/components/LpSelect.vue.d.ts +45 -0
  47. package/dist/components/LpServiceNode.vue.d.ts +14 -0
  48. package/dist/components/LpSidebar.vue.d.ts +85 -0
  49. package/dist/components/LpSidebarNav.vue.d.ts +34 -0
  50. package/dist/components/LpSkeleton.vue.d.ts +6 -0
  51. package/dist/components/LpSlider.vue.d.ts +23 -0
  52. package/dist/components/LpStat.vue.d.ts +22 -0
  53. package/dist/components/LpStepper.vue.d.ts +10 -0
  54. package/dist/components/LpSwitch.vue.d.ts +11 -0
  55. package/dist/components/LpTable.vue.d.ts +63 -0
  56. package/dist/components/LpTableOfContents.vue.d.ts +19 -0
  57. package/dist/components/LpTabs.vue.d.ts +45 -0
  58. package/dist/components/LpTextarea.vue.d.ts +17 -0
  59. package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
  60. package/dist/components/LpToaster.vue.d.ts +3 -0
  61. package/dist/components/LpTooltip.vue.d.ts +19 -0
  62. package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
  63. package/dist/components/LpUptimeBar.vue.d.ts +30 -0
  64. package/dist/components/codeHighlight.d.ts +8 -0
  65. package/dist/components/countries-data.d.ts +2 -0
  66. package/dist/components/countries.d.ts +20 -0
  67. package/dist/components/dropdown.d.ts +29 -0
  68. package/dist/components/sidebar.d.ts +18 -0
  69. package/dist/composables/useInputFilter.d.ts +14 -0
  70. package/dist/composables/usePillTransition.d.ts +9 -0
  71. package/dist/composables/useToast.d.ts +39 -0
  72. package/dist/countries-data-9rgqJWHo.js +203 -0
  73. package/dist/icons.d.ts +1 -0
  74. package/dist/index.css +1 -0
  75. package/dist/index.d.ts +100 -0
  76. package/dist/index.js +17600 -0
  77. package/dist/layout/registry.d.ts +26 -0
  78. package/dist/layout/tree.d.ts +75 -0
  79. package/dist/layout/useLayout.d.ts +39 -0
  80. package/dist/theme/config.d.ts +16 -0
  81. package/dist/theme/define.d.ts +12 -0
  82. package/dist/theme/presets.d.ts +20 -0
  83. package/dist/theme/tokens.d.ts +72 -0
  84. package/dist/theme/useTheme.d.ts +36 -0
  85. package/package.json +93 -0
  86. package/src/canvas.css +67 -0
  87. package/src/component-names.ts +65 -0
  88. package/src/components/LayoutCanvas.vue +76 -0
  89. package/src/components/LayoutNode.vue +248 -0
  90. package/src/components/LpAlert.vue +59 -0
  91. package/src/components/LpAppShell.vue +210 -0
  92. package/src/components/LpAutocomplete.vue +244 -0
  93. package/src/components/LpAvatar.vue +67 -0
  94. package/src/components/LpBadge.vue +32 -0
  95. package/src/components/LpBreadcrumbs.vue +35 -0
  96. package/src/components/LpButton.vue +111 -0
  97. package/src/components/LpCalendar.vue +134 -0
  98. package/src/components/LpCard.vue +71 -0
  99. package/src/components/LpCheckbox.vue +44 -0
  100. package/src/components/LpCodeBlock.vue +298 -0
  101. package/src/components/LpCommandPalette.vue +323 -0
  102. package/src/components/LpConfirmDialog.vue +49 -0
  103. package/src/components/LpContextMenu.vue +111 -0
  104. package/src/components/LpDatePicker.vue +117 -0
  105. package/src/components/LpDisclosure.vue +92 -0
  106. package/src/components/LpDivider.vue +20 -0
  107. package/src/components/LpDrawer.vue +402 -0
  108. package/src/components/LpDropdownMenu.vue +54 -0
  109. package/src/components/LpEmptyState.vue +21 -0
  110. package/src/components/LpFormField.vue +31 -0
  111. package/src/components/LpIcon.vue +49 -0
  112. package/src/components/LpInfraNode.vue +108 -0
  113. package/src/components/LpInput.vue +147 -0
  114. package/src/components/LpLaneNode.vue +48 -0
  115. package/src/components/LpLink.vue +48 -0
  116. package/src/components/LpLogViewer.vue +569 -0
  117. package/src/components/LpModal.vue +123 -0
  118. package/src/components/LpNotificationBell.vue +238 -0
  119. package/src/components/LpNumberField.vue +56 -0
  120. package/src/components/LpOtpInput.vue +81 -0
  121. package/src/components/LpPagination.vue +165 -0
  122. package/src/components/LpPasswordInput.vue +50 -0
  123. package/src/components/LpPhoneInput.vue +241 -0
  124. package/src/components/LpPopover.vue +42 -0
  125. package/src/components/LpProgress.vue +30 -0
  126. package/src/components/LpRadio.vue +28 -0
  127. package/src/components/LpRadioGroup.vue +43 -0
  128. package/src/components/LpScrollArea.vue +82 -0
  129. package/src/components/LpSegmented.vue +83 -0
  130. package/src/components/LpSelect.vue +0 -0
  131. package/src/components/LpServiceNode.vue +57 -0
  132. package/src/components/LpSidebar.vue +200 -0
  133. package/src/components/LpSidebarNav.vue +118 -0
  134. package/src/components/LpSkeleton.vue +16 -0
  135. package/src/components/LpSlider.vue +53 -0
  136. package/src/components/LpStat.vue +90 -0
  137. package/src/components/LpStepper.vue +46 -0
  138. package/src/components/LpSwitch.vue +19 -0
  139. package/src/components/LpTable.vue +252 -0
  140. package/src/components/LpTableOfContents.vue +183 -0
  141. package/src/components/LpTabs.vue +120 -0
  142. package/src/components/LpTextarea.vue +39 -0
  143. package/src/components/LpThemeSwitcher.vue +168 -0
  144. package/src/components/LpToaster.vue +216 -0
  145. package/src/components/LpTooltip.vue +30 -0
  146. package/src/components/LpTopologyCanvas.vue +370 -0
  147. package/src/components/LpUptimeBar.vue +126 -0
  148. package/src/components/codeHighlight.ts +137 -0
  149. package/src/components/countries-data.ts +207 -0
  150. package/src/components/countries.ts +67 -0
  151. package/src/components/dropdown.ts +64 -0
  152. package/src/components/sidebar.ts +26 -0
  153. package/src/composables/useInputFilter.ts +89 -0
  154. package/src/composables/usePillTransition.ts +19 -0
  155. package/src/composables/useToast.ts +92 -0
  156. package/src/icons.ts +11 -0
  157. package/src/index.css +21 -0
  158. package/src/index.ts +146 -0
  159. package/src/layout/registry.ts +44 -0
  160. package/src/layout/tree.ts +288 -0
  161. package/src/layout/useLayout.ts +134 -0
  162. package/src/nuxt.ts +22 -0
  163. package/src/theme/config.ts +48 -0
  164. package/src/theme/define.ts +50 -0
  165. package/src/theme/presets.ts +279 -0
  166. package/src/theme/tokens.ts +151 -0
  167. package/src/theme/useTheme.ts +288 -0
  168. package/src/tokens/scrollbar.css +138 -0
  169. package/src/tokens/tokens.css +333 -0
  170. 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>