@octanejs/formisch 0.0.1

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/UPSTREAM.md +95 -0
  4. package/package.json +65 -0
  5. package/src/components/Field/Field.tsrx +22 -0
  6. package/src/components/Field/Field.tsrx.d.ts +17 -0
  7. package/src/components/Field/index.ts +1 -0
  8. package/src/components/FieldArray/FieldArray.tsrx +22 -0
  9. package/src/components/FieldArray/FieldArray.tsrx.d.ts +18 -0
  10. package/src/components/FieldArray/index.ts +1 -0
  11. package/src/components/Form/Form.tsrx +28 -0
  12. package/src/components/Form/Form.tsrx.d.ts +15 -0
  13. package/src/components/Form/index.ts +1 -0
  14. package/src/components/index.ts +3 -0
  15. package/src/core/array/copyItemState/copyItemState.ts +101 -0
  16. package/src/core/array/copyItemState/index.ts +1 -0
  17. package/src/core/array/index.ts +3 -0
  18. package/src/core/array/resetItemState/index.ts +1 -0
  19. package/src/core/array/resetItemState/resetItemState.ts +172 -0
  20. package/src/core/array/swapItemState/index.ts +1 -0
  21. package/src/core/array/swapItemState/swapItemState.ts +138 -0
  22. package/src/core/field/focusFieldElement/focusFieldElement.ts +32 -0
  23. package/src/core/field/focusFieldElement/index.ts +1 -0
  24. package/src/core/field/getDirtyFieldInput/getDirtyFieldInput.ts +66 -0
  25. package/src/core/field/getDirtyFieldInput/index.ts +1 -0
  26. package/src/core/field/getElementInput/getElementInput.ts +78 -0
  27. package/src/core/field/getElementInput/index.ts +1 -0
  28. package/src/core/field/getFieldBool/getFieldBool.ts +22 -0
  29. package/src/core/field/getFieldBool/index.ts +1 -0
  30. package/src/core/field/getFieldInput/getFieldInput.ts +52 -0
  31. package/src/core/field/getFieldInput/index.ts +1 -0
  32. package/src/core/field/getFieldStore/getFieldStore.ts +34 -0
  33. package/src/core/field/getFieldStore/index.ts +1 -0
  34. package/src/core/field/index.ts +11 -0
  35. package/src/core/field/initializeFieldStore/index.ts +1 -0
  36. package/src/core/field/initializeFieldStore/initializeFieldStore.ts +325 -0
  37. package/src/core/field/setFieldBool/index.ts +1 -0
  38. package/src/core/field/setFieldBool/setFieldBool.ts +29 -0
  39. package/src/core/field/setFieldInput/index.ts +1 -0
  40. package/src/core/field/setFieldInput/setFieldInput.ts +180 -0
  41. package/src/core/field/setInitialFieldInput/index.ts +1 -0
  42. package/src/core/field/setInitialFieldInput/setInitialFieldInput.ts +99 -0
  43. package/src/core/field/walkFieldStore/index.ts +1 -0
  44. package/src/core/field/walkFieldStore/walkFieldStore.ts +49 -0
  45. package/src/core/form/createFormStore/createFormStore.ts +56 -0
  46. package/src/core/form/createFormStore/index.ts +1 -0
  47. package/src/core/form/decodeFormData/decodeFormData.ts +436 -0
  48. package/src/core/form/decodeFormData/index.ts +1 -0
  49. package/src/core/form/index.ts +4 -0
  50. package/src/core/form/validateFormInput/index.ts +1 -0
  51. package/src/core/form/validateFormInput/validateFormInput.ts +138 -0
  52. package/src/core/form/validateIfRequired/index.ts +1 -0
  53. package/src/core/form/validateIfRequired/validateIfRequired.ts +31 -0
  54. package/src/core/framework/index.ts +80 -0
  55. package/src/core/index.ts +6 -0
  56. package/src/core/types/field/field.ts +201 -0
  57. package/src/core/types/field/index.ts +1 -0
  58. package/src/core/types/form/form.ts +140 -0
  59. package/src/core/types/form/index.ts +1 -0
  60. package/src/core/types/index.ts +6 -0
  61. package/src/core/types/path/index.ts +10 -0
  62. package/src/core/types/path/path.ts +301 -0
  63. package/src/core/types/schema/index.ts +1 -0
  64. package/src/core/types/schema/schema.ts +18 -0
  65. package/src/core/types/signal/index.ts +1 -0
  66. package/src/core/types/signal/signal.ts +23 -0
  67. package/src/core/types/utils/index.ts +1 -0
  68. package/src/core/types/utils/utils.ts +46 -0
  69. package/src/core/values.ts +4 -0
  70. package/src/hooks/index.ts +3 -0
  71. package/src/hooks/useField/index.ts +1 -0
  72. package/src/hooks/useField/useField.ts +114 -0
  73. package/src/hooks/useFieldArray/index.ts +1 -0
  74. package/src/hooks/useFieldArray/useFieldArray.ts +63 -0
  75. package/src/hooks/useForm/index.ts +1 -0
  76. package/src/hooks/useForm/useForm.ts +68 -0
  77. package/src/hooks/useSignals/index.ts +1 -0
  78. package/src/hooks/useSignals/useSignals.ts +31 -0
  79. package/src/index.ts +19 -0
  80. package/src/internal.ts +32 -0
  81. package/src/methods/focus/focus.ts +35 -0
  82. package/src/methods/focus/index.ts +1 -0
  83. package/src/methods/getDeepErrorEntries/getDeepErrorEntries.ts +108 -0
  84. package/src/methods/getDeepErrorEntries/index.ts +1 -0
  85. package/src/methods/getDeepErrors/getDeepErrors.ts +90 -0
  86. package/src/methods/getDeepErrors/index.ts +1 -0
  87. package/src/methods/getDirtyInput/getDirtyInput.ts +87 -0
  88. package/src/methods/getDirtyInput/index.ts +1 -0
  89. package/src/methods/getDirtyPaths/getDirtyPaths.ts +123 -0
  90. package/src/methods/getDirtyPaths/index.ts +1 -0
  91. package/src/methods/getErrors/getErrors.ts +70 -0
  92. package/src/methods/getErrors/index.ts +1 -0
  93. package/src/methods/getInput/getInput.ts +75 -0
  94. package/src/methods/getInput/index.ts +1 -0
  95. package/src/methods/handleSubmit/handleSubmit.ts +83 -0
  96. package/src/methods/handleSubmit/index.ts +1 -0
  97. package/src/methods/index.ts +23 -0
  98. package/src/methods/insert/index.ts +1 -0
  99. package/src/methods/insert/insert.ts +134 -0
  100. package/src/methods/isDirty/index.ts +1 -0
  101. package/src/methods/isDirty/isDirty.ts +72 -0
  102. package/src/methods/isEdited/index.ts +1 -0
  103. package/src/methods/isEdited/isEdited.ts +72 -0
  104. package/src/methods/isTouched/index.ts +1 -0
  105. package/src/methods/isTouched/isTouched.ts +72 -0
  106. package/src/methods/isValid/index.ts +1 -0
  107. package/src/methods/isValid/isValid.ts +74 -0
  108. package/src/methods/move/index.ts +1 -0
  109. package/src/methods/move/move.ts +124 -0
  110. package/src/methods/pickDirty/index.ts +1 -0
  111. package/src/methods/pickDirty/pickDirty.ts +87 -0
  112. package/src/methods/remove/index.ts +1 -0
  113. package/src/methods/remove/remove.ts +76 -0
  114. package/src/methods/replace/index.ts +1 -0
  115. package/src/methods/replace/replace.ts +80 -0
  116. package/src/methods/reset/index.ts +1 -0
  117. package/src/methods/reset/reset.ts +216 -0
  118. package/src/methods/setErrors/index.ts +1 -0
  119. package/src/methods/setErrors/setErrors.ts +63 -0
  120. package/src/methods/setInput/index.ts +1 -0
  121. package/src/methods/setInput/setInput.ts +87 -0
  122. package/src/methods/submit/index.ts +1 -0
  123. package/src/methods/submit/submit.ts +11 -0
  124. package/src/methods/swap/index.ts +1 -0
  125. package/src/methods/swap/swap.ts +85 -0
  126. package/src/methods/validate/index.ts +1 -0
  127. package/src/methods/validate/validate.ts +34 -0
  128. package/src/types/field.ts +48 -0
  129. package/src/types/form.ts +12 -0
  130. package/src/types/index.ts +2 -0
@@ -0,0 +1,56 @@
1
+ import type * as v from 'valibot';
2
+ import { type FieldSchema, initializeFieldStore } from '../../field/index.ts';
3
+ import { createSignal } from '../../framework/index.ts';
4
+ import type { EmptyInput, FormConfig, FormSchema, InternalFormStore } from '../../types/index.ts';
5
+
6
+ /**
7
+ * The default empty input of a form. Required string fields start as an empty
8
+ * string, while every other type starts as `undefined`.
9
+ */
10
+ export const DEFAULT_EMPTY_INPUT: EmptyInput = { string: '' };
11
+
12
+ /**
13
+ * Creates a new internal form store from the provided configuration.
14
+ * Initializes the field store hierarchy, sets validation modes, and
15
+ * creates form state signals.
16
+ *
17
+ * @param config The form configuration.
18
+ * @param parse The schema parse function.
19
+ *
20
+ * @returns The internal form store.
21
+ */
22
+ // @__NO_SIDE_EFFECTS__
23
+ export function createFormStore(
24
+ config: FormConfig,
25
+ parse: (input: unknown) => Promise<v.SafeParseResult<FormSchema>>,
26
+ ): InternalFormStore {
27
+ // Create partial store object
28
+ const store: Partial<InternalFormStore> = {};
29
+
30
+ // Merge configured empty input on top of the defaults before initializing so
31
+ // the field stores can read it from the form store
32
+ store.emptyInput = { ...DEFAULT_EMPTY_INPUT, ...config.emptyInput };
33
+
34
+ // Set form config and validation
35
+ store.validators = 0;
36
+ store.validate = config.validate ?? 'submit';
37
+ store.revalidate = config.revalidate ?? 'input';
38
+ store.parse = parse;
39
+
40
+ // Initialize form state signals
41
+ store.isSubmitting = createSignal(false);
42
+ store.isSubmitted = createSignal(false);
43
+ store.isValidating = createSignal(false);
44
+
45
+ // Initialize field store hierarchy from schema
46
+ initializeFieldStore(
47
+ store as InternalFormStore,
48
+ store,
49
+ config.schema as FieldSchema,
50
+ config.initialInput,
51
+ [],
52
+ );
53
+
54
+ // Return initialized store
55
+ return store as InternalFormStore;
56
+ }
@@ -0,0 +1 @@
1
+ export * from './createFormStore.ts';
@@ -0,0 +1,436 @@
1
+ import type { FormSchema } from '../../types/index.ts';
2
+
3
+ // Matches decimal number with optional sign, fraction and exponent (e.g. "42",
4
+ // "-7", "+0.5", ".5", "1.5e3"), rejecting junk like hex or "Infinity"
5
+ // eslint-disable-next-line security/detect-unsafe-regex
6
+ const NUMBER_REGEX = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;
7
+
8
+ // Matches timezone-less ISO date and time with optional seconds and fractional
9
+ // seconds (e.g. "2023-06-15T14:30" or "2023-06-15T14:30:45.123"), as emitted by
10
+ // `<input type="datetime-local">`
11
+ const ISO_DATE_TIME_REGEX =
12
+ // eslint-disable-next-line security/detect-unsafe-regex
13
+ /^\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;
14
+
15
+ // Hint: Maximum number of items allowed per array field. A numeric path
16
+ // segment is used as an array index, so a single crafted key like
17
+ // `["items",1000000000]` would set the array length to one billion. Creating
18
+ // the sparse array is cheap, but every later pass that walks it by length
19
+ // then runs in O(length), such as `fillDefaults` and the schema validation
20
+ // the caller runs on the result. An array of booleans even writes a value at
21
+ // every index, so a tiny request can exhaust CPU and memory. The limit is a
22
+ // fixed number because a legitimate sparse array (one checked checkbox at a
23
+ // high index, the rest unchecked and absent) is indistinguishable from an
24
+ // attack; only the index magnitude differs. Throwing rather than truncating
25
+ // avoids silently returning wrong data.
26
+ const MAX_ARRAY_LENGTH = 5000;
27
+
28
+ /**
29
+ * Internal schema type with the structural properties that are read while
30
+ * traversing a Valibot schema.
31
+ */
32
+ interface InternalSchema {
33
+ readonly type: string;
34
+ readonly wrapped?: InternalSchema;
35
+ readonly getter?: (input: undefined) => InternalSchema;
36
+ readonly entries?: Record<string, InternalSchema>;
37
+ readonly item?: InternalSchema;
38
+ readonly items?: InternalSchema[];
39
+ readonly options?: InternalSchema[];
40
+ }
41
+
42
+ /**
43
+ * Unwraps wrapper and lazy schemas until a concrete schema is reached.
44
+ *
45
+ * @param schema The schema to unwrap.
46
+ *
47
+ * @returns The unwrapped schema.
48
+ */
49
+ function unwrapSchema(schema: InternalSchema): InternalSchema {
50
+ switch (schema.type) {
51
+ case 'exact_optional':
52
+ case 'nullable':
53
+ case 'nullish':
54
+ case 'optional':
55
+ case 'undefinedable':
56
+ case 'non_nullable':
57
+ case 'non_nullish':
58
+ case 'non_optional':
59
+ return unwrapSchema(schema.wrapped!);
60
+ case 'lazy':
61
+ return unwrapSchema(schema.getter!(undefined));
62
+ default:
63
+ return schema;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Returns the child schema for the given key by traversing objects, arrays,
69
+ * tuples and schema options. Returns `undefined` if no child schema is found.
70
+ *
71
+ * @param schema The parent schema.
72
+ * @param key The path key.
73
+ *
74
+ * @returns The child schema or `undefined`.
75
+ */
76
+ function getChildSchema(
77
+ schema: InternalSchema | undefined,
78
+ key: string | number,
79
+ ): InternalSchema | undefined {
80
+ if (schema) {
81
+ // Unwrap schema before reading its structure
82
+ const unwrapped = unwrapSchema(schema);
83
+
84
+ // If schema is object, return entry schema
85
+ if (
86
+ unwrapped.type === 'object' ||
87
+ unwrapped.type === 'loose_object' ||
88
+ unwrapped.type === 'strict_object'
89
+ ) {
90
+ return unwrapped.entries![key];
91
+ }
92
+
93
+ // If schema is array, return item schema
94
+ if (unwrapped.type === 'array') {
95
+ return unwrapped.item;
96
+ }
97
+
98
+ // If schema is tuple, return item schema at index
99
+ if (
100
+ unwrapped.type === 'tuple' ||
101
+ unwrapped.type === 'loose_tuple' ||
102
+ unwrapped.type === 'strict_tuple'
103
+ ) {
104
+ return unwrapped.items![key as number];
105
+ }
106
+
107
+ // If schema has options, return first matching child schema
108
+ if (
109
+ unwrapped.type === 'union' ||
110
+ unwrapped.type === 'intersect' ||
111
+ unwrapped.type === 'variant'
112
+ ) {
113
+ // Hint: The first matching option is used. For a union or variant where
114
+ // the same key has different types across options, the value is decoded
115
+ // based on the first option, since the matching branch is only known
116
+ // during validation.
117
+ for (const option of unwrapped.options!) {
118
+ const childSchema = getChildSchema(option, key);
119
+ if (childSchema !== undefined) {
120
+ return childSchema;
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Decodes a stringified date based on its format. Empty strings become `null`.
129
+ *
130
+ * @param value The stringified value.
131
+ *
132
+ * @returns The decoded date.
133
+ */
134
+ function decodeDate(value: string): Date | null | undefined {
135
+ if (!value || value === 'null') {
136
+ return null;
137
+ }
138
+ if (value === 'undefined') {
139
+ return undefined;
140
+ }
141
+ // Hint: A timezone-less date and time (from `<input type="datetime-local">`)
142
+ // is interpreted as local time by `new Date`, so it is forced to UTC. Dates,
143
+ // months and full timestamps are already parsed as UTC.
144
+ if (ISO_DATE_TIME_REGEX.test(value)) {
145
+ return new Date(`${value}Z`);
146
+ }
147
+ return new Date(value);
148
+ }
149
+
150
+ /**
151
+ * Decodes a stringified boolean. Empty strings become `null`.
152
+ *
153
+ * @param value The stringified value.
154
+ *
155
+ * @returns The decoded boolean.
156
+ */
157
+ function decodeBoolean(value: string): boolean | null | undefined {
158
+ if (!value || value === 'null') {
159
+ return null;
160
+ }
161
+ if (value === 'undefined') {
162
+ return undefined;
163
+ }
164
+ return !(value === 'false' || value === 'off' || value === '0');
165
+ }
166
+
167
+ /**
168
+ * Decodes a stringified number. Empty strings become `null` and non-numeric
169
+ * values become `NaN`.
170
+ *
171
+ * @param value The stringified value.
172
+ *
173
+ * @returns The decoded number.
174
+ */
175
+ function decodeNumber(value: string): number | null | undefined {
176
+ if (!value || value === 'null') {
177
+ return null;
178
+ }
179
+ if (value === 'undefined') {
180
+ return undefined;
181
+ }
182
+ if (NUMBER_REGEX.test(value)) {
183
+ return Number(value);
184
+ }
185
+ return NaN;
186
+ }
187
+
188
+ /**
189
+ * Decodes a stringified bigint. Empty strings become `null` and invalid values
190
+ * are returned unchanged.
191
+ *
192
+ * @param value The stringified value.
193
+ *
194
+ * @returns The decoded bigint.
195
+ */
196
+ function decodeBigint(value: string): bigint | string | null | undefined {
197
+ if (!value || value === 'null') {
198
+ return null;
199
+ }
200
+ if (value === 'undefined') {
201
+ return undefined;
202
+ }
203
+ try {
204
+ return BigInt(value);
205
+ } catch {
206
+ return value;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Decodes a single form data value based on the concrete schema type. Files
212
+ * and unknown types are returned unchanged.
213
+ *
214
+ * @param value The form data value.
215
+ * @param schema The schema of the value.
216
+ *
217
+ * @returns The decoded value.
218
+ */
219
+ function decodeValue(value: FormDataEntryValue, schema: InternalSchema | undefined): unknown {
220
+ // Non-string values (files) and unknown schemas are returned unchanged
221
+ if (typeof value !== 'string' || !schema) {
222
+ return value;
223
+ }
224
+
225
+ // Decode value based on concrete schema type
226
+ switch (unwrapSchema(schema).type) {
227
+ case 'number':
228
+ return decodeNumber(value);
229
+ case 'boolean':
230
+ return decodeBoolean(value);
231
+ case 'date':
232
+ return decodeDate(value);
233
+ case 'bigint':
234
+ return decodeBigint(value);
235
+ default:
236
+ return value;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Fills in default values that are lost during the form data transfer. Booleans
242
+ * of unchecked checkboxes become `false` and absent arrays become empty. Only
243
+ * containers that are present in the decoded data are completed.
244
+ *
245
+ * @param schema The schema of the value.
246
+ * @param parent The parent object or array holding the value.
247
+ * @param key The key of the value within its parent.
248
+ */
249
+ function fillDefaults(
250
+ schema: InternalSchema,
251
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
+ parent: any,
253
+ key: string | number,
254
+ ): void {
255
+ // Unwrap schema before reading its structure
256
+ const unwrappedSchema = unwrapSchema(schema);
257
+
258
+ // If schema is boolean, default absent (unchecked checkbox) values to `false`
259
+ // Hint: Only `undefined` is treated as absent so that a decoded `null` (e.g.
260
+ // from a nullable boolean) is preserved instead of being coerced to `false`.
261
+ if (unwrappedSchema.type === 'boolean') {
262
+ if (parent[key] === undefined) {
263
+ parent[key] = false;
264
+ }
265
+
266
+ // Otherwise, if schema is array, default absent arrays and complete items
267
+ } else if (unwrappedSchema.type === 'array') {
268
+ if (Array.isArray(parent[key])) {
269
+ for (let index = 0; index < parent[key].length; index++) {
270
+ fillDefaults(unwrappedSchema.item!, parent[key], index);
271
+ }
272
+ } else {
273
+ parent[key] = [];
274
+ }
275
+
276
+ // Otherwise, if schema is tuple, complete items of present tuples
277
+ } else if (
278
+ unwrappedSchema.type === 'tuple' ||
279
+ unwrappedSchema.type === 'loose_tuple' ||
280
+ unwrappedSchema.type === 'strict_tuple'
281
+ ) {
282
+ if (Array.isArray(parent[key])) {
283
+ for (let index = 0; index < unwrappedSchema.items!.length; index++) {
284
+ fillDefaults(unwrappedSchema.items![index], parent[key], index);
285
+ }
286
+ }
287
+
288
+ // Otherwise, if schema is object, complete present entries
289
+ } else if (
290
+ unwrappedSchema.type === 'object' ||
291
+ unwrappedSchema.type === 'loose_object' ||
292
+ unwrappedSchema.type === 'strict_object'
293
+ ) {
294
+ if (parent[key] && typeof parent[key] === 'object') {
295
+ for (const entryKey in unwrappedSchema.entries) {
296
+ fillDefaults(unwrappedSchema.entries[entryKey], parent[key], entryKey);
297
+ }
298
+ }
299
+
300
+ // Otherwise, if schema has options, complete for each option
301
+ // Hint: Defaults from every option are applied because the matching branch
302
+ // of a union or variant is only known during validation, not while
303
+ // decoding. This is correct for intersect (all options apply) and harmless
304
+ // for object options (unknown keys are ignored on parse). A `strictObject`
305
+ // option may reject the extra keys though, so reliably decoding such a
306
+ // variant would require resolving its branch via the discriminator, which
307
+ // is not possible before validation.
308
+ } else if (
309
+ unwrappedSchema.type === 'union' ||
310
+ unwrappedSchema.type === 'intersect' ||
311
+ unwrappedSchema.type === 'variant'
312
+ ) {
313
+ for (const option of unwrappedSchema.options!) {
314
+ fillDefaults(option, parent, key);
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Decodes the entries of a form data object into nested form values using the
321
+ * Valibot schema as the source of truth. Information that is lost during the
322
+ * transfer via HTTP, like numbers, booleans, dates and unchecked checkboxes,
323
+ * is restored based on the schema.
324
+ *
325
+ * The keys of the form data are expected to be the stringified field paths that
326
+ * Formisch assigns to its field elements (for example `["todos",0,"label"]`).
327
+ *
328
+ * @param schema The form schema.
329
+ * @param formData The form data object.
330
+ *
331
+ * @returns The decoded form values.
332
+ */
333
+ // @__NO_SIDE_EFFECTS__
334
+ export function decodeFormData<TSchema extends FormSchema>(
335
+ schema: TSchema,
336
+ formData: FormData,
337
+ ): unknown {
338
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
339
+ const values: any = {};
340
+
341
+ // Build nested values from form data entries
342
+ formData.forEach((value, key) => {
343
+ // Convert stringified key back to field path array, ignore invalid JSON
344
+ let path: unknown = null;
345
+ try {
346
+ path = JSON.parse(key);
347
+ } catch {
348
+ // Ignore invalid JSON keys
349
+ }
350
+
351
+ // Only process valid, non-empty field paths whose value is not empty
352
+ // (unselected) file input
353
+ if (
354
+ Array.isArray(path) &&
355
+ path.length > 0 &&
356
+ (typeof value === 'string' || value.size > 0 || value.name !== '')
357
+ ) {
358
+ // Create temporary references for parent value and schema
359
+ let parentValue = values;
360
+ let parentSchema: InternalSchema | undefined = schema as InternalSchema;
361
+
362
+ // Traverse path segments and build nested structure based on schema
363
+ for (let index = 0; index < path.length; index++) {
364
+ const segment = path[index];
365
+
366
+ // Skip invalid keys and keys that could pollute object prototype
367
+ if (
368
+ (typeof segment !== 'string' && typeof segment !== 'number') ||
369
+ segment === '' ||
370
+ segment === '__proto__' ||
371
+ segment === 'prototype' ||
372
+ segment === 'constructor'
373
+ ) {
374
+ break;
375
+ }
376
+
377
+ // Arrays are indexed by numbers; string segments would write properties
378
+ // like `length` or `push` that inflate or corrupt them
379
+ if (Array.isArray(parentValue)) {
380
+ if (typeof segment === 'string') {
381
+ break;
382
+ }
383
+
384
+ // Throw on oversized array index (see MAX_ARRAY_LENGTH)
385
+ if (segment >= MAX_ARRAY_LENGTH) {
386
+ throw new Error(`Array exceeds the maximum length of ${MAX_ARRAY_LENGTH}`);
387
+ }
388
+ }
389
+
390
+ // Get child schema for current segment
391
+ const childSchema = getChildSchema(parentSchema, segment);
392
+
393
+ // If segment is last one, set or append decoded value
394
+ if (index === path.length - 1) {
395
+ const unwrappedSchema = childSchema && unwrapSchema(childSchema);
396
+
397
+ // If schema is dynamic array, append decoded item
398
+ if (unwrappedSchema && unwrappedSchema.type === 'array') {
399
+ parentValue[segment] ??= [];
400
+ parentValue[segment].push(decodeValue(value, unwrappedSchema.item));
401
+
402
+ // Otherwise, set decoded value
403
+ } else {
404
+ parentValue[segment] = decodeValue(value, childSchema);
405
+ }
406
+
407
+ // Otherwise, create next container and continue traversing
408
+ } else {
409
+ if (parentValue[segment] == null) {
410
+ // Create array for array and tuple schemas, object otherwise
411
+ const schemaType = childSchema && unwrapSchema(childSchema).type;
412
+ parentValue[segment] =
413
+ schemaType === 'array' ||
414
+ schemaType === 'tuple' ||
415
+ schemaType === 'loose_tuple' ||
416
+ schemaType === 'strict_tuple'
417
+ ? []
418
+ : {};
419
+
420
+ // Otherwise, stop on conflicting scalar value to avoid writing a
421
+ // property to a non-object, which throws in strict mode
422
+ } else if (typeof parentValue[segment] !== 'object') {
423
+ break;
424
+ }
425
+ parentValue = parentValue[segment];
426
+ parentSchema = childSchema;
427
+ }
428
+ }
429
+ }
430
+ });
431
+
432
+ // Fill in default values that are lost during transfer
433
+ fillDefaults(schema as InternalSchema, { values }, 'values');
434
+
435
+ return values;
436
+ }
@@ -0,0 +1 @@
1
+ export * from './decodeFormData.ts';
@@ -0,0 +1,4 @@
1
+ export * from './createFormStore/index.ts';
2
+ export * from './decodeFormData/index.ts';
3
+ export * from './validateFormInput/index.ts';
4
+ export * from './validateIfRequired/index.ts';
@@ -0,0 +1 @@
1
+ export * from './validateFormInput.ts';
@@ -0,0 +1,138 @@
1
+ import type * as v from 'valibot';
2
+ import { focusFieldElement, getFieldInput, walkFieldStore } from '../../field/index.ts';
3
+ import { batch, untrack } from '../../framework/index.ts';
4
+ import type { InternalFormStore, Schema } from '../../types/index.ts';
5
+
6
+ /**
7
+ * Validate form input config interface.
8
+ */
9
+ export interface ValidateFormInputConfig {
10
+ /**
11
+ * Whether to focus the first field with an error.
12
+ */
13
+ readonly shouldFocus?: boolean | undefined;
14
+ }
15
+
16
+ /**
17
+ * Validates the form input using the configured Valibot schema. Parses the
18
+ * current form input, processes validation issues, assigns errors to fields,
19
+ * and optionally focuses the first field with an error.
20
+ *
21
+ * @param internalFormStore The form store to validate.
22
+ * @param config The validation configuration.
23
+ *
24
+ * @returns The Valibot validation result.
25
+ */
26
+ export async function validateFormInput(
27
+ internalFormStore: InternalFormStore,
28
+ config?: ValidateFormInputConfig,
29
+ ): Promise<v.SafeParseResult<Schema>> {
30
+ // Update validation state
31
+ internalFormStore.validators++;
32
+ internalFormStore.isValidating.value = true;
33
+
34
+ try {
35
+ // Parse form input with Valibot schema
36
+ const result = await internalFormStore.parse(untrack(() => getFieldInput(internalFormStore)));
37
+
38
+ // Create variables for root and nested errors
39
+ let rootErrors: [string, ...string[]] | undefined;
40
+ let nestedErrors: Record<string, [string, ...string[]] | undefined> | undefined;
41
+
42
+ // Process validation issues into error variables
43
+ if (result.issues) {
44
+ // Initialize nested errors object
45
+ nestedErrors = {};
46
+
47
+ // Process each validation issue
48
+ for (const issue of result.issues) {
49
+ // If issue has path, assign to nested errors
50
+ if (issue.path) {
51
+ // Initialize path array
52
+ const path = [];
53
+
54
+ // Build path from issue path items
55
+ for (const pathItem of issue.path) {
56
+ const key = pathItem.key;
57
+ const keyType = typeof key;
58
+ const itemType = pathItem.type;
59
+ // Skip unsupported path types
60
+ if (
61
+ (keyType !== 'string' && keyType !== 'number') ||
62
+ itemType === 'map' ||
63
+ itemType === 'set'
64
+ ) {
65
+ break;
66
+ }
67
+
68
+ // Add key to path
69
+ path.push(key);
70
+ }
71
+
72
+ // Convert path to name of field
73
+ const name = JSON.stringify(path);
74
+
75
+ // Append or initialize nested errors
76
+ const fieldErrors = nestedErrors[name];
77
+ if (fieldErrors) {
78
+ fieldErrors.push(issue.message);
79
+ } else {
80
+ nestedErrors[name] = [issue.message];
81
+ }
82
+
83
+ // Otherwise, assign to root errors
84
+ } else {
85
+ if (rootErrors) {
86
+ rootErrors.push(issue.message);
87
+ } else {
88
+ rootErrors = [issue.message];
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ // Create variable to decide if first error field should be focused
95
+ let shouldFocus = config?.shouldFocus ?? false;
96
+
97
+ // Batch error, focus and validation state updates together so reactive
98
+ // subscribers observe a single consistent update
99
+ batch(() => {
100
+ // Untracked to avoid subscribing a surrounding reactive scope to the
101
+ // form structure.
102
+ untrack(() => {
103
+ // Set or reset errors on each field store.
104
+ walkFieldStore(internalFormStore, (internalFieldStore) => {
105
+ if (internalFieldStore.path.length === 0) {
106
+ internalFieldStore.errors.value = rootErrors ?? null;
107
+ } else {
108
+ const fieldErrors = nestedErrors?.[internalFieldStore.name] ?? null;
109
+ internalFieldStore.errors.value = fieldErrors;
110
+
111
+ // Focus the first erroring field whose element can actually receive
112
+ // focus, so the focus is not consumed by a field without a focusable
113
+ // element (e.g. unmounted or hidden)
114
+ if (shouldFocus && fieldErrors && focusFieldElement(internalFieldStore)) {
115
+ shouldFocus = false;
116
+ }
117
+ }
118
+ });
119
+ });
120
+
121
+ // Reset validation state of form
122
+ internalFormStore.validators--;
123
+ internalFormStore.isValidating.value = internalFormStore.validators > 0;
124
+ });
125
+
126
+ // Return validation result
127
+ return result;
128
+
129
+ // If parsing throws, still reset validation state so form does not stay
130
+ // stuck in a validating state
131
+ } catch (error) {
132
+ batch(() => {
133
+ internalFormStore.validators--;
134
+ internalFormStore.isValidating.value = internalFormStore.validators > 0;
135
+ });
136
+ throw error;
137
+ }
138
+ }
@@ -0,0 +1 @@
1
+ export * from './validateIfRequired.ts';
@@ -0,0 +1,31 @@
1
+ import { getFieldBool } from '../../field/index.ts';
2
+ import { untrack } from '../../framework/index.ts';
3
+ import type { InternalFieldStore, InternalFormStore, ValidationMode } from '../../types/index.ts';
4
+ import { validateFormInput } from '../validateFormInput/validateFormInput.ts';
5
+
6
+ /**
7
+ * Validates the form input if required based on the validation mode and form
8
+ * state. Determines whether to use initial validation mode, revalidation mode,
9
+ * or skip validation entirely.
10
+ *
11
+ * @param internalFormStore The form store to validate.
12
+ * @param internalFieldStore The field store that triggered validation.
13
+ * @param validationMode The validation mode that triggered this check.
14
+ */
15
+ export function validateIfRequired(
16
+ internalFormStore: InternalFormStore,
17
+ internalFieldStore: InternalFieldStore,
18
+ validationMode: ValidationMode,
19
+ ): void {
20
+ if (
21
+ validationMode ===
22
+ (internalFormStore.validate === 'initial' ||
23
+ (internalFormStore.validate === 'submit'
24
+ ? untrack(() => internalFormStore.isSubmitted.value)
25
+ : untrack(() => getFieldBool(internalFieldStore, 'errors')))
26
+ ? internalFormStore.revalidate
27
+ : internalFormStore.validate)
28
+ ) {
29
+ validateFormInput(internalFormStore);
30
+ }
31
+ }