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