@leavepulse/ui 0.14.8 → 0.14.10

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.
@@ -30,6 +30,11 @@ const card = tv({
30
30
  // NOTE: in Tailwind v4 `translate-*` writes the native `translate` property
31
31
  // (not `transform`), so the transition list MUST name `translate` explicitly
32
32
  // or the lift snaps while the rest eases.
33
+ //
34
+ // The glow reaches past the card, so a clipping ancestor (scroll area,
35
+ // dialog body) will slice it unless the list reserves room — see the
36
+ // `.lp-glow-room` helper in tokens.css, whose spread tokens describe exactly
37
+ // the distance this shadow travels.
33
38
  interactive: {
34
39
  true: "cursor-pointer transition-[translate,box-shadow,border-color] duration-medium ease-[var(--ease-emphasized)] hover:-translate-y-0.5 hover:border-brand/40 hover:shadow-[var(--lp-card-glow)] hover:[--surface-panel-shadow:var(--lp-card-glow)] [--lp-card-glow:0_8px_24px_-6px_color-mix(in_srgb,var(--color-brand)_38%,transparent)] motion-reduce:transition-none motion-reduce:hover:translate-y-0",
35
40
  },
@@ -47,8 +52,10 @@ const props = withDefaults(
47
52
  interactive?: boolean
48
53
  /** Right-click menu for the card. Omit (or pass []) to keep the native one. */
49
54
  menuItems?: ContextMenuItemDef[]
55
+ /** Element to render as. A card inside a <ul> has to be an `li` to be valid. */
56
+ as?: string
50
57
  }>(),
51
- { variant: "raised", padded: true, interactive: false },
58
+ { variant: "raised", padded: true, interactive: false, as: "div" },
52
59
  )
53
60
 
54
61
  const classes = computed(() =>
@@ -61,11 +68,11 @@ const classes = computed(() =>
61
68
  (inheritAttrs:false above), so the card surface looks identical whether or
62
69
  not it's wrapped in a context menu. -->
63
70
  <LpContextMenu v-if="menuItems?.length" :items="menuItems">
64
- <div :class="classes" v-bind="$attrs">
71
+ <component :is="as" :class="classes" v-bind="$attrs">
65
72
  <slot />
66
- </div>
73
+ </component>
67
74
  </LpContextMenu>
68
- <div v-else :class="classes" v-bind="$attrs">
75
+ <component :is="as" v-else :class="classes" v-bind="$attrs">
69
76
  <slot />
70
- </div>
77
+ </component>
71
78
  </template>
@@ -0,0 +1,512 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * File/folder tree — the kit convention for browsing a hierarchy of files so
4
+ * apps stop re-implementing indent math, chevron state and tree keyboard rules.
5
+ * Data-driven: pass a `FileNode[]`; it renders directories-first rows with
6
+ * type-derived icons, expand/collapse, single selection, and optional per-row
7
+ * context menus.
8
+ *
9
+ * Read-only by design — it navigates, it doesn't mutate. Renames/uploads belong
10
+ * to the caller, reachable through `node.menu`.
11
+ *
12
+ * Lazy loading: a directory whose `children` is undefined is treated as
13
+ * not-yet-loaded — it stays expandable, shows a spinner while the caller fetches
14
+ * (`loadingIds`), and emits `expand` once. An empty array means genuinely empty.
15
+ *
16
+ * Keyboard (role="tree" conventions): ↑/↓ move through visible rows, → opens a
17
+ * directory or steps into it, ← collapses or steps out to the parent, Home/End
18
+ * jump to the ends, Enter/Space activates. A roving tabindex keeps the tree a
19
+ * single tab stop.
20
+ */
21
+ import { computed, ref, watch } from "vue"
22
+ import {
23
+ ancestorIds,
24
+ checkStateOf,
25
+ formatSize,
26
+ sortNodes,
27
+ subtreeIds,
28
+ subtreeSize,
29
+ type FileNode,
30
+ } from "./fileTree"
31
+ import LpEmptyState from "./LpEmptyState.vue"
32
+ import LpFileTreeNode from "./LpFileTreeNode.vue"
33
+ import LpScrollArea from "./LpScrollArea.vue"
34
+
35
+ export type { FileNode } from "./fileTree"
36
+
37
+ /** Rolled-up stats for the ticked entries — what a backup picker summarises. */
38
+ export interface FileTreeSummary {
39
+ /** Ticked files (directories excluded — they're containers, not payload). */
40
+ files: number
41
+ /** Ticked directories. */
42
+ dirs: number
43
+ /** Total bytes across ticked files, or undefined when no size is known. */
44
+ bytes?: number
45
+ /** `bytes` pre-formatted ("2.4 GB"), or "—". */
46
+ sizeLabel: string
47
+ }
48
+
49
+ const props = withDefaults(
50
+ defineProps<{
51
+ nodes: FileNode[]
52
+ /** Selected node id (v-model:selected). */
53
+ selected?: string
54
+ /** Expanded directory ids (v-model:expanded). Omit to let the tree own it. */
55
+ expanded?: string[]
56
+ /** Directory ids currently fetching children — shows a spinner on the row. */
57
+ loadingIds?: string[]
58
+ /**
59
+ * Multi-select mode: every row gets a tri-state checkbox and ticking a
60
+ * directory cascades to its loaded subtree. This is the mode for "pick what
61
+ * to back up" — `selected` stays independent (it's the highlighted row).
62
+ */
63
+ checkable?: boolean
64
+ /** Ticked node ids (v-model:checked). */
65
+ checked?: string[]
66
+ /** Right-aligned size column (directories roll up their subtree). */
67
+ showSize?: boolean
68
+ /** Right-aligned last-modified column. */
69
+ showModified?: boolean
70
+ /** Directories first, then by name. Turn off to keep the caller's order. */
71
+ sort?: boolean
72
+ /** Row icon size in px. */
73
+ iconSize?: number
74
+ /** Skeleton rows while the initial listing loads. */
75
+ loading?: boolean
76
+ skeletonRows?: number
77
+ /** Empty-state copy when `nodes` is empty. */
78
+ emptyLabel?: string
79
+ emptyDescription?: string
80
+ }>(),
81
+ {
82
+ sort: true,
83
+ iconSize: 15,
84
+ checkable: false,
85
+ showSize: false,
86
+ showModified: false,
87
+ loading: false,
88
+ skeletonRows: 8,
89
+ emptyLabel: "No files",
90
+ emptyDescription: "This folder is empty.",
91
+ },
92
+ )
93
+
94
+ const emit = defineEmits<{
95
+ (e: "update:selected", id: string): void
96
+ (e: "update:expanded", ids: string[]): void
97
+ /** Any row activated (file or directory). */
98
+ (e: "select", node: FileNode): void
99
+ /** A file row activated — the common case for opening a preview. */
100
+ (e: "open", node: FileNode): void
101
+ /** A directory opened for the first time: fetch its children here. */
102
+ (e: "expand", node: FileNode): void
103
+ (e: "collapse", node: FileNode): void
104
+ (e: "update:checked", ids: string[]): void
105
+ /** Ticked set changed, with the rolled-up stats already computed. */
106
+ (e: "summary", summary: FileTreeSummary): void
107
+ }>()
108
+
109
+ defineSlots<{
110
+ /** Replace the row's label (icons, chevron and indent stay). */
111
+ row(props: { node: FileNode; depth: number; expanded: boolean; selected: boolean }): unknown
112
+ /** Footer under the tree, handed the live selection stats. */
113
+ summary(props: FileTreeSummary): unknown
114
+ }>()
115
+
116
+ // Expansion is optionally controlled: with the `expanded` prop bound we mirror
117
+ // it, otherwise the tree owns the set. Either way the internal ref is the single
118
+ // source the rows read, so uncontrolled use needs no wiring from the caller.
119
+ const openIds = ref<Set<string>>(new Set(props.expanded ?? []))
120
+ watch(
121
+ () => props.expanded,
122
+ (ids) => {
123
+ if (ids) openIds.value = new Set(ids)
124
+ },
125
+ )
126
+
127
+ const loadingSet = computed(() => new Set(props.loadingIds ?? []))
128
+
129
+ // Same controlled/uncontrolled deal as `expanded`.
130
+ const checkedIds = ref<Set<string>>(new Set(props.checked ?? []))
131
+ watch(
132
+ () => props.checked,
133
+ (ids) => {
134
+ if (ids) checkedIds.value = new Set(ids)
135
+ },
136
+ )
137
+
138
+ /** Index every node by id once per data change — the tree walks it a lot. */
139
+ const byId = computed(() => {
140
+ const map = new Map<string, FileNode>()
141
+ function walk(list: FileNode[]) {
142
+ for (const node of list) {
143
+ map.set(node.id, node)
144
+ if (node.children) walk(node.children)
145
+ }
146
+ }
147
+ walk(props.nodes)
148
+ return map
149
+ })
150
+
151
+ /**
152
+ * What the ticked set adds up to. Directories are counted but not sized — their
153
+ * bytes arrive through the files inside them, so summing both would double-count.
154
+ * A ticked-but-unexpanded directory contributes its own `size` when it has one,
155
+ * since none of its files are in the set yet.
156
+ */
157
+ const summary = computed<FileTreeSummary>(() => {
158
+ let files = 0
159
+ let dirs = 0
160
+ let bytes = 0
161
+ let sized = false
162
+ for (const id of checkedIds.value) {
163
+ const node = byId.value.get(id)
164
+ if (!node) continue
165
+ if (node.kind === "dir") {
166
+ dirs++
167
+ // Only when nothing inside it is ticked (unexpanded/lazy folder).
168
+ const inside = node.children?.length
169
+ ? subtreeIds(node).some((child) => child !== id && checkedIds.value.has(child))
170
+ : false
171
+ if (!inside) {
172
+ const size = subtreeSize(node)
173
+ if (size !== undefined) {
174
+ bytes += size
175
+ sized = true
176
+ }
177
+ }
178
+ } else {
179
+ files++
180
+ if (node.size !== undefined) {
181
+ bytes += node.size
182
+ sized = true
183
+ }
184
+ }
185
+ }
186
+ return {
187
+ files,
188
+ dirs,
189
+ bytes: sized ? bytes : undefined,
190
+ sizeLabel: sized ? formatSize(bytes) : "—",
191
+ }
192
+ })
193
+
194
+ watch(summary, (value) => emit("summary", value), { deep: false })
195
+
196
+ function setChecked(next: Set<string>) {
197
+ checkedIds.value = next
198
+ emit("update:checked", [...next])
199
+ }
200
+
201
+ /**
202
+ * Tick/untick a node and its loaded subtree, then reconcile ancestors: a parent
203
+ * is in the set only while every one of its children is, so the derived
204
+ * tri-state and the emitted id list agree.
205
+ */
206
+ function onCheck(node: FileNode, value: boolean) {
207
+ if (node.disabled) return
208
+ const next = new Set(checkedIds.value)
209
+ for (const id of subtreeIds(node)) {
210
+ const target = byId.value.get(id)
211
+ if (target?.disabled) continue // never tick an entry the caller locked
212
+ if (value) next.add(id)
213
+ else next.delete(id)
214
+ }
215
+ // Walk ancestors outward, re-deriving each from its children.
216
+ for (const ancestorId of ancestorIds(props.nodes, node.id).reverse()) {
217
+ const ancestor = byId.value.get(ancestorId)
218
+ if (!ancestor?.children?.length) continue
219
+ const all = ancestor.children.every((c) => c.disabled || next.has(c.id))
220
+ if (all) next.add(ancestorId)
221
+ else next.delete(ancestorId)
222
+ }
223
+ setChecked(next)
224
+ }
225
+
226
+ /** Tick every node in the tree (skipping disabled ones). */
227
+ function checkAll() {
228
+ const next = new Set<string>()
229
+ for (const [id, node] of byId.value) if (!node.disabled) next.add(id)
230
+ setChecked(next)
231
+ }
232
+
233
+ function clearChecked() {
234
+ setChecked(new Set())
235
+ }
236
+
237
+ // Directories we've already emitted `expand` for, so a collapse/re-expand cycle
238
+ // doesn't refetch. Callers that want a refresh can drop `children` again.
239
+ const requested = new Set<string>()
240
+
241
+ const roots = computed(() => (props.sort ? sortNodes(props.nodes) : props.nodes))
242
+
243
+ /** Rows currently visible, in render order — the axis the arrow keys walk. */
244
+ const visible = computed(() => {
245
+ const out: FileNode[] = []
246
+ function walk(list: FileNode[]) {
247
+ for (const node of props.sort ? sortNodes(list) : list) {
248
+ out.push(node)
249
+ if (node.kind === "dir" && openIds.value.has(node.id) && node.children?.length) {
250
+ walk(node.children)
251
+ }
252
+ }
253
+ }
254
+ walk(props.nodes)
255
+ return out
256
+ })
257
+
258
+ // The roving-tabindex row. Follows selection when the caller sets one; falls
259
+ // back to the first row so the tree is reachable by Tab before any click.
260
+ const focusedId = ref<string | undefined>(props.selected)
261
+ watch(
262
+ () => props.selected,
263
+ (id) => {
264
+ if (id) focusedId.value = id
265
+ },
266
+ )
267
+ watch(
268
+ visible,
269
+ (rows) => {
270
+ if (!rows.length) {
271
+ focusedId.value = undefined
272
+ } else if (!rows.some((n) => n.id === focusedId.value)) {
273
+ // The focused row scrolled out of existence (parent collapsed, data
274
+ // changed) — fall back to the first visible row.
275
+ focusedId.value = rows[0].id
276
+ }
277
+ },
278
+ { immediate: true },
279
+ )
280
+
281
+ const root = ref<HTMLElement | null>(null)
282
+
283
+ function focusRow(id: string) {
284
+ focusedId.value = id
285
+ // Rows are plain divs keyed by data-node-id; query rather than hold refs
286
+ // through the recursion.
287
+ const el = root.value?.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(id)}"]`)
288
+ el?.focus()
289
+ el?.scrollIntoView({ block: "nearest" })
290
+ }
291
+
292
+ function setExpanded(next: Set<string>) {
293
+ openIds.value = next
294
+ emit("update:expanded", [...next])
295
+ }
296
+
297
+ function expand(node: FileNode) {
298
+ if (node.kind !== "dir" || openIds.value.has(node.id)) return
299
+ const next = new Set(openIds.value)
300
+ next.add(node.id)
301
+ setExpanded(next)
302
+ // Fetch on first open only when the children aren't loaded yet.
303
+ if (node.children === undefined && !requested.has(node.id)) {
304
+ requested.add(node.id)
305
+ emit("expand", node)
306
+ }
307
+ }
308
+
309
+ function collapse(node: FileNode) {
310
+ if (!openIds.value.has(node.id)) return
311
+ const next = new Set(openIds.value)
312
+ next.delete(node.id)
313
+ setExpanded(next)
314
+ emit("collapse", node)
315
+ }
316
+
317
+ function onToggle(node: FileNode) {
318
+ if (openIds.value.has(node.id)) collapse(node)
319
+ else expand(node)
320
+ }
321
+
322
+ function onSelect(node: FileNode) {
323
+ if (node.disabled) return
324
+ emit("update:selected", node.id)
325
+ emit("select", node)
326
+ if (node.kind === "file") emit("open", node)
327
+ }
328
+
329
+ /** Nearest expanded ancestor of `id`, or undefined at the top level. */
330
+ function parentOf(id: string): FileNode | undefined {
331
+ const trail = ancestorIds(props.nodes, id)
332
+ const parentId = trail.at(-1)
333
+ return parentId ? visible.value.find((n) => n.id === parentId) : undefined
334
+ }
335
+
336
+ function move(delta: number) {
337
+ const rows = visible.value
338
+ if (!rows.length) return
339
+ const i = rows.findIndex((n) => n.id === focusedId.value)
340
+ const next = rows[Math.min(rows.length - 1, Math.max(0, (i < 0 ? 0 : i) + delta))]
341
+ if (next) focusRow(next.id)
342
+ }
343
+
344
+ function current(): FileNode | undefined {
345
+ return visible.value.find((n) => n.id === focusedId.value)
346
+ }
347
+
348
+ function onKeydown(e: KeyboardEvent) {
349
+ const node = current()
350
+ switch (e.key) {
351
+ case "ArrowDown":
352
+ e.preventDefault()
353
+ move(1)
354
+ break
355
+ case "ArrowUp":
356
+ e.preventDefault()
357
+ move(-1)
358
+ break
359
+ case "ArrowRight": {
360
+ if (!node) return
361
+ e.preventDefault()
362
+ // Closed directory opens; already-open one steps to its first child.
363
+ if (node.kind === "dir" && !openIds.value.has(node.id)) expand(node)
364
+ else if (node.kind === "dir" && node.children?.length) move(1)
365
+ break
366
+ }
367
+ case "ArrowLeft": {
368
+ if (!node) return
369
+ e.preventDefault()
370
+ // Open directory closes; anything else steps out to the parent row.
371
+ if (node.kind === "dir" && openIds.value.has(node.id)) collapse(node)
372
+ else {
373
+ const parent = parentOf(node.id)
374
+ if (parent) focusRow(parent.id)
375
+ }
376
+ break
377
+ }
378
+ case "Home":
379
+ e.preventDefault()
380
+ if (visible.value[0]) focusRow(visible.value[0].id)
381
+ break
382
+ case "End": {
383
+ e.preventDefault()
384
+ const last = visible.value.at(-1)
385
+ if (last) focusRow(last.id)
386
+ break
387
+ }
388
+ case " ": {
389
+ if (!node) return
390
+ e.preventDefault()
391
+ // In checkable mode Space is the tick — the standard tree behaviour for a
392
+ // multi-select tree — and Enter stays "open/activate".
393
+ if (props.checkable) {
394
+ onCheck(node, checkStateOf(node, checkedIds.value) !== "checked")
395
+ break
396
+ }
397
+ onSelect(node)
398
+ if (node.kind === "dir") onToggle(node)
399
+ break
400
+ }
401
+ case "Enter": {
402
+ if (!node) return
403
+ e.preventDefault()
404
+ onSelect(node)
405
+ if (node.kind === "dir") onToggle(node)
406
+ break
407
+ }
408
+ }
409
+ }
410
+
411
+ /** Expand every ancestor of `id`, focus it and make it the selection. */
412
+ function reveal(id: string) {
413
+ const next = new Set(openIds.value)
414
+ for (const ancestor of ancestorIds(props.nodes, id)) next.add(ancestor)
415
+ setExpanded(next)
416
+ emit("update:selected", id)
417
+ // Wait a frame so the newly expanded rows exist before we query for one.
418
+ requestAnimationFrame(() => focusRow(id))
419
+ }
420
+
421
+ function expandAll() {
422
+ const next = new Set(openIds.value)
423
+ function walk(list: FileNode[]) {
424
+ for (const node of list) {
425
+ if (node.kind === "dir" && node.children?.length) {
426
+ next.add(node.id)
427
+ walk(node.children)
428
+ }
429
+ }
430
+ }
431
+ walk(props.nodes)
432
+ setExpanded(next)
433
+ }
434
+
435
+ function collapseAll() {
436
+ setExpanded(new Set())
437
+ }
438
+
439
+ defineExpose({ reveal, expandAll, collapseAll, checkAll, clearChecked, summary })
440
+ </script>
441
+
442
+ <template>
443
+ <div v-if="loading" class="flex flex-col gap-1">
444
+ <div
445
+ v-for="n in skeletonRows"
446
+ :key="n"
447
+ class="h-7 animate-pulse rounded-control bg-surface-soft"
448
+ :style="{ marginLeft: `${(n % 3) * 14}px` }"
449
+ />
450
+ </div>
451
+
452
+ <LpEmptyState
453
+ v-else-if="!roots.length"
454
+ icon="lucide:folder-search"
455
+ :title="emptyLabel"
456
+ :description="emptyDescription"
457
+ />
458
+
459
+ <!-- h-full so the tree fills whatever box the caller gave it and the scroll
460
+ area below gets a bounded height; without it the rows just grow past the
461
+ container and nothing ever scrolls. -->
462
+ <div v-else class="flex h-full min-h-0 flex-col">
463
+ <LpScrollArea class="min-h-0 flex-1">
464
+ <ul
465
+ ref="root"
466
+ role="tree"
467
+ :aria-multiselectable="checkable"
468
+ class="flex min-w-0 flex-col"
469
+ @keydown="onKeydown"
470
+ >
471
+ <LpFileTreeNode
472
+ v-for="node in roots"
473
+ :key="node.id"
474
+ :node="node"
475
+ :depth="0"
476
+ :expanded="openIds"
477
+ :loading="loadingSet"
478
+ :selected-id="selected"
479
+ :focused-id="focusedId"
480
+ :sort="sort"
481
+ :icon-size="iconSize"
482
+ :checkable="checkable"
483
+ :checked="checkedIds"
484
+ :show-size="showSize"
485
+ :show-modified="showModified"
486
+ @select="onSelect"
487
+ @toggle="onToggle"
488
+ @focus="focusedId = $event.id"
489
+ @check="onCheck"
490
+ >
491
+ <template #row="slotProps">
492
+ <slot name="row" v-bind="slotProps" />
493
+ </template>
494
+ </LpFileTreeNode>
495
+ </ul>
496
+ </LpScrollArea>
497
+
498
+ <!-- Selection stats. The default is a plain status line; pass #summary to
499
+ render your own, or ignore both and read the `summary` event. -->
500
+ <div
501
+ v-if="checkable && ($slots.summary || summary.files || summary.dirs)"
502
+ class="shrink-0 border-t border-line px-2 py-1.5 text-xs text-muted"
503
+ >
504
+ <slot name="summary" v-bind="summary">
505
+ {{ summary.files }} {{ summary.files === 1 ? "file" : "files" }}<template
506
+ v-if="summary.dirs"
507
+ >, {{ summary.dirs }} {{ summary.dirs === 1 ? "folder" : "folders" }}</template>
508
+ <template v-if="summary.bytes !== undefined"> · {{ summary.sizeLabel }}</template>
509
+ </slot>
510
+ </div>
511
+ </div>
512
+ </template>