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