/** * Form module types and interfaces. * * @module bquery/forms */ import type { Computed, Signal } from '../reactive/index'; /** * Result of a single validation rule. * A string indicates an error message; `true` or `undefined` means valid. */ export type ValidationResult = string | true | undefined; /** * Synchronous validator function. */ export type SyncValidator = (value: T) => ValidationResult; /** * Asynchronous validator function. */ export type AsyncValidator = (value: T) => Promise; /** * Either a sync or async validator. */ export type Validator = SyncValidator | AsyncValidator; /** * When automatic field validation should run. * * - `'manual'`: only when explicitly triggered * - `'change'`: every value mutation triggers validation * - `'blur'`: validation runs when the field is touched * - `'both'`: validate on both change and blur */ export type FormFieldValidationMode = 'manual' | 'change' | 'blur' | 'both'; /** * Configuration for a single form field. * * @template T - The type of the field value */ export type FieldConfig = { /** Initial value for this field */ initialValue: T; /** Validation rules applied in order; stops at first failure */ validators?: Validator[]; /** * Per-field automatic validation mode. Overrides the form-level * `validationStrategy` for this field only. Defaults to `'manual'`. */ validateOn?: FormFieldValidationMode; /** Delay automatic validation by the given milliseconds. */ debounceMs?: number; /** Optional transform applied to values passed through `setValues()` / `restore()`. */ parse?: (raw: unknown) => T; /** Optional transform applied to outgoing values from `getValues()` / submit payloads. */ format?: (value: T) => T; /** * When `true`, the field starts disabled. Disabled fields are skipped by * `validate()` and `handleSubmit()`. */ disabled?: boolean; }; /** * Options accepted by {@link FormField.setValue}. */ export type SetFieldValueOptions = { /** Mark the field as touched after writing the value. */ touch?: boolean; /** Trigger validation for this field after writing the value. */ validate?: boolean; /** Skip automatic validation/subscriber side effects for this write. */ silent?: boolean; }; /** * Options accepted by {@link useFormField().setValue}. */ export type UseFormFieldSetValueOptions = SetFieldValueOptions; /** * Reactive state for a single form field. * * @template T - The type of the field value */ export type FormField = { /** Reactive signal holding the current value */ value: Signal; /** Reactive signal for the first validation error (empty string when valid) */ error: Signal; /** Whether the field value differs from its initial value */ isDirty: Computed; /** Whether the field has been interacted with (blur / explicit touch) */ isTouched: Signal; /** Whether the field has never been modified */ isPristine: Computed; /** Reactive signal: `true` while async validation is still running */ isValidating: Signal; /** Reactive signal: `true` while the field has focus */ isFocused: Signal; /** Reactive signal: `true` while the field is disabled and skipped by validation */ disabled: Signal; /** Timestamp (ms since epoch) of the first change since reset, or `null` while pristine */ dirtySince: Signal; /** Mark the field as touched */ touch: () => void; /** Reset the field to its initial value and clear errors */ reset: () => void; /** Atomically set the field value with an optional touch flag. */ setValue: (value: T, options?: SetFieldValueOptions) => void; /** Set the field's error message. */ setError: (message: string) => void; /** Clear the field's error message. */ clearError: () => void; /** Mark the field as focused (does not call DOM `focus()`). */ focus: () => void; /** Mark the field as blurred (does not call DOM `blur()`). */ blur: () => void; }; /** * Configuration for {@link useFormField}. */ export type UseFormFieldOptions = { /** Validation rules applied in order; stops at first failure */ validators?: Validator[]; /** When validation should run automatically. */ validateOn?: FormFieldValidationMode; /** Delay automatic validation by the given milliseconds. */ debounceMs?: number; /** Initial error message for the field. */ initialError?: string; }; /** * Return value of {@link useFormField}. */ export type UseFormFieldReturn = FormField & { /** Standalone fields support immediate validation via `setValue(..., { validate: true })`. */ setValue: (value: T, options?: UseFormFieldSetValueOptions) => void; /** Whether the current field has no validation error */ isValid: Computed; /** Validate the current field value immediately */ validate: () => Promise; /** Cancel pending timers and automatic validation subscriptions */ destroy: () => void; }; /** * Map of field names to their reactive field state. */ export type FormFields> = { [K in keyof T]: FormField; }; /** * Map of field names to their error strings (reactive signals). */ export type FormErrors> = { [K in keyof T]: Signal; }; /** * Cross-field validation function. */ export type CrossFieldValidator> = ( values: T ) => | Partial> | undefined | Promise> | undefined>; /** * Submit handler function. */ export type SubmitHandler> = (values: T) => void | Promise; /** * Form-wide **automatic** validation strategy applied to fields that do not * declare their own `validateOn`. * * This setting controls only *automatic* validation as the user interacts with * the form. It does **not** gate submit: {@link Form.handleSubmit} always runs * the full {@link Form.validate} pass (every field validator plus * cross-validators) before invoking `onSubmit`, regardless of strategy. * * - `'manual'` (**default**): no automatic per-keystroke / per-blur validation; * errors surface on `handleSubmit()` or an explicit `validate()` / * `validateField()` / `setValue(v, { validate: true })`. This is the default * because it is the least surprising for the common "validate on submit" flow * and avoids flagging fields the user has not finished editing. Opt into live * validation with one of the modes below. * - `'onChange'`: validate each field on every value change. * - `'onBlur'`: validate each field when it is blurred / touched. * - `'onSubmit'`: like `'manual'` for automatic feedback (submit still * validates) — provided as an explicit, self-documenting alias. */ export type FormValidationStrategy = 'onChange' | 'onBlur' | 'onSubmit' | 'manual'; /** * Determines whether `form.validate()` stops at the first failing per-field * validator (`'first'`) or runs all of them (`'all'`). */ export type FormValidationMode = 'first' | 'all'; /** * A coarse change listener subscribed via {@link Form.subscribe}. */ export type FormChangeListener> = (values: T) => void; /** * Configuration for `createForm()`. */ export type FormConfig> = { /** Per-field configuration */ fields: { [K in keyof T]: FieldConfig }; /** Optional cross-field validators */ crossValidators?: CrossFieldValidator[]; /** Successful-submit callback */ onSubmit?: SubmitHandler; /** Error callback when the submit handler throws or rejects. */ onSubmitError?: (error: unknown, values: T) => void | Promise; /** Callback invoked after a successful submit. */ onSubmitSuccess?: (values: T) => void | Promise; /** * Form-wide automatic validation strategy. Defaults to `'manual'` — submit * always validates; automatic per-change/per-blur validation is opt-in. See * {@link FormValidationStrategy} for the full contract. */ validationStrategy?: FormValidationStrategy; /** Per-field validation mode. Defaults to `'first'`. */ mode?: FormValidationMode; }; /** * Plain snapshot of form values + errors + touched flags. */ export type FormSnapshot> = { values: T; errors: Partial>; touched: Partial>; }; /** * Return value of `createForm()`. */ export type Form> = { fields: FormFields; errors: FormErrors; isValid: Computed; isDirty: Computed; isPristine: Computed; isValidating: Computed; isSubmitting: Signal; submitCount: Signal; lastSubmittedAt: Signal; submitError: Signal; handleSubmit: () => Promise; validateField: (name: keyof T & string) => Promise; validate: () => Promise; reset: () => void; resetField: (name: keyof T & string) => void; resetErrors: () => void; touchAll: () => void; untouchAll: () => void; getValues: () => T; getDirtyValues: () => Partial; setValues: (values: Partial) => void; setErrors: (errors: Partial>) => void; subscribe: (listener: FormChangeListener) => () => void; snapshot: () => FormSnapshot; restore: (snapshot: FormSnapshot) => void; toFormData: () => FormData; toJSON: () => T; destroy: () => void; }; // --------------------------------------------------------------------------- // Field arrays // --------------------------------------------------------------------------- /** * Stable key extractor for a field array. Receives an item value and its * current index and must return a key that is **unique and stable** for the * lifetime of that item. Used for keyed list reconciliation (e.g. `bq-for`). */ export type FieldArrayKeyFn = (value: T, index: number) => string | number; /** * Configuration for {@link createFieldArray}. */ export type FieldArrayConfig = { /** Initial items. */ initial?: readonly T[]; /** Factory that creates a {@link FormField} for each array item. */ factory: (value: T) => FormField; /** Validators applied to the entire array. */ validators?: Validator[]; /** * Optional stable-key extractor for keyed list reconciliation. When supplied, * the array enforces the contract on every structural mutation: keys must be * present (non-empty `string`/`number`) and unique. A violation throws a * descriptive `Error` naming the offending key — surfacing the * "stable item ids" requirement at the point of failure instead of as silent * DOM-reuse bugs. When omitted, the array is positional (unchanged behaviour) * and {@link FormFieldArray.keyAt} / {@link FormFieldArray.keys} return * `undefined` / `[]`. */ getKey?: FieldArrayKeyFn; }; /** * Reactive array of fields with mutation helpers. */ export type FormFieldArray = { items: Signal[]>; length: Computed; error: Signal; add: (value: T) => FormField; insert: (index: number, value: T) => FormField; remove: (index: number) => boolean; move: (from: number, to: number) => void; clear: () => void; validate: () => Promise; reset: () => void; getValues: () => T[]; /** * Stable key for the item at `index`, computed via the configured * `getKey`. Returns `undefined` when no `getKey` was supplied or the index is * out of range. */ keyAt: (index: number) => string | number | undefined; /** * The stable keys of all current items, in order. Returns `[]` when no * `getKey` was supplied. */ keys: () => (string | number)[]; /** * Tear down the array: clears every item (calling `destroy()` / `dispose()` * on each if available) and disposes the internal reactive primitives * (`items`, `length`, `error`). After `destroy()`, the field array should * not be used. */ destroy: () => void; }; // --------------------------------------------------------------------------- // Form-DOM bridge // --------------------------------------------------------------------------- /** * Options for {@link bindField}. */ export type BindFieldOptions = { /** Override the field's `debounceMs` for this binding. */ debounceMs?: number; /** Custom DOM-event → field-value extractor. */ getValue?: (element: Element) => unknown; }; /** * Options for {@link bindForm}. */ export type BindFormOptions = { /** Lookup function for the DOM element that should display a field's error. */ errorSlot?: (name: string, formElement: HTMLElement) => HTMLElement | null; /** Override the input `name` → form field-key mapping. */ fieldMap?: Record; };