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.
Files changed (46) hide show
  1. package/LICENSE.md +21 -21
  2. package/README.md +101 -101
  3. package/dist/App.svelte +1046 -1046
  4. package/dist/Container.svelte +59 -59
  5. package/dist/app.css +234 -234
  6. package/dist/app.d.ts +10 -10
  7. package/dist/lib/Accordion.svelte +155 -155
  8. package/dist/lib/Badge.svelte +44 -44
  9. package/dist/lib/Button.svelte +185 -185
  10. package/dist/lib/Calendar.svelte +384 -384
  11. package/dist/lib/Card.svelte +103 -103
  12. package/dist/lib/Carousel.svelte +293 -293
  13. package/dist/lib/CheckBox.svelte +210 -210
  14. package/dist/lib/CodeView.svelte +308 -308
  15. package/dist/lib/ColorPicker.svelte +159 -159
  16. package/dist/lib/ContextMenu.svelte +328 -328
  17. package/dist/lib/DatePicker.svelte +246 -246
  18. package/dist/lib/Dialog.svelte +233 -233
  19. package/dist/lib/Field.svelte +299 -299
  20. package/dist/lib/FilePicker.svelte +295 -295
  21. package/dist/lib/Form.svelte +438 -438
  22. package/dist/lib/Hamburger.svelte +217 -217
  23. package/dist/lib/InstallPWA.svelte +94 -94
  24. package/dist/lib/Menu.svelte +623 -623
  25. package/dist/lib/NoticeBase.svelte +140 -140
  26. package/dist/lib/PaginatedCard.svelte +73 -73
  27. package/dist/lib/Pagination.svelte +119 -119
  28. package/dist/lib/PrimaryColorSelect.svelte +111 -111
  29. package/dist/lib/ProgressBar.svelte +141 -141
  30. package/dist/lib/ProgressCircle.svelte +190 -190
  31. package/dist/lib/Radio.svelte +189 -189
  32. package/dist/lib/SearchInput.svelte +104 -104
  33. package/dist/lib/Select.svelte +524 -524
  34. package/dist/lib/Slider.svelte +253 -253
  35. package/dist/lib/Splitter.svelte +159 -159
  36. package/dist/lib/Switch.svelte +168 -168
  37. package/dist/lib/Table.svelte +299 -299
  38. package/dist/lib/Tabs.svelte +213 -213
  39. package/dist/lib/ThemeToggle.svelte +128 -128
  40. package/dist/lib/TimePicker.svelte +312 -312
  41. package/dist/lib/TimePickerNew.svelte +634 -634
  42. package/dist/lib/Toast.svelte +123 -123
  43. package/dist/lib/Tooltip.svelte +110 -110
  44. package/dist/lib/Topbar.svelte +112 -112
  45. package/dist/styles.css +234 -234
  46. package/package.json +52 -52
@@ -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}