sv5ui 1.5.0 → 1.6.0

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 (37) hide show
  1. package/README.md +60 -183
  2. package/dist/Checkbox/Checkbox.svelte +8 -2
  3. package/dist/CheckboxGroup/CheckboxGroup.svelte +15 -2
  4. package/dist/Form/Form.svelte +203 -0
  5. package/dist/Form/Form.svelte.d.ts +26 -0
  6. package/dist/Form/form.context.svelte.d.ts +64 -0
  7. package/dist/Form/form.context.svelte.js +478 -0
  8. package/dist/Form/form.types.d.ts +164 -0
  9. package/dist/Form/form.types.js +12 -0
  10. package/dist/Form/form.variants.d.ts +39 -0
  11. package/dist/Form/form.variants.js +17 -0
  12. package/dist/Form/index.d.ts +4 -0
  13. package/dist/Form/index.js +6 -0
  14. package/dist/Form/validate-schema.d.ts +13 -0
  15. package/dist/Form/validate-schema.js +113 -0
  16. package/dist/FormField/FormField.svelte +71 -8
  17. package/dist/FormField/form-field.types.d.ts +15 -0
  18. package/dist/Input/Input.svelte +31 -5
  19. package/dist/Input/Input.svelte.d.ts +25 -4
  20. package/dist/Input/input.types.d.ts +24 -3
  21. package/dist/PinInput/PinInput.svelte +9 -2
  22. package/dist/RadioGroup/RadioGroup.svelte +17 -3
  23. package/dist/Select/Select.svelte +14 -3
  24. package/dist/SelectMenu/SelectMenu.svelte +9 -2
  25. package/dist/Slider/Slider.svelte +4 -1
  26. package/dist/Switch/Switch.svelte +8 -2
  27. package/dist/Table/Table.svelte +11 -0
  28. package/dist/Table/table.types.d.ts +3 -0
  29. package/dist/Table/table.variants.js +5 -5
  30. package/dist/Textarea/Textarea.svelte +27 -1
  31. package/dist/hooks/index.d.ts +1 -1
  32. package/dist/hooks/index.js +1 -1
  33. package/dist/hooks/useFormField.svelte.d.ts +64 -0
  34. package/dist/hooks/useFormField.svelte.js +70 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. package/package.json +26 -3
@@ -0,0 +1,64 @@
1
+ import { SvelteSet } from 'svelte/reactivity';
2
+ import { type FormError, type FormErrorWithId, type FormErrorEvent, type FormFieldRegistryEntry, type FormInputEvents, type FormSchema, type FormSubmitEvent, type FormValidateOpts, type NestedFormEntry } from './form.types.js';
3
+ export declare const FORM_CONTEXT_KEY: unique symbol;
4
+ export declare function getFormContext<T = unknown>(): FormContext<T> | undefined;
5
+ export interface FormContextOptions<T> {
6
+ getState: () => T;
7
+ getSchema: () => FormSchema | undefined;
8
+ getCustomValidate: () => ((state: T) => FormError[] | Promise<FormError[]>) | undefined;
9
+ getValidateOn: () => FormInputEvents[];
10
+ getValidateOnInputDelay: () => number;
11
+ getDisabled: () => boolean;
12
+ getLoadingAuto: () => boolean;
13
+ getTransform: () => boolean;
14
+ getName: () => string | undefined;
15
+ getOnSubmit: () => ((event: FormSubmitEvent<unknown>) => void | Promise<void>) | undefined;
16
+ getOnError: () => ((event: FormErrorEvent) => void) | undefined;
17
+ }
18
+ export declare class FormContext<T = unknown> {
19
+ #private;
20
+ errors: FormErrorWithId[];
21
+ loading: boolean;
22
+ submitCount: number;
23
+ dirtyFields: SvelteSet<string>;
24
+ touchedFields: SvelteSet<string>;
25
+ blurredFields: SvelteSet<string>;
26
+ formId: string | number;
27
+ constructor(opts: FormContextOptions<T>, formId: string | number, parentCtx?: FormContext);
28
+ get dirty(): boolean;
29
+ get disabled(): boolean;
30
+ /**
31
+ * Current state. For nested forms, read the sub-tree from parent state by name.
32
+ * Falls back to `{}` if the parent hasn't initialized the sub-object yet —
33
+ * avoids crashing downstream validators when consumers forget to prefill it.
34
+ */
35
+ get state(): T;
36
+ getErrors(name?: string | RegExp): FormErrorWithId[];
37
+ setErrors(errs: FormError[], name?: string | RegExp): void;
38
+ clear(name?: string | RegExp): void;
39
+ /**
40
+ * Reset all form tracking state: errors, dirty/touched/blurred field sets,
41
+ * and submitCount. Does NOT modify `state` (caller owns that). Cascades to
42
+ * nested forms.
43
+ */
44
+ reset(): void;
45
+ registerField(name: string, entry: FormFieldRegistryEntry): void;
46
+ unregisterField(name: string): void;
47
+ attachChild(childFormId: string | number, entry: NestedFormEntry): void;
48
+ detachChild(childFormId: string | number): void;
49
+ onFocus(name: string): void;
50
+ onBlur(name: string): void;
51
+ onChange(name: string): void;
52
+ onInput(name: string, eager?: boolean): void;
53
+ validate(opts?: FormValidateOpts): Promise<T | false>;
54
+ /**
55
+ * Run full validation and fire onsubmit / onerror.
56
+ *
57
+ * @param sourceEvent - Real SubmitEvent from the `<form>` submit handler,
58
+ * forwarded to the user's onsubmit / onerror so they can read `submitter`,
59
+ * `preventDefault`, etc. When called programmatically (via the public API),
60
+ * a fresh SubmitEvent is synthesized.
61
+ */
62
+ submit(sourceEvent?: SubmitEvent): Promise<void>;
63
+ dispose(): void;
64
+ }
@@ -0,0 +1,478 @@
1
+ import { getContext } from 'svelte';
2
+ import { SvelteSet } from 'svelte/reactivity';
3
+ import { FormValidationException } from './form.types.js';
4
+ import { getAtPath, setAtPath, validateSchema } from './validate-schema.js';
5
+ // ==================== CONTEXT KEY ====================
6
+ export const FORM_CONTEXT_KEY = Symbol('sv5ui:form');
7
+ export function getFormContext() {
8
+ return getContext(FORM_CONTEXT_KEY);
9
+ }
10
+ // ==================== HELPERS ====================
11
+ function matchesName(errorName, target) {
12
+ if (!errorName)
13
+ return false;
14
+ return errorName === target || errorName.startsWith(target + '.');
15
+ }
16
+ function matchesTarget(errorName, target) {
17
+ if (!errorName)
18
+ return false;
19
+ if (target instanceof RegExp)
20
+ return target.test(errorName);
21
+ return matchesName(errorName, target);
22
+ }
23
+ function addFormPath(error, formPath) {
24
+ if (!formPath || !error.name)
25
+ return error;
26
+ return { ...error, name: `${formPath}.${error.name}` };
27
+ }
28
+ // ==================== FORM CONTEXT CLASS ====================
29
+ export class FormContext {
30
+ // ---------- Reactive state ----------
31
+ errors = $state([]);
32
+ loading = $state(false);
33
+ submitCount = $state(0);
34
+ dirtyFields = new SvelteSet();
35
+ touchedFields = new SvelteSet();
36
+ blurredFields = new SvelteSet();
37
+ // ---------- Non-reactive internals ----------
38
+ // These maps are intentionally plain (not SvelteMap): they are private and
39
+ // never read from reactive contexts (templates / $derived), so reactivity
40
+ // would only add proxy overhead without any subscriber to notify.
41
+ formId;
42
+ #opts;
43
+ #parentCtx;
44
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
45
+ #fieldRegistry = new Map();
46
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
47
+ #nestedForms = new Map();
48
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
49
+ #timers = new Map();
50
+ #transformedState = null;
51
+ #disposed = false;
52
+ /**
53
+ * Re-entrancy guard for submit(). Independent of `loading` because users
54
+ * can set `loadingAuto: false` (which keeps `loading` at `false` during
55
+ * an in-flight submit). Without a separate flag, rapid clicks would
56
+ * concurrently run the validation pipeline and double-count submitCount.
57
+ */
58
+ #submitting = false;
59
+ /**
60
+ * Cached Set view of `validateOn` for O(1) membership lookup on every
61
+ * event handler (onBlur / onInput / onChange / onFocus). Rebuilt lazily
62
+ * when the underlying array reference changes.
63
+ */
64
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
65
+ #validateOnSet = new Set();
66
+ #validateOnRef = null;
67
+ constructor(opts, formId, parentCtx) {
68
+ this.#opts = opts;
69
+ this.formId = formId;
70
+ this.#parentCtx = parentCtx;
71
+ }
72
+ // ==================== GETTERS ====================
73
+ get dirty() {
74
+ return this.dirtyFields.size > 0;
75
+ }
76
+ get disabled() {
77
+ return this.#opts.getDisabled() || this.loading;
78
+ }
79
+ /**
80
+ * Current state. For nested forms, read the sub-tree from parent state by name.
81
+ * Falls back to `{}` if the parent hasn't initialized the sub-object yet —
82
+ * avoids crashing downstream validators when consumers forget to prefill it.
83
+ */
84
+ get state() {
85
+ const name = this.#opts.getName();
86
+ if (this.#parentCtx && name) {
87
+ const sub = getAtPath(this.#parentCtx.state, name);
88
+ return (sub ?? {});
89
+ }
90
+ return this.#opts.getState();
91
+ }
92
+ // ==================== ERROR QUERIES ====================
93
+ getErrors(name) {
94
+ if (!name)
95
+ return this.errors;
96
+ return this.errors.filter((e) => matchesTarget(e.name, name));
97
+ }
98
+ setErrors(errs, name) {
99
+ const resolved = this.#resolveErrorIds(errs);
100
+ if (!name) {
101
+ this.errors = resolved;
102
+ }
103
+ else {
104
+ const kept = this.errors.filter((e) => !matchesTarget(e.name, name));
105
+ this.errors = [...kept, ...resolved];
106
+ }
107
+ // Cascade to nested forms so errors set on the parent propagate to the
108
+ // child's own `errors` array. Each child receives only the errors whose
109
+ // names fall under its path, with the path prefix stripped.
110
+ for (const form of this.#nestedForms.values()) {
111
+ if (!form.name)
112
+ continue;
113
+ if (name && !matchesTarget(form.name, name))
114
+ continue;
115
+ const childErrs = errs
116
+ .filter((e) => e.name && matchesName(e.name, form.name))
117
+ .map((e) => ({
118
+ ...e,
119
+ name: e.name.slice(form.name.length + 1) || undefined
120
+ }));
121
+ form.setErrors(childErrs);
122
+ }
123
+ }
124
+ clear(name) {
125
+ if (!name) {
126
+ this.errors = [];
127
+ // Cascade to nested forms
128
+ for (const form of this.#nestedForms.values()) {
129
+ form.clear();
130
+ }
131
+ return;
132
+ }
133
+ this.errors = this.errors.filter((e) => !matchesTarget(e.name, name));
134
+ // Cascade to matching nested forms. Reuses `matchesTarget` where the
135
+ // "error name" is the child form's path — if `name` equals the path
136
+ // or targets a field inside the path, we clear the whole child.
137
+ for (const form of this.#nestedForms.values()) {
138
+ if (form.name && matchesTarget(form.name, name)) {
139
+ form.clear();
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Reset all form tracking state: errors, dirty/touched/blurred field sets,
145
+ * and submitCount. Does NOT modify `state` (caller owns that). Cascades to
146
+ * nested forms.
147
+ */
148
+ reset() {
149
+ this.errors = [];
150
+ this.dirtyFields.clear();
151
+ this.touchedFields.clear();
152
+ this.blurredFields.clear();
153
+ this.submitCount = 0;
154
+ // Cascade to nested forms
155
+ for (const form of this.#nestedForms.values()) {
156
+ form.reset();
157
+ }
158
+ }
159
+ // ==================== FIELD REGISTRY ====================
160
+ registerField(name, entry) {
161
+ this.#fieldRegistry.set(name, entry);
162
+ }
163
+ unregisterField(name) {
164
+ this.#fieldRegistry.delete(name);
165
+ }
166
+ // ==================== NESTED FORMS ====================
167
+ attachChild(childFormId, entry) {
168
+ this.#nestedForms.set(childFormId, entry);
169
+ }
170
+ detachChild(childFormId) {
171
+ this.#nestedForms.delete(childFormId);
172
+ }
173
+ // ==================== EVENT HANDLERS ====================
174
+ onFocus(name) {
175
+ this.touchedFields.add(name);
176
+ if (this.#getValidateOnSet().has('focus')) {
177
+ void this.#validateField(name);
178
+ }
179
+ }
180
+ onBlur(name) {
181
+ this.touchedFields.add(name);
182
+ this.blurredFields.add(name);
183
+ if (this.#getValidateOnSet().has('blur')) {
184
+ void this.#validateField(name);
185
+ }
186
+ }
187
+ onChange(name) {
188
+ this.touchedFields.add(name);
189
+ this.dirtyFields.add(name);
190
+ if (this.#getValidateOnSet().has('change')) {
191
+ void this.#validateField(name);
192
+ }
193
+ }
194
+ onInput(name, eager = false) {
195
+ this.touchedFields.add(name);
196
+ this.dirtyFields.add(name);
197
+ if (!this.#getValidateOnSet().has('input'))
198
+ return;
199
+ const entryEager = this.#fieldRegistry.get(name)?.eagerValidation;
200
+ if (!eager && !entryEager && !this.blurredFields.has(name))
201
+ return;
202
+ this.#debounceField(name);
203
+ }
204
+ /**
205
+ * Returns a cached Set for validateOn lookups. Invalidates the cache when
206
+ * the underlying array reference from `getValidateOn()` changes, so prop
207
+ * updates still take effect.
208
+ */
209
+ #getValidateOnSet() {
210
+ const arr = this.#opts.getValidateOn();
211
+ if (arr !== this.#validateOnRef) {
212
+ this.#validateOnRef = arr;
213
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
214
+ this.#validateOnSet = new Set(arr);
215
+ }
216
+ return this.#validateOnSet;
217
+ }
218
+ // ==================== VALIDATION CORE ====================
219
+ // The validation pipeline inherently has 6 sequential stages (nested → custom →
220
+ // schema → merge → scope → transform) mirroring Nuxt UI's Form.vue. Decomposing
221
+ // further would fragment the logic across helpers without reducing real complexity.
222
+ // eslint-disable-next-line complexity
223
+ async validate(opts = {}) {
224
+ const state = this.state;
225
+ const names = opts.name ? (Array.isArray(opts.name) ? opts.name : [opts.name]) : undefined;
226
+ // 1. Nested forms validation (when opts.nested is true). Runs in two modes:
227
+ //
228
+ // (a) Full-form validate — `names` is undefined: every child validates
229
+ // everything it owns, results contribute to `nestedResults` for
230
+ // merging into the transformed state in stage 6.
231
+ //
232
+ // (b) Field-scoped validate — `names` has values: only children whose
233
+ // path is a prefix of, or equals, any requested name are called,
234
+ // and they receive only the matching sub-name(s). E.g. parent
235
+ // validate({ name: 'address.street', nested: true }) reaches a
236
+ // child form named 'address' and calls child.validate({ name:
237
+ // 'street', nested: true }).
238
+ let nestedErrors = [];
239
+ const nestedResults = [];
240
+ if (opts.nested) {
241
+ for (const form of this.#nestedForms.values()) {
242
+ // Figure out which of the requested names belong to this child.
243
+ let childNames;
244
+ if (names) {
245
+ if (!form.name)
246
+ continue;
247
+ const formPath = form.name;
248
+ const matched = [];
249
+ for (const n of names) {
250
+ if (n === formPath) {
251
+ // targeting whole child — pass undefined (full)
252
+ matched.length = 0;
253
+ matched.push('');
254
+ break;
255
+ }
256
+ if (matchesName(n, formPath)) {
257
+ matched.push(n.slice(formPath.length + 1));
258
+ }
259
+ }
260
+ if (matched.length === 0)
261
+ continue;
262
+ childNames = matched[0] === '' ? undefined : matched;
263
+ }
264
+ try {
265
+ const result = await form.validate({
266
+ name: childNames,
267
+ silent: false,
268
+ nested: true,
269
+ transform: opts.transform
270
+ });
271
+ if (result !== false) {
272
+ nestedResults.push({ name: form.name, value: result });
273
+ }
274
+ }
275
+ catch (err) {
276
+ if (err instanceof FormValidationException) {
277
+ nestedErrors = nestedErrors.concat(err.errors.map((e) => addFormPath(e, form.name)));
278
+ }
279
+ else {
280
+ throw err;
281
+ }
282
+ }
283
+ }
284
+ }
285
+ // 2. Custom validate
286
+ let customErrors = [];
287
+ const customValidate = this.#opts.getCustomValidate();
288
+ if (customValidate) {
289
+ try {
290
+ const result = await customValidate(state);
291
+ customErrors = result ?? [];
292
+ }
293
+ catch (err) {
294
+ customErrors = [
295
+ {
296
+ message: err instanceof Error ? err.message : 'Custom validation failed'
297
+ }
298
+ ];
299
+ }
300
+ }
301
+ // 3. Schema validation. Reset transformed state first — a previous
302
+ // successful run may have populated it, and we don't want that leaking
303
+ // into stage 6 if the current run fails validation.
304
+ let schemaErrors = [];
305
+ const schema = this.#opts.getSchema();
306
+ if (schema) {
307
+ this.#transformedState = null;
308
+ try {
309
+ const result = await validateSchema(state, schema);
310
+ if (result.errors) {
311
+ schemaErrors = result.errors;
312
+ }
313
+ else {
314
+ this.#transformedState = result.value ?? null;
315
+ }
316
+ }
317
+ catch (err) {
318
+ schemaErrors = [
319
+ {
320
+ message: err instanceof Error ? err.message : 'Schema validation failed'
321
+ }
322
+ ];
323
+ }
324
+ }
325
+ else {
326
+ this.#transformedState = null;
327
+ }
328
+ // 4. Merge and resolve error ids
329
+ const allErrors = this.#resolveErrorIds([...customErrors, ...schemaErrors, ...nestedErrors]);
330
+ // 5. Scope by field names if requested (field-level validation)
331
+ if (names) {
332
+ const scoped = this.#filterErrorsByNames(allErrors, names);
333
+ const untouched = this.errors.filter((e) => !this.#matchesAnyName(e, names));
334
+ const scopedMatching = allErrors.filter((e) => this.#matchesAnyName(e, names));
335
+ this.errors = [...untouched, ...scopedMatching];
336
+ if (scoped.length > 0) {
337
+ if (opts.silent)
338
+ return false;
339
+ throw new FormValidationException(this.formId, scoped);
340
+ }
341
+ }
342
+ else {
343
+ this.errors = allErrors;
344
+ if (allErrors.length > 0) {
345
+ if (opts.silent)
346
+ return false;
347
+ throw new FormValidationException(this.formId, allErrors);
348
+ }
349
+ }
350
+ // 6. Apply transform (merge nested results into either the schema-
351
+ // transformed state or a shallow clone of the current state). Runs for
352
+ // both full-form and field-scoped validation so that a field-level
353
+ // validate of a nested path still produces a transformed payload.
354
+ if (opts.transform) {
355
+ const base = this.#transformedState ??
356
+ { ...state };
357
+ for (const nr of nestedResults) {
358
+ if (nr.name) {
359
+ setAtPath(base, nr.name, nr.value);
360
+ }
361
+ else if (nr.value && typeof nr.value === 'object') {
362
+ Object.assign(base, nr.value);
363
+ }
364
+ }
365
+ return base;
366
+ }
367
+ return state;
368
+ }
369
+ // ==================== SUBMIT ====================
370
+ /**
371
+ * Run full validation and fire onsubmit / onerror.
372
+ *
373
+ * @param sourceEvent - Real SubmitEvent from the `<form>` submit handler,
374
+ * forwarded to the user's onsubmit / onerror so they can read `submitter`,
375
+ * `preventDefault`, etc. When called programmatically (via the public API),
376
+ * a fresh SubmitEvent is synthesized.
377
+ */
378
+ async submit(sourceEvent) {
379
+ // Re-entrancy guard: independent of `loading` so double-submit is blocked
380
+ // even when `loadingAuto: false`.
381
+ if (this.#submitting)
382
+ return;
383
+ this.#submitting = true;
384
+ this.submitCount++;
385
+ const loadingAuto = this.#opts.getLoadingAuto();
386
+ if (loadingAuto)
387
+ this.loading = true;
388
+ const event = sourceEvent ?? new SubmitEvent('submit');
389
+ try {
390
+ const data = await this.validate({
391
+ nested: true,
392
+ transform: this.#opts.getTransform()
393
+ });
394
+ if (data === false)
395
+ return;
396
+ const onsubmit = this.#opts.getOnSubmit();
397
+ const submitEvent = Object.assign(event, { data });
398
+ await onsubmit?.(submitEvent);
399
+ this.dirtyFields.clear();
400
+ }
401
+ catch (err) {
402
+ if (err instanceof FormValidationException) {
403
+ const onerror = this.#opts.getOnError();
404
+ const errorEvent = Object.assign(event, { errors: err.errors });
405
+ onerror?.(errorEvent);
406
+ return;
407
+ }
408
+ throw err;
409
+ }
410
+ finally {
411
+ if (loadingAuto)
412
+ this.loading = false;
413
+ this.#submitting = false;
414
+ }
415
+ }
416
+ // ==================== DISPOSAL ====================
417
+ dispose() {
418
+ this.#disposed = true;
419
+ for (const t of this.#timers.values())
420
+ clearTimeout(t);
421
+ this.#timers.clear();
422
+ this.#fieldRegistry.clear();
423
+ this.#nestedForms.clear();
424
+ // Clear reactive state too — handles the edge case where a consumer
425
+ // retains a reference to the context after component unmount.
426
+ this.errors = [];
427
+ this.loading = false;
428
+ this.submitCount = 0;
429
+ this.dirtyFields.clear();
430
+ this.touchedFields.clear();
431
+ this.blurredFields.clear();
432
+ this.#transformedState = null;
433
+ this.#submitting = false;
434
+ }
435
+ // ==================== PRIVATE: VALIDATION HELPERS ====================
436
+ #validateField(name) {
437
+ return this.validate({ name, silent: true });
438
+ }
439
+ #resolveErrorIds(errs) {
440
+ // Skip allocation when there's nothing to enrich — most FormErrorWithId
441
+ // upgrades are no-ops because the error's name isn't in the registry.
442
+ return errs.map((err) => {
443
+ if (!err.name)
444
+ return err;
445
+ const id = this.#fieldRegistry.get(err.name)?.id;
446
+ if (id === undefined)
447
+ return err;
448
+ return { ...err, id };
449
+ });
450
+ }
451
+ #matchesAnyName(error, names) {
452
+ if (!error.name)
453
+ return false;
454
+ for (const n of names) {
455
+ if (matchesName(error.name, n))
456
+ return true;
457
+ const pattern = this.#fieldRegistry.get(n)?.pattern;
458
+ if (pattern && pattern.test(error.name))
459
+ return true;
460
+ }
461
+ return false;
462
+ }
463
+ #filterErrorsByNames(all, names) {
464
+ return all.filter((e) => this.#matchesAnyName(e, names));
465
+ }
466
+ #debounceField(name) {
467
+ const delay = this.#fieldRegistry.get(name)?.validateOnInputDelay ??
468
+ this.#opts.getValidateOnInputDelay();
469
+ const existing = this.#timers.get(name);
470
+ if (existing)
471
+ clearTimeout(existing);
472
+ this.#timers.set(name, setTimeout(() => {
473
+ this.#timers.delete(name);
474
+ if (!this.#disposed)
475
+ void this.#validateField(name);
476
+ }, delay));
477
+ }
478
+ }