From 3e6d1da1aad91e0baa3de6e9beea51cd102d348c Mon Sep 17 00:00:00 2001 From: Tiana_ Date: Wed, 8 Apr 2026 15:40:25 -0300 Subject: [PATCH 1/2] contract-first API with formOptions extensibility, hidden field policy, validation guards and production-grade docs --- README.md | 495 +++++++++++++++++++-------- __tests__/ConditionalField.test.tsx | 70 +++- __tests__/useAsyncValidation.test.ts | 74 +++- __tests__/useFormWizard.test.tsx | 126 ++++++- package.json | 2 +- src/components/ConditionalField.tsx | 32 +- src/components/FormWizard.tsx | 23 +- src/hooks/useAsyncValidation.ts | 42 ++- src/hooks/useFormWizard.ts | 48 ++- src/index.ts | 4 + src/types/index.ts | 27 +- tsconfig.build.json | 5 + tsconfig.json | 2 + 13 files changed, 721 insertions(+), 229 deletions(-) create mode 100644 tsconfig.build.json diff --git a/README.md b/README.md index bd5fa5b..eb645d8 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,197 @@ # @itiana/form-architect -Multi-step form wizard library for React. Provides a composable `FormWizard`, per-step validation with React Hook Form, conditional field rendering, and debounced async validation – all with strict TypeScript types and no runtime dependencies beyond React and react-hook-form. +Multi-step form wizard library for React — composable, type-safe, and built on react-hook-form. -## Use Cases +--- -- **Onboarding flows** - break a long registration form into focused steps with per-step field validation -- **Checkout wizards** - shipping → payment → review, with conditional fields based on payment type -- **Dynamic surveys** - show or hide questions based on previous answers using `ConditionalField` -- **Profile editors** - async-validate unique usernames or emails before advancing to the next step -- **Configuration wizards** - multi-stage settings UIs where later steps depend on earlier choices +## What Problem It Solves + +Single-page forms become hard to manage once they branch on user choices, require async field checks, or span more than a screen's worth of inputs. `@itiana/form-architect` gives you a structured wizard model — steps, per-step validation, conditional field rendering, and debounced async checks — without replacing react-hook-form's validation engine. Each step validates only its own registered fields before advancing, so users see errors at the right moment rather than all at once on submit. + +--- + +## When to Use + +- Multi-step onboarding or registration wizards +- Checkout flows (shipping → payment → review) +- Branching surveys where later questions depend on earlier answers +- Configuration UIs where async checks (username availability, coupon codes) gate step advancement +- Any flow where you want validated forward progress and the ability to go back + +## When NOT to Use + +- Single-step forms — react-hook-form alone is simpler +- Forms that run as React Server Actions (RSC / Next.js server-side submit) +- Dynamic field arrays (`useFieldArray`) — this library has no built-in field array support +- UIs that need to control the active step index from outside the wizard + +--- + +## Compatibility + +| Dependency | Tested version | Notes | +|-----------------|----------------|----------------------------------------| +| React | 18.x | React 19 not verified | +| react-hook-form | ^7.0.0 | Peer dependency | +| TypeScript | >=5.4 | Strict mode required | +| Vite | 5.x | Library mode build, vite-plugin-dts | + +--- + +## Installation + +```bash +npm install @itiana/form-architect react-hook-form +``` + +Peer dependencies: `react ^18`, `react-dom ^18`, `react-hook-form ^7`. + +--- + +## Quick Start + +```tsx +import { FormWizard, FormStep, ConditionalField } from '@itiana/form-architect'; +import type { StepConfig } from '@itiana/form-architect'; + +interface CheckoutData { + email: string; + shippingAddress: string; + paymentMethod: 'card' | 'paypal'; + cardNumber?: string; +} + +const steps: StepConfig[] = [ + { id: 'contact', title: 'Contact', fields: ['email'] }, + { id: 'shipping', title: 'Shipping', fields: ['shippingAddress'] }, + { id: 'payment', title: 'Payment', fields: ['paymentMethod', 'cardNumber'] }, +]; + +export function CheckoutWizard() { + async function handleSubmit(data: CheckoutData) { + try { + await submitOrder(data); + } catch (err) { + // handle submission error — show toast, set server error, etc. + console.error(err); + } + } + + return ( + + steps={steps} + defaultValues={{ email: '', shippingAddress: '', paymentMethod: 'card' }} + onSubmit={handleSubmit} + onStepChange={(from, to) => analytics.track('wizard_step', { from, to })} + > + {({ currentStep, wizardState, next, previous, form }) => { + const { formState: { errors, isSubmitting } } = form; + + return ( + <> +

+ Step {wizardState.currentStepIndex + 1} of {wizardState.totalSteps} +

+ + {currentStep.id === 'contact' && ( + +
+ + + {errors.email && ( + + {errors.email.message} + + )} +
+
+ )} + + {currentStep.id === 'shipping' && ( + +
+ + + {errors.shippingAddress && ( + + {errors.shippingAddress.message} + + )} +
+
+ )} + + {currentStep.id === 'payment' && ( + +
+ + +
+ + +
+ + + {errors.cardNumber && ( + + {errors.cardNumber.message} + + )} +
+
+
+ )} + +
+ {!wizardState.isFirstStep && ( + + )} + {!wizardState.isLastStep ? ( + + ) : ( + + )} +
+ + ); + }} + + ); +} +``` + +--- ## Architecture @@ -34,189 +217,211 @@ graph TD UFW --> Types ``` -## Tech Stack +`FormWizard` is a thin shell: it delegates all state to `useFormWizard`, which owns the step index and wraps react-hook-form. Components like `FormStep` and `ConditionalField` are structural primitives — they impose no validation logic of their own. The library sits entirely above react-hook-form's API surface, so every RHF feature (rules, context, `useWatch`, `useFormContext`) remains available inside the render prop. -| Layer | Library | -|---|---| -| Framework | React 18 + TypeScript 5 (strict) | -| Forms | react-hook-form 7 | -| Build | Vite 5 (library mode) + vite-plugin-dts | -| Tests | Vitest 2 + React Testing Library 16 | +--- -## Install +## Core Concepts -```bash -npm install @itiana/form-architect react-hook-form -``` +### Validation Lifecycle -Peer deps: `react ^18`, `react-dom ^18`, `react-hook-form ^7`. +When `next()` is called, the wizard triggers `trigger()` on only the fields listed in the current step's `fields` array. If validation fails, the step does not advance and errors surface normally through `formState.errors`. On final submit, `handleSubmit` runs a full-form validation pass before calling `onSubmit`. -## Quick Start +Steps with an empty `fields` array (e.g. a review step) advance without triggering any validation. -```tsx -import { - FormWizard, - FormStep, - ConditionalField, - useAsyncValidation, -} from '@itiana/form-architect'; -import type { StepConfig } from '@itiana/form-architect'; +### Hidden Field Policy -interface RegistrationData { - email: string; - username: string; - plan: 'free' | 'pro'; - teamName?: string; -} +`ConditionalField` accepts an `unregisterOnHide` boolean prop. When `true`, fields inside a hidden `ConditionalField` are unregistered from react-hook-form when they disappear, clearing their values and errors. When `false` (the default), values are preserved in the form state even while the field is hidden — useful when you want hidden values to survive a back-and-forward navigation without resetting. -const steps: StepConfig[] = [ - { id: 'account', title: 'Account', fields: ['email', 'username'] }, - { id: 'plan', title: 'Plan', fields: ['plan', 'teamName'] }, - { id: 'review', title: 'Review', fields: [] }, -]; +### Step Navigation -async function checkUsername(value: string): Promise { - const res = await fetch(`/api/check-username?q=${value}`); - const { available } = await res.json() as { available: boolean }; - return available || 'Username is already taken'; -} +| Method | Description | +|----------------------------------|------------------------------------------------------------------------| +| `next(options?)` | Validate current step fields then advance. Returns `Promise`. | +| `previous()` | Move back one step without validation. | +| `goTo(index, options?)` | Jump to any step. Optionally validate the current step first. | +| `reset()` | Reset form values to `defaultValues` and return to step 0. | -function RegistrationWizard() { - return ( - - steps={steps} - defaultValues={{ email: '', username: '', plan: 'free' }} - onSubmit={(data) => console.info('Submit', data)} - > - {({ currentStep, wizardState, next, previous, form }) => ( - <> - {/* Progress */} -

Step {wizardState.currentStepIndex + 1} of {wizardState.totalSteps}

- - {currentStep.id === 'account' && ( - - - - - )} - - {currentStep.id === 'plan' && ( - - - - - - - - )} - - {currentStep.id === 'review' && ( - -

Email: {form.getValues('email')}

-

Plan: {form.getValues('plan')}

-
- )} - - {/* Navigation */} - {!wizardState.isFirstStep && ( - - )} - {!wizardState.isLastStep ? ( - - ) : ( - - )} - - )} - - ); -} -``` +--- -## Components +## API Reference ### `FormWizard` -Root component. Wraps everything in a React Hook Form `FormProvider` and a `
` element. Render prop exposes the full wizard context. +Root component. Provides a `FormProvider` context and renders a `` element. Uses a render prop to expose the full wizard context. -| Prop | Type | Default | -|---|---|---| -| `steps` | `StepConfig[]` | required | -| `defaultValues` | `Partial` | – | -| `onSubmit` | `(data: T) => void \| Promise` | required | -| `children` | `(ctx: UseFormWizardReturn) => ReactNode` | required | +| Prop | Type | Required | Default | Description | +|-----------------|---------------------------------------------------|----------|-----------|----------------------------------------------------| +| `steps` | `StepConfig[]` | yes | — | Ordered step definitions | +| `defaultValues` | `DefaultValues` | no | — | Initial form values passed to react-hook-form | +| `onSubmit` | `(data: T) => void \| Promise` | yes | — | Called after final-step validation passes | +| `children` | `(ctx: UseFormWizardReturn) => ReactNode` | yes | — | Render prop receiving the full wizard context | +| `className` | `string` | no | — | CSS class applied to the `` element | +| `formOptions` | `Omit, 'defaultValues'>` | no | — | Extra options forwarded to `useForm` (e.g. `mode`) | +| `onStepChange` | `(from: number, to: number) => void` | no | — | Fired after each successful step transition | ### `FormStep` -Semantic section wrapper with an optional heading and description. +Semantic section wrapper. Renders an `
` with an optional `

` heading and `

` description. Accepts all standard `HTMLAttributes`. -```tsx - - {/* form fields */} - -``` +| Prop | Type | Required | Description | +|---------------|-----------------|----------|-------------------------------| +| `title` | `string` | no | Rendered as a heading element | +| `description` | `string` | no | Rendered as a paragraph | +| `children` | `ReactNode` | yes | Form fields | ### `ConditionalField` -Renders children only when one or more conditions on watched fields are satisfied. Conditions evaluate without re-renders beyond those caused by the watched field changing. - -```tsx -Not eligible

} -> - - -``` +Renders `children` only when the condition(s) evaluate to true against live watched field values. -Supported operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `includes`, `truthy`, `falsy`. +| Prop | Type | Required | Default | Description | +|------------------|------------------------------------|----------|---------|--------------------------------------------------------------------| +| `condition` | `FieldCondition \| FieldCondition[]` | yes | — | One or more field conditions to evaluate | +| `allOf` | `boolean` | no | `false` | When `true`, all conditions must pass. When `false`, any one suffices. | +| `children` | `ReactNode` | yes | — | Content shown when condition is met | +| `fallback` | `ReactNode` | no | — | Content shown when condition is not met | +| `unregisterOnHide` | `boolean` | no | `false` | Unregister fields from RHF when hidden, clearing their values | -## Hooks +**Supported operators:** `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `includes`, `truthy`, `falsy`. ### `useFormWizard(options)` -Low-level hook for building custom wizard UIs without `FormWizard`. Returns the full `UseFormWizardReturn` context. +Low-level hook for building custom wizard UIs without the `FormWizard` component. Returns `UseFormWizardReturn`. ```ts -const { form, wizardState, next, previous, goTo, reset, handleSubmit } = - useFormWizard({ steps, defaultValues }); +const { form, wizardState, currentStep, steps, next, previous, goTo, reset, handleSubmit } = + useFormWizard({ + steps, + defaultValues: { email: '' }, + formOptions: { mode: 'onBlur' }, + onStepChange: (from, to) => console.info(from, to), + }); ``` +**`UseFormWizardOptions` fields:** + +| Field | Type | Required | Description | +|-----------------|---------------------------------------------------|----------|-----------------------------------------------------| +| `steps` | `StepConfig[]` | yes | Ordered step definitions | +| `defaultValues` | `DefaultValues` | no | Initial form values | +| `formOptions` | `Omit, 'defaultValues'>` | no | Extra options forwarded to `useForm` | +| `onStepChange` | `(from: number, to: number) => void` | no | Callback fired on each successful step transition | + +**`UseFormWizardReturn` fields:** + +| Field | Type | Description | +|----------------|---------------------------------------------------------------------|------------------------------------------| +| `form` | `UseFormReturn` | Full react-hook-form instance | +| `wizardState` | `WizardState` | Current step index, progress, flags | +| `steps` | `StepConfig[]` | The step definitions array | +| `currentStep` | `StepConfig` | Active step definition | +| `next` | `(options?: WizardNavigationOptions) => Promise` | Validate and advance | +| `previous` | `() => void` | Move back without validation | +| `goTo` | `(index: number, options?: GoToOptions) => Promise` | Jump to any step index | +| `reset` | `() => void` | Reset form and return to step 0 | +| `handleSubmit` | `(onValid: (data: T) => void \| Promise) => (e?) => Promise` | Wrapped RHF submit handler | + ### `useAsyncValidation(validator, debounceMs?)` -Debounced async validator with in-flight cancellation. Safe to call on every keystroke. +Debounced async validator with `AbortController` cancellation. Safe to call on every keystroke. ```ts +async function checkUsername(value: string, signal: AbortSignal): Promise { + const res = await fetch(`/api/check-username?q=${value}`, { signal }); + const { available } = await res.json() as { available: boolean }; + return available || 'Username is already taken'; +} + const { validate, state } = useAsyncValidation(checkUsername, 400); -// Inside react-hook-form register: -form.register('username', { - validate: (v) => validate(v), -}); +// Inside register: +form.register('username', { validate: (v) => validate(v) }); -// Render validation state: -{state.isPending && Checking...} -{state.error && {state.error}} +// Render state: +{state.isPending && Checking availability...} +{state.error && {state.error}} ``` -## Scripts +**`AsyncValidationState` fields:** + +| Field | Type | Description | +|-------------|---------------------------------|-------------------------------------------------------| +| `isPending` | `boolean` | `true` while a debounced check is in flight | +| `result` | `AsyncValidationResult \| null` | Last completed result (valid / invalid / cancelled) | +| `error` | `string \| null` | Shortcut for the message when result is `invalid` | +| `isValid` | `boolean \| null` | `true` / `false` after resolution, `null` while idle | + +**`AsyncValidationResult` discriminated union:** + +```ts +type AsyncValidationResult = + | { status: 'valid' } + | { status: 'invalid'; message: string } + | { status: 'cancelled' }; +``` + +--- + +## Accessibility + +`FormWizard` and `FormStep` are structural primitives — they render a `` and `
` respectively but make no assumptions about your heading hierarchy, ARIA roles, or live regions. Consumers are responsible for: + +- Associating `