@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.
- package/dist/chunks/{LpCard-1yvWT9fx.js → LpCard-BmrTvWOe.js} +21 -9
- package/dist/chunks/LpFileTree-0Ko_uj8q.js +371 -0
- package/dist/chunks/LpFileTreeNode-D3lNEP-1.js +216 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpCard.vue.d.ts +5 -0
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpFileTree.vue.d.ts +106 -0
- package/dist/components/LpFileTree.vue.js +4 -0
- package/dist/components/LpFileTreeNode.vue.d.ts +45 -0
- package/dist/components/LpFileTreeNode.vue.js +4 -0
- package/dist/components/LpLogViewer.vue.d.ts +1 -1
- package/dist/components/fileTree.d.ts +54 -0
- package/dist/components/fileTree.js +155 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +86 -75
- package/package.json +2 -2
- package/src/component-names.ts +2 -0
- package/src/components/LpCard.vue +12 -5
- package/src/components/LpFileTree.vue +512 -0
- package/src/components/LpFileTreeNode.vue +264 -0
- package/src/components/fileTree.ts +214 -0
- package/src/index.ts +13 -0
- package/src/tokens/tokens.css +58 -2
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* One row of LpFileTree plus its recursively rendered children. Split out so the
|
|
4
|
+
* tree can recurse without LpFileTree importing itself, and so the row's
|
|
5
|
+
* indent/expand/selection logic lives in one place. Not exported on its own; an
|
|
6
|
+
* LpFileTree detail.
|
|
7
|
+
*
|
|
8
|
+
* Roving tabindex: exactly one row in the whole tree is focusable at a time
|
|
9
|
+
* (the one matching `focusedId`), which is what a11y expects of role="tree" —
|
|
10
|
+
* Tab enters the tree once, arrows move within it. The parent owns that state.
|
|
11
|
+
*/
|
|
12
|
+
import { computed } from "vue"
|
|
13
|
+
import {
|
|
14
|
+
checkStateOf,
|
|
15
|
+
fileIcon,
|
|
16
|
+
formatModified,
|
|
17
|
+
formatSize,
|
|
18
|
+
sortNodes,
|
|
19
|
+
subtreeSize,
|
|
20
|
+
type FileNode,
|
|
21
|
+
} from "./fileTree"
|
|
22
|
+
import LpCheckbox from "./LpCheckbox.vue"
|
|
23
|
+
import LpContextMenu from "./LpContextMenu.vue"
|
|
24
|
+
import LpIcon from "./LpIcon.vue"
|
|
25
|
+
|
|
26
|
+
const props = defineProps<{
|
|
27
|
+
node: FileNode
|
|
28
|
+
/** Nesting depth, 0 at the root — drives the indent guide and aria-level. */
|
|
29
|
+
depth: number
|
|
30
|
+
expanded: Set<string>
|
|
31
|
+
loading: Set<string>
|
|
32
|
+
selectedId?: string
|
|
33
|
+
focusedId?: string
|
|
34
|
+
sort: boolean
|
|
35
|
+
/** Rendered size of the row's icon, in px. */
|
|
36
|
+
iconSize: number
|
|
37
|
+
/** Render a tri-state checkbox per row (multi-select mode). */
|
|
38
|
+
checkable: boolean
|
|
39
|
+
checked: Set<string>
|
|
40
|
+
showSize: boolean
|
|
41
|
+
showModified: boolean
|
|
42
|
+
}>()
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{
|
|
45
|
+
(e: "select", node: FileNode): void
|
|
46
|
+
(e: "toggle", node: FileNode): void
|
|
47
|
+
(e: "focus", node: FileNode): void
|
|
48
|
+
(e: "check", node: FileNode, value: boolean): void
|
|
49
|
+
}>()
|
|
50
|
+
|
|
51
|
+
// Declared explicitly: the component renders itself, so an inferred slot type
|
|
52
|
+
// would reference its own initializer (TS7022).
|
|
53
|
+
defineSlots<{
|
|
54
|
+
row(props: { node: FileNode; depth: number; expanded: boolean; selected: boolean }): unknown
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
const isDir = computed(() => props.node.kind === "dir")
|
|
58
|
+
const isOpen = computed(() => isDir.value && props.expanded.has(props.node.id))
|
|
59
|
+
const isLoading = computed(() => props.loading.has(props.node.id))
|
|
60
|
+
const isSelected = computed(() => props.selectedId === props.node.id)
|
|
61
|
+
const isFocused = computed(() => props.focusedId === props.node.id)
|
|
62
|
+
|
|
63
|
+
// A directory with no `children` array hasn't been loaded yet — we can't know if
|
|
64
|
+
// it's empty, so it stays expandable and the caller fills it on `expand`.
|
|
65
|
+
const hasChildren = computed(() => isDir.value && (props.node.children?.length ?? 0) > 0)
|
|
66
|
+
const canExpand = computed(() => isDir.value && (props.node.children === undefined || hasChildren.value))
|
|
67
|
+
|
|
68
|
+
const children = computed(() => {
|
|
69
|
+
const list = props.node.children ?? []
|
|
70
|
+
return props.sort ? sortNodes(list) : list
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const checkState = computed(() =>
|
|
74
|
+
props.checkable ? checkStateOf(props.node, props.checked) : "unchecked",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// A directory shows the rolled-up size of what's under it, so a backup picker
|
|
78
|
+
// can answer "how much is this folder" without expanding it.
|
|
79
|
+
const sizeLabel = computed(() => {
|
|
80
|
+
if (!props.showSize) return ""
|
|
81
|
+
const bytes = subtreeSize(props.node)
|
|
82
|
+
return bytes === undefined ? "" : formatSize(bytes)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const modifiedLabel = computed(() =>
|
|
86
|
+
props.showModified && props.node.modified !== undefined
|
|
87
|
+
? formatModified(props.node.modified)
|
|
88
|
+
: "",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Per-row stagger for the reveal animation. Capped so a large directory doesn't
|
|
93
|
+
* spend a second unfolding — past the cap every remaining row comes in together.
|
|
94
|
+
*/
|
|
95
|
+
function rowDelay(index: number): string {
|
|
96
|
+
return `${Math.min(index * 18, 180)}ms`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Respect the OS setting — skip the height animation entirely. */
|
|
100
|
+
function reducedMotion(): boolean {
|
|
101
|
+
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Expand/collapse the branch by animating from 0 to its measured height. The
|
|
105
|
+
// Web Animations API is used directly (rather than a CSS class) because the
|
|
106
|
+
// target height isn't known until the rows are in the DOM.
|
|
107
|
+
function onBranchEnter(el: Element, done: () => void) {
|
|
108
|
+
if (reducedMotion()) return done()
|
|
109
|
+
const height = (el as HTMLElement).scrollHeight
|
|
110
|
+
el.animate(
|
|
111
|
+
[
|
|
112
|
+
{ height: "0px", opacity: 0 },
|
|
113
|
+
{ height: `${height}px`, opacity: 1 },
|
|
114
|
+
],
|
|
115
|
+
{ duration: 220, easing: "cubic-bezier(0.2, 0, 0, 1)" },
|
|
116
|
+
).onfinish = () => done()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function onBranchLeave(el: Element, done: () => void) {
|
|
120
|
+
if (reducedMotion()) return done()
|
|
121
|
+
const height = (el as HTMLElement).scrollHeight
|
|
122
|
+
el.animate(
|
|
123
|
+
[
|
|
124
|
+
{ height: `${height}px`, opacity: 1 },
|
|
125
|
+
{ height: "0px", opacity: 0 },
|
|
126
|
+
],
|
|
127
|
+
{ duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)" },
|
|
128
|
+
).onfinish = () => done()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onActivate() {
|
|
132
|
+
if (props.node.disabled) return
|
|
133
|
+
emit("focus", props.node)
|
|
134
|
+
// A directory row toggles; a file row selects. Directories still emit select
|
|
135
|
+
// so callers can drive a details panel off the highlighted entry.
|
|
136
|
+
emit("select", props.node)
|
|
137
|
+
if (isDir.value) emit("toggle", props.node)
|
|
138
|
+
}
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<template>
|
|
142
|
+
<li
|
|
143
|
+
role="treeitem"
|
|
144
|
+
class="min-w-0"
|
|
145
|
+
:aria-level="depth + 1"
|
|
146
|
+
:aria-selected="isSelected"
|
|
147
|
+
:aria-expanded="canExpand ? isOpen : undefined"
|
|
148
|
+
:aria-disabled="node.disabled || undefined"
|
|
149
|
+
>
|
|
150
|
+
<!-- Passthrough when the node has no menu, so the row keeps the native one. -->
|
|
151
|
+
<LpContextMenu :items="node.menu ?? []">
|
|
152
|
+
<div
|
|
153
|
+
:data-node-id="node.id"
|
|
154
|
+
:tabindex="isFocused ? 0 : -1"
|
|
155
|
+
class="group/row relative flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
|
|
156
|
+
:class="[
|
|
157
|
+
node.disabled
|
|
158
|
+
? 'cursor-not-allowed text-muted/50'
|
|
159
|
+
: isSelected
|
|
160
|
+
? 'bg-brand-soft text-ink'
|
|
161
|
+
: 'text-muted hover:bg-surface-soft hover:text-ink',
|
|
162
|
+
]"
|
|
163
|
+
:style="{ paddingLeft: `${depth * 14 + 6}px` }"
|
|
164
|
+
@click.stop="onActivate"
|
|
165
|
+
>
|
|
166
|
+
<!-- Chevron sits in a fixed-width slot so names line up whether or not
|
|
167
|
+
the row can expand. Leaves get the same gutter, left empty. -->
|
|
168
|
+
<span class="flex size-4 shrink-0 items-center justify-center">
|
|
169
|
+
<LpIcon
|
|
170
|
+
v-if="isLoading"
|
|
171
|
+
name="lucide:loader-circle"
|
|
172
|
+
:size="13"
|
|
173
|
+
class="animate-spin text-muted"
|
|
174
|
+
/>
|
|
175
|
+
<LpIcon
|
|
176
|
+
v-else-if="canExpand"
|
|
177
|
+
name="lucide:chevron-right"
|
|
178
|
+
:size="14"
|
|
179
|
+
class="text-muted transition-transform duration-[var(--duration-fast)]"
|
|
180
|
+
:class="isOpen ? 'rotate-90' : ''"
|
|
181
|
+
/>
|
|
182
|
+
</span>
|
|
183
|
+
|
|
184
|
+
<!-- Checkbox is its own hit target: ticking a folder must not also
|
|
185
|
+
expand it (and vice versa), so the click stops here. -->
|
|
186
|
+
<LpCheckbox
|
|
187
|
+
v-if="checkable"
|
|
188
|
+
:model-value="checkState === 'checked'"
|
|
189
|
+
:indeterminate="checkState === 'indeterminate'"
|
|
190
|
+
:disabled="node.disabled"
|
|
191
|
+
class="shrink-0"
|
|
192
|
+
:aria-label="node.name"
|
|
193
|
+
@click.stop
|
|
194
|
+
@update:model-value="emit('check', node, $event)"
|
|
195
|
+
/>
|
|
196
|
+
|
|
197
|
+
<LpIcon
|
|
198
|
+
:name="fileIcon(node, isOpen)"
|
|
199
|
+
:size="iconSize"
|
|
200
|
+
class="shrink-0"
|
|
201
|
+
:class="node.disabled ? '' : isDir ? 'text-brand' : 'text-muted'"
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
<span class="min-w-0 flex-1 truncate">
|
|
205
|
+
<slot name="row" :node="node" :depth="depth" :expanded="isOpen" :selected="isSelected">
|
|
206
|
+
{{ node.name }}
|
|
207
|
+
</slot>
|
|
208
|
+
</span>
|
|
209
|
+
|
|
210
|
+
<!-- Metadata columns: tabular figures so sizes line up down the tree. -->
|
|
211
|
+
<span v-if="node.meta" class="shrink-0 text-xs text-muted">{{ node.meta }}</span>
|
|
212
|
+
<span
|
|
213
|
+
v-if="modifiedLabel"
|
|
214
|
+
class="shrink-0 text-xs tabular-nums text-muted/80"
|
|
215
|
+
>
|
|
216
|
+
{{ modifiedLabel }}
|
|
217
|
+
</span>
|
|
218
|
+
<span
|
|
219
|
+
v-if="sizeLabel"
|
|
220
|
+
class="w-16 shrink-0 text-right text-xs tabular-nums text-muted"
|
|
221
|
+
>
|
|
222
|
+
{{ sizeLabel }}
|
|
223
|
+
</span>
|
|
224
|
+
</div>
|
|
225
|
+
</LpContextMenu>
|
|
226
|
+
|
|
227
|
+
<!-- Height is only known at runtime, so the expand/collapse is measured in
|
|
228
|
+
JS hooks rather than a keyframe. Rows inside stagger themselves in. -->
|
|
229
|
+
<Transition
|
|
230
|
+
:css="false"
|
|
231
|
+
@enter="onBranchEnter"
|
|
232
|
+
@leave="onBranchLeave"
|
|
233
|
+
>
|
|
234
|
+
<ul v-if="isOpen && hasChildren" role="group" class="flex min-w-0 flex-col overflow-hidden">
|
|
235
|
+
<LpFileTreeNode
|
|
236
|
+
v-for="(child, i) in children"
|
|
237
|
+
:key="child.id"
|
|
238
|
+
:style="{ '--row-delay': rowDelay(i) }"
|
|
239
|
+
class="animate-[tree-row-in_var(--duration-medium)_var(--ease-emphasized)_both] [animation-delay:var(--row-delay)] motion-reduce:animate-none"
|
|
240
|
+
:node="child"
|
|
241
|
+
:depth="depth + 1"
|
|
242
|
+
:expanded="expanded"
|
|
243
|
+
:loading="loading"
|
|
244
|
+
:selected-id="selectedId"
|
|
245
|
+
:focused-id="focusedId"
|
|
246
|
+
:sort="sort"
|
|
247
|
+
:icon-size="iconSize"
|
|
248
|
+
:checkable="checkable"
|
|
249
|
+
:checked="checked"
|
|
250
|
+
:show-size="showSize"
|
|
251
|
+
:show-modified="showModified"
|
|
252
|
+
@select="emit('select', $event)"
|
|
253
|
+
@toggle="emit('toggle', $event)"
|
|
254
|
+
@focus="emit('focus', $event)"
|
|
255
|
+
@check="(n, v) => emit('check', n, v)"
|
|
256
|
+
>
|
|
257
|
+
<template #row="slotProps">
|
|
258
|
+
<slot name="row" v-bind="slotProps" />
|
|
259
|
+
</template>
|
|
260
|
+
</LpFileTreeNode>
|
|
261
|
+
</ul>
|
|
262
|
+
</Transition>
|
|
263
|
+
</li>
|
|
264
|
+
</template>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared file-tree types and helpers. Lives in its own module so LpFileTree and
|
|
3
|
+
* its recursive LpFileTreeNode body can both import them without a
|
|
4
|
+
* component-to-component cycle. Re-exported from LpFileTree.vue.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
8
|
+
|
|
9
|
+
export interface FileNode {
|
|
10
|
+
/** Stable identity — use the full path when you have one. */
|
|
11
|
+
id: string
|
|
12
|
+
name: string
|
|
13
|
+
/** Directories can hold `children`; files are leaves. */
|
|
14
|
+
kind: "file" | "dir"
|
|
15
|
+
/** Size in bytes. Shown right-aligned when `showSize` is on. */
|
|
16
|
+
size?: number
|
|
17
|
+
/** Last-modified time — Date, epoch ms, or a pre-formatted string. */
|
|
18
|
+
modified?: Date | number | string
|
|
19
|
+
/** Extra right-aligned text (badge-ish), e.g. "read-only". */
|
|
20
|
+
meta?: string
|
|
21
|
+
/**
|
|
22
|
+
* Omit on a directory to mark its children as not-yet-loaded: the row shows a
|
|
23
|
+
* chevron and emits `expand` on first open so the caller can fetch them.
|
|
24
|
+
* An empty array means "loaded, and genuinely empty".
|
|
25
|
+
*/
|
|
26
|
+
children?: FileNode[]
|
|
27
|
+
/** Override the type-derived icon. */
|
|
28
|
+
icon?: string
|
|
29
|
+
/** Dimmed row (e.g. a git-ignored or unreadable entry). Still expandable. */
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
/** Right-click menu for this entry. */
|
|
32
|
+
menu?: ContextMenuItemDef[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Directory icons are state-driven; files fall back to an extension map. */
|
|
36
|
+
const EXT_ICONS: Record<string, string> = {
|
|
37
|
+
json: "lucide:file-json",
|
|
38
|
+
jsonc: "lucide:file-json",
|
|
39
|
+
yaml: "lucide:file-cog",
|
|
40
|
+
yml: "lucide:file-cog",
|
|
41
|
+
toml: "lucide:file-cog",
|
|
42
|
+
ini: "lucide:file-cog",
|
|
43
|
+
conf: "lucide:file-cog",
|
|
44
|
+
env: "lucide:file-key",
|
|
45
|
+
ts: "lucide:file-code",
|
|
46
|
+
tsx: "lucide:file-code",
|
|
47
|
+
js: "lucide:file-code",
|
|
48
|
+
mjs: "lucide:file-code",
|
|
49
|
+
cjs: "lucide:file-code",
|
|
50
|
+
vue: "lucide:file-code",
|
|
51
|
+
py: "lucide:file-code",
|
|
52
|
+
rs: "lucide:file-code",
|
|
53
|
+
go: "lucide:file-code",
|
|
54
|
+
sh: "lucide:file-terminal",
|
|
55
|
+
zsh: "lucide:file-terminal",
|
|
56
|
+
bash: "lucide:file-terminal",
|
|
57
|
+
md: "lucide:file-text",
|
|
58
|
+
mdx: "lucide:file-text",
|
|
59
|
+
txt: "lucide:file-text",
|
|
60
|
+
log: "lucide:scroll-text",
|
|
61
|
+
csv: "lucide:file-spreadsheet",
|
|
62
|
+
sql: "lucide:database",
|
|
63
|
+
png: "lucide:file-image",
|
|
64
|
+
jpg: "lucide:file-image",
|
|
65
|
+
jpeg: "lucide:file-image",
|
|
66
|
+
gif: "lucide:file-image",
|
|
67
|
+
svg: "lucide:file-image",
|
|
68
|
+
webp: "lucide:file-image",
|
|
69
|
+
ico: "lucide:file-image",
|
|
70
|
+
zip: "lucide:file-archive",
|
|
71
|
+
gz: "lucide:file-archive",
|
|
72
|
+
tar: "lucide:file-archive",
|
|
73
|
+
tgz: "lucide:file-archive",
|
|
74
|
+
xz: "lucide:file-archive",
|
|
75
|
+
pdf: "lucide:file-type",
|
|
76
|
+
lock: "lucide:file-lock",
|
|
77
|
+
pem: "lucide:file-key",
|
|
78
|
+
key: "lucide:file-key",
|
|
79
|
+
crt: "lucide:file-key",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Dotfiles that read as config regardless of extension. */
|
|
83
|
+
const NAME_ICONS: Record<string, string> = {
|
|
84
|
+
".env": "lucide:file-key",
|
|
85
|
+
".gitignore": "lucide:git-branch",
|
|
86
|
+
".dockerignore": "lucide:container",
|
|
87
|
+
dockerfile: "lucide:container",
|
|
88
|
+
"docker-compose.yml": "lucide:container",
|
|
89
|
+
"docker-compose.yaml": "lucide:container",
|
|
90
|
+
makefile: "lucide:file-cog",
|
|
91
|
+
"package.json": "lucide:package",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Icon for a node: explicit override → dir state → name → extension → generic. */
|
|
95
|
+
export function fileIcon(node: FileNode, expanded = false): string {
|
|
96
|
+
if (node.icon) return node.icon
|
|
97
|
+
if (node.kind === "dir") return expanded ? "lucide:folder-open" : "lucide:folder"
|
|
98
|
+
|
|
99
|
+
const lower = node.name.toLowerCase()
|
|
100
|
+
if (NAME_ICONS[lower]) return NAME_ICONS[lower]
|
|
101
|
+
// Leading dot doesn't start an extension (".env" is a name, not an ext).
|
|
102
|
+
const dot = lower.lastIndexOf(".")
|
|
103
|
+
const ext = dot > 0 ? lower.slice(dot + 1) : ""
|
|
104
|
+
return EXT_ICONS[ext] ?? "lucide:file"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Directories first, then case-insensitive by name — the file-manager order. */
|
|
108
|
+
export function sortNodes(nodes: FileNode[]): FileNode[] {
|
|
109
|
+
return [...nodes].sort((a, b) => {
|
|
110
|
+
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
|
|
111
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Human-readable byte size — binary units, matching what file managers show. */
|
|
116
|
+
export function formatSize(bytes: number): string {
|
|
117
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "—"
|
|
118
|
+
if (bytes < 1024) return `${bytes} B`
|
|
119
|
+
const units = ["KB", "MB", "GB", "TB", "PB"]
|
|
120
|
+
let value = bytes / 1024
|
|
121
|
+
let i = 0
|
|
122
|
+
while (value >= 1024 && i < units.length - 1) {
|
|
123
|
+
value /= 1024
|
|
124
|
+
i++
|
|
125
|
+
}
|
|
126
|
+
// One decimal below 10 (2.4 MB), none above (240 MB) — keeps the column tidy.
|
|
127
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Short local date-time for the modified column; passes strings through. */
|
|
131
|
+
export function formatModified(value: Date | number | string): string {
|
|
132
|
+
if (typeof value === "string") return value
|
|
133
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
134
|
+
if (Number.isNaN(date.getTime())) return "—"
|
|
135
|
+
return date.toLocaleString(undefined, {
|
|
136
|
+
day: "2-digit",
|
|
137
|
+
month: "short",
|
|
138
|
+
hour: "2-digit",
|
|
139
|
+
minute: "2-digit",
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Total size of a subtree: a directory's own `size` when the caller computed one
|
|
145
|
+
* (the common case for a server-side listing), otherwise the sum of its loaded
|
|
146
|
+
* descendants. Returns undefined when nothing along the way carries a size.
|
|
147
|
+
*/
|
|
148
|
+
export function subtreeSize(node: FileNode): number | undefined {
|
|
149
|
+
if (node.kind === "file") return node.size
|
|
150
|
+
if (node.size !== undefined) return node.size
|
|
151
|
+
if (!node.children?.length) return undefined
|
|
152
|
+
let total = 0
|
|
153
|
+
let seen = false
|
|
154
|
+
for (const child of node.children) {
|
|
155
|
+
const size = subtreeSize(child)
|
|
156
|
+
if (size !== undefined) {
|
|
157
|
+
total += size
|
|
158
|
+
seen = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return seen ? total : undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every id in a subtree including the root — the unit a checkbox toggles. */
|
|
165
|
+
export function subtreeIds(node: FileNode): string[] {
|
|
166
|
+
const out = [node.id]
|
|
167
|
+
for (const child of node.children ?? []) out.push(...subtreeIds(child))
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type CheckState = "checked" | "unchecked" | "indeterminate"
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A node's checkbox state derived from the checked-id set.
|
|
175
|
+
*
|
|
176
|
+
* A directory reflects its loaded children: all checked → checked, some →
|
|
177
|
+
* indeterminate. A directory whose children aren't loaded yet (or which has
|
|
178
|
+
* none) falls back to its own membership, so an unexpanded folder can still be
|
|
179
|
+
* ticked as a whole — which is exactly what a backup picker needs.
|
|
180
|
+
*/
|
|
181
|
+
export function checkStateOf(node: FileNode, checked: ReadonlySet<string>): CheckState {
|
|
182
|
+
if (node.kind === "file" || !node.children?.length) {
|
|
183
|
+
return checked.has(node.id) ? "checked" : "unchecked"
|
|
184
|
+
}
|
|
185
|
+
let all = true
|
|
186
|
+
let any = false
|
|
187
|
+
for (const child of node.children) {
|
|
188
|
+
const state = checkStateOf(child, checked)
|
|
189
|
+
if (state === "checked") any = true
|
|
190
|
+
else if (state === "indeterminate") {
|
|
191
|
+
any = true
|
|
192
|
+
all = false
|
|
193
|
+
} else all = false
|
|
194
|
+
}
|
|
195
|
+
if (all) return "checked"
|
|
196
|
+
return any ? "indeterminate" : "unchecked"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Ids of every ancestor directory of `id`, so callers can reveal a deep node. */
|
|
200
|
+
export function ancestorIds(nodes: FileNode[], id: string): string[] {
|
|
201
|
+
const path: string[] = []
|
|
202
|
+
function walk(list: FileNode[], trail: string[]): boolean {
|
|
203
|
+
for (const node of list) {
|
|
204
|
+
if (node.id === id) {
|
|
205
|
+
path.push(...trail)
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
if (node.children && walk(node.children, [...trail, node.id])) return true
|
|
209
|
+
}
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
walk(nodes, [])
|
|
213
|
+
return path
|
|
214
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,19 @@ export { default as LpDrawer } from "./components/LpDrawer.vue"
|
|
|
25
25
|
export { default as LpDropdownMenu } from "./components/LpDropdownMenu.vue"
|
|
26
26
|
export type { MenuItem } from "./components/LpDropdownMenu.vue"
|
|
27
27
|
export { default as LpEmptyState } from "./components/LpEmptyState.vue"
|
|
28
|
+
export { default as LpFileTree } from "./components/LpFileTree.vue"
|
|
29
|
+
export type { FileTreeSummary } from "./components/LpFileTree.vue"
|
|
30
|
+
export type { CheckState, FileNode } from "./components/fileTree"
|
|
31
|
+
export {
|
|
32
|
+
ancestorIds,
|
|
33
|
+
checkStateOf,
|
|
34
|
+
fileIcon,
|
|
35
|
+
formatModified,
|
|
36
|
+
formatSize,
|
|
37
|
+
sortNodes,
|
|
38
|
+
subtreeIds,
|
|
39
|
+
subtreeSize,
|
|
40
|
+
} from "./components/fileTree"
|
|
28
41
|
export { default as LpFormField } from "./components/LpFormField.vue"
|
|
29
42
|
export { default as LpIcon } from "./components/LpIcon.vue"
|
|
30
43
|
export { default as LpInfraNode } from "./components/LpInfraNode.vue"
|
package/src/tokens/tokens.css
CHANGED
|
@@ -60,6 +60,16 @@
|
|
|
60
60
|
/* ── depth ───────────────────────────────────── */
|
|
61
61
|
--shadow-panel: 0 16px 48px rgba(0, 0, 0, 0.32);
|
|
62
62
|
|
|
63
|
+
/* How far an interactive card's hover glow spreads past its own box, and how
|
|
64
|
+
far the card lifts. A scroll area or any `overflow: hidden` parent clips
|
|
65
|
+
whatever crosses its edge, so a list of cards has to reserve this much room
|
|
66
|
+
around itself or the glow gets sliced off — see the `.lp-glow-room` helper.
|
|
67
|
+
Derived from LpCard's glow (0 8px 24px -6px): blur 24 minus spread 6 = 18
|
|
68
|
+
sideways and up, plus the 8px downward offset below. */
|
|
69
|
+
--lp-glow-spread: 18px;
|
|
70
|
+
--lp-glow-spread-bottom: 26px;
|
|
71
|
+
--lp-card-lift: 2px;
|
|
72
|
+
|
|
63
73
|
/* ── surface / skin ──────────────────────────────
|
|
64
74
|
The skin axis (flat default). applyTheme() overrides these at runtime per
|
|
65
75
|
TokenSet.surface; components paint panels via .lp-skin-panel which reads
|
|
@@ -131,16 +141,22 @@
|
|
|
131
141
|
* of appearing. Exit drops back the same way but shorter and on an accelerating
|
|
132
142
|
* curve, since a dismissal shouldn't be savoured.
|
|
133
143
|
*/
|
|
144
|
+
/*
|
|
145
|
+
* Centred-dialog enter/exit. Deliberately translate + fade with NO scale: the
|
|
146
|
+
* panel is a flex column with a max-height and its own scroll regions, and
|
|
147
|
+
* scaling it re-runs that layout every frame — the content visibly squashed and
|
|
148
|
+
* the animation stuttered. A short rise reads as arriving just as well.
|
|
149
|
+
*/
|
|
134
150
|
@keyframes pop-in {
|
|
135
151
|
from {
|
|
136
152
|
opacity: 0;
|
|
137
|
-
transform: translate(-50%, calc(-50% +
|
|
153
|
+
transform: translate(-50%, calc(-50% + 10px));
|
|
138
154
|
}
|
|
139
155
|
}
|
|
140
156
|
@keyframes pop-out {
|
|
141
157
|
to {
|
|
142
158
|
opacity: 0;
|
|
143
|
-
transform: translate(-50%, calc(-50% +
|
|
159
|
+
transform: translate(-50%, calc(-50% + 5px));
|
|
144
160
|
}
|
|
145
161
|
}
|
|
146
162
|
/*
|
|
@@ -263,6 +279,21 @@
|
|
|
263
279
|
transform: translateY(0);
|
|
264
280
|
}
|
|
265
281
|
}
|
|
282
|
+
/*
|
|
283
|
+
* Tree/list row reveal: a row fades in while sliding from its parent's indent,
|
|
284
|
+
* so an expanding branch reads as unfolding outward rather than popping. Used by
|
|
285
|
+
* LpFileTree, which staggers it per row via --row-delay.
|
|
286
|
+
*/
|
|
287
|
+
@keyframes tree-row-in {
|
|
288
|
+
from {
|
|
289
|
+
opacity: 0;
|
|
290
|
+
transform: translateX(-6px);
|
|
291
|
+
}
|
|
292
|
+
to {
|
|
293
|
+
opacity: 1;
|
|
294
|
+
transform: translateX(0);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
266
297
|
|
|
267
298
|
/*
|
|
268
299
|
* Skin painter. A raised panel opts in with .lp-skin-panel and gets the active
|
|
@@ -294,6 +325,31 @@
|
|
|
294
325
|
z-index: 1;
|
|
295
326
|
}
|
|
296
327
|
|
|
328
|
+
/*
|
|
329
|
+
* Room for a hover glow. An interactive card's glow spreads past the card's own
|
|
330
|
+
* box, so inside anything that clips — a scroll area, a dialog body, a panel
|
|
331
|
+
* with `overflow: hidden` — the glow gets sliced off flat against the edge, and
|
|
332
|
+
* a card sitting at the very top or bottom of a list loses it entirely. The
|
|
333
|
+
* same clip takes the focus ring, which is likewise drawn outside the border;
|
|
334
|
+
* the glow is the wider of the two, so reserving for it covers both.
|
|
335
|
+
*
|
|
336
|
+
* Put this on the element that LAYS OUT the cards (the <ul>, the grid), not on
|
|
337
|
+
* the clipping parent: it insets the cards by exactly the distance the glow
|
|
338
|
+
* travels, so the glow lands on that padding instead of on the clip boundary.
|
|
339
|
+
*
|
|
340
|
+
* Deliberately plain padding, with NO negative margin to cancel it: the whole
|
|
341
|
+
* point is to keep the glow inside the clip, and pulling the box back out with
|
|
342
|
+
* margins would push it straight back under the blade. The cards therefore sit
|
|
343
|
+
* a little in from the edge — that inset IS the fix, not a side effect.
|
|
344
|
+
*
|
|
345
|
+
* Bottom gets more room than the top because the glow is offset downward, and
|
|
346
|
+
* the top gets the lift added back, since a hovered card rises into it.
|
|
347
|
+
*/
|
|
348
|
+
.lp-glow-room {
|
|
349
|
+
padding: calc(var(--lp-glow-spread) + var(--lp-card-lift)) var(--lp-glow-spread)
|
|
350
|
+
var(--lp-glow-spread-bottom);
|
|
351
|
+
}
|
|
352
|
+
|
|
297
353
|
/* ── circular theme reveal (View Transitions) ────
|
|
298
354
|
applyThemeWithTransition() snapshots the page, swaps the theme tokens, then
|
|
299
355
|
shrinks a clip-path circle in the OLD snapshot from the toggle's position,
|