/** * vapor-chamber - Form Bus * * v0.5.0 — Reactive form state management built on the command bus. * * createFormBus wraps a command bus around a typed form, giving you: * - Reactive values, errors, dirty, valid, and submitting state * - Per-field validation rules * - Full plugin pipeline on every form command (logger, throttle, authGuard, etc.) * - Undo/redo via the history plugin * * @example * const form = createFormBus({ * fields: { email: '', password: '' }, * rules: { * email: (v) => v.includes('@') ? null : 'Invalid email', * password: (v) => v.length >= 8 ? null : 'Too short', * }, * onSubmit: async (values) => await api.login(values), * }); * * form.set('email', 'user@example.com'); * await form.submit(); // runs validation, then onSubmit * form.reset(); // restores initial field values */ import { signal } from './signal'; import type { Signal, CreateSignal } from './signal'; import { createCommandBus } from './command-bus'; import type { CommandBus, Plugin, PluginOptions } from './command-bus'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type FormRules> = { [K in keyof T]?: (value: T[K], values: T) => string | null | Promise; }; export type FormBusOptions> = { /** Initial field values — also used as the reset target. */ fields: T; /** Per-field validation rules. Return a string on error, null on pass. */ rules?: FormRules; /** Called after successful validation on submit(). May be async. */ onSubmit?: (values: T) => void | Promise; /** * Create reactive signals for all form state (values, errors, isDirty, etc.). * Default: true. Set to false for headless / server-side / batch use cases * where reactivity is not needed — avoids 7 signal allocations per form. * When false, all Signal fields still work as plain get/set wrappers. */ reactive?: boolean; /** * Inject an external command bus instead of creating an isolated one. * When provided, form commands (formSet, formTouch, formReset, formValidate) * flow through this bus — making them visible to DevTools, metrics, logger, * and global listeners. * * @example * const bus = getCommandBus(); * const form = createFormBus({ fields: { email: '' }, bus }); * // formSet, formTouch, etc. now visible in setupDevtools() */ bus?: CommandBus; }; export type FormBus> = { /** Reactive current field values. */ values: Signal; /** Reactive per-field error messages. Empty when all fields pass. */ errors: Signal>>; /** Reactive set of fields the user has interacted with. */ touched: Signal>>; /** True when any field differs from its initial value. */ isDirty: Signal; /** True when no validation errors exist. */ isValid: Signal; /** True while onSubmit is in flight. */ isSubmitting: Signal; /** True while async validation is running. */ isValidating: Signal; /** True when either validating or submitting — use for disabling submit buttons. */ isBusy: Signal; /** Set a single field value and re-run validation. */ set(field: K, value: T[K]): void; /** Mark a field as touched (shows errors for that field). */ touch(field: K): void; /** Validate and call onSubmit. Returns true on success, false on validation failure. */ submit(): Promise; /** Reset all fields to their initial values and clear errors/touched state. */ reset(): void; /** Attach a plugin to the form's internal command bus. */ use(plugin: Plugin, options?: PluginOptions): void; /** The underlying command bus — for advanced use (DevTools, testing). */ bus: CommandBus; }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Sync-only validation used for live per-field feedback during set(). Skips async rules. */ function runRulesSync>( rules: FormRules, values: T, ): Partial> { const errs: Partial> = {}; for (const key in rules) { if (!(key in values)) continue; const rule = rules[key as keyof T]; if (!rule) continue; const msg = rule(values[key as keyof T], values); if (typeof msg === 'string') errs[key as keyof T] = msg; } return errs; } /** Awaits all rules (sync and async). Used in submit() for full validation. */ async function runRulesAsync>( rules: FormRules, values: T, ): Promise>> { const errs: Partial> = {}; // Run fields concurrently — two 300ms server-side validators cost 300ms, not // 600ms. Sync rules resolve immediately; per-field error mapping is preserved. const keys: Array = []; const results: Array> = []; for (const key in rules) { if (!(key in values)) continue; const rule = rules[key as keyof T]; if (!rule) continue; keys.push(key as keyof T); results.push(rule(values[key as keyof T], values)); } const settled = await Promise.all(results); for (let i = 0; i < keys.length; i++) { if (settled[i]) errs[keys[i]] = settled[i] as string; } return errs; } function hasDiff>(a: T, b: T): boolean { return Object.keys(b).some((k) => a[k] !== b[k]); } // --------------------------------------------------------------------------- // createFormBus // --------------------------------------------------------------------------- /** * createFormBus — reactive form state manager built on the command bus. * * All form mutations go through the internal bus, so plugins (logger, * throttle, authGuard, etc.) intercept them like any other command. */ export function createFormBus>( options: FormBusOptions, ): FormBus { const { onSubmit, reactive: useReactive = true } = options; const rules = (options.rules ?? {}) as FormRules; const initial: T = { ...options.fields }; const bus = options.bus ?? createCommandBus(); // When reactive: false, use plain get/set wrappers instead of Vue signals. // Saves 7 signal allocations per form in headless/batch/SSR contexts. const sig: CreateSignal = useReactive ? signal : (v: V): Signal => { let _val = v; return { get value() { return _val; }, set value(v: V) { _val = v; } }; }; const values = sig({ ...initial }); const errors = sig>>({}); const touched = sig>>({}); const isDirty = sig(false); const isValid = sig(true); const isSubmitting = sig(false); const isValidating = sig(false); const isBusy = sig(false); /** Update isBusy whenever isSubmitting or isValidating changes */ function updateBusy(): void { isBusy.value = isSubmitting.value || isValidating.value; } // ---- formSet ----------------------------------------------------------- bus.register('formSet', (cmd) => { const { field, value } = cmd.payload as { field: keyof T; value: T[keyof T] }; const next = { ...values.value, [field]: value } as T; values.value = next; const errs = runRulesSync(rules, next); errors.value = errs; isDirty.value = hasDiff(initial, next); isValid.value = Object.keys(errs).length === 0; return next; }); // ---- formTouch --------------------------------------------------------- bus.register('formTouch', (cmd) => { const { field } = cmd.payload as { field: keyof T }; touched.value = { ...touched.value, [field]: true }; return touched.value; }); // ---- formReset --------------------------------------------------------- bus.register('formReset', () => { values.value = { ...initial }; errors.value = {}; touched.value = {}; isDirty.value = false; isValid.value = true; isSubmitting.value = false; isValidating.value = false; isBusy.value = false; return values.value; }); // ---- formValidate (internal) ------------------------------------------ bus.register('formValidate', () => { // Touch all fields so errors become visible const allTouched: Partial> = {}; for (const k in initial) allTouched[k as keyof T] = true; touched.value = allTouched; const errs = runRulesSync(rules, values.value); errors.value = errs; isValid.value = Object.keys(errs).length === 0; return { valid: isValid.value, errors: errs }; }); // ---- Public API -------------------------------------------------------- function set(field: K, value: T[K]): void { bus.dispatch('formSet', {}, { field, value }); } function touch(field: K): void { bus.dispatch('formTouch', {}, { field }); } function reset(): void { bus.dispatch('formReset', {}); } async function submit(): Promise { // Touch all fields so errors become visible const allTouched: Partial> = {}; for (const k in initial) allTouched[k as keyof T] = true; touched.value = allTouched; // Run all rules — awaits async validators too // Set isValidating so the UI can show loading state during async validation isValidating.value = true; updateBusy(); try { const errs = await runRulesAsync(rules, values.value); errors.value = errs; isValid.value = Object.keys(errs).length === 0; } finally { isValidating.value = false; updateBusy(); } if (!isValid.value) return false; isSubmitting.value = true; updateBusy(); try { if (onSubmit) await onSubmit(values.value); return true; } finally { isSubmitting.value = false; updateBusy(); } } function use(plugin: Plugin, pluginOptions?: PluginOptions): void { bus.use(plugin, pluginOptions); } return { values, errors, touched, isDirty, isValid, isSubmitting, isValidating, isBusy, set, touch, submit, reset, use, bus }; }