svelte-comp 1.3.5 → 1.3.6
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/LICENSE.md +21 -21
- package/README.md +101 -101
- package/dist/App.svelte +1046 -1046
- package/dist/Container.svelte +59 -59
- package/dist/app.css +234 -234
- package/dist/app.d.ts +10 -10
- package/dist/lib/Accordion.svelte +155 -155
- package/dist/lib/Badge.svelte +44 -44
- package/dist/lib/Button.svelte +185 -185
- package/dist/lib/Calendar.svelte +384 -384
- package/dist/lib/Card.svelte +103 -103
- package/dist/lib/Carousel.svelte +293 -293
- package/dist/lib/CheckBox.svelte +210 -210
- package/dist/lib/CodeView.svelte +308 -308
- package/dist/lib/ColorPicker.svelte +159 -159
- package/dist/lib/ContextMenu.svelte +328 -328
- package/dist/lib/DatePicker.svelte +246 -246
- package/dist/lib/Dialog.svelte +233 -233
- package/dist/lib/Field.svelte +299 -299
- package/dist/lib/FilePicker.svelte +295 -295
- package/dist/lib/Form.svelte +438 -438
- package/dist/lib/Hamburger.svelte +217 -217
- package/dist/lib/InstallPWA.svelte +94 -94
- package/dist/lib/Menu.svelte +623 -623
- package/dist/lib/NoticeBase.svelte +140 -140
- package/dist/lib/PaginatedCard.svelte +73 -73
- package/dist/lib/Pagination.svelte +119 -119
- package/dist/lib/PrimaryColorSelect.svelte +111 -111
- package/dist/lib/ProgressBar.svelte +141 -141
- package/dist/lib/ProgressCircle.svelte +190 -190
- package/dist/lib/Radio.svelte +189 -189
- package/dist/lib/SearchInput.svelte +104 -104
- package/dist/lib/Select.svelte +524 -524
- package/dist/lib/Slider.svelte +253 -253
- package/dist/lib/Splitter.svelte +159 -159
- package/dist/lib/Switch.svelte +168 -168
- package/dist/lib/Table.svelte +299 -299
- package/dist/lib/Tabs.svelte +213 -213
- package/dist/lib/ThemeToggle.svelte +128 -128
- package/dist/lib/TimePicker.svelte +312 -312
- package/dist/lib/TimePickerNew.svelte +634 -634
- package/dist/lib/Toast.svelte +123 -123
- package/dist/lib/Tooltip.svelte +110 -110
- package/dist/lib/Topbar.svelte +112 -112
- package/dist/styles.css +234 -234
- package/package.json +52 -52
package/dist/lib/Form.svelte
CHANGED
|
@@ -1,438 +1,438 @@
|
|
|
1
|
-
<!-- src/lib/Form.svelte -->
|
|
2
|
-
<script lang="ts">
|
|
3
|
-
/**
|
|
4
|
-
* @component Form
|
|
5
|
-
* @description Declarative, schema-driven form generator. Renders `Field`, `Select`, and `CheckBox` based on `FieldSchema`. Supports validation, controlled state, and an external API via `expose`.
|
|
6
|
-
*
|
|
7
|
-
* @prop schema {FieldSchema[]} - Field configuration for the generated form
|
|
8
|
-
*
|
|
9
|
-
* @prop value {FormValues} - Initial form data
|
|
10
|
-
* @default {}
|
|
11
|
-
*
|
|
12
|
-
* @prop rowGap {number | SizeKey} - Vertical spacing between fields
|
|
13
|
-
* @default "md"
|
|
14
|
-
*
|
|
15
|
-
* @prop validateOn {"input" | "blur" | "submit"} - When validation should run
|
|
16
|
-
* @default "blur"
|
|
17
|
-
*
|
|
18
|
-
* @prop onChange {(form: FormValues) => void} - Fired when form values change
|
|
19
|
-
*
|
|
20
|
-
* @prop onSubmit {(form: FormValues, ctx: { reset: () => void }) => void | Promise<void>} - Submission handler
|
|
21
|
-
*
|
|
22
|
-
* @prop formId {string} - Stable identifier for form elements
|
|
23
|
-
*
|
|
24
|
-
* @prop expose {(api: FormApi) => void} - Exposes imperative Form API
|
|
25
|
-
*
|
|
26
|
-
* @prop labelAlign {AlignText} - Alignment for labels
|
|
27
|
-
* @options left|center|right
|
|
28
|
-
* @default "left"
|
|
29
|
-
*
|
|
30
|
-
* @prop labelWeight {LabelWeight} - Font weight for labels
|
|
31
|
-
* @options normal|medium|semibold|bold
|
|
32
|
-
* @default "medium"
|
|
33
|
-
*
|
|
34
|
-
* @prop labelSize {SizeKey} - Size preset for labels
|
|
35
|
-
* @options xs|sm|md|lg|xl
|
|
36
|
-
* @default "md"
|
|
37
|
-
*
|
|
38
|
-
* @prop compact {boolean} - Enables denser sizing across controls
|
|
39
|
-
* @default false
|
|
40
|
-
*
|
|
41
|
-
* @note Initial value for each field: `value[name]` → `schema.default` → `''` (or `false` for checkboxes).
|
|
42
|
-
* @note `validateOn='input'|'blur'|'submit'` controls when validators run; built-in checks: `required`, `number`, and `email` regex.
|
|
43
|
-
* @note `when(form)` hides a field dynamically; hidden fields are skipped during validation.
|
|
44
|
-
* @note `Select` options are coerced to strings for the underlying control; provide string values if you rely on strict equality.
|
|
45
|
-
* @note Errors are rendered with stable `id`s and wired via `aria-describedby`; `invalid` flags are passed to inputs.
|
|
46
|
-
* @note `expose` provides `{ reset, submit, validate, getData }`; `validate` returns `Promise<boolean>`.
|
|
47
|
-
* @note `compact` reduces control sizes (`xs→xs`, `sm→xs`, `md→sm`, `lg→md`, `xl→lg`) and centers labels where applicable.
|
|
48
|
-
*/
|
|
49
|
-
import Field from "./Field.svelte";
|
|
50
|
-
import Select from "./Select.svelte";
|
|
51
|
-
import CheckBox from "./CheckBox.svelte";
|
|
52
|
-
import Toast from "./Toast.svelte";
|
|
53
|
-
import type {
|
|
54
|
-
AlignText,
|
|
55
|
-
LabelWeight,
|
|
56
|
-
SizeKey,
|
|
57
|
-
ToastVariant,
|
|
58
|
-
FieldSchema,
|
|
59
|
-
FormApi,
|
|
60
|
-
FormValues,
|
|
61
|
-
} from "./types";
|
|
62
|
-
import { TEXT } from "./types";
|
|
63
|
-
import { cx, debounce } from "../utils";
|
|
64
|
-
|
|
65
|
-
type Props = {
|
|
66
|
-
schema: FieldSchema[];
|
|
67
|
-
value?: FormValues;
|
|
68
|
-
rowGap?: number | SizeKey;
|
|
69
|
-
validateOn?: "input" | "blur" | "submit";
|
|
70
|
-
onChange?: (form: FormValues) => void;
|
|
71
|
-
onSubmit?: (
|
|
72
|
-
form: FormValues,
|
|
73
|
-
ctx: { reset: () => void }
|
|
74
|
-
) => void | Promise<void>;
|
|
75
|
-
formId?: string;
|
|
76
|
-
expose?: (api: FormApi) => void;
|
|
77
|
-
labelAlign?: AlignText;
|
|
78
|
-
labelWeight?: LabelWeight;
|
|
79
|
-
labelSize?: SizeKey;
|
|
80
|
-
compact?: boolean;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
let {
|
|
84
|
-
schema,
|
|
85
|
-
value = {},
|
|
86
|
-
rowGap = "md",
|
|
87
|
-
validateOn = "blur",
|
|
88
|
-
onChange,
|
|
89
|
-
onSubmit,
|
|
90
|
-
formId,
|
|
91
|
-
expose,
|
|
92
|
-
labelAlign = "left",
|
|
93
|
-
labelWeight = "medium",
|
|
94
|
-
labelSize = "md",
|
|
95
|
-
compact = false,
|
|
96
|
-
}: Props = $props();
|
|
97
|
-
|
|
98
|
-
const compactMap = {
|
|
99
|
-
xs: "xs",
|
|
100
|
-
sm: "xs",
|
|
101
|
-
md: "sm",
|
|
102
|
-
lg: "md",
|
|
103
|
-
xl: "lg",
|
|
104
|
-
} as const;
|
|
105
|
-
|
|
106
|
-
const weightClasses = {
|
|
107
|
-
normal: "font-normal",
|
|
108
|
-
medium: "font-medium",
|
|
109
|
-
semibold: "font-semibold",
|
|
110
|
-
bold: "font-bold",
|
|
111
|
-
} as const;
|
|
112
|
-
|
|
113
|
-
const gapClasses = {
|
|
114
|
-
xs: "gap-1",
|
|
115
|
-
sm: "gap-2",
|
|
116
|
-
md: "gap-4",
|
|
117
|
-
lg: "gap-6",
|
|
118
|
-
xl: "gap-8",
|
|
119
|
-
} as const;
|
|
120
|
-
|
|
121
|
-
const toStr = (v: unknown) => (v == null ? "" : String(v));
|
|
122
|
-
|
|
123
|
-
let form = $state<FormValues>({});
|
|
124
|
-
|
|
125
|
-
const touched = $state<Record<string, boolean>>({});
|
|
126
|
-
const errors = $state<Record<string, string[]>>({});
|
|
127
|
-
let toasts = $state<
|
|
128
|
-
Array<{
|
|
129
|
-
id: number;
|
|
130
|
-
message: string;
|
|
131
|
-
variant: ToastVariant;
|
|
132
|
-
title?: string;
|
|
133
|
-
}>
|
|
134
|
-
>([]);
|
|
135
|
-
let toastId = 0;
|
|
136
|
-
|
|
137
|
-
const inputWidthClass = $derived(compact ? "" : "w-full");
|
|
138
|
-
|
|
139
|
-
const rowGapClass = $derived(
|
|
140
|
-
typeof rowGap === "number" ? "" : (gapClasses[rowGap] ?? gapClasses.md)
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
const rowGapStyle = $derived(typeof rowGap === "number" ? rowGap + "px" : "");
|
|
144
|
-
|
|
145
|
-
const getFieldValue = (name: string): string | number => {
|
|
146
|
-
const current = form[name];
|
|
147
|
-
return typeof current === "number" ? current : toStr(current);
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
function getCompactSize(size: SizeKey) {
|
|
151
|
-
return compact ? (compactMap[size] ?? "md") : size;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const globalFormInstanceCounter = globalThis as unknown as {
|
|
155
|
-
__svelteCompFormInstanceCounter?: number;
|
|
156
|
-
};
|
|
157
|
-
globalFormInstanceCounter.__svelteCompFormInstanceCounter ??= 0;
|
|
158
|
-
const instanceId = $state(globalFormInstanceCounter.__svelteCompFormInstanceCounter++);
|
|
159
|
-
|
|
160
|
-
const baseFormId = $derived.by(() => {
|
|
161
|
-
if (formId && formId.trim()) return formId.trim();
|
|
162
|
-
const key = schema.map((f) => f.name).join("|");
|
|
163
|
-
let hash = 0;
|
|
164
|
-
for (let i = 0; i < key.length; i += 1) {
|
|
165
|
-
hash = (hash << 5) - hash + key.charCodeAt(i);
|
|
166
|
-
hash |= 0;
|
|
167
|
-
}
|
|
168
|
-
const suffix = Math.abs(hash >>> 0).toString(36) || "form";
|
|
169
|
-
return `form-${suffix}`;
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const stableFormId = $derived.by(() => {
|
|
173
|
-
if (formId && formId.trim()) return baseFormId;
|
|
174
|
-
return `${baseFormId}-${instanceId}`;
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
$effect(() => {
|
|
178
|
-
const nextNames = new Set(schema.map((f) => f.name));
|
|
179
|
-
|
|
180
|
-
for (const key of Object.keys(form)) {
|
|
181
|
-
if (!nextNames.has(key)) {
|
|
182
|
-
delete form[key];
|
|
183
|
-
delete touched[key];
|
|
184
|
-
delete errors[key];
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
for (const f of schema) {
|
|
189
|
-
const hasIncoming = Object.prototype.hasOwnProperty.call(value, f.name);
|
|
190
|
-
if (hasIncoming) {
|
|
191
|
-
form[f.name] = value[f.name];
|
|
192
|
-
} else if (!(f.name in form)) {
|
|
193
|
-
form[f.name] = f.default ?? (f.type === "checkbox" ? false : "");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (!(f.name in touched)) touched[f.name] = false;
|
|
197
|
-
if (!errors[f.name]) errors[f.name] = [];
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const scheduleValidation = debounce((name: string) => {
|
|
202
|
-
void validateField(name);
|
|
203
|
-
}, 150);
|
|
204
|
-
|
|
205
|
-
function setValue(name: string, v: unknown) {
|
|
206
|
-
if (form[name] === v) return;
|
|
207
|
-
form[name] = v;
|
|
208
|
-
if (validateOn === "input") scheduleValidation(name);
|
|
209
|
-
onChange?.({ ...form });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function blur(name: string) {
|
|
213
|
-
if (!touched[name]) touched[name] = true;
|
|
214
|
-
if (validateOn !== "submit") validateField(name);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function removeToast(id: number) {
|
|
218
|
-
toasts = toasts.filter((t) => t.id !== id);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function addToast(variant: ToastVariant, message: string, title?: string) {
|
|
222
|
-
const id = toastId++;
|
|
223
|
-
toasts = [...toasts, { id, message, variant, title }];
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function validateField(name: string) {
|
|
227
|
-
const f = schema.find((s) => s.name === name);
|
|
228
|
-
if (!f) return;
|
|
229
|
-
const val = form[name];
|
|
230
|
-
const out: string[] = [];
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
f.required &&
|
|
234
|
-
(val === "" || val == null || (f.type === "checkbox" && !val))
|
|
235
|
-
) {
|
|
236
|
-
out.push("Required");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (f.type === "number" && val !== "" && Number.isNaN(Number(val))) {
|
|
240
|
-
out.push("Must be a number");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (f.type === "email" && val !== "") {
|
|
244
|
-
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(val));
|
|
245
|
-
if (!ok) out.push("Invalid email");
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (f.validators) {
|
|
249
|
-
for (const v of f.validators) {
|
|
250
|
-
const r = await v(val, form);
|
|
251
|
-
if (r) out.push(r);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
errors[name] = out;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function showField(f: FieldSchema) {
|
|
259
|
-
return f.when ? !!f.when(form) : true;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const visible = $derived(schema.filter(showField));
|
|
263
|
-
const visibleNames = $derived(visible.map((f) => f.name));
|
|
264
|
-
|
|
265
|
-
async function validateForm() {
|
|
266
|
-
await Promise.all(visibleNames.map(validateField));
|
|
267
|
-
return visibleNames.every((n) => (errors[n]?.length ?? 0) === 0);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function reset() {
|
|
271
|
-
for (const f of schema) {
|
|
272
|
-
form[f.name] = f.default ?? (f.type === "checkbox" ? false : "");
|
|
273
|
-
touched[f.name] = false;
|
|
274
|
-
errors[f.name] = [];
|
|
275
|
-
}
|
|
276
|
-
onChange?.({ ...form });
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async function submit(e?: Event) {
|
|
280
|
-
e?.preventDefault?.();
|
|
281
|
-
const ok = await validateForm();
|
|
282
|
-
if (!ok) return;
|
|
283
|
-
try {
|
|
284
|
-
await onSubmit?.({ ...form }, { reset });
|
|
285
|
-
addToast("success", "Form submitted successfully");
|
|
286
|
-
} catch (err) {
|
|
287
|
-
addToast("danger", "Form submission failed", "Error");
|
|
288
|
-
throw err;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
$effect(() => {
|
|
293
|
-
const api: FormApi = {
|
|
294
|
-
reset,
|
|
295
|
-
submit: () => submit(),
|
|
296
|
-
validate: () => validateForm(),
|
|
297
|
-
getData: () => ({ ...form }),
|
|
298
|
-
setValue: (name, value) => setValue(name, value),
|
|
299
|
-
};
|
|
300
|
-
expose?.(api);
|
|
301
|
-
});
|
|
302
|
-
</script>
|
|
303
|
-
|
|
304
|
-
<form
|
|
305
|
-
id={stableFormId}
|
|
306
|
-
onsubmit={submit}
|
|
307
|
-
novalidate
|
|
308
|
-
class={cx(
|
|
309
|
-
"grid font-sans w-full",
|
|
310
|
-
rowGapClass,
|
|
311
|
-
compact && "gap-2 justify-items-center"
|
|
312
|
-
)}
|
|
313
|
-
style:gap={rowGapStyle}
|
|
314
|
-
>
|
|
315
|
-
{#each visible as f (f.name)}
|
|
316
|
-
{#key f.name}
|
|
317
|
-
<div
|
|
318
|
-
class={cx(
|
|
319
|
-
"grid gap-1 w-full min-w-0",
|
|
320
|
-
compact ? "justify-items-center" : "justify-items-stretch"
|
|
321
|
-
)}
|
|
322
|
-
>
|
|
323
|
-
{#if f.label}
|
|
324
|
-
<div
|
|
325
|
-
class={cx(
|
|
326
|
-
weightClasses[labelWeight],
|
|
327
|
-
compact
|
|
328
|
-
? "text-xs -mb-1 text-center"
|
|
329
|
-
: [
|
|
330
|
-
TEXT[labelSize],
|
|
331
|
-
labelAlign === "center"
|
|
332
|
-
? "text-center"
|
|
333
|
-
: labelAlign === "right"
|
|
334
|
-
? "text-right"
|
|
335
|
-
: "text-left",
|
|
336
|
-
],
|
|
337
|
-
"text-[var(--color-text-default)] leading-normal"
|
|
338
|
-
)}
|
|
339
|
-
>
|
|
340
|
-
{f.label}
|
|
341
|
-
{#if f.required}
|
|
342
|
-
<span class="text-[var(--color-text-default)]">*</span>
|
|
343
|
-
{/if}
|
|
344
|
-
</div>
|
|
345
|
-
{/if}
|
|
346
|
-
|
|
347
|
-
{#if f.type === "select"}
|
|
348
|
-
{@const hasErr =
|
|
349
|
-
(touched[f.name] || validateOn !== "submit") &&
|
|
350
|
-
errors[f.name]?.length > 0}
|
|
351
|
-
{@const errId = hasErr ? `${stableFormId}-err-${f.name}` : undefined}
|
|
352
|
-
<Select
|
|
353
|
-
class={cx(f.class, inputWidthClass)}
|
|
354
|
-
options={(f.options ?? []).map((o) => ({
|
|
355
|
-
label: toStr(o.label),
|
|
356
|
-
value: toStr(o.value),
|
|
357
|
-
disabled: o.disabled,
|
|
358
|
-
}))}
|
|
359
|
-
sz={getCompactSize(f.sz ?? "md")}
|
|
360
|
-
variant={f.variant ?? "default"}
|
|
361
|
-
value={toStr(form[f.name])}
|
|
362
|
-
onChange={(v: string) => setValue(f.name, v)}
|
|
363
|
-
onblur={() => blur(f.name)}
|
|
364
|
-
invalid={hasErr}
|
|
365
|
-
describedBy={errId}
|
|
366
|
-
/>
|
|
367
|
-
{:else if f.type === "checkbox"}
|
|
368
|
-
<div class={cx(f.class, compact && "mx-auto justify-self-center")}>
|
|
369
|
-
<CheckBox
|
|
370
|
-
class={f.class ?? ""}
|
|
371
|
-
sz={getCompactSize(f.sz ?? "md")}
|
|
372
|
-
variant="default"
|
|
373
|
-
checked={!!form[f.name]}
|
|
374
|
-
onChange={(c: boolean) => setValue(f.name, c)}
|
|
375
|
-
onblur={() => blur(f.name)}
|
|
376
|
-
/>
|
|
377
|
-
</div>
|
|
378
|
-
{:else}
|
|
379
|
-
{@const hasErr =
|
|
380
|
-
(touched[f.name] || validateOn !== "submit") &&
|
|
381
|
-
errors[f.name]?.length > 0}
|
|
382
|
-
|
|
383
|
-
{@const errId = hasErr ? `${stableFormId}-err-${f.name}` : undefined}
|
|
384
|
-
|
|
385
|
-
<Field
|
|
386
|
-
class={cx(f.class, inputWidthClass)}
|
|
387
|
-
as={f.type === "textarea" ? "textarea" : "input"}
|
|
388
|
-
type={f.type === "number"
|
|
389
|
-
? "number"
|
|
390
|
-
: f.type === "password"
|
|
391
|
-
? "password"
|
|
392
|
-
: f.type === "email"
|
|
393
|
-
? "email"
|
|
394
|
-
: "text"}
|
|
395
|
-
rows={f.rows ?? 3}
|
|
396
|
-
parseNumber={f.type === "number"}
|
|
397
|
-
sz={getCompactSize(f.sz ?? "md")}
|
|
398
|
-
variant={f.variant ?? "default"}
|
|
399
|
-
value={getFieldValue(f.name)}
|
|
400
|
-
onChange={(v: string | number) => setValue(f.name, v)}
|
|
401
|
-
onblur={() => blur(f.name)}
|
|
402
|
-
invalid={hasErr}
|
|
403
|
-
describedBy={errId}
|
|
404
|
-
autocomplete={f.type === "email" ? "email" : undefined}
|
|
405
|
-
inputmode={f.type === "email" ? "email" : undefined}
|
|
406
|
-
/>
|
|
407
|
-
{/if}
|
|
408
|
-
|
|
409
|
-
{#if (touched[f.name] || validateOn !== "submit") && errors[f.name]?.length}
|
|
410
|
-
<ul
|
|
411
|
-
id={`${stableFormId}-err-${f.name}`}
|
|
412
|
-
class={cx("m-0 pl-4 list-none", compact && "text-center")}
|
|
413
|
-
>
|
|
414
|
-
{#each errors[f.name] ?? [] as m, idx (`${f.name}-${idx}`)}
|
|
415
|
-
<li
|
|
416
|
-
class={cx(
|
|
417
|
-
"text-xs leading-tight mt-1 text-[var(--color-text-red)]",
|
|
418
|
-
compact && "text-[10px]"
|
|
419
|
-
)}
|
|
420
|
-
>
|
|
421
|
-
{m}
|
|
422
|
-
</li>
|
|
423
|
-
{/each}
|
|
424
|
-
</ul>
|
|
425
|
-
{/if}
|
|
426
|
-
</div>
|
|
427
|
-
{/key}
|
|
428
|
-
{/each}
|
|
429
|
-
</form>
|
|
430
|
-
|
|
431
|
-
{#each toasts as t (t.id)}
|
|
432
|
-
<Toast
|
|
433
|
-
title={t.title}
|
|
434
|
-
message={t.message}
|
|
435
|
-
variant={t.variant}
|
|
436
|
-
onClose={() => removeToast(t.id)}
|
|
437
|
-
/>
|
|
438
|
-
{/each}
|
|
1
|
+
<!-- src/lib/Form.svelte -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
/**
|
|
4
|
+
* @component Form
|
|
5
|
+
* @description Declarative, schema-driven form generator. Renders `Field`, `Select`, and `CheckBox` based on `FieldSchema`. Supports validation, controlled state, and an external API via `expose`.
|
|
6
|
+
*
|
|
7
|
+
* @prop schema {FieldSchema[]} - Field configuration for the generated form
|
|
8
|
+
*
|
|
9
|
+
* @prop value {FormValues} - Initial form data
|
|
10
|
+
* @default {}
|
|
11
|
+
*
|
|
12
|
+
* @prop rowGap {number | SizeKey} - Vertical spacing between fields
|
|
13
|
+
* @default "md"
|
|
14
|
+
*
|
|
15
|
+
* @prop validateOn {"input" | "blur" | "submit"} - When validation should run
|
|
16
|
+
* @default "blur"
|
|
17
|
+
*
|
|
18
|
+
* @prop onChange {(form: FormValues) => void} - Fired when form values change
|
|
19
|
+
*
|
|
20
|
+
* @prop onSubmit {(form: FormValues, ctx: { reset: () => void }) => void | Promise<void>} - Submission handler
|
|
21
|
+
*
|
|
22
|
+
* @prop formId {string} - Stable identifier for form elements
|
|
23
|
+
*
|
|
24
|
+
* @prop expose {(api: FormApi) => void} - Exposes imperative Form API
|
|
25
|
+
*
|
|
26
|
+
* @prop labelAlign {AlignText} - Alignment for labels
|
|
27
|
+
* @options left|center|right
|
|
28
|
+
* @default "left"
|
|
29
|
+
*
|
|
30
|
+
* @prop labelWeight {LabelWeight} - Font weight for labels
|
|
31
|
+
* @options normal|medium|semibold|bold
|
|
32
|
+
* @default "medium"
|
|
33
|
+
*
|
|
34
|
+
* @prop labelSize {SizeKey} - Size preset for labels
|
|
35
|
+
* @options xs|sm|md|lg|xl
|
|
36
|
+
* @default "md"
|
|
37
|
+
*
|
|
38
|
+
* @prop compact {boolean} - Enables denser sizing across controls
|
|
39
|
+
* @default false
|
|
40
|
+
*
|
|
41
|
+
* @note Initial value for each field: `value[name]` → `schema.default` → `''` (or `false` for checkboxes).
|
|
42
|
+
* @note `validateOn='input'|'blur'|'submit'` controls when validators run; built-in checks: `required`, `number`, and `email` regex.
|
|
43
|
+
* @note `when(form)` hides a field dynamically; hidden fields are skipped during validation.
|
|
44
|
+
* @note `Select` options are coerced to strings for the underlying control; provide string values if you rely on strict equality.
|
|
45
|
+
* @note Errors are rendered with stable `id`s and wired via `aria-describedby`; `invalid` flags are passed to inputs.
|
|
46
|
+
* @note `expose` provides `{ reset, submit, validate, getData }`; `validate` returns `Promise<boolean>`.
|
|
47
|
+
* @note `compact` reduces control sizes (`xs→xs`, `sm→xs`, `md→sm`, `lg→md`, `xl→lg`) and centers labels where applicable.
|
|
48
|
+
*/
|
|
49
|
+
import Field from "./Field.svelte";
|
|
50
|
+
import Select from "./Select.svelte";
|
|
51
|
+
import CheckBox from "./CheckBox.svelte";
|
|
52
|
+
import Toast from "./Toast.svelte";
|
|
53
|
+
import type {
|
|
54
|
+
AlignText,
|
|
55
|
+
LabelWeight,
|
|
56
|
+
SizeKey,
|
|
57
|
+
ToastVariant,
|
|
58
|
+
FieldSchema,
|
|
59
|
+
FormApi,
|
|
60
|
+
FormValues,
|
|
61
|
+
} from "./types";
|
|
62
|
+
import { TEXT } from "./types";
|
|
63
|
+
import { cx, debounce } from "../utils";
|
|
64
|
+
|
|
65
|
+
type Props = {
|
|
66
|
+
schema: FieldSchema[];
|
|
67
|
+
value?: FormValues;
|
|
68
|
+
rowGap?: number | SizeKey;
|
|
69
|
+
validateOn?: "input" | "blur" | "submit";
|
|
70
|
+
onChange?: (form: FormValues) => void;
|
|
71
|
+
onSubmit?: (
|
|
72
|
+
form: FormValues,
|
|
73
|
+
ctx: { reset: () => void }
|
|
74
|
+
) => void | Promise<void>;
|
|
75
|
+
formId?: string;
|
|
76
|
+
expose?: (api: FormApi) => void;
|
|
77
|
+
labelAlign?: AlignText;
|
|
78
|
+
labelWeight?: LabelWeight;
|
|
79
|
+
labelSize?: SizeKey;
|
|
80
|
+
compact?: boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let {
|
|
84
|
+
schema,
|
|
85
|
+
value = {},
|
|
86
|
+
rowGap = "md",
|
|
87
|
+
validateOn = "blur",
|
|
88
|
+
onChange,
|
|
89
|
+
onSubmit,
|
|
90
|
+
formId,
|
|
91
|
+
expose,
|
|
92
|
+
labelAlign = "left",
|
|
93
|
+
labelWeight = "medium",
|
|
94
|
+
labelSize = "md",
|
|
95
|
+
compact = false,
|
|
96
|
+
}: Props = $props();
|
|
97
|
+
|
|
98
|
+
const compactMap = {
|
|
99
|
+
xs: "xs",
|
|
100
|
+
sm: "xs",
|
|
101
|
+
md: "sm",
|
|
102
|
+
lg: "md",
|
|
103
|
+
xl: "lg",
|
|
104
|
+
} as const;
|
|
105
|
+
|
|
106
|
+
const weightClasses = {
|
|
107
|
+
normal: "font-normal",
|
|
108
|
+
medium: "font-medium",
|
|
109
|
+
semibold: "font-semibold",
|
|
110
|
+
bold: "font-bold",
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
const gapClasses = {
|
|
114
|
+
xs: "gap-1",
|
|
115
|
+
sm: "gap-2",
|
|
116
|
+
md: "gap-4",
|
|
117
|
+
lg: "gap-6",
|
|
118
|
+
xl: "gap-8",
|
|
119
|
+
} as const;
|
|
120
|
+
|
|
121
|
+
const toStr = (v: unknown) => (v == null ? "" : String(v));
|
|
122
|
+
|
|
123
|
+
let form = $state<FormValues>({});
|
|
124
|
+
|
|
125
|
+
const touched = $state<Record<string, boolean>>({});
|
|
126
|
+
const errors = $state<Record<string, string[]>>({});
|
|
127
|
+
let toasts = $state<
|
|
128
|
+
Array<{
|
|
129
|
+
id: number;
|
|
130
|
+
message: string;
|
|
131
|
+
variant: ToastVariant;
|
|
132
|
+
title?: string;
|
|
133
|
+
}>
|
|
134
|
+
>([]);
|
|
135
|
+
let toastId = 0;
|
|
136
|
+
|
|
137
|
+
const inputWidthClass = $derived(compact ? "" : "w-full");
|
|
138
|
+
|
|
139
|
+
const rowGapClass = $derived(
|
|
140
|
+
typeof rowGap === "number" ? "" : (gapClasses[rowGap] ?? gapClasses.md)
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const rowGapStyle = $derived(typeof rowGap === "number" ? rowGap + "px" : "");
|
|
144
|
+
|
|
145
|
+
const getFieldValue = (name: string): string | number => {
|
|
146
|
+
const current = form[name];
|
|
147
|
+
return typeof current === "number" ? current : toStr(current);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function getCompactSize(size: SizeKey) {
|
|
151
|
+
return compact ? (compactMap[size] ?? "md") : size;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const globalFormInstanceCounter = globalThis as unknown as {
|
|
155
|
+
__svelteCompFormInstanceCounter?: number;
|
|
156
|
+
};
|
|
157
|
+
globalFormInstanceCounter.__svelteCompFormInstanceCounter ??= 0;
|
|
158
|
+
const instanceId = $state(globalFormInstanceCounter.__svelteCompFormInstanceCounter++);
|
|
159
|
+
|
|
160
|
+
const baseFormId = $derived.by(() => {
|
|
161
|
+
if (formId && formId.trim()) return formId.trim();
|
|
162
|
+
const key = schema.map((f) => f.name).join("|");
|
|
163
|
+
let hash = 0;
|
|
164
|
+
for (let i = 0; i < key.length; i += 1) {
|
|
165
|
+
hash = (hash << 5) - hash + key.charCodeAt(i);
|
|
166
|
+
hash |= 0;
|
|
167
|
+
}
|
|
168
|
+
const suffix = Math.abs(hash >>> 0).toString(36) || "form";
|
|
169
|
+
return `form-${suffix}`;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const stableFormId = $derived.by(() => {
|
|
173
|
+
if (formId && formId.trim()) return baseFormId;
|
|
174
|
+
return `${baseFormId}-${instanceId}`;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
$effect(() => {
|
|
178
|
+
const nextNames = new Set(schema.map((f) => f.name));
|
|
179
|
+
|
|
180
|
+
for (const key of Object.keys(form)) {
|
|
181
|
+
if (!nextNames.has(key)) {
|
|
182
|
+
delete form[key];
|
|
183
|
+
delete touched[key];
|
|
184
|
+
delete errors[key];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const f of schema) {
|
|
189
|
+
const hasIncoming = Object.prototype.hasOwnProperty.call(value, f.name);
|
|
190
|
+
if (hasIncoming) {
|
|
191
|
+
form[f.name] = value[f.name];
|
|
192
|
+
} else if (!(f.name in form)) {
|
|
193
|
+
form[f.name] = f.default ?? (f.type === "checkbox" ? false : "");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!(f.name in touched)) touched[f.name] = false;
|
|
197
|
+
if (!errors[f.name]) errors[f.name] = [];
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const scheduleValidation = debounce((name: string) => {
|
|
202
|
+
void validateField(name);
|
|
203
|
+
}, 150);
|
|
204
|
+
|
|
205
|
+
function setValue(name: string, v: unknown) {
|
|
206
|
+
if (form[name] === v) return;
|
|
207
|
+
form[name] = v;
|
|
208
|
+
if (validateOn === "input") scheduleValidation(name);
|
|
209
|
+
onChange?.({ ...form });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function blur(name: string) {
|
|
213
|
+
if (!touched[name]) touched[name] = true;
|
|
214
|
+
if (validateOn !== "submit") validateField(name);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function removeToast(id: number) {
|
|
218
|
+
toasts = toasts.filter((t) => t.id !== id);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function addToast(variant: ToastVariant, message: string, title?: string) {
|
|
222
|
+
const id = toastId++;
|
|
223
|
+
toasts = [...toasts, { id, message, variant, title }];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function validateField(name: string) {
|
|
227
|
+
const f = schema.find((s) => s.name === name);
|
|
228
|
+
if (!f) return;
|
|
229
|
+
const val = form[name];
|
|
230
|
+
const out: string[] = [];
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
f.required &&
|
|
234
|
+
(val === "" || val == null || (f.type === "checkbox" && !val))
|
|
235
|
+
) {
|
|
236
|
+
out.push("Required");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (f.type === "number" && val !== "" && Number.isNaN(Number(val))) {
|
|
240
|
+
out.push("Must be a number");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (f.type === "email" && val !== "") {
|
|
244
|
+
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(val));
|
|
245
|
+
if (!ok) out.push("Invalid email");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (f.validators) {
|
|
249
|
+
for (const v of f.validators) {
|
|
250
|
+
const r = await v(val, form);
|
|
251
|
+
if (r) out.push(r);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
errors[name] = out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function showField(f: FieldSchema) {
|
|
259
|
+
return f.when ? !!f.when(form) : true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const visible = $derived(schema.filter(showField));
|
|
263
|
+
const visibleNames = $derived(visible.map((f) => f.name));
|
|
264
|
+
|
|
265
|
+
async function validateForm() {
|
|
266
|
+
await Promise.all(visibleNames.map(validateField));
|
|
267
|
+
return visibleNames.every((n) => (errors[n]?.length ?? 0) === 0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function reset() {
|
|
271
|
+
for (const f of schema) {
|
|
272
|
+
form[f.name] = f.default ?? (f.type === "checkbox" ? false : "");
|
|
273
|
+
touched[f.name] = false;
|
|
274
|
+
errors[f.name] = [];
|
|
275
|
+
}
|
|
276
|
+
onChange?.({ ...form });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function submit(e?: Event) {
|
|
280
|
+
e?.preventDefault?.();
|
|
281
|
+
const ok = await validateForm();
|
|
282
|
+
if (!ok) return;
|
|
283
|
+
try {
|
|
284
|
+
await onSubmit?.({ ...form }, { reset });
|
|
285
|
+
addToast("success", "Form submitted successfully");
|
|
286
|
+
} catch (err) {
|
|
287
|
+
addToast("danger", "Form submission failed", "Error");
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
$effect(() => {
|
|
293
|
+
const api: FormApi = {
|
|
294
|
+
reset,
|
|
295
|
+
submit: () => submit(),
|
|
296
|
+
validate: () => validateForm(),
|
|
297
|
+
getData: () => ({ ...form }),
|
|
298
|
+
setValue: (name, value) => setValue(name, value),
|
|
299
|
+
};
|
|
300
|
+
expose?.(api);
|
|
301
|
+
});
|
|
302
|
+
</script>
|
|
303
|
+
|
|
304
|
+
<form
|
|
305
|
+
id={stableFormId}
|
|
306
|
+
onsubmit={submit}
|
|
307
|
+
novalidate
|
|
308
|
+
class={cx(
|
|
309
|
+
"grid font-sans w-full",
|
|
310
|
+
rowGapClass,
|
|
311
|
+
compact && "gap-2 justify-items-center"
|
|
312
|
+
)}
|
|
313
|
+
style:gap={rowGapStyle}
|
|
314
|
+
>
|
|
315
|
+
{#each visible as f (f.name)}
|
|
316
|
+
{#key f.name}
|
|
317
|
+
<div
|
|
318
|
+
class={cx(
|
|
319
|
+
"grid gap-1 w-full min-w-0",
|
|
320
|
+
compact ? "justify-items-center" : "justify-items-stretch"
|
|
321
|
+
)}
|
|
322
|
+
>
|
|
323
|
+
{#if f.label}
|
|
324
|
+
<div
|
|
325
|
+
class={cx(
|
|
326
|
+
weightClasses[labelWeight],
|
|
327
|
+
compact
|
|
328
|
+
? "text-xs -mb-1 text-center"
|
|
329
|
+
: [
|
|
330
|
+
TEXT[labelSize],
|
|
331
|
+
labelAlign === "center"
|
|
332
|
+
? "text-center"
|
|
333
|
+
: labelAlign === "right"
|
|
334
|
+
? "text-right"
|
|
335
|
+
: "text-left",
|
|
336
|
+
],
|
|
337
|
+
"text-[var(--color-text-default)] leading-normal"
|
|
338
|
+
)}
|
|
339
|
+
>
|
|
340
|
+
{f.label}
|
|
341
|
+
{#if f.required}
|
|
342
|
+
<span class="text-[var(--color-text-default)]">*</span>
|
|
343
|
+
{/if}
|
|
344
|
+
</div>
|
|
345
|
+
{/if}
|
|
346
|
+
|
|
347
|
+
{#if f.type === "select"}
|
|
348
|
+
{@const hasErr =
|
|
349
|
+
(touched[f.name] || validateOn !== "submit") &&
|
|
350
|
+
errors[f.name]?.length > 0}
|
|
351
|
+
{@const errId = hasErr ? `${stableFormId}-err-${f.name}` : undefined}
|
|
352
|
+
<Select
|
|
353
|
+
class={cx(f.class, inputWidthClass)}
|
|
354
|
+
options={(f.options ?? []).map((o) => ({
|
|
355
|
+
label: toStr(o.label),
|
|
356
|
+
value: toStr(o.value),
|
|
357
|
+
disabled: o.disabled,
|
|
358
|
+
}))}
|
|
359
|
+
sz={getCompactSize(f.sz ?? "md")}
|
|
360
|
+
variant={f.variant ?? "default"}
|
|
361
|
+
value={toStr(form[f.name])}
|
|
362
|
+
onChange={(v: string) => setValue(f.name, v)}
|
|
363
|
+
onblur={() => blur(f.name)}
|
|
364
|
+
invalid={hasErr}
|
|
365
|
+
describedBy={errId}
|
|
366
|
+
/>
|
|
367
|
+
{:else if f.type === "checkbox"}
|
|
368
|
+
<div class={cx(f.class, compact && "mx-auto justify-self-center")}>
|
|
369
|
+
<CheckBox
|
|
370
|
+
class={f.class ?? ""}
|
|
371
|
+
sz={getCompactSize(f.sz ?? "md")}
|
|
372
|
+
variant="default"
|
|
373
|
+
checked={!!form[f.name]}
|
|
374
|
+
onChange={(c: boolean) => setValue(f.name, c)}
|
|
375
|
+
onblur={() => blur(f.name)}
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
{:else}
|
|
379
|
+
{@const hasErr =
|
|
380
|
+
(touched[f.name] || validateOn !== "submit") &&
|
|
381
|
+
errors[f.name]?.length > 0}
|
|
382
|
+
|
|
383
|
+
{@const errId = hasErr ? `${stableFormId}-err-${f.name}` : undefined}
|
|
384
|
+
|
|
385
|
+
<Field
|
|
386
|
+
class={cx(f.class, inputWidthClass)}
|
|
387
|
+
as={f.type === "textarea" ? "textarea" : "input"}
|
|
388
|
+
type={f.type === "number"
|
|
389
|
+
? "number"
|
|
390
|
+
: f.type === "password"
|
|
391
|
+
? "password"
|
|
392
|
+
: f.type === "email"
|
|
393
|
+
? "email"
|
|
394
|
+
: "text"}
|
|
395
|
+
rows={f.rows ?? 3}
|
|
396
|
+
parseNumber={f.type === "number"}
|
|
397
|
+
sz={getCompactSize(f.sz ?? "md")}
|
|
398
|
+
variant={f.variant ?? "default"}
|
|
399
|
+
value={getFieldValue(f.name)}
|
|
400
|
+
onChange={(v: string | number) => setValue(f.name, v)}
|
|
401
|
+
onblur={() => blur(f.name)}
|
|
402
|
+
invalid={hasErr}
|
|
403
|
+
describedBy={errId}
|
|
404
|
+
autocomplete={f.type === "email" ? "email" : undefined}
|
|
405
|
+
inputmode={f.type === "email" ? "email" : undefined}
|
|
406
|
+
/>
|
|
407
|
+
{/if}
|
|
408
|
+
|
|
409
|
+
{#if (touched[f.name] || validateOn !== "submit") && errors[f.name]?.length}
|
|
410
|
+
<ul
|
|
411
|
+
id={`${stableFormId}-err-${f.name}`}
|
|
412
|
+
class={cx("m-0 pl-4 list-none", compact && "text-center")}
|
|
413
|
+
>
|
|
414
|
+
{#each errors[f.name] ?? [] as m, idx (`${f.name}-${idx}`)}
|
|
415
|
+
<li
|
|
416
|
+
class={cx(
|
|
417
|
+
"text-xs leading-tight mt-1 text-[var(--color-text-red)]",
|
|
418
|
+
compact && "text-[10px]"
|
|
419
|
+
)}
|
|
420
|
+
>
|
|
421
|
+
{m}
|
|
422
|
+
</li>
|
|
423
|
+
{/each}
|
|
424
|
+
</ul>
|
|
425
|
+
{/if}
|
|
426
|
+
</div>
|
|
427
|
+
{/key}
|
|
428
|
+
{/each}
|
|
429
|
+
</form>
|
|
430
|
+
|
|
431
|
+
{#each toasts as t (t.id)}
|
|
432
|
+
<Toast
|
|
433
|
+
title={t.title}
|
|
434
|
+
message={t.message}
|
|
435
|
+
variant={t.variant}
|
|
436
|
+
onClose={() => removeToast(t.id)}
|
|
437
|
+
/>
|
|
438
|
+
{/each}
|