sv5ui 1.5.1 → 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.
@@ -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
+ }
@@ -0,0 +1,164 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLFormAttributes } from 'svelte/elements';
3
+ import type { ClassNameValue } from 'tailwind-merge';
4
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
5
+ import type { FormSlots } from './form.variants.js';
6
+ export type { StandardSchemaV1 };
7
+ export type StandardSchemaV1InferInput<Schema extends StandardSchemaV1> = StandardSchemaV1.InferInput<Schema>;
8
+ export type StandardSchemaV1InferOutput<Schema extends StandardSchemaV1> = StandardSchemaV1.InferOutput<Schema>;
9
+ export interface JoiSchema {
10
+ validate: (value: unknown, options?: object) => {
11
+ value: unknown;
12
+ error?: {
13
+ details: Array<{
14
+ message: string;
15
+ path: Array<string | number>;
16
+ }>;
17
+ };
18
+ };
19
+ validateAsync: (value: unknown, options?: object) => Promise<unknown>;
20
+ $_root: unknown;
21
+ type: string;
22
+ }
23
+ export type FormSchema = StandardSchemaV1 | JoiSchema;
24
+ export type InferInput<Schema> = Schema extends StandardSchemaV1 ? StandardSchemaV1InferInput<Schema> : never;
25
+ export type InferOutput<Schema> = Schema extends StandardSchemaV1 ? StandardSchemaV1InferOutput<Schema> : never;
26
+ export type FormData<S, T extends boolean = true> = T extends true ? InferOutput<S> : InferInput<S>;
27
+ export interface FormError<P extends string = string> {
28
+ name?: P;
29
+ message: string;
30
+ }
31
+ export interface FormErrorWithId extends FormError {
32
+ id?: string;
33
+ }
34
+ export type FormInputEvents = 'input' | 'blur' | 'change' | 'focus';
35
+ export type FormSubmitEvent<T = unknown> = SubmitEvent & {
36
+ data: T;
37
+ };
38
+ export type FormErrorEvent = SubmitEvent & {
39
+ errors: FormErrorWithId[];
40
+ };
41
+ export declare class FormValidationException extends Error {
42
+ formId: string | number;
43
+ errors: FormErrorWithId[];
44
+ constructor(formId: string | number, errors: FormErrorWithId[]);
45
+ }
46
+ export interface FormFieldRegistryEntry {
47
+ id?: string;
48
+ pattern?: RegExp;
49
+ eagerValidation?: boolean;
50
+ validateOnInputDelay?: number;
51
+ }
52
+ export interface NestedFormEntry {
53
+ formId: string | number;
54
+ name?: string;
55
+ validate: (opts?: FormValidateOpts) => Promise<unknown | false>;
56
+ clear: (name?: string | RegExp) => void;
57
+ reset: () => void;
58
+ setErrors: (errs: FormError[], name?: string | RegExp) => void;
59
+ }
60
+ export interface FormValidateOpts {
61
+ name?: string | string[];
62
+ silent?: boolean;
63
+ nested?: boolean;
64
+ transform?: boolean;
65
+ }
66
+ export interface FormApi<T = unknown> {
67
+ validate(opts?: FormValidateOpts): Promise<T | false>;
68
+ submit(): Promise<void>;
69
+ clear(name?: string | RegExp): void;
70
+ getErrors(name?: string | RegExp): FormErrorWithId[];
71
+ setErrors(errs: FormError[], name?: string | RegExp): void;
72
+ /**
73
+ * Reset form tracking state: clears all errors, dirty/touched/blurred field
74
+ * sets, and resets submitCount to 0. Does NOT modify `state` — you own that
75
+ * and should reassign it yourself if you want to restore initial values.
76
+ */
77
+ reset(): void;
78
+ readonly errors: FormErrorWithId[];
79
+ readonly loading: boolean;
80
+ readonly disabled: boolean;
81
+ readonly dirty: boolean;
82
+ readonly dirtyFields: ReadonlySet<string>;
83
+ readonly touchedFields: ReadonlySet<string>;
84
+ readonly blurredFields: ReadonlySet<string>;
85
+ /** Number of times `submit()` has been called (whether validation passed or not). */
86
+ readonly submitCount: number;
87
+ }
88
+ export type FormProps<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false> = Omit<HTMLFormAttributes, 'class' | 'id' | 'name' | 'onsubmit' | 'onerror'> & {
89
+ /** Bindable reference to the root DOM element. */
90
+ ref?: HTMLElement | null;
91
+ /**
92
+ * Bindable form API — methods and reactive state accessors.
93
+ *
94
+ * For typed access to `validate()` / `submit()` results, annotate the
95
+ * consumer variable explicitly:
96
+ *
97
+ * ```ts
98
+ * import type { FormApi } from 'sv5ui'
99
+ * import type { z } from 'zod'
100
+ *
101
+ * const schema = z.object({ email: z.string().email() })
102
+ * let form = $state<FormApi<z.infer<typeof schema>>>()
103
+ * ```
104
+ */
105
+ api?: FormApi<unknown>;
106
+ /** Optional explicit id for the form (used as key for internal bus). */
107
+ id?: string | number;
108
+ /**
109
+ * Schema to validate the form state.
110
+ * Standard Schema (Zod 3.24+, Valibot 1.0+, Yup 1.7+) or Joi.
111
+ */
112
+ schema?: S;
113
+ /** The object representing the current state of the form. Bindable. */
114
+ state?: S extends FormSchema ? (N extends false ? Partial<InferInput<S>> : never) : object;
115
+ /**
116
+ * Custom validation function. Runs alongside `schema` if both provided.
117
+ */
118
+ validate?: (state: S extends FormSchema ? Partial<InferInput<S>> : object) => FormError[] | Promise<FormError[]>;
119
+ /**
120
+ * Input events that trigger field-level validation.
121
+ * Submit always triggers full validation regardless.
122
+ * @default ['input', 'blur', 'change']
123
+ */
124
+ validateOn?: FormInputEvents[];
125
+ /**
126
+ * Debounce delay in ms for `input` event validation.
127
+ * @default 300
128
+ */
129
+ validateOnInputDelay?: number;
130
+ /** Disable all inputs inside the form (propagated via context). */
131
+ disabled?: boolean;
132
+ /**
133
+ * When `true`, the form is automatically disabled during async submit.
134
+ * @default true
135
+ */
136
+ loadingAuto?: boolean;
137
+ /**
138
+ * When `true`, apply schema transformations on submit.
139
+ * @default true
140
+ */
141
+ transform?: T;
142
+ /**
143
+ * When `true`, this form attaches to its parent Form and validates alongside it.
144
+ * @default false
145
+ */
146
+ nested?: N & boolean;
147
+ /**
148
+ * Dotted path of this form's state within its parent. Only meaningful when `nested = true`.
149
+ */
150
+ name?: N extends true ? string : never;
151
+ /** Submit handler. Called with validated (and optionally transformed) data. */
152
+ onsubmit?: (event: FormSubmitEvent<S extends FormSchema ? FormData<S, T> : object>) => void | Promise<void>;
153
+ /** Error handler. Called when validation fails on submit. */
154
+ onerror?: (event: FormErrorEvent) => void;
155
+ /** Additional CSS classes for the root element. */
156
+ class?: ClassNameValue;
157
+ /** Override styles for specific form slots. */
158
+ ui?: Partial<Record<FormSlots, ClassNameValue>>;
159
+ /** Default slot — receives reactive `errors` and `loading`. */
160
+ children?: Snippet<[{
161
+ errors: FormErrorWithId[];
162
+ loading: boolean;
163
+ }]>;
164
+ };
@@ -0,0 +1,12 @@
1
+ // ==================== EXCEPTIONS ====================
2
+ export class FormValidationException extends Error {
3
+ formId;
4
+ errors;
5
+ constructor(formId, errors) {
6
+ super('Form validation exception');
7
+ this.name = 'FormValidationException';
8
+ this.formId = formId;
9
+ this.errors = errors;
10
+ Object.setPrototypeOf(this, FormValidationException.prototype);
11
+ }
12
+ }
@@ -0,0 +1,39 @@
1
+ import { type VariantProps } from 'tailwind-variants';
2
+ /**
3
+ * Form component variants.
4
+ *
5
+ * The Form component renders a near-styleless `<form>` (or `<div>` for nested forms) —
6
+ * matching Nuxt UI v4's minimal form theme. This exists primarily so users can inject
7
+ * shared form spacing via `defineConfig({ form: { slots: { root: 'space-y-4' } } })`.
8
+ */
9
+ export declare const formVariants: import("tailwind-variants").TVReturnType<{
10
+ [key: string]: {
11
+ [key: string]: import("tailwind-merge").ClassNameValue | {
12
+ root?: import("tailwind-merge").ClassNameValue;
13
+ };
14
+ };
15
+ } | {
16
+ [x: string]: {
17
+ [x: string]: import("tailwind-merge").ClassNameValue | {
18
+ root?: import("tailwind-merge").ClassNameValue;
19
+ };
20
+ };
21
+ } | {}, {
22
+ root: string;
23
+ }, undefined, {
24
+ [key: string]: {
25
+ [key: string]: import("tailwind-merge").ClassNameValue | {
26
+ root?: import("tailwind-merge").ClassNameValue;
27
+ };
28
+ };
29
+ } | {}, {
30
+ root: string;
31
+ }, import("tailwind-variants").TVReturnType<unknown, {
32
+ root: string;
33
+ }, undefined, unknown, unknown, undefined>>;
34
+ export type FormVariantProps = VariantProps<typeof formVariants>;
35
+ export type FormSlots = keyof ReturnType<typeof formVariants>;
36
+ export declare const formDefaults: {
37
+ defaultVariants: NonNullable<typeof formVariants.defaultVariants>;
38
+ slots: Partial<Record<FormSlots, string>>;
39
+ };