@leavepulse/ui 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/component-names.d.ts +1 -0
- package/dist/components/LayoutCanvas.vue.d.ts +28 -0
- package/dist/components/LayoutNode.vue.d.ts +40 -0
- package/dist/components/LpAlert.vue.d.ts +44 -0
- package/dist/components/LpAppShell.vue.d.ts +83 -0
- package/dist/components/LpAutocomplete.vue.d.ts +50 -0
- package/dist/components/LpAvatar.vue.d.ts +20 -0
- package/dist/components/LpBadge.vue.d.ts +47 -0
- package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
- package/dist/components/LpButton.vue.d.ts +113 -0
- package/dist/components/LpCalendar.vue.d.ts +20 -0
- package/dist/components/LpCard.vue.d.ts +29 -0
- package/dist/components/LpCheckbox.vue.d.ts +28 -0
- package/dist/components/LpCodeBlock.vue.d.ts +39 -0
- package/dist/components/LpCommandPalette.vue.d.ts +42 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
- package/dist/components/LpContextMenu.vue.d.ts +30 -0
- package/dist/components/LpDatePicker.vue.d.ts +25 -0
- package/dist/components/LpDisclosure.vue.d.ts +37 -0
- package/dist/components/LpDivider.vue.d.ts +17 -0
- package/dist/components/LpDrawer.vue.d.ts +88 -0
- package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
- package/dist/components/LpEmptyState.vue.d.ts +18 -0
- package/dist/components/LpFormField.vue.d.ts +21 -0
- package/dist/components/LpIcon.vue.d.ts +8 -0
- package/dist/components/LpInfraNode.vue.d.ts +21 -0
- package/dist/components/LpInput.vue.d.ts +68 -0
- package/dist/components/LpLaneNode.vue.d.ts +24 -0
- package/dist/components/LpLink.vue.d.ts +27 -0
- package/dist/components/LpLogViewer.vue.d.ts +92 -0
- package/dist/components/LpModal.vue.d.ts +43 -0
- package/dist/components/LpNotificationBell.vue.d.ts +51 -0
- package/dist/components/LpNumberField.vue.d.ts +14 -0
- package/dist/components/LpOtpInput.vue.d.ts +27 -0
- package/dist/components/LpPagination.vue.d.ts +25 -0
- package/dist/components/LpPasswordInput.vue.d.ts +28 -0
- package/dist/components/LpPhoneInput.vue.d.ts +37 -0
- package/dist/components/LpPopover.vue.d.ts +34 -0
- package/dist/components/LpProgress.vue.d.ts +12 -0
- package/dist/components/LpRadio.vue.d.ts +18 -0
- package/dist/components/LpRadioGroup.vue.d.ts +26 -0
- package/dist/components/LpScrollArea.vue.d.ts +24 -0
- package/dist/components/LpSegmented.vue.d.ts +24 -0
- package/dist/components/LpSelect.vue.d.ts +45 -0
- package/dist/components/LpServiceNode.vue.d.ts +14 -0
- package/dist/components/LpSidebar.vue.d.ts +85 -0
- package/dist/components/LpSidebarNav.vue.d.ts +34 -0
- package/dist/components/LpSkeleton.vue.d.ts +6 -0
- package/dist/components/LpSlider.vue.d.ts +23 -0
- package/dist/components/LpStat.vue.d.ts +22 -0
- package/dist/components/LpStepper.vue.d.ts +10 -0
- package/dist/components/LpSwitch.vue.d.ts +11 -0
- package/dist/components/LpTable.vue.d.ts +63 -0
- package/dist/components/LpTableOfContents.vue.d.ts +19 -0
- package/dist/components/LpTabs.vue.d.ts +45 -0
- package/dist/components/LpTextarea.vue.d.ts +17 -0
- package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
- package/dist/components/LpToaster.vue.d.ts +3 -0
- package/dist/components/LpTooltip.vue.d.ts +19 -0
- package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
- package/dist/components/LpUptimeBar.vue.d.ts +30 -0
- package/dist/components/codeHighlight.d.ts +8 -0
- package/dist/components/countries-data.d.ts +2 -0
- package/dist/components/countries.d.ts +20 -0
- package/dist/components/dropdown.d.ts +29 -0
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/composables/useInputFilter.d.ts +14 -0
- package/dist/composables/usePillTransition.d.ts +9 -0
- package/dist/composables/useToast.d.ts +39 -0
- package/dist/countries-data-9rgqJWHo.js +203 -0
- package/dist/icons.d.ts +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +17600 -0
- package/dist/layout/registry.d.ts +26 -0
- package/dist/layout/tree.d.ts +75 -0
- package/dist/layout/useLayout.d.ts +39 -0
- package/dist/theme/config.d.ts +16 -0
- package/dist/theme/define.d.ts +12 -0
- package/dist/theme/presets.d.ts +20 -0
- package/dist/theme/tokens.d.ts +72 -0
- package/dist/theme/useTheme.d.ts +36 -0
- package/package.json +93 -0
- package/src/canvas.css +67 -0
- package/src/component-names.ts +65 -0
- package/src/components/LayoutCanvas.vue +76 -0
- package/src/components/LayoutNode.vue +248 -0
- package/src/components/LpAlert.vue +59 -0
- package/src/components/LpAppShell.vue +210 -0
- package/src/components/LpAutocomplete.vue +244 -0
- package/src/components/LpAvatar.vue +67 -0
- package/src/components/LpBadge.vue +32 -0
- package/src/components/LpBreadcrumbs.vue +35 -0
- package/src/components/LpButton.vue +111 -0
- package/src/components/LpCalendar.vue +134 -0
- package/src/components/LpCard.vue +71 -0
- package/src/components/LpCheckbox.vue +44 -0
- package/src/components/LpCodeBlock.vue +298 -0
- package/src/components/LpCommandPalette.vue +323 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpContextMenu.vue +111 -0
- package/src/components/LpDatePicker.vue +117 -0
- package/src/components/LpDisclosure.vue +92 -0
- package/src/components/LpDivider.vue +20 -0
- package/src/components/LpDrawer.vue +402 -0
- package/src/components/LpDropdownMenu.vue +54 -0
- package/src/components/LpEmptyState.vue +21 -0
- package/src/components/LpFormField.vue +31 -0
- package/src/components/LpIcon.vue +49 -0
- package/src/components/LpInfraNode.vue +108 -0
- package/src/components/LpInput.vue +147 -0
- package/src/components/LpLaneNode.vue +48 -0
- package/src/components/LpLink.vue +48 -0
- package/src/components/LpLogViewer.vue +569 -0
- package/src/components/LpModal.vue +123 -0
- package/src/components/LpNotificationBell.vue +238 -0
- package/src/components/LpNumberField.vue +56 -0
- package/src/components/LpOtpInput.vue +81 -0
- package/src/components/LpPagination.vue +165 -0
- package/src/components/LpPasswordInput.vue +50 -0
- package/src/components/LpPhoneInput.vue +241 -0
- package/src/components/LpPopover.vue +42 -0
- package/src/components/LpProgress.vue +30 -0
- package/src/components/LpRadio.vue +28 -0
- package/src/components/LpRadioGroup.vue +43 -0
- package/src/components/LpScrollArea.vue +82 -0
- package/src/components/LpSegmented.vue +83 -0
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpServiceNode.vue +57 -0
- package/src/components/LpSidebar.vue +200 -0
- package/src/components/LpSidebarNav.vue +118 -0
- package/src/components/LpSkeleton.vue +16 -0
- package/src/components/LpSlider.vue +53 -0
- package/src/components/LpStat.vue +90 -0
- package/src/components/LpStepper.vue +46 -0
- package/src/components/LpSwitch.vue +19 -0
- package/src/components/LpTable.vue +252 -0
- package/src/components/LpTableOfContents.vue +183 -0
- package/src/components/LpTabs.vue +120 -0
- package/src/components/LpTextarea.vue +39 -0
- package/src/components/LpThemeSwitcher.vue +168 -0
- package/src/components/LpToaster.vue +216 -0
- package/src/components/LpTooltip.vue +30 -0
- package/src/components/LpTopologyCanvas.vue +370 -0
- package/src/components/LpUptimeBar.vue +126 -0
- package/src/components/codeHighlight.ts +137 -0
- package/src/components/countries-data.ts +207 -0
- package/src/components/countries.ts +67 -0
- package/src/components/dropdown.ts +64 -0
- package/src/components/sidebar.ts +26 -0
- package/src/composables/useInputFilter.ts +89 -0
- package/src/composables/usePillTransition.ts +19 -0
- package/src/composables/useToast.ts +92 -0
- package/src/icons.ts +11 -0
- package/src/index.css +21 -0
- package/src/index.ts +146 -0
- package/src/layout/registry.ts +44 -0
- package/src/layout/tree.ts +288 -0
- package/src/layout/useLayout.ts +134 -0
- package/src/nuxt.ts +22 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/define.ts +50 -0
- package/src/theme/presets.ts +279 -0
- package/src/theme/tokens.ts +151 -0
- package/src/theme/useTheme.ts +288 -0
- package/src/tokens/scrollbar.css +138 -0
- package/src/tokens/tokens.css +333 -0
- package/src/tokens/utilities.css +23 -0
|
@@ -0,0 +1,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>
|