@orochibraru/ui 1.0.0 → 1.0.1
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/README.md +84 -3
- package/dist/components/blocks/accent-toggle.svelte +7 -3
- package/dist/components/blocks/accent-toggle.svelte.d.ts +1 -1
- package/dist/components/blocks/bottom-nav.svelte +1 -1
- package/dist/components/blocks/command-palette.svelte +268 -0
- package/dist/components/blocks/command-palette.svelte.d.ts +37 -0
- package/dist/components/blocks/error-page.svelte +23 -7
- package/dist/components/blocks/error-page.svelte.d.ts +4 -0
- package/dist/components/blocks/fab.svelte +1 -1
- package/dist/components/blocks/install-prompt.svelte +14 -8
- package/dist/components/blocks/install-prompt.svelte.d.ts +2 -1
- package/dist/components/blocks/music-player/labelled-slider.svelte +3 -12
- package/dist/components/blocks/music-player/labelled-slider.svelte.d.ts +1 -2
- package/dist/components/blocks/music-player.svelte +13 -12
- package/dist/components/blocks/otp-field.svelte +77 -33
- package/dist/components/blocks/otp-field.svelte.d.ts +7 -0
- package/dist/components/blocks/password-input.svelte +56 -16
- package/dist/components/blocks/password-input.svelte.d.ts +3 -0
- package/dist/components/blocks/stepper/stepper-phone-header.svelte +93 -0
- package/dist/components/blocks/stepper/stepper-phone-header.svelte.d.ts +13 -0
- package/dist/components/blocks/stepper.svelte +175 -0
- package/dist/components/blocks/stepper.svelte.d.ts +30 -0
- package/dist/components/blocks/time-range-picker.svelte +229 -0
- package/dist/components/blocks/time-range-picker.svelte.d.ts +33 -0
- package/dist/components/blocks/time-series-chart/data-table.svelte +43 -0
- package/dist/components/blocks/time-series-chart/data-table.svelte.d.ts +15 -0
- package/dist/components/blocks/time-series-chart/legend.svelte +23 -0
- package/dist/components/blocks/time-series-chart/legend.svelte.d.ts +10 -0
- package/dist/components/blocks/time-series-chart/marks.svelte +118 -0
- package/dist/components/blocks/time-series-chart/marks.svelte.d.ts +29 -0
- package/dist/components/blocks/time-series-chart/messages.d.ts +13 -0
- package/dist/components/blocks/time-series-chart/messages.js +12 -0
- package/dist/components/blocks/time-series-chart/readout.svelte +44 -0
- package/dist/components/blocks/time-series-chart/readout.svelte.d.ts +15 -0
- package/dist/components/blocks/time-series-chart/scale.d.ts +24 -0
- package/dist/components/blocks/time-series-chart/scale.js +136 -0
- package/dist/components/blocks/time-series-chart/time.d.ts +7 -0
- package/dist/components/blocks/time-series-chart/time.js +96 -0
- package/dist/components/blocks/time-series-chart.svelte +492 -0
- package/dist/components/blocks/time-series-chart.svelte.d.ts +18 -0
- package/dist/components/blocks/top-loading-bar.svelte +1 -1
- package/dist/components/blocks/video-player/center-controls.svelte +7 -7
- package/dist/components/blocks/video-player/control-bar.svelte +41 -35
- package/dist/components/blocks/video-player/control-button.svelte +1 -1
- package/dist/components/blocks/video-player/option-menu.svelte +1 -1
- package/dist/components/blocks/video-player/scrub-bar.svelte +39 -12
- package/dist/components/blocks/video-player/scrub-bar.svelte.d.ts +2 -0
- package/dist/components/blocks/video-player.svelte +2 -2
- package/dist/components/kit/accordion.svelte +70 -0
- package/dist/components/kit/accordion.svelte.d.ts +37 -0
- package/dist/components/kit/avatar-group.svelte +84 -0
- package/dist/components/kit/avatar-group.svelte.d.ts +14 -0
- package/dist/components/kit/bar-list.svelte +104 -0
- package/dist/components/kit/bar-list.svelte.d.ts +22 -0
- package/dist/components/kit/breadcrumbs.svelte +102 -0
- package/dist/components/kit/breadcrumbs.svelte.d.ts +14 -0
- package/dist/components/kit/checkbox-list.svelte +98 -0
- package/dist/components/kit/checkbox-list.svelte.d.ts +15 -0
- package/dist/components/kit/checkbox.svelte +84 -0
- package/dist/components/kit/checkbox.svelte.d.ts +8 -0
- package/dist/components/kit/confirm-dialog.svelte +127 -0
- package/dist/components/kit/confirm-dialog.svelte.d.ts +19 -0
- package/dist/components/kit/data-table.svelte +280 -0
- package/dist/components/kit/data-table.svelte.d.ts +46 -0
- package/dist/components/kit/date-field.svelte +210 -0
- package/dist/components/kit/date-field.svelte.d.ts +14 -0
- package/dist/components/kit/field-frame.svelte +71 -0
- package/dist/components/kit/field-frame.svelte.d.ts +26 -0
- package/dist/components/kit/fieldset-frame.svelte +69 -0
- package/dist/components/kit/fieldset-frame.svelte.d.ts +14 -0
- package/dist/components/kit/file-dropzone/files.d.ts +14 -0
- package/dist/components/kit/file-dropzone/files.js +69 -0
- package/dist/components/kit/file-dropzone.svelte +253 -0
- package/dist/components/kit/file-dropzone.svelte.d.ts +25 -0
- package/dist/components/kit/form.svelte +335 -0
- package/dist/components/kit/form.svelte.d.ts +61 -0
- package/dist/components/kit/input.svelte +73 -0
- package/dist/components/kit/input.svelte.d.ts +9 -0
- package/dist/components/kit/menu.svelte +163 -0
- package/dist/components/kit/menu.svelte.d.ts +28 -0
- package/dist/components/kit/pagination.svelte +134 -0
- package/dist/components/kit/pagination.svelte.d.ts +17 -0
- package/dist/components/kit/radio-list.svelte +131 -0
- package/dist/components/kit/radio-list.svelte.d.ts +15 -0
- package/dist/components/kit/segmented-control/segment.svelte +47 -0
- package/dist/components/kit/segmented-control/segment.svelte.d.ts +11 -0
- package/dist/components/kit/segmented-control.svelte +70 -0
- package/dist/components/kit/segmented-control.svelte.d.ts +16 -0
- package/dist/components/kit/select.svelte +244 -0
- package/dist/components/kit/select.svelte.d.ts +26 -0
- package/dist/components/kit/status-badge.svelte +54 -0
- package/dist/components/kit/status-badge.svelte.d.ts +31 -0
- package/dist/components/kit/switch.svelte +84 -0
- package/dist/components/kit/switch.svelte.d.ts +8 -0
- package/dist/components/kit/tabs.svelte +86 -0
- package/dist/components/kit/tabs.svelte.d.ts +20 -0
- package/dist/components/kit/textarea.svelte +92 -0
- package/dist/components/kit/textarea.svelte.d.ts +11 -0
- package/dist/components/ui/drawer/drawer-content.svelte +1 -1
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
- package/dist/components/ui/sonner/sonner.svelte +2 -4
- package/dist/mcp-content.json +1 -1
- package/dist/mcp.js +11 -11
- package/dist/server/hooks.d.ts +9 -0
- package/dist/server/hooks.js +41 -0
- package/dist/server/logger.d.ts +39 -0
- package/dist/server/logger.js +144 -0
- package/dist/server/telemetry.d.ts +23 -0
- package/dist/server/telemetry.js +82 -0
- package/dist/theme.css +18 -6
- package/package.json +40 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type { FileRejection } from "./file-dropzone/files.js";
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import FileIcon from "@lucide/svelte/icons/file";
|
|
7
|
+
import FileAudioIcon from "@lucide/svelte/icons/file-headphone";
|
|
8
|
+
import FileImageIcon from "@lucide/svelte/icons/file-image";
|
|
9
|
+
import FileVideoIcon from "@lucide/svelte/icons/file-play";
|
|
10
|
+
import FileTextIcon from "@lucide/svelte/icons/file-text";
|
|
11
|
+
import FolderUpIcon from "@lucide/svelte/icons/folder-up";
|
|
12
|
+
import UploadIcon from "@lucide/svelte/icons/upload";
|
|
13
|
+
import XIcon from "@lucide/svelte/icons/x";
|
|
14
|
+
|
|
15
|
+
import { Button } from "../ui/button/index.js";
|
|
16
|
+
import { cn } from "../../utils.js";
|
|
17
|
+
|
|
18
|
+
import FieldFrame, { errorList, type FieldProps } from "./field-frame.svelte";
|
|
19
|
+
import {
|
|
20
|
+
displaySize,
|
|
21
|
+
type FileRejection,
|
|
22
|
+
filesIn,
|
|
23
|
+
rejectionOf,
|
|
24
|
+
} from "./file-dropzone/files.js";
|
|
25
|
+
|
|
26
|
+
const defaultMessages = {
|
|
27
|
+
drop: "Drop files here, or click to choose",
|
|
28
|
+
tap: "Tap to choose files",
|
|
29
|
+
folder: "Or choose a folder",
|
|
30
|
+
maxFiles: (count: number) =>
|
|
31
|
+
count === 1 ? "One file" : `Up to ${count} files`,
|
|
32
|
+
maxFileSize: (size: string) => `${size} max each`,
|
|
33
|
+
remove: (name: string) => `Remove ${name}`,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Props = FieldProps & {
|
|
37
|
+
files?: File[];
|
|
38
|
+
accept?: string;
|
|
39
|
+
maxFiles?: number;
|
|
40
|
+
maxFileSize?: number;
|
|
41
|
+
folders?: boolean;
|
|
42
|
+
onfiles?: (added: File[]) => void;
|
|
43
|
+
onreject?: (rejected: { file: File; reason: FileRejection }) => void;
|
|
44
|
+
messages?: Partial<typeof defaultMessages>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let {
|
|
48
|
+
files = $bindable([]),
|
|
49
|
+
name,
|
|
50
|
+
label,
|
|
51
|
+
description,
|
|
52
|
+
error,
|
|
53
|
+
required = false,
|
|
54
|
+
disabled = false,
|
|
55
|
+
id,
|
|
56
|
+
class: className,
|
|
57
|
+
accept,
|
|
58
|
+
maxFiles,
|
|
59
|
+
maxFileSize,
|
|
60
|
+
folders = false,
|
|
61
|
+
onfiles,
|
|
62
|
+
onreject,
|
|
63
|
+
messages = {},
|
|
64
|
+
}: Props = $props();
|
|
65
|
+
|
|
66
|
+
const text = $derived({ ...defaultMessages, ...messages });
|
|
67
|
+
const generatedId = $props.id();
|
|
68
|
+
const inputId = $derived(id ?? generatedId);
|
|
69
|
+
const invalid = $derived(errorList(error).length > 0);
|
|
70
|
+
const full = $derived(maxFiles !== undefined && files.length >= maxFiles);
|
|
71
|
+
const blocked = $derived(disabled || full);
|
|
72
|
+
const limits = $derived(
|
|
73
|
+
[
|
|
74
|
+
maxFiles !== undefined && text.maxFiles(maxFiles),
|
|
75
|
+
maxFileSize !== undefined && text.maxFileSize(displaySize(maxFileSize)),
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(", "),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
let input = $state<HTMLInputElement | null>(null);
|
|
82
|
+
let dragDepth = $state(0);
|
|
83
|
+
|
|
84
|
+
$effect(() => {
|
|
85
|
+
if (!input || typeof DataTransfer === "undefined") {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const transfer = new DataTransfer();
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
transfer.items.add(file);
|
|
91
|
+
}
|
|
92
|
+
input.files = transfer.files;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function add(incoming: File[]) {
|
|
96
|
+
const accepted: File[] = [];
|
|
97
|
+
for (const file of incoming) {
|
|
98
|
+
const reason = rejectionOf(file, files.length + accepted.length + 1, {
|
|
99
|
+
accept,
|
|
100
|
+
maxFiles,
|
|
101
|
+
maxFileSize,
|
|
102
|
+
});
|
|
103
|
+
if (reason) {
|
|
104
|
+
onreject?.({ file, reason });
|
|
105
|
+
} else {
|
|
106
|
+
accepted.push(file);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (accepted.length > 0) {
|
|
110
|
+
files = [...files, ...accepted];
|
|
111
|
+
onfiles?.(accepted);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function remove(index: number) {
|
|
116
|
+
files = files.filter((_, position) => position !== index);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function ondrop(event: DragEvent) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
dragDepth = 0;
|
|
122
|
+
if (blocked || !event.dataTransfer) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const entries = Array.from(event.dataTransfer.items)
|
|
126
|
+
.map((item) => item.webkitGetAsEntry?.())
|
|
127
|
+
.filter((entry): entry is FileSystemEntry => Boolean(entry));
|
|
128
|
+
if (folders && entries.some((entry) => entry.isDirectory)) {
|
|
129
|
+
add((await Promise.all(entries.map(filesIn))).flat());
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
add(Array.from(event.dataTransfer.files));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function onpick(event: Event & { currentTarget: HTMLInputElement }) {
|
|
136
|
+
const picked = Array.from(event.currentTarget.files ?? []);
|
|
137
|
+
event.currentTarget.value = "";
|
|
138
|
+
files = [...files];
|
|
139
|
+
add(picked);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function iconOf(file: File) {
|
|
143
|
+
if (file.type.startsWith("image/")) {
|
|
144
|
+
return FileImageIcon;
|
|
145
|
+
}
|
|
146
|
+
if (file.type.startsWith("video/")) {
|
|
147
|
+
return FileVideoIcon;
|
|
148
|
+
}
|
|
149
|
+
if (file.type.startsWith("audio/")) {
|
|
150
|
+
return FileAudioIcon;
|
|
151
|
+
}
|
|
152
|
+
return file.type.startsWith("text/") || file.type === "application/pdf"
|
|
153
|
+
? FileTextIcon
|
|
154
|
+
: FileIcon;
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<FieldFrame
|
|
159
|
+
id={inputId}
|
|
160
|
+
{label}
|
|
161
|
+
{description}
|
|
162
|
+
{error}
|
|
163
|
+
{required}
|
|
164
|
+
class={className}
|
|
165
|
+
>
|
|
166
|
+
<label
|
|
167
|
+
for={inputId}
|
|
168
|
+
aria-disabled={blocked || undefined}
|
|
169
|
+
data-dragging={dragDepth > 0 || undefined}
|
|
170
|
+
data-invalid={invalid || undefined}
|
|
171
|
+
class="border-input hover:bg-accent/40 has-focus-visible:ring-ring/50 data-dragging:border-primary data-dragging:bg-primary/5 data-invalid:border-destructive flex min-h-40 cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed bg-white p-6 text-center transition-colors has-focus-visible:ring-3 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:bg-transparent"
|
|
172
|
+
ondragenter={(event) => {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
dragDepth += 1;
|
|
175
|
+
}}
|
|
176
|
+
ondragover={(event) => event.preventDefault()}
|
|
177
|
+
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
|
178
|
+
{ondrop}
|
|
179
|
+
>
|
|
180
|
+
<span
|
|
181
|
+
class="bg-accent text-accent-foreground flex size-12 items-center justify-center rounded-2xl"
|
|
182
|
+
>
|
|
183
|
+
<UploadIcon class="size-6" />
|
|
184
|
+
</span>
|
|
185
|
+
<span class="font-medium">
|
|
186
|
+
<span class="max-md:hidden">{text.drop}</span>
|
|
187
|
+
<span class="md:hidden">{text.tap}</span>
|
|
188
|
+
</span>
|
|
189
|
+
{#if limits}
|
|
190
|
+
<span class="text-muted-foreground text-sm">{limits}</span>
|
|
191
|
+
{/if}
|
|
192
|
+
<input
|
|
193
|
+
bind:this={input}
|
|
194
|
+
id={inputId}
|
|
195
|
+
{name}
|
|
196
|
+
type="file"
|
|
197
|
+
{accept}
|
|
198
|
+
multiple={maxFiles !== 1}
|
|
199
|
+
disabled={blocked}
|
|
200
|
+
aria-invalid={invalid || undefined}
|
|
201
|
+
aria-describedby={invalid
|
|
202
|
+
? `${inputId}-error`
|
|
203
|
+
: description
|
|
204
|
+
? `${inputId}-description`
|
|
205
|
+
: undefined}
|
|
206
|
+
class="sr-only"
|
|
207
|
+
onchange={onpick}
|
|
208
|
+
/>
|
|
209
|
+
</label>
|
|
210
|
+
{#if folders}
|
|
211
|
+
<label
|
|
212
|
+
class="text-primary has-focus-visible:ring-ring/50 inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-lg text-sm font-medium has-focus-visible:ring-3"
|
|
213
|
+
>
|
|
214
|
+
<FolderUpIcon class="size-4" />
|
|
215
|
+
{text.folder}
|
|
216
|
+
<input
|
|
217
|
+
type="file"
|
|
218
|
+
webkitdirectory
|
|
219
|
+
disabled={blocked}
|
|
220
|
+
class="sr-only"
|
|
221
|
+
onchange={onpick}
|
|
222
|
+
/>
|
|
223
|
+
</label>
|
|
224
|
+
{/if}
|
|
225
|
+
{#if files.length > 0}
|
|
226
|
+
<ul class="border-border divide-border divide-y rounded-2xl border">
|
|
227
|
+
{#each files as file, index (`${file.name}-${file.size}-${index}`)}
|
|
228
|
+
{@const Icon = iconOf(file)}
|
|
229
|
+
<li class="flex min-h-14 items-center gap-3 py-2 pr-1.5 pl-3">
|
|
230
|
+
<Icon class="text-muted-foreground size-5 shrink-0" />
|
|
231
|
+
<span class="min-w-0 flex-1">
|
|
232
|
+
<span class="block truncate text-sm font-medium"
|
|
233
|
+
>{file.webkitRelativePath || file.name}</span
|
|
234
|
+
>
|
|
235
|
+
<span class="text-muted-foreground block text-xs tabular"
|
|
236
|
+
>{displaySize(file.size)}</span
|
|
237
|
+
>
|
|
238
|
+
</span>
|
|
239
|
+
<Button
|
|
240
|
+
variant="ghost"
|
|
241
|
+
size="icon"
|
|
242
|
+
class={cn("size-11 rounded-full md:size-9")}
|
|
243
|
+
aria-label={text.remove(file.name)}
|
|
244
|
+
{disabled}
|
|
245
|
+
onclick={() => remove(index)}
|
|
246
|
+
>
|
|
247
|
+
<XIcon />
|
|
248
|
+
</Button>
|
|
249
|
+
</li>
|
|
250
|
+
{/each}
|
|
251
|
+
</ul>
|
|
252
|
+
{/if}
|
|
253
|
+
</FieldFrame>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type { FileRejection } from "./file-dropzone/files.js";
|
|
2
|
+
import { type FieldProps } from "./field-frame.svelte";
|
|
3
|
+
import { type FileRejection } from "./file-dropzone/files.js";
|
|
4
|
+
declare const FileDropzone: import("svelte").Component<FieldProps & {
|
|
5
|
+
files?: File[];
|
|
6
|
+
accept?: string;
|
|
7
|
+
maxFiles?: number;
|
|
8
|
+
maxFileSize?: number;
|
|
9
|
+
folders?: boolean;
|
|
10
|
+
onfiles?: (added: File[]) => void;
|
|
11
|
+
onreject?: (rejected: {
|
|
12
|
+
file: File;
|
|
13
|
+
reason: FileRejection;
|
|
14
|
+
}) => void;
|
|
15
|
+
messages?: Partial<{
|
|
16
|
+
drop: string;
|
|
17
|
+
tap: string;
|
|
18
|
+
folder: string;
|
|
19
|
+
maxFiles: (count: number) => string;
|
|
20
|
+
maxFileSize: (size: string) => string;
|
|
21
|
+
remove: (name: string) => string;
|
|
22
|
+
}>;
|
|
23
|
+
}, {}, "files">;
|
|
24
|
+
type FileDropzone = ReturnType<typeof FileDropzone>;
|
|
25
|
+
export default FileDropzone;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Component } from "svelte";
|
|
3
|
+
|
|
4
|
+
export type FormFieldType =
|
|
5
|
+
| "text"
|
|
6
|
+
| "email"
|
|
7
|
+
| "password"
|
|
8
|
+
| "number"
|
|
9
|
+
| "url"
|
|
10
|
+
| "tel"
|
|
11
|
+
| "search"
|
|
12
|
+
| "textarea"
|
|
13
|
+
| "select"
|
|
14
|
+
| "date"
|
|
15
|
+
| "checkbox"
|
|
16
|
+
| "switch"
|
|
17
|
+
| "checkboxes"
|
|
18
|
+
| "radio"
|
|
19
|
+
| "segmented"
|
|
20
|
+
| "file";
|
|
21
|
+
|
|
22
|
+
export interface FormFieldOption {
|
|
23
|
+
value: string;
|
|
24
|
+
label: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
icon?: Component<{ class?: string }>;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FormField {
|
|
31
|
+
name: string;
|
|
32
|
+
type?: FormFieldType;
|
|
33
|
+
label?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
required?: boolean;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
autocomplete?: HTMLInputElement["autocomplete"];
|
|
39
|
+
options?: FormFieldOption[];
|
|
40
|
+
searchable?: boolean;
|
|
41
|
+
variant?: "list" | "cards";
|
|
42
|
+
rows?: number;
|
|
43
|
+
maxlength?: number;
|
|
44
|
+
min?: string;
|
|
45
|
+
max?: string;
|
|
46
|
+
/** For `file`: what it accepts, how many and how big. */
|
|
47
|
+
accept?: string;
|
|
48
|
+
maxFiles?: number;
|
|
49
|
+
maxFileSize?: number;
|
|
50
|
+
/** Takes the whole row when the form has two columns. */
|
|
51
|
+
span?: "full";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type FormValues = Record<string, string | string[] | boolean>;
|
|
55
|
+
|
|
56
|
+
/** Each field's starting value: what `values` gives, else empty for its type. */
|
|
57
|
+
export function initialValues(
|
|
58
|
+
fields: FormField[],
|
|
59
|
+
values: FormValues = {},
|
|
60
|
+
): FormValues {
|
|
61
|
+
return Object.fromEntries(
|
|
62
|
+
fields.map((field) => [
|
|
63
|
+
field.name,
|
|
64
|
+
values[field.name] ??
|
|
65
|
+
(field.type === "checkbox" || field.type === "switch"
|
|
66
|
+
? false
|
|
67
|
+
: field.type === "checkboxes"
|
|
68
|
+
? []
|
|
69
|
+
: ""),
|
|
70
|
+
]),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<script lang="ts">
|
|
76
|
+
import { enhance } from "$app/forms";
|
|
77
|
+
import { type Snippet, untrack } from "svelte";
|
|
78
|
+
|
|
79
|
+
import { Button } from "../ui/button/index.js";
|
|
80
|
+
import { Spinner } from "../ui/spinner/index.js";
|
|
81
|
+
import { type ActionData, enhanceToast } from "../../toast.js";
|
|
82
|
+
import { cn } from "../../utils.js";
|
|
83
|
+
|
|
84
|
+
import CheckboxList from "./checkbox-list.svelte";
|
|
85
|
+
import Checkbox from "./checkbox.svelte";
|
|
86
|
+
import DateField from "./date-field.svelte";
|
|
87
|
+
import FileDropzone from "./file-dropzone.svelte";
|
|
88
|
+
import Input from "./input.svelte";
|
|
89
|
+
import RadioList from "./radio-list.svelte";
|
|
90
|
+
import SegmentedControl from "./segmented-control.svelte";
|
|
91
|
+
import Select from "./select.svelte";
|
|
92
|
+
import Switch from "./switch.svelte";
|
|
93
|
+
import Textarea from "./textarea.svelte";
|
|
94
|
+
|
|
95
|
+
const defaultMessages = {
|
|
96
|
+
submit: "Save",
|
|
97
|
+
loading: "Saving",
|
|
98
|
+
success: "Saved.",
|
|
99
|
+
error: "Couldn't save. Check the form and try again.",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
interface Props {
|
|
103
|
+
fields: FormField[];
|
|
104
|
+
values?: FormValues;
|
|
105
|
+
action?: string;
|
|
106
|
+
/** Field errors to show, like a form action's `errors` after a full-page submit. */
|
|
107
|
+
errors?: Record<string, string[] | undefined>;
|
|
108
|
+
columns?: 1 | 2;
|
|
109
|
+
onsuccess?: (data: ActionData) => void | Promise<void>;
|
|
110
|
+
children?: Snippet;
|
|
111
|
+
actions?: Snippet<[{ pending: boolean }]>;
|
|
112
|
+
messages?: Partial<typeof defaultMessages>;
|
|
113
|
+
class?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let {
|
|
117
|
+
fields,
|
|
118
|
+
values = $bindable({}),
|
|
119
|
+
action,
|
|
120
|
+
errors = {},
|
|
121
|
+
columns = 2,
|
|
122
|
+
onsuccess,
|
|
123
|
+
children,
|
|
124
|
+
actions,
|
|
125
|
+
messages = {},
|
|
126
|
+
class: className,
|
|
127
|
+
}: Props = $props();
|
|
128
|
+
|
|
129
|
+
const text = $derived({ ...defaultMessages, ...messages });
|
|
130
|
+
|
|
131
|
+
let current = $state<FormValues>(
|
|
132
|
+
initialValues(
|
|
133
|
+
untrack(() => fields),
|
|
134
|
+
untrack(() => values),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
let returnedErrors = $state<Record<string, string[] | undefined>>({});
|
|
138
|
+
let pending = $state(false);
|
|
139
|
+
|
|
140
|
+
values = current;
|
|
141
|
+
|
|
142
|
+
const fieldErrors = $derived({ ...errors, ...returnedErrors });
|
|
143
|
+
|
|
144
|
+
function textValue(name: string) {
|
|
145
|
+
return {
|
|
146
|
+
get value() {
|
|
147
|
+
return String(current[name] ?? "");
|
|
148
|
+
},
|
|
149
|
+
set value(next: string) {
|
|
150
|
+
current[name] = next;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function listValue(name: string) {
|
|
156
|
+
return {
|
|
157
|
+
get value() {
|
|
158
|
+
const value = current[name];
|
|
159
|
+
return Array.isArray(value) ? value : [];
|
|
160
|
+
},
|
|
161
|
+
set value(next: string[]) {
|
|
162
|
+
current[name] = next;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function checkedValue(name: string) {
|
|
168
|
+
return {
|
|
169
|
+
get value() {
|
|
170
|
+
return current[name] === true;
|
|
171
|
+
},
|
|
172
|
+
set value(next: boolean) {
|
|
173
|
+
current[name] = next;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function errorsFrom(data: ActionData): Record<string, string[] | undefined> {
|
|
179
|
+
const found = data?.errors;
|
|
180
|
+
return found && typeof found === "object"
|
|
181
|
+
? (found as Record<string, string[] | undefined>)
|
|
182
|
+
: {};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const submit = $derived(
|
|
186
|
+
enhanceToast({
|
|
187
|
+
loading: text.loading,
|
|
188
|
+
success: text.success,
|
|
189
|
+
error: text.error,
|
|
190
|
+
onStart: () => {
|
|
191
|
+
pending = true;
|
|
192
|
+
returnedErrors = {};
|
|
193
|
+
},
|
|
194
|
+
onSettled: () => {
|
|
195
|
+
pending = false;
|
|
196
|
+
},
|
|
197
|
+
onFailure: (data) => {
|
|
198
|
+
returnedErrors = errorsFrom(data);
|
|
199
|
+
},
|
|
200
|
+
onSuccess: async (data) => {
|
|
201
|
+
await onsuccess?.(data);
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
</script>
|
|
206
|
+
|
|
207
|
+
<form
|
|
208
|
+
method="POST"
|
|
209
|
+
{action}
|
|
210
|
+
enctype={fields.some((field) => field.type === "file")
|
|
211
|
+
? "multipart/form-data"
|
|
212
|
+
: undefined}
|
|
213
|
+
use:enhance={submit}
|
|
214
|
+
class={cn("grid gap-6", className)}
|
|
215
|
+
novalidate
|
|
216
|
+
>
|
|
217
|
+
<div
|
|
218
|
+
class={cn(
|
|
219
|
+
"grid grid-cols-1 gap-x-6 gap-y-5",
|
|
220
|
+
columns === 2 && "md:grid-cols-2",
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
{#each fields as field (field.name)}
|
|
224
|
+
{@const type = field.type ?? "text"}
|
|
225
|
+
{@const common = {
|
|
226
|
+
name: field.name,
|
|
227
|
+
label: field.label,
|
|
228
|
+
description: field.description,
|
|
229
|
+
required: field.required,
|
|
230
|
+
disabled: field.disabled || pending,
|
|
231
|
+
error: fieldErrors[field.name],
|
|
232
|
+
}}
|
|
233
|
+
<div
|
|
234
|
+
class={cn(
|
|
235
|
+
"min-w-0",
|
|
236
|
+
(field.span === "full" ||
|
|
237
|
+
type === "textarea" ||
|
|
238
|
+
type === "checkboxes" ||
|
|
239
|
+
type === "file" ||
|
|
240
|
+
(type === "radio" && field.variant === "cards")) &&
|
|
241
|
+
"md:col-span-2",
|
|
242
|
+
)}
|
|
243
|
+
>
|
|
244
|
+
{#if type === "textarea"}
|
|
245
|
+
{@const bound = textValue(field.name)}
|
|
246
|
+
<Textarea
|
|
247
|
+
{...common}
|
|
248
|
+
placeholder={field.placeholder}
|
|
249
|
+
rows={field.rows}
|
|
250
|
+
maxlength={field.maxlength}
|
|
251
|
+
bind:value={bound.value}
|
|
252
|
+
/>
|
|
253
|
+
{:else if type === "select"}
|
|
254
|
+
{@const bound = textValue(field.name)}
|
|
255
|
+
<Select
|
|
256
|
+
{...common}
|
|
257
|
+
options={field.options ?? []}
|
|
258
|
+
placeholder={field.placeholder}
|
|
259
|
+
searchable={field.searchable}
|
|
260
|
+
bind:value={bound.value}
|
|
261
|
+
/>
|
|
262
|
+
{:else if type === "date"}
|
|
263
|
+
{@const bound = textValue(field.name)}
|
|
264
|
+
<DateField
|
|
265
|
+
{...common}
|
|
266
|
+
min={field.min}
|
|
267
|
+
max={field.max}
|
|
268
|
+
placeholder={field.placeholder}
|
|
269
|
+
bind:value={bound.value}
|
|
270
|
+
/>
|
|
271
|
+
{:else if type === "checkbox"}
|
|
272
|
+
{@const bound = checkedValue(field.name)}
|
|
273
|
+
<Checkbox {...common} bind:checked={bound.value} />
|
|
274
|
+
{:else if type === "switch"}
|
|
275
|
+
{@const bound = checkedValue(field.name)}
|
|
276
|
+
<Switch {...common} bind:checked={bound.value} />
|
|
277
|
+
{:else if type === "checkboxes"}
|
|
278
|
+
{@const bound = listValue(field.name)}
|
|
279
|
+
<CheckboxList
|
|
280
|
+
{...common}
|
|
281
|
+
options={field.options ?? []}
|
|
282
|
+
bind:value={bound.value}
|
|
283
|
+
/>
|
|
284
|
+
{:else if type === "radio"}
|
|
285
|
+
{@const bound = textValue(field.name)}
|
|
286
|
+
<RadioList
|
|
287
|
+
{...common}
|
|
288
|
+
options={field.options ?? []}
|
|
289
|
+
variant={field.variant}
|
|
290
|
+
bind:value={bound.value}
|
|
291
|
+
/>
|
|
292
|
+
{:else if type === "file"}
|
|
293
|
+
<FileDropzone
|
|
294
|
+
{...common}
|
|
295
|
+
accept={field.accept}
|
|
296
|
+
maxFiles={field.maxFiles}
|
|
297
|
+
maxFileSize={field.maxFileSize}
|
|
298
|
+
/>
|
|
299
|
+
{:else if type === "segmented"}
|
|
300
|
+
{@const bound = textValue(field.name)}
|
|
301
|
+
<SegmentedControl
|
|
302
|
+
{...common}
|
|
303
|
+
options={field.options ?? []}
|
|
304
|
+
bind:value={bound.value}
|
|
305
|
+
/>
|
|
306
|
+
{:else}
|
|
307
|
+
{@const bound = textValue(field.name)}
|
|
308
|
+
<Input
|
|
309
|
+
{...common}
|
|
310
|
+
{type}
|
|
311
|
+
placeholder={field.placeholder}
|
|
312
|
+
autocomplete={field.autocomplete}
|
|
313
|
+
maxlength={field.maxlength}
|
|
314
|
+
bind:value={bound.value}
|
|
315
|
+
/>
|
|
316
|
+
{/if}
|
|
317
|
+
</div>
|
|
318
|
+
{/each}
|
|
319
|
+
</div>
|
|
320
|
+
{@render children?.()}
|
|
321
|
+
{#if actions}
|
|
322
|
+
{@render actions({ pending })}
|
|
323
|
+
{:else}
|
|
324
|
+
<div class="flex justify-end">
|
|
325
|
+
<Button
|
|
326
|
+
type="submit"
|
|
327
|
+
disabled={pending}
|
|
328
|
+
class="h-11 w-full rounded-xl md:w-auto md:px-6"
|
|
329
|
+
>
|
|
330
|
+
{#if pending}<Spinner />{/if}
|
|
331
|
+
{text.submit}
|
|
332
|
+
</Button>
|
|
333
|
+
</div>
|
|
334
|
+
{/if}
|
|
335
|
+
</form>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Component } from "svelte";
|
|
2
|
+
export type FormFieldType = "text" | "email" | "password" | "number" | "url" | "tel" | "search" | "textarea" | "select" | "date" | "checkbox" | "switch" | "checkboxes" | "radio" | "segmented" | "file";
|
|
3
|
+
export interface FormFieldOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
icon?: Component<{
|
|
8
|
+
class?: string;
|
|
9
|
+
}>;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface FormField {
|
|
13
|
+
name: string;
|
|
14
|
+
type?: FormFieldType;
|
|
15
|
+
label?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
autocomplete?: HTMLInputElement["autocomplete"];
|
|
21
|
+
options?: FormFieldOption[];
|
|
22
|
+
searchable?: boolean;
|
|
23
|
+
variant?: "list" | "cards";
|
|
24
|
+
rows?: number;
|
|
25
|
+
maxlength?: number;
|
|
26
|
+
min?: string;
|
|
27
|
+
max?: string;
|
|
28
|
+
/** For `file`: what it accepts, how many and how big. */
|
|
29
|
+
accept?: string;
|
|
30
|
+
maxFiles?: number;
|
|
31
|
+
maxFileSize?: number;
|
|
32
|
+
/** Takes the whole row when the form has two columns. */
|
|
33
|
+
span?: "full";
|
|
34
|
+
}
|
|
35
|
+
export type FormValues = Record<string, string | string[] | boolean>;
|
|
36
|
+
/** Each field's starting value: what `values` gives, else empty for its type. */
|
|
37
|
+
export declare function initialValues(fields: FormField[], values?: FormValues): FormValues;
|
|
38
|
+
import { type Snippet } from "svelte";
|
|
39
|
+
import { type ActionData } from "../../toast.js";
|
|
40
|
+
declare const Form: Component<{
|
|
41
|
+
fields: FormField[];
|
|
42
|
+
values?: FormValues;
|
|
43
|
+
action?: string;
|
|
44
|
+
/** Field errors to show, like a form action's `errors` after a full-page submit. */
|
|
45
|
+
errors?: Record<string, string[] | undefined>;
|
|
46
|
+
columns?: 1 | 2;
|
|
47
|
+
onsuccess?: (data: ActionData) => void | Promise<void>;
|
|
48
|
+
children?: Snippet;
|
|
49
|
+
actions?: Snippet<[{
|
|
50
|
+
pending: boolean;
|
|
51
|
+
}]>;
|
|
52
|
+
messages?: Partial<{
|
|
53
|
+
submit: string;
|
|
54
|
+
loading: string;
|
|
55
|
+
success: string;
|
|
56
|
+
error: string;
|
|
57
|
+
}>;
|
|
58
|
+
class?: string;
|
|
59
|
+
}, {}, "values">;
|
|
60
|
+
type Form = ReturnType<typeof Form>;
|
|
61
|
+
export default Form;
|