@spaethtech/svelte-ui 0.21.1-dev.129.7debcd5 → 0.21.1-dev.131.6298c61

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.
@@ -23,7 +23,10 @@
23
23
  children,
24
24
  ...rest
25
25
  }: {
26
- items: MenuItem[];
26
+ /** The menu items — a static list, OR a function of the triggering event so one wrapper can build
27
+ * the menu from whatever was right-clicked (read `event.target`, a row's `data-key`, …) without
28
+ * the consumer tracking target state during the bubble. Return `[]` to suppress the menu. */
29
+ items: MenuItem[] | ((event: MouseEvent) => MenuItem[]);
27
30
  size?: Responsive<Size>;
28
31
  variant?: Variant;
29
32
  disabled?: boolean;
@@ -35,10 +38,14 @@
35
38
  let anchorEl = $state<HTMLElement>();
36
39
  let x = $state(0);
37
40
  let y = $state(0);
41
+ // The items shown for the current open — a snapshot (for the function form, computed from the event).
42
+ let resolvedItems = $state<MenuItem[]>([]);
38
43
 
39
44
  function onContextMenu(e: MouseEvent) {
40
45
  if (disabled) return;
41
46
  e.preventDefault();
47
+ resolvedItems = typeof items === "function" ? items(e) : items;
48
+ if (!resolvedItems?.length) return; // nothing to show → don't open (and let no empty menu flash)
42
49
  x = e.clientX;
43
50
  y = e.clientY;
44
51
  open = true;
@@ -57,4 +64,4 @@
57
64
  style="position: fixed; left: {x}px; top: {y}px; width: 0; height: 0; pointer-events: none;"
58
65
  ></span>
59
66
 
60
- <Menu anchor={anchorEl} bind:open {items} {size} {variant} side="bottom" align="start" />
67
+ <Menu anchor={anchorEl} bind:open items={resolvedItems} {size} {variant} side="bottom" align="start" />
@@ -5,7 +5,10 @@ import type { Variant } from "../../types/variants.js";
5
5
  import type { Size } from "../../types/sizes.js";
6
6
  import type { Responsive } from "../../types/responsive.js";
7
7
  type $$ComponentProps = {
8
- items: MenuItem[];
8
+ /** The menu items — a static list, OR a function of the triggering event so one wrapper can build
9
+ * the menu from whatever was right-clicked (read `event.target`, a row's `data-key`, …) without
10
+ * the consumer tracking target state during the bubble. Return `[]` to suppress the menu. */
11
+ items: MenuItem[] | ((event: MouseEvent) => MenuItem[]);
9
12
  size?: Responsive<Size>;
10
13
  variant?: Variant;
11
14
  disabled?: boolean;
@@ -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,3 @@
1
+ export { default as FileList } from "./FileList.svelte";
2
+ export type { ExplorerEntry, FileListColumn } from "./types.js";
3
+ export { formatSize } from "./types.js";
@@ -0,0 +1,2 @@
1
+ export { default as FileList } from "./FileList.svelte";
2
+ export { formatSize } from "./types.js";
@@ -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
+ }
@@ -256,6 +256,7 @@
256
256
  bind:this={rowRefs[f.key]}
257
257
  role="treeitem"
258
258
  data-key={f.key}
259
+ aria-label={f.node.label}
259
260
  aria-level={f.level + 1}
260
261
  aria-selected={active}
261
262
  aria-expanded={f.hasChildren ? f.open : undefined}
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";
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";
@@ -840,8 +840,11 @@ Vertical event sequence — marker + connecting line + title/time/description.
840
840
  Right-click menu over any content; opens `Menu` at the pointer.
841
841
 
842
842
  - **Location**: `src/lib/components/ContextMenu/ContextMenu.svelte`
843
- - **Props**: `items` (`MenuItem[]`), `size`, `variant`, `disabled` (leaves the native menu), `class`,
844
- `children` (the right-clickable content)
843
+ - **Props**: `items` (`MenuItem[]` **or** `(event: MouseEvent) => MenuItem[]`), `size`, `variant`,
844
+ `disabled` (leaves the native menu), `class`, `children` (the right-clickable content)
845
+ - **Dynamic items**: pass a function to build the menu from whatever was right-clicked — read
846
+ `event.target` (e.g. a row's `data-key`) and return that target's items; return `[]` to suppress the
847
+ menu. Lets one wrapper serve many targets (e.g. a `Tree`/file list) without tracking target state.
845
848
  - **Composition**: `Menu` anchored to a 0-size point at the pointer; flips/shifts at edges
846
849
 
847
850
  ### Tree
@@ -865,6 +868,28 @@ Hierarchical, expandable tree view (ARIA `tree`).
865
868
  select, Home/End
866
869
  - **Accessibility**: `role="tree"`/`treeitem` with `aria-level`/`-expanded`/`-selected`/`-disabled`
867
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
+
868
893
  ### ScrollArea
869
894
 
870
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.129.7debcd5",
3
+ "version": "0.21.1-dev.131.6298c61",
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"