@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,200 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* App-shell side navigation. A vertical rail of icon + label items, optionally
|
|
4
|
+
* split into labelled sections, with the active item marked by a sliding brand
|
|
5
|
+
* pill (motion-v shared layoutId — same feel as Tabs/Segmented/Pagination).
|
|
6
|
+
* Items carry an optional badge count. Header/footer slots hold a logo, a
|
|
7
|
+
* collapse control, a user chip, etc. Data-driven: bind the active id with
|
|
8
|
+
* v-model. Each item can route via href (rendered as <a>) or just emit select.
|
|
9
|
+
*
|
|
10
|
+
* Phones: set `responsive` and the rail hides below the `mobileBreakpoint` and
|
|
11
|
+
* is replaced by a swipeable LpDrawer (vaul-vue gives drag-to-close for free).
|
|
12
|
+
* The host owns the burger button and binds v-model:open; selecting an item
|
|
13
|
+
* auto-closes the drawer. With `responsive` off the component renders exactly
|
|
14
|
+
* the static <nav> it always has — existing call sites are unaffected.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, useId } from "vue"
|
|
17
|
+
import LpDrawer from "./LpDrawer.vue"
|
|
18
|
+
import LpSidebarNav from "./LpSidebarNav.vue"
|
|
19
|
+
import type { SidebarItem, SidebarSection } from "./sidebar"
|
|
20
|
+
|
|
21
|
+
export type { SidebarItem, SidebarSection } from "./sidebar"
|
|
22
|
+
|
|
23
|
+
const props = withDefaults(
|
|
24
|
+
defineProps<{
|
|
25
|
+
/** Active item id (v-model). Ignored when `isActive` is provided. */
|
|
26
|
+
modelValue?: string
|
|
27
|
+
/** Flat items, or sections for grouped nav. */
|
|
28
|
+
items?: SidebarItem[]
|
|
29
|
+
sections?: SidebarSection[]
|
|
30
|
+
/**
|
|
31
|
+
* Custom active test — for router-driven nav where "active" is a path-prefix
|
|
32
|
+
* match (e.g. /billing vs /billing-details), not a simple id compare. When
|
|
33
|
+
* given it overrides the modelValue check. The consumer owns the routing.
|
|
34
|
+
*/
|
|
35
|
+
isActive?: (item: SidebarItem) => boolean
|
|
36
|
+
/** Show a skeleton placeholder instead of the items. */
|
|
37
|
+
loading?: boolean
|
|
38
|
+
/** Skeleton row count while loading. */
|
|
39
|
+
skeletonRows?: number
|
|
40
|
+
/** Render the user/header skeleton row too (paired with a #header avatar). */
|
|
41
|
+
skeletonHeader?: boolean
|
|
42
|
+
/**
|
|
43
|
+
* Collapse to a swipeable drawer on phones. Off by default so existing
|
|
44
|
+
* static-rail usage is unchanged.
|
|
45
|
+
*/
|
|
46
|
+
responsive?: boolean
|
|
47
|
+
/** Drawer open state on mobile (v-model:open). Only used when `responsive`. */
|
|
48
|
+
open?: boolean
|
|
49
|
+
/** Tailwind breakpoint below which the drawer takes over. */
|
|
50
|
+
mobileBreakpoint?: "sm" | "md" | "lg" | "xl"
|
|
51
|
+
/** Let a pull from the left screen edge drag the mobile drawer open. */
|
|
52
|
+
edgeOpen?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Draw a hairline rule between the #header (logo) and the nav. On by
|
|
55
|
+
* default — it visually anchors the brand above the items. Pass false for a
|
|
56
|
+
* flush, header-less look.
|
|
57
|
+
*/
|
|
58
|
+
divider?: boolean
|
|
59
|
+
}>(),
|
|
60
|
+
{
|
|
61
|
+
skeletonRows: 6,
|
|
62
|
+
skeletonHeader: true,
|
|
63
|
+
responsive: false,
|
|
64
|
+
mobileBreakpoint: "md",
|
|
65
|
+
divider: true,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const emit = defineEmits<{
|
|
70
|
+
(e: "update:modelValue", id: string): void
|
|
71
|
+
(e: "update:open", value: boolean): void
|
|
72
|
+
(e: "select", item: SidebarItem): void
|
|
73
|
+
}>()
|
|
74
|
+
|
|
75
|
+
// Normalise both inputs to a single section list.
|
|
76
|
+
const groups = computed<SidebarSection[]>(() =>
|
|
77
|
+
props.sections ?? (props.items ? [{ items: props.items }] : []),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
function itemActive(item: SidebarItem): boolean {
|
|
81
|
+
return props.isActive ? props.isActive(item) : item.id === props.modelValue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function activate(item: SidebarItem) {
|
|
85
|
+
if (item.disabled) return
|
|
86
|
+
emit("update:modelValue", item.id)
|
|
87
|
+
emit("select", item)
|
|
88
|
+
// Selecting an item dismisses the mobile drawer; harmless on desktop.
|
|
89
|
+
if (props.responsive && props.open) emit("update:open", false)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Each shell (rail vs drawer) gets its own pill layoutId: when responsive they
|
|
93
|
+
// can be mounted at once, and a shared layoutId would make motion-v tug one pill
|
|
94
|
+
// between two live elements.
|
|
95
|
+
const baseId = `lp-sidebar-${useId()}`
|
|
96
|
+
const railPillId = `${baseId}-rail`
|
|
97
|
+
const drawerPillId = `${baseId}-drawer`
|
|
98
|
+
|
|
99
|
+
// Hide the static rail at/under the breakpoint when responsive; otherwise the
|
|
100
|
+
// rail is always shown and the drawer is never rendered.
|
|
101
|
+
const RAIL_VISIBILITY: Record<NonNullable<typeof props.mobileBreakpoint>, string> = {
|
|
102
|
+
sm: "hidden sm:flex",
|
|
103
|
+
md: "hidden md:flex",
|
|
104
|
+
lg: "hidden lg:flex",
|
|
105
|
+
xl: "hidden xl:flex",
|
|
106
|
+
}
|
|
107
|
+
const railClass = computed(() =>
|
|
108
|
+
props.responsive ? RAIL_VISIBILITY[props.mobileBreakpoint] : "flex",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// This component has a fragment root (the desktop <nav> + the mobile drawer), so
|
|
112
|
+
// Vue can't auto-inherit a consumer-passed `class`. Take attrs over manually and
|
|
113
|
+
// land them on the desktop rail (where a layout class like `shrink-0` belongs).
|
|
114
|
+
defineOptions({ inheritAttrs: false })
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<template>
|
|
118
|
+
<!-- Desktop / always-on rail -->
|
|
119
|
+
<nav
|
|
120
|
+
class="h-full w-60 flex-col gap-1 border-r border-line bg-surface-raised p-3"
|
|
121
|
+
:class="railClass"
|
|
122
|
+
aria-label="Sidebar"
|
|
123
|
+
v-bind="$attrs"
|
|
124
|
+
>
|
|
125
|
+
<div
|
|
126
|
+
v-if="$slots.header"
|
|
127
|
+
class="shrink-0"
|
|
128
|
+
:class="divider ? 'mb-3 border-b border-line pb-3' : 'mb-2'"
|
|
129
|
+
>
|
|
130
|
+
<slot name="header" />
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<LpSidebarNav
|
|
134
|
+
:groups="groups"
|
|
135
|
+
:pill-id="railPillId"
|
|
136
|
+
:item-active="itemActive"
|
|
137
|
+
:loading="loading"
|
|
138
|
+
:skeleton-rows="skeletonRows"
|
|
139
|
+
:skeleton-header="skeletonHeader"
|
|
140
|
+
@activate="activate"
|
|
141
|
+
>
|
|
142
|
+
<template v-if="$slots.item" #item="slotProps">
|
|
143
|
+
<slot name="item" v-bind="slotProps" />
|
|
144
|
+
</template>
|
|
145
|
+
</LpSidebarNav>
|
|
146
|
+
|
|
147
|
+
<!-- Actions (search, "new …") sit just above the footer, pinned to the
|
|
148
|
+
bottom of the rail and outside the scroll region — not trailing the
|
|
149
|
+
nav list. -->
|
|
150
|
+
<div v-if="$slots.actions" class="mt-2 flex shrink-0 flex-col gap-2">
|
|
151
|
+
<slot name="actions" />
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div v-if="$slots.footer" class="mt-2 shrink-0 border-t border-line pt-3">
|
|
155
|
+
<slot name="footer" />
|
|
156
|
+
</div>
|
|
157
|
+
</nav>
|
|
158
|
+
|
|
159
|
+
<!-- Mobile: same nav inside a swipeable drawer (drag-to-close via vaul). -->
|
|
160
|
+
<LpDrawer
|
|
161
|
+
v-if="responsive"
|
|
162
|
+
:open="open"
|
|
163
|
+
direction="left"
|
|
164
|
+
size="sm"
|
|
165
|
+
:edge-open="edgeOpen"
|
|
166
|
+
@update:open="(v) => emit('update:open', v)"
|
|
167
|
+
>
|
|
168
|
+
<div class="flex h-full flex-col gap-1">
|
|
169
|
+
<div
|
|
170
|
+
v-if="$slots.header"
|
|
171
|
+
class="shrink-0"
|
|
172
|
+
:class="divider ? 'mb-3 border-b border-line pb-3' : 'mb-2'"
|
|
173
|
+
>
|
|
174
|
+
<slot name="header" />
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<LpSidebarNav
|
|
178
|
+
:groups="groups"
|
|
179
|
+
:pill-id="drawerPillId"
|
|
180
|
+
:item-active="itemActive"
|
|
181
|
+
:loading="loading"
|
|
182
|
+
:skeleton-rows="skeletonRows"
|
|
183
|
+
:skeleton-header="skeletonHeader"
|
|
184
|
+
@activate="activate"
|
|
185
|
+
>
|
|
186
|
+
<template v-if="$slots.item" #item="slotProps">
|
|
187
|
+
<slot name="item" v-bind="slotProps" />
|
|
188
|
+
</template>
|
|
189
|
+
</LpSidebarNav>
|
|
190
|
+
|
|
191
|
+
<div v-if="$slots.actions" class="mt-2 flex shrink-0 flex-col gap-2">
|
|
192
|
+
<slot name="actions" />
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div v-if="$slots.footer" class="mt-2 shrink-0 border-t border-line pt-3">
|
|
196
|
+
<slot name="footer" />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</LpDrawer>
|
|
200
|
+
</template>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Inner body of LpSidebar: the section/item list plus the loading skeleton and
|
|
4
|
+
* the sliding active pill. Split out so LpSidebar can render the exact same nav
|
|
5
|
+
* in two shells — the static desktop rail and the swipeable mobile drawer —
|
|
6
|
+
* without duplicating markup. Not exported on its own; an LpSidebar detail.
|
|
7
|
+
*/
|
|
8
|
+
import { Motion } from "motion-v"
|
|
9
|
+
import { usePillTransition } from "../composables/usePillTransition"
|
|
10
|
+
import LpBadge from "./LpBadge.vue"
|
|
11
|
+
import LpContextMenu from "./LpContextMenu.vue"
|
|
12
|
+
import LpIcon from "./LpIcon.vue"
|
|
13
|
+
import LpScrollArea from "./LpScrollArea.vue"
|
|
14
|
+
import type { SidebarItem, SidebarSection } from "./sidebar"
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(
|
|
17
|
+
defineProps<{
|
|
18
|
+
/** Normalised section list (LpSidebar collapses items/sections into this). */
|
|
19
|
+
groups: SidebarSection[]
|
|
20
|
+
/** Shared layoutId so the active pill flies between desktop/drawer renders. */
|
|
21
|
+
pillId: string
|
|
22
|
+
itemActive: (item: SidebarItem) => boolean
|
|
23
|
+
loading?: boolean
|
|
24
|
+
skeletonRows?: number
|
|
25
|
+
skeletonHeader?: boolean
|
|
26
|
+
}>(),
|
|
27
|
+
{ skeletonRows: 6, skeletonHeader: true },
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
(e: "activate", item: SidebarItem): void
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const pillTransition = usePillTransition()
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<!-- Loading skeleton: an optional identity row + a run of item rows. -->
|
|
39
|
+
<div v-if="loading" class="flex min-h-0 flex-1 flex-col gap-4">
|
|
40
|
+
<div v-if="skeletonHeader" class="flex items-center gap-3 px-1">
|
|
41
|
+
<div class="size-10 shrink-0 animate-pulse rounded-pill bg-surface-soft" />
|
|
42
|
+
<div class="flex-1 space-y-2">
|
|
43
|
+
<div class="h-3.5 w-24 animate-pulse rounded bg-surface-soft" />
|
|
44
|
+
<div class="h-3 w-16 animate-pulse rounded bg-surface-soft/60" />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="space-y-1">
|
|
48
|
+
<div
|
|
49
|
+
v-for="n in skeletonRows"
|
|
50
|
+
:key="n"
|
|
51
|
+
class="h-9 animate-pulse rounded-control bg-surface-soft"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- min-h-full keeps the content as tall as the viewport so the #actions
|
|
57
|
+
block's mt-auto can still pin it to the bottom. -->
|
|
58
|
+
<LpScrollArea v-else class="min-h-0 flex-1" content-class="flex min-h-full flex-col gap-4">
|
|
59
|
+
<div v-for="(section, si) in groups" :key="si" class="flex flex-col gap-0.5">
|
|
60
|
+
<p
|
|
61
|
+
v-if="section.title"
|
|
62
|
+
class="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
|
|
63
|
+
>
|
|
64
|
+
{{ section.title }}
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
<!-- Optional right-click menu per item; passthrough when item.menu is
|
|
68
|
+
absent or empty, so the item keeps the native context menu. -->
|
|
69
|
+
<LpContextMenu
|
|
70
|
+
v-for="item in section.items"
|
|
71
|
+
:key="item.id"
|
|
72
|
+
:items="item.menu ?? []"
|
|
73
|
+
>
|
|
74
|
+
<component
|
|
75
|
+
:is="item.href && !item.disabled ? 'a' : 'button'"
|
|
76
|
+
:href="item.href && !item.disabled ? item.href : undefined"
|
|
77
|
+
:type="item.href ? undefined : 'button'"
|
|
78
|
+
:disabled="item.href ? undefined : item.disabled"
|
|
79
|
+
:aria-current="itemActive(item) ? 'page' : undefined"
|
|
80
|
+
:aria-disabled="item.disabled || undefined"
|
|
81
|
+
class="group/item relative flex items-center gap-3 rounded-control px-3 py-2 text-sm font-medium outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
|
|
82
|
+
:class="item.disabled
|
|
83
|
+
? 'cursor-not-allowed text-muted/50'
|
|
84
|
+
: itemActive(item)
|
|
85
|
+
? 'text-ink'
|
|
86
|
+
: 'text-muted hover:text-ink'"
|
|
87
|
+
@click="emit('activate', item)"
|
|
88
|
+
>
|
|
89
|
+
<!-- Sliding active pill -->
|
|
90
|
+
<Motion
|
|
91
|
+
v-if="itemActive(item)"
|
|
92
|
+
:layout-id="pillId"
|
|
93
|
+
:transition="pillTransition"
|
|
94
|
+
class="absolute inset-0 z-0 rounded-control bg-brand-soft"
|
|
95
|
+
/>
|
|
96
|
+
<!-- #item slot for fully custom rows; falls back to icon+label+badge. -->
|
|
97
|
+
<slot name="item" :item="item" :active="itemActive(item)">
|
|
98
|
+
<LpIcon
|
|
99
|
+
v-if="item.icon"
|
|
100
|
+
:name="item.icon"
|
|
101
|
+
:size="17"
|
|
102
|
+
class="relative z-10 shrink-0 transition-colors"
|
|
103
|
+
:class="itemActive(item) ? 'text-brand' : ''"
|
|
104
|
+
/>
|
|
105
|
+
<span class="relative z-10 min-w-0 flex-1 truncate text-left">{{ item.label }}</span>
|
|
106
|
+
<LpBadge
|
|
107
|
+
v-if="item.badge != null"
|
|
108
|
+
:tone="itemActive(item) ? 'brand' : 'neutral'"
|
|
109
|
+
class="relative z-10 shrink-0"
|
|
110
|
+
>
|
|
111
|
+
{{ item.badge }}
|
|
112
|
+
</LpBadge>
|
|
113
|
+
</slot>
|
|
114
|
+
</component>
|
|
115
|
+
</LpContextMenu>
|
|
116
|
+
</div>
|
|
117
|
+
</LpScrollArea>
|
|
118
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Loading placeholder. Sizing comes from the consumer's classes (w-/h-).
|
|
3
|
+
defineProps<{ rounded?: "control" | "card" | "pill" }>()
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
class="animate-pulse bg-surface-soft"
|
|
10
|
+
:class="{
|
|
11
|
+
'rounded-control': rounded === 'control' || !rounded,
|
|
12
|
+
'rounded-card': rounded === 'card',
|
|
13
|
+
'rounded-pill': rounded === 'pill',
|
|
14
|
+
}"
|
|
15
|
+
/>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Single-value slider (reka Slider). reka models value as an array; we expose
|
|
3
|
+
// a plain number for the common single-thumb case. Themed via tokens.
|
|
4
|
+
import { SliderRange, SliderRoot, SliderThumb, SliderTrack } from "reka-ui"
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(
|
|
7
|
+
defineProps<{
|
|
8
|
+
modelValue?: number
|
|
9
|
+
min?: number
|
|
10
|
+
max?: number
|
|
11
|
+
step?: number
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
/** Accessible name for the thumb when there's no visible label. */
|
|
14
|
+
ariaLabel?: string
|
|
15
|
+
}>(),
|
|
16
|
+
{ modelValue: 0, min: 0, max: 100, step: 1 },
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{
|
|
20
|
+
// fires live while dragging
|
|
21
|
+
(e: "update:modelValue", value: number): void
|
|
22
|
+
// fires once on release (reka valueCommit) — for commit-vs-preview splits
|
|
23
|
+
(e: "change", value: number): void
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
function onUpdate(v: number[] | undefined) {
|
|
27
|
+
if (v && v.length) emit("update:modelValue", v[0])
|
|
28
|
+
}
|
|
29
|
+
function onCommit(v: number[] | undefined) {
|
|
30
|
+
if (v && v.length) emit("change", v[0])
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<SliderRoot
|
|
36
|
+
:model-value="[props.modelValue]"
|
|
37
|
+
:min="min"
|
|
38
|
+
:max="max"
|
|
39
|
+
:step="step"
|
|
40
|
+
:disabled="disabled"
|
|
41
|
+
class="relative flex h-5 w-full touch-none select-none items-center data-[disabled]:opacity-55"
|
|
42
|
+
@update:model-value="onUpdate"
|
|
43
|
+
@value-commit="onCommit"
|
|
44
|
+
>
|
|
45
|
+
<SliderTrack class="relative h-1.5 grow overflow-hidden rounded-pill bg-surface-soft">
|
|
46
|
+
<SliderRange class="absolute h-full rounded-pill bg-brand" />
|
|
47
|
+
</SliderTrack>
|
|
48
|
+
<SliderThumb
|
|
49
|
+
:aria-label="ariaLabel"
|
|
50
|
+
class="block size-4 rounded-pill border-2 border-brand bg-surface shadow outline-none transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hover:scale-100 motion-reduce:active:scale-100"
|
|
51
|
+
/>
|
|
52
|
+
</SliderRoot>
|
|
53
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Stat tile: dot + caps label, big mono value, optional delta/trend badge and a
|
|
3
|
+
// subtitle. A product pattern, fully token-driven — re-skins with the theme.
|
|
4
|
+
import { computed } from "vue"
|
|
5
|
+
import LpIcon from "./LpIcon.vue"
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(
|
|
8
|
+
defineProps<{
|
|
9
|
+
label: string
|
|
10
|
+
value: string
|
|
11
|
+
hint?: string
|
|
12
|
+
icon?: string
|
|
13
|
+
online?: boolean
|
|
14
|
+
/** Change vs. a previous period. Renders a coloured arrow + this text. A
|
|
15
|
+
* number is shown with a sign; a string is shown verbatim (e.g. "+12%"). */
|
|
16
|
+
delta?: number | string
|
|
17
|
+
/** Appended to a numeric delta, e.g. "%". Ignored for string deltas. */
|
|
18
|
+
deltaSuffix?: string
|
|
19
|
+
/** Trend direction. "auto" derives it from a numeric delta's sign. */
|
|
20
|
+
trend?: "auto" | "up" | "down" | "flat"
|
|
21
|
+
/** For metrics where down is good (latency, errors): swaps the colours so a
|
|
22
|
+
* downward trend reads as positive (action) and upward as danger. */
|
|
23
|
+
invertTrend?: boolean
|
|
24
|
+
}>(),
|
|
25
|
+
{ trend: "auto" },
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// Resolve the trend direction: explicit wins; "auto" reads a numeric delta.
|
|
29
|
+
const dir = computed<"up" | "down" | "flat">(() => {
|
|
30
|
+
if (props.trend !== "auto") return props.trend
|
|
31
|
+
if (typeof props.delta === "number") {
|
|
32
|
+
if (props.delta > 0) return "up"
|
|
33
|
+
if (props.delta < 0) return "down"
|
|
34
|
+
}
|
|
35
|
+
return "flat"
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const trendIcon = computed(() =>
|
|
39
|
+
dir.value === "up"
|
|
40
|
+
? "lucide:trending-up"
|
|
41
|
+
: dir.value === "down"
|
|
42
|
+
? "lucide:trending-down"
|
|
43
|
+
: "lucide:minus",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// up = good by default; invertTrend flips good/bad for "lower is better" metrics.
|
|
47
|
+
const trendColor = computed(() => {
|
|
48
|
+
if (dir.value === "flat") return "text-muted"
|
|
49
|
+
const good = props.invertTrend ? dir.value === "down" : dir.value === "up"
|
|
50
|
+
return good ? "text-action" : "text-danger"
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Numeric delta gets an explicit sign; string deltas are shown as given.
|
|
54
|
+
const deltaText = computed(() => {
|
|
55
|
+
if (props.delta == null) return ""
|
|
56
|
+
if (typeof props.delta === "number") {
|
|
57
|
+
const sign = props.delta > 0 ? "+" : ""
|
|
58
|
+
return `${sign}${props.delta}${props.deltaSuffix ?? ""}`
|
|
59
|
+
}
|
|
60
|
+
return props.delta
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const showDelta = computed(() => props.delta != null)
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div class="flex flex-col gap-2">
|
|
68
|
+
<div class="flex items-center gap-2">
|
|
69
|
+
<span v-if="online" class="size-1.5 rounded-full bg-action" />
|
|
70
|
+
<LpIcon v-if="icon" :name="icon" :size="14" class="text-muted" />
|
|
71
|
+
<span class="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted">
|
|
72
|
+
{{ label }}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="flex items-baseline gap-2">
|
|
76
|
+
<span class="font-mono text-3xl font-medium tracking-tight text-ink">
|
|
77
|
+
{{ value }}
|
|
78
|
+
</span>
|
|
79
|
+
<span
|
|
80
|
+
v-if="showDelta"
|
|
81
|
+
class="inline-flex items-center gap-0.5 text-sm font-medium tabular-nums"
|
|
82
|
+
:class="trendColor"
|
|
83
|
+
>
|
|
84
|
+
<LpIcon :name="trendIcon" :size="14" />
|
|
85
|
+
{{ deltaText }}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
<span v-if="hint" class="text-sm text-muted">{{ hint }}</span>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import LpIcon from "./LpIcon.vue"
|
|
3
|
+
|
|
4
|
+
export interface Step {
|
|
5
|
+
label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
defineProps<{ steps: Step[]; current: number }>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<ol class="flex items-center gap-2">
|
|
13
|
+
<li
|
|
14
|
+
v-for="(step, i) in steps"
|
|
15
|
+
:key="i"
|
|
16
|
+
class="flex flex-1 items-center gap-2 last:flex-none"
|
|
17
|
+
:aria-current="i === current ? 'step' : undefined"
|
|
18
|
+
>
|
|
19
|
+
<div class="flex items-center gap-2">
|
|
20
|
+
<span
|
|
21
|
+
class="flex size-7 shrink-0 items-center justify-center rounded-pill border text-xs font-semibold transition-colors"
|
|
22
|
+
:class="i < current
|
|
23
|
+
? 'border-transparent bg-brand text-ink-inverse'
|
|
24
|
+
: i === current
|
|
25
|
+
? 'border-brand text-brand'
|
|
26
|
+
: 'border-line text-muted'"
|
|
27
|
+
>
|
|
28
|
+
<LpIcon v-if="i < current" name="lucide:check" :size="14" />
|
|
29
|
+
<template v-else>{{ i + 1 }}</template>
|
|
30
|
+
</span>
|
|
31
|
+
<span class="text-sm" :class="i <= current ? 'text-ink' : 'text-muted'">
|
|
32
|
+
{{ step.label }}
|
|
33
|
+
<!-- Status for screen readers; the colour/check are visual only. -->
|
|
34
|
+
<span class="sr-only">{{
|
|
35
|
+
i < current ? "(completed)" : i === current ? "(current step)" : ""
|
|
36
|
+
}}</span>
|
|
37
|
+
</span>
|
|
38
|
+
</div>
|
|
39
|
+
<span
|
|
40
|
+
v-if="i < steps.length - 1"
|
|
41
|
+
class="h-px flex-1"
|
|
42
|
+
:class="i < current ? 'bg-brand' : 'bg-line'"
|
|
43
|
+
/>
|
|
44
|
+
</li>
|
|
45
|
+
</ol>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { SwitchRoot, SwitchThumb } from "reka-ui"
|
|
3
|
+
|
|
4
|
+
defineProps<{ modelValue?: boolean; disabled?: boolean }>()
|
|
5
|
+
defineEmits<{ (e: "update:modelValue", value: boolean): void }>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<SwitchRoot
|
|
10
|
+
:model-value="modelValue"
|
|
11
|
+
:disabled="disabled"
|
|
12
|
+
class="group inline-flex h-5 w-9 shrink-0 items-center rounded-pill border border-line bg-surface-soft px-0.5 outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring data-[state=checked]:border-transparent data-[state=checked]:bg-brand disabled:cursor-not-allowed disabled:opacity-55"
|
|
13
|
+
@update:model-value="(v) => $emit('update:modelValue', v)"
|
|
14
|
+
>
|
|
15
|
+
<SwitchThumb
|
|
16
|
+
class="h-3.5 w-3.5 rounded-full bg-ink shadow transition-[translate,width,background-color] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-active:w-5 group-data-[state=checked]:translate-x-4 group-data-[state=checked]:bg-ink-inverse group-data-[state=checked]:group-active:-translate-x-1 motion-reduce:group-active:w-3.5"
|
|
17
|
+
/>
|
|
18
|
+
</SwitchRoot>
|
|
19
|
+
</template>
|