@spaethtech/svelte-ui 0.21.1-dev.130.6593aa4 → 0.21.1-dev.132.b812036
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/components/FileList/FileList.svelte +354 -0
- package/dist/components/FileList/FileList.svelte.d.ts +39 -0
- package/dist/components/FileList/index.d.ts +3 -0
- package/dist/components/FileList/index.js +2 -0
- package/dist/components/FileList/types.d.ts +16 -0
- package/dist/components/FileList/types.js +17 -0
- package/dist/components/SideBarMenu/SideBarMenu.svelte +88 -28
- package/dist/components/SideBarMenu/SideBarMenu.svelte.d.ts +19 -4
- package/dist/i18n.d.ts +13 -0
- package/dist/i18n.js +11 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/docs/components.md +22 -0
- package/package.json +5 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
/**
|
|
3
|
+
* FileList — the details pane of a file explorer: Name · Date modified · Type · Size, folders first,
|
|
4
|
+
* sortable by any column. Click selects (Ctrl/⌘ adds, Shift extends), double-click or Enter opens,
|
|
5
|
+
* right-click reports what's under the pointer (the event bubbles on, so a wrapping svelte-ui
|
|
6
|
+
* `ContextMenu` opens), and desktop files dragged in are handed to `ondropfiles` (onto a folder row →
|
|
7
|
+
* that folder). Keys: Delete, F2 (rename in place), Backspace (up), Ctrl+A, arrows.
|
|
8
|
+
*
|
|
9
|
+
* Consumer-driven: the PARENT owns the data and every action (via callbacks); this component owns only
|
|
10
|
+
* selection, sort, the in-place rename field, and the drop highlight. Unlike `DataTable` this is shaped
|
|
11
|
+
* like an OS explorer (fixed explorer columns that collapse by container width), not a generic grid.
|
|
12
|
+
* See FileList.spec.md.
|
|
13
|
+
*/
|
|
14
|
+
-->
|
|
15
|
+
<script lang="ts" module>
|
|
16
|
+
export type { ExplorerEntry, FileListColumn } from "./types.js";
|
|
17
|
+
export { formatSize } from "./types.js";
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import { untrack } from "svelte";
|
|
22
|
+
import Badge from "../Badge/Badge.svelte";
|
|
23
|
+
import Input from "../Input.svelte";
|
|
24
|
+
import { uiI18n } from "../../i18n.js";
|
|
25
|
+
import { variantToken, type Variant } from "../../types/variants.js";
|
|
26
|
+
import IconFolder from "~icons/mdi/folder";
|
|
27
|
+
import IconFile from "~icons/mdi/file-outline";
|
|
28
|
+
import IconDrive from "~icons/mdi/harddisk";
|
|
29
|
+
import IconUp from "~icons/mdi/menu-up";
|
|
30
|
+
import IconDown from "~icons/mdi/menu-down";
|
|
31
|
+
import { formatSize, type ExplorerEntry, type FileListColumn } from "./types.js";
|
|
32
|
+
|
|
33
|
+
const i18n = uiI18n();
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
entries,
|
|
37
|
+
loading = false,
|
|
38
|
+
error = null,
|
|
39
|
+
selected = $bindable(new Set<string>()),
|
|
40
|
+
editing = $bindable<string | null>(null),
|
|
41
|
+
sortCol = $bindable<FileListColumn>("name"),
|
|
42
|
+
sortDir = $bindable<1 | -1>(1),
|
|
43
|
+
emptyText,
|
|
44
|
+
canDrop = true,
|
|
45
|
+
variant = "primary",
|
|
46
|
+
formatDate,
|
|
47
|
+
class: cls = "",
|
|
48
|
+
onopen,
|
|
49
|
+
oncontextmenu,
|
|
50
|
+
ondropfiles,
|
|
51
|
+
ondelete,
|
|
52
|
+
onrename,
|
|
53
|
+
oncommitname,
|
|
54
|
+
onup,
|
|
55
|
+
onsort,
|
|
56
|
+
}: {
|
|
57
|
+
entries: ExplorerEntry[];
|
|
58
|
+
loading?: boolean;
|
|
59
|
+
error?: string | null;
|
|
60
|
+
/** Selected keys (bindable). */
|
|
61
|
+
selected?: Set<string>;
|
|
62
|
+
/** The entry being renamed in place (bindable), or null. */
|
|
63
|
+
editing?: string | null;
|
|
64
|
+
/** Active sort column / direction (bindable; `onsort` also fires on change). */
|
|
65
|
+
sortCol?: FileListColumn;
|
|
66
|
+
sortDir?: 1 | -1;
|
|
67
|
+
/** Empty-state text. Default: localized "This folder is empty." */
|
|
68
|
+
emptyText?: string;
|
|
69
|
+
canDrop?: boolean;
|
|
70
|
+
/** Selection accent (shared axis). Default `primary`. */
|
|
71
|
+
variant?: Variant;
|
|
72
|
+
/** Override the Date column formatting. Default: `Intl.DateTimeFormat(i18n.locale)` medium+short. */
|
|
73
|
+
formatDate?: (modified: string) => string;
|
|
74
|
+
class?: string;
|
|
75
|
+
onopen: (e: ExplorerEntry) => void;
|
|
76
|
+
/** Right-click: `entry` is the row under the pointer, or null on empty space. Not prevented — a
|
|
77
|
+
* wrapping `ContextMenu` still opens. */
|
|
78
|
+
oncontextmenu?: (ev: MouseEvent, entry: ExplorerEntry | null) => void;
|
|
79
|
+
ondropfiles?: (files: File[], into: ExplorerEntry | null) => void;
|
|
80
|
+
ondelete?: (entries: ExplorerEntry[]) => void;
|
|
81
|
+
onrename?: (entry: ExplorerEntry) => void;
|
|
82
|
+
/** An in-place rename ended — `name` null = cancelled/unchanged. */
|
|
83
|
+
oncommitname?: (entry: ExplorerEntry, name: string | null) => void;
|
|
84
|
+
onup?: () => void;
|
|
85
|
+
/** Fired when the sort column/direction changes (e.g. to persist or sort server-side). */
|
|
86
|
+
onsort?: (col: FileListColumn, dir: 1 | -1) => void;
|
|
87
|
+
} = $props();
|
|
88
|
+
|
|
89
|
+
const token = $derived(variantToken[variant]);
|
|
90
|
+
const empty = $derived(emptyText ?? i18n.messages.emptyFolder);
|
|
91
|
+
|
|
92
|
+
const defaultFormatDate = (iso: string) =>
|
|
93
|
+
new Intl.DateTimeFormat(i18n.locale, {
|
|
94
|
+
year: "numeric",
|
|
95
|
+
month: "short",
|
|
96
|
+
day: "numeric",
|
|
97
|
+
hour: "2-digit",
|
|
98
|
+
minute: "2-digit",
|
|
99
|
+
}).format(new Date(iso));
|
|
100
|
+
const fmtDate = (iso: string) => (formatDate ? formatDate(iso) : defaultFormatDate(iso));
|
|
101
|
+
|
|
102
|
+
/** "File folder", "PDF file", "Local disk" — the Type column (localized). */
|
|
103
|
+
function typeLabel(e: ExplorerEntry): string {
|
|
104
|
+
if (e.kind === "drive") return i18n.messages.localDisk;
|
|
105
|
+
if (e.kind === "folder") return i18n.messages.fileFolder;
|
|
106
|
+
const dot = e.name.lastIndexOf(".");
|
|
107
|
+
return dot > 0
|
|
108
|
+
? i18n.messages.fileOfType({ ext: e.name.slice(dot + 1).toUpperCase() })
|
|
109
|
+
: i18n.messages.genericFile;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let draft = $state("");
|
|
113
|
+
// field starts from the current name — only when editing starts, not on every live listing
|
|
114
|
+
$effect(() => {
|
|
115
|
+
const k = editing;
|
|
116
|
+
if (k) untrack(() => (draft = entries.find((e) => e.key === k)?.name ?? ""));
|
|
117
|
+
});
|
|
118
|
+
function finish(e: ExplorerEntry, commit: boolean) {
|
|
119
|
+
if (editing !== e.key) return;
|
|
120
|
+
editing = null;
|
|
121
|
+
const name = draft.trim();
|
|
122
|
+
oncommitname?.(e, commit && name && name !== e.name ? name : null);
|
|
123
|
+
}
|
|
124
|
+
/** Focus the field and select the name without its extension, as a desktop explorer does. */
|
|
125
|
+
function editField(node: HTMLElement, e: ExplorerEntry) {
|
|
126
|
+
const input = node.querySelector("input");
|
|
127
|
+
if (input) {
|
|
128
|
+
input.focus();
|
|
129
|
+
const dot = e.kind === "file" ? e.name.lastIndexOf(".") : -1;
|
|
130
|
+
input.setSelectionRange(0, dot > 0 ? dot : e.name.length);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let anchor = $state<string | null>(null);
|
|
135
|
+
let dropOn = $state<string | null>(null); // a folder key, or "" for the pane
|
|
136
|
+
|
|
137
|
+
const rank = (e: ExplorerEntry) => (e.kind === "file" ? 1 : 0);
|
|
138
|
+
const sorted = $derived(
|
|
139
|
+
[...entries].sort((a, b) => {
|
|
140
|
+
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
141
|
+
let c = 0;
|
|
142
|
+
if (sortCol === "size") c = (a.size ?? -1) - (b.size ?? -1);
|
|
143
|
+
else if (sortCol === "modified")
|
|
144
|
+
c = String(a.modified ?? "").localeCompare(String(b.modified ?? ""));
|
|
145
|
+
else if (sortCol === "type") c = typeLabel(a).localeCompare(typeLabel(b));
|
|
146
|
+
if (c === 0)
|
|
147
|
+
c = a.name.localeCompare(b.name, i18n.locale, { numeric: true, sensitivity: "base" });
|
|
148
|
+
return c * sortDir;
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
const picked = $derived(sorted.filter((e) => selected.has(e.key)));
|
|
152
|
+
|
|
153
|
+
function sortBy(c: FileListColumn) {
|
|
154
|
+
if (sortCol === c) sortDir = sortDir === 1 ? -1 : 1;
|
|
155
|
+
else {
|
|
156
|
+
sortCol = c;
|
|
157
|
+
sortDir = 1;
|
|
158
|
+
}
|
|
159
|
+
onsort?.(sortCol, sortDir);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function click(ev: MouseEvent, e: ExplorerEntry) {
|
|
163
|
+
if (ev.shiftKey && anchor) {
|
|
164
|
+
const keys = sorted.map((x) => x.key);
|
|
165
|
+
const [a, b] = [keys.indexOf(anchor), keys.indexOf(e.key)].sort((x, y) => x - y);
|
|
166
|
+
selected = new Set(keys.slice(a, b + 1));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (ev.ctrlKey || ev.metaKey) {
|
|
170
|
+
const s = new Set(selected);
|
|
171
|
+
if (s.has(e.key)) s.delete(e.key);
|
|
172
|
+
else s.add(e.key);
|
|
173
|
+
selected = s;
|
|
174
|
+
} else selected = new Set([e.key]);
|
|
175
|
+
anchor = e.key;
|
|
176
|
+
}
|
|
177
|
+
// A right-click bubbles on (so a wrapping ContextMenu opens where the pointer is); the row's handler
|
|
178
|
+
// runs first and marks the event so the pane's handler doesn't also fire for empty space.
|
|
179
|
+
const ROW = Symbol("row");
|
|
180
|
+
function context(ev: MouseEvent, e: ExplorerEntry | null) {
|
|
181
|
+
if (!oncontextmenu) return;
|
|
182
|
+
if (!e && (ev as unknown as Record<symbol, boolean>)[ROW]) return;
|
|
183
|
+
if (e) (ev as unknown as Record<symbol, boolean>)[ROW] = true;
|
|
184
|
+
if (e && !selected.has(e.key)) {
|
|
185
|
+
selected = new Set([e.key]);
|
|
186
|
+
anchor = e.key;
|
|
187
|
+
}
|
|
188
|
+
if (!e) selected = new Set();
|
|
189
|
+
oncontextmenu(ev, e);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function onkeydown(ev: KeyboardEvent) {
|
|
193
|
+
if ((ev.target as HTMLElement).closest("input")) return;
|
|
194
|
+
const keys = sorted.map((x) => x.key);
|
|
195
|
+
const i = anchor ? keys.indexOf(anchor) : -1;
|
|
196
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
|
197
|
+
const n = Math.max(0, Math.min(keys.length - 1, i + (ev.key === "ArrowDown" ? 1 : -1)));
|
|
198
|
+
if (keys[n]) {
|
|
199
|
+
anchor = keys[n];
|
|
200
|
+
selected = new Set([keys[n]]);
|
|
201
|
+
document.getElementById(rowId(keys[n]))?.scrollIntoView({ block: "nearest" });
|
|
202
|
+
}
|
|
203
|
+
} else if (ev.key === "Enter" && picked.length === 1) onopen(picked[0]);
|
|
204
|
+
else if (ev.key === "Delete" && picked.length && ondelete) ondelete(picked);
|
|
205
|
+
else if (ev.key === "F2" && picked.length === 1) {
|
|
206
|
+
if (oncommitname) editing = picked[0].key;
|
|
207
|
+
else onrename?.(picked[0]);
|
|
208
|
+
} else if (ev.key === "Backspace" && onup) onup();
|
|
209
|
+
else if (ev.key === "a" && (ev.ctrlKey || ev.metaKey)) selected = new Set(keys);
|
|
210
|
+
else if (ev.key === "Escape") selected = new Set();
|
|
211
|
+
else return;
|
|
212
|
+
ev.preventDefault();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const hasFiles = (ev: DragEvent) => !!ev.dataTransfer?.types.includes("Files");
|
|
216
|
+
function over(ev: DragEvent, target: string) {
|
|
217
|
+
if (!canDrop || !ondropfiles || !hasFiles(ev)) return;
|
|
218
|
+
ev.preventDefault();
|
|
219
|
+
ev.stopPropagation();
|
|
220
|
+
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "copy";
|
|
221
|
+
dropOn = target;
|
|
222
|
+
}
|
|
223
|
+
function drop(ev: DragEvent, into: ExplorerEntry | null) {
|
|
224
|
+
if (!canDrop || !ondropfiles || !hasFiles(ev)) return;
|
|
225
|
+
ev.preventDefault();
|
|
226
|
+
ev.stopPropagation();
|
|
227
|
+
dropOn = null;
|
|
228
|
+
const files = Array.from(ev.dataTransfer?.files ?? []);
|
|
229
|
+
if (files.length) ondropfiles(files, into);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const rowId = (key: string) =>
|
|
233
|
+
`fl-${btoa(unescape(encodeURIComponent(key))).replace(/[^A-Za-z0-9]/g, "")}`;
|
|
234
|
+
const muted = "[color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]";
|
|
235
|
+
// columns follow the LIST's own width (it sits beside a tree): Name + Size, then Date, then Type
|
|
236
|
+
const cols =
|
|
237
|
+
"grid grid-cols-[minmax(0,1fr)_6rem] @lg:grid-cols-[minmax(0,1fr)_11rem_6rem] @2xl:grid-cols-[minmax(0,1fr)_11rem_8rem_6rem] gap-x-4 items-center";
|
|
238
|
+
</script>
|
|
239
|
+
|
|
240
|
+
{#snippet head(c: FileListColumn, label: string, extra = "")}
|
|
241
|
+
<span
|
|
242
|
+
role="columnheader"
|
|
243
|
+
class={extra}
|
|
244
|
+
aria-sort={sortCol === c ? (sortDir === 1 ? "ascending" : "descending") : "none"}
|
|
245
|
+
>
|
|
246
|
+
<span
|
|
247
|
+
role="button"
|
|
248
|
+
tabindex="0"
|
|
249
|
+
class="inline-flex cursor-pointer items-center gap-0.5 hover:underline"
|
|
250
|
+
onclick={() => sortBy(c)}
|
|
251
|
+
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), sortBy(c))}
|
|
252
|
+
>{label}{#if sortCol === c}{#if sortDir === 1}<IconUp class="size-4" />{:else}<IconDown
|
|
253
|
+
class="size-4"
|
|
254
|
+
/>{/if}{/if}</span
|
|
255
|
+
>
|
|
256
|
+
</span>
|
|
257
|
+
{/snippet}
|
|
258
|
+
|
|
259
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
|
260
|
+
<div
|
|
261
|
+
role="grid"
|
|
262
|
+
aria-multiselectable="true"
|
|
263
|
+
aria-label={i18n.messages.files}
|
|
264
|
+
tabindex="0"
|
|
265
|
+
class="@container flex h-full min-h-0 flex-col select-none outline-none {cls}
|
|
266
|
+
{dropOn === '' ? `ring-2 ring-inset [--tw-ring-color:var(${token})]` : ''}"
|
|
267
|
+
{onkeydown}
|
|
268
|
+
oncontextmenu={(ev) => context(ev, null)}
|
|
269
|
+
ondragover={(ev) => over(ev, "")}
|
|
270
|
+
ondragleave={(ev) => {
|
|
271
|
+
if (!(ev.currentTarget as HTMLElement).contains(ev.relatedTarget as Node)) dropOn = null;
|
|
272
|
+
}}
|
|
273
|
+
ondrop={(ev) => drop(ev, null)}
|
|
274
|
+
onclick={(ev) => {
|
|
275
|
+
if (ev.target === ev.currentTarget) selected = new Set();
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
<div
|
|
279
|
+
role="row"
|
|
280
|
+
class="{cols} border-b px-3 py-2 font-medium [border-color:var(--ui-border-color)] {muted}"
|
|
281
|
+
>
|
|
282
|
+
{@render head("name", i18n.messages.colName)}
|
|
283
|
+
{@render head("modified", i18n.messages.colDateModified, "hidden @lg:block")}
|
|
284
|
+
{@render head("type", i18n.messages.colType, "hidden @2xl:block")}
|
|
285
|
+
{@render head("size", i18n.messages.colSize, "text-right")}
|
|
286
|
+
</div>
|
|
287
|
+
<div class="min-h-0 grow overflow-y-auto py-1" role="rowgroup">
|
|
288
|
+
{#if error}
|
|
289
|
+
<div class="px-3 py-6 [color:var(--ui-color-error)]">{error}</div>
|
|
290
|
+
{:else if loading && entries.length === 0}
|
|
291
|
+
<div class="px-3 py-6 {muted}">{i18n.messages.loading}</div>
|
|
292
|
+
{:else if entries.length === 0}
|
|
293
|
+
<div class="px-3 py-6 {muted}">{empty}</div>
|
|
294
|
+
{:else}
|
|
295
|
+
{#each sorted as e (e.key)}
|
|
296
|
+
{@const isSel = selected.has(e.key)}
|
|
297
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
298
|
+
<div
|
|
299
|
+
id={rowId(e.key)}
|
|
300
|
+
role="row"
|
|
301
|
+
aria-selected={isSel}
|
|
302
|
+
tabindex="-1"
|
|
303
|
+
class="{cols} mx-1 cursor-default rounded px-3 py-1.5
|
|
304
|
+
{isSel
|
|
305
|
+
? `[background-color:color-mix(in_srgb,var(${token})_16%,transparent)]`
|
|
306
|
+
: dropOn === e.key
|
|
307
|
+
? `[background-color:color-mix(in_srgb,var(${token})_10%,transparent)] ring-1 ring-inset [--tw-ring-color:var(${token})]`
|
|
308
|
+
: 'hover:[background-color:var(--ui-color-hover)]'}"
|
|
309
|
+
onclick={(ev) => click(ev, e)}
|
|
310
|
+
ondblclick={() => onopen(e)}
|
|
311
|
+
oncontextmenu={(ev) => context(ev, e)}
|
|
312
|
+
ondragover={e.kind === "file" ? undefined : (ev) => over(ev, e.key)}
|
|
313
|
+
ondrop={e.kind === "file" ? undefined : (ev) => drop(ev, e)}
|
|
314
|
+
>
|
|
315
|
+
<span role="gridcell" class="flex min-w-0 items-center gap-2">
|
|
316
|
+
{#if e.kind === "drive"}
|
|
317
|
+
<IconDrive class="size-5 shrink-0 {muted}" />
|
|
318
|
+
{:else if e.kind === "folder"}
|
|
319
|
+
<IconFolder class="size-5 shrink-0 [color:var(--ui-color-warning)]" />
|
|
320
|
+
{:else}
|
|
321
|
+
<IconFile class="size-5 shrink-0 {muted}" />
|
|
322
|
+
{/if}
|
|
323
|
+
{#if editing === e.key}
|
|
324
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
325
|
+
<div
|
|
326
|
+
class="min-w-0 grow"
|
|
327
|
+
use:editField={e}
|
|
328
|
+
onclick={(ev) => ev.stopPropagation()}
|
|
329
|
+
ondblclick={(ev) => ev.stopPropagation()}
|
|
330
|
+
onkeydown={(ev) => {
|
|
331
|
+
if (ev.key === "Enter") (ev.preventDefault(), finish(e, true));
|
|
332
|
+
else if (ev.key === "Escape") (ev.preventDefault(), finish(e, false));
|
|
333
|
+
}}
|
|
334
|
+
onfocusout={() => finish(e, true)}
|
|
335
|
+
>
|
|
336
|
+
<Input bind:value={draft} aria-label={i18n.messages.newName} />
|
|
337
|
+
</div>
|
|
338
|
+
{:else}
|
|
339
|
+
<span class="truncate">{e.name}</span>
|
|
340
|
+
{/if}
|
|
341
|
+
{#each e.flags ?? [] as f (f)}<Badge size="sm" variant="neutral" text={f} />{/each}
|
|
342
|
+
</span>
|
|
343
|
+
<span role="gridcell" class="hidden truncate @lg:block {muted}"
|
|
344
|
+
>{e.modified ? fmtDate(e.modified) : ""}</span
|
|
345
|
+
>
|
|
346
|
+
<span role="gridcell" class="hidden truncate @2xl:block {muted}">{typeLabel(e)}</span>
|
|
347
|
+
<span role="gridcell" class="truncate text-right {muted}"
|
|
348
|
+
>{e.kind === "file" ? formatSize(e.size, i18n.locale) : ""}</span
|
|
349
|
+
>
|
|
350
|
+
</div>
|
|
351
|
+
{/each}
|
|
352
|
+
{/if}
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type { ExplorerEntry, FileListColumn } from "./types.js";
|
|
2
|
+
export { formatSize } from "./types.js";
|
|
3
|
+
import { type Variant } from "../../types/variants.js";
|
|
4
|
+
import { type ExplorerEntry, type FileListColumn } from "./types.js";
|
|
5
|
+
type $$ComponentProps = {
|
|
6
|
+
entries: ExplorerEntry[];
|
|
7
|
+
loading?: boolean;
|
|
8
|
+
error?: string | null;
|
|
9
|
+
/** Selected keys (bindable). */
|
|
10
|
+
selected?: Set<string>;
|
|
11
|
+
/** The entry being renamed in place (bindable), or null. */
|
|
12
|
+
editing?: string | null;
|
|
13
|
+
/** Active sort column / direction (bindable; `onsort` also fires on change). */
|
|
14
|
+
sortCol?: FileListColumn;
|
|
15
|
+
sortDir?: 1 | -1;
|
|
16
|
+
/** Empty-state text. Default: localized "This folder is empty." */
|
|
17
|
+
emptyText?: string;
|
|
18
|
+
canDrop?: boolean;
|
|
19
|
+
/** Selection accent (shared axis). Default `primary`. */
|
|
20
|
+
variant?: Variant;
|
|
21
|
+
/** Override the Date column formatting. Default: `Intl.DateTimeFormat(i18n.locale)` medium+short. */
|
|
22
|
+
formatDate?: (modified: string) => string;
|
|
23
|
+
class?: string;
|
|
24
|
+
onopen: (e: ExplorerEntry) => void;
|
|
25
|
+
/** Right-click: `entry` is the row under the pointer, or null on empty space. Not prevented — a
|
|
26
|
+
* wrapping `ContextMenu` still opens. */
|
|
27
|
+
oncontextmenu?: (ev: MouseEvent, entry: ExplorerEntry | null) => void;
|
|
28
|
+
ondropfiles?: (files: File[], into: ExplorerEntry | null) => void;
|
|
29
|
+
ondelete?: (entries: ExplorerEntry[]) => void;
|
|
30
|
+
onrename?: (entry: ExplorerEntry) => void;
|
|
31
|
+
/** An in-place rename ended — `name` null = cancelled/unchanged. */
|
|
32
|
+
oncommitname?: (entry: ExplorerEntry, name: string | null) => void;
|
|
33
|
+
onup?: () => void;
|
|
34
|
+
/** Fired when the sort column/direction changes (e.g. to persist or sort server-side). */
|
|
35
|
+
onsort?: (col: FileListColumn, dir: 1 | -1) => void;
|
|
36
|
+
};
|
|
37
|
+
declare const FileList: import("svelte").Component<$$ComponentProps, {}, "selected" | "editing" | "sortCol" | "sortDir">;
|
|
38
|
+
type FileList = ReturnType<typeof FileList>;
|
|
39
|
+
export default FileList;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Shapes for the `FileList` details view. Backend-agnostic — the consumer owns the data. */
|
|
2
|
+
export interface ExplorerEntry {
|
|
3
|
+
/** Unique key — usually the full path. */
|
|
4
|
+
key: string;
|
|
5
|
+
name: string;
|
|
6
|
+
kind: "drive" | "folder" | "file";
|
|
7
|
+
size?: number | null;
|
|
8
|
+
/** ISO date string (or anything `new Date()` accepts); formatted for the Date column. */
|
|
9
|
+
modified?: string | null;
|
|
10
|
+
/** Small `Badge`s rendered after the name ("read-only", …). */
|
|
11
|
+
flags?: string[];
|
|
12
|
+
}
|
|
13
|
+
/** The sortable columns. */
|
|
14
|
+
export type FileListColumn = "name" | "modified" | "type" | "size";
|
|
15
|
+
/** Human size — `1.5 MB`. The numeric part is localized via `locale` (decimal separator). */
|
|
16
|
+
export declare function formatSize(n: number | null | undefined, locale?: string): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Shapes for the `FileList` details view. Backend-agnostic — the consumer owns the data. */
|
|
2
|
+
/** Human size — `1.5 MB`. The numeric part is localized via `locale` (decimal separator). */
|
|
3
|
+
export function formatSize(n, locale) {
|
|
4
|
+
if (n === null || n === undefined)
|
|
5
|
+
return "";
|
|
6
|
+
const u = ["B", "KB", "MB", "GB", "TB"];
|
|
7
|
+
let i = 0;
|
|
8
|
+
let v = n;
|
|
9
|
+
while (v >= 1024 && i < u.length - 1) {
|
|
10
|
+
v /= 1024;
|
|
11
|
+
i++;
|
|
12
|
+
}
|
|
13
|
+
const num = i
|
|
14
|
+
? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(v)
|
|
15
|
+
: String(v);
|
|
16
|
+
return `${num} ${u[i]}`;
|
|
17
|
+
}
|
|
@@ -95,8 +95,9 @@
|
|
|
95
95
|
link?: string;
|
|
96
96
|
badge?: SideBarMenuBadge;
|
|
97
97
|
/** Child items — rendered as a fly-out popout. Recursive: a child that itself has children
|
|
98
|
-
* cascades into a further popout (or steps deeper in `stepped` mode).
|
|
99
|
-
|
|
98
|
+
* cascades into a further popout (or steps deeper in `stepped` mode). May include
|
|
99
|
+
* `{ divider: true }` entries to draw a separator between children. */
|
|
100
|
+
children?: SideBarMenuEntry[];
|
|
100
101
|
/** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
|
|
101
102
|
* row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
|
|
102
103
|
onclick?: (e: MouseEvent) => void;
|
|
@@ -114,6 +115,20 @@
|
|
|
114
115
|
* the default `currentPath` segment-boundary match against `link`. */
|
|
115
116
|
activeMatch?: SideBarMenuActiveRule;
|
|
116
117
|
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A horizontal rule between adjacent rows in an `items` / `bottomItems` / `children` list — a 1px
|
|
121
|
+
* full-bleed separator (`--ui-border-color`) drawn between the two rows it sits between. It adds no
|
|
122
|
+
* navigable row and (visually) only the 1px line, so it never disturbs the fixed-height row rhythm.
|
|
123
|
+
* Leading, trailing, and consecutive dividers collapse — a divider renders ONLY between two real
|
|
124
|
+
* rows. Honoured identically in the fly-out popout, the stepped sub-view, and the inline
|
|
125
|
+
* expanded/drawer rail. Use the discriminated form `{ divider: true }` so a genuinely malformed item
|
|
126
|
+
* still falls through to the visible "malformed item" row rather than silently becoming a separator.
|
|
127
|
+
*/
|
|
128
|
+
export type SideBarMenuDivider = { divider: true };
|
|
129
|
+
|
|
130
|
+
/** A SideBarMenu list entry: either a {@link SideBarMenuItem} or a {@link SideBarMenuDivider}. */
|
|
131
|
+
export type SideBarMenuEntry = SideBarMenuItem | SideBarMenuDivider;
|
|
117
132
|
</script>
|
|
118
133
|
|
|
119
134
|
<script lang="ts">
|
|
@@ -159,8 +174,8 @@
|
|
|
159
174
|
class: additionalClass = "",
|
|
160
175
|
...rest
|
|
161
176
|
}: {
|
|
162
|
-
items?:
|
|
163
|
-
bottomItems?:
|
|
177
|
+
items?: SideBarMenuEntry[];
|
|
178
|
+
bottomItems?: SideBarMenuEntry[];
|
|
164
179
|
currentPath: string;
|
|
165
180
|
size?: Size;
|
|
166
181
|
/**
|
|
@@ -492,7 +507,34 @@
|
|
|
492
507
|
// reserving group that lack an icon still get an empty box so everything aligns. The main rail counts
|
|
493
508
|
// `items` + `bottomItems` as ONE group (so top/bottom align); each popout / stepped sub-view is its
|
|
494
509
|
// own group.
|
|
495
|
-
|
|
510
|
+
// ── Dividers ────────────────────────────────────────────────────
|
|
511
|
+
// A list entry is either a real item or a `{ divider: true }` separator. Dividers are not navigable
|
|
512
|
+
// rows — they render as a 1px full-bleed rule BETWEEN two real rows (see `renderRows`).
|
|
513
|
+
const isDivider = (e: SideBarMenuEntry): e is SideBarMenuDivider =>
|
|
514
|
+
typeof e === "object" && e !== null && (e as SideBarMenuDivider).divider === true;
|
|
515
|
+
const itemsOnly = (list: SideBarMenuEntry[] = []): SideBarMenuItem[] =>
|
|
516
|
+
list.filter((e): e is SideBarMenuItem => !isDivider(e));
|
|
517
|
+
|
|
518
|
+
// Normalize a list into the rows to render, each tagged with whether a divider precedes it. Leading,
|
|
519
|
+
// trailing, and consecutive dividers collapse — `dividerBefore` is set only when a divider sits
|
|
520
|
+
// between two real rows. `index` is the entry's ORIGINAL position in the list, so open-path ids
|
|
521
|
+
// (`top:3`, `id>5`) stay stable and unique no matter how many dividers precede a row.
|
|
522
|
+
type RenderRow = { item: SideBarMenuItem; index: number; dividerBefore: boolean };
|
|
523
|
+
function renderRows(list: SideBarMenuEntry[] = []): RenderRow[] {
|
|
524
|
+
const out: RenderRow[] = [];
|
|
525
|
+
let pending = false;
|
|
526
|
+
list.forEach((e, index) => {
|
|
527
|
+
if (isDivider(e)) {
|
|
528
|
+
if (out.length > 0) pending = true; // drop leading; collapse runs of dividers
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
out.push({ item: e, index, dividerBefore: pending });
|
|
532
|
+
pending = false;
|
|
533
|
+
});
|
|
534
|
+
return out; // a trailing divider is never consumed → dropped
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const anyIcon = (list: SideBarMenuEntry[] = []) => itemsOnly(list).some((i) => i.icon || i.badge);
|
|
496
538
|
const railReserve = $derived(anyIcon(items) || anyIcon(bottomItems));
|
|
497
539
|
|
|
498
540
|
// ── Mobile drawer state ─────────────────────────────────────────
|
|
@@ -605,7 +647,8 @@
|
|
|
605
647
|
} else if (item.link && pathMatches(item.link, currentPath)) {
|
|
606
648
|
return true;
|
|
607
649
|
}
|
|
608
|
-
|
|
650
|
+
const kids = itemsOnly(item.children);
|
|
651
|
+
if (kids.length) return kids.some(isItemActive);
|
|
609
652
|
return false;
|
|
610
653
|
}
|
|
611
654
|
// A row's OWN active state (drives `aria-current`) — its own rule/link only, never descendants.
|
|
@@ -919,7 +962,7 @@
|
|
|
919
962
|
{@const active = isItemActive(item)}
|
|
920
963
|
{@const ownActive = isItemOwnActive(item)}
|
|
921
964
|
{@const reserveIcon = opts.reserveIcon ?? true}
|
|
922
|
-
{@const hasChildren = (item.children
|
|
965
|
+
{@const hasChildren = itemsOnly(item.children).length > 0}
|
|
923
966
|
{@const isParent = hasChildren || !!item.popout}
|
|
924
967
|
{@const hasLink = !!item.link}
|
|
925
968
|
{@const isOpen = !!opts.id && isOnPath(opts.id)}
|
|
@@ -1028,7 +1071,7 @@
|
|
|
1028
1071
|
depth: number,
|
|
1029
1072
|
)}
|
|
1030
1073
|
{@render row(item, { id, isPopoutChild: depth > 0, reserveIcon })}
|
|
1031
|
-
{#if ((
|
|
1074
|
+
{#if (itemsOnly(item.children).length > 0 || !!item.popout) && !isStepped}
|
|
1032
1075
|
{@const top = depth === 0}
|
|
1033
1076
|
{@const geom = top
|
|
1034
1077
|
? geometryFor(id, group)
|
|
@@ -1085,9 +1128,9 @@
|
|
|
1085
1128
|
{#snippet popoutPanel(item: SideBarMenuItem, id: ItemId, group: "top" | "bottom", maxHeight: string)}
|
|
1086
1129
|
{@const popoutId = popoutDomId(id)}
|
|
1087
1130
|
{@const isOpen = isOnPath(id)}
|
|
1088
|
-
{@const
|
|
1089
|
-
{@const win = popoutWindow(id,
|
|
1090
|
-
{@const childReserve = anyIcon(
|
|
1131
|
+
{@const rows = renderRows(item.children)}
|
|
1132
|
+
{@const win = popoutWindow(id, rows.length)}
|
|
1133
|
+
{@const childReserve = anyIcon(item.children)}
|
|
1091
1134
|
{@const rowsShown = (win.up ? 1 : 0) + (win.end - win.start) + (win.down ? 1 : 0)}
|
|
1092
1135
|
{@const bd = popoutBorder(id, group, !id.includes(">"), rowsShown)}
|
|
1093
1136
|
<!-- Nav submenu popout — a list of links (NOT role="menu"); Tab navigates, per the disclosure-nav
|
|
@@ -1106,7 +1149,7 @@
|
|
|
1106
1149
|
: (e) => {
|
|
1107
1150
|
if (win.up || win.down) {
|
|
1108
1151
|
e.preventDefault();
|
|
1109
|
-
stepPopout(id,
|
|
1152
|
+
stepPopout(id, rows.length, Math.sign(e.deltaY) * 3);
|
|
1110
1153
|
}
|
|
1111
1154
|
}}
|
|
1112
1155
|
onkeydown={item.popout
|
|
@@ -1114,10 +1157,10 @@
|
|
|
1114
1157
|
: (e) => {
|
|
1115
1158
|
if (e.key === "ArrowDown") {
|
|
1116
1159
|
e.preventDefault();
|
|
1117
|
-
stepPopout(id,
|
|
1160
|
+
stepPopout(id, rows.length, 1);
|
|
1118
1161
|
} else if (e.key === "ArrowUp") {
|
|
1119
1162
|
e.preventDefault();
|
|
1120
|
-
stepPopout(id,
|
|
1163
|
+
stepPopout(id, rows.length, -1);
|
|
1121
1164
|
}
|
|
1122
1165
|
}}
|
|
1123
1166
|
>
|
|
@@ -1129,15 +1172,20 @@
|
|
|
1129
1172
|
{@render item.popout()}
|
|
1130
1173
|
</div>
|
|
1131
1174
|
{:else}
|
|
1132
|
-
{#if win.up}{@render arrowRow(id,
|
|
1175
|
+
{#if win.up}{@render arrowRow(id, rows.length, -1)}{/if}
|
|
1133
1176
|
<ul class="flex flex-col">
|
|
1134
|
-
{#each
|
|
1177
|
+
{#each rows.slice(win.start, win.end) as r, i (r.index)}
|
|
1178
|
+
<!-- A divider renders only BETWEEN real rows: suppress it at the window's top edge (i===0),
|
|
1179
|
+
where there's nothing above it and it would collide with the panel border. -->
|
|
1180
|
+
{#if r.dividerBefore && i > 0}
|
|
1181
|
+
<li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
|
|
1182
|
+
{/if}
|
|
1135
1183
|
<li class="list-none">
|
|
1136
|
-
{@render menuNode(
|
|
1184
|
+
{@render menuNode(r.item, `${id}>${r.index}`, group, childReserve, 1)}
|
|
1137
1185
|
</li>
|
|
1138
1186
|
{/each}
|
|
1139
1187
|
</ul>
|
|
1140
|
-
{#if win.down}{@render arrowRow(id,
|
|
1188
|
+
{#if win.down}{@render arrowRow(id, rows.length, 1)}{/if}
|
|
1141
1189
|
{/if}
|
|
1142
1190
|
</div>
|
|
1143
1191
|
{/snippet}
|
|
@@ -1203,20 +1251,29 @@
|
|
|
1203
1251
|
return here — there is no in-drawer back row by design so
|
|
1204
1252
|
the back affordance stays in a single, consistent spot. -->
|
|
1205
1253
|
{@const currentParent = stepPath[stepPath.length - 1]}
|
|
1206
|
-
{@const
|
|
1254
|
+
{@const stepRows = renderRows(currentParent.children)}
|
|
1255
|
+
{@const stepReserve = anyIcon(currentParent.children)}
|
|
1207
1256
|
<ul class="flex flex-col {scrollList}">
|
|
1208
|
-
{#each
|
|
1257
|
+
{#each stepRows as r, i (r.index)}
|
|
1258
|
+
{#if r.dividerBefore && i > 0}
|
|
1259
|
+
<li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
|
|
1260
|
+
{/if}
|
|
1209
1261
|
<li class="list-none">
|
|
1210
|
-
{@render row(
|
|
1262
|
+
{@render row(r.item, { id: `step:${r.index}`, isPopoutChild: true, reserveIcon: stepReserve })}
|
|
1211
1263
|
</li>
|
|
1212
1264
|
{/each}
|
|
1213
1265
|
</ul>
|
|
1214
1266
|
{:else}
|
|
1215
|
-
{
|
|
1267
|
+
{@const topRows = renderRows(items)}
|
|
1268
|
+
{@const bottomRows = renderRows(bottomItems)}
|
|
1269
|
+
{#if topRows.length > 0}
|
|
1216
1270
|
<ul class="flex flex-col {scrollList}">
|
|
1217
|
-
{#each
|
|
1271
|
+
{#each topRows as r, i (r.index)}
|
|
1272
|
+
{#if r.dividerBefore && i > 0}
|
|
1273
|
+
<li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
|
|
1274
|
+
{/if}
|
|
1218
1275
|
<li class="list-none">
|
|
1219
|
-
{@render menuNode(item, `top:${
|
|
1276
|
+
{@render menuNode(r.item, `top:${r.index}`, "top", railReserve, 0)}
|
|
1220
1277
|
</li>
|
|
1221
1278
|
{/each}
|
|
1222
1279
|
</ul>
|
|
@@ -1224,15 +1281,18 @@
|
|
|
1224
1281
|
|
|
1225
1282
|
<!-- With items present, the scrollable top list (flex-1) already fills the gap; only add a spacer
|
|
1226
1283
|
to push the bottom cluster down when there are no top items. -->
|
|
1227
|
-
{#if
|
|
1284
|
+
{#if topRows.length === 0 && bottomRows.length > 0}
|
|
1228
1285
|
<div class="flex-1"></div>
|
|
1229
1286
|
{/if}
|
|
1230
1287
|
|
|
1231
|
-
{#if
|
|
1288
|
+
{#if bottomRows.length > 0}
|
|
1232
1289
|
<ul class="flex flex-col shrink-0">
|
|
1233
|
-
{#each
|
|
1290
|
+
{#each bottomRows as r, i (r.index)}
|
|
1291
|
+
{#if r.dividerBefore && i > 0}
|
|
1292
|
+
<li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
|
|
1293
|
+
{/if}
|
|
1234
1294
|
<li class="list-none">
|
|
1235
|
-
{@render menuNode(item, `bottom:${
|
|
1295
|
+
{@render menuNode(r.item, `bottom:${r.index}`, "bottom", railReserve, 0)}
|
|
1236
1296
|
</li>
|
|
1237
1297
|
{/each}
|
|
1238
1298
|
</ul>
|
|
@@ -75,8 +75,9 @@ export type SideBarMenuItem = {
|
|
|
75
75
|
link?: string;
|
|
76
76
|
badge?: SideBarMenuBadge;
|
|
77
77
|
/** Child items — rendered as a fly-out popout. Recursive: a child that itself has children
|
|
78
|
-
* cascades into a further popout (or steps deeper in `stepped` mode).
|
|
79
|
-
|
|
78
|
+
* cascades into a further popout (or steps deeper in `stepped` mode). May include
|
|
79
|
+
* `{ divider: true }` entries to draw a separator between children. */
|
|
80
|
+
children?: SideBarMenuEntry[];
|
|
80
81
|
/** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
|
|
81
82
|
* row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
|
|
82
83
|
onclick?: (e: MouseEvent) => void;
|
|
@@ -94,14 +95,28 @@ export type SideBarMenuItem = {
|
|
|
94
95
|
* the default `currentPath` segment-boundary match against `link`. */
|
|
95
96
|
activeMatch?: SideBarMenuActiveRule;
|
|
96
97
|
};
|
|
98
|
+
/**
|
|
99
|
+
* A horizontal rule between adjacent rows in an `items` / `bottomItems` / `children` list — a 1px
|
|
100
|
+
* full-bleed separator (`--ui-border-color`) drawn between the two rows it sits between. It adds no
|
|
101
|
+
* navigable row and (visually) only the 1px line, so it never disturbs the fixed-height row rhythm.
|
|
102
|
+
* Leading, trailing, and consecutive dividers collapse — a divider renders ONLY between two real
|
|
103
|
+
* rows. Honoured identically in the fly-out popout, the stepped sub-view, and the inline
|
|
104
|
+
* expanded/drawer rail. Use the discriminated form `{ divider: true }` so a genuinely malformed item
|
|
105
|
+
* still falls through to the visible "malformed item" row rather than silently becoming a separator.
|
|
106
|
+
*/
|
|
107
|
+
export type SideBarMenuDivider = {
|
|
108
|
+
divider: true;
|
|
109
|
+
};
|
|
110
|
+
/** A SideBarMenu list entry: either a {@link SideBarMenuItem} or a {@link SideBarMenuDivider}. */
|
|
111
|
+
export type SideBarMenuEntry = SideBarMenuItem | SideBarMenuDivider;
|
|
97
112
|
import type { HTMLAttributes } from "svelte/elements";
|
|
98
113
|
import type { Size } from "../../types/sizes.js";
|
|
99
114
|
import { type Variant } from "../../types/variants.js";
|
|
100
115
|
import { type Breakpoint } from "../../types/breakpoints.js";
|
|
101
116
|
type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
|
|
102
117
|
type $$ComponentProps = {
|
|
103
|
-
items?:
|
|
104
|
-
bottomItems?:
|
|
118
|
+
items?: SideBarMenuEntry[];
|
|
119
|
+
bottomItems?: SideBarMenuEntry[];
|
|
105
120
|
currentPath: string;
|
|
106
121
|
size?: Size;
|
|
107
122
|
/**
|
package/dist/i18n.d.ts
CHANGED
|
@@ -62,6 +62,19 @@ export type UiMessages = {
|
|
|
62
62
|
removeFile: MsgFn<{
|
|
63
63
|
name: string;
|
|
64
64
|
}>;
|
|
65
|
+
files: Msg;
|
|
66
|
+
colName: Msg;
|
|
67
|
+
colDateModified: Msg;
|
|
68
|
+
colType: Msg;
|
|
69
|
+
colSize: Msg;
|
|
70
|
+
emptyFolder: Msg;
|
|
71
|
+
newName: Msg;
|
|
72
|
+
localDisk: Msg;
|
|
73
|
+
fileFolder: Msg;
|
|
74
|
+
genericFile: Msg;
|
|
75
|
+
fileOfType: MsgFn<{
|
|
76
|
+
ext: string;
|
|
77
|
+
}>;
|
|
65
78
|
queryHelp: Msg;
|
|
66
79
|
querySyntax: Msg;
|
|
67
80
|
casts: Msg;
|
package/dist/i18n.js
CHANGED
|
@@ -53,6 +53,17 @@ export const defaultUiMessages = {
|
|
|
53
53
|
textAreaControls: "Text area with controls",
|
|
54
54
|
uploadFiles: "Upload files: drag and drop or activate to browse",
|
|
55
55
|
removeFile: ({ name }) => `Remove ${name}`,
|
|
56
|
+
files: "Files",
|
|
57
|
+
colName: "Name",
|
|
58
|
+
colDateModified: "Date modified",
|
|
59
|
+
colType: "Type",
|
|
60
|
+
colSize: "Size",
|
|
61
|
+
emptyFolder: "This folder is empty.",
|
|
62
|
+
newName: "New name",
|
|
63
|
+
localDisk: "Local disk",
|
|
64
|
+
fileFolder: "File folder",
|
|
65
|
+
genericFile: "File",
|
|
66
|
+
fileOfType: ({ ext }) => `${ext} file`,
|
|
56
67
|
queryHelp: "Query help",
|
|
57
68
|
querySyntax: "Query syntax",
|
|
58
69
|
casts: "Casts",
|
package/dist/index.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export { CommandPalette } from "./components/CommandPalette/index.js";
|
|
|
53
53
|
export type { Command } from "./components/CommandPalette/index.js";
|
|
54
54
|
export { Tree } from "./components/Tree/index.js";
|
|
55
55
|
export type { TreeNode } from "./components/Tree/index.js";
|
|
56
|
+
export { FileList, formatSize } from "./components/FileList/index.js";
|
|
57
|
+
export type { ExplorerEntry, FileListColumn } from "./components/FileList/index.js";
|
|
56
58
|
export type { MenuItem, MenuAction, MenuSeparator } from "./data/table/types.js";
|
|
57
59
|
export { ButtonGroup } from "./components/ButtonGroup/index.js";
|
|
58
60
|
export type { ButtonGroupItem, ButtonGroupSelect } from "./components/ButtonGroup/index.js";
|
|
@@ -74,7 +76,7 @@ export { default as Disclosure2 } from "./components/Disclosure2.svelte";
|
|
|
74
76
|
export { default as Accordion2 } from "./components/Accordion2.svelte";
|
|
75
77
|
export { default as DatePicker2 } from "./components/DatePicker2.svelte";
|
|
76
78
|
export { default as SideBarMenu } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
77
|
-
export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, SideBarMenuMode, SideBarMenuActiveRule, } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
79
|
+
export type { SideBarMenuItem, SideBarMenuDivider, SideBarMenuEntry, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, SideBarMenuMode, SideBarMenuActiveRule, } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
78
80
|
export { Breadcrumbs } from "./components/Breadcrumbs/index.js";
|
|
79
81
|
export type { BreadcrumbItem } from "./components/Breadcrumbs/index.js";
|
|
80
82
|
export { default as TabStrip } from "./components/TabStrip/TabStrip.svelte";
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,7 @@ export { default as Menu } from "./components/Menu.svelte";
|
|
|
55
55
|
export { ContextMenu } from "./components/ContextMenu/index.js";
|
|
56
56
|
export { CommandPalette } from "./components/CommandPalette/index.js";
|
|
57
57
|
export { Tree } from "./components/Tree/index.js";
|
|
58
|
+
export { FileList, formatSize } from "./components/FileList/index.js";
|
|
58
59
|
export { ButtonGroup } from "./components/ButtonGroup/index.js";
|
|
59
60
|
export { default as Checkbox } from "./components/Checkbox.svelte";
|
|
60
61
|
export { default as Toggle } from "./components/Toggle.svelte";
|
package/docs/components.md
CHANGED
|
@@ -868,6 +868,28 @@ Hierarchical, expandable tree view (ARIA `tree`).
|
|
|
868
868
|
select, Home/End
|
|
869
869
|
- **Accessibility**: `role="tree"`/`treeitem` with `aria-level`/`-expanded`/`-selected`/`-disabled`
|
|
870
870
|
|
|
871
|
+
### FileList
|
|
872
|
+
|
|
873
|
+
The **details pane of a file explorer** — `Name · Date modified · Type · Size`, folders first. Shaped
|
|
874
|
+
like an OS explorer (columns collapse by the component's own width via container queries), not a generic
|
|
875
|
+
`DataTable`. **Consumer-driven**: the parent owns the data + every action; the component owns selection,
|
|
876
|
+
sort, the rename field, and the drop highlight.
|
|
877
|
+
|
|
878
|
+
- **Location**: `src/lib/components/FileList/FileList.svelte` · subpath `@spaethtech/svelte-ui/file-list`
|
|
879
|
+
- **Types**: `ExplorerEntry` (`{ key, name, kind: 'drive'|'folder'|'file', size?, modified?, flags? }`),
|
|
880
|
+
`FileListColumn`; helper `formatSize(n, locale?)`
|
|
881
|
+
- **State props (bindable)**: `selected` (`Set<string>`), `editing` (key being renamed), `sortCol`,
|
|
882
|
+
`sortDir`
|
|
883
|
+
- **Other props**: `entries`, `loading`, `error`, `emptyText` (default localized), `canDrop`, `variant`
|
|
884
|
+
(selection accent), `formatDate(iso)` (override; default `Intl.DateTimeFormat(locale)`), `class`
|
|
885
|
+
- **Callbacks** (actions): `onopen(e)` *(required)*, `oncontextmenu(ev, entry|null)` (not prevented, so
|
|
886
|
+
a wrapping `ContextMenu` opens), `ondropfiles(files, into|null)`, `ondelete(entries)`, `onrename(e)`,
|
|
887
|
+
`oncommitname(e, name|null)`, `onup()`, `onsort(col, dir)`
|
|
888
|
+
- **Interaction**: click / Ctrl+click / Shift+click select; double-click or Enter opens; F2 renames in
|
|
889
|
+
place; Delete / Backspace / Ctrl+A / arrows; drag desktop files onto the pane or a folder row
|
|
890
|
+
- **i18n**: column headers, Type labels, empty/aria strings via `UiMessages`; date/number via `locale`
|
|
891
|
+
- **Composition**: built on `Input` (rename) + `Badge` (flags); pairs with `ContextMenu` + `Tree`
|
|
892
|
+
|
|
871
893
|
### ScrollArea
|
|
872
894
|
|
|
873
895
|
Scroll container with a themed thin scrollbar.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spaethtech/svelte-ui",
|
|
3
|
-
"version": "0.21.1-dev.
|
|
3
|
+
"version": "0.21.1-dev.132.b812036",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/spaethtech/svelte-ui.git"
|
|
@@ -253,6 +253,10 @@
|
|
|
253
253
|
"types": "./dist/components/Tree/index.d.ts",
|
|
254
254
|
"svelte": "./dist/components/Tree/index.js"
|
|
255
255
|
},
|
|
256
|
+
"./file-list": {
|
|
257
|
+
"types": "./dist/components/FileList/index.d.ts",
|
|
258
|
+
"svelte": "./dist/components/FileList/index.js"
|
|
259
|
+
},
|
|
256
260
|
"./button-group": {
|
|
257
261
|
"types": "./dist/components/ButtonGroup/index.d.ts",
|
|
258
262
|
"svelte": "./dist/components/ButtonGroup/index.js"
|