@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,323 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Command palette (⌘K). A centered dialog with a filter box and a keyboard-
4
+ * navigable, grouped command list. Built on reka Dialog (focus trap + overlay);
5
+ * the list nav (↑/↓/Enter) is owned here so the query input keeps focus while
6
+ * arrows move the highlight — the pattern the LeavePulse frontend palette uses.
7
+ *
8
+ * Presentation-only and data-agnostic: the consumer supplies `commands` (a
9
+ * composable can assemble them from routes/docs/API). Scoring ranks label >
10
+ * keywords > description; matches are highlighted; the active row scrolls into
11
+ * view. Open state is v-model:open; a global ⌘K/Ctrl-K toggle is on by default.
12
+ */
13
+ import {
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogOverlay,
17
+ DialogPortal,
18
+ DialogRoot,
19
+ DialogTitle,
20
+ } from "reka-ui"
21
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
22
+ import LpIcon from "./LpIcon.vue"
23
+ import LpScrollArea from "./LpScrollArea.vue"
24
+
25
+ export interface Command {
26
+ id: string
27
+ label: string
28
+ /** Second line under the label. */
29
+ description?: string
30
+ icon?: string
31
+ /** Right-aligned hint, e.g. "⌘P". */
32
+ shortcut?: string
33
+ /** Extra search terms not shown but matched against the query. */
34
+ keywords?: string[]
35
+ /** Section heading this command sits under. */
36
+ group?: string
37
+ disabled?: boolean
38
+ /**
39
+ * Hidden from the default (empty-query) list — only surfaces once the user
40
+ * types something it matches. For rarely-used or destructive actions (prune,
41
+ * decommission, …) that shouldn't clutter the palette but stay reachable.
42
+ */
43
+ hidden?: boolean
44
+ onSelect?: () => void
45
+ }
46
+
47
+ const props = withDefaults(
48
+ defineProps<{
49
+ open?: boolean
50
+ commands: Command[]
51
+ placeholder?: string
52
+ emptyText?: string
53
+ /** Bind a global ⌘K / Ctrl-K toggle. */
54
+ hotkey?: boolean
55
+ }>(),
56
+ { placeholder: "Type a command or search…", emptyText: "No results", hotkey: true },
57
+ )
58
+
59
+ const emit = defineEmits<{
60
+ (e: "update:open", value: boolean): void
61
+ (e: "select", command: Command): void
62
+ }>()
63
+
64
+ const query = ref("")
65
+ const selectedIndex = ref(0)
66
+ const inputRef = ref<HTMLInputElement | null>(null)
67
+ const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
68
+
69
+ // ── filtering + scoring ──────────────────────────────────────
70
+ // No query → everything (grouped). With a query, score by where it hits:
71
+ // label > keywords > description, then alphabetical. Mirrors the app palette.
72
+ const ranked = computed<Command[]>(() => {
73
+ const q = query.value.trim().toLowerCase()
74
+ // Empty query: show everything EXCEPT hidden commands (they only surface once
75
+ // typed). With a query, hidden commands rank like any other.
76
+ if (!q) return props.commands.filter((c) => !c.hidden)
77
+ return props.commands
78
+ .map((c) => {
79
+ const label = c.label.toLowerCase().includes(q)
80
+ const kw = (c.keywords ?? []).some((k) => k.toLowerCase().includes(q))
81
+ const desc = c.description?.toLowerCase().includes(q) ?? false
82
+ const score = (label ? 4 : 0) + (kw ? 3 : 0) + (desc ? 2 : 0)
83
+ return { c, score }
84
+ })
85
+ .filter((e) => e.score > 0)
86
+ .sort((a, b) => b.score - a.score || a.c.label.localeCompare(b.c.label))
87
+ .map((e) => e.c)
88
+ })
89
+
90
+ // Group preserving first-seen order; ungrouped commands sit under "".
91
+ const grouped = computed<{ name: string; items: Command[] }[]>(() => {
92
+ const order: string[] = []
93
+ const map = new Map<string, Command[]>()
94
+ for (const c of ranked.value) {
95
+ const g = c.group ?? ""
96
+ if (!map.has(g)) {
97
+ map.set(g, [])
98
+ order.push(g)
99
+ }
100
+ map.get(g)!.push(c)
101
+ }
102
+ return order.map((name) => ({ name, items: map.get(name)! }))
103
+ })
104
+
105
+ // Flat list in render order — what arrow keys walk through.
106
+ const flat = computed(() => grouped.value.flatMap((g) => g.items))
107
+ function flatIndexOf(c: Command): number {
108
+ return flat.value.indexOf(c)
109
+ }
110
+
111
+ // ── highlight ────────────────────────────────────────────────
112
+ // Split text around query terms (≥2 chars, longest-first) for <mark> wrapping,
113
+ // without v-html. Multi-word queries highlight each term.
114
+ interface Part {
115
+ text: string
116
+ hit: boolean
117
+ }
118
+ function highlight(value: string): Part[] {
119
+ const terms = [...new Set(query.value.toLowerCase().trim().split(/\s+/).filter((t) => t.length >= 2))].sort(
120
+ (a, b) => b.length - a.length,
121
+ )
122
+ if (!value || terms.length === 0) return value ? [{ text: value, hit: false }] : []
123
+ const lower = value.toLowerCase()
124
+ const parts: Part[] = []
125
+ let i = 0
126
+ while (i < value.length) {
127
+ let at = -1
128
+ let term = ""
129
+ for (const t of terms) {
130
+ const found = lower.indexOf(t, i)
131
+ if (found !== -1 && (at === -1 || found < at || (found === at && t.length > term.length))) {
132
+ at = found
133
+ term = t
134
+ }
135
+ }
136
+ if (at === -1) {
137
+ parts.push({ text: value.slice(i), hit: false })
138
+ break
139
+ }
140
+ if (at > i) parts.push({ text: value.slice(i, at), hit: false })
141
+ parts.push({ text: value.slice(at, at + term.length), hit: true })
142
+ i = at + term.length
143
+ }
144
+ return parts
145
+ }
146
+
147
+ // ── selection + keyboard ─────────────────────────────────────
148
+ watch(query, () => (selectedIndex.value = 0))
149
+ watch(
150
+ () => flat.value.length,
151
+ (len) => {
152
+ if (selectedIndex.value >= len) selectedIndex.value = Math.max(0, len - 1)
153
+ },
154
+ )
155
+ watch([selectedIndex, () => flat.value.map((c) => c.id).join("|")], async () => {
156
+ await nextTick()
157
+ scrollSelectedIntoView()
158
+ })
159
+
160
+ function scrollSelectedIntoView() {
161
+ const el = scrollRef.value?.viewportEl?.querySelector<HTMLElement>(
162
+ `[data-cmd-index="${selectedIndex.value}"]`,
163
+ )
164
+ el?.scrollIntoView({ block: "nearest" })
165
+ }
166
+
167
+ function moveDown() {
168
+ const len = flat.value.length
169
+ if (len) selectedIndex.value = (selectedIndex.value + 1) % len
170
+ }
171
+ function moveUp() {
172
+ const len = flat.value.length
173
+ if (len) selectedIndex.value = (selectedIndex.value - 1 + len) % len
174
+ }
175
+ function runSelected() {
176
+ run(flat.value[selectedIndex.value])
177
+ }
178
+
179
+ function run(c: Command | undefined) {
180
+ if (!c || c.disabled) return
181
+ emit("select", c)
182
+ c.onSelect?.()
183
+ emit("update:open", false)
184
+ }
185
+
186
+ // Reset + focus when opening; clear on close.
187
+ watch(
188
+ () => props.open,
189
+ async (o) => {
190
+ if (o) {
191
+ query.value = ""
192
+ selectedIndex.value = 0
193
+ await nextTick()
194
+ inputRef.value?.focus()
195
+ }
196
+ },
197
+ )
198
+
199
+ function onHotkey(e: KeyboardEvent) {
200
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
201
+ e.preventDefault()
202
+ emit("update:open", !props.open)
203
+ }
204
+ }
205
+ onMounted(() => {
206
+ if (props.hotkey) window.addEventListener("keydown", onHotkey)
207
+ })
208
+ onBeforeUnmount(() => window.removeEventListener("keydown", onHotkey))
209
+ </script>
210
+
211
+ <template>
212
+ <DialogRoot :open="open" @update:open="(v) => emit('update:open', v)">
213
+ <DialogPortal>
214
+ <DialogOverlay
215
+ 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]"
216
+ />
217
+ <DialogContent
218
+ class="fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] w-[min(92vw,40rem)] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
219
+ @open-auto-focus.prevent
220
+ >
221
+ <DialogTitle class="sr-only">Command palette</DialogTitle>
222
+ <DialogDescription class="sr-only">Search and run a command.</DialogDescription>
223
+
224
+ <!-- Filter -->
225
+ <div class="flex shrink-0 items-center gap-2.5 border-b border-line px-4">
226
+ <LpIcon name="lucide:search" :size="18" class="shrink-0 text-muted" />
227
+ <input
228
+ ref="inputRef"
229
+ v-model="query"
230
+ type="text"
231
+ :placeholder="placeholder"
232
+ class="h-12 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-muted"
233
+ @keydown.down.prevent="moveDown"
234
+ @keydown.up.prevent="moveUp"
235
+ @keydown.enter.prevent="runSelected"
236
+ />
237
+ <kbd class="shrink-0 rounded border border-line px-1.5 py-0.5 text-[10px] text-muted">esc</kbd>
238
+ </div>
239
+
240
+ <!-- Results -->
241
+ <LpScrollArea ref="scrollRef" class="min-h-0 flex-1" content-class="p-2">
242
+ <p v-if="flat.length === 0" class="px-2 py-10 text-center text-sm text-muted">
243
+ {{ emptyText }}
244
+ </p>
245
+
246
+ <template v-for="group in grouped" :key="group.name">
247
+ <div
248
+ v-if="group.name"
249
+ class="px-2 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
250
+ >
251
+ {{ group.name }}
252
+ </div>
253
+ <button
254
+ v-for="c in group.items"
255
+ :key="c.id"
256
+ type="button"
257
+ :data-cmd-index="flatIndexOf(c)"
258
+ :disabled="c.disabled"
259
+ class="lp-cmd-item flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-left text-sm outline-none transition-colors duration-[var(--duration-fast)] disabled:pointer-events-none disabled:opacity-45"
260
+ :class="flatIndexOf(c) === selectedIndex ? 'bg-brand-soft text-brand' : 'text-ink hover:bg-surface-soft/60'"
261
+ @click="run(c)"
262
+ @pointerenter="selectedIndex = flatIndexOf(c)"
263
+ >
264
+ <LpIcon v-if="c.icon" :name="c.icon" :size="16" class="shrink-0" />
265
+ <span class="min-w-0 flex-1">
266
+ <span class="block truncate font-medium">
267
+ <template v-for="(p, pi) in highlight(c.label)" :key="`l${pi}`">
268
+ <mark v-if="p.hit" class="rounded-xs bg-brand/25 text-ink">{{ p.text }}</mark>
269
+ <template v-else>{{ p.text }}</template>
270
+ </template>
271
+ </span>
272
+ <span v-if="c.description" class="mt-0.5 block truncate text-xs text-muted">
273
+ <template v-for="(p, pi) in highlight(c.description)" :key="`d${pi}`">
274
+ <mark v-if="p.hit" class="rounded-xs bg-brand/20 text-ink">{{ p.text }}</mark>
275
+ <template v-else>{{ p.text }}</template>
276
+ </template>
277
+ </span>
278
+ </span>
279
+ <kbd v-if="c.shortcut" class="shrink-0 text-xs text-muted">{{ c.shortcut }}</kbd>
280
+ </button>
281
+ </template>
282
+ </LpScrollArea>
283
+
284
+ <!-- Footer hints -->
285
+ <div
286
+ class="flex shrink-0 items-center gap-4 border-t border-line px-4 py-2.5 text-[11px] text-muted"
287
+ >
288
+ <span class="flex items-center gap-1">
289
+ <kbd class="rounded border border-line px-1 text-[10px]">↑</kbd>
290
+ <kbd class="rounded border border-line px-1 text-[10px]">↓</kbd>
291
+ navigate
292
+ </span>
293
+ <span class="flex items-center gap-1">
294
+ <kbd class="rounded border border-line px-1 text-[10px]">↵</kbd>
295
+ select
296
+ </span>
297
+ <span class="ml-auto flex items-center gap-1">
298
+ <kbd class="rounded border border-line px-1 text-[10px]">esc</kbd>
299
+ close
300
+ </span>
301
+ </div>
302
+ </DialogContent>
303
+ </DialogPortal>
304
+ </DialogRoot>
305
+ </template>
306
+
307
+ <style scoped>
308
+ /* Each result eases in; honours reduced motion. */
309
+ .lp-cmd-item {
310
+ animation: lp-cmd-in 140ms ease-out both;
311
+ }
312
+ @keyframes lp-cmd-in {
313
+ from {
314
+ opacity: 0;
315
+ transform: translateY(3px);
316
+ }
317
+ }
318
+ @media (prefers-reduced-motion: reduce) {
319
+ .lp-cmd-item {
320
+ animation: none;
321
+ }
322
+ }
323
+ </style>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ // Confirmation dialog built on LpModal. Resolves an action via confirm/cancel.
3
+ import LpButton from "./LpButton.vue"
4
+ import LpModal from "./LpModal.vue"
5
+
6
+ withDefaults(
7
+ defineProps<{
8
+ open?: boolean
9
+ title?: string
10
+ description?: string
11
+ confirmLabel?: string
12
+ cancelLabel?: string
13
+ danger?: boolean
14
+ }>(),
15
+ { confirmLabel: "Confirm", cancelLabel: "Cancel" },
16
+ )
17
+
18
+ const emit = defineEmits<{
19
+ (e: "update:open", value: boolean): void
20
+ (e: "confirm"): void
21
+ (e: "cancel"): void
22
+ }>()
23
+
24
+ function confirm() {
25
+ emit("confirm")
26
+ emit("update:open", false)
27
+ }
28
+ function cancel() {
29
+ emit("cancel")
30
+ emit("update:open", false)
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <LpModal
36
+ :open="open"
37
+ :title="title"
38
+ :description="description"
39
+ @update:open="(v) => $emit('update:open', v)"
40
+ >
41
+ <slot />
42
+ <template #footer>
43
+ <LpButton variant="ghost" @click="cancel">{{ cancelLabel }}</LpButton>
44
+ <LpButton :variant="danger ? 'danger' : 'solid'" @click="confirm">
45
+ {{ confirmLabel }}
46
+ </LpButton>
47
+ </template>
48
+ </LpModal>
49
+ </template>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Right-click context menu (reka ContextMenu). Same item shape as
4
+ * LpDropdownMenu, plus nested submenus (`children`) and a shortcut hint. The
5
+ * trigger is whatever you put in the default slot — right-clicking it (or
6
+ * long-press on touch) opens the menu at the pointer. Shares the floating-panel
7
+ * surface + option-row look with the rest of the kit via dropdown.ts.
8
+ */
9
+ import {
10
+ ContextMenuContent,
11
+ ContextMenuItem,
12
+ ContextMenuPortal,
13
+ ContextMenuRoot,
14
+ ContextMenuSeparator,
15
+ ContextMenuSub,
16
+ ContextMenuSubContent,
17
+ ContextMenuSubTrigger,
18
+ ContextMenuTrigger,
19
+ } from "reka-ui"
20
+ import { computed } from "vue"
21
+ import { POPOVER_PANEL } from "./dropdown"
22
+ import LpIcon from "./LpIcon.vue"
23
+
24
+ export interface ContextMenuItemDef {
25
+ label: string
26
+ icon?: string
27
+ /** Right-aligned hint, e.g. "⌘C". */
28
+ shortcut?: string
29
+ danger?: boolean
30
+ disabled?: boolean
31
+ separatorBefore?: boolean
32
+ onSelect?: () => void
33
+ /** Nested submenu. When present, onSelect is ignored. */
34
+ children?: ContextMenuItemDef[]
35
+ }
36
+
37
+ const props = defineProps<{ items: ContextMenuItemDef[] }>()
38
+
39
+ // With no items, render just the trigger slot — no reka machinery — so the
40
+ // element keeps the browser's native context menu. Lets callers wrap
41
+ // unconditionally and pass [] to opt out per element.
42
+ const enabled = computed(() => props.items.length > 0)
43
+
44
+ const PANEL = `${POPOVER_PANEL} z-(--z-popover) min-w-48 p-1 outline-none`
45
+ const ITEM =
46
+ "group/item flex cursor-pointer select-none 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-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[highlighted]:bg-brand-soft"
47
+ const ICON =
48
+ "transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-data-[highlighted]/item:translate-x-0.5"
49
+ </script>
50
+
51
+ <template>
52
+ <slot v-if="!enabled" />
53
+
54
+ <ContextMenuRoot v-else>
55
+ <ContextMenuTrigger as-child>
56
+ <slot />
57
+ </ContextMenuTrigger>
58
+ <ContextMenuPortal>
59
+ <ContextMenuContent :class="PANEL">
60
+ <template v-for="(item, i) in items" :key="i">
61
+ <ContextMenuSeparator v-if="item.separatorBefore" class="my-1 h-px bg-line" />
62
+
63
+ <!-- Submenu branch -->
64
+ <ContextMenuSub v-if="item.children?.length">
65
+ <ContextMenuSubTrigger
66
+ :class="[ITEM, 'justify-between text-ink data-[highlighted]:text-brand data-[state=open]:bg-brand-soft data-[state=open]:text-brand']"
67
+ >
68
+ <span class="flex items-center gap-2">
69
+ <LpIcon v-if="item.icon" :name="item.icon" :size="15" :class="ICON" />
70
+ {{ item.label }}
71
+ </span>
72
+ <LpIcon name="lucide:chevron-right" :size="14" class="text-muted" />
73
+ </ContextMenuSubTrigger>
74
+ <ContextMenuPortal>
75
+ <ContextMenuSubContent :class="PANEL" :side-offset="2" :align-offset="-4">
76
+ <template v-for="(sub, j) in item.children" :key="j">
77
+ <ContextMenuSeparator v-if="sub.separatorBefore" class="my-1 h-px bg-line" />
78
+ <ContextMenuItem
79
+ :class="[ITEM, 'justify-between', sub.danger ? 'text-danger data-[highlighted]:bg-danger-soft data-[highlighted]:text-danger' : 'text-ink data-[highlighted]:text-brand']"
80
+ :disabled="sub.disabled"
81
+ @select="sub.onSelect?.()"
82
+ >
83
+ <span class="flex items-center gap-2">
84
+ <LpIcon v-if="sub.icon" :name="sub.icon" :size="15" :class="ICON" />
85
+ {{ sub.label }}
86
+ </span>
87
+ <kbd v-if="sub.shortcut" class="text-xs text-muted">{{ sub.shortcut }}</kbd>
88
+ </ContextMenuItem>
89
+ </template>
90
+ </ContextMenuSubContent>
91
+ </ContextMenuPortal>
92
+ </ContextMenuSub>
93
+
94
+ <!-- Leaf item -->
95
+ <ContextMenuItem
96
+ v-else
97
+ :class="[ITEM, 'justify-between', item.danger ? 'text-danger data-[highlighted]:bg-danger-soft data-[highlighted]:text-danger' : 'text-ink data-[highlighted]:text-brand']"
98
+ :disabled="item.disabled"
99
+ @select="item.onSelect?.()"
100
+ >
101
+ <span class="flex items-center gap-2">
102
+ <LpIcon v-if="item.icon" :name="item.icon" :size="15" :class="ICON" />
103
+ {{ item.label }}
104
+ </span>
105
+ <kbd v-if="item.shortcut" class="text-xs text-muted">{{ item.shortcut }}</kbd>
106
+ </ContextMenuItem>
107
+ </template>
108
+ </ContextMenuContent>
109
+ </ContextMenuPortal>
110
+ </ContextMenuRoot>
111
+ </template>
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Date field + calendar popover. The model is an ISO date string ("YYYY-MM-DD",
4
+ * v-model), same as LpCalendar (which it embeds). The trigger shows a formatted
5
+ * date; picking a day fills the field and closes the popover. Clearable, with
6
+ * min/max + isDisabled forwarded to the calendar. Themed like the other inputs.
7
+ */
8
+ import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui"
9
+ import { computed, ref } from "vue"
10
+ import { POPOVER_PANEL } from "./dropdown"
11
+ import LpCalendar from "./LpCalendar.vue"
12
+ import LpIcon from "./LpIcon.vue"
13
+
14
+ const props = withDefaults(
15
+ defineProps<{
16
+ /** Selected date "YYYY-MM-DD" (v-model). */
17
+ modelValue?: string
18
+ placeholder?: string
19
+ min?: string
20
+ max?: string
21
+ isDisabled?: (iso: string) => boolean
22
+ clearable?: boolean
23
+ disabled?: boolean
24
+ invalid?: boolean
25
+ size?: "sm" | "md" | "lg"
26
+ /** Intl format for the displayed date. */
27
+ format?: Intl.DateTimeFormatOptions
28
+ }>(),
29
+ {
30
+ placeholder: "Pick a date",
31
+ size: "md",
32
+ format: () => ({ year: "numeric", month: "short", day: "numeric" }),
33
+ },
34
+ )
35
+
36
+ const emit = defineEmits<{
37
+ (e: "update:modelValue", value: string | undefined): void
38
+ }>()
39
+
40
+ const open = ref(false)
41
+
42
+ const display = computed(() => {
43
+ if (!props.modelValue) return ""
44
+ // Parse as local midnight so the formatted day can't drift across a timezone.
45
+ const [y, m, d] = props.modelValue.split("-").map(Number)
46
+ if (!y || !m || !d) return props.modelValue
47
+ return new Intl.DateTimeFormat(undefined, props.format).format(new Date(y, m - 1, d))
48
+ })
49
+
50
+ const shellSize = {
51
+ sm: "h-(--size-control-sm) text-xs",
52
+ md: "h-(--size-control-md) text-sm",
53
+ lg: "h-(--size-control-lg) text-sm",
54
+ }
55
+
56
+ function onPick(v: string | undefined) {
57
+ emit("update:modelValue", v)
58
+ open.value = false
59
+ }
60
+
61
+ function clear() {
62
+ emit("update:modelValue", undefined)
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <PopoverRoot v-model:open="open">
68
+ <PopoverTrigger as-child>
69
+ <button
70
+ type="button"
71
+ :disabled="disabled"
72
+ class="group flex w-full items-center gap-2 rounded-control border bg-surface-soft px-3 text-left text-ink outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:border-brand disabled:cursor-not-allowed disabled:opacity-55"
73
+ :class="[
74
+ shellSize[size],
75
+ invalid ? 'border-danger focus-visible:ring-danger-soft' : 'border-line focus-visible:border-brand',
76
+ ]"
77
+ >
78
+ <LpIcon name="lucide:calendar" :size="16" class="shrink-0 text-muted" />
79
+ <span class="min-w-0 flex-1 truncate" :class="display ? '' : 'text-muted'">
80
+ {{ display || placeholder }}
81
+ </span>
82
+ <span
83
+ v-if="clearable && modelValue && !disabled"
84
+ role="button"
85
+ tabindex="-1"
86
+ aria-label="Clear"
87
+ class="shrink-0 text-muted transition-colors hover:text-ink"
88
+ @click.stop="clear"
89
+ @keydown.enter.stop="clear"
90
+ >
91
+ <LpIcon name="lucide:x" :size="15" />
92
+ </span>
93
+ <LpIcon
94
+ name="lucide:chevron-down"
95
+ :size="15"
96
+ class="shrink-0 text-muted transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-data-[state=open]:rotate-180"
97
+ />
98
+ </button>
99
+ </PopoverTrigger>
100
+ <PopoverPortal>
101
+ <PopoverContent
102
+ :side-offset="6"
103
+ align="start"
104
+ :class="[POPOVER_PANEL, 'z-(--z-popover) rounded-card p-0 outline-none']"
105
+ >
106
+ <LpCalendar
107
+ :model-value="modelValue"
108
+ :min="min"
109
+ :max="max"
110
+ :is-disabled="isDisabled"
111
+ class="border-0 bg-transparent"
112
+ @update:model-value="onPick"
113
+ />
114
+ </PopoverContent>
115
+ </PopoverPortal>
116
+ </PopoverRoot>
117
+ </template>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Collapsible "show more" section built on reka Collapsible. The trigger is a
4
+ * full-width row with a chevron that rotates open; the body animates its height
5
+ * via the collapsible-down/up keyframes (driven by reka's content-height var).
6
+ *
7
+ * `scrollIntoView` opts the block into auto-revealing itself: when it opens, the
8
+ * root is smoothly scrolled into the nearest scroll container so the freshly
9
+ * shown content isn't left below the fold.
10
+ */
11
+ import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "reka-ui"
12
+ import { type ComponentPublicInstance, nextTick, ref } from "vue"
13
+ import LpIcon from "./LpIcon.vue"
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ /** Controlled open state. Omit to let the component own it (with `defaultOpen`). */
18
+ open?: boolean
19
+ defaultOpen?: boolean
20
+ /** Trigger label (or use the #trigger slot for richer content). */
21
+ label?: string
22
+ /** Optional iconify name shown left of the label. */
23
+ icon?: string
24
+ /**
25
+ * On open, smooth-scroll the block to the top of the nearest scroll
26
+ * container so its content isn't left below the fold. Fires immediately,
27
+ * in parallel with the expand.
28
+ */
29
+ scrollIntoView?: boolean
30
+ }>(),
31
+ { defaultOpen: false, scrollIntoView: false },
32
+ )
33
+
34
+ const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
35
+
36
+ // reka's CollapsibleRoot renders a real element but exposes it as a component
37
+ // instance, so reach through $el for the DOM node we scroll into view.
38
+ const root = ref<ComponentPublicInstance | null>(null)
39
+
40
+ function onToggle(value: boolean) {
41
+ emit("update:open", value)
42
+ if (value && props.scrollIntoView) {
43
+ // Scroll immediately, in parallel with the expand. nextTick only waits for
44
+ // reka to flip data-state; the header's position is stable while the body
45
+ // grows, so pinning the block's top to the viewport top with a smooth scroll
46
+ // feels instant and reveals the content as it animates open.
47
+ nextTick(() => {
48
+ const el = root.value?.$el as HTMLElement | undefined
49
+ el?.scrollIntoView({ behavior: "smooth", block: "start" })
50
+ })
51
+ }
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <CollapsibleRoot
57
+ ref="root"
58
+ :open="open"
59
+ :default-open="defaultOpen"
60
+ class="overflow-hidden rounded-control border border-line bg-surface-soft"
61
+ @update:open="onToggle"
62
+ >
63
+ <CollapsibleTrigger
64
+ class="group flex w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-ink outline-none focus-visible:ring-2 focus-visible:ring-ring"
65
+ >
66
+ <LpIcon v-if="icon" :name="icon" :size="14" class="text-muted" />
67
+ <span class="min-w-0 flex-1 text-left">
68
+ <slot name="trigger">{{ label }}</slot>
69
+ </span>
70
+ <LpIcon
71
+ name="lucide:chevron-down"
72
+ :size="16"
73
+ class="text-muted transition-transform duration-[var(--duration-fast)] group-data-[state=open]:rotate-180"
74
+ />
75
+ </CollapsibleTrigger>
76
+
77
+ <CollapsibleContent
78
+ class="group/content overflow-hidden data-[state=open]:animate-[collapsible-down_260ms_var(--ease-emphasized)] data-[state=closed]:animate-[collapsible-up_200ms_var(--ease-emphasized)]"
79
+ >
80
+ <!--
81
+ The height animation lives on the wrapper; the inner panel fades + drifts
82
+ up so the content eases in rather than snapping at full opacity. The slide
83
+ is short (4px) and runs slightly behind the expand for a settled feel.
84
+ -->
85
+ <div
86
+ class="grid gap-2.5 px-3 pt-1 pb-3.5 group-data-[state=open]/content:animate-[disclosure-body-in_300ms_var(--ease-emphasized)] motion-reduce:animate-none"
87
+ >
88
+ <slot />
89
+ </div>
90
+ </CollapsibleContent>
91
+ </CollapsibleRoot>
92
+ </template>