@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,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>