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.
Files changed (37) hide show
  1. package/README.md +60 -183
  2. package/dist/Checkbox/Checkbox.svelte +8 -2
  3. package/dist/CheckboxGroup/CheckboxGroup.svelte +15 -2
  4. package/dist/Form/Form.svelte +203 -0
  5. package/dist/Form/Form.svelte.d.ts +26 -0
  6. package/dist/Form/form.context.svelte.d.ts +64 -0
  7. package/dist/Form/form.context.svelte.js +478 -0
  8. package/dist/Form/form.types.d.ts +164 -0
  9. package/dist/Form/form.types.js +12 -0
  10. package/dist/Form/form.variants.d.ts +39 -0
  11. package/dist/Form/form.variants.js +17 -0
  12. package/dist/Form/index.d.ts +4 -0
  13. package/dist/Form/index.js +6 -0
  14. package/dist/Form/validate-schema.d.ts +13 -0
  15. package/dist/Form/validate-schema.js +113 -0
  16. package/dist/FormField/FormField.svelte +71 -8
  17. package/dist/FormField/form-field.types.d.ts +15 -0
  18. package/dist/Input/Input.svelte +31 -5
  19. package/dist/Input/Input.svelte.d.ts +25 -4
  20. package/dist/Input/input.types.d.ts +24 -3
  21. package/dist/PinInput/PinInput.svelte +9 -2
  22. package/dist/RadioGroup/RadioGroup.svelte +17 -3
  23. package/dist/Select/Select.svelte +14 -3
  24. package/dist/SelectMenu/SelectMenu.svelte +9 -2
  25. package/dist/Slider/Slider.svelte +4 -1
  26. package/dist/Switch/Switch.svelte +8 -2
  27. package/dist/Table/Table.svelte +11 -0
  28. package/dist/Table/table.types.d.ts +3 -0
  29. package/dist/Table/table.variants.js +5 -5
  30. package/dist/Textarea/Textarea.svelte +27 -1
  31. package/dist/hooks/index.d.ts +1 -1
  32. package/dist/hooks/index.js +1 -1
  33. package/dist/hooks/useFormField.svelte.d.ts +64 -0
  34. package/dist/hooks/useFormField.svelte.js +70 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. 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 { FormFieldContext } from '../hooks/useFormField.svelte.js'
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(error !== undefined && error !== false)
59
- const errorMessage = $derived(typeof error === 'string' ? error : undefined)
112
+ const hasError = $derived(resolvedError !== undefined && resolvedError !== false)
113
+ const errorMessage = $derived(typeof resolvedError === 'string' ? resolvedError : undefined)
60
114
 
61
- setContext<FormFieldContext>('formField', {
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 error
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
  */
@@ -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 const Input: import("svelte").Component<InputProps, {}, "ref" | "value">;
4
- type Input = ReturnType<typeof Input>;
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
- export type InputProps = Omit<HTMLInputAttributes, 'class' | 'size'> & {
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
- value?: string;
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'