@formisch/svelte 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Formisch is also available for [Preact][formisch-preact], [Qwik][formisch-qwik],
9
9
  - Small bundle size starting at 2.5 kB
10
10
  - Schema-based validation with Valibot
11
11
  - Type safety with autocompletion in editor
12
+ - Open source and fully tested with 100 % coverage
12
13
  - It's fast – DOM updates are fine-grained
13
14
  - Minimal, readable and well thought out API
14
15
  - Supports all native HTML form fields
@@ -78,6 +78,13 @@ interface InternalBaseStore {
78
78
  schema: Schema;
79
79
  /**
80
80
  * The initial elements of the field.
81
+ *
82
+ * Hint: This may look unused, but do not remove it. `copyItemState` and
83
+ * `swapItemState` move the `elements` reference between field stores when
84
+ * array items are inserted, moved, removed or swapped, and `reset` restores
85
+ * each field's original element via `elements = initialElements`. Without it,
86
+ * focus and file reset target the wrong element after a reorder followed by a
87
+ * reset.
81
88
  */
82
89
  initialElements: FieldElement[];
83
90
  /**
@@ -399,7 +406,10 @@ type ExactRequired<TValue> = TValue extends Record<PropertyKey, unknown> ? IsExa
399
406
  */
400
407
  type PathValue<TValue, TPath extends Path> = TPath extends readonly [infer TKey, ...infer TRest extends Path] ? TKey extends ExactKeysOf<ExactRequired<TValue>> ? PathValue<PropertiesOf<ExactRequired<TValue>>[TKey], TRest> : unknown : TValue;
401
408
  /**
402
- * Checks whether a value is an array or contains one anywhere in its shape.
409
+ * Checks whether a value is a dynamic array or contains one anywhere in its
410
+ * shape. A fixed-length tuple is not itself a dynamic array, but it counts when
411
+ * it contains one, so paths can still navigate through tuples to reach nested
412
+ * arrays.
403
413
  *
404
414
  * Hint: The inner conditionals (`TValue extends readonly unknown[]` and
405
415
  * `TValue extends Record<PropertyKey, unknown>`) distribute over union members,
@@ -410,7 +420,7 @@ type PathValue<TValue, TPath extends Path> = TPath extends readonly [infer TKey,
410
420
  * `true extends ...`, which is `true` whenever at least one union member
411
421
  * contributed `true`.
412
422
  */
413
- type IsOrHasArray<TValue> = true extends (IsAny<TValue> extends true ? false : TValue extends readonly unknown[] ? true : TValue extends Record<PropertyKey, unknown> ? { [TKey in keyof TValue]: IsOrHasArray<TValue[TKey]> }[keyof TValue] : false) ? true : false;
423
+ type IsOrHasArray<TValue> = true extends (IsAny<TValue> extends true ? false : TValue extends readonly unknown[] ? number extends TValue["length"] ? true : IsOrHasArray<TValue[number]> : TValue extends Record<PropertyKey, unknown> ? { [TKey in keyof TValue]: IsOrHasArray<TValue[TKey]> }[keyof TValue] : false) ? true : false;
414
424
  /**
415
425
  * Extracts the exact keys of a tuple, array or object that contain arrays.
416
426
  */
@@ -425,7 +435,7 @@ type PropertiesOfArrayPath<TValue> = { [TKey in ExactKeysOfArrayPath<TValue>]: T
425
435
  /**
426
436
  * Lazily evaluates only the first valid array path segment based on the given value.
427
437
  */
428
- type LazyArrayPath<TValue, TPathToCheck extends Path, TValidPath extends Path = readonly []> = TPathToCheck extends readonly [] ? TValue extends readonly unknown[] ? TValidPath : readonly [...TValidPath, ExactKeysOfArrayPath<TValue>] : TPathToCheck extends readonly [infer TFirstKey extends ExactKeysOfArrayPath<TValue>, ...infer TPathRest extends Path] ? LazyArrayPath<Required<PropertiesOfArrayPath<TValue>[TFirstKey]>, TPathRest, readonly [...TValidPath, TFirstKey]> : IsNever<ExactKeysOfArrayPath<TValue>> extends false ? readonly [...TValidPath, ExactKeysOfArrayPath<TValue>] : never;
438
+ type LazyArrayPath<TValue, TPathToCheck extends Path, TValidPath extends Path = readonly []> = TPathToCheck extends readonly [] ? TValue extends readonly unknown[] ? number extends TValue["length"] ? TValidPath : IsNever<ExactKeysOfArrayPath<TValue>> extends false ? readonly [...TValidPath, ExactKeysOfArrayPath<TValue>] : never : readonly [...TValidPath, ExactKeysOfArrayPath<TValue>] : TPathToCheck extends readonly [infer TFirstKey extends ExactKeysOfArrayPath<TValue>, ...infer TPathRest extends Path] ? LazyArrayPath<Required<PropertiesOfArrayPath<TValue>[TFirstKey]>, TPathRest, readonly [...TValidPath, TFirstKey]> : IsNever<ExactKeysOfArrayPath<TValue>> extends false ? readonly [...TValidPath, ExactKeysOfArrayPath<TValue>] : never;
429
439
  /**
430
440
  * Returns the path if valid, otherwise the first possible valid array path
431
441
  * based on the given value.
@@ -475,9 +485,12 @@ declare function copyItemState(fromInternalFieldStore: InternalFieldStore, toInt
475
485
  * form reset functionality.
476
486
  *
477
487
  * @param internalFieldStore The field store to reset.
478
- * @param initialInput The new input value (can be any type including array or object).
488
+ * @param input The new input value (can be any type including array or object).
489
+ * @param keepStart Whether to keep `startInput` and `startItems` as the dirty
490
+ * baseline instead of resetting them to the new input. Used when a field store
491
+ * is reused for an in-place edit so its dirty state is detected correctly.
479
492
  */
480
- declare function resetItemState(internalFieldStore: InternalFieldStore, initialInput: unknown): void;
493
+ declare function resetItemState(internalFieldStore: InternalFieldStore, input: unknown, keepStart?: boolean): void;
481
494
  //#endregion
482
495
  //#region src/array/swapItemState/swapItemState.d.ts
483
496
  /**
@@ -491,6 +504,24 @@ declare function resetItemState(internalFieldStore: InternalFieldStore, initialI
491
504
  */
492
505
  declare function swapItemState(firstInternalFieldStore: InternalFieldStore, secondInternalFieldStore: InternalFieldStore): void;
493
506
  //#endregion
507
+ //#region src/field/focusFieldElement/focusFieldElement.d.ts
508
+ /**
509
+ * Focuses the first focusable element of a field store. The elements are tried
510
+ * in order and the first one that actually receives focus wins, so detached,
511
+ * disabled or hidden elements are skipped. The browser decides focusability,
512
+ * which is read back via the element's root `activeElement` so elements in a
513
+ * shadow root or another document are handled correctly.
514
+ *
515
+ * Hint: A `display: none` or `hidden` element is correctly skipped in real
516
+ * browsers, but jsdom has no layout and focuses it anyway, so that case cannot
517
+ * be covered by unit tests.
518
+ *
519
+ * @param internalFieldStore The field store to focus.
520
+ *
521
+ * @returns Whether an element was focused.
522
+ */
523
+ declare function focusFieldElement(internalFieldStore: InternalFieldStore): boolean;
524
+ //#endregion
494
525
  //#region src/field/getDirtyFieldInput/getDirtyFieldInput.d.ts
495
526
  /**
496
527
  * Returns only the dirty input of the field store. Arrays are treated as
@@ -624,6 +655,23 @@ declare function walkFieldStore(internalFieldStore: InternalFieldStore, callback
624
655
  */
625
656
  declare function createFormStore(config: FormConfig, parse: (input: unknown) => Promise<v.SafeParseResult<FormSchema>>): InternalFormStore;
626
657
  //#endregion
658
+ //#region src/form/decodeFormData/decodeFormData.d.ts
659
+ /**
660
+ * Decodes the entries of a form data object into nested form values using the
661
+ * Valibot schema as the source of truth. Information that is lost during the
662
+ * transfer via HTTP, like numbers, booleans, dates and unchecked checkboxes,
663
+ * is restored based on the schema.
664
+ *
665
+ * The keys of the form data are expected to be the stringified field paths that
666
+ * Formisch assigns to its field elements (for example `["todos",0,"label"]`).
667
+ *
668
+ * @param schema The form schema.
669
+ * @param formData The form data object.
670
+ *
671
+ * @returns The decoded form values.
672
+ */
673
+ declare function decodeFormData<TSchema extends FormSchema>(schema: TSchema, formData: FormData): unknown;
674
+ //#endregion
627
675
  //#region src/form/validateFormInput/validateFormInput.d.ts
628
676
  /**
629
677
  * Validate form input config interface.
@@ -693,4 +741,4 @@ declare function createSignal<T>(initialValue: T): Signal<T>;
693
741
  */
694
742
  declare function batch<T>(fn: () => T): T;
695
743
  //#endregion
696
- export { BaseFormStore, Batch, DeepPartial, DirtyPath, FieldElement, FieldSchema, FormConfig, FormSchema, INTERNAL, InternalArrayStore, InternalBaseStore, InternalFieldStore, InternalFormStore, InternalObjectStore, InternalValueStore, IsAny, IsNever, MaybePromise, PartialValues, Path, PathKey, PathValue, RequiredPath, Schema, Signal, SubmitEventHandler, SubmitHandler, Untrack, ValidArrayPath, ValidPath, ValidateFormInputConfig, ValidationMode, batch, copyItemState, createFormStore, createId, createSignal, framework, getDirtyFieldInput, getElementInput, getFieldBool, getFieldInput, getFieldStore, initializeFieldStore, resetItemState, setFieldBool, setFieldInput, setInitialFieldInput, swapItemState, untrack, validateFormInput, validateIfRequired, walkFieldStore };
744
+ export { BaseFormStore, Batch, DeepPartial, DirtyPath, FieldElement, FieldSchema, FormConfig, FormSchema, INTERNAL, InternalArrayStore, InternalBaseStore, InternalFieldStore, InternalFormStore, InternalObjectStore, InternalValueStore, IsAny, IsNever, MaybePromise, PartialValues, Path, PathKey, PathValue, RequiredPath, Schema, Signal, SubmitEventHandler, SubmitHandler, Untrack, ValidArrayPath, ValidPath, ValidateFormInputConfig, ValidationMode, batch, copyItemState, createFormStore, createId, createSignal, decodeFormData, focusFieldElement, framework, getDirtyFieldInput, getElementInput, getFieldBool, getFieldInput, getFieldStore, initializeFieldStore, resetItemState, setFieldBool, setFieldInput, setInitialFieldInput, swapItemState, untrack, validateFormInput, validateIfRequired, walkFieldStore };
@@ -179,31 +179,45 @@ function copyItemState(fromInternalFieldStore, toInternalFieldStore) {
179
179
  * form reset functionality.
180
180
  *
181
181
  * @param internalFieldStore The field store to reset.
182
- * @param initialInput The new input value (can be any type including array or object).
182
+ * @param input The new input value (can be any type including array or object).
183
+ * @param keepStart Whether to keep `startInput` and `startItems` as the dirty
184
+ * baseline instead of resetting them to the new input. Used when a field store
185
+ * is reused for an in-place edit so its dirty state is detected correctly.
183
186
  */
184
- function resetItemState(internalFieldStore, initialInput) {
187
+ function resetItemState(internalFieldStore, input, keepStart = false) {
185
188
  batch(() => {
186
- internalFieldStore.elements = [];
189
+ const elements = [];
190
+ if (internalFieldStore.elements === internalFieldStore.initialElements) internalFieldStore.initialElements = elements;
191
+ internalFieldStore.elements = elements;
187
192
  internalFieldStore.errors.value = null;
188
193
  internalFieldStore.isTouched.value = false;
189
194
  internalFieldStore.isDirty.value = false;
190
195
  if (internalFieldStore.kind === "array" || internalFieldStore.kind === "object") {
191
- const objectInput = initialInput == null ? initialInput : true;
192
- internalFieldStore.startInput.value = objectInput;
196
+ const objectInput = input == null ? input : true;
197
+ if (!keepStart) internalFieldStore.startInput.value = objectInput;
193
198
  internalFieldStore.input.value = objectInput;
194
- if (internalFieldStore.kind === "array") if (initialInput) {
195
- const newItems = initialInput.map(createId);
196
- internalFieldStore.startItems.value = newItems;
199
+ if (internalFieldStore.kind === "array") if (input) {
200
+ const length = internalFieldStore.schema.type === "array" ? input.length : internalFieldStore.children.length;
201
+ const newItems = Array.from({ length }, createId);
202
+ if (!keepStart) internalFieldStore.startItems.value = newItems;
197
203
  internalFieldStore.items.value = newItems;
198
- for (let index = 0; index < initialInput.length; index++) if (internalFieldStore.children[index]) resetItemState(internalFieldStore.children[index], initialInput[index]);
204
+ let path;
205
+ for (let index = 0; index < length; index++) if (internalFieldStore.children[index]) resetItemState(internalFieldStore.children[index], input[index], keepStart);
206
+ else {
207
+ path ??= JSON.parse(internalFieldStore.name);
208
+ internalFieldStore.children[index] = {};
209
+ path.push(index);
210
+ initializeFieldStore(internalFieldStore.children[index], internalFieldStore.schema.item, input[index], path);
211
+ path.pop();
212
+ }
199
213
  } else {
200
- internalFieldStore.startItems.value = [];
214
+ if (!keepStart) internalFieldStore.startItems.value = [];
201
215
  internalFieldStore.items.value = [];
202
216
  }
203
- else for (const key in internalFieldStore.children) resetItemState(internalFieldStore.children[key], initialInput?.[key]);
217
+ else for (const key in internalFieldStore.children) resetItemState(internalFieldStore.children[key], input?.[key], keepStart);
204
218
  } else {
205
- internalFieldStore.startInput.value = initialInput;
206
- internalFieldStore.input.value = initialInput;
219
+ if (!keepStart) internalFieldStore.startInput.value = input;
220
+ internalFieldStore.input.value = input;
207
221
  }
208
222
  });
209
223
  }
@@ -273,6 +287,31 @@ function swapItemState(firstInternalFieldStore, secondInternalFieldStore) {
273
287
  });
274
288
  }
275
289
 
290
+ //#endregion
291
+ //#region src/field/focusFieldElement/focusFieldElement.ts
292
+ /**
293
+ * Focuses the first focusable element of a field store. The elements are tried
294
+ * in order and the first one that actually receives focus wins, so detached,
295
+ * disabled or hidden elements are skipped. The browser decides focusability,
296
+ * which is read back via the element's root `activeElement` so elements in a
297
+ * shadow root or another document are handled correctly.
298
+ *
299
+ * Hint: A `display: none` or `hidden` element is correctly skipped in real
300
+ * browsers, but jsdom has no layout and focuses it anyway, so that case cannot
301
+ * be covered by unit tests.
302
+ *
303
+ * @param internalFieldStore The field store to focus.
304
+ *
305
+ * @returns Whether an element was focused.
306
+ */
307
+ function focusFieldElement(internalFieldStore) {
308
+ for (const element of internalFieldStore.elements) {
309
+ element.focus();
310
+ if (element.getRootNode().activeElement === element) return true;
311
+ }
312
+ return false;
313
+ }
314
+
276
315
  //#endregion
277
316
  //#region src/field/getFieldBool/getFieldBool.ts
278
317
  /**
@@ -428,11 +467,9 @@ function getFieldStore(internalFormStore, path) {
428
467
  */
429
468
  function setFieldBool(internalFieldStore, type, bool) {
430
469
  batch(() => {
431
- if (internalFieldStore.kind === "array") {
432
- internalFieldStore[type].value = bool;
433
- for (let index = 0; index < untrack(() => internalFieldStore.items.value).length; index++) setFieldBool(internalFieldStore.children[index], type, bool);
434
- } else if (internalFieldStore.kind == "object") for (const key in internalFieldStore.children) setFieldBool(internalFieldStore.children[key], type, bool);
435
- else internalFieldStore[type].value = bool;
470
+ internalFieldStore[type].value = bool;
471
+ if (internalFieldStore.kind === "array") for (let index = 0; index < untrack(() => internalFieldStore.items.value).length; index++) setFieldBool(internalFieldStore.children[index], type, bool);
472
+ else if (internalFieldStore.kind === "object") for (const key in internalFieldStore.children) setFieldBool(internalFieldStore.children[key], type, bool);
436
473
  });
437
474
  }
438
475
 
@@ -450,20 +487,20 @@ function setNestedInput(internalFieldStore, input) {
450
487
  if (internalFieldStore.kind === "array") {
451
488
  const arrayInput = input ?? [];
452
489
  const items = internalFieldStore.items.value;
453
- if (arrayInput.length < items.length) internalFieldStore.items.value = items.slice(0, arrayInput.length);
454
- else if (arrayInput.length > items.length) {
455
- if (arrayInput.length > internalFieldStore.children.length) {
456
- const path = JSON.parse(internalFieldStore.name);
457
- for (let index = internalFieldStore.children.length; index < arrayInput.length; index++) {
458
- internalFieldStore.children[index] = {};
459
- path.push(index);
460
- initializeFieldStore(internalFieldStore.children[index], internalFieldStore.schema.item, arrayInput[index], path);
461
- path.pop();
462
- }
490
+ const length = internalFieldStore.schema.type === "array" ? arrayInput.length : internalFieldStore.children.length;
491
+ if (length < items.length) internalFieldStore.items.value = items.slice(0, length);
492
+ else if (length > items.length) {
493
+ const path = JSON.parse(internalFieldStore.name);
494
+ for (let index = items.length; index < length; index++) if (internalFieldStore.children[index]) resetItemState(internalFieldStore.children[index], arrayInput[index], true);
495
+ else {
496
+ internalFieldStore.children[index] = {};
497
+ path.push(index);
498
+ initializeFieldStore(internalFieldStore.children[index], internalFieldStore.schema.item, arrayInput[index], path);
499
+ path.pop();
463
500
  }
464
- internalFieldStore.items.value = [...items, ...arrayInput.slice(items.length).map(createId)];
501
+ internalFieldStore.items.value = [...items, ...Array.from({ length: length - items.length }, createId)];
465
502
  }
466
- for (let index = 0; index < arrayInput.length; index++) setNestedInput(internalFieldStore.children[index], arrayInput[index]);
503
+ for (let index = 0; index < length; index++) setNestedInput(internalFieldStore.children[index], arrayInput[index]);
467
504
  internalFieldStore.input.value = input == null ? input : true;
468
505
  internalFieldStore.isDirty.value = internalFieldStore.startInput.value !== internalFieldStore.input.value || internalFieldStore.startItems.value.length !== internalFieldStore.items.value.length;
469
506
  } else if (internalFieldStore.kind === "object") {
@@ -510,21 +547,22 @@ function setFieldInput(internalFormStore, path, input) {
510
547
  function setInitialFieldInput(internalFieldStore, initialInput) {
511
548
  batch(() => {
512
549
  if (internalFieldStore.kind === "array") {
513
- internalFieldStore.input.value = initialInput == null ? initialInput : true;
550
+ internalFieldStore.initialInput.value = initialInput == null ? initialInput : true;
514
551
  const initialArrayInput = initialInput ?? [];
515
- if (initialArrayInput.length > internalFieldStore.children.length) {
552
+ const length = internalFieldStore.schema.type === "array" ? initialArrayInput.length : internalFieldStore.children.length;
553
+ if (length > internalFieldStore.children.length) {
516
554
  const path = JSON.parse(internalFieldStore.name);
517
- for (let index = internalFieldStore.children.length; index < initialArrayInput.length; index++) {
555
+ for (let index = internalFieldStore.children.length; index < length; index++) {
518
556
  internalFieldStore.children[index] = {};
519
557
  path.push(index);
520
558
  initializeFieldStore(internalFieldStore.children[index], internalFieldStore.schema.item, initialArrayInput[index], path);
521
559
  path.pop();
522
560
  }
523
561
  }
524
- internalFieldStore.initialItems.value = initialArrayInput.map(createId);
562
+ internalFieldStore.initialItems.value = Array.from({ length }, createId);
525
563
  for (let index = 0; index < internalFieldStore.children.length; index++) setInitialFieldInput(internalFieldStore.children[index], initialArrayInput[index]);
526
564
  } else if (internalFieldStore.kind === "object") {
527
- internalFieldStore.input.value = initialInput == null ? initialInput : true;
565
+ internalFieldStore.initialInput.value = initialInput == null ? initialInput : true;
528
566
  for (const key in internalFieldStore.children) setInitialFieldInput(internalFieldStore.children[key], initialInput?.[key]);
529
567
  } else internalFieldStore.initialInput.value = initialInput;
530
568
  });
@@ -570,6 +608,203 @@ function createFormStore(config, parse) {
570
608
  return store;
571
609
  }
572
610
 
611
+ //#endregion
612
+ //#region src/form/decodeFormData/decodeFormData.ts
613
+ const NUMBER_REGEX = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;
614
+ const ISO_DATE_TIME_REGEX = /^\d{4}-(?:0[1-9]|1[0-2])-(?:[12]\d|0[1-9]|3[01])T(?:0\d|1\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?$/u;
615
+ const MAX_ARRAY_LENGTH = 5e3;
616
+ /**
617
+ * Unwraps wrapper and lazy schemas until a concrete schema is reached.
618
+ *
619
+ * @param schema The schema to unwrap.
620
+ *
621
+ * @returns The unwrapped schema.
622
+ */
623
+ function unwrapSchema(schema) {
624
+ switch (schema.type) {
625
+ case "exact_optional":
626
+ case "nullable":
627
+ case "nullish":
628
+ case "optional":
629
+ case "undefinedable":
630
+ case "non_nullable":
631
+ case "non_nullish":
632
+ case "non_optional": return unwrapSchema(schema.wrapped);
633
+ case "lazy": return unwrapSchema(schema.getter(void 0));
634
+ default: return schema;
635
+ }
636
+ }
637
+ /**
638
+ * Returns the child schema for the given key by traversing objects, arrays,
639
+ * tuples and schema options. Returns `undefined` if no child schema is found.
640
+ *
641
+ * @param schema The parent schema.
642
+ * @param key The path key.
643
+ *
644
+ * @returns The child schema or `undefined`.
645
+ */
646
+ function getChildSchema(schema, key) {
647
+ if (schema) {
648
+ const unwrapped = unwrapSchema(schema);
649
+ if (unwrapped.type === "object" || unwrapped.type === "loose_object" || unwrapped.type === "strict_object") return unwrapped.entries[key];
650
+ if (unwrapped.type === "array") return unwrapped.item;
651
+ if (unwrapped.type === "tuple" || unwrapped.type === "loose_tuple" || unwrapped.type === "strict_tuple") return unwrapped.items[key];
652
+ if (unwrapped.type === "union" || unwrapped.type === "intersect" || unwrapped.type === "variant") for (const option of unwrapped.options) {
653
+ const childSchema = getChildSchema(option, key);
654
+ if (childSchema !== void 0) return childSchema;
655
+ }
656
+ }
657
+ }
658
+ /**
659
+ * Decodes a stringified date based on its format. Empty strings become `null`.
660
+ *
661
+ * @param value The stringified value.
662
+ *
663
+ * @returns The decoded date.
664
+ */
665
+ function decodeDate(value) {
666
+ if (!value || value === "null") return null;
667
+ if (value === "undefined") return;
668
+ if (ISO_DATE_TIME_REGEX.test(value)) return /* @__PURE__ */ new Date(`${value}Z`);
669
+ return new Date(value);
670
+ }
671
+ /**
672
+ * Decodes a stringified boolean. Empty strings become `null`.
673
+ *
674
+ * @param value The stringified value.
675
+ *
676
+ * @returns The decoded boolean.
677
+ */
678
+ function decodeBoolean(value) {
679
+ if (!value || value === "null") return null;
680
+ if (value === "undefined") return;
681
+ return !(value === "false" || value === "off" || value === "0");
682
+ }
683
+ /**
684
+ * Decodes a stringified number. Empty strings become `null` and non-numeric
685
+ * values become `NaN`.
686
+ *
687
+ * @param value The stringified value.
688
+ *
689
+ * @returns The decoded number.
690
+ */
691
+ function decodeNumber(value) {
692
+ if (!value || value === "null") return null;
693
+ if (value === "undefined") return;
694
+ if (NUMBER_REGEX.test(value)) return Number(value);
695
+ return NaN;
696
+ }
697
+ /**
698
+ * Decodes a stringified bigint. Empty strings become `null` and invalid values
699
+ * are returned unchanged.
700
+ *
701
+ * @param value The stringified value.
702
+ *
703
+ * @returns The decoded bigint.
704
+ */
705
+ function decodeBigint(value) {
706
+ if (!value || value === "null") return null;
707
+ if (value === "undefined") return;
708
+ try {
709
+ return BigInt(value);
710
+ } catch {
711
+ return value;
712
+ }
713
+ }
714
+ /**
715
+ * Decodes a single form data value based on the concrete schema type. Files
716
+ * and unknown types are returned unchanged.
717
+ *
718
+ * @param value The form data value.
719
+ * @param schema The schema of the value.
720
+ *
721
+ * @returns The decoded value.
722
+ */
723
+ function decodeValue(value, schema) {
724
+ if (typeof value !== "string" || !schema) return value;
725
+ switch (unwrapSchema(schema).type) {
726
+ case "number": return decodeNumber(value);
727
+ case "boolean": return decodeBoolean(value);
728
+ case "date": return decodeDate(value);
729
+ case "bigint": return decodeBigint(value);
730
+ default: return value;
731
+ }
732
+ }
733
+ /**
734
+ * Fills in default values that are lost during the form data transfer. Booleans
735
+ * of unchecked checkboxes become `false` and absent arrays become empty. Only
736
+ * containers that are present in the decoded data are completed.
737
+ *
738
+ * @param schema The schema of the value.
739
+ * @param parent The parent object or array holding the value.
740
+ * @param key The key of the value within its parent.
741
+ */
742
+ function fillDefaults(schema, parent, key) {
743
+ const unwrappedSchema = unwrapSchema(schema);
744
+ if (unwrappedSchema.type === "boolean") {
745
+ if (parent[key] === void 0) parent[key] = false;
746
+ } else if (unwrappedSchema.type === "array") if (Array.isArray(parent[key])) for (let index = 0; index < parent[key].length; index++) fillDefaults(unwrappedSchema.item, parent[key], index);
747
+ else parent[key] = [];
748
+ else if (unwrappedSchema.type === "tuple" || unwrappedSchema.type === "loose_tuple" || unwrappedSchema.type === "strict_tuple") {
749
+ if (Array.isArray(parent[key])) for (let index = 0; index < unwrappedSchema.items.length; index++) fillDefaults(unwrappedSchema.items[index], parent[key], index);
750
+ } else if (unwrappedSchema.type === "object" || unwrappedSchema.type === "loose_object" || unwrappedSchema.type === "strict_object") {
751
+ if (parent[key] && typeof parent[key] === "object") for (const entryKey in unwrappedSchema.entries) fillDefaults(unwrappedSchema.entries[entryKey], parent[key], entryKey);
752
+ } else if (unwrappedSchema.type === "union" || unwrappedSchema.type === "intersect" || unwrappedSchema.type === "variant") for (const option of unwrappedSchema.options) fillDefaults(option, parent, key);
753
+ }
754
+ /**
755
+ * Decodes the entries of a form data object into nested form values using the
756
+ * Valibot schema as the source of truth. Information that is lost during the
757
+ * transfer via HTTP, like numbers, booleans, dates and unchecked checkboxes,
758
+ * is restored based on the schema.
759
+ *
760
+ * The keys of the form data are expected to be the stringified field paths that
761
+ * Formisch assigns to its field elements (for example `["todos",0,"label"]`).
762
+ *
763
+ * @param schema The form schema.
764
+ * @param formData The form data object.
765
+ *
766
+ * @returns The decoded form values.
767
+ */
768
+ /* @__NO_SIDE_EFFECTS__ */
769
+ function decodeFormData(schema, formData) {
770
+ const values = {};
771
+ formData.forEach((value, key) => {
772
+ let path = null;
773
+ try {
774
+ path = JSON.parse(key);
775
+ } catch {}
776
+ if (Array.isArray(path) && path.length > 0 && (typeof value === "string" || value.size > 0 || value.name !== "")) {
777
+ let parentValue = values;
778
+ let parentSchema = schema;
779
+ for (let index = 0; index < path.length; index++) {
780
+ const segment = path[index];
781
+ if (typeof segment !== "string" && typeof segment !== "number" || segment === "" || segment === "__proto__" || segment === "prototype" || segment === "constructor") break;
782
+ if (Array.isArray(parentValue)) {
783
+ if (typeof segment === "string") break;
784
+ if (segment >= MAX_ARRAY_LENGTH) throw new Error(`Array exceeds the maximum length of ${MAX_ARRAY_LENGTH}`);
785
+ }
786
+ const childSchema = getChildSchema(parentSchema, segment);
787
+ if (index === path.length - 1) {
788
+ const unwrappedSchema = childSchema && unwrapSchema(childSchema);
789
+ if (unwrappedSchema && unwrappedSchema.type === "array") {
790
+ parentValue[segment] ??= [];
791
+ parentValue[segment].push(decodeValue(value, unwrappedSchema.item));
792
+ } else parentValue[segment] = decodeValue(value, childSchema);
793
+ } else {
794
+ if (parentValue[segment] == null) {
795
+ const schemaType = childSchema && unwrapSchema(childSchema).type;
796
+ parentValue[segment] = schemaType === "array" || schemaType === "tuple" || schemaType === "loose_tuple" || schemaType === "strict_tuple" ? [] : {};
797
+ } else if (typeof parentValue[segment] !== "object") break;
798
+ parentValue = parentValue[segment];
799
+ parentSchema = childSchema;
800
+ }
801
+ }
802
+ }
803
+ });
804
+ fillDefaults(schema, { values }, "values");
805
+ return values;
806
+ }
807
+
573
808
  //#endregion
574
809
  //#region src/form/validateFormInput/validateFormInput.ts
575
810
  /**
@@ -585,44 +820,49 @@ function createFormStore(config, parse) {
585
820
  async function validateFormInput(internalFormStore, config) {
586
821
  internalFormStore.validators++;
587
822
  internalFormStore.isValidating.value = true;
588
- const result = await internalFormStore.parse(untrack(() => /* @__PURE__ */ getFieldInput(internalFormStore)));
589
- let rootErrors;
590
- let nestedErrors;
591
- if (result.issues) {
592
- nestedErrors = {};
593
- for (const issue of result.issues) if (issue.path) {
594
- const path = [];
595
- for (const pathItem of issue.path) {
596
- const key = pathItem.key;
597
- const keyType = typeof key;
598
- const itemType = pathItem.type;
599
- if (keyType !== "string" && keyType !== "number" || itemType === "map" || itemType === "set") break;
600
- path.push(key);
601
- }
602
- const name = JSON.stringify(path);
603
- const fieldErrors = nestedErrors[name];
604
- if (fieldErrors) fieldErrors.push(issue.message);
605
- else nestedErrors[name] = [issue.message];
606
- } else if (rootErrors) rootErrors.push(issue.message);
607
- else rootErrors = [issue.message];
608
- }
609
- let shouldFocus = config?.shouldFocus ?? false;
610
- batch(() => {
611
- walkFieldStore(internalFormStore, (internalFieldStore) => {
612
- if (internalFieldStore.name === "[]") internalFieldStore.errors.value = rootErrors ?? null;
613
- else {
614
- const fieldErrors = nestedErrors?.[internalFieldStore.name] ?? null;
615
- internalFieldStore.errors.value = fieldErrors;
616
- if (shouldFocus && fieldErrors) {
617
- internalFieldStore.elements[0]?.focus();
618
- shouldFocus = false;
823
+ try {
824
+ const result = await internalFormStore.parse(untrack(() => /* @__PURE__ */ getFieldInput(internalFormStore)));
825
+ let rootErrors;
826
+ let nestedErrors;
827
+ if (result.issues) {
828
+ nestedErrors = {};
829
+ for (const issue of result.issues) if (issue.path) {
830
+ const path = [];
831
+ for (const pathItem of issue.path) {
832
+ const key = pathItem.key;
833
+ const keyType = typeof key;
834
+ const itemType = pathItem.type;
835
+ if (keyType !== "string" && keyType !== "number" || itemType === "map" || itemType === "set") break;
836
+ path.push(key);
619
837
  }
620
- }
838
+ const name = JSON.stringify(path);
839
+ const fieldErrors = nestedErrors[name];
840
+ if (fieldErrors) fieldErrors.push(issue.message);
841
+ else nestedErrors[name] = [issue.message];
842
+ } else if (rootErrors) rootErrors.push(issue.message);
843
+ else rootErrors = [issue.message];
844
+ }
845
+ let shouldFocus = config?.shouldFocus ?? false;
846
+ batch(() => {
847
+ walkFieldStore(internalFormStore, (internalFieldStore) => {
848
+ if (internalFieldStore.name === "[]") internalFieldStore.errors.value = rootErrors ?? null;
849
+ else {
850
+ const fieldErrors = nestedErrors?.[internalFieldStore.name] ?? null;
851
+ internalFieldStore.errors.value = fieldErrors;
852
+ if (shouldFocus && fieldErrors && focusFieldElement(internalFieldStore)) shouldFocus = false;
853
+ }
854
+ });
855
+ internalFormStore.validators--;
856
+ internalFormStore.isValidating.value = internalFormStore.validators > 0;
621
857
  });
622
- internalFormStore.validators--;
623
- internalFormStore.isValidating.value = internalFormStore.validators > 0;
624
- });
625
- return result;
858
+ return result;
859
+ } catch (error) {
860
+ batch(() => {
861
+ internalFormStore.validators--;
862
+ internalFormStore.isValidating.value = internalFormStore.validators > 0;
863
+ });
864
+ throw error;
865
+ }
626
866
  }
627
867
 
628
868
  //#endregion
@@ -648,4 +888,4 @@ function validateIfRequired(internalFormStore, internalFieldStore, validationMod
648
888
  const INTERNAL = "~internal";
649
889
 
650
890
  //#endregion
651
- export { INTERNAL, batch, copyItemState, createFormStore, createId, createSignal, framework, getDirtyFieldInput, getElementInput, getFieldBool, getFieldInput, getFieldStore, initializeFieldStore, resetItemState, setFieldBool, setFieldInput, setInitialFieldInput, swapItemState, untrack, validateFormInput, validateIfRequired, walkFieldStore };
891
+ export { INTERNAL, batch, copyItemState, createFormStore, createId, createSignal, decodeFormData, focusFieldElement, framework, getDirtyFieldInput, getElementInput, getFieldBool, getFieldInput, getFieldStore, initializeFieldStore, resetItemState, setFieldBool, setFieldInput, setInitialFieldInput, swapItemState, untrack, validateFormInput, validateIfRequired, walkFieldStore };
@@ -13,7 +13,7 @@ interface FocusFieldConfig<TSchema extends FormSchema, TFieldPath extends Requir
13
13
  readonly path: ValidPath<v.InferInput<TSchema>, TFieldPath>;
14
14
  }
15
15
  /**
16
- * Focuses the first input element of a field. This is useful for
16
+ * Focuses the first focusable input element of a field. This is useful for
17
17
  * programmatically setting focus to a specific field, such as after
18
18
  * validation errors or user interactions.
19
19
  *
@@ -1,8 +1,8 @@
1
- import { INTERNAL, batch, copyItemState, createId, getDirtyFieldInput, getFieldBool, getFieldInput, getFieldStore, initializeFieldStore, resetItemState, setFieldInput, setInitialFieldInput, swapItemState, untrack, validateFormInput, validateIfRequired, walkFieldStore } from "../core/index.svelte";
1
+ import { INTERNAL, batch, copyItemState, createId, focusFieldElement, getDirtyFieldInput, getFieldBool, getFieldInput, getFieldStore, initializeFieldStore, resetItemState, setFieldInput, setInitialFieldInput, swapItemState, untrack, validateFormInput, validateIfRequired, walkFieldStore } from "../core/index.svelte";
2
2
 
3
3
  //#region src/focus/focus.ts
4
4
  /**
5
- * Focuses the first input element of a field. This is useful for
5
+ * Focuses the first focusable input element of a field. This is useful for
6
6
  * programmatically setting focus to a specific field, such as after
7
7
  * validation errors or user interactions.
8
8
  *
@@ -10,7 +10,7 @@ import { INTERNAL, batch, copyItemState, createId, getDirtyFieldInput, getFieldB
10
10
  * @param config The focus field configuration.
11
11
  */
12
12
  function focus(form, config) {
13
- getFieldStore(form[INTERNAL], config.path).elements[0]?.focus();
13
+ focusFieldElement(getFieldStore(form[INTERNAL], config.path));
14
14
  }
15
15
 
16
16
  //#endregion
@@ -41,7 +41,13 @@ export function useField(form, config) {
41
41
  [createAttachmentKey()](element) {
42
42
  internalFieldStore.elements.push(element);
43
43
  return () => {
44
- internalFieldStore.elements = internalFieldStore.elements.filter((el) => el !== element);
44
+ const elements = internalFieldStore.elements.filter((el) => el !== element);
45
+ // Keep `initialElements` in sync unless a reorder has moved the
46
+ // elements, so resetting a remounted field restores its live element
47
+ if (internalFieldStore.elements === internalFieldStore.initialElements) {
48
+ internalFieldStore.initialElements = elements;
49
+ }
50
+ internalFieldStore.elements = elements;
45
51
  };
46
52
  },
47
53
  onfocus() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@formisch/svelte",
3
3
  "description": "The lightweight, schema-first, and fully type-safe form library for Svelte",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "license": "MIT",
6
6
  "author": "Fabian Hiller",
7
7
  "homepage": "https://formisch.dev",
@@ -58,7 +58,8 @@
58
58
  "@sveltejs/vite-plugin-svelte": "^6.2.0",
59
59
  "@testing-library/jest-dom": "^6.6.0",
60
60
  "@testing-library/svelte": "^5.2.4",
61
- "@vitest/coverage-v8": "^3.2.4",
61
+ "@types/node": "24.0.13",
62
+ "@vitest/coverage-v8": "^4.1.7",
62
63
  "eslint": "^9.35.0",
63
64
  "eslint-plugin-svelte": "^3.12.2",
64
65
  "globals": "^16.4.0",
@@ -69,7 +70,7 @@
69
70
  "typescript": "^5.9.2",
70
71
  "valibot": "^1.4.1",
71
72
  "vite": "^7.1.5",
72
- "vitest": "^3.2.4"
73
+ "vitest": "^4.1.7"
73
74
  },
74
75
  "peerDependencies": {
75
76
  "svelte": "^5.29.0",