@leavepulse/ui 0.33.1 → 0.35.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.
@@ -9,6 +9,22 @@
9
9
  * composable can assemble them from routes/docs/API). Scoring ranks label >
10
10
  * keywords > description; matches are highlighted; the active row scrolls into
11
11
  * view. Open state is v-model:open; a global ⌘K/Ctrl-K toggle is on by default.
12
+ *
13
+ * Three things a palette outgrows the moment it stops being a list of routes,
14
+ * all of them here rather than in each app that hit the wall:
15
+ *
16
+ * search — `commands` is everything the palette knows, so anything living
17
+ * behind an API (people, files, tickets) had no way in. `search` is an
18
+ * async provider: it runs on the typed query, debounced, with the previous
19
+ * request aborted, and its results join the static ones.
20
+ *
21
+ * modes — a flat list cannot ask a second question. A command returning
22
+ * `children` (or a provider of them) pushes a mode: the query resets, a
23
+ * chip names where you are, and Backspace on an empty query pops back out.
24
+ *
25
+ * recency — the list is ordered the same on every open, so the command
26
+ * someone runs hourly sits wherever the alphabet put it. With `recent`
27
+ * bound, picks float to a group of their own.
12
28
  */
13
29
  import {
14
30
  DialogContent,
@@ -18,7 +34,7 @@ import {
18
34
  DialogRoot,
19
35
  DialogTitle,
20
36
  } from "reka-ui"
21
- import { computed, nextTick, ref, watch } from "vue"
37
+ import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue"
22
38
  import LpIcon from "./LpIcon.vue"
23
39
  import LpScrollArea from "./LpScrollArea.vue"
24
40
  import { useHotkeys } from "../composables/useHotkeys"
@@ -42,9 +58,37 @@ export interface Command {
42
58
  * decommission, …) that shouldn't clutter the palette but stay reachable.
43
59
  */
44
60
  hidden?: boolean
61
+ /**
62
+ * Picking this opens a mode instead of closing: a nested list, either given
63
+ * outright or fetched from the query typed inside it. This is what keeps a
64
+ * palette from flattening "restart <service>" into one row per service —
65
+ * and what lets the second step search a list too big to enumerate.
66
+ *
67
+ * `onSelect` still runs when the mode opens, so a command can both act and
68
+ * drill in.
69
+ */
70
+ children?: Command[] | ((query: string) => Command[] | Promise<Command[]>)
71
+ /** Placeholder while inside this command's mode. */
72
+ childPlaceholder?: string
73
+ /**
74
+ * Keep the palette open after this runs. For a command that toggles
75
+ * something the user may want to toggle again (a theme, a filter) without
76
+ * reopening and retyping.
77
+ */
78
+ keepOpen?: boolean
45
79
  onSelect?: () => void
46
80
  }
47
81
 
82
+ /** Footer hints and the mode chip, spelled out so an app can translate them. */
83
+ export interface PaletteLabels {
84
+ navigate: string
85
+ select: string
86
+ close: string
87
+ /** Hint for leaving a mode, shown only while inside one. */
88
+ back: string
89
+ searching: string
90
+ }
91
+
48
92
  const props = withDefaults(
49
93
  defineProps<{
50
94
  open?: boolean
@@ -53,13 +97,66 @@ const props = withDefaults(
53
97
  emptyText?: string
54
98
  /** Bind a global ⌘K / Ctrl-K toggle. */
55
99
  hotkey?: boolean
100
+ /**
101
+ * Commands fetched from the typed query — what reaches the things too many
102
+ * (or too remote) to hand over as a list: people, files, tickets. Called
103
+ * debounced, never for a query shorter than `minChars`, and only its
104
+ * newest call counts: an earlier one that lands late is dropped, so a slow
105
+ * response can't overwrite a fresher list.
106
+ *
107
+ * Results join the static commands, so a palette can do both.
108
+ */
109
+ search?: (query: string, signal: AbortSignal) => Command[] | Promise<Command[]>
110
+ /** Wait after the last keystroke before `search` runs. */
111
+ debounce?: number
112
+ /** Shortest query `search` runs for. */
113
+ minChars?: number
114
+ /**
115
+ * Ids of recently picked commands, newest first — bind it to storage and
116
+ * the palette opens on what this person actually uses. It only reorders
117
+ * commands already in the list; an id that no longer resolves is skipped,
118
+ * so a stored history never resurrects a command that has gone away.
119
+ */
120
+ recent?: string[]
121
+ /** How many recent commands to surface. */
122
+ recentLimit?: number
123
+ /** Headings the palette adds itself. */
124
+ recentLabel?: string
125
+ resultsLabel?: string
126
+ /** Footer hints and the mode chip, spelled out for translation. */
127
+ labels?: Partial<PaletteLabels>
56
128
  }>(),
57
- { placeholder: "Type a command or search…", emptyText: "No results", hotkey: true },
129
+ {
130
+ placeholder: "Type a command or search…",
131
+ emptyText: "No results",
132
+ hotkey: true,
133
+ debounce: 250,
134
+ minChars: 1,
135
+ recentLimit: 5,
136
+ recentLabel: "Recent",
137
+ resultsLabel: "Results",
138
+ },
58
139
  )
59
140
 
141
+ const DEFAULT_LABELS: PaletteLabels = {
142
+ navigate: "navigate",
143
+ select: "select",
144
+ close: "close",
145
+ back: "back",
146
+ searching: "Searching…",
147
+ }
148
+
149
+ const text = computed<PaletteLabels>(() => ({ ...DEFAULT_LABELS, ...props.labels }))
150
+
60
151
  const emit = defineEmits<{
61
152
  (e: "update:open", value: boolean): void
62
153
  (e: "select", command: Command): void
154
+ /**
155
+ * A command ran — id first, because the common use is appending it to a
156
+ * stored recency list. Fires for leaf commands only: opening a mode is
157
+ * navigation, not a pick.
158
+ */
159
+ (e: "run", id: string, command: Command): void
63
160
  }>()
64
161
 
65
162
  const query = ref("")
@@ -67,15 +164,140 @@ const selectedIndex = ref(0)
67
164
  const inputRef = ref<HTMLInputElement | null>(null)
68
165
  const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
69
166
 
167
+ // ── modes ────────────────────────────────────────────────────
168
+ /*
169
+ * The trail of commands drilled into, outermost first. Empty is the root list.
170
+ * A stack rather than one level because nesting costs nothing here and a
171
+ * two-step action ("container → restart → confirm") is the shape that shows up
172
+ * as soon as a palette does more than navigate.
173
+ *
174
+ * shallowRef: entries are consumer objects that may carry anything (a fetcher,
175
+ * a row of data), and deep-tracking them would walk it on every keystroke.
176
+ */
177
+ const trail = shallowRef<Command[]>([])
178
+ const mode = computed<Command | null>(() => trail.value[trail.value.length - 1] ?? null)
179
+
180
+ /*
181
+ * Commands of the open mode. A static `children` array is used as given; a
182
+ * function is a fetcher and goes through the same async path as `search`,
183
+ * which is what lets the second step be a lookup rather than a list.
184
+ */
185
+ const modeItems = shallowRef<Command[]>([])
186
+
187
+ function enter(c: Command) {
188
+ trail.value = [...trail.value, c]
189
+ query.value = ""
190
+ selectedIndex.value = 0
191
+ modeItems.value = Array.isArray(c.children) ? c.children : []
192
+ refresh()
193
+ // Entering by click leaves the focus on the button that no longer exists,
194
+ // and the dialog takes it — from there the next keystroke goes nowhere. The
195
+ // mode exists to be typed into, so the caret goes back in the box.
196
+ inputRef.value?.focus()
197
+ }
198
+
199
+ /** Unwind the trail to `depth` entries — 0 is the root. */
200
+ function popTo(depth: number) {
201
+ const next = trail.value.slice(0, Math.max(0, depth))
202
+ trail.value = next
203
+ query.value = ""
204
+ selectedIndex.value = 0
205
+ const parent = next[next.length - 1]
206
+ modeItems.value = parent && Array.isArray(parent.children) ? parent.children : []
207
+ refresh()
208
+ inputRef.value?.focus()
209
+ }
210
+
211
+ /** Leave the innermost mode. Returns false at the root, so Escape can close. */
212
+ function back(): boolean {
213
+ if (!trail.value.length) return false
214
+ popTo(trail.value.length - 1)
215
+ return true
216
+ }
217
+
218
+ // ── async results ────────────────────────────────────────────
219
+ /*
220
+ * One in-flight request, shared by `search` at the root and by a fetching
221
+ * `children` inside a mode — they differ only in which function to call.
222
+ *
223
+ * Two races to lose here, and both are lost silently: a stale response
224
+ * overwriting a fresher one, and a response arriving after the palette moved
225
+ * on (closed, or into another mode). The token guards both — the abort signal
226
+ * only asks the consumer to stop, it cannot promise they did.
227
+ */
228
+ const found = shallowRef<Command[]>([])
229
+ const searching = ref(false)
230
+
231
+ let timer: ReturnType<typeof setTimeout> | undefined
232
+ let controller: AbortController | undefined
233
+ let token = 0
234
+
235
+ function fetcher(): ((q: string, signal: AbortSignal) => Command[] | Promise<Command[]>) | null {
236
+ const current = mode.value
237
+ if (current) {
238
+ return typeof current.children === "function"
239
+ ? (q: string) => (current.children as (q: string) => Command[] | Promise<Command[]>)(q)
240
+ : null
241
+ }
242
+ return props.search ?? null
243
+ }
244
+
245
+ function cancel() {
246
+ if (timer) clearTimeout(timer)
247
+ timer = undefined
248
+ controller?.abort()
249
+ controller = undefined
250
+ token += 1
251
+ searching.value = false
252
+ }
253
+
254
+ function refresh() {
255
+ cancel()
256
+ found.value = []
257
+ const run = fetcher()
258
+ if (!run) return
259
+ const q = query.value.trim()
260
+ // A fetching mode runs on an empty query too: opening it should show the
261
+ // first page, not an empty screen waiting for a keystroke. Root search does
262
+ // not — ⌘K would fire a request at every open.
263
+ const inMode = mode.value !== null
264
+ if (!inMode && q.length < Math.max(1, props.minChars)) return
265
+ searching.value = true
266
+ const mine = ++token
267
+ timer = setTimeout(async () => {
268
+ controller = new AbortController()
269
+ try {
270
+ const hits = await run(q, controller.signal)
271
+ if (mine !== token) return
272
+ found.value = hits
273
+ } catch {
274
+ // Abort or a failing provider: the static commands still stand.
275
+ if (mine === token) found.value = []
276
+ } finally {
277
+ if (mine === token) searching.value = false
278
+ }
279
+ }, props.debounce)
280
+ }
281
+
282
+ onBeforeUnmount(cancel)
283
+
70
284
  // ── filtering + scoring ──────────────────────────────────────
285
+ /*
286
+ * What the query filters: the open mode's own children, or the root commands.
287
+ * A mode is a context, not a filter over the parent list — showing the root
288
+ * next to "which service?" is how a palette gets someone to restart the wrong
289
+ * thing.
290
+ */
291
+ const source = computed<Command[]>(() => (mode.value ? modeItems.value : props.commands))
292
+
71
293
  // No query → everything (grouped). With a query, score by where it hits:
72
294
  // label > keywords > description, then alphabetical. Mirrors the app palette.
73
295
  const ranked = computed<Command[]>(() => {
74
296
  const q = query.value.trim().toLowerCase()
75
297
  // Empty query: show everything EXCEPT hidden commands (they only surface once
76
298
  // typed). With a query, hidden commands rank like any other.
77
- if (!q) return props.commands.filter((c) => !c.hidden)
78
- return props.commands
299
+ if (!q) return source.value.filter((c) => !c.hidden)
300
+ return source.value
79
301
  .map((c) => {
80
302
  const label = c.label.toLowerCase().includes(q)
81
303
  const kw = (c.keywords ?? []).some((k) => k.toLowerCase().includes(q))
@@ -88,18 +310,56 @@ const ranked = computed<Command[]>(() => {
88
310
  .map((e) => e.c)
89
311
  })
90
312
 
313
+ /*
314
+ * Fetched results sit under their own heading unless they brought a group of
315
+ * their own, so they never look like they were part of the static list. An id
316
+ * already matched locally is dropped: the same command listed twice is a
317
+ * misclick waiting to happen.
318
+ */
319
+ const fetched = computed<Command[]>(() => {
320
+ if (!found.value.length) return []
321
+ const seen = new Set(ranked.value.map((c) => c.id))
322
+ return found.value
323
+ .filter((c) => !seen.has(c.id))
324
+ .map((c) => (c.group ? c : { ...c, group: props.resultsLabel }))
325
+ })
326
+
327
+ /*
328
+ * Recent picks, newest first, lifted out of the list they already belong to.
329
+ * Only on the root's empty query: once someone types, or drills into a mode,
330
+ * relevance is the better order and a duplicated row is just noise.
331
+ */
332
+ const recentItems = computed<Command[]>(() => {
333
+ if (mode.value || query.value.trim() || !props.recent?.length) return []
334
+ const byId = new Map(props.commands.map((c) => [c.id, c]))
335
+ const out: Command[] = []
336
+ for (const id of props.recent) {
337
+ const c = byId.get(id)
338
+ if (c && !c.disabled && !out.includes(c)) out.push(c)
339
+ if (out.length >= props.recentLimit) break
340
+ }
341
+ return out
342
+ })
343
+
91
344
  // Group preserving first-seen order; ungrouped commands sit under "".
345
+ // Recent first (it answers "the thing I always run"), then the matched list,
346
+ // then whatever the provider found.
92
347
  const grouped = computed<{ name: string; items: Command[] }[]>(() => {
93
348
  const order: string[] = []
94
349
  const map = new Map<string, Command[]>()
95
- for (const c of ranked.value) {
96
- const g = c.group ?? ""
97
- if (!map.has(g)) {
98
- map.set(g, [])
99
- order.push(g)
350
+ const push = (c: Command, group: string) => {
351
+ if (!map.has(group)) {
352
+ map.set(group, [])
353
+ order.push(group)
100
354
  }
101
- map.get(g)!.push(c)
355
+ map.get(group)!.push(c)
102
356
  }
357
+ const recent = recentItems.value
358
+ for (const c of recent) push(c, props.recentLabel)
359
+ // A recent entry is shown once: leaving the copy in its own group would put
360
+ // the same row on screen twice with nothing to tell them apart.
361
+ for (const c of ranked.value) if (!recent.includes(c)) push(c, c.group ?? "")
362
+ for (const c of fetched.value) push(c, c.group ?? "")
103
363
  return order.map((name) => ({ name, items: map.get(name)! }))
104
364
  })
105
365
 
@@ -146,7 +406,10 @@ function highlight(value: string): Part[] {
146
406
  }
147
407
 
148
408
  // ── selection + keyboard ─────────────────────────────────────
149
- watch(query, () => (selectedIndex.value = 0))
409
+ watch(query, () => {
410
+ selectedIndex.value = 0
411
+ refresh()
412
+ })
150
413
  watch(
151
414
  () => flat.value.length,
152
415
  (len) => {
@@ -181,16 +444,61 @@ function run(c: Command | undefined) {
181
444
  if (!c || c.disabled) return
182
445
  emit("select", c)
183
446
  c.onSelect?.()
447
+ // A command with children drills in instead of closing, and is navigation
448
+ // rather than a pick — so it stays out of the recency history.
449
+ if (c.children) {
450
+ enter(c)
451
+ return
452
+ }
453
+ emit("run", c.id, c)
454
+ if (c.keepOpen) {
455
+ // Back to the top of a fresh list: the query that found this command is
456
+ // spent, and leaving it would hide whatever the action just changed.
457
+ query.value = ""
458
+ selectedIndex.value = 0
459
+ inputRef.value?.focus()
460
+ return
461
+ }
184
462
  emit("update:open", false)
185
463
  }
186
464
 
187
- // Reset + focus when opening; clear on close.
465
+ /*
466
+ * Backspace on an empty query leaves the mode. Mirrors how the Slack and
467
+ * Linear palettes behave, and means the way out is the key already under the
468
+ * finger — Escape stays the way out of the palette itself.
469
+ */
470
+ function onBackspace(event: KeyboardEvent) {
471
+ if (query.value) return
472
+ if (back()) event.preventDefault()
473
+ }
474
+
475
+ /*
476
+ * Escape unwinds one mode at a time and only closes at the root, so drilling
477
+ * in three levels does not make leaving one an all-or-nothing choice. reka's
478
+ * Dialog would close on its own, so the key is handled here and declined
479
+ * (stopPropagation) only while there is a mode to pop.
480
+ */
481
+ function onEscape(event: KeyboardEvent) {
482
+ if (!trail.value.length) return
483
+ event.preventDefault()
484
+ event.stopPropagation()
485
+ back()
486
+ }
487
+
488
+ // Reset + focus when opening; clear on close. The trail resets too: reopening
489
+ // into the mode someone left three screens deep is never what they meant by
490
+ // ⌘K, and a pending request from last time is dropped rather than landing in
491
+ // the new list.
188
492
  watch(
189
493
  () => props.open,
190
494
  async (o) => {
495
+ cancel()
496
+ query.value = ""
497
+ selectedIndex.value = 0
498
+ trail.value = []
499
+ modeItems.value = []
500
+ found.value = []
191
501
  if (o) {
192
- query.value = ""
193
- selectedIndex.value = 0
194
502
  await nextTick()
195
503
  inputRef.value?.focus()
196
504
  }
@@ -228,26 +536,52 @@ useHotkeys(() =>
228
536
  <DialogTitle class="sr-only">Command palette</DialogTitle>
229
537
  <DialogDescription class="sr-only">Search and run a command.</DialogDescription>
230
538
 
231
- <!-- Filter -->
539
+ <!-- Filter. The trail sits inside the box as chips, the way a scoped
540
+ search reads everywhere else: what you type is filtered by what is
541
+ to its left. -->
232
542
  <div class="flex shrink-0 items-center gap-2.5 border-b border-line px-4">
233
- <LpIcon name="lucide:search" :size="18" class="shrink-0 text-muted" />
543
+ <LpIcon
544
+ v-if="!searching"
545
+ name="lucide:search"
546
+ :size="18"
547
+ class="shrink-0 text-muted"
548
+ />
549
+ <LpIcon v-else name="lucide:loader-circle" :size="18" class="shrink-0 animate-spin text-muted" />
550
+
551
+ <button
552
+ v-for="(step, i) in trail"
553
+ :key="step.id"
554
+ type="button"
555
+ class="flex shrink-0 items-center gap-1.5 rounded-md bg-brand-soft px-2 py-1 text-xs font-medium text-brand transition-opacity hover:opacity-80"
556
+ :title="text.back"
557
+ @click="popTo(i)"
558
+ >
559
+ <LpIcon v-if="step.icon" :name="step.icon" :size="13" />
560
+ {{ step.label }}
561
+ <LpIcon name="lucide:x" :size="12" class="opacity-60" />
562
+ </button>
563
+
234
564
  <input
235
565
  ref="inputRef"
236
566
  v-model="query"
237
567
  type="text"
238
- :placeholder="placeholder"
568
+ :placeholder="mode?.childPlaceholder ?? placeholder"
239
569
  class="h-12 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-muted"
240
570
  @keydown.down.prevent="moveDown"
241
571
  @keydown.up.prevent="moveUp"
242
572
  @keydown.enter.prevent="runSelected"
573
+ @keydown.backspace="onBackspace"
574
+ @keydown.esc="onEscape"
243
575
  />
244
576
  <kbd class="shrink-0 rounded border border-line px-1.5 py-0.5 text-[10px] text-muted">esc</kbd>
245
577
  </div>
246
578
 
247
579
  <!-- Results -->
248
580
  <LpScrollArea ref="scrollRef" class="min-h-0 flex-1" content-class="p-2">
581
+ <!-- While a request is out there is nothing to say yet: "no results"
582
+ under a spinner reads as an answer, and it is not one. -->
249
583
  <p v-if="flat.length === 0" class="px-2 py-10 text-center text-sm text-muted">
250
- {{ emptyText }}
584
+ {{ searching ? text.searching : emptyText }}
251
585
  </p>
252
586
 
253
587
  <template v-for="group in grouped" :key="group.name">
@@ -284,6 +618,14 @@ useHotkeys(() =>
284
618
  </span>
285
619
  </span>
286
620
  <kbd v-if="c.shortcut" class="shrink-0 text-xs text-muted">{{ c.shortcut }}</kbd>
621
+ <!-- Drills in rather than runs: worth saying before the click,
622
+ since the two look identical otherwise. -->
623
+ <LpIcon
624
+ v-else-if="c.children"
625
+ name="lucide:chevron-right"
626
+ :size="14"
627
+ class="shrink-0 text-muted"
628
+ />
287
629
  </button>
288
630
  </template>
289
631
  </LpScrollArea>
@@ -295,15 +637,21 @@ useHotkeys(() =>
295
637
  <span class="flex items-center gap-1">
296
638
  <kbd class="rounded border border-line px-1 text-[10px]">↑</kbd>
297
639
  <kbd class="rounded border border-line px-1 text-[10px]">↓</kbd>
298
- navigate
640
+ {{ text.navigate }}
299
641
  </span>
300
642
  <span class="flex items-center gap-1">
301
643
  <kbd class="rounded border border-line px-1 text-[10px]">↵</kbd>
302
- select
644
+ {{ text.select }}
645
+ </span>
646
+ <!-- Only inside a mode: at the root Backspace just edits the query,
647
+ and advertising it there would be a lie. -->
648
+ <span v-if="trail.length" class="flex items-center gap-1">
649
+ <kbd class="rounded border border-line px-1 text-[10px]">⌫</kbd>
650
+ {{ text.back }}
303
651
  </span>
304
652
  <span class="ml-auto flex items-center gap-1">
305
653
  <kbd class="rounded border border-line px-1 text-[10px]">esc</kbd>
306
- close
654
+ {{ text.close }}
307
655
  </span>
308
656
  </div>
309
657
  </DialogContent>
@@ -0,0 +1,177 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A dialog whose sections stay visible while you read one — settings, a profile
4
+ * with tabs' worth of detail, a wizard that lets you skip a step. The shape
5
+ * these all reach for is a nav column that persists next to the body, and
6
+ * without it they fall back to a row of tabs that runs out of width, or to a
7
+ * back-and-forth between a list screen and a detail screen.
8
+ *
9
+ * The nav lives INSIDE the dialog. Standing it outside, as a sibling floating
10
+ * beside the panel, is the arrangement this component deliberately does not
11
+ * use: it then sits outside the focus trap, `interact-outside` reads a click on
12
+ * it as a click on the scrim and closes the dialog under the pointer, and its
13
+ * height has to be reconciled with a dialog height that changes as content
14
+ * arrives. Inside, all three come out right for free, and `LpModal` keeps the
15
+ * centring every other modal in the kit relies on.
16
+ *
17
+ * Width is the one cost: the rail comes out of the body's width, which is why
18
+ * the default `size` is wider than a plain modal's. Below `mobileBreakpoint`
19
+ * there is no width to spare at all, so the rail becomes a drawer — and since
20
+ * LpSidebar leaves opening it to the app, the header grows the button for it
21
+ * rather than leaving the sections unreachable on a phone.
22
+ */
23
+ import { computed, ref } from "vue"
24
+ import LpButton from "./LpButton.vue"
25
+ import LpIcon from "./LpIcon.vue"
26
+ import LpModal from "./LpModal.vue"
27
+ import LpSidebar from "./LpSidebar.vue"
28
+ import type { SidebarItem, SidebarSection } from "./sidebar"
29
+
30
+ export type { SidebarItem, SidebarSection } from "./sidebar"
31
+
32
+ type Breakpoint = "sm" | "md" | "lg" | "xl"
33
+
34
+ const props = withDefaults(
35
+ defineProps<{
36
+ open?: boolean
37
+ /** Active section id (v-model). */
38
+ modelValue?: string
39
+ /** Flat sections, or grouped ones — the shapes LpSidebar already takes. */
40
+ items?: SidebarItem[]
41
+ sections?: SidebarSection[]
42
+ title?: string
43
+ description?: string
44
+ /**
45
+ * Defaults wider than `LpModal`'s `md`: the rail comes out of the content's
46
+ * width, and at `md` what is left is too narrow to be worth splitting.
47
+ */
48
+ size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"
49
+ width?: string
50
+ /** Width of the nav rail (any CSS length). */
51
+ navWidth?: string
52
+ /** Put a filter above the nav — see LpSidebar's `searchable`. */
53
+ searchable?: boolean
54
+ searchPlaceholder?: string
55
+ /** Skeleton rows in place of the nav while the sections load. */
56
+ loading?: boolean
57
+ /** Breakpoint below which the rail becomes a drawer. */
58
+ mobileBreakpoint?: Breakpoint
59
+ /** Label for the button that opens that drawer. */
60
+ navLabel?: string
61
+ /**
62
+ * Let the body fill the height instead of scrolling itself. On by default,
63
+ * unlike a plain modal: the rail is only a full-height column if the two
64
+ * of them agree on a height to fill.
65
+ */
66
+ fillBody?: boolean
67
+ }>(),
68
+ {
69
+ size: "2xl",
70
+ navWidth: "13rem",
71
+ mobileBreakpoint: "lg",
72
+ navLabel: "Sections",
73
+ fillBody: true,
74
+ },
75
+ )
76
+
77
+ const emit = defineEmits<{
78
+ (e: "update:open", value: boolean): void
79
+ (e: "update:modelValue", value: string): void
80
+ }>()
81
+
82
+ const navOpen = ref(false)
83
+
84
+ const active = computed({
85
+ get: () => props.modelValue ?? "",
86
+ set: (v: string) => emit("update:modelValue", v),
87
+ })
88
+
89
+ const hasNav = computed(
90
+ () => Boolean(props.items?.length || props.sections?.length),
91
+ )
92
+
93
+ /*
94
+ * Spelled out rather than built from the prop: a class assembled at runtime is
95
+ * not in anyone's Tailwind scan and would never be generated. Mirrors the
96
+ * rail's own visibility in LpSidebar — the button appears exactly where the
97
+ * rail disappears.
98
+ */
99
+ const TRIGGER_VISIBILITY: Record<Breakpoint, string> = {
100
+ sm: "sm:hidden",
101
+ md: "md:hidden",
102
+ lg: "lg:hidden",
103
+ xl: "xl:hidden",
104
+ }
105
+ const triggerClass = computed(() => TRIGGER_VISIBILITY[props.mobileBreakpoint])
106
+ </script>
107
+
108
+ <template>
109
+ <LpModal
110
+ :open="open"
111
+ :title="title"
112
+ :description="description"
113
+ :size="size"
114
+ :width="width"
115
+ :fill-body="fillBody"
116
+ @update:open="emit('update:open', $event)"
117
+ >
118
+ <template #title>
119
+ <div class="flex items-center gap-2">
120
+ <LpButton
121
+ v-if="hasNav"
122
+ variant="ghost"
123
+ size="sm"
124
+ square
125
+ :aria-label="navLabel"
126
+ :class="triggerClass"
127
+ @click="navOpen = true"
128
+ >
129
+ <LpIcon name="lucide:menu" :size="18" />
130
+ </LpButton>
131
+ <slot name="title">{{ title }}</slot>
132
+ </div>
133
+ </template>
134
+
135
+ <div class="flex min-h-0 flex-1 gap-4">
136
+ <!-- `shrink-0`: a flex item's default is to give up width to a greedy
137
+ sibling, so a wide table in the body would squeeze the rail down to
138
+ its longest unbreakable word. -->
139
+ <LpSidebar
140
+ v-if="hasNav"
141
+ v-model="active"
142
+ v-model:open="navOpen"
143
+ variant="panel"
144
+ :items="items"
145
+ :sections="sections"
146
+ :width="navWidth"
147
+ :searchable="searchable"
148
+ :search-placeholder="searchPlaceholder"
149
+ :loading="loading"
150
+ responsive
151
+ :mobile-breakpoint="mobileBreakpoint"
152
+ class="shrink-0"
153
+ >
154
+ <template v-if="$slots.navHeader" #header>
155
+ <slot name="navHeader" />
156
+ </template>
157
+ <template v-if="$slots.navItem" #item="slotProps">
158
+ <slot name="navItem" v-bind="slotProps" />
159
+ </template>
160
+ <template v-if="$slots.navFooter" #footer>
161
+ <slot name="navFooter" />
162
+ </template>
163
+ </LpSidebar>
164
+
165
+ <!-- `min-w-0` is the same guard in the other direction: without it a wide
166
+ child sets this column's minimum to its own width, and the dialog
167
+ grows past its size instead of the content scrolling. -->
168
+ <div class="flex min-h-0 min-w-0 flex-1 flex-col">
169
+ <slot />
170
+ </div>
171
+ </div>
172
+
173
+ <template v-if="$slots.footer" #footer>
174
+ <slot name="footer" />
175
+ </template>
176
+ </LpModal>
177
+ </template>