first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,533 @@
# @suaveplan/react-forms - React forms library — ApiClient/Auth contexts, ValidationEngine, useFormValidation/useDomainForm with field rendering, createDomainForm factory (with three escape hatches), four recipes (wizard, repeating-fieldset, conditional, async-search), MoneyInput + Field re-exports, and a TranslationKey-typed validator suite (sync + async + composers).
## Summary
React forms library for the Suaveplan ecosystem. Wraps
react-hook-form + Zod with a primitives + factory + recipes
architecture: every user-facing string is typed `TranslationKey`,
every error code is registered with `@suaveplan/error`, every
lifecycle event emits OTel telemetry.
Wave 5b adds the field-rendering layer, the `createDomainForm`
factory (with three escape hatches), four recipes (wizard,
repeating-fieldset, conditional, async-search), and the
`MoneyInput` primitive on top of Wave 5a's contexts + validation
engine + validator suite.
<!-- begin:auto-docs -->
## Documentation tree
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ - [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ - [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ - [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ - [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ - [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ - [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ - [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ - [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ - [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ - [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ - [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ - [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ - [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ - [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/react-forms
```
Peer deps: `react ^18.0.0 || ^19.0.0`, `@suaveplan/react-ui` (must be
provided by the consumer; this package re-exports `<Field>` and
imports `<Input>` / `<Button>` / `<Skeleton>` / etc. from it).
Workspace deps: `@suaveplan/react-i18n` (`TranslationKey`),
`@suaveplan/react-ui` (component primitives), `@suaveplan/error`
(`ReactFormsError`), `@suaveplan/timing` (async-validator debounce),
`@suaveplan/api-schemas`, `@suaveplan/i18n` (currency / date
formatters).
External catalog deps: `react-hook-form`, `@hookform/resolvers`,
`zod`, `libphonenumber-js`, `@zxcvbn-ts/core`, `@opentelemetry/api`.
## Quick start — `createDomainForm`
The factory is the 80%-case ergonomics layer. Declare a Zod schema +
field config + submit handler:
```tsx
import { createDomainForm } from "@suaveplan/react-forms";
import { RegisterInput } from "@suaveplan/api-schemas";
const RegisterForm = createDomainForm({
formId: "register",
schema: RegisterInput,
fields: {
email: { kind: "email", label: "auth.email.label" as never },
password: {
kind: "password",
label: "auth.password.label" as never,
strengthMeter: true,
},
name: { kind: "text", label: "auth.name.label" as never },
},
defaultValues: { email: "", password: "", name: "" },
submit: {
label: "auth.register.submit" as never,
onSubmit: async (values) => {
await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(values),
});
},
},
});
export function RegisterPage(): JSX.Element {
return <RegisterForm />;
}
```
Three escape hatches keep RHF visible:
```tsx
// 1. Render-prop children for full custom shells:
<RegisterForm>
{({ form, fields, submit, isSubmitting }) => (
<CustomLayout>{Object.values(fields)}</CustomLayout>
)}
</RegisterForm>
// 2. Static <Form.Field name=...> for partial layouts:
<div>
<RegisterForm.Field name="email" />
<p>Then choose a password.</p>
<RegisterForm.Field name="password" />
</div>
// 3. The standalone useDomainForm hook, exported separately:
const { form, fields, submit } = useDomainForm({ ...config });
```
## Recipes
### Wizard — multi-step factory
```tsx
import { createWizardForm } from "@suaveplan/react-forms";
const SignUpWizard = createWizardForm({
formId: "signup",
steps: [
{
id: "credentials",
title: "auth.steps.credentials" as never,
schema: z.object({ email: z.string().email(), password: z.string().min(8) }),
fields: {
email: { kind: "email", label: "auth.email.label" as never },
password: { kind: "password", label: "auth.password.label" as never },
},
},
{
id: "profile",
title: "auth.steps.profile" as never,
schema: z.object({ name: z.string().min(1) }),
fields: {
name: { kind: "text", label: "auth.name.label" as never },
},
},
],
onComplete: async (values) => api.auth.register(values),
});
```
### Repeating fieldset — dynamic-array rows
```tsx
import { createRepeatingFieldset } from "@suaveplan/react-forms";
const Contacts = createRepeatingFieldset<{ name: string; email: string }>({
formId: "checkout",
name: "contacts",
minRows: 1,
maxRows: 5,
defaultRow: { name: "", email: "" },
fieldset: {
name: { kind: "text", label: "checkout.contact.name" as never },
email: { kind: "email", label: "checkout.contact.email" as never },
},
});
```
### Conditional — show/hide fields
```tsx
import { ConditionalField } from "@suaveplan/react-forms";
<ConditionalField<CheckoutValues>
when={(v) => v.paymentMethod === "transfer"}
names={["bankAccount"]}
watch={["paymentMethod"]}
>
<RegisterForm.Field name="bankAccount" />
</ConditionalField>
```
When the gate flips closed the recipe unregisters `bankAccount`
from the form so it disappears from the submit payload.
### Async search — load-then-edit
```tsx
import { createAsyncSearchForm } from "@suaveplan/react-forms";
const EditUserForm = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: { kind: "email", label: "user.email.label" as never },
name: { kind: "text", label: "user.name.label" as never },
},
load: async (id, signal) => {
const res = await fetch(`/api/users/${id}`, { signal });
return (await res.json()) as { email: string; name: string };
},
submit: {
label: "user.save" as never,
onSubmit: async (values) => {
await fetch(`/api/users/${id}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
},
});
<EditUserForm id="alice" />;
```
## Validation library — sync + async + composers
```ts
import {
compose,
email,
minLength,
passwordStrength,
required,
sequence,
} from "@suaveplan/react-forms";
const validateEmail = sequence(required(), email());
const validatePassword = compose(
required(),
minLength(8),
passwordStrength({ minScore: 3 })
);
validateEmail("alice@example.com"); // → { valid: true }
validatePassword("hunter2"); // → { valid: false, messageKey, messageArgs }
```
Async validators receive an `AbortSignal`:
```ts
import { asyncEmailUnique } from "@suaveplan/react-forms";
const checkEmailUnique = asyncEmailUnique({
check: async (email, signal) => {
const res = await fetch(
`/api/users/email-available?email=${encodeURIComponent(email)}`,
{ signal }
);
return res.ok;
},
debounceMs: 300,
});
```
## Standalone hooks
`useDomainForm` is exported separately for consumers building bespoke
shells:
```tsx
import { useDomainForm } from "@suaveplan/react-forms";
const { form, fields, submit, isSubmitting, serverError } = useDomainForm({
formId: "login",
schema,
fields, // optional — when omitted, `fields` is null
onSubmit: async (values) => api.auth.login(values),
});
```
## Field kinds shipped in Wave 5b
`text`, `email`, `password` (with optional `strengthMeter`),
`number`, `currency` (uses `MoneyInput`), `checkbox`, `toggle`,
`radio`, `textarea`, `custom` (consumer render-prop).
## Deferred primitives — Wave 5c roadmap
The following primitives and field kinds are NOT in Wave 5b. They
ship in Wave 5c once the underlying react-ui components land:
| Primitive | Underlying component | Wave |
|---|---|---|
| `accessible-date-picker` | `Calendar` (react-ui) | Wave 5c after react-ui Wave 3h |
| `date-time-picker` | `Calendar` + `TimeInput` | Wave 5c after react-ui Wave 3h |
| `searchable-select` | `Combobox` (react-ui) | Wave 5c after react-ui Wave 3h |
| `tag-input` | `Badge` (react-ui) | Wave 5c after react-ui Wave 3d |
| `code-input` | `InputOTP` (react-ui) | Wave 5c after react-ui Wave 3h |
| `file-upload-field` | `@suaveplan/react-file-upload` | Wave 5c after react-file-upload ships |
| `rich-text-field` | `@suaveplan/react-rich-text` | Wave 5c after react-rich-text ships |
Field kinds excluded from the Wave 5b enum: `date`, `dateTime`,
`select`, `multi-select`, `combobox`, `file`, `rich-text`, `otp`.
Passing one of these to `createDomainForm` today fails the
discriminator and throws `REACT_FORMS.FIELD_KIND_INVALID` at
module-eval time.
## Modules
- [`src/errors/error-codes.md`](src/errors/error-codes.md) — REACT_FORMS error code registry.
- [`src/errors/react-forms-error.md`](src/errors/react-forms-error.md) — `ReactFormsError` base class.
- [`src/observability/react-forms-metrics.md`](src/observability/react-forms-metrics.md) — OTel instruments and in-memory ring.
- [`src/locales/locales.md`](src/locales/locales.md) — bundled recipe-default strings.
- [`src/locales/translation-keys.md`](src/locales/translation-keys.md) — codegen union of contributed keys.
- [`src/locales/augmentation.md`](src/locales/augmentation.md) — `ContributedTranslationKeys` registration.
- [`src/core/api-client-context/api-client-context.md`](src/core/api-client-context/api-client-context.md) — `<ApiClientProvider>` + `useApiClient`.
- [`src/core/api-client-context/use-api-client.md`](src/core/api-client-context/use-api-client.md) — hook details + throw semantics.
- [`src/core/auth-context/auth-context.md`](src/core/auth-context/auth-context.md) — `<AuthProvider>` + `useAuth`.
- [`src/core/auth-context/use-auth.md`](src/core/auth-context/use-auth.md) — hook details + throw semantics.
- [`src/core/validation-engine/validation-engine.md`](src/core/validation-engine/validation-engine.md) — four-phase pipeline.
- [`src/core/validation-engine/cross-field-rule.md`](src/core/validation-engine/cross-field-rule.md) — `defineCrossFieldRule` + `violate`.
- [`src/core/validation-engine/server-error-mapper.md`](src/core/validation-engine/server-error-mapper.md) — server envelope unwrap + map.
- [`src/core/use-form-validation/use-form-validation.md`](src/core/use-form-validation/use-form-validation.md) — RHF resolver bridge.
- [`src/core/use-domain-form/use-domain-form.md`](src/core/use-domain-form/use-domain-form.md) — standalone domain-form hook (now with field rendering).
- [`src/primitives/field/field.md`](src/primitives/field/field.md) — single-source re-export of react-ui's Field family.
- [`src/primitives/money-input/money-input.md`](src/primitives/money-input/money-input.md) — locale-aware currency input.
- [`src/factory/field-config/field-config.md`](src/factory/field-config/field-config.md) — discriminated-union FieldConfig + Zod schemas.
- [`src/factory/render-field/render-field.md`](src/factory/render-field/render-field.md) — single-field renderer.
- [`src/factory/create-domain-form/create-domain-form.md`](src/factory/create-domain-form/create-domain-form.md) — declarative factory + escape hatches.
- [`src/recipes/wizard/wizard.md`](src/recipes/wizard/wizard.md) — multi-step factory.
- [`src/recipes/repeating-fieldset/repeating-fieldset.md`](src/recipes/repeating-fieldset/repeating-fieldset.md) — dynamic-array fieldset.
- [`src/recipes/conditional/conditional.md`](src/recipes/conditional/conditional.md) — show/hide field gate.
- [`src/recipes/async-search/async-search.md`](src/recipes/async-search/async-search.md) — load-then-edit recipe.
- [`src/validation/validators/required/required.md`](src/validation/validators/required/required.md) — presence validator.
- [`src/validation/validators/min-length/min-length.md`](src/validation/validators/min-length/min-length.md) — minimum length.
- [`src/validation/validators/max-length/max-length.md`](src/validation/validators/max-length/max-length.md) — maximum length.
- [`src/validation/validators/email/email.md`](src/validation/validators/email/email.md) — RFC 5322 subset email.
- [`src/validation/validators/url/url.md`](src/validation/validators/url/url.md) — WHATWG URL.
- [`src/validation/validators/pattern/pattern.md`](src/validation/validators/pattern/pattern.md) — regex match.
- [`src/validation/validators/phone/phone.md`](src/validation/validators/phone/phone.md) — libphonenumber-js phone.
- [`src/validation/validators/postcode/postcode.md`](src/validation/validators/postcode/postcode.md) — per-country postcode.
- [`src/validation/validators/credit-card/credit-card.md`](src/validation/validators/credit-card/credit-card.md) — Luhn checksum.
- [`src/validation/validators/password-strength/password-strength.md`](src/validation/validators/password-strength/password-strength.md) — zxcvbn-backed strength.
- [`src/validation/validators/iban/iban.md`](src/validation/validators/iban/iban.md) — ISO 13616 IBAN.
- [`src/validation/validators/bic/bic.md`](src/validation/validators/bic/bic.md) — ISO 9362 BIC.
- [`src/validation/validators/custom/custom.md`](src/validation/validators/custom/custom.md) — predicate adapter.
- [`src/validation/async/async-email-unique/async-email-unique.md`](src/validation/async/async-email-unique/async-email-unique.md) — debounced email-uniqueness.
- [`src/validation/async/async-username-available/async-username-available.md`](src/validation/async/async-username-available/async-username-available.md) — debounced username-availability.
- [`src/validation/compose/compose.md`](src/validation/compose/compose.md) — AND composer.
- [`src/validation/compose/or.md`](src/validation/compose/or.md) — OR composer.
- [`src/validation/compose/sequence.md`](src/validation/compose/sequence.md) — ordered AND composer.

View File

@@ -0,0 +1,103 @@
# `api-client-context.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- use-api-client — React hook that reads the active `…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- use-api-client — React hook that reads the active `…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- use-api-client — React hook that reads the active `…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
## Purpose
Mounts the consumer's `ApiClient` instance into a React context so
form submit handlers, domain hooks, and any descendant component can
reach it via `useApiClient()` without prop drilling. The provider is
intentionally a thin wrapper over `React.createContext` — no
validation, no transformation — because the forms package never
inspects the client's methods, only forwards the value to the
consumer's `submit.onSubmit` callback.
The marker `ApiClient` interface (in `api-client.types.ts`) keeps the
context value typed as a nominal handle rather than `unknown` so DI
containers, stack traces, and TypeScript inlay hints display a
meaningful name.
## Features
- `<ApiClientProvider value={...}>` — generic over `TApi extends
ApiClient`; the provider's prop type pins the consumer's concrete
client type at the mount site.
- `ApiClientContext` — exported as the raw React context for advanced
consumers (testing harnesses, server-side rendering glue) that need
to mutate the value imperatively.
- `displayName` set to `"ApiClientContext"` so React DevTools shows a
meaningful name instead of `Context.Provider`.
## Usage: Basic Example
```tsx
import { ApiClientProvider } from "@suaveplan/react-forms/core/api-client-context";
import { createApiClient } from "./api/client";
const api = createApiClient({ baseUrl: "/api" });
export function App({ children }: { children: ReactNode }) {
return (
<ApiClientProvider value={api}>{children}</ApiClientProvider>
);
}
```
## Usage: Advanced Example
```tsx
import {
ApiClientContext,
ApiClientProvider,
} from "@suaveplan/react-forms/core/api-client-context";
// Mock-server rendering: swap the API client for a different value
// per route.
function RouteScopedApi({ route, children }) {
const api = useMemo(() => buildClientForRoute(route), [route]);
return <ApiClientProvider value={api}>{children}</ApiClientProvider>;
}
// Direct context read — when you want explicit control over the
// hook's throw-when-absent semantics.
function MaybeApiBadge() {
const api = useContext(ApiClientContext);
return api === null ? <span>offline</span> : <span>{api.baseUrl}</span>;
}
```
## API Reference
- `ApiClientContext: Context<ApiClient | null>` — raw React context.
`null` default lets `useApiClient` discriminate "no provider".
- `ApiClientProvider<TApi extends ApiClient>(props: { value: TApi;
children: ReactNode }): ReactNode` — provider component.
## Implementation Notes
- The default value is `null` (not `undefined`) so the hook can use a
cheap `value === null` guard. Both forms work, but `null` is
explicit about "this is intentional" rather than the ambient default.
- The provider does not memoise its value — when the consumer's
`value` prop reference changes, every descendant re-renders. If the
consumer wants a stable reference they should memoise upstream
(e.g. `useMemo(() => createApi(), [])`).
- The generic `TApi` flows from the provider's prop to the hook
through TypeScript inference; consumers writing
`useApiClient<MyApi>()` get the precise type even though the context
value itself is typed only as the marker.

View File

@@ -0,0 +1,84 @@
# `use-api-client.ts`
## Purpose
React hook that reads the active `ApiClient` from the surrounding
`<ApiClientProvider>` and returns it typed as the consumer's concrete
client type. Throws `ReactFormsError(REACT_FORMS.API_CLIENT_NOT_FOUND)`
when called outside a provider — the throw is preferred over silent
`undefined`-return because every caller depends on the value being
present and a downstream "Cannot read properties of undefined" is much
harder to debug than a structured error with `context.hook ===
"useApiClient"`.
## Features
- Generic over `TApi extends ApiClient` so callers narrow the marker
context value to their concrete client type at the call site:
`const api = useApiClient<MyApi>();`.
- Structured throw with stable error code on no-provider — easy to
catch in error boundaries or assert against in tests.
- Zero allocation on the hot path — the body is a single `useContext`
call plus a null check.
## Usage: Basic Example
```ts
import { useApiClient } from "@suaveplan/react-forms/core/api-client-context";
function CreateUserButton() {
const api = useApiClient<MyApi>();
return (
<button onClick={() => api.users.create({ name: "Alice" })}>
Create
</button>
);
}
```
## Usage: Advanced Example
```ts
import { useApiClient } from "@suaveplan/react-forms/core/api-client-context";
import {
REACT_FORMS_CODES,
ReactFormsError,
} from "@suaveplan/react-forms/errors";
function safelyUseApi<T extends ApiClient>(): T | undefined {
try {
return useApiClient<T>();
} catch (cause) {
if (
cause instanceof ReactFormsError &&
cause.code === REACT_FORMS_CODES.API_CLIENT_NOT_FOUND
) {
return undefined;
}
throw cause;
}
}
```
## API Reference
- `useApiClient<TApi extends ApiClient>(): TApi` — read the active API
client.
- Throws `ReactFormsError` with
`code === "REACT_FORMS.API_CLIENT_NOT_FOUND"` when the hook is
invoked outside an `<ApiClientProvider>` ancestor. The thrown
error carries `context.hook === "useApiClient"` so downstream
error handlers can disambiguate against
`useAuth`'s sibling `AUTH_CONTEXT_NOT_FOUND` failure.
## Implementation Notes
- Throwing during render is the React-idiomatic signal for "this
component is misconfigured" — the nearest error boundary catches it
and renders a fallback, which is exactly the right developer UX.
- The hook does NOT memoise the returned value — `useContext` already
returns a stable reference per provider, so wrapping in `useMemo`
would be redundant and would only add overhead.
- The generic `TApi` is a pure type assertion; the runtime value is
whatever the provider published. Consumers that need runtime type
guards should wrap the returned value in their own validation layer.

View File

@@ -0,0 +1,93 @@
# `auth-context.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
## Purpose
Mounts the consumer's `AuthContextValue` (current user, signIn,
signOut, optional permissions) into a React context so registration
forms, sign-in forms, and any descendant component can reach the auth
implementation via `useAuth()` without prop drilling. Mirrors the
`ApiClientContext` shape — thin provider, generic-over-TUser hook,
typed throw-when-absent for the no-provider case.
## Features
- `<AuthProvider value={...}>` — generic over `TUser`; the consumer
pins the concrete user shape at the mount site.
- `AuthContext` — exported as the raw React context for advanced
consumers (testing harnesses, SSR glue) that need imperative
access.
- `displayName` set to `"AuthContext"` so React DevTools shows a
meaningful name.
## Usage: Basic Example
```tsx
import { AuthProvider } from "@suaveplan/react-forms/core/auth-context";
import { useMyAuth } from "./auth/use-my-auth";
export function App({ children }: { children: ReactNode }) {
const auth = useMyAuth();
return <AuthProvider value={auth}>{children}</AuthProvider>;
}
```
## Usage: Advanced Example
```tsx
import {
AuthContext,
AuthProvider,
} from "@suaveplan/react-forms/core/auth-context";
// Test harness: stub the auth context with a deterministic value.
function TestHarness({ children }) {
const stub = useMemo(
() => ({
currentUser: { id: "test" },
isAuthenticated: true,
signIn: async () => ({ id: "test" }),
signOut: async () => undefined,
permissions: new Set<string>(["read", "write"]),
}),
[]
);
return <AuthProvider value={stub}>{children}</AuthProvider>;
}
```
## API Reference
- `AuthContext: Context<AuthContextValue | null>` — raw React context.
`null` default lets `useAuth` discriminate "no provider".
- `AuthProvider<TUser>(props: { value: AuthContextValue<TUser>;
children: ReactNode }): ReactNode` — provider component.
## Implementation Notes
- The default value is `null` (not `undefined`) — same rationale as
`ApiClientContext`: explicit nullability is easier to reason about
than the ambient default.
- The provider does not memoise — when the consumer's `value` prop
reference changes, descendants re-render. Memoise upstream when the
auth implementation has stable identity.
- The generic `TUser` flows from the provider's prop to the hook
through TypeScript inference; consumers writing
`useAuth<MyUser>()` get the precise type back even though the
context value itself is typed only as `AuthContextValue<unknown>`.

View File

@@ -0,0 +1,79 @@
# `use-auth.ts`
## Purpose
React hook that reads the active `AuthContextValue` from the
surrounding `<AuthProvider>` and returns it typed as
`AuthContextValue<TUser>`. Throws `ReactFormsError(REACT_FORMS.AUTH_CONTEXT_NOT_FOUND)`
when called outside a provider — same throw-vs-undefined rationale as
`useApiClient`.
## Features
- Generic over `TUser` so callers narrow the marker context value to
their concrete user shape: `const { currentUser } = useAuth<MyUser>();`.
- Structured throw with stable error code on no-provider.
- Zero allocation on the hot path.
## Usage: Basic Example
```ts
import { useAuth } from "@suaveplan/react-forms/core/auth-context";
function GreetUser() {
const { currentUser } = useAuth<MyUser>();
if (currentUser === null) return <a href="/login">Sign in</a>;
return <span>Hello, {currentUser.displayName}</span>;
}
```
## Usage: Advanced Example
```ts
import { useAuth } from "@suaveplan/react-forms/core/auth-context";
function PermissionGate({
permission,
children,
}: {
permission: string;
children: ReactNode;
}) {
const { permissions } = useAuth();
if (permissions === undefined || !permissions.has(permission)) {
return null;
}
return <>{children}</>;
}
```
## API Reference
- `useAuth<TUser = unknown>(): AuthContextValue<TUser>` — read the
active auth context.
- Throws `ReactFormsError` with
`code === "REACT_FORMS.AUTH_CONTEXT_NOT_FOUND"` when invoked
outside an `<AuthProvider>` ancestor. The thrown error carries
`context.hook === "useAuth"` so downstream handlers can
disambiguate against `useApiClient`'s sibling failure.
## Implementation Notes
- Throwing during render is the React-idiomatic signal for "this
component is misconfigured" — the nearest error boundary catches it
and renders a fallback. The structured `ReactFormsError` carries
enough context (`tier`, `hook`, `code`) for downstream telemetry
pipelines to disambiguate forms-tier failures from other error
classes without parsing the message.
- The hook does NOT memoise the returned value — `useContext` already
returns a stable reference per provider, so wrapping in `useMemo`
would only add overhead. Consumers needing reference-stable derived
values should compose `useMemo` themselves at the call site.
- `TUser` is a pure type assertion; the runtime value is whatever the
provider published. Consumers wanting runtime type guards should
wrap the returned value in their own validation layer (e.g. a Zod
schema parse) before treating fields like `currentUser.id` as
strings.
- Mirror of `useApiClient` — the two hooks share their structure
intentionally so consumer apps that wire both contexts present a
uniform error-handling surface to downstream components.

View File

@@ -0,0 +1,161 @@
# `use-domain-form.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Standalone domain-form hook that wraps react-hook-form's `useForm`
with the package's `ValidationEngine` resolver, a typed submit
pipeline, server-error mapping, and OTel telemetry. The same hook
will also be wrapped by `createDomainForm` (Wave 5b) to deliver the
factory-driven ergonomic shell.
The hook is fully usable today for consumers building custom shells —
the returned `form` is the complete RHF context, and `submit` is the
hardened submit function with telemetry + server-error mapping wired
in. The only thing that does NOT ship in Wave 5a is the
field-rendering layer (`fields`), which depends on
`@suaveplan/react-ui`'s `FieldGroup` and `Field` primitives. `fields`
returns `null` until Wave 5b lands.
## Features
- RHF integration via the package's `useFormValidation` resolver.
- Submit pipeline that catches `onSubmit` failures and routes them
through `unwrapServerEnvelope` + `mapServerErrors` so server-side
field errors land on the correct RHF fields.
- `isSubmitting` boolean and `serverError` envelope surfaced for
top-of-form error rendering.
- OTel `react_forms.submit.total{form,outcome}` and
`react_forms.submit.duration_ms{form}` emitted per submit.
- Submit re-entry guarded by a `useRef` flag so double-clicking
doesn't fire two submits.
- `formId` is required for stable telemetry attribution.
## Usage: Basic Example
```tsx
import { useDomainForm } from "@suaveplan/react-forms/core/use-domain-form";
import z from "zod";
const schema = z.object({
email: z.string().email(),
});
function LoginShell() {
const { form, submit, isSubmitting, serverError } = useDomainForm({
formId: "login",
schema,
onSubmit: async (values) => {
await api.auth.login(values);
},
});
return (
<form onSubmit={submit}>
<input {...form.register("email")} />
{form.formState.errors.email && (
<span>{form.formState.errors.email.message}</span>
)}
<button disabled={isSubmitting}>Sign in</button>
{serverError.hasError && <p>Submit failed</p>}
</form>
);
}
```
## Usage: Advanced Example
```tsx
import {
asyncEmailUnique,
} from "@suaveplan/react-forms/validation/async";
import {
defineCrossFieldRule,
useDomainForm,
violate,
} from "@suaveplan/react-forms/core";
function RegisterShell() {
const { form, submit, isSubmitting } = useDomainForm({
formId: "register",
schema: registerSchema,
crossFieldRules: [passwordsMatch],
asyncValidators: [
{
field: "email",
validator: asyncEmailUnique({
check: (email, signal) =>
api.auth.checkEmail(email, signal),
}),
},
],
defaultValues: {
email: "",
password: "",
passwordConfirm: "",
},
onSubmit: async (values) => {
await api.auth.register(values);
},
});
return (
<form onSubmit={submit}>{/* ... */}</form>
);
}
```
## API Reference
- `useDomainForm<TValues>(options: UseDomainFormOptions<TValues>):
UseDomainFormReturn<TValues>` — the hook.
- `interface UseDomainFormOptions<TValues>` — extends
`ValidationEngineConfig<TValues>` with:
- `formId: string` — telemetry attribution.
- `defaultValues?: Partial<TValues>` — initial values for RHF.
- `onSubmit: (values: TValues) => Promise<unknown>` — submit
handler; throws are routed through the server-error mapper.
- `interface UseDomainFormReturn<TValues>`:
- `form: UseFormReturn<TValues>` — full RHF context.
- `submit: (event?: BaseSyntheticEvent) => Promise<void>` — wired
submit handler.
- `isSubmitting: boolean` — true while a submit is in flight.
- `serverError: ServerErrorState` — `{ hasError, cause? }`.
- `fields: null` — placeholder; renderer ships in Wave 5b.
- `children: undefined` — placeholder; render-prop slot ships in
Wave 5b.
## Implementation Notes
### Wave 5a vs Wave 5b
This is the Wave 5a deliverable. The hook is fully functional for
custom-shell consumers — register fields directly via
`form.register("name")` and lay them out yourself. Wave 5b will:
1. Add the `<FieldGroup>`/`<Field>` rendering layer that populates
the `fields: null` slot with React elements keyed by name.
2. Wrap this hook in the `createDomainForm(config)` factory that
returns a `ComponentType<FormProps>` plus the `.Field` static
accessor and `.useFormContext` re-export.
3. Add the `recipes/wizard`, `recipes/repeating-fieldset`,
`recipes/conditional`, and `recipes/async-search` factories on top.
### Submit pipeline
- The submit guard uses BOTH `useState` (for re-render) and `useRef`
(for synchronous re-entry detection). The ref check happens before
the state update so a synchronous double-call does not start two
submits even before React schedules a re-render.
- `mapServerErrors` is called inside the `handleSubmit` callback so
`form.setError` lands during a render cycle RHF understands. Setting
errors outside `handleSubmit`'s callback can race RHF's internal
state and not surface in `formState.errors`.
- `unwrapServerEnvelope` returns `undefined` for non-envelope failures;
the catch arm wraps those in `ReactFormsError(SUBMIT_FAILED)` so
consumer error boundaries get a stable shape.
- `recordSubmitOutcome` and `recordSubmitDuration` always fire from
the `finally` arm so a thrown error still contributes to the
metrics.

View File

@@ -0,0 +1,122 @@
# `use-form-validation.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
React hook that bridges a `ValidationEngine` instance to react-hook-
form's `Resolver` contract. Constructs the engine once per render-
cycle (memoised on the supplied config), wires its
`engine.resolve(values)` output into RHF's `ResolverResult` shape, and
cancels in-flight async validators on unmount via `engine.cancel()`.
The hook is the layer where the package's `messageKey` discriminated-
union failure shape gets translated into RHF's
`{ type, message, types }` field-error shape. RHF's `message` becomes
the consumer's `TranslationKey`; `types.messageArgs` carries the ICU
args bag for downstream `t(messageKey, messageArgs)` resolution.
## Features
- Memoised engine — stable engine identity across re-renders unless
the config changes.
- Auto-cancel on unmount — `useEffect` registers a cleanup that calls
`engine.cancel()` so async validators do not leak after the form
goes away.
- Caller-supplied engine override — pass `options.engine` to reuse an
existing engine instance (useful for tests asserting engine state).
- Stable `Resolver` identity per engine — RHF re-creates internal
state when the resolver reference changes, so memoisation matters.
## Usage: Basic Example
```ts
import { useForm } from "react-hook-form";
import z from "zod";
import { useFormValidation } from "@suaveplan/react-forms/core/use-form-validation";
const schema = z.object({
email: z.string().email(),
});
function LoginForm() {
const resolver = useFormValidation({ schema });
const form = useForm({ resolver });
return <form onSubmit={form.handleSubmit(...)}>...</form>;
}
```
## Usage: Advanced Example
```ts
import { useMemo } from "react";
import {
asyncEmailUnique,
} from "@suaveplan/react-forms/validation/async";
import {
defineCrossFieldRule,
useFormValidation,
violate,
} from "@suaveplan/react-forms/core";
function RegisterForm() {
const config = useMemo(
() => ({
schema: registerSchema,
crossFieldRules: [passwordsMatch],
asyncValidators: [
{
field: "email",
validator: asyncEmailUnique({
check: async (email, signal) => {
const r = await fetch(`/api/email/check?e=${email}`, {
signal,
});
return r.status === 404;
},
}),
},
],
}),
[]
);
const resolver = useFormValidation(config);
const form = useForm({ resolver, mode: "onBlur" });
return <form onSubmit={form.handleSubmit(submitFn)}>...</form>;
}
```
## API Reference
- `useFormValidation<TValues>(options:
UseFormValidationOptions<TValues>): Resolver<TValues>` — hook.
- `interface UseFormValidationOptions<TValues> extends
ValidationEngineConfig<TValues> {`
- `engine?: ValidationEngine<TValues>` — caller-supplied engine
override.
- `}`
- `type FormSchema<T extends FieldValuesMap> = z.ZodType<T>` — alias
for the Zod schema type accepted by the engine.
## Implementation Notes
- The memo dep array includes the four config fields — schema,
crossFieldRules, asyncValidators, debounceMs — so the engine is
rebuilt only when any of these change. Consumers passing a fresh
array literal each render would defeat the memoisation; the
Advanced Example above wraps the config in `useMemo` precisely to
avoid that.
- The cleanup `useEffect` runs only on unmount (empty dep array). It
pulls `engine.cancel()` through a `ref` so the closure captures the
latest engine even after re-renders.
- The translation from `EngineFieldErrors` to `ResolverResult` uses
RHF's `type: "validate"` discriminator, which is RHF's standard
catch-all for custom-validation errors. Consumers reading the
resolver result will see `formState.errors.<field>.message ===
"<messageKey>"` and can pass that into their `useTranslation()` t
function for rendering.
- The `messageArgs` go into `types.messageArgs` because RHF's
`FieldError` has no first-class slot for ICU args. Consumers
rendering errors with ICU support extract them via
`formState.errors.<field>.types?.messageArgs`.

View File

@@ -0,0 +1,104 @@
# `cross-field-rule.ts`
## Purpose
Convenience factory + helper for declaring `CrossFieldRule` values.
Cross-field rules are the second pipeline phase inside
`ValidationEngine` — they run after the Zod schema passes and have
read access to the entire form values object, so they can express
constraints like "passwordConfirm matches password" or "endDate must
be after startDate" that no per-field validator can.
## Features
- `defineCrossFieldRule(opts)` — pass-through factory that gives
consumers a typed call site without having to import the interface
and write the shape literal.
- `violate(field, messageKey, messageArgs?)` — tiny helper that
constructs a single `CrossFieldViolation` so rule bodies can stay
one-liner-friendly.
## Usage: Basic Example
```ts
import {
defineCrossFieldRule,
violate,
} from "@suaveplan/react-forms/core/validation-engine";
import type { TranslationKey } from "@suaveplan/react-i18n";
const passwordsMatch = defineCrossFieldRule<{
password: string;
passwordConfirm: string;
}>({
name: "passwords-match",
fields: ["password", "passwordConfirm"],
validate: ({ password, passwordConfirm }) =>
password === passwordConfirm
? null
: [
violate(
"passwordConfirm",
"auth.password.mismatch" as TranslationKey
),
],
});
```
## Usage: Advanced Example
```ts
import {
defineCrossFieldRule,
violate,
} from "@suaveplan/react-forms/core/validation-engine";
const dateRangeOrdered = defineCrossFieldRule<{
startDate: Date;
endDate: Date;
}>({
name: "date-range-ordered",
fields: ["startDate", "endDate"],
validate: ({ startDate, endDate }) => {
const violations = [];
if (endDate.getTime() <= startDate.getTime()) {
violations.push(
violate(
"endDate",
"form.date.before_start" as TranslationKey,
{ startDate: startDate.toISOString() }
)
);
}
return violations.length > 0 ? violations : null;
},
});
```
## API Reference
- `defineCrossFieldRule<TValues>(opts: CrossFieldRuleOptions<TValues>):
CrossFieldRule<TValues>` — factory.
- `interface CrossFieldRuleOptions<TValues> {`
- `name: string` — stable rule name.
- `fields: readonly string[]` — field paths the rule depends on.
- `validate: (values: TValues) => CrossFieldViolation[] | null` —
pure validator.
- `}`
- `violate(field: string, messageKey: TranslationKey, messageArgs?:
Readonly<Record<string, unknown>>): CrossFieldViolation` — single-
violation helper.
## Implementation Notes
- The factory is a pass-through (literally `return opts`) so the type
parameters propagate cleanly into the consumer's call site without
needing a generic adapter layer. The cost of the function call is
zero after the JIT inlines it.
- The `fields` declaration is a documentation handle today — Wave 5b's
engine wiring uses it to skip rule re-execution when none of the
declared fields changed since last run. Even before Wave 5b lands,
the field list is a useful audit signal.
- `violate`'s conditional branch on `messageArgs === undefined`
avoids emitting an explicit `undefined` member into the resulting
object, which keeps JSON serialisation of violations lean.

View File

@@ -0,0 +1,98 @@
# `server-error-mapper.ts`
## Purpose
Bridges server-side validation envelopes into the engine's per-field
`ValidatorFailure` shape. When a form's `submit.onSubmit` callback
returns or throws a `ServerErrorEnvelope` (or a `ReactFormsError`
whose `context.fieldErrors` carries the same shape), this module
unwraps the envelope, maps each entry into the engine's collection
shape, and increments the
`react_forms.validation.error_total{kind="server"}` counter.
## Features
- `mapServerErrors(envelope)` — convert a `ServerErrorEnvelope` into
the engine's `EngineFieldErrors` map.
- `unwrapServerEnvelope(cause)` — duck-type a thrown value into a
`ServerErrorEnvelope` if it carries the right shape; returns
`undefined` for anything else so the caller can re-throw.
- `throwServerErrorMapped(envelope, cause?)` — wrap an envelope in a
structured `ReactFormsError(REACT_FORMS.SERVER_ERROR_MAPPED)` for
cases where the engine wants to surface the mapping outcome to an
error boundary.
- OTel telemetry emitted per mapped field error.
## Usage: Basic Example
```ts
import {
mapServerErrors,
unwrapServerEnvelope,
} from "@suaveplan/react-forms/core/validation-engine";
try {
await api.users.create(values);
} catch (cause) {
const envelope = unwrapServerEnvelope(cause);
if (envelope) {
const errors = mapServerErrors(envelope);
// errors === { email: { valid: false, messageKey: "auth.email.taken" } }
} else {
throw cause;
}
}
```
## Usage: Advanced Example
```ts
import {
mapServerErrors,
throwServerErrorMapped,
unwrapServerEnvelope,
} from "@suaveplan/react-forms/core/validation-engine";
async function submit(values) {
try {
return await api.users.register(values);
} catch (cause) {
const envelope = unwrapServerEnvelope(cause);
if (envelope === undefined) throw cause;
// Map for in-form display
const errors = mapServerErrors(envelope);
if (Object.keys(errors).length > 0) {
// also surface to error boundary
throwServerErrorMapped(envelope, cause);
}
}
}
```
## API Reference
- `mapServerErrors(envelope: ServerErrorEnvelope): EngineFieldErrors`
— convert envelope into the engine's per-field failure map.
- `unwrapServerEnvelope(cause: unknown): ServerErrorEnvelope |
undefined` — duck-type a thrown value.
- `throwServerErrorMapped(envelope: ServerErrorEnvelope, cause?:
unknown): never` — throw a structured `ReactFormsError`.
- `unsafeAssertTranslationKey(value: string): TranslationKey` — test-
only escape hatch for the brand check; real callers should always
go through the consumer's translation augmentation.
## Implementation Notes
- The mapper uses a positive duck-type check (`Array.isArray` plus
per-entry type guards) rather than a Zod parse to keep the hot
path zero-allocation and runtime-dependency-free. Zod's parse
would add ~3 µs per envelope and the engine runs this on every
submit.
- Each mapped entry increments the OTel counter; consumers wanting
per-form attribution can add `form` to the counter via a wrapping
layer in `useDomainForm`.
- The `unsafeAssertTranslationKey` helper exists so spec-conformance
tests can construct branded keys without going through the
consumer's full augmentation path. It is exported from the
validation-engine module — NOT from the package barrel — so
application code never sees it.

View File

@@ -0,0 +1,131 @@
# `validation-engine.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Coordinates the four-phase validation pipeline that turns a Zod schema
plus optional cross-field rules and async validators into a single
`ValidationResult` consumable by react-hook-form's resolver. The
engine is the synchronisation point that ensures phases run in the
right order, that schema errors short-circuit cross-field rules, that
schema or cross-field errors short-circuit async validators (no
network calls when the form is structurally invalid), and that an
in-flight pipeline run is cancelled before a newer one starts so
async validators do not race to set stale errors.
## Features
- Four-phase pipeline: Zod schema → cross-field rules → async
validators → server-error mapping (the last phase is invoked
separately by `useDomainForm`).
- AbortSignal cancellation between runs — calling `resolve(values)` a
second time aborts the first.
- Strict precedence — earlier-phase failures take priority per field;
later phases never overwrite an earlier failure.
- OTel telemetry — `react_forms.validation.duration_ms` per run,
`react_forms.validation.error_total{kind}` per failure.
- Throws `ReactFormsError(REACT_FORMS.SCHEMA_INVALID)` from the
constructor when the supplied schema is not a Zod schema (no
`.safeParse` member).
## Usage: Basic Example
```ts
import z from "zod";
import { ValidationEngine } from "@suaveplan/react-forms/core/validation-engine";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const engine = new ValidationEngine({ schema });
const result = await engine.resolve({ email: "x", password: "short" });
// result.values === undefined
// result.errors.email && result.errors.password
```
## Usage: Advanced Example
```ts
import z from "zod";
import {
asyncEmailUnique,
} from "@suaveplan/react-forms/validation/async";
import {
defineCrossFieldRule,
ValidationEngine,
violate,
} from "@suaveplan/react-forms/core/validation-engine";
import type { TranslationKey } from "@suaveplan/react-i18n";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
passwordConfirm: z.string(),
});
const passwordsMatch = defineCrossFieldRule({
name: "passwords-match",
fields: ["password", "passwordConfirm"],
validate: ({ password, passwordConfirm }) =>
password === passwordConfirm
? null
: [
violate(
"passwordConfirm",
"auth.password.mismatch" as TranslationKey
),
],
});
const checkUnique = asyncEmailUnique({
check: async (email, signal) => {
const r = await fetch(`/api/email/check?e=${email}`, {
signal,
});
return r.status === 404;
},
});
const engine = new ValidationEngine({
schema,
crossFieldRules: [passwordsMatch],
asyncValidators: [{ field: "email", validator: checkUnique }],
});
```
## API Reference
- `class ValidationEngine<TValues>` — pipeline coordinator.
- `new ValidationEngine(config: ValidationEngineConfig<TValues>)` —
throws `REACT_FORMS.SCHEMA_INVALID` on a non-Zod schema.
- `engine.resolve(values: TValues): Promise<ValidationResult<TValues>>`
— run the pipeline; cancels the previous run.
- `engine.cancel(): void` — cancel any in-flight async validators
(call on form unmount).
## Implementation Notes
- The engine carries its own `AbortController` instance and replaces
it on every `resolve()` call. The previous controller is aborted
first so async validators that observe their `signal` can short-
circuit; validators that don't observe the signal will still
resolve, but the engine guards via `runId` checks so their results
are dropped.
- Zod errors are mapped to a stable namespaced key
(`react_forms.validation.zod.<issue.code>`) so consumer catalogs
can register one entry per Zod issue family rather than per error.
The original Zod message is forwarded via `messageArgs.message` so
the catalog template can interpolate it when human copy is
desired.
- The engine deliberately does NOT debounce `resolve()` calls — that
is the resolver's responsibility (RHF's `mode: "onBlur" |
"onChange"` controls how often `resolve` is invoked). Debouncing
is layered on top of the engine, not inside it.
- The pipeline cancels in-flight pipelines via the `runId` discriminator
so a slow async validator from the previous run cannot overwrite
the current run's errors. The `runId !== this.currentRunId` check
is what gives the engine its "stale-result safe" guarantee.

View File

@@ -0,0 +1,90 @@
# `error-codes.ts`
## Purpose
Registers the thirteen stable error codes used by
`@suaveplan/react-forms` under the `REACT_FORMS` namespace via
`defineCodes` from `@suaveplan/error`. These codes are the single
source of truth for error identity across the React forms surface —
every `ReactFormsError` carries one of them as its `.code`, every
spec-conformance test asserts against them, and every observability
sink (OTel attributes, structured log fields, error-wire JSON) uses the
fully-qualified `REACT_FORMS.<reason>` string.
The registry must run at module-import time so the codes are present in
the global registry before any hook, component, or validator throws. The
barrel imports the file as a side-effect-only entry to guarantee the
registration sequence regardless of which subpath the consumer imports
first.
## Features
- Frozen `REACT_FORMS_CODES` record with thirteen entries —
added/removed only through an OpenSpec change.
- `ReactFormsCode` literal-union type derived from the registry, used as
the `TCode` parameter of `ReactFormsError`.
- Side-effect-free constant declaration: no logger calls, no global
mutation beyond what `defineCodes` performs internally on the shared
registry.
## Usage: Basic Example
```ts
import { REACT_FORMS_CODES } from "@suaveplan/react-forms/errors";
console.log(REACT_FORMS_CODES.VALIDATION_FAILED);
// → "REACT_FORMS.VALIDATION_FAILED"
```
## Usage: Advanced Example
```ts
import {
REACT_FORMS_CODES,
ReactFormsError,
} from "@suaveplan/react-forms/errors";
function unwrapApi(api: unknown): unknown {
if (api === undefined) {
throw new ReactFormsError(
REACT_FORMS_CODES.API_CLIENT_NOT_FOUND,
"react_forms.error.api_client_not_found",
{ context: { hook: "useApiClient" } }
);
}
return api;
}
```
## API Reference
- `REACT_FORMS_CODES` — frozen `Record<Reason, "REACT_FORMS.<Reason>">`
produced by `defineCodes("REACT_FORMS", [...])`. Reasons:
`VALIDATION_FAILED`, `SUBMIT_FAILED`, `ASYNC_VALIDATOR_TIMEOUT`,
`ASYNC_VALIDATOR_ABORTED`, `SERVER_ERROR_MAPPED`,
`API_CLIENT_NOT_FOUND`, `AUTH_CONTEXT_NOT_FOUND`,
`FIELD_KIND_INVALID`, `SCHEMA_INVALID`,
`HARDCODED_STRING_DETECTED`, `CONTEXT_NOT_PROVIDED` (recipe needs
a missing provider), `REPEATING_ROW_LIMIT` (`add()` past `maxRows`
or `remove()` past `minRows`), `ASYNC_SEARCH_LOAD_FAILED`
(async-search recipe's `load(id, signal)` rejected).
- `ReactFormsCode` — `(typeof REACT_FORMS_CODES)[keyof typeof
REACT_FORMS_CODES]`. Use as the `TCode` parameter of
`SuaveplanError<TCode>` extensions.
## Implementation Notes
- The file is intentionally tiny — its only responsibility is to call
`defineCodes(...)` and re-export the resulting record + code union.
Keeping it side-effect-free past the registration call lets bundlers
prove a no-mutation tree and lets test harnesses import it without
perturbing other modules' state.
- `as const` on the reasons array preserves the literal types so
`ReactFormsCode` becomes a precise discriminated union rather than a
widened `string`.
- The package barrel imports this file as a side-effect entry
(`import "./errors/error-codes.js";`) so the codes register before the
consumer's first throw site is reached.
- New codes require an OpenSpec change — adding a code is a public-API
change because consumers may switch on it. Removing a code is a
breaking change.

View File

@@ -0,0 +1,90 @@
# `react-forms-error.ts`
## Purpose
Defines the base `ReactFormsError` class shared by every error path in
`@suaveplan/react-forms`. Extending the project-wide `SuaveplanError`
plugs the forms tier into the cause-chain traversal, redaction-aware
JSON wire format, OTel span-event adapter, and i18n message-resolver
hook implemented by `@suaveplan/error`.
The constructor stamps `context.tier === "react-forms"` on every
instance so observability sinks can filter forms-layer failures without
inspecting `error.constructor.name` (which is rewritten by minifiers).
## Features
- One concrete class for every error code in the registry — caller code
always throws `new ReactFormsError(code, messageKey, options)`.
- Auto-injected `context.tier === "react-forms"` for observability.
- Generic-typed `code` field constrained to `ReactFormsCode` so
`instanceof` narrows the union for TypeScript-aware consumers.
- Forwards `cause`, `traceId`, `clock`, and any caller-supplied context
unchanged through `SuaveplanError`'s wire format.
## Usage: Basic Example
```ts
import {
REACT_FORMS_CODES,
ReactFormsError,
} from "@suaveplan/react-forms/errors";
throw new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed"
);
```
## Usage: Advanced Example
```ts
import {
REACT_FORMS_CODES,
ReactFormsError,
} from "@suaveplan/react-forms/errors";
try {
await submit(values);
} catch (cause) {
throw new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed",
{
cause,
context: { formId: "register", attempt: 2 },
traceId: spanContext.traceId,
}
);
}
```
## API Reference
- `class ReactFormsError extends SuaveplanError<ReactFormsCode>` — the
base error class.
- `new ReactFormsError(code, messageKey, options?)` — constructs an
error.
- `code: ReactFormsCode` — one of the values in `REACT_FORMS_CODES`.
- `messageKey: string` — i18n catalog key resolved at render time by
the consumer's `useErrorMessage` / `t(key)` pipeline.
- `options?: SuaveplanErrorOptions` — optional `cause`, `context`,
`clock`, `traceId`. The constructor merges
`context = { tier: "react-forms", ...options.context }` so the tier
cannot be overwritten by accident.
## Implementation Notes
- The class is intentionally minimal — any forms-specific behaviour that
needs to live on the error (e.g. `error.context.field`,
`error.context.fieldErrors`) is supplied by the caller through the
`context` option, not via subclassing. One class plus N codes keeps
the surface auditable.
- `messageKey` is intentionally typed as `string` rather than
`TranslationKey`. The `TranslationKey` brand is enforced at the public
prop boundary (validators, FieldConfig); errors thrown from internal
defensive guards may use raw catalog keys outside the branded
namespace.
- The `tier: "react-forms"` stamp is the same convention used by every
other React-tier error class in the project (e.g.
`ReactI18nError`'s `tier: "react-i18n"`).

View File

@@ -0,0 +1,187 @@
# `createDomainForm`
Declarative typed-form factory. Wraps `useDomainForm` + `<RHF
FormProvider>` + the layout dispatcher into a single component while
preserving direct access to react-hook-form via three escape
hatches.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createDomainForm` is the factory consumers reach for to build a form
in three lines: declare a Zod schema, declare a typed field-config
map, declare a submit handler. The factory returns a working
component plus a `.Field` static accessor for partial layouts. The
underlying RHF context is never hidden — the render-prop children
escape hatch yields the raw `useFormReturn`, the standalone
`useDomainForm` hook is exported for consumers who need a fully
custom shell, and `<RegisterForm.Field name="email" />` lets the
consumer mix factory-rendered fields into their own JSX.
Field-config validation runs at module-evaluation time via Zod —
misconfigurations (`select` without `options`, `currency` without
an ISO code) throw `REACT_FORMS.FIELD_KIND_INVALID` before the
component ever renders, so a typo never reaches a user-facing
surface.
## Features
- Three escape hatches: default render, render-prop children, static
`Form.Field` accessor.
- Built-in layouts: `vertical` (default), `horizontal`, `grid`. Plus
a `LayoutRenderFn` escape hatch for custom JSX.
- Auto-rendered submit footer with translated label and optional
cancel button.
- Wraps RHF's `<FormProvider>` automatically so consumers using
`useFormContext()` deep in the tree work without extra wiring.
- Passes through every option to the underlying `useDomainForm`:
`crossFieldRules`, `asyncValidators`, `debounceMs`, `defaultValues`.
- `data-form-id` on the rendered form so OTel-instrumented dashboards
can correlate DOM events to telemetry counters.
## Usage: Basic Example
```tsx
import { createDomainForm } from "@suaveplan/react-forms/factory/create-domain-form";
import { RegisterInput } from "@suaveplan/api-schemas/auth/register";
const RegisterForm = createDomainForm({
formId: "register",
schema: RegisterInput,
fields: {
email: { kind: "email", label: "auth.email.label" as never },
password: {
kind: "password",
label: "auth.password.label" as never,
strengthMeter: true,
},
name: { kind: "text", label: "auth.name.label" as never },
},
defaultValues: { email: "", password: "", name: "" },
submit: {
label: "auth.register.submit" as never,
onSubmit: async (values) => {
await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(values),
});
},
},
});
export function RegisterPage(): JSX.Element {
return <RegisterForm />;
}
```
## Usage: Advanced Example
Drop down to render-prop children for a custom shell (e.g. a two-pane
layout with a sidebar of additional context):
```tsx
import { Button } from "@suaveplan/react-ui";
export function RegisterPageWithSidebar(): JSX.Element {
return (
<RegisterForm>
{({ form, fields, submit, isSubmitting }) => (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
<div>
{fields.email}
{fields.password}
{fields.name}
<Button
type="button"
onClick={() => submit()}
disabled={isSubmitting}
>
Sign up
</Button>
</div>
<aside data-form-pristine={String(!form.formState.isDirty)}>
<p>By signing up you agree to the terms.</p>
</aside>
</div>
)}
</RegisterForm>
);
}
```
Or use the `Form.Field` static accessor for a partial layout where
the consumer wants the factory's chrome on some fields but renders
others themselves:
```tsx
export function RegisterMixed(): JSX.Element {
return (
<div>
<RegisterForm.Field name="email" />
<RegisterForm.Field name="password" />
<p>Show your name in your public profile?</p>
<RegisterForm.Field name="name" />
</div>
);
}
```
## API Reference
### `createDomainForm(config)`
Returns a `DomainFormComponent<TValues>` — a React component plus a
`.Field` static accessor.
Config (`CreateDomainFormConfig<TValues>`):
- `formId: string` — stable identifier used in OTel attributes.
- `schema: ZodType<TValues>` — Zod schema spine; defines `TValues`.
- `fields: FieldConfigMap<TValues>` — per-field config map.
- `layout?: DomainFormLayout` — `"vertical"` (default), `"horizontal"`,
`"grid"`, or a `LayoutRenderFn`.
- `defaultValues?: Partial<TValues>` — initial form values.
- `submit: { label: TranslationKey; cancelLabel?: TranslationKey; onSubmit: (values: TValues) => Promise<unknown> }` — submit
configuration.
- `asyncValidators?: ReadonlyArray<AsyncValidatorRegistration>` —
per-field async validators.
- `crossFieldRules?: ReadonlyArray<CrossFieldRule<TValues>>` —
cross-field rules.
- `debounceMs?: number` — async-validator debounce window.
### `<DomainForm>` props (the returned component)
- `children?: DomainFormChildren` — render-prop callback. When
supplied, the factory yields control and the default footer is
NOT rendered.
- `className?: string` — class applied to the underlying `<form>`.
### `<DomainForm.Field name>` static accessor
Renders a single field by name with the factory chrome. Throws
`REACT_FORMS.FIELD_KIND_INVALID` when the name is not in the config
map.
## Implementation Notes
- The `validateFieldConfigMap` call lives at module-evaluation time
inside the `createDomainForm` body so misconfigurations fail at
import time, not on first render.
- The `<FormProvider>` wrapper ensures hooks like `useFormContext` and
`useWatch` work for descendants without consumers having to wire it
themselves.
- The factory's component does NOT memoise `useDomainForm`'s returned
`fields` between the default render and the children render-prop
branch — the same pre-rendered elements are passed through both
paths via the same memoised reference held inside the hook.
- When children are supplied, the default submit footer is omitted —
the consumer controls the entire sub-tree below the `<form>` tag,
including any submit button.
- `Form.Field` calls `useDomainForm` independently to render its
single field. This means a parent that uses both `<RegisterForm />`
and `<RegisterForm.Field name="email" />` will instantiate two
hook trees with independent state. For combined usage, prefer the
render-prop children API which shares one hook instance.

View File

@@ -0,0 +1,120 @@
# `field-config`
`FieldConfig` discriminated union, per-kind Zod schemas, and the
`validateFieldConfig` / `validateFieldConfigMap` helpers used by the
factory at module-evaluation time.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Every form built via `createDomainForm` declares a typed
`FieldConfigMap<TValues>` mapping field name → kind-specific config.
The map drives label rendering, input selection, validation wiring,
and submit payload construction. Misconfigurations (`select` without
`options`, `currency` without an ISO code, `custom` without a
`render` callback) MUST fail at factory-define time so a bug never
reaches the user-facing render path. The `FieldConfigSchema` Zod
discriminated union enforces those invariants for every kind shipped
in Wave 5b.
The supported kinds in Wave 5b are: `text`, `email`, `password`,
`number`, `currency`, `checkbox`, `toggle`, `radio`, `textarea`,
`custom`. Deferred kinds (`date`, `dateTime`, `select`, `multi-select`,
`combobox`, `file`, `rich-text`, `otp`) are NOT in the discriminator
yet — they land in Wave 5c when the underlying react-ui primitives
ship. Passing a deferred kind today fails the discriminator and
throws `REACT_FORMS.FIELD_KIND_INVALID` with the offending field name
on the error context.
## Features
- Discriminated union over every kind so TypeScript narrows per kind.
- Per-kind Zod schema validating the runtime shape (currency code is
ISO 4217 length 3, radio has at least one option, custom has a
function `render`).
- `validateFieldConfig` throws `REACT_FORMS.FIELD_KIND_INVALID` with
the offending field name and the structured Zod issues on the
error context.
- `validateFieldConfigMap` walks every entry of the map so the factory
can fail-fast at define time.
- `SUPPORTED_FIELD_KINDS` exposes the in-Wave kind allowlist for
tooling / introspection.
## Usage: Basic Example
```ts
import { validateFieldConfig } from "@suaveplan/react-forms/factory/field-config";
const config = validateFieldConfig("email", {
kind: "email",
label: "auth.email.label",
});
console.log(config.kind); // "email"
```
## Usage: Advanced Example
```ts
import {
validateFieldConfigMap,
type FieldConfigMap,
} from "@suaveplan/react-forms/factory/field-config";
interface RegisterValues {
readonly email: string;
readonly password: string;
readonly name: string;
}
const fields: FieldConfigMap<RegisterValues> = {
email: { kind: "email", label: "auth.email.label" as never },
password: {
kind: "password",
label: "auth.password.label" as never,
strengthMeter: true,
},
name: { kind: "text", label: "auth.name.label" as never },
};
const validated = validateFieldConfigMap(fields);
// On failure: throws ReactFormsError(REACT_FORMS.FIELD_KIND_INVALID)
// carrying { field, issues }
```
## API Reference
- `FieldConfigSchema` — Zod discriminated union over every kind.
Use this for ad-hoc parsing if not running through the helper
functions.
- `validateFieldConfig(fieldName, config)` — parse a single config.
Throws `ReactFormsError(REACT_FORMS.FIELD_KIND_INVALID)` on failure.
Returns the parsed `FieldConfig` on success.
- `validateFieldConfigMap(fields)` — parse every entry of a map.
Returns the same map on success; throws on the first invalid entry.
- `isSupportedFieldKind(kind)` — boolean predicate over the in-Wave
kind allowlist. Useful for runtime guards in custom factory wrappers.
- `FieldConfig`, `FieldConfigMap`, `SUPPORTED_FIELD_KINDS`,
`SupportedFieldKind`, plus the per-kind config interfaces — declared
in `field-config.types.ts` and re-exported via the index barrel.
## Implementation Notes
- The Zod schemas accept plain `string` for every label / placeholder /
helperText property because the `TranslationKey` brand exists only
in TypeScript's type space — the brand cannot be observed at
runtime. The compile-time check is the gate; the runtime check
exists to catch shape misuse (missing required fields, wrong types)
rather than translation-key registration errors.
- The discriminator is `kind`, matching the master-design §9.5 surface
exactly. Wave 5c will widen the discriminator without breaking
consumers — the existing entries stay valid.
- `custom` accepts any function as `render` because the factory only
invokes it inside a Controller with the correct argument shape.
Type-narrowing the function signature inside Zod produces unhelpful
unknown-arg errors; the `CustomFieldConfig` TypeScript type is the
consumer-facing contract.
- The supported-kind allowlist is exported as a frozen tuple
(`as const`) so the literal types stay narrowed.

View File

@@ -0,0 +1,162 @@
# `RenderField`
Single-field renderer used by `useDomainForm` and `createDomainForm` to
turn a typed `FieldConfig` into a `<Field>` chassis container paired
with the appropriate input control.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maps a `FieldConfig<TValues>` discriminated-union entry to a complete
`<Field>` subtree (label / control / description / error) wired to a
react-hook-form `UseFormReturn`. Drives the auto-render pathway used
by both the standalone `useDomainForm` hook and the higher-level
`createDomainForm` factory. Consumers should rarely import this
directly — the factory is the public surface — but it is exposed via
the `factory/render-field` subpath for advanced cases that compose
their own layout while still wanting per-field auto-rendering.
The renderer wires `id`, `htmlFor`, `aria-describedby`, `aria-invalid`,
and `data-invalid` consistently across every kind so the output passes
WCAG 2.2 AA without consumer wiring. It uses RHF's `register` for
plain inputs (text, email, password, number, textarea) and
`Controller` for inputs with non-DOM event shapes (currency,
checkbox, toggle, radio, custom).
## Features
- One JSX subtree per Wave 5b field kind: text, email, password,
number, currency, checkbox, toggle, radio, textarea, custom.
- Consistent ARIA wiring: `htmlFor`, `aria-describedby`, `aria-invalid`,
`data-invalid`, `data-disabled`, `role="alert"` on errors.
- Inline `<Translated>` helper resolves every `TranslationKey` against
the active i18n at render time so no consumer has to thread `t`
through the renderer.
- Honours `config.disabled` everywhere — register-bound inputs receive
`{ disabled: true }`, controller-bound inputs forward the prop.
- `data-strength-meter="true"` written on password fields whose config
declares `strengthMeter: true` so a sibling visualiser can subscribe
to the value via `useFormContext()`.
## Usage: Basic Example
```tsx
import { RenderField } from "@suaveplan/react-forms/factory/create-domain-form";
import { useForm } from "react-hook-form";
interface Values {
readonly email: string;
}
function EmailFieldShell(): JSX.Element {
const form = useForm<Values>({ defaultValues: { email: "" } });
return (
<RenderField
name="email"
config={{ kind: "email", label: "auth.email.label" as never }}
form={form}
/>
);
}
```
## Usage: Advanced Example
Drive a multi-section layout by selecting which fields to render
inline vs which to render in a sidebar:
```tsx
import { RenderField } from "@suaveplan/react-forms/factory/create-domain-form";
import { FieldGroup } from "@suaveplan/react-ui";
import { useForm } from "react-hook-form";
interface Values {
readonly email: string;
readonly password: string;
readonly captcha: string;
}
const fields = {
email: { kind: "email" as const, label: "auth.email.label" as never },
password: {
kind: "password" as const,
label: "auth.password.label" as never,
strengthMeter: true,
},
captcha: {
kind: "custom" as const,
label: "auth.captcha.label" as never,
render: ({ field }: { readonly field: { readonly name: string } }) => (
<div data-captcha={field.name} />
),
},
};
export function SplitLayout(): JSX.Element {
const form = useForm<Values>({
defaultValues: { email: "", password: "", captcha: "" },
});
return (
<div style={{ display: "flex", gap: "1rem" }}>
<FieldGroup style={{ flex: 1 }}>
<RenderField name="email" config={fields.email} form={form} />
<RenderField
name="password"
config={fields.password}
form={form}
/>
</FieldGroup>
<aside>
<RenderField
name="captcha"
config={fields.captcha}
form={form}
/>
</aside>
</div>
);
}
```
## API Reference
### `<RenderField>` (component)
Props:
- `name: string` — field name (must match a key in `form`'s value
shape).
- `config: FieldConfig<TValues>` — discriminated-union field config.
- `form: UseFormReturn<TValues>` — RHF context returned by
`useDomainForm` or `useForm`.
The component subscribes to `form.formState` via
`form.getFieldState(name, formState)` so error messages and
`data-invalid` flags re-render on the same React commit cycle as the
underlying state change.
## Implementation Notes
- The renderer uses RHF's `register` for plain inputs whose native
`change` events flow through normally (text, email, password,
number, textarea). `Controller` is used for inputs with non-DOM
event shapes: currency (number-shaped value), checkbox / toggle
(boolean), radio (string), and custom (consumer-defined).
- The internal `<Translated>` helper is co-located rather than
factored out so the renderer file remains self-contained and the
`useTranslation()` subscription is established once per field.
- `aria-describedby` chains description and error ids when both are
present (`"field-x-description field-x-error"`); when only one is
active, the chain collapses to that single id. This matches the
WAI-ARIA APG recommendation for grouped helper text + alerts.
- The renderer never reads `config.errorMessage` directly — RHF's
`fieldState.error?.message` is the source of truth for the
rendered error text. The validator suite produces `messageKey`s
that flow through unchanged.
- The custom renderer cast on `render({ field, fieldState })` returns
`ReactNode` cast to `ReactElement` because `Controller`'s
`RenderFunction` requires `ReactElement`. Consumer functions are
free to return any `ReactNode`; the wrapper coerces.

View File

@@ -0,0 +1,72 @@
# `augmentation` — `ContributedTranslationKeys` registration
## Purpose
Side-effect-only TypeScript module that augments
`@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with
every key shipped by `@suaveplan/react-forms`. The augmentation runs
purely in TypeScript's type space — there is no runtime value to
import — but the file is included as a side-effect import from
`src/locales/locales.ts` so consumers' `tsconfig` picks up the
declaration when they `import "@suaveplan/react-forms"`.
The augmentation widens the branded `TranslationKey` union so a
recipe's `next` button label is statically known to be a registered
key. Misuse (`labels: { next: "typo" }`) is a TypeScript error before
the form ever mounts.
## Features
- Pure type-space augmentation — zero bytes of JavaScript runtime cost.
- Mirrors the keys in the generated `augmentation.d.ts` exactly so
side-effect import paths and declaration paths stay consistent.
- Imported as a side effect from `src/locales/locales.ts` so the
package barrel carries the augmentation transitively.
- Empty runtime export keeps tooling that strips type-only files from
side-effect chains honest.
## Usage: Basic Example
The augmentation is automatically applied when the package is imported.
No explicit usage is required.
```ts
import "@suaveplan/react-forms";
import type { TranslationKey } from "@suaveplan/react-i18n";
const k: TranslationKey = "react-forms.wizard.next"; // ✅ typechecks
```
## Usage: Advanced Example
If the consumer is building a server-side helper that does not import
the package barrel but still wants the augmentation widened, pull the
augmentation alone:
```ts
import "@suaveplan/react-forms/locales/augmentation";
import type { TranslationKey } from "@suaveplan/react-i18n";
declare const fromConfig: string;
const key = fromConfig as TranslationKey;
```
## API Reference
This module exports a single empty object literal (`export {}`) so the
TypeScript compiler treats it as a module and applies the
`declare module` augmentation. There are no runtime exports.
## Implementation Notes
- The file MUST stay in sync with `augmentation.d.ts`; both are
generated from `en-GB.json` by `bun
packages/react/react-i18n/scripts/generate-translation-types.ts
packages/react/react-forms --package-name @suaveplan/react-forms`.
- Two files exist (one `.ts`, one `.d.ts`) so consumers' bundlers do
not strip the runtime side-effect even when declaration-only files
are filtered out by aggressive tree-shaking.
- The unique-symbol brand on `ContributedTranslationKeys` ensures the
augmentation surface stays mergeable across packages — never declare
a `type ContributedTranslationKeys = { ... }`; it MUST stay an
`interface`.

View File

@@ -0,0 +1,105 @@
# Locales
Bundled translation strings shipped with `@suaveplan/react-forms`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The package owns a small set of user-facing strings used by the recipes
(`Next`, `Back`, `Submit`, `Add`, `Remove`, `Loading…`, `Failed to
load`). The strings ship as flat-key JSON bundles in five locales
(`en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`) and are merged into the
consumer's `I18nInstance.catalog` at provider mount.
Consumers MAY override any key via `<TranslationProvider>`'s catalog
merging or via the recipe factory's per-recipe `labels` prop.
## Features
- Flat-key bundles addressable by `react-forms.<recipe>.<key>`
- Lazy loaders (one dynamic import per locale) so only the active
locale's JSON ships in the consumer's main bundle
- Type-narrowed via the generated `augmentation.d.ts` so misuse fails
at compile time
- Sorted key list (`REACT_FORMS_BUNDLE_KEYS`) for parity tests
- One source of truth (`en-GB.json`); other locales are kept parity-
complete by `scripts/check-locale-completeness.ts`
## Usage: Basic Example
```ts
import {
REACT_FORMS_BUNDLE_KEYS,
reactFormsBundles,
} from "@suaveplan/react-forms";
const en = await reactFormsBundles["en-GB"]();
console.log(Object.keys(en).length); // 7
console.log(REACT_FORMS_BUNDLE_KEYS[0]); // "react-forms.asyncSearch.error"
```
## Usage: Advanced Example
Wire the bundles into the active `I18nInstance` so recipe defaults
resolve correctly. The pattern mirrors `react-ui`'s `ReactUiProvider`
flat-to-nested merging step:
```ts
import { reactFormsBundles } from "@suaveplan/react-forms";
import type { I18nInstance } from "@suaveplan/i18n/plugin";
function nestFlat(bag: Record<string, string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(bag)) {
const parts = k.split(".");
let cur: Record<string, unknown> = out;
for (let i = 0; i < parts.length - 1; i += 1) {
const seg = parts[i] as string;
if (typeof cur[seg] !== "object" || cur[seg] === null) {
cur[seg] = {};
}
cur = cur[seg] as Record<string, unknown>;
}
cur[parts[parts.length - 1] as string] = v;
}
return out;
}
export async function registerReactFormsLocales(
i18n: I18nInstance
): Promise<void> {
for (const [locale, loader] of Object.entries(reactFormsBundles)) {
const bundle = await loader();
i18n.catalog.mergeMessages(
locale,
nestFlat(bundle as Record<string, string>)
);
}
}
```
## API Reference
- `SupportedFormsLocale` — `"en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU"`. The five locales the package ships translations for.
- `reactFormsBundles` — `Readonly<Record<SupportedFormsLocale, () => Promise<FormsLocaleBundle>>>`. Frozen map of locale → async loader returning the flat key/value bag for that locale. Loaders are lazy so consumers only chunk the active locale.
- `FormsLocaleBundle` — `Readonly<Record<string, string>>`. Shape of a single locale's bundle.
- `REACT_FORMS_BUNDLE_KEYS` — `ReadonlyArray<string>`. Sorted list of every key in `en-GB.json` for parity tests.
## Implementation Notes
- The bundles are imported via dynamic JSON imports so the bundler can
tree-shake unused locales when consumers split per language route.
- The augmentation lives in two files (`augmentation.ts` + the
generated `augmentation.d.ts`) so a side-effect runtime import
carries the augmentation through the package barrel even when
declaration-only files are pruned by aggressive bundlers.
- `Object.freeze` plus `Readonly<...>` annotations prevent accidental
mutation by consumers — the bundles MUST be treated as immutable.
- Adding a key requires updating ALL FIVE locale bundles + re-running
the codegen (`bun packages/react/react-i18n/scripts/generate-translation-types.ts packages/react/react-forms --package-name @suaveplan/react-forms`)
- duplicating the entry into `augmentation.ts`. The
`check-locale-completeness.ts` CI gate fails the build if any locale
drifts.

View File

@@ -0,0 +1,76 @@
# `translation-keys` — codegen union of contributed keys
## Purpose
`translation-keys.ts` is the codegen-emitted string-literal union of
every translation key contributed by `@suaveplan/react-forms`. It is
regenerated from `src/locales/en-GB.json` by `bun
packages/react/react-i18n/scripts/generate-translation-types.ts
packages/react/react-forms --package-name @suaveplan/react-forms` and
committed verbatim so consumers and CI can statically reference the
union without re-running the codegen.
The union is useful for non-React consumers (universal helpers,
server-side validators) that want a typed reference to the
forms-contributed keys without taking on the full
`@suaveplan/react-i18n` `ContributedTranslationKeys` augmentation
pathway. React consumers should prefer the branded `TranslationKey`
type from `@suaveplan/react-i18n` instead — that type widens
automatically as more packages augment the interface and is the type
the recipe and factory props use.
## Features
- Single-purpose: re-declares every key in `en-GB.json` as a string-
literal union.
- Sorted alphabetically via `localeCompare("en")` for diff stability.
- Generated comment header documents the regeneration command so the
file does not drift on hand-edit.
- Side-effect-free — no runtime code, no imports from runtime packages.
## Usage: Basic Example
```ts
import type { ReactFormsTranslationKey } from "@suaveplan/react-forms";
declare const key: ReactFormsTranslationKey;
```
## Usage: Advanced Example
```ts
import type { ReactFormsTranslationKey } from "@suaveplan/react-forms";
const RECIPE_LABELS: Record<string, ReactFormsTranslationKey> = {
next: "react-forms.wizard.next",
back: "react-forms.wizard.back",
submit: "react-forms.wizard.submit",
add: "react-forms.repeating.add",
remove: "react-forms.repeating.remove",
loading: "react-forms.asyncSearch.loading",
error: "react-forms.asyncSearch.error",
};
```
## API Reference
### `ReactFormsTranslationKey`
String-literal union of every (flattened) key in
`src/locales/en-GB.json`. Sorted alphabetically; deterministic across
regeneration runs.
## Implementation Notes
- The file is regenerated by codegen — never hand-edit. Re-run `bun
packages/react/react-i18n/scripts/generate-translation-types.ts
packages/react/react-forms --package-name @suaveplan/react-forms`
after every change to `en-GB.json` and commit the regenerated file.
- The sibling `augmentation.d.ts` (also generated by the same codegen)
extends the runtime-typed `ContributedTranslationKeys` interface that
`TranslationKey` reads. The two files always carry the same set of
keys; the union here is a convenience export for non-React consumers,
the augmentation is the default type integration.
- The output ordering uses `localeCompare("en")` so the union is byte-
identical for identical input regardless of `en-GB.json`'s in-file
key insertion order.

View File

@@ -0,0 +1,115 @@
# `react-forms-metrics.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument set for `@suaveplan/react-forms`. Six
instruments — three counters and three histograms — cover every form
lifecycle event the engine, the async-validator queue, and the submit
pipeline emit. Built lazily so the package pays zero overhead when no
`MeterProvider` is registered, but always emits through the OTel API
when one is.
The module also keeps a small in-memory state ring so spec-conformance
tests can assert exact attribute payloads without standing up a full
SDK pipeline. The ring is process-local; consumers running multiple
isolated forms in the same process should call
`resetReactFormsMetricsState()` between cases.
## Features
- Lazy meter — `metrics.getMeter("suaveplan.react-forms")` is invoked
on first use, then cached.
- Counters: `react_forms.submit.total`,
`react_forms.async_validator.total`,
`react_forms.validation.error_total`.
- Histograms: `react_forms.submit.duration_ms`,
`react_forms.async_validator.duration_ms`,
`react_forms.validation.duration_ms`.
- In-memory ring per instrument so tests can assert attributes without
an SDK exporter.
- Stable contract — instrument names + attribute keys are part of the
package's public surface.
## Usage: Basic Example
```ts
import {
recordSubmitDuration,
recordSubmitOutcome,
} from "@suaveplan/react-forms/observability";
const start = performance.now();
try {
await submitForm(values);
recordSubmitOutcome({ form: "register", outcome: "success" });
} catch (cause) {
recordSubmitOutcome({ form: "register", outcome: "error" });
throw cause;
} finally {
recordSubmitDuration(performance.now() - start, { form: "register" });
}
```
## Usage: Advanced Example
```ts
import {
getReactFormsMetricsState,
recordAsyncValidatorDuration,
recordAsyncValidatorOutcome,
recordValidationError,
resetReactFormsMetricsState,
} from "@suaveplan/react-forms/observability";
resetReactFormsMetricsState();
recordAsyncValidatorOutcome({
validator: "asyncEmailUnique",
outcome: "aborted",
});
recordAsyncValidatorDuration(42, { validator: "asyncEmailUnique" });
recordValidationError({ kind: "async" });
const state = getReactFormsMetricsState();
state.asyncValidatorTotal[0]?.attributes; // { validator, outcome }
state.validationErrorTotal[0]?.attributes; // { kind: "async" }
```
## API Reference
- `getReactFormsMetrics(): CachedInstruments` — return cached counters
- histograms; build them on first call.
- `recordSubmitOutcome({ form, outcome })` — increment the
`react_forms.submit.total` counter and push a sample.
- `recordSubmitDuration(durationMs, { form })` — record on
`react_forms.submit.duration_ms`.
- `recordAsyncValidatorOutcome({ validator, outcome })` — increment
`react_forms.async_validator.total`.
- `recordAsyncValidatorDuration(durationMs, { validator })` — record
on `react_forms.async_validator.duration_ms`.
- `recordValidationError({ kind })` — increment
`react_forms.validation.error_total` with `kind` in
`{schema, async, cross-field, server}`.
- `recordValidationDuration(durationMs)` — record on
`react_forms.validation.duration_ms`.
- `getReactFormsMetricsState(): ReactFormsMetricsState` — read the
in-memory ring.
- `resetReactFormsMetricsState(): void` — clear the ring.
- `resetReactFormsMetricsCache(): void` — drop the cached meter so a
fresh one is built (test-only).
## Implementation Notes
- The cache uses a closure-scoped `let cached` rather than a module
constant so the test-only `resetReactFormsMetricsCache()` can invalidate
it. Production code never calls the reset path.
- Attribute keys are flat — `form`, `outcome`, `validator`, `kind` — to
match dashboard-friendly conventions and avoid nested attributes that
many OTel exporters serialise inconsistently.
- Outcome unions (`SubmitOutcome`, `AsyncValidatorOutcome`) and
`ValidationErrorKind` are exported so consumers wiring custom dashboards
can typecheck against the same allowlist the package uses.
- Renaming an instrument is a breaking change because consumer dashboards
query by name. Per AGENTS.md §13a a migration note is required.

View File

@@ -0,0 +1,121 @@
# `field` — single-source re-export of react-ui's Field family
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`primitives/field` re-exports the `<Field>`, `<FieldLabel>`,
`<FieldDescription>`, `<FieldError>`, `<FieldGroup>`, `<FieldSet>`, and
`<FieldLegend>` primitives from `@suaveplan/react-ui` so consumers
building forms have a single, semantically-named import path. The
underlying components are owned and tested inside `react-ui`; this
module only forwards them so the forms package never duplicates the
chassis surface.
The contract is identity-equality: `field.test.ts` asserts that every
re-exported symbol is reference-equal to the upstream symbol. Adding a
local wrapper would break the contract and silently fork the
underlying behaviour — never wrap, always re-export.
## Features
- Re-exports six React component references plus their TypeScript
prop types, identity-equal to the `react-ui` originals.
- Zero runtime cost — pure barrel export, no JS additions.
- Maintains a single upgrade vector: bumping the workspace `react-ui`
dep updates the field surface here automatically.
- Sibling `.test.ts` enforces the identity-equality contract so an
accidental wrapper rewrite fails the suite.
## Usage: Basic Example
```tsx
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "@suaveplan/react-forms/primitives/field";
import { Input } from "@suaveplan/react-ui";
export function EmailField(): JSX.Element {
return (
<Field invalid={false}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
<FieldDescription>We never share your address.</FieldDescription>
</Field>
);
}
```
## Usage: Advanced Example
Wire a fieldset of grouped fields using the `<FieldSet>` and
`<FieldLegend>` primitives so the rendered tree carries the correct
semantic structure for assistive technologies:
```tsx
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@suaveplan/react-forms/primitives/field";
import { Input } from "@suaveplan/react-ui";
export function AddressFieldset(): JSX.Element {
return (
<FieldSet>
<FieldLegend>Postal address</FieldLegend>
<FieldGroup>
<Field>
<FieldLabel htmlFor="line1">Address line 1</FieldLabel>
<Input id="line1" />
</Field>
<Field>
<FieldLabel htmlFor="line2">Address line 2</FieldLabel>
<Input id="line2" />
</Field>
<Field>
<FieldLabel htmlFor="city">City</FieldLabel>
<Input id="city" />
</Field>
</FieldGroup>
</FieldSet>
);
}
```
## API Reference
Every export is a re-export of the same-named symbol from
`@suaveplan/react-ui`. Refer to the upstream documentation for the
canonical prop tables; this section enumerates only the available
identifiers.
- `Field` — component. Container with `data-invalid` /
`data-disabled` attributes.
- `FieldLabel` — component. `htmlFor`-bound label primitive.
- `FieldDescription` — component. Muted helper text rendered as `<p>`.
- `FieldError` — component. Destructive helper with `role="alert"`.
- `FieldGroup` — component. Vertical stack container.
- `FieldSet` — component. Semantic `<fieldset>` wrapper.
- `FieldLegend` — component. Semantic `<legend>` wrapper.
- `FieldProps`, `FieldLabelProps`, `FieldDescriptionProps`,
`FieldErrorProps`, `FieldGroupProps`, `FieldSetProps`,
`FieldLegendProps` — TypeScript types matching each component.
## Implementation Notes
- The module never adds local wrappers. If a future feature requires
a forms-only `Field` variant, ship it under a new feature directory
(`primitives/<variant>/`) rather than modifying this re-export.
- Identity equality is asserted in the sibling `.test.ts` so any drift
(e.g. accidentally wrapping `Field` with a memoised proxy) fails CI
before publish.
- The barrel `index.ts` exports the same symbols so consumers can pick
either subpath (`@suaveplan/react-forms/primitives/field`) or the
package barrel (`@suaveplan/react-forms`).

View File

@@ -0,0 +1,128 @@
# `MoneyInput`
Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Render a numeric input that displays its value as locale-formatted
currency (`£12,345.67`, `12 345,67 €`, `¥12,345`) while keeping the
public API a plain `number | null`. The component reads the active
i18n locale from `useLocale()` so locale switches re-render the input
in the new locale automatically. Parsing on blur (or per-keystroke,
configurable) converts the user's typed text back into a JavaScript
number for the consumer's `onChange` callback.
The component is fully controlled — the consumer owns the value. No
hidden state is held across renders other than a transient editing
buffer used between focus and blur so the user can type freely
without the formatter rewriting their caret position.
## Features
- Locale-aware formatting via `@suaveplan/i18n`'s `formatCurrency`.
- ISO 4217 currency code drives both display and the precision of the
value reported back to `onChange`.
- Two commit modes: `keystroke` (default, fires on every change) and
`blur` (fires only when the input loses focus).
- Subscribed to `useLocale()` so a `setLocale()` in any ancestor
re-renders the displayed text without remounting.
- Fully type-safe — `placeholder` is typed as `TranslationKey` so a
hardcoded English literal is a compile error.
- Accessible — `inputMode="decimal"` triggers the numeric keyboard on
mobile; the underlying `<Input>` carries the chassis a11y wiring.
## Usage: Basic Example
```tsx
import { useState } from "react";
import { MoneyInput } from "@suaveplan/react-forms/primitives/money-input";
function PriceInput(): JSX.Element {
const [price, setPrice] = useState<number | null>(null);
return <MoneyInput value={price} currency="GBP" onChange={setPrice} />;
}
```
## Usage: Advanced Example
Defer commits until blur, pin the locale to French, and surface the
parsed value back to a react-hook-form `Controller`:
```tsx
import { Controller, type Control } from "react-hook-form";
import { MoneyInput } from "@suaveplan/react-forms/primitives/money-input";
interface OrderForm {
readonly total: number | null;
}
export function OrderTotalField(props: {
readonly control: Control<OrderForm>;
}): JSX.Element {
return (
<Controller
control={props.control}
name="total"
render={({ field }) => (
<MoneyInput
value={field.value ?? null}
currency="EUR"
locale="fr-FR"
commitMode="blur"
onChange={field.onChange}
/>
)}
/>
);
}
```
## API Reference
### `<MoneyInput>` (component)
Props (extends `InputProps` minus `value`, `defaultValue`, `onChange`,
`type`, `inputMode`, `placeholder`):
- `value: number | null` — current amount (`null` is empty).
- `currency: string` — ISO 4217 code (`"GBP"`, `"USD"`, ...).
- `locale?: string` — BCP-47 override; defaults to the active i18n
locale resolved via `useLocale()`.
- `placeholder?: TranslationKey` — empty-state placeholder, typed.
- `onChange: (value: number | null) => void` — committed value
callback. `null` when the user clears the field.
- `commitMode?: "keystroke" | "blur"` — when `onChange` fires.
Defaults to `"keystroke"`.
### Behaviour
- Non-finite values (`NaN`, `Infinity`) supplied via `value` are
coerced to `null` at render time.
- The component never overwrites the user's typing while focused —
prop changes during edit are deferred until blur.
- Parsing tolerates locale-specific decimal separators (`.`, `,`, `٫`)
and grouping separators (space, NBSP, apostrophe, `.`, `,`).
- Sign markers `-`, `+`, and the Unicode minus `−` are all parsed
correctly.
## Implementation Notes
- The editing buffer is rendered as a plain `<input type="text"
inputMode="decimal">` rather than `<input type="number">` because
the latter rejects locale separators in many browsers.
- `data-currency={currency}` is written onto the rendered element so
consumers can style by currency without re-passing the prop.
- The decimal-separator detector caches `Intl.NumberFormat` lookups
per-locale so a re-rendering form does not re-instantiate the
formatter on every keystroke.
- The component is `forwardRef`-bound so consumers can focus / select
it from the parent (e.g. `useEffect(() => ref.current?.focus(), [])`
on mount).
- Currency display formatting always uses the i18n `Intl.NumberFormat`
defaults — to override fraction digits, wrap the component or use
the underlying `formatCurrency` utility directly inside a custom
primitive.

View File

@@ -0,0 +1,150 @@
# `createAsyncSearchForm`
Async-prefilled form recipe. Resolves an entity by `id` via the
supplied `load(id, signal)` callback, then mounts the underlying
`createDomainForm` with the resolved values as `defaultValues`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The canonical "edit by id" pattern: given a stable identifier (URL
slug, list-row click), fetch the underlying entity, then render a
form prefilled with the entity's current state. The recipe handles
the three-phase lifecycle (`loading`, `error`, `ready`) so consumers
do not have to wire `useEffect` + `AbortController` + `useState`
themselves on every page.
The `signal` passed to `load` is aborted on unmount AND when the
`id` prop changes mid-load, so a fast list of entity-edit pages
does not race stale fetches against the active form. The error
phase renders a translated `role="alert"` banner so screen readers
announce the failure.
## Features
- Three-phase lifecycle (`loading` → `ready` OR `loading` → `error`).
- AbortSignal threaded through `load(id, signal)`; auto-aborted on
unmount + id change.
- Default loading shell uses `react-ui`'s `<Skeleton>` so the
placeholder looks the same as every other loading region in the
app.
- Error region renders as `role="alert"` with translated text so
assistive tech announces the failure.
- Loading + error labels are TranslationKey-typed and overridable.
- Built on `createDomainForm` so the inner form inherits every
factory feature (escape hatches, layout, OTel, server-error
mapping).
## Usage: Basic Example
```tsx
import { createAsyncSearchForm } from "@suaveplan/react-forms/recipes/async-search";
import z from "zod";
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
const EditUserForm = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: { kind: "email", label: "user.email.label" as never },
name: { kind: "text", label: "user.name.label" as never },
},
load: async (id, signal) => {
const res = await fetch(`/api/users/${id}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as { email: string; name: string };
},
submit: {
label: "user.save" as never,
onSubmit: async (values) => {
await fetch(`/api/users/${id}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
},
});
export function UserEditPage(props: { readonly id: string }): JSX.Element {
return <EditUserForm id={props.id} />;
}
```
## Usage: Advanced Example
Provide a custom loading skeleton sized to the form's layout:
```tsx
import { Skeleton } from "@suaveplan/react-ui";
export function UserEditPageWithCustomLoader(props: {
readonly id: string;
}): JSX.Element {
return (
<EditUserForm
id={props.id}
loadingFallback={
<div style={{ display: "grid", gap: "1rem" }}>
<Skeleton style={{ height: "3rem" }} />
<Skeleton style={{ height: "3rem" }} />
<Skeleton style={{ height: "2rem", width: "30%" }} />
</div>
}
/>
);
}
```
## API Reference
### `createAsyncSearchForm(config)`
- `formId: string` — stable id for OTel attributes.
- `schema: ZodType<TValues>` — Zod schema spine for the inner form.
- `fields: FieldConfigMap<TValues>` — per-field config map.
- `load: (id: string, signal: AbortSignal) => Promise<TValues>` —
async loader; receives an AbortSignal that is aborted on unmount
- id change.
- `submit: { label: TranslationKey; cancelLabel?: TranslationKey; onSubmit: (values: TValues) => Promise<unknown> }` — submit
configuration mirroring `createDomainForm`'s shape.
- `labels?: { loading?: TranslationKey; error?: TranslationKey }` —
override the bundled defaults.
### `<AsyncSearchForm>` props
- `id: string` — entity identifier passed to `load`.
- `loadingFallback?: ReactNode` — replaces the default Skeleton shell.
- `className?: string` — class on the rendered container.
### `AsyncSearchPhase`
`"loading" | "error" | "ready"` — exported for tooling that wants
to observe the lifecycle.
## Implementation Notes
- The recipe constructs the inner `createDomainForm` lazily inside
`useMemo`, gated by `phase === "ready"`. This means the inner
form's hooks only mount once the data is available — so RHF's
`defaultValues` is the source of truth (it does not flicker
between empty and populated).
- An `AbortController` is created per id-prop change and stored in
the effect's cleanup so unmount + id change both abort the
signal cleanly. The `requestRef` guards against late-resolving
fetches that should not update state for a superseded id.
- The error region intentionally does not surface the underlying
error message — only a translated label. Internal failures
(`cause`) flow through error-tracking middleware (OTel + the
package's `recordSubmitError` helpers) so consumers debug via
observability sinks, not via user-facing strings.
- The recipe re-creates the inner form factory on every successful
load. This is OK because `createDomainForm` is cheap (it returns
a function component); the alternative — a single factory with
controlled defaultValues — fights RHF's `defaultValues` semantics.

View File

@@ -0,0 +1,122 @@
# `ConditionalField`
Show / hide form fields based on a predicate over the parent form's
values. When the gate flips closed the recipe unregisters the named
fields from RHF so their values disappear from the submit payload
and any prior errors are cleared.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conditional fields are the canonical pattern for "depending on the
selected payment method, show the bank account section" — fields
whose presence is a function of other field values. The recipe
subscribes to the parent form via RHF's `useWatch` and renders the
children only when the supplied `when(values)` predicate returns
true. When the predicate flips from `true` to `false` the named
fields are explicitly `unregister`ed so they do not pollute the
submitted payload AND any errors set on them are cleared so they do
not block submission.
The recipe MUST be rendered inside a `<FormProvider>` ancestor.
Missing-provider use cases throw `REACT_FORMS.CONTEXT_NOT_PROVIDED`.
## Features
- Predicate-driven conditional rendering with `useWatch`-based
subscription.
- Explicit `unregister` + `clearErrors` on close so the form state
matches the visible UI exactly.
- Optional `watch` array as a performance hint — the recipe only
re-evaluates the predicate when one of the listed fields changes.
- Children may be a static node OR a render-prop receiving the
current form values (for cheap derived UI).
- Throws on missing provider so misuse surfaces immediately.
## Usage: Basic Example
```tsx
import { ConditionalField } from "@suaveplan/react-forms/recipes/conditional";
interface CheckoutValues {
paymentMethod: "card" | "transfer";
bankAccount?: string;
cardNumber?: string;
}
function ConditionalSection(): JSX.Element {
return (
<ConditionalField<CheckoutValues>
when={(v) => v.paymentMethod === "transfer"}
names={["bankAccount"]}
watch={["paymentMethod"]}
>
<input name="bankAccount" />
</ConditionalField>
);
}
```
## Usage: Advanced Example
Use the render-prop form to show derived UI based on the current
form values:
```tsx
function PaymentSummary(): JSX.Element {
return (
<ConditionalField<CheckoutValues>
when={(v) => v.paymentMethod === "card"}
names={["cardNumber"]}
>
{(values) => (
<div data-card-summary>
Card ending in
{values.cardNumber?.slice(-4) ?? "----"}
</div>
)}
</ConditionalField>
);
}
```
## API Reference
### `<ConditionalField<TValues>>`
Props:
- `when: (values: TValues) => boolean` — predicate. Children render
only when this returns truthy.
- `names: ReadonlyArray<string>` — the names of the conditional
fields. Unregistered + cleared on close.
- `watch?: ReadonlyArray<string>` — fields to subscribe to. Performance
hint — when omitted, the recipe subscribes to the entire form.
- `children: ReactNode | ((values: TValues) => ReactNode)` — node or
render-prop callback.
## Implementation Notes
- The recipe uses `useWatch` to subscribe and `getValues()` to read
the current snapshot. `useWatch`'s subscription is what triggers
re-renders when a watched field changes; `getValues()` returns the
full current values bag so the predicate sees the up-to-date
picture.
- The recipe tracks the previous visibility via a `useRef` so the
unregister-on-close branch only fires on the actual transition,
not on every re-render while hidden.
- `unregister` is called per name to avoid the case where RHF batches
unregisters and one name's error survives. `clearErrors` is called
separately for the same reason.
- The recipe never registers fields itself — that is the consumer's
job (typically via `form.register(name)` inside the child node or
via a sibling `RenderField`). The recipe is purely an
unregister-on-close lifecycle wrapper.
- Children are wrapped in a Fragment in both branches (static and
render-prop) so the recipe contributes zero DOM nodes.
- The throw on missing `<FormProvider>` is intentional: silently
no-oping would let consumers ship forms whose conditional fields
never re-render. Surfacing the error at first mount is safer.

View File

@@ -0,0 +1,173 @@
# `createRepeatingFieldset`
Dynamic-array fieldset recipe backed by react-hook-form's
`useFieldArray`. Renders one row per array entry; add / remove
buttons throw `REACT_FORMS.REPEATING_ROW_LIMIT` when the operation
would breach the configured `minRows` / `maxRows` bounds.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Repeating fieldsets are the canonical pattern for any form whose
shape is "0..N entries of the same record" — contacts, line items,
addresses, attendees. The recipe wires `useFieldArray` against a
named array property on the parent form (the form created via
`createDomainForm`, `createWizardForm`, or a hand-rolled
`<FormProvider>`) and renders one labelled row per entry. Each row
is a small `FieldConfigMap` so all the supported field kinds are
available inside a row.
The recipe MUST be rendered inside a `<FormProvider>`. The factory's
`<DomainForm>` already wraps one; consumers using bespoke shells
must do the same. When the provider is missing the recipe throws
`REACT_FORMS.CONTEXT_NOT_PROVIDED` so the bug surfaces immediately
rather than producing a confusing null-context error inside RHF.
## Features
- Bound to RHF's `useFieldArray` for stable row keys, ordered
insertion / deletion, and integration with the parent form's
validation pass.
- `minRows` / `maxRows` bounds enforced at the call site —
disabled buttons are the soft gate, the throw is the hard gate.
- Semantic `<ul>` / `<li>` elements (with implicit `list` /
`listitem` roles) so screen readers announce the rows as a list.
- Children render-prop yields the rows array (with per-row context
including a `remove` callback) so consumers can build custom row
chrome (drag handles, summary panels, ...).
- Translated `Add` / `Remove` button labels with overrides via the
`labels` config.
## Usage: Basic Example
```tsx
import { createRepeatingFieldset } from "@suaveplan/react-forms/recipes/repeating-fieldset";
interface ContactRow {
readonly name: string;
readonly email: string;
}
const Contacts = createRepeatingFieldset<ContactRow>({
formId: "checkout",
name: "contacts",
minRows: 1,
maxRows: 5,
defaultRow: { name: "", email: "" },
fieldset: {
name: { kind: "text", label: "checkout.contact.name" as never },
email: {
kind: "email",
label: "checkout.contact.email" as never,
},
},
});
export function ContactsSection(): JSX.Element {
// Must be rendered inside a <FormProvider> ancestor.
return <Contacts />;
}
```
## Usage: Advanced Example
Render rows inside a custom drag-and-drop shell with a per-row
summary panel:
```tsx
export function CustomContacts(): JSX.Element {
return (
<Contacts>
{({ rows, add, canAdd }) => (
<div>
{rows.map((row) => (
<article
key={row.id}
data-draggable
data-row-index={row.index}
>
<header>Contact #{row.index + 1}</header>
{Object.values(row.fields)}
<button
type="button"
onClick={row.remove}
disabled={!row.canRemove}
>
Remove
</button>
</article>
))}
<button
type="button"
onClick={add}
disabled={!canAdd}
>
Add contact
</button>
</div>
)}
</Contacts>
);
}
```
## API Reference
### `createRepeatingFieldset(config)`
- `formId: string` — stable id for OTel attributes / DOM inspection.
- `name: string` — array field name on the parent form.
- `minRows?: number` — minimum row count enforced by `remove()`.
Defaults to `0`.
- `maxRows?: number` — maximum row count enforced by `add()`.
Defaults to `Infinity`.
- `defaultRow: TRow` — default values for a newly-added row.
- `fieldset: FieldConfigMap<TRow>` — per-row field config map.
- `labels?: { add?: TranslationKey; remove?: TranslationKey }` —
override the bundled defaults.
### `<RepeatingFieldset>` props
- `children?: (ctx) => ReactNode` — render-prop receiving
`{ rows, add, canAdd, fieldArray }`. When supplied, the default
shell is NOT rendered.
- `className?: string` — class on the rendered `<div>`.
### `RepeatingChildrenContext<TRow>` (passed to children)
- `rows: ReadonlyArray<RepeatingRowContext<TRow>>` — one entry per
row, each containing:
- `index: number`
- `id: string` — RHF's stable row id (use as React key).
- `fields: Readonly<Record<string, ReactElement>>` — pre-rendered
field elements for the row.
- `remove: () => void` — removes this row (throws on min-row
breach).
- `canRemove: boolean` — whether the soft gate would allow remove.
- `add: () => void` — appends a new `defaultRow` (throws on
max-row breach).
- `canAdd: boolean` — whether the soft gate would allow add.
- `fieldArray: UseFieldArrayReturn<...>` — raw RHF field-array
context for advanced operations (`move`, `swap`, `insert`,
`replace`).
## Implementation Notes
- The recipe uses RHF's `useFieldArray` with a stringly-typed name —
the upstream API is intentionally `string`-typed, so the recipe
passes through. TypeScript narrows the row shape at the consumer
level via the `<TRow>` generic.
- Throwing on `add()` past `maxRows` AND disabling the button is
intentional belt-and-braces: the disabled button is the affordance
for the typical user; the throw catches consumers who bypass the
affordance via the children render-prop.
- The default shell renders the add button inside the same `<ul>` as
the rows so the button shares the list semantics. Custom children
can render the button anywhere.
- Field configs are validated at module-eval time so a misconfigured
row never reaches a render.
- `data-row-index` and `data-row-action` attributes are stable DOM
contracts useful for E2E tests and CSS targeting.

View File

@@ -0,0 +1,176 @@
# `createWizardForm`
Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed
field-config map; navigating forward validates the current step
before advancing; navigating backward preserves entered data;
`onComplete` runs after the final step's validation succeeds.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wizards are the canonical pattern for forms that exceed a single
viewport — registration, checkout, multi-page surveys — where the
user benefits from chunked progress. The recipe wraps `useForm` from
react-hook-form with a small step-state machine; field validation
runs against the current step's sub-schema, and only the last step's
button is wired to `onComplete`. The merged values across all steps
flow into the `onComplete` callback as a single object so the
consumer never has to stitch step state together.
The recipe ships with a default chrome (Back / Next / Submit
buttons, vertical field stack), all wired to the bundled translation
keys (`react-forms.wizard.next` / `back` / `submit`). Consumers
override individual labels via the `labels` config option or replace
the entire chrome via the children render-prop.
## Features
- Per-step Zod schema enforced before forward navigation.
- Backward navigation preserves entered data — `useForm` state
spans every step.
- Default chrome with translated Back / Next / Submit buttons; final
step's button auto-flips from `Next` to `Submit`.
- `WizardState` exposed via the children render-prop for custom
progress UIs (`currentIndex`, `totalSteps`, `canGoBack`,
`canGoNext`, `isComplete`).
- `data-wizard-step` attribute on the rendered `<form>` so DOM
inspectors can identify the active step without subscribing to
React state.
- Per-step field-config validation at module-eval time — a
misconfigured step throws `REACT_FORMS.FIELD_KIND_INVALID` before
any render.
## Usage: Basic Example
```tsx
import { createWizardForm } from "@suaveplan/react-forms/recipes/wizard";
import z from "zod";
const SignUpWizard = createWizardForm({
formId: "signup",
steps: [
{
id: "credentials",
title: "auth.steps.credentials" as never,
schema: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
fields: {
email: { kind: "email", label: "auth.email.label" as never },
password: { kind: "password", label: "auth.password.label" as never },
},
},
{
id: "profile",
title: "auth.steps.profile" as never,
schema: z.object({ name: z.string().min(1) }),
fields: {
name: { kind: "text", label: "auth.name.label" as never },
},
},
],
defaultValues: { email: "", password: "", name: "" },
onComplete: async (values) => {
await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(values),
});
},
});
export function SignUpPage(): JSX.Element {
return <SignUpWizard />;
}
```
## Usage: Advanced Example
Replace the default chrome with a custom progress UI showing the
total step count and a localised label per step:
```tsx
export function SignUpPageWithProgress(): JSX.Element {
return (
<SignUpWizard>
{({ state, fields, goNext, goPrev, isSubmitting }) => (
<div>
<ol data-progress>
<li>
Step {state.currentIndex + 1} of {state.totalSteps}
</li>
</ol>
{Object.values(fields)}
<button
type="button"
onClick={goPrev}
disabled={!state.canGoBack || isSubmitting}
>
Back
</button>
<button
type="button"
onClick={() => void goNext()}
disabled={isSubmitting}
>
{state.canGoNext ? "Next" : "Submit"}
</button>
</div>
)}
</SignUpWizard>
);
}
```
## API Reference
### `createWizardForm(config)`
- `formId: string` — stable id for OTel attributes.
- `steps: WizardStep<TValues>[]` — at least one. Each:
- `id: string` — stable step identifier.
- `title: TranslationKey` — heading translation key.
- `schema: ZodType<Partial<TValues>>` — sub-schema for the step.
- `fields: FieldConfigMap<TValues>` — per-step field configs.
- `defaultValues?: Partial<TValues>` — initial values across steps.
- `labels?: { next?: TranslationKey; back?: TranslationKey; submit?: TranslationKey }` —
override the bundled defaults.
- `onComplete: (values: TValues) => Promise<unknown>` — invoked when
the final step validates successfully.
### `<WizardForm>` props
- `children?: (ctx) => ReactNode` — render-prop receiving
`{ form, fields, state, goNext, goPrev, isSubmitting }`.
- `className?: string` — class on the rendered `<form>`.
### `WizardState` (from the render-prop ctx)
- `currentIndex: number`
- `currentStep: { id: string }`
- `totalSteps: number`
- `canGoBack: boolean`
- `canGoNext: boolean`
- `isComplete: boolean`
## Implementation Notes
- The wizard validates ONLY the current step's slice of values — the
later steps are skipped. This means `onComplete` may be called
with values that fail a downstream sub-schema if a later step is
edited, then the user navigates back. Consumers should prefer
schemas where every step's sub-schema is a strict subset of the
full Zod schema.
- The merged values passed to `onComplete` are read via
`form.getValues()` — the RHF state is the single source of truth.
- The default chrome renders both buttons inside a
`<div data-wizard-nav>` so styling layers can target the navigation
block independently of the field stack.
- Throwing on zero steps is a hard contract — there is no defaultable
"empty wizard" UI worth shipping.
- Field-config validation runs once per step at module-eval time;
consumers cannot accidentally smuggle a deferred kind (`select`,
`date`, ...) into a step's config.

View File

@@ -0,0 +1,106 @@
# `async-email-unique.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Async validator factory that resolves to `{ valid: true }` when a
caller-supplied `check(email, signal)` predicate returns `true`
("the email is unique"), and to `{ valid: false, messageKey }` when
the predicate returns `false` ("the email is already registered").
The validator is the canonical pattern for "async field validation
that hits the network" without baking a transport into the package.
The factory wraps the supplied `check` in a `@suaveplan/timing.debounce`
window so a user typing characters fires at most one network call per
window (default 300 ms), and observes its `AbortSignal` argument so
in-flight checks for stale values do not overwrite the response for
the latest value.
## Features
- Caller-injected `check(email, signal): Promise<boolean>` — the
validator owns no transport, no fetch, no network primitive. Tests
pass a real function (no `vi.mock`).
- `AbortSignal` observation — when the signal aborts, the validator
resolves to a structured failure with
`messageKey === "react_forms.validation.async_aborted"`.
- Debounce window via `@suaveplan/timing.debounce` (`leading: false,
trailing: true`).
- In-flight dedup — when the same value is checked twice in quick
succession, the second call returns the in-flight promise rather
than starting a fresh debounce.
- OTel telemetry — every invocation emits
`react_forms.async_validator.duration_ms` and
`react_forms.async_validator.total{validator,outcome}`.
## Usage: Basic Example
```ts
import { asyncEmailUnique } from "@suaveplan/react-forms/validation/async";
const validateUnique = asyncEmailUnique({
check: async (email, signal) => {
const res = await fetch(`/api/users?email=${encodeURIComponent(email)}`, {
signal,
});
return res.status === 404; // 404 = not found = unique
},
});
await validateUnique("alice@example.com", new AbortController().signal);
// → { valid: true } if the email is unique
```
## Usage: Advanced Example
```ts
import { asyncEmailUnique } from "@suaveplan/react-forms/validation/async";
import type { TranslationKey } from "@suaveplan/react-i18n";
const cache = new Map<string, boolean>();
const validateUnique = asyncEmailUnique({
debounceMs: 500,
messageKey: "auth.email.taken" as TranslationKey,
check: async (email, signal) => {
const cached = cache.get(email);
if (cached !== undefined) return cached;
const res = await fetch(`/api/email/check?email=${email}`, {
signal,
});
const fresh = res.status === 404;
cache.set(email, fresh);
return fresh;
},
});
```
## API Reference
- `asyncEmailUnique(opts: AsyncEmailUniqueOptions):
AsyncValidator<string>` — factory.
- `interface AsyncEmailUniqueOptions {`
- `check: (email: string, signal: AbortSignal) => Promise<boolean>`
- `debounceMs?: number` — default `300`.
- `messageKey?: TranslationKey` — default
`react_forms.validation.email_taken`.
- `}`
## Implementation Notes
- The debounce uses `@suaveplan/timing.debounce` rather than `setTimeout`
directly because the convention is that all timing utilities
flow through `@suaveplan/timing` for dependency-injected `Clock`
testability.
- The `check` function receives the abort signal — implementations
that hit `fetch` should pass it through as `fetch(url, { signal })`
so the underlying request is cancelled on abort.
- The validator surfaces `AbortError` (the `DOMException` thrown by
`fetch` on abort) as a structured "aborted" failure rather than
re-throwing. Re-throwing would force the engine to try/catch every
validator call, which obscures normal-failure code paths.
- The `check` predicate can be sync-throwing or async-rejecting;
both paths converge on `{ valid: false, messageKey }` so the
validator never crashes the form.

View File

@@ -0,0 +1,89 @@
# `async-username-available.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Async validator factory that resolves to `{ valid: true }` when a
caller-supplied `check(username, signal)` predicate returns `true`
("the username is available"), and to
`{ valid: false, messageKey }` when it returns `false` ("the username
is already taken"). Mirrors `asyncEmailUnique` in shape — the split
exists so the failure key, OTel `validator` attribute, and dedup
namespace stay distinct between the two validators.
## Features
- Caller-injected `check(username, signal): Promise<boolean>` — no
baked-in transport.
- `AbortSignal` observation — aborts surface as
`react_forms.validation.async_aborted`.
- Debounce window via `@suaveplan/timing.debounce`
(`leading: false, trailing: true`, default 300 ms).
- In-flight dedup keyed on the username value.
- OTel telemetry —
`react_forms.async_validator.duration_ms{validator="asyncUsernameAvailable"}`
histogram and
`react_forms.async_validator.total{validator,outcome}` counter.
## Usage: Basic Example
```ts
import { asyncUsernameAvailable } from "@suaveplan/react-forms/validation/async";
const validate = asyncUsernameAvailable({
check: async (username, signal) => {
const res = await fetch(
`/api/usernames/${encodeURIComponent(username)}`,
{ signal }
);
return res.status === 404; // 404 = available
},
});
await validate("alice", new AbortController().signal);
// → { valid: true }
```
## Usage: Advanced Example
```ts
import { asyncUsernameAvailable } from "@suaveplan/react-forms/validation/async";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validate = asyncUsernameAvailable({
debounceMs: 500,
messageKey: "auth.username.taken" as TranslationKey,
check: async (username, signal) => {
if (username.length < 3) return false;
return await fetch(`/api/usernames/${username}`, {
signal,
}).then((r) => r.status === 404);
},
});
```
## API Reference
- `asyncUsernameAvailable(opts: AsyncUsernameAvailableOptions):
AsyncValidator<string>` — factory.
- `interface AsyncUsernameAvailableOptions {`
- `check: (username: string, signal: AbortSignal) => Promise<boolean>`
- `debounceMs?: number` — default `300`.
- `messageKey?: TranslationKey` — default
`react_forms.validation.username_taken`.
- `}`
## Implementation Notes
- The validator deliberately namespaces its dedup key (`username::<v>`)
so it can co-exist with `asyncEmailUnique` (which uses `email::<v>`)
in the same form's validation engine without one stomping on the
other.
- The debounce uses `@suaveplan/timing.debounce` for the same reasons
as `asyncEmailUnique` — clock injectability via the timing
primitive.
- The factory does NOT enforce a minimum username length; layer
`minLength` via `sequence` if you want the check to skip until the
user has typed enough characters.

View File

@@ -0,0 +1,117 @@
# `compose.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- or — OR-semantics composer for the validator suite.…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- or — OR-semantics composer for the validator suite.…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- or — OR-semantics composer for the validator suite.…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
## Purpose
`compose` (AND semantics) — combine multiple validators into a single
validator that passes only when every input validator passes. Failures
short-circuit on the first failing validator and the composed
validator returns that validator's `messageKey` so the user sees the
single most specific error rather than a fan-out of all violations.
The same module exports `runChain`, the chain-runner used by both
`compose` and the `or` composer in the sibling module. Centralising
the chain logic here keeps the AND/OR/SEQUENCE composers thin and
guarantees consistent sync-vs-async dispatch semantics across all
three.
## Features
- AND semantics: short-circuits on first failure.
- Sync if every input validator is sync; async if any input is async.
- Generic over the value type `T`.
- `runChain` exported as the shared engine for related composers.
## Usage: Basic Example
```ts
import { compose } from "@suaveplan/react-forms/validation/compose";
import {
minLength,
pattern,
} from "@suaveplan/react-forms/validation/validators";
const validateSlug = compose(
minLength(3),
pattern(/^[a-z0-9-]+$/)
);
validateSlug("abc"); // → { valid: true }
validateSlug("ab"); // → { valid: false, messageKey: "react_forms.validation.min_length" }
validateSlug("ABC"); // → { valid: false, messageKey: "react_forms.validation.pattern" }
```
## Usage: Advanced Example
```ts
import { compose } from "@suaveplan/react-forms/validation/compose";
import {
custom,
email,
required,
} from "@suaveplan/react-forms/validation/validators";
const validateEmail = compose(
required("auth.email.required" as TranslationKey),
email("auth.email.invalid" as TranslationKey),
custom(
async (v: string) => Promise.resolve(!v.endsWith("@temp.com")),
"auth.email.disposable" as TranslationKey
)
);
await validateEmail("alice@temp.com", new AbortController().signal);
// → { valid: false, messageKey: "auth.email.disposable" }
```
## API Reference
- `compose<T>(...validators: Validator<T>[]): Validator<T>` — sync
overload.
- `compose<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
AsyncValidator<T>` — mixed overload returning an async validator.
- `runChain<T>(validators, value, signal, mode)` — internal chain-
runner exported for use by sibling composers (`or`, `sequence`).
- `mode: "and" | "or"` — combinator semantics.
## Implementation Notes
- The chain-runner uses an iterative `step()` function rather than a
reducer so the sync path stays sync — `Array.prototype.reduce`
cannot represent a partial early-return cleanly while preserving
sync-vs-async narrowing.
- The runner uses an injected `AbortSignal` (or a fresh one when none
is supplied) for every async validator invocation. This guarantees
every async validator sees a signal, so a validator that ignores the
argument on the assumption "the caller will handle abort" never
silently leaks a hanging promise.
- `runChain` is exported only as an internal helper for the sibling
composers — it is NOT re-exported from the package barrel because
consumers should always go through the named composer (`compose`,
`or`, `sequence`).

View File

@@ -0,0 +1,74 @@
# `or.ts`
## Purpose
OR-semantics composer for the validator suite. Returns a validator
that passes when any of the input validators passes. Successes short-
circuit on the first match (so cheap validators should come first); if
every validator fails the LAST failure is returned, which gives the
caller the most specific error message rather than the most generic.
## Features
- OR semantics: short-circuits on first success.
- Sync if every input is sync; async if any input is async.
- Generic over the value type `T`.
- Empty chain returns `{ valid: true }` (vacuous truth — OR-of-zero
is identity-true; treat as a no-op).
## Usage: Basic Example
```ts
import { or } from "@suaveplan/react-forms/validation/compose";
import {
email,
url,
} from "@suaveplan/react-forms/validation/validators";
const validateContact = or(email(), url({ protocols: ["https"] }));
validateContact("alice@example.com"); // → { valid: true }
validateContact("https://alice.example"); // → { valid: true }
validateContact("nope"); // → { valid: false, messageKey: "react_forms.validation.url" }
```
## Usage: Advanced Example
```ts
import { or } from "@suaveplan/react-forms/validation/compose";
import {
custom,
email,
} from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const looksLikeUsername = custom<string>(
(s) => /^[a-z0-9_-]{3,30}$/i.test(s),
"form.username.invalid" as TranslationKey
);
const emailOrUsername = or(email(), looksLikeUsername);
emailOrUsername("alice"); // → { valid: true }
emailOrUsername("alice@example.com"); // → { valid: true }
emailOrUsername("!!!"); // → { valid: false, messageKey: "form.username.invalid" }
```
## API Reference
- `or<T>(...validators: Validator<T>[]): Validator<T>` — sync
overload.
- `or<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
AsyncValidator<T>` — mixed overload returning an async validator.
## Implementation Notes
- The runner returns the LAST failure (rather than the first or all of
them) because the convention in industry-standard form libraries
(Yup, Formik) is "show the most specific error, which is usually
the most recently checked".
- Cheap validators come first — `or(email(), expensiveAsyncCheck())`
short-circuits on the cheap email match in the common case and only
invokes the async check when the cheap one rejects.
- The chain runner is shared with `compose` (AND); only the `mode`
argument differs.

View File

@@ -0,0 +1,85 @@
# `sequence.ts`
## Purpose
Short-circuit AND composer with explicit ordering semantics. Identical
to `compose` in result — every validator must pass for the composed
validator to pass, and the first failure short-circuits — but the
order in which validators are passed is deliberately meaningful: cheap
synchronous validators come first, expensive async validators come
last. Use `sequence` whenever the order matters; use `compose` when
order is incidental.
The split (rather than just an alias) exists because:
- Naming the intent at the call site (`sequence(required, email,
asyncCheck)`) communicates "run these in order" to the next
developer reading the code, where `compose` reads as "combine
these by AND".
- Future runtime hooks (e.g. tracing-per-step latency, ordered failure
metrics) will land on `sequence` because it carries an explicit
ordering contract; `compose` is order-agnostic so the same hook on
`compose` would be misleading.
## Features
- AND semantics with documented ordering contract.
- Sync if every input is sync; async if any input is async.
- Generic over the value type `T`.
- Empty chain returns `{ valid: true }`.
## Usage: Basic Example
```ts
import { sequence } from "@suaveplan/react-forms/validation/compose";
import {
email,
required,
} from "@suaveplan/react-forms/validation/validators";
// `required` runs first (cheap), `email` runs only when required passed.
const validateEmail = sequence(
required("auth.email.required" as TranslationKey),
email("auth.email.invalid" as TranslationKey)
);
```
## Usage: Advanced Example
```ts
import { sequence } from "@suaveplan/react-forms/validation/compose";
import {
custom,
email,
required,
} from "@suaveplan/react-forms/validation/validators";
const validateEmail = sequence(
required("auth.email.required" as TranslationKey),
email("auth.email.invalid" as TranslationKey),
custom(
async (v: string) => Promise.resolve(!v.endsWith("@temp.com")),
"auth.email.disposable" as TranslationKey
)
);
await validateEmail("alice@temp.com", new AbortController().signal);
// → { valid: false, messageKey: "auth.email.disposable" }
```
## API Reference
- `sequence<T>(...validators: Validator<T>[]): Validator<T>` — sync
overload.
- `sequence<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
AsyncValidator<T>` — mixed overload returning an async validator.
## Implementation Notes
- The implementation delegates to `runChain` from `compose.ts` with
`mode: "and"`. The split is API-level only — there is one
short-circuit AND engine.
- Putting cheap validators first matters because async validators
invariably hit the network. A `sequence` that re-orders them at
runtime would silently fire network I/O the caller did not intend;
the convention is "preserve insertion order, full stop".

View File

@@ -0,0 +1,88 @@
# `shared.ts`
## Purpose
Tiny internal helper module shared by every composer in
`validation/compose`. Centralises the `Promise` discrimination check
so a future change to "what counts as async" only happens in one place.
Keeping the helper here (rather than in `compose.ts`) lets `or.ts` and
`sequence.ts` import it without pulling in the AND-combinator code or
risking circular imports.
## Features
- `isAsyncResult(out)` — type-narrowing predicate that tells
TypeScript whether a `ValidatorResult | Promise<ValidatorResult>` is
the async branch.
- Cross-realm safe — uses `instanceof Promise` rather than duck-typing
on `.then` so monkey-patched `Promise.resolve` shims do not produce
false positives.
## Usage: Basic Example
```ts
import { isAsyncResult } from "./shared.js";
const out: ValidatorResult | Promise<ValidatorResult> = validator(value);
if (isAsyncResult(out)) {
out.then((res) => handle(res));
} else {
handle(out);
}
```
## Usage: Advanced Example
```ts
import { isAsyncResult } from "./shared.js";
function reduceChain<T>(
chain: ReadonlyArray<Validator<T> | AsyncValidator<T>>,
value: T,
signal: AbortSignal
): ValidatorResult | Promise<ValidatorResult> {
let acc: ValidatorResult | Promise<ValidatorResult> = { valid: true };
for (const v of chain) {
if (isAsyncResult(acc)) {
acc = acc.then((prev) =>
prev.valid ? v(value, signal) : prev
);
} else if (!acc.valid) {
return acc;
} else {
acc = v(value, signal);
}
}
return acc;
}
```
## API Reference
- `isAsyncResult(out: ValidatorResult | Promise<ValidatorResult>): out
is Promise<ValidatorResult>` — narrows the union when the value is a
`Promise`.
## Implementation Notes
- The `instanceof Promise` check is the only safe cross-realm
discriminator JavaScript gives us. Duck-typing on `.then` is unsafe
because plain objects with a `.then` member ("thenables") would also
match — that's how callers regularly leak coroutine-style state into
validators that the chain runner would mistake for async results.
- The helper is not part of the package's public surface; it is
internal-only and not re-exported from any barrel — the composers
(`compose`, `or`, `sequence`) are the only call sites and they all
go through `runChain` from `compose.ts`.
- The two-line predicate is intentionally tiny so it inlines into the
caller after the JIT warms up. There is no real-world performance
cost to wrapping every result through this guard, and the cost of
a wrong discrimination (an async result mistakenly handled as sync)
is a missed Promise resolution, which is far worse than the cost
of the function call.
- For micro-tasks where every cycle matters, callers writing tight
loops over many validators could short-circuit by checking
`isAsyncResult` once on the first result and assuming subsequent
results follow the same shape — but the chain runner deliberately
does NOT make this assumption because mixing sync + async in a
composition pipeline is the common case, not an edge case.

View File

@@ -0,0 +1,78 @@
# `bic.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string matches the
ISO 9362 Business Identifier Code (BIC, also known as SWIFT code)
format. The structure is fixed: 4-letter institution code +
2-letter ISO 3166-1 country code + 2-character location code +
optional 3-character branch code, totalling 8 or 11 alphanumeric
characters. There is no checksum.
The default failure message resolves the `TranslationKey`
`react_forms.validation.bic`.
## Features
- Whitespace-tolerant — `"DEUT DEFF"` and `"DEUTDEFF"` both pass.
- Case-insensitive — uppercased before matching.
- Length corridor 8/11 — values of any other length fail before regex
evaluation.
- Pure-JS regex — no runtime dependency.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { bic } from "@suaveplan/react-forms/validation/validators";
const validate = bic();
validate("DEUTDEFF"); // { valid: true }
validate("DEUTDEFF500"); // { valid: true } — branch-coded
validate("12345678"); // { valid: false } — first 4 chars must be letters
```
## Usage: Advanced Example
```ts
import { bic } from "@suaveplan/react-forms/validation/validators";
import { sequence } from "@suaveplan/react-forms/validation/compose";
import { required } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validateBic = sequence(
required("payment.bic.required" as TranslationKey),
bic("payment.bic.invalid" as TranslationKey)
);
```
## API Reference
- `bic(messageKey?: TranslationKey): Validator` — factory.
## Implementation Notes
- The regex enforces the ISO 9362 partitioning explicitly so a
superficially-correct string like `"12345678"` fails (the institution
code MUST be alphabetic).
- The country-code segment accepts any 2-letter combination —
enforcing it against the ISO 3166-1 list would require shipping the
list (changes ~1×/year) inside the package; consumers needing strict
country validation should layer a separate check on the slice
`value.slice(4, 6)` against their preferred country registry.
- The location-code and branch-code segments accept alphanumerics per
the spec (`[A-Z0-9]`) — letters and digits are equally legal.
- Compared with the IBAN validator's mod-97 checksum, BIC has no
cryptographic-style integrity check; a malformed-but-syntax-correct
BIC will still pass and only fail at the receiving bank. Consumers
routing payments should layer a directory lookup on top of the BIC
validator for high-value transactions.
- Whitespace stripping is applied before length checking so a value
formatted with the spec-recommended 4/2/2/3 spacing (e.g.
`"DEUT DE FF 500"`) matches the same regex as the contiguous form.
- The validator is dependency-free: a flat regex plus length corridor
is the entire implementation, which keeps bundle size negligible
and makes the rule auditable from a single 30-line source file.

View File

@@ -0,0 +1,82 @@
# `credit-card.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string is a
syntactically valid credit-card number per the Luhn checksum algorithm
(ISO/IEC 7812-1). The validator strips whitespace and hyphens before
running Luhn so user-friendly formats like `4111-1111-1111-1111` and
`4111 1111 1111 1111` are accepted alongside the contiguous
`4111111111111111` form.
The default failure message resolves the `TranslationKey`
`react_forms.validation.credit_card`.
## Features
- ISO/IEC 7812-1 length corridor (12–19 digits) checked before the
checksum.
- Whitespace and hyphens stripped from the input.
- Pure-JS Luhn implementation — no runtime dependency, no PCI scope
expansion.
- The Luhn helper (`luhnValid(digits: string)`) is exported so callers
can use it standalone — e.g. for a related ID format that also uses
the algorithm (IMEI, ICCID, NPI for US healthcare).
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { creditCard } from "@suaveplan/react-forms/validation/validators";
const validate = creditCard();
validate("4111 1111 1111 1111"); // { valid: true } — Luhn-valid Visa test card
validate("4111-1111-1111-1112"); // { valid: false } — Luhn fails
```
## Usage: Advanced Example
```ts
import {
creditCard,
luhnValid,
} from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validateCard = creditCard(
"checkout.card.invalid" as TranslationKey
);
// Reuse the helper for IMEI validation
const isValidImei = (imei: string) =>
/^\d{15}$/.test(imei) && luhnValid(imei);
```
## API Reference
- `creditCard(messageKey?: TranslationKey): Validator` — factory.
- Returns a validator that passes on Luhn-valid 12–19 digit strings,
fails otherwise.
- `luhnValid(digits: string): boolean` — pure Luhn helper. Accepts a
digits-only string and returns `true` when the checksum is satisfied.
Does NOT validate length or strip non-digits — call sites should
pre-process.
## Implementation Notes
- The Luhn implementation iterates from the rightmost digit, doubles
every second digit, and subtracts 9 when the doubled value exceeds
9. The character-code arithmetic (`charCode - 48`) avoids
`parseInt` allocation per digit and is roughly 4× faster on hot
paths.
- Brand-prefix rules (Visa starts with 4, MasterCard 51-55, etc.) are
intentionally NOT enforced because card networks add new brands
routinely and BIN ranges are owned by ISO/IEC 7812-1 issuers, not
the form library. Consumers wanting brand detection should layer a
separate library on top after Luhn passes.
- The validator does not log, store, or transmit the card number;
values stay on the caller's stack frame and are eligible for
garbage collection immediately after the validator returns.

View File

@@ -0,0 +1,92 @@
# `custom.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Adapter that wraps a caller-supplied boolean predicate into a
`Validator` (or `AsyncValidator`) returning the canonical
`{ valid, messageKey }` discriminated union. The caller writes a
plain predicate that returns `true` for "valid" and `false` for
"invalid"; `custom` translates that into the validator-suite return
shape so the predicate composes uniformly with every other validator
in the package.
The wrapper detects synchronous-vs-asynchronous at call time: if the
predicate returns a `Promise`, the wrapper returns a `Promise<ValidatorResult>`;
otherwise it returns the result synchronously. The TypeScript overloads
declare the two return shapes so callers get the right type back.
## Features
- Two overloads — pure-sync (`(value) => boolean`) → `Validator`, and
the union form (`(value) => boolean | Promise<boolean>`) →
`AsyncValidator`.
- Generic over the value type `T` so the predicate body has its
argument fully typed.
- `messageKey` is required and typed as `TranslationKey` — there is no
default key since custom predicates are opaque to the package.
## Usage: Basic Example
```ts
import { custom } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const isEven = custom<number>(
(n) => n % 2 === 0,
"form.must_be_even" as TranslationKey
);
isEven(4); // → { valid: true }
isEven(7); // → { valid: false, messageKey: "form.must_be_even" }
```
## Usage: Advanced Example
```ts
import { custom } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
// Async predicate against a shared cache
const cache = new Map<string, boolean>();
const inAllowlist = custom<string>(
async (handle) => {
const cached = cache.get(handle);
if (cached !== undefined) return cached;
const fresh = await fetch(`/api/allowlist/${handle}`).then((r) =>
r.ok
);
cache.set(handle, fresh);
return fresh;
},
"auth.handle.not_allowed" as TranslationKey
);
await inAllowlist("alice"); // → { valid: true }
```
## API Reference
- `custom<T>(predicate: (value: T) => boolean, messageKey:
TranslationKey): Validator<T>` — synchronous overload.
- `custom<T>(predicate: (value: T) => boolean | Promise<boolean>,
messageKey: TranslationKey): AsyncValidator<T>` — async overload.
- `type CustomPredicate<T> = (value: T) => boolean | Promise<boolean>`.
## Implementation Notes
- The function-body `if (out instanceof Promise)` discriminator is the
same shape every "sometimes-sync" wrapper in JS uses (see RxJS,
effect-ts). It avoids forcing every caller into `async`-await even
for cheap predicates.
- The `as Validator<T> | AsyncValidator<T>` cast in the implementation
body is a deliberate widening — the public overloads pin the
precise return type per call site, but the implementation must
satisfy both signatures simultaneously.
- `messageKey` has no default because the predicate's failure
semantics are opaque to the package — there is no useful generic key
the package could supply that the consumer's catalog would also
carry.

View File

@@ -0,0 +1,78 @@
# `email.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied value is a
syntactically valid email address per the practical RFC 5322 subset
documented in the source. The pattern accepts the 99.9% of email
addresses real users actually type — local parts of unreserved
characters separated by dots, a domain of dot-separated alphanumeric
labels, and a TLD of at least two ASCII letters — while rejecting
exotic forms (quoted local parts, bracket-IP literals,
internationalised domain names in their punycoded form, comments) that
no real-world signup form should accept.
The default failure message resolves the `TranslationKey`
`react_forms.validation.email`.
## Features
- Length-bounded — rejects inputs above the 254-byte RFC-defined limit
before regex evaluation, both for correctness and for ReDoS
resistance.
- Local-part length capped at 64 chars per the RFC.
- Domain labels enforced to start and end with an alphanumeric and to
be ≤ 63 chars each.
- TLD enforced to ≥ 2 ASCII letters so common typos like `foo@bar.x`
fail.
- Pure factory — the returned validator carries no state.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { email } from "@suaveplan/react-forms/validation/validators";
const validate = email();
validate("alice@example.com"); // { valid: true }
validate("not-an-email"); // { valid: false, messageKey: "react_forms.validation.email" }
```
## Usage: Advanced Example
```ts
import { email } from "@suaveplan/react-forms/validation/validators";
import { sequence } from "@suaveplan/react-forms/validation/compose";
import { required } from "@suaveplan/react-forms/validation/validators";
const validateEmail = sequence(
required("auth.email.required" as TranslationKey),
email("auth.email.invalid" as TranslationKey)
);
validateEmail(""); // → { valid: false, messageKey: "auth.email.required" }
validateEmail("nope"); // → { valid: false, messageKey: "auth.email.invalid" }
validateEmail("alice@example.com"); // → { valid: true }
```
## API Reference
- `email(messageKey?: TranslationKey): Validator` — factory. Returns a
validator that passes on valid email syntax, fails otherwise.
## Implementation Notes
- The pattern is split into three string fragments (`EMAIL_LOCAL`,
`EMAIL_LABEL`, `EMAIL_DOMAIN`) and assembled with `new RegExp` so the
RFC-derived structure is auditable from the source diff.
- ReDoS exposure is bounded by the 254-byte length cap and the
non-backtracking structure of the alternation (no nested quantifiers
on overlapping character classes).
- Non-string inputs ALWAYS fail — pair with `required` if you need the
presence check to come first with its own message.
- Internationalised email addresses (IDN domains) must be punycoded
before validation. The factory does NOT perform automatic conversion
to keep the validator deterministic and dependency-free.

View File

@@ -0,0 +1,84 @@
# `iban.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string is a
syntactically valid International Bank Account Number per ISO 13616.
Implements the full mod-97 checksum: country code + check digits move
to the end, every letter expands to its two-digit numeric equivalent
(A=10..Z=35), and the resulting integer mod 97 must equal 1.
The default failure message resolves the `TranslationKey`
`react_forms.validation.iban`.
## Features
- Whitespace-tolerant — `"GB29 NWBK 6016 1331 9268 19"` and
`"GB29NWBK60161331926819"` both pass.
- Case-insensitive — uppercased before mod-97.
- Length corridor 15–34 (the per-country IBAN length range; we don't
enforce per-country length since the country registry changes too
often to bake in).
- Streaming mod-97 implementation that avoids BigInt allocation:
process 9 digits at a time so the running integer stays under
`Number.MAX_SAFE_INTEGER`.
- Helpers `expandLettersToDigits` and `mod97` exported so callers can
reuse the primitives in related ID validators (e.g. national
fiscal-ID checksums that share the mod-97 structure).
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { iban } from "@suaveplan/react-forms/validation/validators";
const validate = iban();
validate("GB29 NWBK 6016 1331 9268 19"); // { valid: true }
validate("GB29 NWBK 6016 1331 9268 18"); // { valid: false } — checksum fails
```
## Usage: Advanced Example
```ts
import {
iban,
mod97,
expandLettersToDigits,
} from "@suaveplan/react-forms/validation/validators";
const validateIban = iban("payment.iban.invalid" as TranslationKey);
// Reuse the helpers for related checksums
function isValidIsin(isin: string) {
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return false;
const digits = expandLettersToDigits(isin);
return mod97(digits) === 1;
}
```
## API Reference
- `iban(messageKey?: TranslationKey): Validator` — factory.
- `expandLettersToDigits(input: string): string` — replace each letter
with its two-digit numeric equivalent (A=10..Z=35) and pass digits
through unchanged.
- `mod97(digits: string): number` — streaming mod-97 over a digit
string.
## Implementation Notes
- The streaming mod-97 algorithm is the standard ISO/IEC 7064 trick
for modular reduction over arbitrary-length integers without BigInt.
Concatenating the running remainder with the next 9-digit chunk and
parsing the combined integer keeps the value under 2^53 at every
step.
- Per-country length tables (BBAN length per ISO 13616) are
intentionally omitted because the SWIFT registry adds and changes
countries multiple times per year. Callers requiring strict per-
country length checks can layer `pattern` on top.
- The validator does not log, store, or transmit the IBAN; values
stay on the caller's stack frame and are eligible for garbage
collection immediately after the validator returns.

View File

@@ -0,0 +1,70 @@
# `max-length.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied value's length is
less than or equal to `n`. Operates on strings (UTF-16 code-unit
length, same metric as `String.prototype.length`) and arrays. Other
shapes — `null`, `undefined`, plain objects, numbers — pass because
length is meaningless for them; pair this validator with `required` to
enforce presence.
The default failure message resolves the `TranslationKey`
`react_forms.validation.max_length` and carries
`messageArgs: { max, actual }` so consumer catalogs can interpolate the
limits into a localised error string.
## Features
- Accepts `string` and `Array<unknown>`; passes silently for shapes
with no measurable length so `maxLength` is composable with
type-permissive Zod schemas.
- Throws `RangeError` from the factory when `n` is not a non-negative
integer — invalid configuration is loud, not silent.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { maxLength } from "@suaveplan/react-forms/validation/validators";
const validate = maxLength(10);
validate("short"); // { valid: true }
validate("this is too long"); // { valid: false, messageKey, messageArgs: { max: 10, actual: 16 } }
```
## Usage: Advanced Example
```ts
import { maxLength } from "@suaveplan/react-forms/validation/validators";
import { compose } from "@suaveplan/react-forms/validation/compose";
import { minLength } from "@suaveplan/react-forms/validation/validators";
const validateBio = compose(
minLength(20, "bio.too_short" as TranslationKey),
maxLength(280, "bio.too_long" as TranslationKey)
);
```
## API Reference
- `maxLength(n: number, messageKey?: TranslationKey): Validator` —
factory.
- Throws `RangeError` if `n` is not a non-negative integer.
- Returns a validator that yields `{ valid: true }` on pass and
`{ valid: false, messageKey, messageArgs: { max, actual } }` on
failure.
## Implementation Notes
- The factory eagerly validates `n` so misconfiguration surfaces at
module evaluation rather than on first form interaction.
- Unsupported shapes pass instead of failing (the inverse of
`minLength`'s policy) because a max-length constraint on a value
without a length is semantically vacuous — `[1,2,3]` violates "max
length 2" but `42` does not.
- For grapheme-aware counting, callers compose
`custom((v) => Array.from(new Intl.Segmenter().segment(v)).length <= n)`.

View File

@@ -0,0 +1,70 @@
# `min-length.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied value's length is
greater than or equal to `n`. Operates on strings (UTF-16 code-unit
length, same as `String.prototype.length`) and arrays. Other shapes —
`null`, `undefined`, plain objects, `Map`, `Set`, numbers — fail
because length has no meaning for them; pair this validator with
`required` when nullable inputs are legal.
The default failure message resolves the `TranslationKey`
`react_forms.validation.min_length` and carries
`messageArgs: { min, actual }` so consumer catalogs can interpolate the
limits into a localised error string.
## Features
- Accepts `string` and `Array<unknown>` inputs; rejects every other
shape with a deterministic failure that still carries the requested
`min` so the UI can render a consistent message.
- Throws `RangeError` from the factory when `n` is not a non-negative
integer — invalid configuration is loud, not silent.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { minLength } from "@suaveplan/react-forms/validation/validators";
const validate = minLength(8);
validate("password"); // { valid: true }
validate("short"); // { valid: false, messageKey, messageArgs: { min: 8, actual: 5 } }
```
## Usage: Advanced Example
```ts
import { minLength } from "@suaveplan/react-forms/validation/validators";
const validateTags = minLength(2, "tags.too_few" as TranslationKey);
validateTags(["one"]);
// → {
// valid: false,
// messageKey: "tags.too_few",
// messageArgs: { min: 2, actual: 1 },
// }
```
## API Reference
- `minLength(n: number, messageKey?: TranslationKey): Validator` —
factory.
- Throws `RangeError` if `n` is not a non-negative integer.
- Returns a validator that yields `{ valid: true }` on pass and
`{ valid: false, messageKey, messageArgs: { min, actual } }` on
failure.
## Implementation Notes
- The factory eagerly validates `n` so misconfiguration surfaces at
module evaluation rather than on first form interaction.
- `lengthOf` returns `undefined` for unsupported shapes, which the
caller maps to `actual: 0`. This preserves a stable failure shape
while signalling that the input had no measurable length.
- For grapheme-aware counting, callers compose
`custom((v) => Array.from(new Intl.Segmenter().segment(v)).length >= n)`.

View File

@@ -0,0 +1,94 @@
# `password-strength.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied password scores at
or above a configured threshold on the zxcvbn 0–4 scale, as computed
by `@zxcvbn-ts/core`. zxcvbn is the de-facto standard for password
strength estimation — it accounts for keyboard patterns, dictionary
matches, leet-speak substitutions, l33t-shifts, repeated characters,
and date-pattern guesses, producing a score that maps directly to
"how many guesses to crack offline".
The default failure message resolves the `TranslationKey`
`react_forms.validation.password_strength` and carries `messageArgs:
{ score, minScore }` so the consumer's catalog can render strength-
specific copy ("too short", "too common", "almost there", ...).
## Features
- Forwards to `@zxcvbn-ts/core`'s `zxcvbn` function — full strength
estimation with no extra dependencies.
- Configurable `minScore` (`0|1|2|3|4`); default `3` (safely
unguessable, the modern consumer-app baseline).
- Failure exposes both `score` and `minScore` so dashboard / UI copy
can show a strength bar without re-running the estimator.
- Optional `messageKey` typed as `TranslationKey`.
- Dictionary-free at the package level — consumer apps register
English/language wordlists at app boot via
`zxcvbnOptions.setOptions(...)`. This keeps the package lean and
avoids forcing every consumer to ship the same ~50 KB of wordlists.
## Usage: Basic Example
```ts
import { passwordStrength } from "@suaveplan/react-forms/validation/validators";
const validate = passwordStrength();
validate("password"); // { valid: false, score: 0, minScore: 3 }
validate("9!ApricotZebra-2024"); // { valid: true }
```
## Usage: Advanced Example
```ts
// In your app's bootstrap module — register dictionaries once.
import { zxcvbnOptions } from "@zxcvbn-ts/core";
import { dictionary, adjacencyGraphs } from "@zxcvbn-ts/language-en";
zxcvbnOptions.setOptions({
dictionary,
graphs: adjacencyGraphs,
translations: zxcvbnOptions.translations,
});
// In your form definition — use the validator with a strict threshold.
import { passwordStrength } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validate = passwordStrength({
minScore: 4,
messageKey: "auth.password.weak" as TranslationKey,
});
```
## API Reference
- `passwordStrength(opts?: PasswordStrengthOptions): Validator` —
factory.
- `opts.minScore?: 0|1|2|3|4` — minimum acceptable score. Default 3.
- `opts.messageKey?: TranslationKey` — message key on failure.
- `interface PasswordStrengthOptions { minScore?: PasswordStrengthScore;
messageKey?: TranslationKey }`.
- `type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4` — the zxcvbn score
scale. Re-exported so consumers building strength-bar UIs can pin
their own discriminated unions.
## Implementation Notes
- The validator never calls `zxcvbnOptions.setOptions` — that is the
consumer's responsibility at app bootstrap so dictionaries (which are
large) are registered once globally.
- Empty strings always fail with `score: 0` before invoking the
estimator. Non-string inputs do the same.
- `@zxcvbn-ts/core` is intentionally a `dependencies` rather than a
`peerDependencies` entry: forms cannot enforce the consumer registers
language packs, but the core estimator is required for the default
validator to work at all (it returns score 0 with no dictionary,
which still rejects most weak passwords).
- For asynchronous strength estimation (e.g. against a server-side
HIBP-style breach corpus), wrap `zxcvbnAsync` from `@zxcvbn-ts/core`
in a `custom` async validator.

View File

@@ -0,0 +1,71 @@
# `pattern.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string matches a
caller-supplied `RegExp`. The factory clones the regex without `g`/`y`
flags so `RegExp.prototype.test` cannot leak `lastIndex` state between
invocations — a footgun that breaks roughly half of all user-written
form validators when the same validator is called twice.
The default failure message resolves the `TranslationKey`
`react_forms.validation.pattern`.
## Features
- Strips `g` and `y` flags so `test()` is stateless across calls.
- Throws `TypeError` from the factory when the supplied argument is
not a `RegExp` — invalid configuration is loud, not silent.
- Optional `messageKey` typed as `TranslationKey`.
- Non-string inputs always fail; pair with `required` for presence.
## Usage: Basic Example
```ts
import { pattern } from "@suaveplan/react-forms/validation/validators";
const validateSlug = pattern(/^[a-z0-9-]+$/);
validateSlug("hello-world"); // { valid: true }
validateSlug("Hello World"); // { valid: false, messageKey: "react_forms.validation.pattern" }
```
## Usage: Advanced Example
```ts
import { pattern } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validateZip = pattern(
/^\d{5}(?:-\d{4})?$/,
"address.zip.invalid" as TranslationKey
);
validateZip("90210"); // → { valid: true }
validateZip("90210-1234"); // → { valid: true }
validateZip("9021"); // → { valid: false, messageKey: "address.zip.invalid" }
```
## API Reference
- `pattern(re: RegExp, messageKey?: TranslationKey): Validator` —
factory.
- Throws `TypeError` if `re` is not a `RegExp`.
- Returns a validator that yields `{ valid: true }` on match,
`{ valid: false, messageKey }` otherwise.
## Implementation Notes
- The flag-stripping is mandatory; without it,
`pattern(/abc/g)("abc")` would return true on first call and false
on second because `lastIndex` advances through `abc` and never
resets.
- The factory clones the regex via `new RegExp(re.source, safeFlags)`
to insulate the validator from any subsequent mutation of the
original instance (callers occasionally hand a regex out of a shared
config object that other code later edits).
- For more complex constraints (cross-field references, async checks),
combine `pattern` with `compose` / `sequence` from
`@suaveplan/react-forms/validation/compose`.

View File

@@ -0,0 +1,76 @@
# `phone.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string parses as a
valid telephone number per Google's `libphonenumber-js`. Covers every
ITU-T E.164 country code and every per-country numbering plan the
upstream metadata corpus carries — the same corpus Android, the iOS
Phone app, and Google Voice rely on for validation.
The default failure message resolves the `TranslationKey`
`react_forms.validation.phone`.
## Features
- Two acceptance modes: international (E.164 with leading `+`) and
national (when a `region` is supplied).
- Region argument typed as ISO 3166-1 alpha-2 (`"GB"`, `"US"`,
`"FR"`, ...).
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { phone } from "@suaveplan/react-forms/validation/validators";
const validateGB = phone({ region: "GB" });
validateGB("020 7946 0958"); // { valid: true }
validateGB("not a phone"); // { valid: false }
const validateIntl = phone();
validateIntl("+44 20 7946 0958"); // { valid: true }
validateIntl("020 7946 0958"); // { valid: false } — needs leading +
```
## Usage: Advanced Example
```ts
import { phone } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validateUsPhone = phone({
region: "US",
messageKey: "checkout.phone.invalid" as TranslationKey,
});
validateUsPhone("(415) 555-2671"); // → { valid: true }
validateUsPhone("nope"); // → { valid: false, messageKey: "checkout.phone.invalid" }
```
## API Reference
- `phone(opts?: PhoneValidatorOptions): Validator` — factory.
- `opts.region?: string` — ISO 3166-1 alpha-2 country code; treated
as the default country when the input is in national format.
- `opts.messageKey?: TranslationKey` — message key on failure.
- `interface PhoneValidatorOptions { region?: string; messageKey?:
TranslationKey }`.
## Implementation Notes
- The validator is intentionally permissive on output format —
libphonenumber-js accepts spaces, hyphens, dots, and parentheses as
separators. Callers wanting a strict format requirement should layer
`pattern` on top via `compose`.
- The `libphonenumber-js` package ships ~120 KB of metadata even after
tree-shaking. Consumers concerned about bundle size can swap to
`libphonenumber-js/min` (the lighter metadata bundle) at the
application level via Vite/Webpack alias — the validator's API
surface is unchanged.
- Empty strings always fail before the underlying parser is invoked.
This is a small optimisation but matters for forms that re-validate
on every keystroke.

View File

@@ -0,0 +1,88 @@
# `postcode.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string matches the
configured country's postcode format. v1 ships curated regexes for the
five most common deployment countries — `GB`, `US`, `CA`, `DE`, `FR`.
Additional countries are added per consumer request: the alternative
(generating from a third-party data source) would introduce a hard
runtime dependency without meaningfully improving coverage for the core
five.
The default failure message resolves the `TranslationKey`
`react_forms.validation.postcode`.
## Features
- Per-country regex registry — explicit, auditable, no runtime data
source.
- Throws `RangeError` from the factory when `country` is not in the
supported set so misconfiguration surfaces at module evaluation
rather than at form-submit time.
- Trims whitespace before matching so `" SW1A 1AA "` validates the
same as `"SW1A 1AA"`.
- Exposes `SUPPORTED_POSTCODE_COUNTRIES` so consumer apps can render
a country-aware postcode field UI without re-keeping the registry
in sync.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { postcode } from "@suaveplan/react-forms/validation/validators";
const validateGB = postcode({ country: "GB" });
validateGB("SW1A 1AA"); // { valid: true }
validateGB("12345"); // { valid: false }
const validateUS = postcode({ country: "US" });
validateUS("90210"); // { valid: true }
validateUS("90210-1234"); // { valid: true }
```
## Usage: Advanced Example
```ts
import {
postcode,
SUPPORTED_POSTCODE_COUNTRIES,
} from "@suaveplan/react-forms/validation/validators";
function makeValidator(country: string) {
if (!SUPPORTED_POSTCODE_COUNTRIES.includes(country)) {
return (() => ({ valid: true })) as Validator;
}
return postcode({
country,
messageKey: "address.postcode.invalid" as TranslationKey,
});
}
```
## API Reference
- `postcode(opts: PostcodeValidatorOptions): Validator` — factory.
- `opts.country: string` — ISO 3166-1 alpha-2 country code; case-
insensitive.
- `opts.messageKey?: TranslationKey` — message key on failure.
- Throws `RangeError` when the country is not in the supported set.
- `interface PostcodeValidatorOptions { country: string; messageKey?:
TranslationKey }`.
- `SUPPORTED_POSTCODE_COUNTRIES: readonly string[]` — the supported
country codes.
## Implementation Notes
- The factory uppercases the country code internally so callers can
pass either case.
- The five country regexes are commonly used in upstream libraries
(`postal-codes-js`, `i18n-postal-address`); we ship them inline so
the package stays leaf-clean and so the regexes are auditable from
the diff.
- For new country requests, file an OpenSpec change adding the regex +
unit tests against the country's official format reference (Royal
Mail PAF, USPS Pub 28, Canada Post Specs Manual, etc.).

View File

@@ -0,0 +1,81 @@
# `required.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous presence validator for form fields. Returns success when the
supplied value carries semantic content, failure otherwise. Unlike the
typical "is it truthy?" check, this validator treats whitespace-only
strings, empty arrays, and empty `Map` / `Set` instances as absent so a
field full of spaces or an empty tag input fails just as a blank field
would.
The default failure message resolves the `TranslationKey`
`react_forms.validation.required`. Callers may override the key per
field — a registration form might want
`auth.email.required` rather than the generic key — so the same
validator factory can render any message the consumer's catalog
declares.
## Features
- Presence semantics that catch real-world "blank" inputs: `null`,
`undefined`, `""`, `" "`, `[]`, empty `Map`, empty `Set`.
- Falsy primitives (`false`, `0`, `0n`) count as PRESENT so checkbox
and numeric-zero inputs are not silently rejected.
- Optional `messageKey` argument typed as `TranslationKey` so callers
cannot pass a raw literal that would slip past the translation
pipeline.
- Pure factory — the validator returned has no closure over time, RNG,
or network and is safe to call from any pipeline step.
## Usage: Basic Example
```ts
import { required } from "@suaveplan/react-forms/validation/validators";
const validateName = required();
validateName("Jane"); // { valid: true }
validateName(" "); // { valid: false, messageKey: "react_forms.validation.required" }
```
## Usage: Advanced Example
```ts
import { required } from "@suaveplan/react-forms/validation/validators";
import { sequence } from "@suaveplan/react-forms/validation/compose";
import { minLength } from "@suaveplan/react-forms/validation/validators";
const validateUsername = sequence(
required("auth.username.required"),
minLength(3, "auth.username.too_short")
);
validateUsername(""); // → { valid: false, messageKey: "auth.username.required" }
validateUsername("ab"); // → { valid: false, messageKey: "auth.username.too_short" }
validateUsername("alice"); // → { valid: true }
```
## API Reference
- `required(messageKey?: TranslationKey): Validator` — returns a
`Validator<unknown>` that returns `{ valid: true }` when the value is
present, `{ valid: false, messageKey }` otherwise. The default key is
`react_forms.validation.required`.
## Implementation Notes
- The presence check is intentionally narrower than `Boolean(value)` so
legitimate falsy primitives are not counted as missing. This matches
the behaviour every mature form library converges on (Yup, Zod,
react-hook-form's `validate.required`).
- Whitespace-only strings are stripped via `value.trim().length > 0`.
Locale-aware whitespace (e.g. CJK ideographic space U+3000) is
included via JavaScript's spec-compliant `String.prototype.trim`.
- `Array.isArray` is used in preference to `instanceof Array` to handle
cross-realm arrays, which appear when forms are rendered inside
iframes.
- `Map` and `Set` checks come AFTER the array check so `Array.isArray`
short-circuits on the common case.

View File

@@ -0,0 +1,75 @@
# `url.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied value parses
successfully via the WHATWG `URL` constructor and (optionally) matches
a configured protocol allowlist. Using the native parser is both more
correct than any hand-rolled regex (it handles IDN, percent-encoding,
authority components, port ranges, and edge-case input gracefully) and
ReDoS-safe by construction.
The default failure message resolves the `TranslationKey`
`react_forms.validation.url`.
## Features
- WHATWG `URL`-backed parsing — every spec edge case handled by the
runtime.
- Optional `protocols` allowlist (e.g. `["http", "https"]`) so http(s)
inputs can be required and `javascript:` URIs rejected.
- Optional `messageKey` typed as `TranslationKey`.
- Backwards-compatible single-argument form: `url("custom.key")` is
equivalent to `url({ messageKey: "custom.key" })` so the validator
composes uniformly with the other one-arg validators.
## Usage: Basic Example
```ts
import { url } from "@suaveplan/react-forms/validation/validators";
const validate = url();
validate("https://example.com"); // { valid: true }
validate("not a url"); // { valid: false, messageKey: "react_forms.validation.url" }
```
## Usage: Advanced Example
```ts
import { url } from "@suaveplan/react-forms/validation/validators";
const validateWebUrl = url({
protocols: ["http", "https"],
messageKey: "profile.website.invalid" as TranslationKey,
});
validateWebUrl("ftp://example.com"); // → { valid: false, messageKey: "profile.website.invalid" }
validateWebUrl("javascript:alert(1)"); // → { valid: false, messageKey: "profile.website.invalid" }
validateWebUrl("https://example.com/about"); // → { valid: true }
```
## API Reference
- `url(opts?: UrlValidatorOptions | TranslationKey): Validator` —
factory.
- `opts.protocols?: readonly string[]` — protocols (without the
trailing colon) accepted. When omitted, every protocol the runtime
accepts is allowed.
- `opts.messageKey?: TranslationKey` — message key on failure.
- `interface UrlValidatorOptions { protocols?: readonly string[];
messageKey?: TranslationKey }`.
## Implementation Notes
- The constructor's `try/catch` is the cheapest deterministic test for
validity. The catch suppresses the `TypeError` thrown by `URL` on
malformed input and converts it to a structured failure.
- Protocol matching is case-insensitive because `URL.protocol` is
always returned lowercase per the WHATWG URL spec.
- The string-shorthand overload exists so callers writing
`url("auth.url.invalid")` get the same surface as the other
validators (`required`, `email`, `pattern`, ...). The function body
unwraps the shorthand into the canonical options bag.