@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.
Files changed (112) hide show
  1. package/README.md +84 -3
  2. package/dist/components/blocks/accent-toggle.svelte +7 -3
  3. package/dist/components/blocks/accent-toggle.svelte.d.ts +1 -1
  4. package/dist/components/blocks/bottom-nav.svelte +1 -1
  5. package/dist/components/blocks/command-palette.svelte +268 -0
  6. package/dist/components/blocks/command-palette.svelte.d.ts +37 -0
  7. package/dist/components/blocks/error-page.svelte +23 -7
  8. package/dist/components/blocks/error-page.svelte.d.ts +4 -0
  9. package/dist/components/blocks/fab.svelte +1 -1
  10. package/dist/components/blocks/install-prompt.svelte +14 -8
  11. package/dist/components/blocks/install-prompt.svelte.d.ts +2 -1
  12. package/dist/components/blocks/music-player/labelled-slider.svelte +3 -12
  13. package/dist/components/blocks/music-player/labelled-slider.svelte.d.ts +1 -2
  14. package/dist/components/blocks/music-player.svelte +13 -12
  15. package/dist/components/blocks/otp-field.svelte +77 -33
  16. package/dist/components/blocks/otp-field.svelte.d.ts +7 -0
  17. package/dist/components/blocks/password-input.svelte +56 -16
  18. package/dist/components/blocks/password-input.svelte.d.ts +3 -0
  19. package/dist/components/blocks/stepper/stepper-phone-header.svelte +93 -0
  20. package/dist/components/blocks/stepper/stepper-phone-header.svelte.d.ts +13 -0
  21. package/dist/components/blocks/stepper.svelte +175 -0
  22. package/dist/components/blocks/stepper.svelte.d.ts +30 -0
  23. package/dist/components/blocks/time-range-picker.svelte +229 -0
  24. package/dist/components/blocks/time-range-picker.svelte.d.ts +33 -0
  25. package/dist/components/blocks/time-series-chart/data-table.svelte +43 -0
  26. package/dist/components/blocks/time-series-chart/data-table.svelte.d.ts +15 -0
  27. package/dist/components/blocks/time-series-chart/legend.svelte +23 -0
  28. package/dist/components/blocks/time-series-chart/legend.svelte.d.ts +10 -0
  29. package/dist/components/blocks/time-series-chart/marks.svelte +118 -0
  30. package/dist/components/blocks/time-series-chart/marks.svelte.d.ts +29 -0
  31. package/dist/components/blocks/time-series-chart/messages.d.ts +13 -0
  32. package/dist/components/blocks/time-series-chart/messages.js +12 -0
  33. package/dist/components/blocks/time-series-chart/readout.svelte +44 -0
  34. package/dist/components/blocks/time-series-chart/readout.svelte.d.ts +15 -0
  35. package/dist/components/blocks/time-series-chart/scale.d.ts +24 -0
  36. package/dist/components/blocks/time-series-chart/scale.js +136 -0
  37. package/dist/components/blocks/time-series-chart/time.d.ts +7 -0
  38. package/dist/components/blocks/time-series-chart/time.js +96 -0
  39. package/dist/components/blocks/time-series-chart.svelte +492 -0
  40. package/dist/components/blocks/time-series-chart.svelte.d.ts +18 -0
  41. package/dist/components/blocks/top-loading-bar.svelte +1 -1
  42. package/dist/components/blocks/video-player/center-controls.svelte +7 -7
  43. package/dist/components/blocks/video-player/control-bar.svelte +41 -35
  44. package/dist/components/blocks/video-player/control-button.svelte +1 -1
  45. package/dist/components/blocks/video-player/option-menu.svelte +1 -1
  46. package/dist/components/blocks/video-player/scrub-bar.svelte +39 -12
  47. package/dist/components/blocks/video-player/scrub-bar.svelte.d.ts +2 -0
  48. package/dist/components/blocks/video-player.svelte +2 -2
  49. package/dist/components/kit/accordion.svelte +70 -0
  50. package/dist/components/kit/accordion.svelte.d.ts +37 -0
  51. package/dist/components/kit/avatar-group.svelte +84 -0
  52. package/dist/components/kit/avatar-group.svelte.d.ts +14 -0
  53. package/dist/components/kit/bar-list.svelte +104 -0
  54. package/dist/components/kit/bar-list.svelte.d.ts +22 -0
  55. package/dist/components/kit/breadcrumbs.svelte +102 -0
  56. package/dist/components/kit/breadcrumbs.svelte.d.ts +14 -0
  57. package/dist/components/kit/checkbox-list.svelte +98 -0
  58. package/dist/components/kit/checkbox-list.svelte.d.ts +15 -0
  59. package/dist/components/kit/checkbox.svelte +84 -0
  60. package/dist/components/kit/checkbox.svelte.d.ts +8 -0
  61. package/dist/components/kit/confirm-dialog.svelte +127 -0
  62. package/dist/components/kit/confirm-dialog.svelte.d.ts +19 -0
  63. package/dist/components/kit/data-table.svelte +280 -0
  64. package/dist/components/kit/data-table.svelte.d.ts +46 -0
  65. package/dist/components/kit/date-field.svelte +210 -0
  66. package/dist/components/kit/date-field.svelte.d.ts +14 -0
  67. package/dist/components/kit/field-frame.svelte +71 -0
  68. package/dist/components/kit/field-frame.svelte.d.ts +26 -0
  69. package/dist/components/kit/fieldset-frame.svelte +69 -0
  70. package/dist/components/kit/fieldset-frame.svelte.d.ts +14 -0
  71. package/dist/components/kit/file-dropzone/files.d.ts +14 -0
  72. package/dist/components/kit/file-dropzone/files.js +69 -0
  73. package/dist/components/kit/file-dropzone.svelte +253 -0
  74. package/dist/components/kit/file-dropzone.svelte.d.ts +25 -0
  75. package/dist/components/kit/form.svelte +335 -0
  76. package/dist/components/kit/form.svelte.d.ts +61 -0
  77. package/dist/components/kit/input.svelte +73 -0
  78. package/dist/components/kit/input.svelte.d.ts +9 -0
  79. package/dist/components/kit/menu.svelte +163 -0
  80. package/dist/components/kit/menu.svelte.d.ts +28 -0
  81. package/dist/components/kit/pagination.svelte +134 -0
  82. package/dist/components/kit/pagination.svelte.d.ts +17 -0
  83. package/dist/components/kit/radio-list.svelte +131 -0
  84. package/dist/components/kit/radio-list.svelte.d.ts +15 -0
  85. package/dist/components/kit/segmented-control/segment.svelte +47 -0
  86. package/dist/components/kit/segmented-control/segment.svelte.d.ts +11 -0
  87. package/dist/components/kit/segmented-control.svelte +70 -0
  88. package/dist/components/kit/segmented-control.svelte.d.ts +16 -0
  89. package/dist/components/kit/select.svelte +244 -0
  90. package/dist/components/kit/select.svelte.d.ts +26 -0
  91. package/dist/components/kit/status-badge.svelte +54 -0
  92. package/dist/components/kit/status-badge.svelte.d.ts +31 -0
  93. package/dist/components/kit/switch.svelte +84 -0
  94. package/dist/components/kit/switch.svelte.d.ts +8 -0
  95. package/dist/components/kit/tabs.svelte +86 -0
  96. package/dist/components/kit/tabs.svelte.d.ts +20 -0
  97. package/dist/components/kit/textarea.svelte +92 -0
  98. package/dist/components/kit/textarea.svelte.d.ts +11 -0
  99. package/dist/components/ui/drawer/drawer-content.svelte +1 -1
  100. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  101. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  102. package/dist/components/ui/sonner/sonner.svelte +2 -4
  103. package/dist/mcp-content.json +1 -1
  104. package/dist/mcp.js +11 -11
  105. package/dist/server/hooks.d.ts +9 -0
  106. package/dist/server/hooks.js +41 -0
  107. package/dist/server/logger.d.ts +39 -0
  108. package/dist/server/logger.js +144 -0
  109. package/dist/server/telemetry.d.ts +23 -0
  110. package/dist/server/telemetry.js +82 -0
  111. package/dist/theme.css +18 -6
  112. 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;