@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,248 @@
1
+ <script setup lang="ts">
2
+ import { Motion } from "motion-v"
3
+ import { computed, ref } from "vue"
4
+ import type { LayoutNode, Leaf, Side, Split } from "../layout/tree"
5
+ import type { BlockRegistry } from "../layout/registry"
6
+ import { blockTitle } from "../layout/registry"
7
+ import LpIcon from "./LpIcon.vue"
8
+
9
+ const props = defineProps<{
10
+ node: LayoutNode
11
+ dragId: string | null
12
+ edit: boolean
13
+ registry?: BlockRegistry
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ (e: "dragstart", id: string): void
18
+ (e: "dragend"): void
19
+ (e: "drop", targetId: string, side: Side): void
20
+ (e: "set-active", leafId: string, index: number): void
21
+ (e: "remove-block", leafId: string, index: number): void
22
+ (e: "reorder-tab", leafId: string, from: number, to: number): void
23
+ (e: "resize", parent: Split, index: number, deltaFraction: number): void
24
+ }>()
25
+
26
+ // Explicit slot shape so the recursive #block pass-through types cleanly.
27
+ defineSlots<{
28
+ block(props: { block: string; leafId: string; edit: boolean }): unknown
29
+ }>()
30
+
31
+ const hoverSide = ref<Side | null>(null)
32
+
33
+ // Tab reordering is ALWAYS available (independent of edit mode). `tabDrag` holds
34
+ // the index of the tab being dragged within this cell; `tabOver` the index it's
35
+ // hovering, so we can show an insertion cue. Reordering stays inside the cell.
36
+ const tabDrag = ref<number | null>(null)
37
+ const tabOver = ref<number | null>(null)
38
+
39
+ function onTabDragStart(index: number, ev: DragEvent) {
40
+ tabDrag.value = index
41
+ // Mark as a move so the OS cursor is right; data is unused (in-cell only).
42
+ ev.dataTransfer?.setData("text/plain", String(index))
43
+ if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move"
44
+ }
45
+ function onTabDragOver(index: number, ev: DragEvent) {
46
+ if (tabDrag.value === null) return
47
+ ev.preventDefault()
48
+ tabOver.value = index
49
+ }
50
+ function onTabDrop(index: number) {
51
+ const from = tabDrag.value
52
+ tabDrag.value = null
53
+ tabOver.value = null
54
+ if (from === null || from === index || !leaf.value) return
55
+ emit("reorder-tab", leaf.value.id, from, index)
56
+ }
57
+ function onTabDragEnd() {
58
+ tabDrag.value = null
59
+ tabOver.value = null
60
+ }
61
+
62
+ const hintInset: Record<Side, string> = {
63
+ left: "inset-y-0 left-0 right-1/2",
64
+ right: "inset-y-0 right-0 left-1/2",
65
+ top: "inset-x-0 top-0 bottom-1/2",
66
+ bottom: "inset-x-0 bottom-0 top-1/2",
67
+ center: "inset-[12%]",
68
+ }
69
+
70
+ // A leaf shows its tab bar when it actually stacks tabs, or always in edit mode
71
+ // (so a single-block cell still has a draggable handle + a close affordance).
72
+ const leaf = computed(() => (props.node.kind === "leaf" ? (props.node as Leaf) : null))
73
+ const showTabs = computed(() => !!leaf.value && (props.edit || leaf.value.blocks.length > 1))
74
+ const activeBlock = computed(() => leaf.value?.blocks[leaf.value.active] ?? "")
75
+
76
+ function tabTitle(id: string): string {
77
+ return props.registry ? blockTitle(props.registry, id) : id
78
+ }
79
+ function tabIcon(id: string): string | undefined {
80
+ return props.registry?.[id]?.icon
81
+ }
82
+ function tabClosable(id: string): boolean {
83
+ return props.registry?.[id]?.closable !== false
84
+ }
85
+
86
+ function onDragOver(ev: DragEvent) {
87
+ ev.preventDefault()
88
+ const el = ev.currentTarget as HTMLElement
89
+ const r = el.getBoundingClientRect()
90
+ const px = (ev.clientX - r.left) / r.width
91
+ const py = (ev.clientY - r.top) / r.height
92
+ const edge = 0.3
93
+ if (px < edge) hoverSide.value = "left"
94
+ else if (px > 1 - edge) hoverSide.value = "right"
95
+ else if (py < edge) hoverSide.value = "top"
96
+ else if (py > 1 - edge) hoverSide.value = "bottom"
97
+ else hoverSide.value = "center"
98
+ }
99
+
100
+ function startSplitter(ev: PointerEvent, parent: Split, index: number) {
101
+ ev.preventDefault()
102
+ const host = (ev.currentTarget as HTMLElement).parentElement
103
+ if (!host) return
104
+ const rect = host.getBoundingClientRect()
105
+ const horizontal = parent.dir === "row"
106
+ const span = horizontal ? rect.width : rect.height
107
+ let last = horizontal ? ev.clientX : ev.clientY
108
+
109
+ const move = (e: PointerEvent) => {
110
+ const cur = horizontal ? e.clientX : e.clientY
111
+ emit("resize", parent, index, (cur - last) / span)
112
+ last = cur
113
+ }
114
+ const up = () => {
115
+ window.removeEventListener("pointermove", move)
116
+ window.removeEventListener("pointerup", up)
117
+ }
118
+ window.addEventListener("pointermove", move)
119
+ window.addEventListener("pointerup", up)
120
+ }
121
+ </script>
122
+
123
+ <template>
124
+ <!-- Container: children + splitters between them -->
125
+ <div
126
+ v-if="node.kind === 'split'"
127
+ class="flex min-h-0 min-w-0 flex-1"
128
+ :class="node.dir === 'row' ? 'flex-row' : 'flex-col'"
129
+ >
130
+ <template v-for="(child, i) in node.children" :key="child.id">
131
+ <LayoutNode
132
+ :node="child"
133
+ :drag-id="dragId"
134
+ :edit="edit"
135
+ :registry="registry"
136
+ :style="{ flexGrow: child.size, flexBasis: '0px' }"
137
+ @dragstart="(id) => emit('dragstart', id)"
138
+ @dragend="emit('dragend')"
139
+ @drop="(t, s) => emit('drop', t, s)"
140
+ @set-active="(l, idx) => emit('set-active', l, idx)"
141
+ @remove-block="(l, idx) => emit('remove-block', l, idx)"
142
+ @reorder-tab="(l, from, to) => emit('reorder-tab', l, from, to)"
143
+ @resize="(p, idx, d) => emit('resize', p, idx, d)"
144
+ >
145
+ <template #block="slotProps">
146
+ <slot name="block" v-bind="slotProps" />
147
+ </template>
148
+ </LayoutNode>
149
+ <div
150
+ v-if="edit && i < node.children.length - 1"
151
+ class="group relative z-[6] shrink-0 basis-2.5"
152
+ :class="node.dir === 'row' ? 'cursor-col-resize' : 'cursor-row-resize'"
153
+ @pointerdown="(e) => startSplitter(e, node as Split, i)"
154
+ >
155
+ <span
156
+ class="absolute inset-0 m-auto rounded-full bg-line transition-colors group-hover:bg-brand"
157
+ :class="node.dir === 'row' ? 'h-9 w-0.5' : 'h-0.5 w-9'"
158
+ />
159
+ </div>
160
+ <div
161
+ v-else-if="i < node.children.length - 1"
162
+ class="shrink-0 basis-2.5"
163
+ />
164
+ </template>
165
+ </div>
166
+
167
+ <!-- Leaf: a tab stack. Header (tabs) + the active block's content. -->
168
+ <Motion
169
+ v-else-if="leaf"
170
+ :layout="true"
171
+ :transition="{ type: 'spring', stiffness: 520, damping: 42, mass: 0.9 }"
172
+ class="relative flex min-h-0 min-w-0 flex-col"
173
+ :class="{ 'opacity-35': dragId === node.id }"
174
+ >
175
+ <!-- Tab bar. The bar itself is the cell's move-handle (edit only); each tab
176
+ is independently draggable to reorder within the cell — always. -->
177
+ <div
178
+ v-if="showTabs"
179
+ class="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-line px-1 py-1"
180
+ :draggable="edit"
181
+ @dragstart="emit('dragstart', node.id)"
182
+ @dragend="(hoverSide = null), emit('dragend')"
183
+ >
184
+ <button
185
+ v-for="(block, i) in leaf.blocks"
186
+ :key="block + i"
187
+ type="button"
188
+ draggable="true"
189
+ class="inline-flex max-w-40 cursor-grab items-center gap-1.5 rounded-control px-2 py-1 text-xs transition-colors active:cursor-grabbing"
190
+ :class="[
191
+ i === leaf.active
192
+ ? 'bg-surface-soft text-ink'
193
+ : 'text-muted hover:bg-surface-soft/60 hover:text-ink',
194
+ tabDrag === i ? 'opacity-40' : '',
195
+ tabOver === i && tabDrag !== null && tabDrag !== i ? 'ring-1 ring-brand' : '',
196
+ ]"
197
+ @click="emit('set-active', node.id, i)"
198
+ @dragstart.stop="onTabDragStart(i, $event)"
199
+ @dragover="onTabDragOver(i, $event)"
200
+ @drop.stop="onTabDrop(i)"
201
+ @dragend="onTabDragEnd"
202
+ >
203
+ <LpIcon v-if="tabIcon(block)" :name="tabIcon(block)!" :size="13" />
204
+ <span class="truncate">{{ tabTitle(block) }}</span>
205
+ <span
206
+ v-if="edit && tabClosable(block)"
207
+ class="grid size-3.5 place-items-center rounded-pill text-muted hover:bg-line hover:text-ink"
208
+ role="button"
209
+ :aria-label="`Close ${tabTitle(block)}`"
210
+ @click.stop="emit('remove-block', node.id, i)"
211
+ >
212
+ <LpIcon name="lucide:x" :size="11" />
213
+ </span>
214
+ </button>
215
+ </div>
216
+
217
+ <!-- Active block content. -->
218
+ <div class="flex min-h-0 min-w-0 flex-1">
219
+ <slot
220
+ name="block"
221
+ :block="activeBlock"
222
+ :leaf-id="node.id"
223
+ :edit="edit"
224
+ />
225
+ </div>
226
+
227
+ <!-- Drop zones (edit + something being dragged that isn't this cell). -->
228
+ <div
229
+ v-if="edit && dragId && dragId !== node.id"
230
+ class="absolute inset-0 z-[5]"
231
+ @dragover="onDragOver"
232
+ @dragleave="hoverSide = null"
233
+ @drop="
234
+ () => {
235
+ const s = hoverSide
236
+ hoverSide = null
237
+ if (s) emit('drop', node.id, s)
238
+ }
239
+ "
240
+ >
241
+ <div
242
+ v-if="hoverSide"
243
+ class="pointer-events-none absolute rounded-card border-2 border-brand bg-brand/25 transition-all"
244
+ :class="hintInset[hoverSide]"
245
+ />
246
+ </div>
247
+ </Motion>
248
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { tv, type VariantProps } from "tailwind-variants"
3
+ import { computed } from "vue"
4
+ import LpIcon from "./LpIcon.vue"
5
+
6
+ const alert = tv({
7
+ base: "flex gap-3 rounded-card border p-4 text-sm",
8
+ variants: {
9
+ variant: {
10
+ info: "border-line bg-surface-soft text-ink",
11
+ success: "border-action/30 bg-action/10 text-ink",
12
+ warning: "border-accent/30 bg-accent/10 text-ink",
13
+ danger: "border-danger/30 bg-danger/10 text-ink",
14
+ },
15
+ },
16
+ defaultVariants: { variant: "info" },
17
+ })
18
+
19
+ type AlertVariants = VariantProps<typeof alert>
20
+
21
+ const ICONS: Record<NonNullable<AlertVariants["variant"]>, string> = {
22
+ info: "lucide:info",
23
+ success: "lucide:circle-check",
24
+ warning: "lucide:triangle-alert",
25
+ danger: "lucide:octagon-alert",
26
+ }
27
+
28
+ const ICON_TONE: Record<NonNullable<AlertVariants["variant"]>, string> = {
29
+ info: "text-brand",
30
+ success: "text-action",
31
+ warning: "text-accent",
32
+ danger: "text-danger",
33
+ }
34
+
35
+ const props = withDefaults(
36
+ defineProps<{
37
+ variant?: AlertVariants["variant"]
38
+ title?: string
39
+ icon?: string
40
+ }>(),
41
+ { variant: "info" },
42
+ )
43
+
44
+ const classes = computed(() => alert({ variant: props.variant }))
45
+ const resolvedIcon = computed(() => props.icon ?? ICONS[props.variant ?? "info"])
46
+ const iconTone = computed(() => ICON_TONE[props.variant ?? "info"])
47
+ </script>
48
+
49
+ <template>
50
+ <div :class="classes" role="alert">
51
+ <LpIcon :name="resolvedIcon" :size="18" :class="['mt-0.5 shrink-0', iconTone]" />
52
+ <div class="min-w-0">
53
+ <p v-if="title" class="font-semibold text-ink">{{ title }}</p>
54
+ <div :class="['text-muted-strong', title ? 'mt-0.5' : '']">
55
+ <slot />
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </template>
@@ -0,0 +1,210 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Full application shell: a fixed-height (h-dvh) flex frame holding the side
4
+ * navigation rail, a top header bar, and a single scrollable main region.
5
+ *
6
+ * It owns the things every product's cabinet/console layout was re-implementing
7
+ * by hand — and getting subtly wrong:
8
+ * - the rail is full viewport height and never scrolls with the page (only
9
+ * `main` scrolls, via LpScrollArea), so the sidebar footer stays pinned;
10
+ * - a responsive burger that drives LpSidebar's mobile drawer;
11
+ * - a default `isActive` (path prefix-match) and a default page title derived
12
+ * from the nav items, so the header title and the active pill never drift.
13
+ *
14
+ * Everything product-specific goes through slots: #logo (brand in the rail
15
+ * header), #sidebar-actions / #sidebar-footer (pass-through to LpSidebar),
16
+ * #header-actions (right side of the top bar — bell, search, theme switch),
17
+ * #header-title (override the computed title), and the default slot (page body).
18
+ * Overlays that aren't part of the frame (command palette, toaster) live in the
19
+ * host next to <LpAppShell>.
20
+ */
21
+ import { computed, ref, watch } from "vue"
22
+ import LpButton from "./LpButton.vue"
23
+ import LpIcon from "./LpIcon.vue"
24
+ import LpScrollArea from "./LpScrollArea.vue"
25
+ import LpSidebar from "./LpSidebar.vue"
26
+ import type { SidebarItem, SidebarSection } from "./sidebar"
27
+
28
+ export type { SidebarItem, SidebarSection } from "./sidebar"
29
+
30
+ const props = withDefaults(
31
+ defineProps<{
32
+ /** Flat items, or sections for grouped nav. Forwarded to LpSidebar. */
33
+ items?: SidebarItem[]
34
+ sections?: SidebarSection[]
35
+ /** Active item id (v-model) when not using a custom `isActive`. */
36
+ modelValue?: string
37
+ /**
38
+ * The current route path. Enables the built-in prefix-match active test and
39
+ * the derived page title without the host wiring its own `isActive`. Pass
40
+ * `route.path`. Ignored when a custom `isActive` is provided.
41
+ */
42
+ path?: string
43
+ /**
44
+ * Custom active test (router-driven nav). Overrides `path`/`modelValue`.
45
+ */
46
+ isActive?: (item: SidebarItem) => boolean
47
+ /** Override the header title. Falls back to the active item's label. */
48
+ title?: string
49
+ /** Mobile drawer open state (v-model:open). */
50
+ open?: boolean
51
+ /** Tailwind breakpoint below which the rail becomes a drawer. */
52
+ mobileBreakpoint?: "sm" | "md" | "lg" | "xl"
53
+ /** Show the brand/header divider in the rail. */
54
+ divider?: boolean
55
+ /** Max content width inside main (Tailwind class), or null to fill. */
56
+ contentClass?: string
57
+ /**
58
+ * Full-bleed main: render the page slot directly into main with NO
59
+ * scroll-area wrapper and NO padding, so the page owns the whole region
60
+ * (for a canvas / map / graph that fills the viewport and pans internally).
61
+ */
62
+ fullBleed?: boolean
63
+ /** Loading skeleton for the nav. */
64
+ loading?: boolean
65
+ }>(),
66
+ {
67
+ mobileBreakpoint: "lg",
68
+ divider: true,
69
+ contentClass: "mx-auto w-full max-w-6xl",
70
+ },
71
+ )
72
+
73
+ const emit = defineEmits<{
74
+ (e: "update:modelValue", id: string): void
75
+ (e: "update:open", value: boolean): void
76
+ (e: "select", item: SidebarItem): void
77
+ }>()
78
+
79
+ // Normalise items/sections to a flat list for the title computation.
80
+ const flatItems = computed<SidebarItem[]>(() =>
81
+ props.sections ? props.sections.flatMap((s) => s.items) : (props.items ?? []),
82
+ )
83
+
84
+ // Default active test: exact match for "/", prefix match (on a segment
85
+ // boundary) otherwise — so "/billing" doesn't light up on "/billing-x".
86
+ function defaultActive(item: SidebarItem): boolean {
87
+ if (props.path == null) return item.id === props.modelValue
88
+ if (item.id === "/") return props.path === "/"
89
+ return props.path === item.id || props.path.startsWith(`${item.id}/`)
90
+ }
91
+ const activeTest = computed(() => props.isActive ?? defaultActive)
92
+
93
+ // Header title: explicit prop wins; else the longest-matching active item's
94
+ // label (longest so nested routes resolve to their own entry, not a parent).
95
+ const pageTitle = computed(() => {
96
+ if (props.title != null) return props.title
97
+ const test = activeTest.value
98
+ const match = [...flatItems.value]
99
+ .sort((a, b) => b.id.length - a.id.length)
100
+ .find((item) => test(item))
101
+ return match?.label ?? ""
102
+ })
103
+
104
+ // Mobile drawer: also close it on every path change, so href/select navigation
105
+ // from a nav item dismisses it.
106
+ const drawerOpen = computed({
107
+ get: () => props.open ?? false,
108
+ set: (v) => emit("update:open", v),
109
+ })
110
+ watch(
111
+ () => props.path,
112
+ () => {
113
+ if (drawerOpen.value) emit("update:open", false)
114
+ },
115
+ )
116
+
117
+ function onSelect(item: SidebarItem) {
118
+ emit("select", item)
119
+ }
120
+ function onModel(id: string) {
121
+ emit("update:modelValue", id)
122
+ }
123
+
124
+ const burgerHidden: Record<NonNullable<typeof props.mobileBreakpoint>, string> = {
125
+ sm: "sm:hidden",
126
+ md: "md:hidden",
127
+ lg: "lg:hidden",
128
+ xl: "xl:hidden",
129
+ }
130
+ const burgerClass = computed(() => burgerHidden[props.mobileBreakpoint])
131
+
132
+ // Expose the burger trigger so a host can omit its own header entirely.
133
+ const _openDrawer = () => emit("update:open", true)
134
+ defineExpose({ openDrawer: _openDrawer })
135
+ </script>
136
+
137
+ <template>
138
+ <div class="flex h-dvh overflow-hidden bg-surface text-ink">
139
+ <LpSidebar
140
+ :model-value="modelValue"
141
+ :items="items"
142
+ :sections="sections"
143
+ :is-active="activeTest"
144
+ :open="drawerOpen"
145
+ responsive
146
+ edge-open
147
+ :mobile-breakpoint="mobileBreakpoint"
148
+ :divider="divider"
149
+ :loading="loading"
150
+ class="shrink-0"
151
+ @update:model-value="onModel"
152
+ @update:open="(v) => emit('update:open', v)"
153
+ @select="onSelect"
154
+ >
155
+ <template v-if="$slots.logo" #header>
156
+ <slot name="logo" />
157
+ </template>
158
+ <template v-if="$slots['sidebar-actions']" #actions>
159
+ <slot name="sidebar-actions" />
160
+ </template>
161
+ <template v-if="$slots['sidebar-footer']" #footer>
162
+ <slot name="sidebar-footer" />
163
+ </template>
164
+ <template v-if="$slots['sidebar-item']" #item="slotProps">
165
+ <slot name="sidebar-item" v-bind="slotProps" />
166
+ </template>
167
+ </LpSidebar>
168
+
169
+ <!-- Main column: header + the single scroll region. -->
170
+ <div class="flex min-w-0 flex-1 flex-col">
171
+ <header
172
+ class="flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-4 md:px-6"
173
+ >
174
+ <LpButton
175
+ :class="burgerClass"
176
+ variant="ghost"
177
+ size="sm"
178
+ square
179
+ aria-label="Open menu"
180
+ @click="emit('update:open', true)"
181
+ >
182
+ <LpIcon name="lucide:menu" :size="18" />
183
+ </LpButton>
184
+
185
+ <slot name="header-title">
186
+ <h1 class="truncate text-lg font-semibold">{{ pageTitle }}</h1>
187
+ </slot>
188
+
189
+ <div v-if="$slots['header-actions']" class="ml-auto flex items-center gap-2">
190
+ <slot name="header-actions" />
191
+ </div>
192
+ </header>
193
+
194
+ <!-- full-bleed: page owns the whole region (canvas/map); otherwise the
195
+ single scroll region with padding. -->
196
+ <div v-if="fullBleed" class="min-h-0 flex-1 overflow-hidden">
197
+ <slot />
198
+ </div>
199
+ <LpScrollArea
200
+ v-else
201
+ class="min-h-0 flex-1"
202
+ :content-class="`px-4 py-6 md:px-6 ${contentClass}`"
203
+ >
204
+ <slot />
205
+ </LpScrollArea>
206
+ </div>
207
+
208
+ <slot name="overlays" />
209
+ </div>
210
+ </template>