@spaethtech/svelte-ui 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -152,8 +152,13 @@ Mount `<Toaster />` once at the app root, then push from anywhere with `toast.su
152
152
  All from `@spaethtech/svelte-ui` (see the shipped `docs/components.md` + `docs/usage.md` for props +
153
153
  examples):
154
154
 
155
- - **Form:** `Button` `ButtonDropdown` `Input` `Select` `Slider` `List` `TextArea` `Checkbox` `Toggle`
156
- `Radio` `Rating` · **`Slider`** (draggable number/range; `range` dual-thumb, `min`/`max`/`step`,
155
+ - **Form:** `Button` `ButtonDropdown` `Input` `Select` `Combobox` `Slider` `List` `TextArea` `Checkbox`
156
+ `Toggle` `Radio` `Rating` · **`Combobox`** (free-text autocomplete arbitrary value + suggestions,
157
+ client `options` or async `suggest`; vs `Select` which is a closed choice. Composes Input + Popup.) ·
158
+ **`TokenInput`** (multi-value entry — Enter/comma adds a removable `Chip` token, Backspace-on-empty
159
+ removes; `bind:values`, `max`, `allowDuplicates`; FieldChrome.) · **`FileUpload`** (drag-drop + click
160
+ dropzone; `bind:files`, `accept`/`multiple`/`maxSize`, image thumbnails, `progress` map → `Progress`
161
+ bar; FieldChrome.) · **`Slider`** (draggable number/range; `range` dual-thumb, `min`/`max`/`step`,
157
162
  `showValue`/`format`; FieldChrome + `variant`/`size`; keyboard + `role="slider"`) · **`FieldGroup`**
158
163
  (fieldset wrapper for radio/checkbox/toggle sets) · **`ButtonGroup`**
159
164
  (joined row/`col` of `<Button>`s as one unit — an action toolbar or a **controlled** segmented
@@ -166,6 +171,8 @@ examples):
166
171
  `percent`/`stepper`/`clamp`/`liveFormat`) `PhoneInput` (stores E.164; dep-free, inject
167
172
  `parse`/`format` for per-country)
168
173
  - **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`
174
+ · **`DateRangePicker`** (start–end range + presets rail; `bind:start`/`bind:end` ISO; composes
175
+ `Calendar mode="range"` + `Input` + `Popup`. `DatePicker range` is the bare, preset-less variant.)
169
176
  - **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
170
177
  (query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`). · **`Pagination`** (standalone
171
178
  pager: numbered buttons + ellipsis, prev/next/first/last, `bind:page`/`bind:perPage`, `total`,
@@ -195,6 +202,9 @@ examples):
195
202
  - **Layout:** `Card` `CardHeader` `CardBody` `CardFooter` · **`Grid`** (auto-placed equal-cell grid;
196
203
  `columns` + `gap`) · **`Divider`** (thin `role="separator"` rule; `orientation` h/v, optional inline
197
204
  `label`+`labelAlign`; `--ui-border-color`)
205
+ - **Flow:** **`Stepper`** (linear multi-step indicator — `steps` of `StepItem` {`label`,`description`,
206
+ `icon`}, `bind:current`, `orientation` h/v, `onstep` clickable; done/current/upcoming markers +
207
+ connectors. Panels + Next/Back are the consumer's.)
198
208
  - **Disclosure:** **`Disclosure`** (one expand/collapse section; `title`/`header`, `bind:open`, panel
199
209
  stays mounted so state is preserved) · **`Accordion`** (coordinated group — `multiple` single/multi,
200
210
  `bind:value`; children are `<Disclosure value="…">`, no per-item `bind:open`)
@@ -0,0 +1,205 @@
1
+ <!--
2
+ /**
3
+ * Combobox — a free-text autocomplete field: an `<Input>` with a suggestion `listbox`. Unlike Select
4
+ * (closed choice), the value is arbitrary text and options are suggestions. ARIA combobox pattern.
5
+ * Composes Input (FieldChrome + frame + anchor + trailing actions) + Popup. See Combobox.spec.md.
6
+ */
7
+ -->
8
+ <script lang="ts">
9
+ import type { Snippet } from "svelte";
10
+ import Input from "../Input.svelte";
11
+ import Popup from "../Popup.svelte";
12
+ import IconChevronDown from "~icons/mdi/chevron-down";
13
+ import type { Variant } from "../../types/variants.js";
14
+ import type { Size } from "../../types/sizes.js";
15
+ import type { Responsive } from "../../types/responsive.js";
16
+
17
+ let {
18
+ value = $bindable(""),
19
+ options = [],
20
+ suggest,
21
+ minChars = 1,
22
+ debounceMs = 200,
23
+ maxResults,
24
+ onselect,
25
+ variant = "secondary",
26
+ size = "md",
27
+ disabled = false,
28
+ placeholder,
29
+ icon,
30
+ label = null,
31
+ aside,
32
+ error = null,
33
+ description = null,
34
+ id,
35
+ required = false,
36
+ class: cls = "",
37
+ }: {
38
+ value?: string;
39
+ options?: string[];
40
+ suggest?: (query: string) => Promise<string[]>;
41
+ minChars?: number;
42
+ debounceMs?: number;
43
+ maxResults?: number;
44
+ onselect?: (value: string) => void;
45
+ variant?: Variant;
46
+ size?: Responsive<Size>;
47
+ disabled?: boolean;
48
+ placeholder?: string;
49
+ icon?: Snippet;
50
+ label?: string | null;
51
+ aside?: Snippet;
52
+ error?: string | Snippet | null;
53
+ description?: string | Snippet | null;
54
+ id?: string;
55
+ required?: boolean;
56
+ class?: string;
57
+ } = $props();
58
+
59
+ const baseId = $props.id();
60
+ const listId = `${baseId}-listbox`;
61
+ const optId = (i: number) => `${baseId}-opt-${i}`;
62
+
63
+ let open = $state(false);
64
+ let highlight = $state(-1);
65
+ let fieldEl = $state<HTMLDivElement>();
66
+ let inputEl = $state<HTMLInputElement>();
67
+ let asyncResults = $state<string[]>([]);
68
+
69
+ // Suggestions: async `suggest` (debounced) or client-side substring filter of `options`.
70
+ const filtered = $derived.by(() => {
71
+ let out: string[];
72
+ if (suggest) {
73
+ out = asyncResults;
74
+ } else {
75
+ const q = value.trim().toLowerCase();
76
+ out = q ? options.filter((o) => o.toLowerCase().includes(q)) : options;
77
+ }
78
+ return maxResults != null ? out.slice(0, maxResults) : out;
79
+ });
80
+
81
+ // Debounced async fetch — re-runs as `value` changes; gated by `minChars`.
82
+ $effect(() => {
83
+ if (!suggest) return;
84
+ const q = value;
85
+ if (q.trim().length < minChars) {
86
+ asyncResults = [];
87
+ return;
88
+ }
89
+ const t = setTimeout(() => {
90
+ suggest(q)
91
+ .then((r) => (asyncResults = r))
92
+ .catch(() => (asyncResults = []));
93
+ }, debounceMs);
94
+ return () => clearTimeout(t);
95
+ });
96
+
97
+ const canOpen = $derived(open && !disabled && filtered.length > 0);
98
+
99
+ function choose(v: string) {
100
+ value = v;
101
+ open = false;
102
+ highlight = -1;
103
+ onselect?.(v);
104
+ inputEl?.focus();
105
+ }
106
+
107
+ function onkeydown(e: KeyboardEvent) {
108
+ if (disabled) return;
109
+ const n = filtered.length;
110
+ switch (e.key) {
111
+ case "ArrowDown":
112
+ e.preventDefault();
113
+ open = true;
114
+ if (n) highlight = (highlight + 1) % n;
115
+ break;
116
+ case "ArrowUp":
117
+ e.preventDefault();
118
+ open = true;
119
+ if (n) highlight = (highlight - 1 + n) % n;
120
+ break;
121
+ case "Enter":
122
+ if (canOpen && highlight >= 0) {
123
+ e.preventDefault();
124
+ choose(filtered[highlight]);
125
+ }
126
+ break;
127
+ case "Escape":
128
+ open = false;
129
+ highlight = -1;
130
+ break;
131
+ case "Tab":
132
+ open = false;
133
+ break;
134
+ }
135
+ }
136
+ </script>
137
+
138
+ <Input
139
+ bind:value
140
+ bind:element={inputEl}
141
+ bind:fieldElement={fieldEl}
142
+ {variant}
143
+ {size}
144
+ {disabled}
145
+ {placeholder}
146
+ {icon}
147
+ {label}
148
+ {aside}
149
+ {error}
150
+ {description}
151
+ {id}
152
+ {required}
153
+ class={cls}
154
+ role="combobox"
155
+ aria-autocomplete="list"
156
+ aria-expanded={canOpen}
157
+ aria-controls={listId}
158
+ aria-activedescendant={canOpen && highlight >= 0 ? optId(highlight) : undefined}
159
+ autocomplete="off"
160
+ {onkeydown}
161
+ onfocus={() => (open = true)}
162
+ onblur={() => (open = false)}
163
+ oninput={() => {
164
+ open = true;
165
+ highlight = -1;
166
+ }}
167
+ >
168
+ {#snippet actions()}
169
+ <span
170
+ class="inline-flex items-center justify-center opacity-60 transition-transform duration-150 {canOpen
171
+ ? 'rotate-180'
172
+ : ''} [&_svg]:w-4 [&_svg]:h-4"
173
+ aria-hidden="true"
174
+ >
175
+ <IconChevronDown />
176
+ </span>
177
+ {/snippet}
178
+ </Input>
179
+
180
+ <Popup anchor={fieldEl} open={canOpen} side="bottom" align="start" matchWidth lightDismiss={false}>
181
+ <ul
182
+ id={listId}
183
+ role="listbox"
184
+ class="max-h-64 overflow-y-auto rounded-md border shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:var(--ui-border-color)] py-1"
185
+ >
186
+ {#each filtered as opt, i (opt + i)}
187
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
188
+ <li
189
+ id={optId(i)}
190
+ role="option"
191
+ aria-selected={i === highlight}
192
+ class="cursor-pointer px-3 py-1.5 text-sm {i === highlight
193
+ ? '[background-color:var(--ui-color-hover)]'
194
+ : ''} hover:[background-color:var(--ui-color-hover)]"
195
+ onpointerdown={(e) => {
196
+ e.preventDefault(); // keep input focused so the click lands before blur closes the list
197
+ choose(opt);
198
+ }}
199
+ onpointerenter={() => (highlight = i)}
200
+ >
201
+ {opt}
202
+ </li>
203
+ {/each}
204
+ </ul>
205
+ </Popup>
@@ -0,0 +1,28 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { Variant } from "../../types/variants.js";
3
+ import type { Size } from "../../types/sizes.js";
4
+ import type { Responsive } from "../../types/responsive.js";
5
+ type $$ComponentProps = {
6
+ value?: string;
7
+ options?: string[];
8
+ suggest?: (query: string) => Promise<string[]>;
9
+ minChars?: number;
10
+ debounceMs?: number;
11
+ maxResults?: number;
12
+ onselect?: (value: string) => void;
13
+ variant?: Variant;
14
+ size?: Responsive<Size>;
15
+ disabled?: boolean;
16
+ placeholder?: string;
17
+ icon?: Snippet;
18
+ label?: string | null;
19
+ aside?: Snippet;
20
+ error?: string | Snippet | null;
21
+ description?: string | Snippet | null;
22
+ id?: string;
23
+ required?: boolean;
24
+ class?: string;
25
+ };
26
+ declare const Combobox: import("svelte").Component<$$ComponentProps, {}, "value">;
27
+ type Combobox = ReturnType<typeof Combobox>;
28
+ export default Combobox;
@@ -0,0 +1 @@
1
+ export { default as Combobox } from "./Combobox.svelte";
@@ -0,0 +1 @@
1
+ export { default as Combobox } from "./Combobox.svelte";
@@ -0,0 +1,157 @@
1
+ <!--
2
+ /**
3
+ * DateRangePicker — a start–end range picker with a presets rail. Composes the existing `Calendar`
4
+ * (mode="range") inside an `Input`-framed trigger + `Popup`, exposing ergonomic bind:start / bind:end
5
+ * (ISO). See DateRangePicker.spec.md.
6
+ */
7
+ -->
8
+ <script lang="ts" module>
9
+ export type RangePreset = { label: string; start: string; end: string };
10
+ </script>
11
+
12
+ <script lang="ts">
13
+ import type { Snippet } from "svelte";
14
+ import Input from "../Input.svelte";
15
+ import Popup from "../Popup.svelte";
16
+ import Button from "../Button.svelte";
17
+ import Calendar from "../Calendar.svelte";
18
+ import IconCalendar from "~icons/mdi/calendar-outline";
19
+ import type { Variant } from "../../types/variants.js";
20
+ import type { Size } from "../../types/sizes.js";
21
+ import type { Responsive } from "../../types/responsive.js";
22
+
23
+ // Local ISO (avoids the UTC shift of toISOString near midnight).
24
+ const iso = (d: Date) =>
25
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
26
+ function defaultPresets(): RangePreset[] {
27
+ const t = new Date();
28
+ t.setHours(0, 0, 0, 0);
29
+ const shift = (n: number) => {
30
+ const d = new Date(t);
31
+ d.setDate(d.getDate() + n);
32
+ return iso(d);
33
+ };
34
+ const today = iso(t);
35
+ const monthStart = iso(new Date(t.getFullYear(), t.getMonth(), 1));
36
+ return [
37
+ { label: "Today", start: today, end: today },
38
+ { label: "Yesterday", start: shift(-1), end: shift(-1) },
39
+ { label: "Last 7 days", start: shift(-6), end: today },
40
+ { label: "Last 30 days", start: shift(-29), end: today },
41
+ { label: "This month", start: monthStart, end: today },
42
+ ];
43
+ }
44
+
45
+ let {
46
+ start = $bindable(""),
47
+ end = $bindable(""),
48
+ min,
49
+ max,
50
+ presets,
51
+ variant = "secondary",
52
+ size = "md",
53
+ disabled = false,
54
+ placeholder = "Select dates",
55
+ format,
56
+ label = null,
57
+ aside,
58
+ error = null,
59
+ description = null,
60
+ id,
61
+ required = false,
62
+ class: cls = "",
63
+ }: {
64
+ start?: string;
65
+ end?: string;
66
+ min?: string;
67
+ max?: string;
68
+ presets?: RangePreset[];
69
+ variant?: Variant;
70
+ size?: Responsive<Size>;
71
+ disabled?: boolean;
72
+ placeholder?: string;
73
+ format?: (iso: string) => string;
74
+ label?: string | null;
75
+ aside?: Snippet;
76
+ error?: string | Snippet | null;
77
+ description?: string | Snippet | null;
78
+ id?: string;
79
+ required?: boolean;
80
+ class?: string;
81
+ } = $props();
82
+
83
+ const rail = $derived(presets ?? defaultPresets());
84
+ let open = $state(false);
85
+ let fieldEl = $state<HTMLDivElement>();
86
+
87
+ const fmt = (i: string): string => {
88
+ if (format) return format(i);
89
+ const [y, m, d] = i.split("-").map(Number);
90
+ return new Date(y, m - 1, d).toLocaleDateString(undefined, {
91
+ month: "short",
92
+ day: "numeric",
93
+ year: "numeric",
94
+ });
95
+ };
96
+ const display = $derived(
97
+ start && end
98
+ ? `${fmt(start)} – ${fmt(end)}`
99
+ : start
100
+ ? `${fmt(start)} – …`
101
+ : "",
102
+ );
103
+
104
+ function applyPreset(p: RangePreset) {
105
+ start = p.start;
106
+ end = p.end;
107
+ open = false;
108
+ }
109
+ </script>
110
+
111
+ <Input
112
+ value={display}
113
+ readonly
114
+ {variant}
115
+ {size}
116
+ {disabled}
117
+ {placeholder}
118
+ {label}
119
+ {aside}
120
+ {error}
121
+ {description}
122
+ {id}
123
+ {required}
124
+ class={cls}
125
+ icon={calIcon}
126
+ bind:fieldElement={fieldEl}
127
+ onclick={() => (disabled ? null : (open = !open))}
128
+ />
129
+
130
+ {#snippet calIcon()}<IconCalendar />{/snippet}
131
+
132
+ <Popup anchor={fieldEl} bind:open side="bottom" align="start" lightDismiss>
133
+ <div
134
+ class="flex overflow-hidden rounded-md border shadow-lg [background-color:var(--ui-color-background)] [border-color:var(--ui-border-color)]"
135
+ >
136
+ {#if rail.length}
137
+ <div class="flex flex-col gap-0.5 border-r p-2 [border-color:var(--ui-border-color)]">
138
+ {#each rail as p (p.label)}
139
+ <Button text={p.label} variant="ghost" size="sm" onclick={() => applyPreset(p)} />
140
+ {/each}
141
+ </div>
142
+ {/if}
143
+ <div class="p-2">
144
+ <Calendar
145
+ mode="range"
146
+ bind:start
147
+ bind:end
148
+ {min}
149
+ {max}
150
+ {variant}
151
+ onrange={(r) => {
152
+ if (r.start && r.end) open = false;
153
+ }}
154
+ />
155
+ </div>
156
+ </div>
157
+ </Popup>
@@ -0,0 +1,31 @@
1
+ export type RangePreset = {
2
+ label: string;
3
+ start: string;
4
+ end: string;
5
+ };
6
+ import type { Snippet } from "svelte";
7
+ import type { Variant } from "../../types/variants.js";
8
+ import type { Size } from "../../types/sizes.js";
9
+ import type { Responsive } from "../../types/responsive.js";
10
+ type $$ComponentProps = {
11
+ start?: string;
12
+ end?: string;
13
+ min?: string;
14
+ max?: string;
15
+ presets?: RangePreset[];
16
+ variant?: Variant;
17
+ size?: Responsive<Size>;
18
+ disabled?: boolean;
19
+ placeholder?: string;
20
+ format?: (iso: string) => string;
21
+ label?: string | null;
22
+ aside?: Snippet;
23
+ error?: string | Snippet | null;
24
+ description?: string | Snippet | null;
25
+ id?: string;
26
+ required?: boolean;
27
+ class?: string;
28
+ };
29
+ declare const DateRangePicker: import("svelte").Component<$$ComponentProps, {}, "start" | "end">;
30
+ type DateRangePicker = ReturnType<typeof DateRangePicker>;
31
+ export default DateRangePicker;
@@ -0,0 +1,2 @@
1
+ export { default as DateRangePicker } from "./DateRangePicker.svelte";
2
+ export type { RangePreset } from "./DateRangePicker.svelte";
@@ -0,0 +1 @@
1
+ export { default as DateRangePicker } from "./DateRangePicker.svelte";
@@ -0,0 +1,212 @@
1
+ <!--
2
+ /**
3
+ * FileUpload — a drag-and-drop + click file field. A dropzone that accepts dropped files or opens the
4
+ * native picker, then lists them with size, an image thumbnail, a remove ×, and an optional Progress
5
+ * bar (driven by the consumer's upload). Manages selection (bind:files); the upload is the consumer's.
6
+ * Wears FieldChrome; composes Button + Progress. See FileUpload.spec.md.
7
+ */
8
+ -->
9
+ <script lang="ts">
10
+ import { onDestroy, untrack } from "svelte";
11
+ import type { Snippet } from "svelte";
12
+ import FieldChrome, { nextFieldId } from "../FieldChrome.svelte";
13
+ import Button from "../Button.svelte";
14
+ import Progress from "../Progress/Progress.svelte";
15
+ import IconUpload from "~icons/mdi/cloud-upload-outline";
16
+ import IconFile from "~icons/mdi/file-outline";
17
+ import IconClose from "~icons/mdi/close";
18
+ import type { Variant } from "../../types/variants.js";
19
+ import { variantToken } from "../../types/variants.js";
20
+ import type { Size } from "../../types/sizes.js";
21
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
22
+
23
+ let {
24
+ files = $bindable([]),
25
+ accept,
26
+ multiple = false,
27
+ maxSize,
28
+ progress,
29
+ disabled = false,
30
+ onfiles,
31
+ variant = "primary",
32
+ size = "md",
33
+ label = null,
34
+ aside,
35
+ error = null,
36
+ description = null,
37
+ id,
38
+ required = false,
39
+ class: cls = "",
40
+ }: {
41
+ files?: File[];
42
+ accept?: string;
43
+ multiple?: boolean;
44
+ maxSize?: number;
45
+ progress?: Record<string, number>;
46
+ disabled?: boolean;
47
+ onfiles?: (files: File[]) => void;
48
+ variant?: Variant;
49
+ size?: Responsive<Size>;
50
+ label?: string | null;
51
+ aside?: Snippet;
52
+ error?: string | Snippet | null;
53
+ description?: string | Snippet | null;
54
+ id?: string;
55
+ required?: boolean;
56
+ class?: string;
57
+ } = $props();
58
+
59
+ const controlId = id ?? nextFieldId();
60
+ let inputEl = $state<HTMLInputElement>();
61
+ let dragActive = $state(false);
62
+ let reject = $state("");
63
+
64
+ const humanSize = (n: number): string =>
65
+ n < 1024
66
+ ? `${n} B`
67
+ : n < 1024 * 1024
68
+ ? `${(n / 1024).toFixed(1)} KB`
69
+ : `${(n / 1024 / 1024).toFixed(1)} MB`;
70
+
71
+ // Image thumbnails via object URLs, revoked when a file leaves / on unmount.
72
+ let urls = $state<Map<File, string>>(new Map());
73
+ // Depend ONLY on `files`; read/write `urls` untracked so the effect doesn't retrigger itself.
74
+ $effect(() => {
75
+ const cur = files;
76
+ untrack(() => {
77
+ const prev = urls;
78
+ const next = new Map<File, string>();
79
+ for (const f of cur) {
80
+ if (f.type.startsWith("image/")) next.set(f, prev.get(f) ?? URL.createObjectURL(f));
81
+ }
82
+ for (const [f, u] of prev) if (!next.has(f)) URL.revokeObjectURL(u);
83
+ urls = next;
84
+ });
85
+ });
86
+ onDestroy(() => {
87
+ for (const u of urls.values()) URL.revokeObjectURL(u);
88
+ });
89
+
90
+ function add(list: FileList | File[]) {
91
+ if (disabled) return;
92
+ reject = "";
93
+ let out = multiple ? [...files] : [];
94
+ for (const f of Array.from(list)) {
95
+ if (maxSize != null && f.size > maxSize) {
96
+ reject = `"${f.name}" exceeds ${humanSize(maxSize)}.`;
97
+ continue;
98
+ }
99
+ if (out.some((e) => e.name === f.name && e.size === f.size)) continue;
100
+ out.push(f);
101
+ if (!multiple) break;
102
+ }
103
+ files = out;
104
+ onfiles?.(files);
105
+ }
106
+ function removeAt(i: number) {
107
+ files = files.filter((_, j) => j !== i);
108
+ onfiles?.(files);
109
+ }
110
+ function openPicker() {
111
+ if (!disabled) inputEl?.click();
112
+ }
113
+
114
+ const padMap: Record<Size, string> = { sm: "p-4 text-xs", md: "p-6 text-sm", lg: "p-8 text-base" };
115
+ </script>
116
+
117
+ <FieldChrome {label} {aside} error={error ?? (reject || null)} {description} {required} {controlId} {size} class={cls}>
118
+ {#snippet control({ describedBy })}
119
+ <div class="flex flex-col gap-2">
120
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
121
+ <div
122
+ role="button"
123
+ tabindex={disabled ? -1 : 0}
124
+ aria-label="Upload files: drag and drop or activate to browse"
125
+ aria-describedby={describedBy}
126
+ class="flex flex-col items-center justify-center gap-2 rounded-[var(--ui-border-radius)] border-2 border-dashed text-center transition-colors {responsiveClasses(
127
+ size,
128
+ padMap,
129
+ )} {disabled
130
+ ? 'opacity-50 pointer-events-none'
131
+ : 'cursor-pointer hover:[background-color:var(--ui-color-surface)]'}"
132
+ style="border-color: {dragActive
133
+ ? `var(${variantToken[variant]})`
134
+ : 'var(--ui-border-color)'};{dragActive ? ' background-color: var(--ui-color-surface);' : ''}"
135
+ ondragover={(e) => {
136
+ e.preventDefault();
137
+ if (!disabled) dragActive = true;
138
+ }}
139
+ ondragleave={() => (dragActive = false)}
140
+ ondrop={(e) => {
141
+ e.preventDefault();
142
+ dragActive = false;
143
+ if (e.dataTransfer?.files.length) add(e.dataTransfer.files);
144
+ }}
145
+ onclick={openPicker}
146
+ onkeydown={(e) => {
147
+ if (e.key === "Enter" || e.key === " ") {
148
+ e.preventDefault();
149
+ openPicker();
150
+ }
151
+ }}
152
+ >
153
+ <span class="[&_svg]:w-8 [&_svg]:h-8 [color:var(--ui-color-secondary)]"><IconUpload /></span>
154
+ <div>
155
+ <span class="font-medium [color:var(--ui-color-text)]">Drop files here</span>
156
+ <span class="[color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]"> or</span>
157
+ </div>
158
+ <Button text="Browse" {variant} {size} onclick={openPicker} />
159
+ </div>
160
+
161
+ <input
162
+ bind:this={inputEl}
163
+ id={controlId}
164
+ type="file"
165
+ {accept}
166
+ {multiple}
167
+ {disabled}
168
+ class="sr-only"
169
+ onchange={(e) => {
170
+ const t = e.currentTarget;
171
+ if (t.files?.length) add(t.files);
172
+ t.value = ""; // allow re-selecting the same file
173
+ }}
174
+ />
175
+
176
+ {#if files.length}
177
+ <ul class="flex flex-col gap-2">
178
+ {#each files as file, i (file.name + file.size)}
179
+ <li
180
+ class="flex items-center gap-3 rounded-[var(--ui-border-radius)] border p-2 [border-color:var(--ui-border-color)]"
181
+ >
182
+ {#if urls.get(file)}
183
+ <img src={urls.get(file)} alt={file.name} class="w-10 h-10 rounded object-cover shrink-0" />
184
+ {:else}
185
+ <span class="inline-flex w-10 h-10 items-center justify-center shrink-0 [&_svg]:w-6 [&_svg]:h-6 [color:var(--ui-color-secondary)]"><IconFile /></span>
186
+ {/if}
187
+ <div class="min-w-0 flex-1">
188
+ <div class="truncate text-sm [color:var(--ui-color-text)]">{file.name}</div>
189
+ <div class="text-xs [color:color-mix(in_srgb,var(--ui-color-text)_55%,transparent)]">
190
+ {humanSize(file.size)}
191
+ </div>
192
+ {#if progress && progress[file.name] != null}
193
+ <div class="mt-1"><Progress value={progress[file.name]} {variant} size="sm" /></div>
194
+ {/if}
195
+ </div>
196
+ <Button
197
+ title="Remove {file.name}"
198
+ aria-label="Remove {file.name}"
199
+ variant="ghost"
200
+ size="sm"
201
+ icon={removeIcon}
202
+ onclick={() => removeAt(i)}
203
+ />
204
+ </li>
205
+ {/each}
206
+ </ul>
207
+ {/if}
208
+ </div>
209
+ {/snippet}
210
+ </FieldChrome>
211
+
212
+ {#snippet removeIcon()}<IconClose />{/snippet}