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,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
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { tv } 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 const formVariants = tv({
|
|
10
|
+
slots: {
|
|
11
|
+
root: ''
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
export const formDefaults = {
|
|
15
|
+
defaultVariants: {},
|
|
16
|
+
slots: {}
|
|
17
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { default as Form } from './Form.svelte';
|
|
2
|
+
export type { FormProps, FormApi, FormError, FormErrorWithId, FormSubmitEvent, FormErrorEvent, FormSchema, FormInputEvents, FormValidateOpts, InferInput, InferOutput, FormData, StandardSchemaV1, JoiSchema } from './form.types.js';
|
|
3
|
+
export { FormValidationException } from './form.types.js';
|
|
4
|
+
export { getFormContext } from './form.context.svelte.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as Form } from './Form.svelte';
|
|
2
|
+
export { FormValidationException } from './form.types.js';
|
|
3
|
+
// Exposed so users can build custom input components that integrate with a parent Form
|
|
4
|
+
// (read errors, emit blur/input/change/focus events). Internal implementation details
|
|
5
|
+
// like FormContext, validateSchema, getAtPath, etc. are intentionally not re-exported.
|
|
6
|
+
export { getFormContext } from './form.context.svelte.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
import type { FormError, FormSchema, JoiSchema } from './form.types.js';
|
|
3
|
+
export declare function isStandardSchema(schema: unknown): schema is StandardSchemaV1;
|
|
4
|
+
export declare function isJoiSchema(schema: unknown): schema is JoiSchema;
|
|
5
|
+
export interface ValidateResult<T = unknown> {
|
|
6
|
+
errors: FormError[] | null;
|
|
7
|
+
value: T | null;
|
|
8
|
+
}
|
|
9
|
+
export declare function validateStandardSchema<T>(state: unknown, schema: StandardSchemaV1<unknown, T>): Promise<ValidateResult<T>>;
|
|
10
|
+
export declare function validateJoiSchema<T>(state: unknown, schema: JoiSchema): Promise<ValidateResult<T>>;
|
|
11
|
+
export declare function validateSchema<T>(state: unknown, schema: FormSchema): Promise<ValidateResult<T>>;
|
|
12
|
+
export declare function getAtPath<T extends Record<string, unknown>>(data: T, path?: string): unknown;
|
|
13
|
+
export declare function setAtPath<T extends Record<string, unknown>>(data: T, path: string, value: unknown): T;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ==================== TYPE GUARDS ====================
|
|
2
|
+
export function isStandardSchema(schema) {
|
|
3
|
+
return (typeof schema === 'object' &&
|
|
4
|
+
schema !== null &&
|
|
5
|
+
'~standard' in schema &&
|
|
6
|
+
typeof schema['~standard'] === 'object');
|
|
7
|
+
}
|
|
8
|
+
export function isJoiSchema(schema) {
|
|
9
|
+
if (typeof schema !== 'object' || schema === null)
|
|
10
|
+
return false;
|
|
11
|
+
const s = schema;
|
|
12
|
+
return (typeof s.validate === 'function' &&
|
|
13
|
+
typeof s.validateAsync === 'function' &&
|
|
14
|
+
'$_root' in s &&
|
|
15
|
+
typeof s.type === 'string');
|
|
16
|
+
}
|
|
17
|
+
// ==================== NORMALIZE STANDARD SCHEMA PATH ====================
|
|
18
|
+
function normalizePath(path) {
|
|
19
|
+
if (!path || path.length === 0)
|
|
20
|
+
return '';
|
|
21
|
+
return path
|
|
22
|
+
.map((segment) => {
|
|
23
|
+
if (typeof segment === 'object' && segment !== null && 'key' in segment) {
|
|
24
|
+
return String(segment.key);
|
|
25
|
+
}
|
|
26
|
+
return String(segment);
|
|
27
|
+
})
|
|
28
|
+
.join('.');
|
|
29
|
+
}
|
|
30
|
+
// ==================== STANDARD SCHEMA VALIDATOR ====================
|
|
31
|
+
// Handles Zod 3.24+, Valibot 1.0+, Yup 1.7+ (and any other library
|
|
32
|
+
// implementing the Standard Schema spec).
|
|
33
|
+
export async function validateStandardSchema(state, schema) {
|
|
34
|
+
let result = schema['~standard'].validate(state);
|
|
35
|
+
if (result instanceof Promise)
|
|
36
|
+
result = await result;
|
|
37
|
+
if ('issues' in result && result.issues) {
|
|
38
|
+
return {
|
|
39
|
+
errors: result.issues.map((issue) => ({
|
|
40
|
+
name: normalizePath(issue.path),
|
|
41
|
+
message: issue.message
|
|
42
|
+
})),
|
|
43
|
+
value: null
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
errors: null,
|
|
48
|
+
value: result.value
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ==================== JOI VALIDATOR ====================
|
|
52
|
+
export async function validateJoiSchema(state, schema) {
|
|
53
|
+
// abortEarly: false → collect all errors, not just the first.
|
|
54
|
+
const result = schema.validate(state, { abortEarly: false });
|
|
55
|
+
if (result.error) {
|
|
56
|
+
return {
|
|
57
|
+
errors: result.error.details.map((detail) => ({
|
|
58
|
+
name: detail.path.map(String).join('.'),
|
|
59
|
+
message: detail.message
|
|
60
|
+
})),
|
|
61
|
+
value: null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
errors: null,
|
|
66
|
+
value: (result.value ?? null)
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// ==================== DISPATCH ====================
|
|
70
|
+
export function validateSchema(state, schema) {
|
|
71
|
+
// Joi is checked first, even though Joi 18+ also implements Standard Schema:
|
|
72
|
+
// Joi's Standard Schema impl defaults to `abortEarly: true` (stopping at the
|
|
73
|
+
// first error), whereas we want to surface ALL errors per field so the user
|
|
74
|
+
// can see every invalid field at once. The dedicated Joi adapter passes
|
|
75
|
+
// `{ abortEarly: false }` explicitly.
|
|
76
|
+
if (isJoiSchema(schema)) {
|
|
77
|
+
return validateJoiSchema(state, schema);
|
|
78
|
+
}
|
|
79
|
+
if (isStandardSchema(schema)) {
|
|
80
|
+
return validateStandardSchema(state, schema);
|
|
81
|
+
}
|
|
82
|
+
throw new Error('Form validation failed: Unsupported form schema');
|
|
83
|
+
}
|
|
84
|
+
// ==================== PATH UTILITIES (nested form support) ====================
|
|
85
|
+
export function getAtPath(data, path) {
|
|
86
|
+
if (!path)
|
|
87
|
+
return data;
|
|
88
|
+
return path.split('.').reduce((value, key) => {
|
|
89
|
+
if (value === null || value === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
return value[key];
|
|
92
|
+
}, data);
|
|
93
|
+
}
|
|
94
|
+
export function setAtPath(data, path, value) {
|
|
95
|
+
if (!path) {
|
|
96
|
+
return Object.assign(data, value);
|
|
97
|
+
}
|
|
98
|
+
if (!data)
|
|
99
|
+
return data;
|
|
100
|
+
const keys = path.split('.');
|
|
101
|
+
let current = data;
|
|
102
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
103
|
+
const key = keys[i];
|
|
104
|
+
if (current[key] === undefined || current[key] === null) {
|
|
105
|
+
const nextKey = keys[i + 1];
|
|
106
|
+
current[key] = !Number.isNaN(Number(nextKey)) ? [] : {};
|
|
107
|
+
}
|
|
108
|
+
current = current[key];
|
|
109
|
+
}
|
|
110
|
+
const lastKey = keys[keys.length - 1];
|
|
111
|
+
current[lastKey] = value;
|
|
112
|
+
return data;
|
|
113
|
+
}
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import { Label, useId } from 'bits-ui'
|
|
9
9
|
import { formFieldVariants, formFieldDefaults } from './form-field.variants.js'
|
|
10
10
|
import { getComponentConfig } from '../config.js'
|
|
11
|
-
import { setContext } from 'svelte'
|
|
12
|
-
import type
|
|
11
|
+
import { setContext, untrack } from 'svelte'
|
|
12
|
+
import { type FormFieldContext, FORM_FIELD_CONTEXT_KEY } from '../hooks/useFormField.svelte.js'
|
|
13
|
+
import { getFormContext } from '../Form/form.context.svelte.js'
|
|
13
14
|
|
|
14
15
|
const config = getComponentConfig('formField', formFieldDefaults)
|
|
15
16
|
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
size = config.defaultVariants.size ?? 'md',
|
|
26
27
|
required = false,
|
|
27
28
|
orientation = config.defaultVariants.orientation ?? 'vertical',
|
|
29
|
+
eagerValidation,
|
|
30
|
+
validateOnInputDelay,
|
|
31
|
+
errorPattern,
|
|
28
32
|
class: className,
|
|
29
33
|
children,
|
|
30
34
|
labelSlot,
|
|
@@ -38,6 +42,56 @@
|
|
|
38
42
|
const id = useId()
|
|
39
43
|
const ariaId = $derived(name ? `form-field-${name}` : id)
|
|
40
44
|
|
|
45
|
+
// Form-level context (undefined when FormField is used standalone).
|
|
46
|
+
const formCtx = getFormContext()
|
|
47
|
+
|
|
48
|
+
// Resolve error: explicit `error` prop always wins; otherwise ask the Form for
|
|
49
|
+
// errors matching this field's name (or errorPattern regex, if provided).
|
|
50
|
+
const resolvedError = $derived.by<string | boolean | undefined>(() => {
|
|
51
|
+
if (error !== undefined) return error
|
|
52
|
+
if (!formCtx || !name) return undefined
|
|
53
|
+
const errs = errorPattern ? formCtx.getErrors(errorPattern) : formCtx.getErrors(name)
|
|
54
|
+
return errs[0]?.message
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Register this field with the form. Split into two effects:
|
|
58
|
+
//
|
|
59
|
+
// 1. Lifecycle effect — tracks only `name`. Handles the initial registration
|
|
60
|
+
// and cleanup on unmount / rename. Reads other entry fields via `untrack`
|
|
61
|
+
// so they don't retrigger the lifecycle.
|
|
62
|
+
// 2. Update effect — tracks ariaId / errorPattern / eagerValidation /
|
|
63
|
+
// validateOnInputDelay and rewrites the entry via `.set()`. No cleanup —
|
|
64
|
+
// a single atomic overwrite, so no window where the registry is empty.
|
|
65
|
+
//
|
|
66
|
+
// Without this split, changing an unrelated prop (e.g. errorPattern) would
|
|
67
|
+
// cause cleanup→re-register, briefly leaving `#fieldRegistry.get(name)`
|
|
68
|
+
// undefined for any concurrent `#resolveErrorIds` call.
|
|
69
|
+
$effect(() => {
|
|
70
|
+
if (!formCtx || !name) return
|
|
71
|
+
const registeredName = name
|
|
72
|
+
untrack(() => {
|
|
73
|
+
formCtx.registerField(registeredName, {
|
|
74
|
+
id: ariaId,
|
|
75
|
+
pattern: errorPattern,
|
|
76
|
+
eagerValidation,
|
|
77
|
+
validateOnInputDelay
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
return () => {
|
|
81
|
+
formCtx.unregisterField(registeredName)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
$effect(() => {
|
|
86
|
+
if (!formCtx || !name) return
|
|
87
|
+
formCtx.registerField(name, {
|
|
88
|
+
id: ariaId,
|
|
89
|
+
pattern: errorPattern,
|
|
90
|
+
eagerValidation,
|
|
91
|
+
validateOnInputDelay
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
41
95
|
const variantSlots = $derived(formFieldVariants({ size, required, orientation }))
|
|
42
96
|
const classes = $derived({
|
|
43
97
|
root: variantSlots.root({ class: [config.slots.root, className, ui?.root] }),
|
|
@@ -55,10 +109,10 @@
|
|
|
55
109
|
help: variantSlots.help({ class: [config.slots.help, ui?.help] })
|
|
56
110
|
})
|
|
57
111
|
|
|
58
|
-
const hasError = $derived(
|
|
59
|
-
const errorMessage = $derived(typeof
|
|
112
|
+
const hasError = $derived(resolvedError !== undefined && resolvedError !== false)
|
|
113
|
+
const errorMessage = $derived(typeof resolvedError === 'string' ? resolvedError : undefined)
|
|
60
114
|
|
|
61
|
-
setContext<FormFieldContext>(
|
|
115
|
+
setContext<FormFieldContext>(FORM_FIELD_CONTEXT_KEY, {
|
|
62
116
|
get name() {
|
|
63
117
|
return name
|
|
64
118
|
},
|
|
@@ -66,10 +120,19 @@
|
|
|
66
120
|
return size
|
|
67
121
|
},
|
|
68
122
|
get error() {
|
|
69
|
-
return
|
|
123
|
+
return resolvedError
|
|
70
124
|
},
|
|
71
125
|
get ariaId() {
|
|
72
126
|
return ariaId
|
|
127
|
+
},
|
|
128
|
+
get eagerValidation() {
|
|
129
|
+
return eagerValidation
|
|
130
|
+
},
|
|
131
|
+
get validateOnInputDelay() {
|
|
132
|
+
return validateOnInputDelay
|
|
133
|
+
},
|
|
134
|
+
get errorPattern() {
|
|
135
|
+
return errorPattern
|
|
73
136
|
}
|
|
74
137
|
})
|
|
75
138
|
</script>
|
|
@@ -103,12 +166,12 @@
|
|
|
103
166
|
|
|
104
167
|
<div class={classes.container}>
|
|
105
168
|
{#if children}
|
|
106
|
-
{@render children({ error })}
|
|
169
|
+
{@render children({ error: resolvedError })}
|
|
107
170
|
{/if}
|
|
108
171
|
|
|
109
172
|
{#if hasError && (errorMessage || errorSlot)}
|
|
110
173
|
{#if errorSlot}
|
|
111
|
-
{@render errorSlot({ error })}
|
|
174
|
+
{@render errorSlot({ error: resolvedError })}
|
|
112
175
|
{:else if errorMessage}
|
|
113
176
|
<p id="{ariaId}-error" class={classes.error}>{errorMessage}</p>
|
|
114
177
|
{/if}
|
|
@@ -47,6 +47,21 @@ export type FormFieldProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> & {
|
|
|
47
47
|
* @default 'vertical'
|
|
48
48
|
*/
|
|
49
49
|
orientation?: NonNullable<FormFieldVariantProps['orientation']>;
|
|
50
|
+
/**
|
|
51
|
+
* When `true`, input events validate this field before first blur.
|
|
52
|
+
* By default, `input`-triggered validation only runs after the field has
|
|
53
|
+
* been blurred at least once.
|
|
54
|
+
*/
|
|
55
|
+
eagerValidation?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Per-field override for the form's `validateOnInputDelay` (debounce in ms).
|
|
58
|
+
*/
|
|
59
|
+
validateOnInputDelay?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Regex pattern to match form errors for this field (in addition to exact name match).
|
|
62
|
+
* Useful for array-style fields like `items.0.name`, `items.1.name`, etc.
|
|
63
|
+
*/
|
|
64
|
+
errorPattern?: RegExp;
|
|
50
65
|
/**
|
|
51
66
|
* Additional CSS classes for the root element.
|
|
52
67
|
*/
|
package/dist/Input/Input.svelte
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
-
import type { InputProps } from './input.types.js'
|
|
2
|
+
import type { InputProps, InputValue } from './input.types.js'
|
|
3
3
|
|
|
4
|
-
export type Props = InputProps
|
|
4
|
+
export type Props<T extends InputValue = InputValue> = InputProps<T>
|
|
5
5
|
</script>
|
|
6
6
|
|
|
7
|
-
<script lang="ts">
|
|
7
|
+
<script lang="ts" generics="T extends InputValue = InputValue">
|
|
8
8
|
import { inputVariants, inputDefaults } from './input.variants.js'
|
|
9
9
|
import { getComponentConfig, iconsDefaults } from '../config.js'
|
|
10
10
|
import { getContext } from 'svelte'
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import Icon from '../Icon/Icon.svelte'
|
|
16
16
|
import Avatar from '../Avatar/Avatar.svelte'
|
|
17
17
|
import type { AvatarSize } from '../Avatar/avatar.types.js'
|
|
18
|
-
import { useFormField } from '../hooks/useFormField.svelte.js'
|
|
18
|
+
import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
|
|
19
19
|
|
|
20
20
|
const config = getComponentConfig('input', inputDefaults)
|
|
21
21
|
const icons = getComponentConfig('icons', iconsDefaults)
|
|
@@ -42,10 +42,32 @@
|
|
|
42
42
|
leadingSlot,
|
|
43
43
|
trailingSlot,
|
|
44
44
|
class: className,
|
|
45
|
+
onblur,
|
|
46
|
+
oninput,
|
|
47
|
+
onchange,
|
|
48
|
+
onfocus,
|
|
45
49
|
...restProps
|
|
46
|
-
}: Props = $props()
|
|
50
|
+
}: Props<T> = $props()
|
|
47
51
|
|
|
48
52
|
const formFieldContext = useFormField()
|
|
53
|
+
const emit = useFormFieldEmit()
|
|
54
|
+
|
|
55
|
+
function handleBlur(event: FocusEvent & { currentTarget: HTMLInputElement }) {
|
|
56
|
+
emit.onBlur()
|
|
57
|
+
onblur?.(event)
|
|
58
|
+
}
|
|
59
|
+
function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
|
|
60
|
+
emit.onInput()
|
|
61
|
+
oninput?.(event)
|
|
62
|
+
}
|
|
63
|
+
function handleChange(event: Event & { currentTarget: HTMLInputElement }) {
|
|
64
|
+
emit.onChange()
|
|
65
|
+
onchange?.(event)
|
|
66
|
+
}
|
|
67
|
+
function handleFocus(event: FocusEvent & { currentTarget: HTMLInputElement }) {
|
|
68
|
+
emit.onFocus()
|
|
69
|
+
onfocus?.(event)
|
|
70
|
+
}
|
|
49
71
|
|
|
50
72
|
const fieldGroupContext = getContext<
|
|
51
73
|
| {
|
|
@@ -153,6 +175,10 @@
|
|
|
153
175
|
aria-describedby={ariaDescribedBy}
|
|
154
176
|
aria-invalid={resolvedHighlight ? true : undefined}
|
|
155
177
|
class={classes.base}
|
|
178
|
+
onblur={handleBlur}
|
|
179
|
+
oninput={handleInput}
|
|
180
|
+
onchange={handleChange}
|
|
181
|
+
onfocus={handleFocus}
|
|
156
182
|
/>
|
|
157
183
|
|
|
158
184
|
{#if trailingSlot}
|
|
@@ -1,5 +1,26 @@
|
|
|
1
|
-
import type { InputProps } from './input.types.js';
|
|
2
|
-
export type Props = InputProps
|
|
3
|
-
declare
|
|
4
|
-
|
|
1
|
+
import type { InputProps, InputValue } from './input.types.js';
|
|
2
|
+
export type Props<T extends InputValue = InputValue> = InputProps<T>;
|
|
3
|
+
declare function $$render<T extends InputValue = InputValue>(): {
|
|
4
|
+
props: Props<T>;
|
|
5
|
+
exports: {};
|
|
6
|
+
bindings: "ref" | "value";
|
|
7
|
+
slots: {};
|
|
8
|
+
events: {};
|
|
9
|
+
};
|
|
10
|
+
declare class __sveltets_Render<T extends InputValue = InputValue> {
|
|
11
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
12
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
13
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
14
|
+
bindings(): "ref" | "value";
|
|
15
|
+
exports(): {};
|
|
16
|
+
}
|
|
17
|
+
interface $$IsomorphicComponent {
|
|
18
|
+
new <T extends InputValue = InputValue>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
19
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
20
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
21
|
+
<T extends InputValue = InputValue>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
22
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
23
|
+
}
|
|
24
|
+
declare const Input: $$IsomorphicComponent;
|
|
25
|
+
type Input<T extends InputValue = InputValue> = InstanceType<typeof Input<T>>;
|
|
5
26
|
export default Input;
|
|
@@ -3,15 +3,36 @@ import type { HTMLInputAttributes } from 'svelte/elements';
|
|
|
3
3
|
import type { ClassNameValue } from 'tailwind-merge';
|
|
4
4
|
import type { InputVariantProps, InputSlots } from './input.variants.js';
|
|
5
5
|
import type { AvatarProps } from '../Avatar/avatar.types.js';
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Valid value types for `<Input bind:value>`.
|
|
8
|
+
*
|
|
9
|
+
* Covers every HTML input `type` except `"file"` (which uses `FileList` and is
|
|
10
|
+
* typically handled via `onchange` events, not `bind:value`). Pattern borrowed
|
|
11
|
+
* from Nuxt UI v4's `AcceptableValue` — it's the minimum union that covers
|
|
12
|
+
* real-world usage while still giving TypeScript enough to catch mistakes.
|
|
13
|
+
*
|
|
14
|
+
* Use with the component generic for strict typing:
|
|
15
|
+
* ```ts
|
|
16
|
+
* let age = $state<number | undefined>(undefined)
|
|
17
|
+
* <Input type="number" bind:value={age} /> // age stays number
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export type InputValue = string | number | bigint | boolean | null | undefined;
|
|
21
|
+
export type InputProps<T extends InputValue = InputValue> = Omit<HTMLInputAttributes, 'class' | 'size' | 'value'> & {
|
|
7
22
|
/**
|
|
8
23
|
* Bind a reference to the underlying HTMLInputElement.
|
|
9
24
|
*/
|
|
10
25
|
ref?: HTMLInputElement | null;
|
|
11
26
|
/**
|
|
12
27
|
* The current value of the input. Supports two-way binding with `bind:value`.
|
|
13
|
-
|
|
14
|
-
|
|
28
|
+
*
|
|
29
|
+
* The type is inferred from the bound variable — Svelte's `bind:value`
|
|
30
|
+
* coerces based on the `type` attribute automatically:
|
|
31
|
+
* - `string` for `text | email | password | url | search | tel | date | time | color` etc.
|
|
32
|
+
* - `number` for `type="number" | "range"`
|
|
33
|
+
* - `boolean` for hidden boolean state (rare)
|
|
34
|
+
*/
|
|
35
|
+
value?: T;
|
|
15
36
|
/**
|
|
16
37
|
* Controls the visual style of the input.
|
|
17
38
|
* @default 'outline'
|