@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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
187
|
+
function resetItemState(internalFieldStore, input, keepStart = false) {
|
|
185
188
|
batch(() => {
|
|
186
|
-
|
|
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 =
|
|
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 (
|
|
195
|
-
const
|
|
196
|
-
|
|
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
|
-
|
|
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],
|
|
217
|
+
else for (const key in internalFieldStore.children) resetItemState(internalFieldStore.children[key], input?.[key], keepStart);
|
|
204
218
|
} else {
|
|
205
|
-
internalFieldStore.startInput.value =
|
|
206
|
-
internalFieldStore.input.value =
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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, ...
|
|
501
|
+
internalFieldStore.items.value = [...items, ...Array.from({ length: length - items.length }, createId)];
|
|
465
502
|
}
|
|
466
|
-
for (let index = 0; 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.
|
|
550
|
+
internalFieldStore.initialInput.value = initialInput == null ? initialInput : true;
|
|
514
551
|
const initialArrayInput = initialInput ?? [];
|
|
515
|
-
|
|
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 <
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const path
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
-
"@
|
|
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": "^
|
|
73
|
+
"vitest": "^4.1.7"
|
|
73
74
|
},
|
|
74
75
|
"peerDependencies": {
|
|
75
76
|
"svelte": "^5.29.0",
|