svelte-comp 1.3.3 → 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 (138) hide show
  1. package/LICENSE.md +21 -21
  2. package/README.md +101 -100
  3. package/dist/App.svelte +507 -507
  4. package/dist/Container.svelte +59 -59
  5. package/dist/app.css +234 -235
  6. package/dist/app.d.ts +10 -0
  7. package/dist/lib/Accordion.svelte +155 -155
  8. package/dist/lib/Badge.svelte +44 -44
  9. package/dist/lib/Button.svelte +185 -170
  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/Carousel.svelte.d.ts +1 -1
  14. package/dist/lib/CheckBox.svelte +210 -210
  15. package/dist/lib/CodeView.svelte +308 -307
  16. package/dist/lib/ColorPicker.svelte +159 -159
  17. package/dist/lib/ContextMenu.svelte +328 -322
  18. package/dist/lib/DatePicker.svelte +246 -246
  19. package/dist/lib/Dialog.svelte +233 -233
  20. package/dist/lib/Field.svelte +299 -299
  21. package/dist/lib/FilePicker.svelte +295 -240
  22. package/dist/lib/FilePicker.svelte.d.ts +6 -1
  23. package/dist/lib/Form.svelte +438 -438
  24. package/dist/lib/Hamburger.svelte +217 -217
  25. package/dist/lib/InstallPWA.svelte +94 -94
  26. package/dist/lib/Menu.svelte +623 -623
  27. package/dist/lib/NoticeBase.svelte +140 -140
  28. package/dist/lib/PaginatedCard.svelte +73 -73
  29. package/dist/lib/Pagination.svelte +119 -119
  30. package/dist/lib/PrimaryColorSelect.svelte +111 -111
  31. package/dist/lib/ProgressBar.svelte +141 -141
  32. package/dist/lib/ProgressCircle.svelte +190 -190
  33. package/dist/lib/Radio.svelte +189 -189
  34. package/dist/lib/SearchInput.svelte +104 -104
  35. package/dist/lib/Select.svelte +524 -524
  36. package/dist/lib/Slider.svelte +253 -253
  37. package/dist/lib/Splitter.svelte +159 -150
  38. package/dist/lib/Switch.svelte +168 -167
  39. package/dist/lib/Table.svelte +299 -299
  40. package/dist/lib/Tabs.svelte +213 -213
  41. package/dist/lib/ThemeToggle.svelte +128 -127
  42. package/dist/lib/TimePicker.svelte +312 -312
  43. package/dist/lib/TimePickerNew.svelte +634 -0
  44. package/dist/lib/TimePickerNew.svelte.d.ts +49 -0
  45. package/dist/lib/Toast.svelte +123 -123
  46. package/dist/lib/Tooltip.svelte +110 -110
  47. package/dist/lib/Topbar.svelte +107 -107
  48. package/dist/lib/__tests__/Accordion.test.d.ts +1 -0
  49. package/dist/lib/__tests__/Accordion.test.js +171 -0
  50. package/dist/lib/__tests__/Badge.test.d.ts +1 -0
  51. package/dist/lib/__tests__/Badge.test.js +41 -0
  52. package/dist/lib/__tests__/Button.test.d.ts +1 -0
  53. package/dist/lib/__tests__/Button.test.js +269 -0
  54. package/dist/lib/__tests__/Calendar.test.d.ts +1 -0
  55. package/dist/lib/__tests__/Calendar.test.js +171 -0
  56. package/dist/lib/__tests__/Card.test.d.ts +1 -0
  57. package/dist/lib/__tests__/Card.test.js +148 -0
  58. package/dist/lib/__tests__/Carousel.test.d.ts +1 -0
  59. package/dist/lib/__tests__/Carousel.test.js +439 -0
  60. package/dist/lib/__tests__/CheckBox.test.d.ts +1 -0
  61. package/dist/lib/__tests__/CheckBox.test.js +152 -0
  62. package/dist/lib/__tests__/CodeView.test.d.ts +1 -0
  63. package/dist/lib/__tests__/CodeView.test.js +157 -0
  64. package/dist/lib/__tests__/ColorPicker.test.d.ts +1 -0
  65. package/dist/lib/__tests__/ColorPicker.test.js +93 -0
  66. package/dist/lib/__tests__/ContextMenu.test.d.ts +1 -0
  67. package/dist/lib/__tests__/ContextMenu.test.js +67 -0
  68. package/dist/lib/__tests__/DatePicker.test.d.ts +1 -0
  69. package/dist/lib/__tests__/DatePicker.test.js +108 -0
  70. package/dist/lib/__tests__/Dialog.test.d.ts +1 -0
  71. package/dist/lib/__tests__/Dialog.test.js +183 -0
  72. package/dist/lib/__tests__/Field.test.d.ts +1 -0
  73. package/dist/lib/__tests__/Field.test.js +190 -0
  74. package/dist/lib/__tests__/FilePicker.test.d.ts +1 -0
  75. package/dist/lib/__tests__/FilePicker.test.js +179 -0
  76. package/dist/lib/__tests__/Form.integration.test.d.ts +1 -0
  77. package/dist/lib/__tests__/Form.integration.test.js +158 -0
  78. package/dist/lib/__tests__/Form.test.d.ts +1 -0
  79. package/dist/lib/__tests__/Form.test.js +463 -0
  80. package/dist/lib/__tests__/Hamburger.test.d.ts +1 -0
  81. package/dist/lib/__tests__/Hamburger.test.js +161 -0
  82. package/dist/lib/__tests__/InstallPWA.test.d.ts +1 -0
  83. package/dist/lib/__tests__/InstallPWA.test.js +15 -0
  84. package/dist/lib/__tests__/Menu.test.d.ts +1 -0
  85. package/dist/lib/__tests__/Menu.test.js +285 -0
  86. package/dist/lib/__tests__/NoticeBase.test.d.ts +1 -0
  87. package/dist/lib/__tests__/NoticeBase.test.js +60 -0
  88. package/dist/lib/__tests__/PaginatedCard.test.d.ts +1 -0
  89. package/dist/lib/__tests__/PaginatedCard.test.js +89 -0
  90. package/dist/lib/__tests__/Pagination.test.d.ts +1 -0
  91. package/dist/lib/__tests__/Pagination.test.js +168 -0
  92. package/dist/lib/__tests__/PrimaryColorSelect.test.d.ts +1 -0
  93. package/dist/lib/__tests__/PrimaryColorSelect.test.js +92 -0
  94. package/dist/lib/__tests__/ProgressBar.test.d.ts +1 -0
  95. package/dist/lib/__tests__/ProgressBar.test.js +69 -0
  96. package/dist/lib/__tests__/ProgressCircle.test.d.ts +1 -0
  97. package/dist/lib/__tests__/ProgressCircle.test.js +71 -0
  98. package/dist/lib/__tests__/Radio.test.d.ts +1 -0
  99. package/dist/lib/__tests__/Radio.test.js +127 -0
  100. package/dist/lib/__tests__/SearchInput.test.d.ts +1 -0
  101. package/dist/lib/__tests__/SearchInput.test.js +80 -0
  102. package/dist/lib/__tests__/Select.test.d.ts +1 -0
  103. package/dist/lib/__tests__/Select.test.js +408 -0
  104. package/dist/lib/__tests__/Slider.test.d.ts +1 -0
  105. package/dist/lib/__tests__/Slider.test.js +213 -0
  106. package/dist/lib/__tests__/Splitter.test.d.ts +1 -0
  107. package/dist/lib/__tests__/Splitter.test.js +87 -0
  108. package/dist/lib/__tests__/Switch.test.d.ts +1 -0
  109. package/dist/lib/__tests__/Switch.test.js +97 -0
  110. package/dist/lib/__tests__/Table.test.d.ts +1 -0
  111. package/dist/lib/__tests__/Table.test.js +349 -0
  112. package/dist/lib/__tests__/Tabs.test.d.ts +1 -0
  113. package/dist/lib/__tests__/Tabs.test.js +262 -0
  114. package/dist/lib/__tests__/ThemeToggle.test.d.ts +1 -0
  115. package/dist/lib/__tests__/ThemeToggle.test.js +84 -0
  116. package/dist/lib/__tests__/TimePicker.test.d.ts +1 -0
  117. package/dist/lib/__tests__/TimePicker.test.js +146 -0
  118. package/dist/lib/__tests__/TimePickerNew.test.d.ts +1 -0
  119. package/dist/lib/__tests__/TimePickerNew.test.js +322 -0
  120. package/dist/lib/__tests__/Toast.test.d.ts +1 -0
  121. package/dist/lib/__tests__/Toast.test.js +135 -0
  122. package/dist/lib/__tests__/Tooltip.test.d.ts +1 -0
  123. package/dist/lib/__tests__/Tooltip.test.js +171 -0
  124. package/dist/lib/__tests__/Topbar.test.d.ts +1 -0
  125. package/dist/lib/__tests__/Topbar.test.js +25 -0
  126. package/dist/lib/__tests__/setupLangContext.d.ts +1 -0
  127. package/dist/lib/__tests__/setupLangContext.js +65 -0
  128. package/dist/lib/__tests__/storage.test.d.ts +1 -0
  129. package/dist/lib/__tests__/storage.test.js +124 -0
  130. package/dist/lib/__tests__/utils.test.d.ts +1 -0
  131. package/dist/lib/__tests__/utils.test.js +11 -0
  132. package/dist/lib/index.d.ts +1 -0
  133. package/dist/lib/index.js +1 -0
  134. package/dist/lib/lang.d.ts +4 -0
  135. package/dist/lib/lang.js +4 -0
  136. package/dist/styles.css +234 -232
  137. package/dist/utils/index.js +15 -4
  138. 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}