@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,252 @@
1
+ <script setup lang="ts" generic="T extends Record<string, unknown>">
2
+ import { computed, onBeforeUnmount, ref, watch } from "vue"
3
+ import LpCheckbox from "./LpCheckbox.vue"
4
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
5
+ import LpIcon from "./LpIcon.vue"
6
+ import LpScrollArea from "./LpScrollArea.vue"
7
+
8
+ export interface TableColumn<Row> {
9
+ key: string
10
+ label: string
11
+ align?: "left" | "right" | "center"
12
+ width?: string
13
+ /** Allow clicking the header to sort by this column. */
14
+ sortable?: boolean
15
+ }
16
+
17
+ export interface SortState {
18
+ key: string
19
+ dir: "asc" | "desc"
20
+ }
21
+
22
+ type RowKey = string | number
23
+
24
+ const props = withDefaults(
25
+ defineProps<{
26
+ columns: TableColumn<T>[]
27
+ rows: T[]
28
+ /** Stable id per row — required for selection; falls back to index otherwise. */
29
+ rowKey?: keyof T | ((row: T) => RowKey)
30
+ emptyLabel?: string
31
+ emptyIcon?: string
32
+ /** Render a leading checkbox column; selection binds via v-model:selected. */
33
+ selectable?: boolean
34
+ /** Selected row keys (v-model:selected). */
35
+ selected?: RowKey[]
36
+ /** Active sort (v-model:sort). When set, the parent owns ordering (server
37
+ * side); otherwise the table sorts its own rows client-side. */
38
+ sort?: SortState | null
39
+ /** Pin the header while the body scrolls. Pair with a height on the wrapper. */
40
+ stickyHeader?: boolean
41
+ /** Per-row right-click menu. Return [] (or omit the prop) to disable it for
42
+ * a row; the row then keeps the browser's native context menu. */
43
+ rowMenu?: (row: T) => ContextMenuItemDef[]
44
+ }>(),
45
+ {
46
+ emptyLabel: "Nothing here yet",
47
+ emptyIcon: "lucide:inbox",
48
+ selectable: false,
49
+ selected: () => [],
50
+ sort: null,
51
+ },
52
+ )
53
+
54
+ const emit = defineEmits<{
55
+ (e: "update:selected", value: RowKey[]): void
56
+ (e: "update:sort", value: SortState | null): void
57
+ (e: "rowClick", row: T): void
58
+ }>()
59
+
60
+ function keyFor(row: T, index: number): RowKey {
61
+ if (typeof props.rowKey === "function") return props.rowKey(row)
62
+ if (props.rowKey) return row[props.rowKey] as RowKey
63
+ return index
64
+ }
65
+
66
+ function alignClass(align?: "left" | "right" | "center"): string {
67
+ if (align === "right") return "text-right"
68
+ if (align === "center") return "text-center"
69
+ return "text-left"
70
+ }
71
+
72
+ // Resolved right-click menu for a row, or [] when none — keeps the template
73
+ // branch simple and avoids calling rowMenu twice per row.
74
+ function menuFor(row: T): ContextMenuItemDef[] {
75
+ return props.rowMenu?.(row) ?? []
76
+ }
77
+
78
+ // ── sorting ──────────────────────────────────────────────────
79
+ // Client-side sort is applied only when the parent doesn't drive `sort` via
80
+ // v-model (server-side). We compare by raw cell value with a stable-ish coerce.
81
+ const displayRows = computed<T[]>(() => {
82
+ const s = props.sort
83
+ if (!s) return props.rows
84
+ const col = props.columns.find((c) => c.key === s.key)
85
+ if (!col?.sortable) return props.rows
86
+ const factor = s.dir === "asc" ? 1 : -1
87
+ return [...props.rows].sort((a, b) => compare(a[s.key], b[s.key]) * factor)
88
+ })
89
+
90
+ function compare(a: unknown, b: unknown): number {
91
+ if (a == null && b == null) return 0
92
+ if (a == null) return -1
93
+ if (b == null) return 1
94
+ if (typeof a === "number" && typeof b === "number") return a - b
95
+ return String(a).localeCompare(String(b), undefined, { numeric: true })
96
+ }
97
+
98
+ function toggleSort(col: TableColumn<T>) {
99
+ if (!col.sortable) return
100
+ const s = props.sort
101
+ // Cycle: none → asc → desc → none, scoped to the clicked column.
102
+ let next: SortState | null
103
+ if (!s || s.key !== col.key) next = { key: col.key, dir: "asc" }
104
+ else if (s.dir === "asc") next = { key: col.key, dir: "desc" }
105
+ else next = null
106
+ emit("update:sort", next)
107
+ }
108
+
109
+ function sortIcon(col: TableColumn<T>): string {
110
+ if (props.sort?.key !== col.key) return "lucide:chevrons-up-down"
111
+ return props.sort.dir === "asc" ? "lucide:arrow-up" : "lucide:arrow-down"
112
+ }
113
+
114
+ // ── selection ────────────────────────────────────────────────
115
+ const selectedSet = computed(() => new Set(props.selected))
116
+ const allKeys = computed(() => props.rows.map((r, i) => keyFor(r, i)))
117
+ const allChecked = computed(
118
+ () => allKeys.value.length > 0 && allKeys.value.every((k) => selectedSet.value.has(k)),
119
+ )
120
+ const someChecked = computed(
121
+ () => !allChecked.value && allKeys.value.some((k) => selectedSet.value.has(k)),
122
+ )
123
+
124
+ function toggleAll(checked: boolean) {
125
+ emit("update:selected", checked ? [...allKeys.value] : [])
126
+ }
127
+
128
+ function toggleRow(key: RowKey, checked: boolean) {
129
+ const next = new Set(selectedSet.value)
130
+ if (checked) next.add(key)
131
+ else next.delete(key)
132
+ emit("update:selected", [...next])
133
+ }
134
+
135
+ const colSpan = computed(() => props.columns.length + (props.selectable ? 1 : 0))
136
+
137
+ // With stickyHeader, the overlay scrollbar would otherwise run up under the
138
+ // pinned header. Measure the header height and inset the bar by it so the bar
139
+ // starts below the header. Tracked live (density/content can change it).
140
+ const headEl = ref<HTMLElement | null>(null)
141
+ const headHeight = ref(0)
142
+ let ro: ResizeObserver | undefined
143
+ watch(
144
+ [headEl, () => props.stickyHeader],
145
+ ([el, sticky]) => {
146
+ ro?.disconnect()
147
+ if (!el || !sticky || typeof ResizeObserver === "undefined") {
148
+ headHeight.value = 0
149
+ return
150
+ }
151
+ ro = new ResizeObserver(() => {
152
+ headHeight.value = el.offsetHeight
153
+ })
154
+ ro.observe(el)
155
+ headHeight.value = el.offsetHeight
156
+ },
157
+ { immediate: true, flush: "post" },
158
+ )
159
+ onBeforeUnmount(() => ro?.disconnect())
160
+
161
+ const barInsetTop = computed(() =>
162
+ props.stickyHeader && headHeight.value ? `${headHeight.value}px` : undefined,
163
+ )
164
+ </script>
165
+
166
+ <template>
167
+ <LpScrollArea class="rounded-card border border-line" :bar-inset-top="barInsetTop">
168
+ <table class="w-full border-collapse text-sm">
169
+ <thead ref="headEl" :class="stickyHeader ? 'sticky top-0 z-10' : ''">
170
+ <tr class="border-b border-line bg-surface-soft">
171
+ <th v-if="selectable" class="w-px px-4 py-3">
172
+ <LpCheckbox
173
+ :model-value="allChecked"
174
+ :indeterminate="someChecked"
175
+ @update:model-value="toggleAll"
176
+ />
177
+ </th>
178
+ <th
179
+ v-for="col in columns"
180
+ :key="col.key"
181
+ :style="col.width ? { width: col.width } : undefined"
182
+ class="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted"
183
+ :class="alignClass(col.align)"
184
+ :aria-sort="
185
+ sort?.key === col.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined
186
+ "
187
+ >
188
+ <button
189
+ v-if="col.sortable"
190
+ type="button"
191
+ class="group/sort -mx-1 inline-flex items-center gap-1 rounded px-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
192
+ :class="[
193
+ col.align === 'right' ? 'flex-row-reverse' : '',
194
+ sort?.key === col.key ? 'text-ink' : '',
195
+ ]"
196
+ @click="toggleSort(col)"
197
+ >
198
+ {{ col.label }}
199
+ <LpIcon
200
+ :name="sortIcon(col)"
201
+ :size="13"
202
+ class="shrink-0 transition-[transform,opacity] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]"
203
+ :class="sort?.key === col.key ? 'opacity-100' : 'opacity-40 group-hover/sort:opacity-70'"
204
+ />
205
+ </button>
206
+ <template v-else>{{ col.label }}</template>
207
+ </th>
208
+ </tr>
209
+ </thead>
210
+ <tbody>
211
+ <tr v-if="displayRows.length === 0">
212
+ <td :colspan="colSpan" class="px-4 py-10 text-center text-muted">
213
+ <LpIcon :name="emptyIcon" :size="22" class="mx-auto mb-2 opacity-60" />
214
+ <div>{{ emptyLabel }}</div>
215
+ </td>
216
+ </tr>
217
+ <!-- Each row is wrapped in a right-click menu; with no rowMenu (or an
218
+ empty result) LpContextMenu is a passthrough and the row keeps the
219
+ browser's native menu. It renders via as-child, so the DOM stays a
220
+ bare <tr> either way. -->
221
+ <LpContextMenu
222
+ v-for="(row, index) in displayRows"
223
+ :key="keyFor(row, index)"
224
+ :items="menuFor(row)"
225
+ >
226
+ <tr
227
+ class="border-b border-line/60 transition-colors last:border-0 hover:bg-surface-soft/60"
228
+ :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
229
+ @click="emit('rowClick', row)"
230
+ >
231
+ <td v-if="selectable" class="w-px px-4 py-3" @click.stop>
232
+ <LpCheckbox
233
+ :model-value="selectedSet.has(keyFor(row, index))"
234
+ @update:model-value="(v) => toggleRow(keyFor(row, index), v)"
235
+ />
236
+ </td>
237
+ <td
238
+ v-for="col in columns"
239
+ :key="col.key"
240
+ class="px-4 py-3 text-ink"
241
+ :class="alignClass(col.align)"
242
+ >
243
+ <slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
244
+ {{ row[col.key] }}
245
+ </slot>
246
+ </td>
247
+ </tr>
248
+ </LpContextMenu>
249
+ </tbody>
250
+ </table>
251
+ </LpScrollArea>
252
+ </template>
@@ -0,0 +1,183 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * On-this-page table of contents with scroll-spy. Data-driven: pass the heading
4
+ * tree (id + text, optional nested children) and it renders a rail of anchor
5
+ * links, tracking which section is in view via IntersectionObserver and sliding
6
+ * a brand indicator to it (motion-v shared layoutId — same feel as Tabs/Sidebar).
7
+ *
8
+ * The kit convention for a docs/article TOC so apps stop re-implementing the
9
+ * observer + active-link math. Clicking a link smooth-scrolls to the heading and
10
+ * marks it active immediately (so the pill doesn't lag the scroll).
11
+ */
12
+ import { Motion } from "motion-v"
13
+ import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
14
+ import { usePillTransition } from "../composables/usePillTransition"
15
+
16
+ export interface TocLink {
17
+ id: string
18
+ text: string
19
+ depth?: number
20
+ children?: TocLink[]
21
+ }
22
+
23
+ const props = withDefaults(
24
+ defineProps<{
25
+ links: TocLink[]
26
+ /** Heading shown above the list. */
27
+ title?: string
28
+ /** px offset from the top used as the scroll-spy trigger line. */
29
+ offset?: number
30
+ }>(),
31
+ { title: "On this page", offset: 96 },
32
+ )
33
+
34
+ const activeId = ref<string>("")
35
+ // The id the pointer is hovering (null when not hovering). The indicator bar
36
+ // follows the hovered link, falling back to the scroll-spy active one — same
37
+ // feel as LpTabs/LpSidebar.
38
+ const hoveredId = ref<string | null>(null)
39
+ const pillId = `lp-toc-${useId()}`
40
+ const pillTransition = usePillTransition()
41
+
42
+ // Which link the bar sits under: hovered wins, else the active (in-view) one.
43
+ const litId = computed(() => hoveredId.value ?? activeId.value)
44
+
45
+ // Flatten to the ordered list of ids we observe (parents + children).
46
+ function flatIds(links: TocLink[]): string[] {
47
+ return links.flatMap((l) => [l.id, ...(l.children ? flatIds(l.children) : [])])
48
+ }
49
+
50
+ let observer: IntersectionObserver | null = null
51
+
52
+ // While a click-driven smooth scroll is in flight, freeze scroll-spy so the bar
53
+ // doesn't skip through every heading the page passes on the way to the target.
54
+ let spySuppressed = false
55
+ let rafId: number | null = null
56
+ let safetyTimer: ReturnType<typeof setTimeout> | null = null
57
+
58
+ function setup() {
59
+ observer?.disconnect()
60
+ const ids = flatIds(props.links)
61
+ if (!ids.length) return
62
+
63
+ // Track the topmost heading whose top has crossed the offset line. We keep a
64
+ // set of "currently intersecting" and, on every change, pick the first id in
65
+ // document order that's visible — falling back to the last one scrolled past.
66
+ const visible = new Set<string>()
67
+ observer = new IntersectionObserver(
68
+ (entries) => {
69
+ for (const e of entries) {
70
+ if (e.isIntersecting) visible.add(e.target.id)
71
+ else visible.delete(e.target.id)
72
+ }
73
+ if (spySuppressed) return // ignore intermediate sections during a click-scroll
74
+ const firstVisible = ids.find((id) => visible.has(id))
75
+ if (firstVisible) activeId.value = firstVisible
76
+ },
77
+ { rootMargin: `-${props.offset}px 0px -70% 0px`, threshold: 0 },
78
+ )
79
+
80
+ for (const id of ids) {
81
+ const el = document.getElementById(id)
82
+ if (el) observer.observe(el)
83
+ }
84
+ // Seed the initial active id (top-most heading) so the bar isn't empty.
85
+ if (!activeId.value) activeId.value = ids[0] ?? ""
86
+ }
87
+
88
+ // Suppress scroll-spy until the smooth scroll settles. The unfreeze isn't on a
89
+ // fixed delay — we watch scrollY frame-by-frame (rAF) and stop the moment the
90
+ // position holds for a full frame. A long safety timeout is only a fallback so
91
+ // spy can never stay frozen forever if the scroll never starts or is cut short.
92
+ function unfreeze() {
93
+ spySuppressed = false
94
+ if (rafId != null) cancelAnimationFrame(rafId)
95
+ if (safetyTimer != null) clearTimeout(safetyTimer)
96
+ rafId = null
97
+ safetyTimer = null
98
+ }
99
+
100
+ function suppressSpyUntilScrollEnds() {
101
+ spySuppressed = true
102
+ if (rafId != null) cancelAnimationFrame(rafId)
103
+ if (safetyTimer != null) clearTimeout(safetyTimer)
104
+ let last = window.scrollY
105
+ const tick = () => {
106
+ const y = window.scrollY
107
+ if (y === last) {
108
+ unfreeze() // position held for a frame → scrolling has come to rest
109
+ return
110
+ }
111
+ last = y
112
+ rafId = requestAnimationFrame(tick)
113
+ }
114
+ // Start on the next frame so the first sample isn't the pre-scroll y.
115
+ rafId = requestAnimationFrame(tick)
116
+ // Fallback: never stay frozen longer than any scroll could plausibly take.
117
+ safetyTimer = setTimeout(unfreeze, 1000)
118
+ }
119
+
120
+ function onClick(id: string, ev: MouseEvent) {
121
+ const el = document.getElementById(id)
122
+ if (!el) return
123
+ ev.preventDefault()
124
+ activeId.value = id // mark immediately so the bar jumps to the clicked item
125
+ suppressSpyUntilScrollEnds()
126
+ el.scrollIntoView({ behavior: "smooth", block: "start" })
127
+ history.replaceState(null, "", `#${id}`)
128
+ }
129
+
130
+ onMounted(setup)
131
+ watch(() => props.links, setup, { deep: true })
132
+ onBeforeUnmount(() => {
133
+ observer?.disconnect()
134
+ unfreeze()
135
+ })
136
+ </script>
137
+
138
+ <template>
139
+ <nav :aria-label="title" class="text-sm">
140
+ <p class="mb-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted">
141
+ {{ title }}
142
+ </p>
143
+ <ul class="relative flex flex-col gap-0.5 border-l border-line" @mouseleave="hoveredId = null">
144
+ <li v-for="link in links" :key="link.id" class="flex flex-col">
145
+ <a
146
+ :href="`#${link.id}`"
147
+ class="relative -ml-px block py-1 pl-3.5 leading-snug transition-colors duration-[var(--duration-fast)]"
148
+ :class="litId === link.id ? 'text-brand' : 'text-muted hover:text-ink'"
149
+ @click="onClick(link.id, $event)"
150
+ @mouseenter="hoveredId = link.id"
151
+ >
152
+ <Motion
153
+ v-if="litId === link.id"
154
+ :layout-id="pillId"
155
+ :transition="pillTransition"
156
+ class="absolute -left-px top-0 h-full w-0.5 rounded-full bg-brand"
157
+ />
158
+ {{ link.text }}
159
+ </a>
160
+
161
+ <ul v-if="link.children?.length" class="flex flex-col gap-0.5">
162
+ <li v-for="child in link.children" :key="child.id">
163
+ <a
164
+ :href="`#${child.id}`"
165
+ class="relative -ml-px block py-1 pl-7 text-[13px] leading-snug transition-colors duration-[var(--duration-fast)]"
166
+ :class="litId === child.id ? 'text-brand' : 'text-muted hover:text-ink'"
167
+ @click="onClick(child.id, $event)"
168
+ @mouseenter="hoveredId = child.id"
169
+ >
170
+ <Motion
171
+ v-if="litId === child.id"
172
+ :layout-id="pillId"
173
+ :transition="pillTransition"
174
+ class="absolute -left-px top-0 h-full w-0.5 rounded-full bg-brand"
175
+ />
176
+ {{ child.text }}
177
+ </a>
178
+ </li>
179
+ </ul>
180
+ </li>
181
+ </ul>
182
+ </nav>
183
+ </template>
@@ -0,0 +1,120 @@
1
+ <script setup lang="ts">
2
+ import { Motion, useReducedMotion } from "motion-v"
3
+ import { TabsContent, TabsList, TabsRoot, TabsTrigger } from "reka-ui"
4
+ import { computed, ref, useId } from "vue"
5
+ import { usePillTransition } from "../composables/usePillTransition"
6
+ import LpIcon from "./LpIcon.vue"
7
+
8
+ export interface TabItem {
9
+ value: string
10
+ label: string
11
+ /** Optional iconify name (e.g. "lucide:palette"), shown left of the label. */
12
+ icon?: string
13
+ }
14
+
15
+ withDefaults(
16
+ defineProps<{
17
+ modelValue?: string
18
+ items: TabItem[]
19
+ /**
20
+ * "contained" (default) wraps the triggers in a bordered, filled bar.
21
+ * "plain" drops the container chrome — a flat row of tabs with only the
22
+ * sliding pill marking the active one (for nav bars over a custom surface).
23
+ */
24
+ variant?: "contained" | "plain"
25
+ /** Tint the active pill + label with the brand colour (nav-bar style). */
26
+ accent?: boolean
27
+ /** Stretch the bar to full width with equal-share triggers. */
28
+ block?: boolean
29
+ }>(),
30
+ { variant: "contained", accent: false, block: false },
31
+ )
32
+ defineEmits<{ (e: "update:modelValue", value: string): void }>()
33
+
34
+ // The pill sits under the hovered tab, falling back to the active one
35
+ // (UAProject header behaviour). motion-v shared layoutId animates the move.
36
+ // The id must be unique per instance — a shared constant makes the pill fly
37
+ // between separate LpTabs on the same page.
38
+ const pillId = `lp-tab-indicator-${useId()}`
39
+ const hovered = ref<string | null>(null)
40
+
41
+ // Pill move spring (snaps to instant under reduced motion); shared with the
42
+ // other pill-indicator components.
43
+ const pillTransition = usePillTransition()
44
+
45
+ // Panel height spring: when the active #panel differs in height, the wrapper
46
+ // grows/shrinks smoothly instead of snapping. Same feel as the drag layout in
47
+ // LayoutNode; collapses to instant under reduced motion.
48
+ const reduceMotion = useReducedMotion()
49
+ const panelLayoutTransition = computed(() =>
50
+ reduceMotion.value
51
+ ? { duration: 0 }
52
+ : { type: "spring" as const, stiffness: 520, damping: 42, mass: 0.9 },
53
+ )
54
+
55
+ function pillUnder(value: string, active?: string): boolean {
56
+ return hovered.value ? hovered.value === value : active === value
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <TabsRoot
62
+ :model-value="modelValue"
63
+ @update:model-value="(v) => $emit('update:modelValue', v as string)"
64
+ >
65
+ <TabsList
66
+ class="gap-1"
67
+ :class="[
68
+ block ? 'flex w-full' : 'inline-flex',
69
+ variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
70
+ ]"
71
+ @pointerleave="hovered = null"
72
+ >
73
+ <TabsTrigger
74
+ v-for="item in items"
75
+ :key="item.value"
76
+ :value="item.value"
77
+ class="relative inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
78
+ :class="[
79
+ block ? 'flex-1' : '',
80
+ accent ? 'data-[state=active]:text-brand' : 'data-[state=active]:text-ink',
81
+ hovered === item.value ? 'text-ink' : 'text-muted',
82
+ ]"
83
+ @pointerenter="hovered = item.value"
84
+ >
85
+ <Motion
86
+ v-if="pillUnder(item.value, modelValue)"
87
+ :layout-id="pillId"
88
+ :transition="pillTransition"
89
+ class="absolute inset-0 z-0 rounded-md shadow-sm"
90
+ :class="accent
91
+ ? 'border border-brand/35 bg-brand/12'
92
+ : 'border border-line bg-surface-raised'"
93
+ />
94
+ <LpIcon v-if="item.icon" :name="item.icon" :size="14" class="relative z-10" />
95
+ <span class="relative z-10">{{ item.label }}</span>
96
+ </TabsTrigger>
97
+ </TabsList>
98
+
99
+ <!-- Animated panels: only rendered if a per-item #panel slot is provided.
100
+ The Motion wrapper layout-animates its height so switching to a taller
101
+ or shorter panel grows/shrinks smoothly instead of snapping. -->
102
+ <Motion
103
+ v-if="$slots.panel"
104
+ :layout="true"
105
+ :transition="panelLayoutTransition"
106
+ class="mt-3 overflow-hidden"
107
+ >
108
+ <TabsContent
109
+ v-for="item in items"
110
+ :key="item.value"
111
+ :value="item.value"
112
+ class="outline-none data-[state=active]:animate-[tab-in_200ms_var(--ease-emphasized)]"
113
+ >
114
+ <slot name="panel" :value="item.value" />
115
+ </TabsContent>
116
+ </Motion>
117
+
118
+ <slot />
119
+ </TabsRoot>
120
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue"
3
+ import LpFormField from "./LpFormField.vue"
4
+
5
+ const props = defineProps<{
6
+ modelValue?: string
7
+ placeholder?: string
8
+ rows?: number
9
+ invalid?: boolean
10
+ disabled?: boolean
11
+ label?: string
12
+ hint?: string
13
+ error?: string
14
+ }>()
15
+
16
+ defineEmits<{ (e: "update:modelValue", value: string): void }>()
17
+
18
+ const hasField = computed(() => !!props.label || !!props.hint || !!props.error)
19
+ </script>
20
+
21
+ <template>
22
+ <component
23
+ :is="hasField ? LpFormField : 'div'"
24
+ :label="hasField ? label : undefined"
25
+ :hint="hasField ? hint : undefined"
26
+ :error="hasField ? error : undefined"
27
+ :class="hasField ? undefined : 'contents'"
28
+ >
29
+ <textarea
30
+ :value="modelValue"
31
+ :placeholder="placeholder"
32
+ :rows="rows ?? 4"
33
+ :disabled="disabled"
34
+ class="w-full resize-y rounded-control border bg-surface-soft px-3 py-2 text-sm text-ink outline-none transition-colors duration-[var(--duration-fast)] placeholder:text-muted focus:border-brand focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-55"
35
+ :class="(invalid || error) ? 'border-danger focus:border-danger focus:ring-danger-soft' : 'border-line'"
36
+ @input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
37
+ />
38
+ </component>
39
+ </template>