@mobx-sentinel/form
Version:
MobX library for non-intrusive class-based model enhancement. Acting as a sentinel, it provides change detection, reactive validation, and form integration capabilities without contamination.
499 lines (492 loc) • 17.8 kB
text/typescript
import { Validator, Watcher, KeyPath } from '@mobx-sentinel/core';
import * as mobx from 'mobx';
declare const internalToken$1: unique symbol;
/**
* Form field that tracks input state and validation errors
*
* Key features:
* - Tracks touch state
* - Handles intermediate (partial) input states
* - Manages error reporting
* - Supports auto-finalization of intermediate values
*/
declare class FormField {
#private;
readonly id: string;
readonly fieldName: string;
readonly validator: Validator<any>;
/** @ignore */
constructor(args: {
fieldName: string;
validator: Validator<any>;
getFinalizationDelayMs: () => number;
});
/**
* Whether the field is touched.
*
* A field becomes touched when the user interacts with it.
*/
get isTouched(): boolean;
/**
* Whether the field value is intermediate (partial input)
*
* @example
* - Typing "user@" in an email field
* - Typing a partial date "2024-"
*
* @remarks
* Intermediate values are automatically finalized after a delay
*/
get isIntermediate(): boolean;
/**
* Whether the field value is changed
*/
get isChanged(): boolean;
/**
* Whether the error states has been reported.
*
* Check this value to determine if errors should be displayed to the user.
*
* @returns
* - `undefined` - Validity is undetermined (not yet reported)
* - `false` - Field is valid
* - `true` - Field is invalid
*
* @remarks
* - Error reporting is delayed until validation is complete.
* - It can be used directly with the `aria-invalid` attribute.
*/
get isErrorReported(): boolean | undefined;
/**
* Error messages for the field
*
* Regardless of {@link isErrorReported}, this value is always up-to-date.
*/
get errors(): ReadonlySet<string>;
/**
* Whether the field has errors
*
* Regardless of {@link isErrorReported}, this value is always up-to-date.
*/
get hasErrors(): boolean;
/**
* Reset the field state
*
* @remarks
* - Clears touched state
* - Clears change state
* - Clears error reporting
* - Cancels any pending auto-finalization
*/
reset(): void;
/**
* Mark the field as touched
*
* It's usually triggered by `onFocus`.
*/
markAsTouched(): void;
/**
* Mark the field as changed
*
* It's usually triggered by `onChange`.
*
* @param type Default to "final"
*
* @remarks
* - "final" immediately reports errors
* - "intermediate" schedules auto-finalization after delay
*/
markAsChanged(type?: FormField.ChangeType): void;
/**
* Report the errors of the field.
*
* It will wait until the validation is up-to-date before reporting the errors.
*/
reportError(): void;
/** Finalize the intermediate change if needed (usually triggered by onBlur) */
finalizeChangeIfNeeded(): void;
/** @internal @ignore */
[internalToken$1](): {
isReported: mobx.IObservableValue<boolean>;
};
}
declare namespace FormField {
/** Strict field name */
type NameStrict<T> = keyof T & string;
/** Augmented field name with an arbitrary suffix */
type NameAugmented<T> = `${NameStrict<T>}:${string}`;
/** Field name */
type Name<T> = NameStrict<T> | NameAugmented<T>;
/**
* The type of change that has occurred in the field.
*
* - "final" - The input is complete and no further update is necessary to make it valid.
* - "intermediate" - The input is incomplete and does not yet conform to the expected format.
*/
type ChangeType = "final" | "intermediate";
}
type ConfigOf<T> = T extends new (form: Form<any>, config: infer Config) => FormBinding ? Config : T extends new (field: FormField, config: infer Config) => FormBinding ? Config : T extends new (fields: FormField[], config: infer Config) => FormBinding ? Config : never;
/** Interface for form binding classes */
interface FormBinding {
/** Configuration of the binding */
config?: object;
/** Binding properties which passed to the view component */
readonly props: object;
}
/** Polymorphic function of Form#bind */
interface FormBindingFunc<T> extends FormBindingFunc.ForField<T>, FormBindingFunc.ForMultiField<T>, FormBindingFunc.ForForm<T> {
}
declare namespace FormBindingFunc {
/** Bind configuration */
type Config = {
/** Cache key for the binding */
cacheKey?: string;
};
/** Bind to a field */
interface ForField<T> {
/** Create a binding for the field */
<Binding extends new (field: FormField) => FormBinding>(fieldName: FormField.Name<T>, binding: Binding, config?: Config): InstanceType<Binding>["props"];
/** Create a binding for the field with the config */
<Binding extends new (field: FormField, config: any) => FormBinding>(fieldName: FormField.Name<T>, binding: Binding, config: NoInfer<ConfigOf<Binding>> & Config): InstanceType<Binding>["props"];
}
/** Bind to multiple fields */
interface ForMultiField<T> {
/** Create a binding for the multiple fields */
<Binding extends new (fields: FormField[]) => FormBinding>(fieldNames: FormField.Name<T>[], binding: Binding, config?: Config): InstanceType<Binding>["props"];
/** Create a binding for the multiple fields with the config */
<Binding extends new (fields: FormField[], config: any) => FormBinding>(fieldNames: FormField.Name<T>[], binding: Binding, config: NoInfer<ConfigOf<Binding>> & Config): InstanceType<Binding>["props"];
}
/** Bind to the form */
interface ForForm<T> {
/** Create a binding for the form */
<Binding extends new (form: Form<T>) => FormBinding>(binding: Binding, config?: Config): InstanceType<Binding>["props"];
/** Create a binding for the form with the config */
<Binding extends new (form: Form<T>, config: any) => FormBinding>(binding: Binding, config: NoInfer<ConfigOf<Binding>> & Config): InstanceType<Binding>["props"];
}
}
declare namespace FormBindingFuncExtension {
/** Bind configuration */
type Config = FormBindingFunc.Config;
/** Bind to a field */
namespace ForField {
/** Create a binding for the field with an optional config */
type OptionalConfig<T, Binding extends new (field: FormField, config?: any) => FormBinding> = (fieldName: FormField.Name<T>, config?: NoInfer<ConfigOf<Binding>> & Config) => InstanceType<Binding>["props"];
/** Create a binding for the field with a required config */
type RequiredConfig<T, Binding extends new (field: FormField, config: any) => FormBinding> = (fieldName: FormField.Name<T>, config: NoInfer<ConfigOf<Binding>> & Config) => InstanceType<Binding>["props"];
}
/** Bind to multiple fields */
namespace ForMultiField {
/** Create a binding for the multiple fields */
type OptionalConfig<T, Binding extends new (fields: FormField[], config?: any) => FormBinding> = (fieldNames: FormField.Name<T>[], config?: NoInfer<ConfigOf<Binding>> & Config) => InstanceType<Binding>["props"];
/** Create a binding for the multiple fields with the config */
type RequiredConfig<T, Binding extends new (fields: FormField[], config: any) => FormBinding> = (fieldNames: FormField.Name<T>[], config: NoInfer<ConfigOf<Binding>> & Config) => InstanceType<Binding>["props"];
}
/** Bind to the form */
namespace ForForm {
/** Create a binding for the form */
type OptionalConfig<T, Binding extends new (form: Form<T>, config: any) => FormBinding> = (config?: NoInfer<ConfigOf<Binding>> & Config) => InstanceType<Binding>["props"];
/** Create a binding for the form with the config */
type RequiredConfig<T, Binding extends new (form: Form<T>, config: any) => FormBinding> = (config: NoInfer<ConfigOf<Binding>> & Config) => InstanceType<Binding>["props"];
}
}
/** Form configuration */
type FormConfig = {
/**
* Automatically finalize the form when the input is intermediate (partial input). [in milliseconds]
*
* @default 3000
*/
autoFinalizationDelayMs: number;
/**
* Allow submission even if the form is not dirty.
*
* @default false
*/
allowSubmitNonDirty: boolean;
/**
* Allow submission even if the form is invalid.
*
* @default false
*/
allowSubmitInvalid: boolean;
};
/** Update the global configuration */
declare function configureForm(config: Partial<Readonly<FormConfig>>): Readonly<FormConfig>;
/** Reset the global configuration to the default */
declare function configureForm(reset: true): Readonly<FormConfig>;
/**
* Manages form submission lifecycle and handlers
*
* Key features:
* - Handles submission lifecycle (`willSubmit` -> `submit` -> `didSubmit`)
* - Executes handlers in registration order
* - Supports cancellation of in-progress submissions
* - Tracks submission state
*/
declare class Submission {
#private;
/** Whether the submission is running */
get isRunning(): boolean;
/**
* Add a handler for submission events
*
* @returns Function to remove the handler
*/
addHandler<K extends keyof Submission.Handlers>(event: K, handler: Submission.Handlers[K]): () => void;
/**
* Execute the submission process
*
* @remarks
* - Cancels any in-progress submission
* - Executes handlers in order
* - `submit` handlers are executed serially
* - Aborts if any `submit` handler returns false
* - Handles exceptions in all phases
*
* @returns `true` if submission succeeded, `false` if failed or aborted
*/
exec(): Promise<boolean>;
}
declare namespace Submission {
/** @inline */
type Handlers = {
/**
* Called before submission starts
* @param abortSignal - Abort signal for the submission
* @returns `true` to continue submission, `false` to cancel submission
*/
willSubmit: (abortSignal: AbortSignal) => Promise<boolean>;
/**
* Async handler that perform the submission (serialized)
*
* @param abortSignal - Abort signal for the submission
* @returns `true` if submission succeeded, `false` if failed or aborted
*/
submit: (abortSignal: AbortSignal) => Promise<boolean>;
/**
* Called after submission completes
*
* @param succeed - `true` if submission succeeded, `false` if failed or aborted
*/
didSubmit: (succeed: boolean) => void;
};
}
declare const internalToken: unique symbol;
/**
* Form manages submission, fields, and bindings.
*
* Key features:
* - Manages form fields and their states
* - Supports nested forms
* - Manages form submission lifecycle
* - Provides binding system for UI integration
* - Integrates with Watcher for change detection
* - Integrates with Validator for validation
*/
declare class Form<T> {
#private;
readonly id: string;
readonly watcher: Watcher;
readonly validator: Validator<T>;
/** Extension fields for bindings */
[k: `bind${Capitalize<string>}`]: unknown;
/**
* Get a form instance for the subject.
*
* @remarks
* - Returns existing instance if one exists for the subject
* - Creates new instance if none exists
* - Instances are cached and garbage collected with their subjects
* - Multiple forms per subject supported via `formKey`
*
* @param subject The model object to create form for
* @param formKey Optional key for multiple forms per subject
*
* @throws `TypeError` if subject is not an object
*/
static get<T extends object>(subject: T, formKey?: symbol): Form<T>;
/**
* Get a form instance for the subject.
*
* Same as {@link Form.get} but returns null instead of throwing an error.
*/
static getSafe<T extends object>(subject: T, formKey?: symbol): Form<T> | null;
/**
* Manually dispose the form instance for the subject.
*
* Use with caution.\
* You don't usually need to use this method at all.\
* It's only for advanced use cases, such as testing.
*
* @see {@link Form.get}
*/
static dispose(subject: object, formKey?: symbol): void;
private constructor();
/**
* The configuration of the form
*
* This is a computed value that combines the global configuration and the local configuration.
*/
get config(): Readonly<FormConfig>;
/** Configure the form locally */
configure: {
/** Override the global configuration locally */
(config: Partial<Readonly<FormConfig>>): void;
/** Reset to the global configuration */
(reset: true): void;
};
/**
* Whether the form is dirty (including sub-forms)
*
* @remarks Alias for {@link Watcher.changed}.
*/
get isDirty(): boolean;
/**
* Whether the form is valid (including sub-forms)
*
* @remarks Alias for {@link Validator.isValid}.
*/
get isValid(): boolean;
/**
* The number of invalid fields
*
* @remarks Alias for {@link Validator.invalidKeyCount}.
*/
get invalidFieldCount(): number;
/**
* The number of total invalid field paths (counts invalid fields in sub-forms)
*
* @remarks Alias for {@link Validator.invalidKeyPathCount}.
*/
get invalidFieldPathCount(): number;
/**
* Whether the form is in validator state
*
* @remarks Alias for {@link Validator.isValidating}.
*/
get isValidating(): boolean;
/** Whether the form is in submitting state */
get isSubmitting(): boolean;
/** Whether the form is busy (submitting or validating) */
get isBusy(): boolean;
/**
* Whether the form can be submitted
*
* @remarks
* - Checks if the form is not busy
* - Checks if the form is valid or allows invalid submissions
* - Checks if the form is dirty or allows non-dirty submissions
*/
get canSubmit(): boolean;
/**
* Sub-forms within the form.
*
* Forms are collected via `@nested` annotation.
*/
get subForms(): ReadonlyMap<KeyPath, Form<any>>;
/** Report error states on all fields and sub-forms */
reportError(): void;
/**
* Reset the form's state
*
* @remarks
* - Resets the fields
* - Resets the sub-forms
* - Resets the watcher
* - Does not reset the validator
*/
reset(): void;
/**
* Mark the form as dirty
*
* @remarks Alias for {@link Watcher.assumeChanged}.
*/
markAsDirty(): void;
/**
* Submit the form.
*
* @remarks
* - Checks if {@link canSubmit} is `true`
* - Executes handlers in order
* - Aborts if any `submit` handler returns `false`
* - Handles exceptions in all phases
* - Resets the form after successful submission
*
* @param args.force Whether to force submission even if {@link canSubmit} is `false`.
* It cancels any in-progress submission if any.
*
* @returns `true` if submission succeeded, `false` if failed or aborted
*/
submit(args?: {
force?: boolean;
}): Promise<boolean>;
/**
* Add a submission handler
*
* @remarks
* Handlers are called in this order:
* 1. `willSubmit` - Called before submission starts
* 2. `submit` - Async handlers that perform the submission (serialized)
* 3. `didSubmit` - Called after submission completes
*
* @see {@link Form.Handlers}
*
* @returns Function to remove the handler
*/
addHandler: {
(event: "willSubmit", handler: Submission.Handlers["willSubmit"]): () => void;
(event: "submit", handler: Submission.Handlers["submit"]): () => void;
(event: "didSubmit", handler: Submission.Handlers["didSubmit"]): () => void;
};
/**
* Get a field by name
*
* @remarks Fields are cached and reused.
*/
getField(fieldName: FormField.Name<T>): FormField;
/**
* Create UI binding for form elements
*
* @remarks
* - Can bind to individual fields
* - Can bind to multiple fields
* - Can bind to the entire form
* - Bindings are cached and reused
* - Supports configuration via binding classes
*/
bind: FormBindingFunc<T>;
/**
* Get error messages for a field
*
* @param fieldName Field to get errors for
* @param includePreReported Whether to include errors not yet reported
*
* @returns Set of error messages
*/
getErrors(fieldName: FormField.Name<T>, includePreReported?: boolean): ReadonlySet<string>;
/**
* Get all error messages for the form
*
* @param fieldName Field to get errors for. If omitted, all errors are returned.
*
* @returns Set of error messages
*/
getAllErrors(fieldName?: FormField.Name<T>): Set<string>;
/**
* The first error message (including nested objects)
*
* @remarks Alias for {@link Validator.firstErrorMessage}.
*/
get firstErrorMessage(): string | null;
/** @internal @ignore */
[internalToken](): {
fields: Map<string, FormField>;
bindings: Map<string, FormBinding>;
submission: Submission;
};
}
declare namespace Form {
/** @inline */
type Handlers = Submission.Handlers;
}
export { Form, type FormBinding, FormBindingFunc, FormBindingFuncExtension, type FormConfig, FormField, configureForm };