Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,220 @@
{
"name": "@suaveplan/react-forms",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./core": {
"import": {
"types": "./dist/core/index.d.ts",
"default": "./dist/core/index.js"
}
},
"./core/api-client-context": {
"import": {
"types": "./dist/core/api-client-context/index.d.ts",
"default": "./dist/core/api-client-context/index.js"
}
},
"./core/auth-context": {
"import": {
"types": "./dist/core/auth-context/index.d.ts",
"default": "./dist/core/auth-context/index.js"
}
},
"./core/validation-engine": {
"import": {
"types": "./dist/core/validation-engine/index.d.ts",
"default": "./dist/core/validation-engine/index.js"
}
},
"./core/use-form-validation": {
"import": {
"types": "./dist/core/use-form-validation/index.d.ts",
"default": "./dist/core/use-form-validation/index.js"
}
},
"./core/use-domain-form": {
"import": {
"types": "./dist/core/use-domain-form/index.d.ts",
"default": "./dist/core/use-domain-form/index.js"
}
},
"./validation": {
"import": {
"types": "./dist/validation/index.d.ts",
"default": "./dist/validation/index.js"
}
},
"./validation/validators": {
"import": {
"types": "./dist/validation/validators/index.d.ts",
"default": "./dist/validation/validators/index.js"
}
},
"./validation/async": {
"import": {
"types": "./dist/validation/async/index.d.ts",
"default": "./dist/validation/async/index.js"
}
},
"./validation/compose": {
"import": {
"types": "./dist/validation/compose/index.d.ts",
"default": "./dist/validation/compose/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/react-forms-metrics.d.ts",
"default": "./dist/observability/react-forms-metrics.js"
}
},
"./primitives": {
"import": {
"types": "./dist/primitives/index.d.ts",
"default": "./dist/primitives/index.js"
}
},
"./primitives/field": {
"import": {
"types": "./dist/primitives/field/index.d.ts",
"default": "./dist/primitives/field/index.js"
}
},
"./primitives/money-input": {
"import": {
"types": "./dist/primitives/money-input/index.d.ts",
"default": "./dist/primitives/money-input/index.js"
}
},
"./factory": {
"import": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/index.js"
}
},
"./factory/create-domain-form": {
"import": {
"types": "./dist/factory/create-domain-form/index.d.ts",
"default": "./dist/factory/create-domain-form/index.js"
}
},
"./factory/field-config": {
"import": {
"types": "./dist/factory/field-config/index.d.ts",
"default": "./dist/factory/field-config/index.js"
}
},
"./recipes": {
"import": {
"types": "./dist/recipes/index.d.ts",
"default": "./dist/recipes/index.js"
}
},
"./recipes/wizard": {
"import": {
"types": "./dist/recipes/wizard/index.d.ts",
"default": "./dist/recipes/wizard/index.js"
}
},
"./recipes/repeating-fieldset": {
"import": {
"types": "./dist/recipes/repeating-fieldset/index.d.ts",
"default": "./dist/recipes/repeating-fieldset/index.js"
}
},
"./recipes/conditional": {
"import": {
"types": "./dist/recipes/conditional/index.d.ts",
"default": "./dist/recipes/conditional/index.js"
}
},
"./recipes/async-search": {
"import": {
"types": "./dist/recipes/async-search/index.d.ts",
"default": "./dist/recipes/async-search/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ && bun run ../../../../scripts/lint/check-no-hardcoded-strings.ts ./src",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-schemas": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n": "workspace:^",
"@suaveplan/i18n-formatter-currency": "workspace:^",
"@suaveplan/react-i18n": "workspace:^",
"@suaveplan/react-ui": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"@hookform/resolvers": "catalog:forms",
"@opentelemetry/api": "catalog:observability",
"@zxcvbn-ts/core": "catalog:forms",
"libphonenumber-js": "catalog:forms",
"react-hook-form": "catalog:forms",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"@suaveplan/react-ui": "workspace:^",
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@playwright/test": "catalog:testing",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-dom": "workspace:^",
"@testing-library/react": "catalog:testing",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bun
/**
* `check-locale-completeness.ts` — assert every key in
* `<package>/src/locales/en-GB.json` exists in every other locale
* bundle shipped by `@suaveplan/react-forms`. Wraps the shared check
* implemented inside `@suaveplan/react-ui`'s sibling script so the two
* packages stay parity-equivalent.
*
* Usage:
* bun packages/react/react-forms/scripts/check-locale-completeness.ts <package-root>
*
* Exit codes:
* 0 — every key parity-complete
* 1 — at least one key missing
* 2 — internal failure
*/
import { checkLocaleCompleteness } from "../../react-ui/scripts/check-locale-completeness.js";
interface BundleDiff {
readonly locale: string;
readonly missing: ReadonlyArray<string>;
readonly extra: ReadonlyArray<string>;
}
function format(diffs: ReadonlyArray<BundleDiff>): string {
if (diffs.length === 0) {
return "check-locale-completeness: every locale parity-complete.";
}
const lines: string[] = [
`check-locale-completeness: ${diffs.length} locale(s) failed parity.`,
];
for (const d of diffs) {
lines.push(` ${d.locale}:`);
if (d.missing.length > 0) {
lines.push(" missing keys:");
for (const k of d.missing) lines.push(` - ${k}`);
}
if (d.extra.length > 0) {
lines.push(" extra keys (not in en-GB):");
for (const k of d.extra) lines.push(` - ${k}`);
}
}
return lines.join("\n");
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) {
process.stderr.write(
"Usage: check-locale-completeness.ts <package-root>\n"
);
process.exit(2);
}
const root = argv[0] as string;
const diffs = (await checkLocaleCompleteness(root)) as BundleDiff[];
const message = format(diffs);
if (diffs.length === 0) {
process.stdout.write(`${message}\n`);
process.exit(0);
}
process.stderr.write(`${message}\n`);
process.exit(1);
}
if (import.meta.main) {
await main().catch((err: unknown) => {
process.stderr.write(`internal error: ${(err as Error).message}\n`);
process.exit(2);
});
}

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,101 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, render, screen } from "@testing-library/react";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import { ApiClientContext, ApiClientProvider } from "./api-client-context.js";
import { useApiClient } from "./use-api-client.js";
interface FakeApi {
readonly id: string;
}
function ConsumerLabel() {
const api = useApiClient<FakeApi>();
return <span data-testid="api-id">{api.id}</span>;
}
afterEach(() => {
cleanup();
});
describe("ApiClientProvider + useApiClient", () => {
it("publishes the value into the context for descendants to read", {
timeout: 5000,
}, () => {
render(
<ApiClientProvider value={{ id: "test-api" } as FakeApi}>
<ConsumerLabel />
</ApiClientProvider>
);
expect(screen.getByTestId("api-id").textContent).toBe("test-api");
});
it("returns the same reference identity inside the consumer", {
timeout: 5000,
}, () => {
const api: FakeApi = { id: "stable" };
let observed: FakeApi | undefined;
function Capture() {
observed = useApiClient<FakeApi>();
return null;
}
render(
<ApiClientProvider value={api}>
<Capture />
</ApiClientProvider>
);
expect(observed).toBe(api);
});
it("sets a debug-friendly displayName on the context", {
timeout: 5000,
}, () => {
expect(ApiClientContext.displayName).toBe("ApiClientContext");
});
it("throws REACT_FORMS.API_CLIENT_NOT_FOUND when used outside the provider", {
timeout: 5000,
}, () => {
function Renderer() {
useApiClient<FakeApi>();
return null;
}
// Wrap in a try/catch via render to surface the throw without
// an error boundary cluttering the test.
let captured: unknown;
try {
render(<Renderer />);
} catch (cause) {
captured = cause;
}
expect(captured).toBeInstanceOf(ReactFormsError);
if (captured instanceof ReactFormsError) {
expect(captured.code).toBe(REACT_FORMS_CODES.API_CLIENT_NOT_FOUND);
expect(captured.context).toEqual({
tier: "react-forms",
hook: "useApiClient",
});
}
});
it("supports re-rendering with a new value", { timeout: 5000 }, () => {
const a: FakeApi = { id: "a" };
const b: FakeApi = { id: "b" };
const { rerender } = render(
<ApiClientProvider value={a}>
<ConsumerLabel />
</ApiClientProvider>
);
expect(screen.getByTestId("api-id").textContent).toBe("a");
rerender(
<ApiClientProvider value={b}>
<ConsumerLabel />
</ApiClientProvider>
);
expect(screen.getByTestId("api-id").textContent).toBe("b");
});
});

View File

@@ -0,0 +1,44 @@
import { createContext, createElement, type ReactNode } from "react";
import type { ApiClient } from "./api-client.types.js";
/**
* React context carrying the consumer's `ApiClient`. Default value is
* `null` so the `useApiClient` hook can detect "no provider" cleanly
* and throw a structured `ReactFormsError` rather than silently
* returning `undefined`.
*
* The context value is typed as `ApiClient | null` rather than the
* full generic `TApi` parameter because React contexts cannot carry
* type parameters — the generic `TApi` lives on `useApiClient<TApi>()`
* which casts the marker context value to the consumer-supplied type.
*/
export const ApiClientContext = createContext<ApiClient | null>(null);
ApiClientContext.displayName = "ApiClientContext";
export interface ApiClientProviderProps<TApi extends ApiClient> {
/**
* Concrete API client implementation. Pass an instance whose
* methods return typed promises — the forms package never inspects
* the value, only forwards it via `ctx.api` to your submit handler.
*/
readonly value: TApi;
/** Subtree that should have access to the API client. */
readonly children: ReactNode;
}
/**
* Mount this provider near the root of your application so every
* `useApiClient()` and `createDomainForm` submit handler can reach the
* API client. The provider is thin — no validation, no transformation
* — it just publishes the consumer's value into the context.
*/
export function ApiClientProvider<TApi extends ApiClient>(
props: ApiClientProviderProps<TApi>
): ReactNode {
return createElement(
ApiClientContext.Provider,
{ value: props.value as ApiClient },
props.children
);
}

View File

@@ -0,0 +1,26 @@
/**
* Minimal `ApiClient` contract that the @suaveplan/react-forms package
* surfaces to consumer apps via `<ApiClientProvider value={...}>`.
*
* The contract is intentionally a marker type. Real apps inject a
* concrete client whose methods return typed promises (e.g.
* `auth.login(input)`, `users.list({ pageSize: 10 })`); the forms
* package never sees the methods directly — it only forwards the
* value through `ctx.api` to the consumer's `submit.onSubmit` callback,
* where the consumer asserts the concrete type via the generic
* parameter on `useApiClient<TApi>()`.
*
* Keeping the marker as a non-empty interface (rather than `unknown`
* or `object`) means consumers see a typed handle in DI containers
* and stack traces — the brand member never exists at runtime.
*/
export interface ApiClient {
/**
* Phantom brand. Never present at runtime; never read by the
* package. Exists purely so TypeScript treats the interface as
* nominally distinct from a bare `object` and so the provider
* accepts only values the consumer explicitly typed as an
* `ApiClient`.
*/
readonly __apiClientBrand?: never;
}

View File

@@ -0,0 +1,7 @@
export type { ApiClient } from "./api-client.types.js";
export {
ApiClientContext,
ApiClientProvider,
type ApiClientProviderProps,
} from "./api-client-context.js";
export { useApiClient } from "./use-api-client.js";

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,33 @@
import { useContext } from "react";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import type { ApiClient } from "./api-client.types.js";
import { ApiClientContext } from "./api-client-context.js";
/**
* Read the active `ApiClient` from the surrounding
* `<ApiClientProvider>`. Generic over `TApi` so consumers narrow the
* marker context value to their concrete client type at the call site:
*
* ```ts
* const api = useApiClient<MyApi>();
* await api.users.create({ ... });
* ```
*
* Throws `ReactFormsError(REACT_FORMS.API_CLIENT_NOT_FOUND)` when the
* hook is called outside an `<ApiClientProvider>` ancestor. The throw
* is preferred over silent `undefined`-return because every consumer
* of this hook depends on the value being present — silent undefined
* defers the failure to a `Cannot read properties of undefined`
* downstream which is much harder to debug.
*/
export function useApiClient<TApi extends ApiClient = ApiClient>(): TApi {
const value = useContext(ApiClientContext);
if (value === null) {
throw new ReactFormsError(
REACT_FORMS_CODES.API_CLIENT_NOT_FOUND,
"react_forms.error.api_client_not_found",
{ context: { hook: "useApiClient" } }
);
}
return value as TApi;
}

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,146 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, render, screen } from "@testing-library/react";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import type { AuthContextValue } from "./auth.types.js";
import { AuthContext, AuthProvider } from "./auth-context.js";
import { useAuth } from "./use-auth.js";
interface FakeUser {
readonly id: string;
readonly displayName: string;
}
function buildAuth(
overrides?: Partial<AuthContextValue<FakeUser>>
): AuthContextValue<FakeUser> {
return {
currentUser: null,
isAuthenticated: false,
signIn: async () => {
throw new Error("not configured");
},
signOut: async () => undefined,
...overrides,
};
}
function ConsumerName() {
const { currentUser } = useAuth<FakeUser>();
return (
<span data-testid="user">{currentUser?.displayName ?? "guest"}</span>
);
}
afterEach(() => {
cleanup();
});
describe("AuthProvider + useAuth", () => {
it("publishes the value into the context for descendants to read", {
timeout: 5000,
}, () => {
const auth = buildAuth({
currentUser: { id: "u1", displayName: "Alice" },
isAuthenticated: true,
});
render(
<AuthProvider value={auth}>
<ConsumerName />
</AuthProvider>
);
expect(screen.getByTestId("user").textContent).toBe("Alice");
});
it("returns the same reference identity inside the consumer", {
timeout: 5000,
}, () => {
const auth = buildAuth();
let observed: AuthContextValue<FakeUser> | undefined;
function Capture() {
observed = useAuth<FakeUser>();
return null;
}
render(
<AuthProvider value={auth}>
<Capture />
</AuthProvider>
);
expect(observed).toBe(auth);
});
it("sets a debug-friendly displayName on the context", {
timeout: 5000,
}, () => {
expect(AuthContext.displayName).toBe("AuthContext");
});
it("throws REACT_FORMS.AUTH_CONTEXT_NOT_FOUND when used outside the provider", {
timeout: 5000,
}, () => {
function Renderer() {
useAuth<FakeUser>();
return null;
}
let captured: unknown;
try {
render(<Renderer />);
} catch (cause) {
captured = cause;
}
expect(captured).toBeInstanceOf(ReactFormsError);
if (captured instanceof ReactFormsError) {
expect(captured.code).toBe(
REACT_FORMS_CODES.AUTH_CONTEXT_NOT_FOUND
);
expect(captured.context).toEqual({
tier: "react-forms",
hook: "useAuth",
});
}
});
it("supports re-rendering with a new value", { timeout: 5000 }, () => {
const a = buildAuth({
currentUser: { id: "1", displayName: "A" },
isAuthenticated: true,
});
const b = buildAuth({
currentUser: { id: "2", displayName: "B" },
isAuthenticated: true,
});
const { rerender } = render(
<AuthProvider value={a}>
<ConsumerName />
</AuthProvider>
);
expect(screen.getByTestId("user").textContent).toBe("A");
rerender(
<AuthProvider value={b}>
<ConsumerName />
</AuthProvider>
);
expect(screen.getByTestId("user").textContent).toBe("B");
});
it("exposes the optional permissions set when supplied", {
timeout: 5000,
}, () => {
let perms: ReadonlySet<string> | undefined;
function CapturePerms() {
perms = useAuth<FakeUser>().permissions;
return null;
}
const auth = buildAuth({ permissions: new Set(["a", "b"]) });
render(
<AuthProvider value={auth}>
<CapturePerms />
</AuthProvider>
);
expect(perms).toEqual(new Set(["a", "b"]));
});
});

View File

@@ -0,0 +1,36 @@
import { createContext, createElement, type ReactNode } from "react";
import type { AuthContextValue } from "./auth.types.js";
/**
* React context carrying the consumer's `AuthContextValue`. Default
* value is `null` so the `useAuth` hook can detect "no provider"
* cleanly and throw a structured `ReactFormsError` rather than
* silently returning `undefined`.
*/
export const AuthContext = createContext<AuthContextValue | null>(null);
AuthContext.displayName = "AuthContext";
export interface AuthProviderProps<TUser> {
/** Concrete auth implementation. */
readonly value: AuthContextValue<TUser>;
/** Subtree that should have access to the auth context. */
readonly children: ReactNode;
}
/**
* Mount this provider near the root of your application so every
* `useAuth()` and `createDomainForm` submit handler can reach the
* auth context. The provider is thin — no validation, no
* transformation — it just publishes the consumer's value into the
* context.
*/
export function AuthProvider<TUser>(
props: AuthProviderProps<TUser>
): ReactNode {
return createElement(
AuthContext.Provider,
{ value: props.value as AuthContextValue<unknown> },
props.children
);
}

View File

@@ -0,0 +1,49 @@
/**
* Generic auth contract surfaced by the @suaveplan/react-forms
* `<AuthProvider>` and consumed by submit handlers that need to
* sign the user in on success (e.g. registration, magic-link verify).
*
* The contract is intentionally small — `currentUser`, `signIn`,
* `signOut`, plus the optional `permissions` set — so that any auth
* implementation can satisfy it without inheriting forms-specific
* surface. Concrete clients return their concrete user shape via the
* generic `TUser` parameter on `useAuth<TUser>()`.
*/
export interface AuthContextValue<TUser = unknown> {
/**
* Current signed-in user, or `null` when no session is active.
*/
readonly currentUser: TUser | null;
/**
* Whether a user is currently signed in. Convenience boolean
* derived from `currentUser` — the consumer's implementation MAY
* compute it from `currentUser !== null` or from a separate
* session token, depending on its session model.
*/
readonly isAuthenticated: boolean;
/**
* Sign the user in with the supplied credentials. The shape of
* `credentials` is opaque to the forms package — the consumer's
* implementation pins the type. The promise resolves with the
* signed-in user on success and rejects on failure.
*/
readonly signIn: (credentials: AuthCredentials) => Promise<TUser>;
/**
* Sign the user out and clear any local session state.
*/
readonly signOut: () => Promise<void>;
/**
* Optional: set of permission strings the current user holds.
* Consumers using RBAC patterns wire this to an authorization
* service; consumers using ACL patterns may leave it `undefined`.
*/
readonly permissions?: ReadonlySet<string>;
}
/**
* Opaque credentials envelope. The consumer's auth implementation
* defines the concrete shape (e.g. `{ email, password }`,
* `{ token }`, `{ provider, code }`) — the forms package never
* inspects it.
*/
export type AuthCredentials = Readonly<Record<string, unknown>>;

View File

@@ -0,0 +1,10 @@
export type {
AuthContextValue,
AuthCredentials,
} from "./auth.types.js";
export {
AuthContext,
AuthProvider,
type AuthProviderProps,
} from "./auth-context.js";
export { useAuth } from "./use-auth.js";

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,28 @@
import { useContext } from "react";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import type { AuthContextValue } from "./auth.types.js";
import { AuthContext } from "./auth-context.js";
/**
* Read the active `AuthContextValue` from the surrounding
* `<AuthProvider>`. Generic over `TUser` so consumers narrow the
* marker context value to their concrete user shape at the call site:
*
* ```ts
* const { currentUser, signIn } = useAuth<MyUser>();
* ```
*
* Throws `ReactFormsError(REACT_FORMS.AUTH_CONTEXT_NOT_FOUND)` when
* the hook is called outside an `<AuthProvider>` ancestor.
*/
export function useAuth<TUser = unknown>(): AuthContextValue<TUser> {
const value = useContext(AuthContext);
if (value === null) {
throw new ReactFormsError(
REACT_FORMS_CODES.AUTH_CONTEXT_NOT_FOUND,
"react_forms.error.auth_context_not_found",
{ context: { hook: "useAuth" } }
);
}
return value as AuthContextValue<TUser>;
}

View File

@@ -0,0 +1,49 @@
export type { ApiClient } from "./api-client-context/index.js";
export {
ApiClientContext,
ApiClientProvider,
type ApiClientProviderProps,
useApiClient,
} from "./api-client-context/index.js";
export type {
AuthContextValue,
AuthCredentials,
} from "./auth-context/index.js";
export {
AuthContext,
AuthProvider,
type AuthProviderProps,
useAuth,
} from "./auth-context/index.js";
export type {
ServerErrorState,
UseDomainFormOptions,
UseDomainFormReturn,
} from "./use-domain-form/index.js";
export { useDomainForm } from "./use-domain-form/index.js";
export type {
FormSchema,
UseFormValidationOptions,
} from "./use-form-validation/index.js";
export { useFormValidation } from "./use-form-validation/index.js";
export type {
AsyncValidatorRegistration,
CrossFieldRule,
CrossFieldRuleOptions,
CrossFieldViolation,
EngineFieldErrors,
FieldValuesMap,
ServerErrorEnvelope,
ServerFieldError,
ValidationEngineConfig,
ValidationResult,
} from "./validation-engine/index.js";
export {
defineCrossFieldRule,
mapServerErrors,
throwServerErrorMapped,
unsafeAssertTranslationKey,
unwrapServerEnvelope,
ValidationEngine,
violate,
} from "./validation-engine/index.js";

View File

@@ -0,0 +1,6 @@
export { useDomainForm } from "./use-domain-form.js";
export type {
ServerErrorState,
UseDomainFormOptions,
UseDomainFormReturn,
} from "./use-domain-form.types.js";

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,262 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { act, cleanup, renderHook } from "@testing-library/react";
import z from "zod";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import {
getReactFormsMetricsState,
resetReactFormsMetricsCache,
resetReactFormsMetricsState,
} from "../../observability/react-forms-metrics.js";
import { useDomainForm } from "./use-domain-form.js";
afterEach(() => {
cleanup();
resetReactFormsMetricsState();
resetReactFormsMetricsCache();
});
describe("useDomainForm", () => {
const schema = z.object({
email: z.string().email(),
});
it("returns the RHF context and a stable submit reference", {
timeout: 5000,
}, () => {
const { result } = renderHook(() =>
useDomainForm({
formId: "x",
schema,
onSubmit: async () => Promise.resolve(),
})
);
expect(typeof result.current.form.register).toBe("function");
expect(typeof result.current.submit).toBe("function");
expect(result.current.fields).toBeNull();
expect(result.current.fieldConfigs).toBeNull();
expect(result.current.serverError).toEqual({ hasError: false });
expect(result.current.isSubmitting).toBe(false);
});
it("invokes onSubmit with the parsed values when validation passes", {
timeout: 5000,
}, async () => {
let received: unknown;
const { result } = renderHook(() =>
useDomainForm({
formId: "x",
schema,
defaultValues: { email: "alice@example.com" },
onSubmit: async (values) => {
received = values;
},
})
);
await act(async () => {
await result.current.submit();
});
expect(received).toEqual({ email: "alice@example.com" });
expect(getReactFormsMetricsState().submitTotal).toEqual([
{ attributes: { form: "x", outcome: "success" } },
]);
});
it("surfaces a server-error envelope into the RHF field errors", {
timeout: 5000,
}, async () => {
const { result } = renderHook(() => {
const r = useDomainForm({
formId: "register",
schema,
defaultValues: { email: "alice@example.com" },
onSubmit: async () => {
throw new ReactFormsError(
REACT_FORMS_CODES.SERVER_ERROR_MAPPED,
"react_forms.error.server_error_mapped",
{
context: {
fieldErrors: [
{
field: "email",
messageKey: "auth.email.taken",
},
],
},
}
);
},
});
// Touch the proxy so RHF subscribes to the errors slice and
// re-renders when setError fires inside the submit pipeline.
void r.form.formState.errors;
return r;
});
await act(async () => {
await result.current.submit();
});
const fieldState = result.current.form.getFieldState("email" as never);
expect(fieldState.error?.message).toBe("auth.email.taken");
expect(result.current.serverError.hasError).toBe(true);
expect(getReactFormsMetricsState().submitTotal).toEqual([
{ attributes: { form: "register", outcome: "error" } },
]);
});
it("wraps a non-envelope onSubmit failure in a structured ReactFormsError state", {
timeout: 5000,
}, async () => {
const { result } = renderHook(() =>
useDomainForm({
formId: "x",
schema,
defaultValues: { email: "alice@example.com" },
onSubmit: async () => {
throw new Error("network down");
},
})
);
await act(async () => {
await result.current.submit();
});
expect(result.current.serverError.hasError).toBe(true);
});
it("does NOT call onSubmit when the schema fails", {
timeout: 5000,
}, async () => {
let calls = 0;
const { result } = renderHook(() => {
const r = useDomainForm({
formId: "x",
schema,
defaultValues: { email: "not-an-email" },
onSubmit: async () => {
calls += 1;
},
});
void r.form.formState.errors;
return r;
});
await act(async () => {
await result.current.submit();
});
expect(calls).toBe(0);
const fieldState = result.current.form.getFieldState("email" as never);
expect(
fieldState.error?.message?.startsWith("react_forms.validation.zod.")
).toBe(true);
});
it("guards against re-entrant submit calls", {
timeout: 5000,
}, async () => {
let callCount = 0;
const onSubmit = async (): Promise<void> => {
callCount += 1;
await new Promise<void>((resolve) => {
setTimeout(resolve, 30);
});
};
const { result } = renderHook(() =>
useDomainForm({
formId: "x",
schema,
defaultValues: { email: "alice@example.com" },
onSubmit,
})
);
await act(async () => {
const p1 = result.current.submit();
const p2 = result.current.submit();
await Promise.all([p1, p2]);
});
expect(callCount).toBe(1);
});
it("forwards crossFieldRules + asyncValidators + debounceMs to the engine config", {
timeout: 5000,
}, () => {
const rule = {
name: "trace",
fields: ["email"],
validate: (): null => null,
} as const;
const validator = async () =>
Promise.resolve({
valid: true as const,
});
const { result } = renderHook(() =>
useDomainForm({
formId: "all-config",
schema,
crossFieldRules: [rule],
asyncValidators: [{ field: "email", validator }],
debounceMs: 25,
onSubmit: async () => undefined,
})
);
expect(typeof result.current.submit).toBe("function");
});
it("captures non-envelope onSubmit failures via the catch arm setServerError", {
timeout: 5000,
}, async () => {
const { result } = renderHook(() =>
useDomainForm({
formId: "x",
schema,
defaultValues: { email: "alice@example.com" },
onSubmit: async () => {
throw new Error("network down");
},
})
);
await act(async () => {
await result.current.submit();
});
expect(result.current.serverError.hasError).toBe(true);
});
it("captures resolver-thrown failures via the outer catch arm", {
timeout: 5000,
}, async () => {
const throwingResolver = (): never => {
throw new Error("resolver crashed");
};
const throwingSchema = {
safeParse: throwingResolver,
} as unknown as typeof schema;
const { result } = renderHook(() =>
useDomainForm({
formId: "resolver-crash",
schema: throwingSchema,
defaultValues: { email: "alice@example.com" },
onSubmit: async () => undefined,
})
);
await act(async () => {
await result.current.submit();
});
expect(result.current.serverError.hasError).toBe(true);
});
it("records submit duration in the metrics ring", {
timeout: 5000,
}, async () => {
const { result } = renderHook(() =>
useDomainForm({
formId: "perf",
schema,
defaultValues: { email: "alice@example.com" },
onSubmit: async () => undefined,
})
);
await act(async () => {
await result.current.submit();
});
const samples = getReactFormsMetricsState().submitDurationMs;
expect(samples.length).toBe(1);
expect(samples[0]?.attributes.form).toBe("perf");
expect(typeof samples[0]?.value).toBe("number");
});
});

View File

@@ -0,0 +1,206 @@
import {
type ReactElement,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { type FieldValues, type UseFormProps, useForm } from "react-hook-form";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import { validateFieldConfigMap } from "../../factory/field-config/field-config.js";
import type { FieldConfig } from "../../factory/field-config/field-config.types.js";
import { RenderField } from "../../factory/render-field/render-field.js";
import {
recordSubmitDuration,
recordSubmitOutcome,
} from "../../observability/react-forms-metrics.js";
import { useFormValidation } from "../use-form-validation/use-form-validation.js";
import {
mapServerErrors,
unwrapServerEnvelope,
} from "../validation-engine/server-error-mapper.js";
import type {
FieldValuesMap,
ValidationEngineConfig,
} from "../validation-engine/validation-engine.types.js";
import type {
ServerErrorState,
UseDomainFormOptions,
UseDomainFormReturn,
} from "./use-domain-form.types.js";
const NO_SERVER_ERROR: ServerErrorState = { hasError: false };
/**
* Standalone domain-form hook. Wraps `useForm` (react-hook-form) with
* the package's `ValidationEngine` resolver, a typed submit pipeline,
* server-error mapping, and OTel telemetry.
*
* When the consumer supplies a `fields` config map, the hook renders
* each entry into a `<Field>` chassis container (label / control /
* description / error) and returns the elements keyed by name. When
* omitted, `fields` is `null` and the consumer wires markup themselves.
*/
export function useDomainForm<TValues extends FieldValuesMap = FieldValuesMap>(
options: UseDomainFormOptions<TValues>
): UseDomainFormReturn<TValues & FieldValues> {
const resolver = useFormValidation<TValues>(toEngineConfig(options));
const useFormOptions = {
resolver: resolver as NonNullable<
UseFormProps<TValues & FieldValues>["resolver"]
>,
} as UseFormProps<TValues & FieldValues>;
if (options.defaultValues !== undefined) {
(
useFormOptions as {
defaultValues: Partial<TValues & FieldValues>;
}
).defaultValues = options.defaultValues as Partial<
TValues & FieldValues
>;
}
const form = useForm<TValues & FieldValues>(useFormOptions);
const [isSubmitting, setSubmitting] = useState(false);
const [serverError, setServerError] =
useState<ServerErrorState>(NO_SERVER_ERROR);
const submittingRef = useRef(false);
const submit = useCallback(
async (event?: React.BaseSyntheticEvent) => {
if (submittingRef.current) return;
submittingRef.current = true;
setSubmitting(true);
setServerError(NO_SERVER_ERROR);
const start = Date.now();
let outcome: "success" | "error" = "success";
try {
await form.handleSubmit(async (values) => {
try {
await options.onSubmit(values as unknown as TValues);
} catch (cause) {
outcome = "error";
const envelope = unwrapServerEnvelope(cause);
if (envelope !== undefined) {
const errors = mapServerErrors(envelope);
for (const [field, failure] of Object.entries(
errors
)) {
form.setError(
field as Parameters<
typeof form.setError
>[0],
{
type: "server",
message: failure.messageKey,
}
);
}
setServerError({ hasError: true, cause });
return;
}
setServerError({ hasError: true, cause });
throw new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed",
{ cause, context: { form: options.formId } }
);
}
})(event);
} catch (cause) {
outcome = "error";
if (!(cause instanceof ReactFormsError)) {
setServerError({ hasError: true, cause });
}
} finally {
submittingRef.current = false;
setSubmitting(false);
recordSubmitDuration(Date.now() - start, {
form: options.formId,
});
recordSubmitOutcome({
form: options.formId,
outcome,
});
}
},
[form, options]
);
// Validate the field config once per identity change. The throw at
// module-eval time satisfies the FIELD_KIND_INVALID contract;
// useMemo just memoises the validated map so we do not re-validate
// on every render.
const validatedConfigs = useMemo<Readonly<
Record<string, FieldConfig>
> | null>(() => {
if (options.fields === undefined) return null;
validateFieldConfigMap(
options.fields as Parameters<typeof validateFieldConfigMap>[0]
);
return options.fields as Readonly<Record<string, FieldConfig>>;
}, [options.fields]);
// Subscribe to RHF formState so a setError or input change re-runs
// this hook and re-renders the children with the latest invalid /
// error message state. The `void` reads are intentional — RHF's
// formState is a Proxy, accessing a key sets up the subscription.
void form.formState.errors;
void form.formState.isSubmitting;
const fields = useMemo<Readonly<
Record<string, ReactElement>
> | null>(() => {
if (validatedConfigs === null) return null;
const out: Record<string, ReactElement> = {};
for (const [name, config] of Object.entries(validatedConfigs)) {
out[name] = (
<RenderField
key={name}
name={name}
config={config as FieldConfig<TValues & FieldValues>}
form={form}
/>
);
}
return out;
// The `formState.errors` / `isSubmitting` reads above
// subscribe this hook to the RHF state slices needed for
// re-render after setError or async validation completes; the
// memo dep array intentionally only watches fields + form
// identity since the RHF Proxy reads above re-run on slice
// changes anyway.
}, [validatedConfigs, form]);
return useMemo<UseDomainFormReturn<TValues & FieldValues>>(
() => ({
form: form as unknown as UseDomainFormReturn<
TValues & FieldValues
>["form"],
submit,
isSubmitting,
serverError,
fields,
fieldConfigs: validatedConfigs,
}),
[form, submit, isSubmitting, serverError, fields, validatedConfigs]
);
}
function toEngineConfig<TValues extends FieldValuesMap>(
options: UseDomainFormOptions<TValues>
): ValidationEngineConfig<TValues> {
const config: {
-readonly [K in keyof ValidationEngineConfig<TValues>]: ValidationEngineConfig<TValues>[K];
} = { schema: options.schema };
if (options.crossFieldRules !== undefined) {
config.crossFieldRules = options.crossFieldRules;
}
if (options.asyncValidators !== undefined) {
config.asyncValidators = options.asyncValidators;
}
if (options.debounceMs !== undefined) {
config.debounceMs = options.debounceMs;
}
return config;
}

View File

@@ -0,0 +1,78 @@
import type { ReactElement } from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import type {
FieldConfig,
FieldConfigMap,
} from "../../factory/field-config/field-config.types.js";
import type {
FieldValuesMap,
ValidationEngineConfig,
} from "../validation-engine/validation-engine.types.js";
export interface UseDomainFormOptions<
TValues extends FieldValuesMap = FieldValuesMap,
> extends ValidationEngineConfig<TValues> {
/**
* Stable form id used in OTel attributes (`form` label on every
* submit/duration/error metric). Required so observability
* dashboards can attribute lifecycle events to a particular form
* without relying on inferred React component names.
*/
readonly formId: string;
/**
* Initial values mounted on the underlying RHF form. Optional —
* defaults to `{}`.
*/
readonly defaultValues?: Partial<TValues>;
/**
* Called when the form submits with values that pass every
* pipeline phase. The return value is awaited; if it throws, the
* thrown value flows through the server-error mapper before
* surfacing on `serverError`.
*/
readonly onSubmit: (values: TValues) => Promise<unknown>;
/**
* Optional per-field config map. When supplied, the hook returns a
* `fields` record where every entry is a pre-rendered `<Field>` (with
* label / description / error chrome) wrapping the appropriate input
* primitive. When omitted, `fields` is `null` and the consumer wires
* markup themselves via `form.register` / `Controller`.
*/
readonly fields?: FieldConfigMap<TValues & FieldValues>;
}
/**
* Server error envelope materialised on the form when `onSubmit`
* throws. Surfaced via the hook's return value so consumers can
* render top-of-form messages without subscribing to RHF state.
*/
export interface ServerErrorState {
readonly hasError: boolean;
readonly cause?: unknown;
}
export interface UseDomainFormReturn<
TValues extends FieldValues = FieldValues,
> {
/** The underlying react-hook-form context. */
readonly form: UseFormReturn<TValues>;
/** Submit handler bound to the form's `handleSubmit` flow. */
readonly submit: (event?: React.BaseSyntheticEvent) => Promise<void>;
/** True while a submit is in flight. */
readonly isSubmitting: boolean;
/** Server-error state from the most recent submit. */
readonly serverError: ServerErrorState;
/**
* Pre-rendered field elements keyed by field name. Populated when
* the consumer supplied a `fields` config to `useDomainForm`;
* `null` when no config was supplied (the consumer is wiring
* markup themselves).
*/
readonly fields: Readonly<Record<string, ReactElement>> | null;
/**
* The validated config map, exposed so factory layers can render
* `<RegisterForm.Field name="email" />` against the same shape
* `useDomainForm` used. `null` when no `fields` were supplied.
*/
readonly fieldConfigs: Readonly<Record<string, FieldConfig>> | null;
}

View File

@@ -0,0 +1,5 @@
export type {
FormSchema,
UseFormValidationOptions,
} from "./use-form-validation.js";
export { useFormValidation } from "./use-form-validation.js";

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,160 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, renderHook } from "@testing-library/react";
import z from "zod";
import { ValidationEngine } from "../validation-engine/validation-engine.js";
import { useFormValidation } from "./use-form-validation.js";
afterEach(() => {
cleanup();
});
describe("useFormValidation", () => {
const schema = z.object({
email: z.string().email(),
});
it("returns a resolver that yields parsed values when the schema passes", {
timeout: 5000,
}, async () => {
const { result } = renderHook(() => useFormValidation({ schema }));
const out = await result.current(
{ email: "alice@example.com" },
undefined,
{
fields: {},
shouldUseNativeValidation: undefined,
}
);
expect(out).toEqual({
values: { email: "alice@example.com" },
errors: {},
});
});
it("maps engine errors into RHF FieldErrors", {
timeout: 5000,
}, async () => {
const { result } = renderHook(() => useFormValidation({ schema }));
const out = await result.current({ email: "not-an-email" }, undefined, {
fields: {},
shouldUseNativeValidation: undefined,
});
expect(out.values).toEqual({});
const errs = out.errors as Record<
string,
{ type: string; message: string }
>;
expect(errs.email).toBeDefined();
expect(errs.email.type).toBe("validate");
expect(
errs.email.message.startsWith("react_forms.validation.zod.")
).toBe(true);
});
it("forwards messageArgs via the types.messageArgs slot", {
timeout: 5000,
}, async () => {
const { result } = renderHook(() => useFormValidation({ schema }));
const out = await result.current({ email: "bad" }, undefined, {
fields: {},
shouldUseNativeValidation: undefined,
});
const errs = out.errors as Record<
string,
{ type: string; message: string; types?: { messageArgs: unknown } }
>;
expect(errs.email.types?.messageArgs).toBeDefined();
});
it("reuses a caller-supplied engine when provided", {
timeout: 5000,
}, () => {
const engine = new ValidationEngine({ schema });
const { result, rerender } = renderHook(
({ e }) => useFormValidation({ schema, engine: e }),
{ initialProps: { e: engine } }
);
const first = result.current;
rerender({ e: engine });
expect(result.current).toBe(first);
});
it("omits the types.messageArgs slot when the engine emits a no-args failure", {
timeout: 5000,
}, async () => {
const noArgsRule = {
name: "no-args",
fields: ["email"],
validate: () => [
{
field: "email",
messageKey: "form.email.bad" as never,
},
],
} as const;
const { result } = renderHook(() =>
useFormValidation({
schema,
crossFieldRules: [noArgsRule],
})
);
const out = await result.current(
{ email: "alice@example.com" },
undefined,
{
fields: {},
shouldUseNativeValidation: undefined,
}
);
const errs = out.errors as Record<
string,
{
type: string;
message: string;
types?: { messageArgs: unknown };
}
>;
expect(errs.email).toBeDefined();
expect(errs.email.types).toBeUndefined();
});
it("forwards every optional config field through toEngineConfig", {
timeout: 5000,
}, () => {
const rule = {
name: "trace",
fields: ["email"],
validate: (): null => null,
} as const;
const validator = async () =>
Promise.resolve({
valid: true as const,
});
const { result } = renderHook(() =>
useFormValidation({
schema,
crossFieldRules: [rule],
asyncValidators: [{ field: "email", validator }],
debounceMs: 25,
})
);
expect(typeof result.current).toBe("function");
});
it("cancels the engine on unmount so async validators do not leak", {
timeout: 5000,
}, () => {
let cancelled = false;
const engine = new ValidationEngine({ schema });
const realCancel = engine.cancel.bind(engine);
engine.cancel = () => {
cancelled = true;
realCancel();
};
const { unmount } = renderHook(() =>
useFormValidation({ schema, engine })
);
unmount();
expect(cancelled).toBe(true);
});
});

View File

@@ -0,0 +1,152 @@
import { useEffect, useMemo, useRef } from "react";
import type {
FieldErrors,
FieldValues,
Resolver,
ResolverResult,
} from "react-hook-form";
import type { z } from "zod";
import { ValidationEngine } from "../validation-engine/validation-engine.js";
import type {
FieldValuesMap,
ValidationEngineConfig,
} from "../validation-engine/validation-engine.types.js";
export interface UseFormValidationOptions<
TValues extends FieldValuesMap = FieldValuesMap,
> extends ValidationEngineConfig<TValues> {
/**
* Optional caller-supplied engine. When provided, the hook reuses
* it instead of constructing a new one — useful for tests that
* want to assert engine state across multiple renders.
*/
readonly engine?: ValidationEngine<TValues>;
}
/**
* Hook that wraps a `ValidationEngine` instance into a react-hook-form-
* compatible `Resolver`. The engine is constructed once per
* `(schema, crossFieldRules, asyncValidators)` triple and is cancelled
* automatically on unmount.
*
* The returned resolver is stable across re-renders unless the input
* config object identity changes — consumers wanting fresh engines per
* render should memoise the config externally.
*/
export function useFormValidation<
TValues extends FieldValuesMap = FieldValuesMap,
>(options: UseFormValidationOptions<TValues>): Resolver<TValues> {
const engineRef = useRef<ValidationEngine<TValues> | undefined>(undefined);
const engine = useMemo(() => {
if (options.engine !== undefined) return options.engine;
const next = new ValidationEngine<TValues>(toEngineConfig(options));
return next;
}, [
options.engine,
options.schema,
options.crossFieldRules,
options.asyncValidators,
options.debounceMs,
options,
]);
engineRef.current = engine;
useEffect(() => {
return () => {
engineRef.current?.cancel();
};
}, []);
return useMemo<Resolver<TValues>>(() => {
return (async (values) => {
const result = await engine.resolve(values as TValues);
return toResolverResult<TValues>(
result.values as TValues | undefined,
result.errors
);
}) as Resolver<TValues>;
}, [engine]);
}
interface EngineFailureLike {
readonly messageKey: string;
readonly messageArgs?: Readonly<Record<string, unknown>>;
}
/**
* Convert the engine's internal `EngineFieldErrors` into RHF's
* `FieldErrors<TFieldValues>` shape.
*
* RHF expects `errors` keyed by field path with a `{ type, message }`
* shape. The `type` is a stable RHF discriminator (we use `"validate"`,
* which is RHF's catch-all custom-validation type), and `message` is
* the engine's `messageKey` so downstream `useErrorMessage` /
* translation hooks can resolve the catalog entry.
*
* The `messageArgs` are surfaced via the `types` field per RHF
* convention so consumers can extract them via
* `formState.errors.email.types?.messageArgs`.
*/
function toResolverResult<TValues extends FieldValues>(
values: TValues | undefined,
errors: Record<string, EngineFailureLike>
): ResolverResult<TValues> {
if (values !== undefined && Object.keys(errors).length === 0) {
return {
values,
errors: {} as Record<string, never>,
};
}
const out: Record<string, unknown> = {};
for (const [field, failure] of Object.entries(errors)) {
out[field] = {
type: "validate",
message: failure.messageKey,
...(failure.messageArgs !== undefined
? {
types: {
messageArgs: failure.messageArgs as unknown as string,
},
}
: {}),
};
}
return {
values: {} as Record<string, never>,
errors: out as FieldErrors<TValues>,
};
}
/**
* Reusable schema constructor type alias so consumers writing custom
* resolvers can reuse our engine config shape without re-importing
* Zod from a different path. Kept as an alias only — no runtime
* symbol.
*/
export type FormSchema<T extends FieldValuesMap> = z.ZodType<T>;
/**
* Build a `ValidationEngineConfig` from the hook options without
* emitting `undefined` members under `exactOptionalPropertyTypes`.
*/
function toEngineConfig<TValues extends FieldValuesMap>(
options: UseFormValidationOptions<TValues>
): ValidationEngineConfig<TValues> {
const config: {
-readonly [K in keyof ValidationEngineConfig<TValues>]: ValidationEngineConfig<TValues>[K];
} = {
schema: options.schema,
};
if (options.crossFieldRules !== undefined) {
config.crossFieldRules = options.crossFieldRules;
}
if (options.asyncValidators !== undefined) {
config.asyncValidators = options.asyncValidators;
}
if (options.debounceMs !== undefined) {
config.debounceMs = options.debounceMs;
}
return config;
}

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,67 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defineCrossFieldRule, violate } from "./cross-field-rule.js";
describe("defineCrossFieldRule", () => {
it("returns the supplied options unchanged so type inference flows", {
timeout: 5000,
}, () => {
interface Values {
password: string;
passwordConfirm: string;
}
const rule = defineCrossFieldRule<Values>({
name: "passwords-match",
fields: ["password", "passwordConfirm"],
validate: ({ password, passwordConfirm }) =>
password === passwordConfirm
? null
: [
violate(
"passwordConfirm",
"auth.password.mismatch" as TranslationKey
),
],
});
expect(rule.name).toBe("passwords-match");
expect(rule.fields).toEqual(["password", "passwordConfirm"]);
expect(
rule.validate({ password: "a", passwordConfirm: "a" })
).toBeNull();
expect(rule.validate({ password: "a", passwordConfirm: "b" })).toEqual([
{
field: "passwordConfirm",
messageKey: "auth.password.mismatch",
},
]);
});
});
describe("violate", () => {
it("constructs a violation without messageArgs by default", {
timeout: 5000,
}, () => {
const v = violate(
"passwordConfirm",
"auth.password.mismatch" as TranslationKey
);
expect(v).toEqual({
field: "passwordConfirm",
messageKey: "auth.password.mismatch",
});
expect(Object.hasOwn(v, "messageArgs")).toBe(false);
});
it("includes messageArgs when supplied", { timeout: 5000 }, () => {
const v = violate(
"endDate",
"form.date.before_start" as TranslationKey,
{ startDate: "2025-01-01" }
);
expect(v).toEqual({
field: "endDate",
messageKey: "form.date.before_start",
messageArgs: { startDate: "2025-01-01" },
});
});
});

View File

@@ -0,0 +1,50 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import type {
CrossFieldRule,
CrossFieldViolation,
FieldValuesMap,
} from "./validation-engine.types.js";
export interface CrossFieldRuleOptions<
TValues extends FieldValuesMap = FieldValuesMap,
> {
/** Stable rule name — surfaces in telemetry and logs. */
readonly name: string;
/** Field paths the rule depends on. */
readonly fields: readonly string[];
/**
* Pure validator. Returns `null` when the constraint holds, or
* one or more `CrossFieldViolation` values when it fails.
*/
readonly validate: (values: TValues) => CrossFieldViolation[] | null;
}
/**
* `defineCrossFieldRule` — convenience factory for cross-field rules.
*
* The factory is a pass-through: it takes the supplied options and
* returns them as a `CrossFieldRule`. It exists primarily to give
* consumers a typed call site (with full inference of `TValues` from
* the schema) without having to import the interface and write the
* shape literal by hand.
*/
export function defineCrossFieldRule<
TValues extends FieldValuesMap = FieldValuesMap,
>(opts: CrossFieldRuleOptions<TValues>): CrossFieldRule<TValues> {
return opts;
}
/**
* Helper that constructs a single-field violation. Consumers writing
* `validate` bodies often need to emit one violation per failed
* constraint; this helper saves them the curly braces.
*/
export function violate(
field: string,
messageKey: TranslationKey,
messageArgs?: Readonly<Record<string, unknown>>
): CrossFieldViolation {
return messageArgs === undefined
? { field, messageKey }
: { field, messageKey, messageArgs };
}

View File

@@ -0,0 +1,23 @@
export type { CrossFieldRuleOptions } from "./cross-field-rule.js";
export {
defineCrossFieldRule,
violate,
} from "./cross-field-rule.js";
export {
mapServerErrors,
throwServerErrorMapped,
unsafeAssertTranslationKey,
unwrapServerEnvelope,
} from "./server-error-mapper.js";
export { ValidationEngine } from "./validation-engine.js";
export type {
AsyncValidatorRegistration,
CrossFieldRule,
CrossFieldViolation,
EngineFieldErrors,
FieldValuesMap,
ServerErrorEnvelope,
ServerFieldError,
ValidationEngineConfig,
ValidationResult,
} from "./validation-engine.types.js";

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,182 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import {
getReactFormsMetricsState,
resetReactFormsMetricsCache,
resetReactFormsMetricsState,
} from "../../observability/react-forms-metrics.js";
import {
mapServerErrors,
throwServerErrorMapped,
unsafeAssertTranslationKey,
unwrapServerEnvelope,
} from "./server-error-mapper.js";
afterEach(() => {
resetReactFormsMetricsState();
resetReactFormsMetricsCache();
});
describe("mapServerErrors", () => {
it("returns an empty record when the envelope has no fieldErrors", {
timeout: 5000,
}, () => {
expect(mapServerErrors({})).toEqual({});
});
it("maps each field error into a ValidatorFailure keyed by field path", {
timeout: 5000,
}, () => {
const out = mapServerErrors({
fieldErrors: [
{
field: "email",
messageKey: "auth.email.taken" as TranslationKey,
},
{
field: "username",
messageKey: "auth.username.taken" as TranslationKey,
messageArgs: { suggestion: "alice2" },
},
],
});
expect(out).toEqual({
email: { valid: false, messageKey: "auth.email.taken" },
username: {
valid: false,
messageKey: "auth.username.taken",
messageArgs: { suggestion: "alice2" },
},
});
});
it("emits one validation_error_total{kind=server} per mapped entry", {
timeout: 5000,
}, () => {
mapServerErrors({
fieldErrors: [
{
field: "email",
messageKey: "auth.email.taken" as TranslationKey,
},
{
field: "username",
messageKey: "auth.username.taken" as TranslationKey,
},
],
});
expect(getReactFormsMetricsState().validationErrorTotal).toEqual([
{ attributes: { kind: "server" } },
{ attributes: { kind: "server" } },
]);
});
});
describe("unwrapServerEnvelope", () => {
it("recognises ReactFormsError with context.fieldErrors", {
timeout: 5000,
}, () => {
const error = new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed",
{
context: {
fieldErrors: [
{
field: "email",
messageKey: "auth.email.taken",
},
],
},
}
);
expect(unwrapServerEnvelope(error)).toEqual({
fieldErrors: [{ field: "email", messageKey: "auth.email.taken" }],
});
});
it("recognises a plain object with fieldErrors", { timeout: 5000 }, () => {
const cause = {
fieldErrors: [{ field: "x", messageKey: "form.x.bad" }],
};
expect(unwrapServerEnvelope(cause)).toEqual({
fieldErrors: [{ field: "x", messageKey: "form.x.bad" }],
});
});
it("returns undefined for non-envelope values", { timeout: 5000 }, () => {
expect(unwrapServerEnvelope(undefined)).toBeUndefined();
expect(unwrapServerEnvelope(null)).toBeUndefined();
expect(unwrapServerEnvelope(new Error("x"))).toBeUndefined();
expect(unwrapServerEnvelope({})).toBeUndefined();
expect(
unwrapServerEnvelope({ fieldErrors: "not-array" })
).toBeUndefined();
});
it("returns undefined when fieldErrors entries are malformed", {
timeout: 5000,
}, () => {
expect(
unwrapServerEnvelope({
fieldErrors: [{ field: 1, messageKey: "x" }],
})
).toBeUndefined();
expect(
unwrapServerEnvelope({
fieldErrors: [{ field: "x", messageKey: 1 }],
})
).toBeUndefined();
expect(unwrapServerEnvelope({ fieldErrors: [null] })).toBeUndefined();
});
it("returns undefined when ReactFormsError has no fieldErrors context", {
timeout: 5000,
}, () => {
const error = new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed"
);
expect(unwrapServerEnvelope(error)).toBeUndefined();
});
});
describe("throwServerErrorMapped", () => {
it("throws a structured ReactFormsError with the envelope on the context", {
timeout: 5000,
}, () => {
const root = new Error("root cause");
const envelope = {
fieldErrors: [
{
field: "email",
messageKey: "auth.email.taken" as TranslationKey,
},
],
};
expect(() => throwServerErrorMapped(envelope, root)).toThrow(
ReactFormsError
);
try {
throwServerErrorMapped(envelope, root);
} catch (cause) {
if (cause instanceof ReactFormsError) {
expect(cause.code).toBe(REACT_FORMS_CODES.SERVER_ERROR_MAPPED);
expect(cause.cause).toBe(root);
expect(cause.context?.fieldErrors).toEqual(
envelope.fieldErrors
);
}
}
});
});
describe("unsafeAssertTranslationKey", () => {
it("brands a plain string as a TranslationKey at the type level", {
timeout: 5000,
}, () => {
const key = unsafeAssertTranslationKey("form.x");
expect(key).toBe("form.x");
});
});

View File

@@ -0,0 +1,119 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import { recordValidationError } from "../../observability/react-forms-metrics.js";
import type { ValidatorFailure } from "../../validation/validator.types.js";
import type {
EngineFieldErrors,
ServerErrorEnvelope,
ServerFieldError,
} from "./validation-engine.types.js";
/**
* Map a `ServerErrorEnvelope` (or a thrown `ReactFormsError` whose
* context carries `fieldErrors`) into the `EngineFieldErrors` shape
* the engine consumes. Each entry produces one
* `ValidatorFailure` keyed by the field path.
*
* Increments `react_forms.validation.error_total{kind="server"}` once
* per mapped field error so dashboards can count server-driven
* rejection rates separately from schema and async-validation
* rejections.
*/
export function mapServerErrors(
envelope: ServerErrorEnvelope
): EngineFieldErrors {
const out: EngineFieldErrors = {};
const fieldErrors = envelope.fieldErrors;
if (fieldErrors === undefined) return out;
for (const entry of fieldErrors) {
out[entry.field] = toFailure(entry);
recordValidationError({ kind: "server" });
}
return out;
}
/**
* Convert one `ServerFieldError` into a `ValidatorFailure`.
*/
function toFailure(entry: ServerFieldError): ValidatorFailure {
return entry.messageArgs === undefined
? { valid: false, messageKey: entry.messageKey }
: {
valid: false,
messageKey: entry.messageKey,
messageArgs: entry.messageArgs,
};
}
/**
* Throw a structured `ReactFormsError(REACT_FORMS.SERVER_ERROR_MAPPED)`
* whose context carries the mapped field errors, suitable for
* surfacing the mapping outcome to error boundaries.
*/
export function throwServerErrorMapped(
envelope: ServerErrorEnvelope,
cause?: unknown
): never {
throw new ReactFormsError(
REACT_FORMS_CODES.SERVER_ERROR_MAPPED,
"react_forms.error.server_error_mapped",
{
cause,
context: {
fieldErrors: envelope.fieldErrors as readonly unknown[],
},
}
);
}
/**
* Inspect a thrown value and extract the `ServerErrorEnvelope` from
* it, if possible. Recognises:
*
* - A `ReactFormsError` whose `context.fieldErrors` is an array of
* `ServerFieldError`-shaped objects.
* - A plain object literally matching `{ fieldErrors: [...] }`.
*
* Anything else returns `undefined` and the caller should re-throw
* the original value.
*/
export function unwrapServerEnvelope(
cause: unknown
): ServerErrorEnvelope | undefined {
if (cause instanceof ReactFormsError) {
const fe = cause.context?.fieldErrors;
if (isFieldErrorArray(fe)) return { fieldErrors: fe };
}
if (typeof cause === "object" && cause !== null && "fieldErrors" in cause) {
const fe = (cause as { fieldErrors?: unknown }).fieldErrors;
if (isFieldErrorArray(fe)) return { fieldErrors: fe };
}
return undefined;
}
function isFieldErrorArray(
value: unknown
): value is readonly ServerFieldError[] {
if (!Array.isArray(value)) return false;
for (const entry of value) {
if (
typeof entry !== "object" ||
entry === null ||
typeof (entry as ServerFieldError).field !== "string" ||
typeof (entry as ServerFieldError).messageKey !== "string"
) {
return false;
}
}
return true;
}
/**
* Test-only helper: assert at compile time that a key string can be
* narrowed to `TranslationKey` once the consumer registers it via
* module augmentation. Real callers should use `t(messageKey,
* messageArgs)` directly via `useTranslation()`.
*/
export function unsafeAssertTranslationKey(value: string): TranslationKey {
return value as TranslationKey;
}

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,377 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import {
getReactFormsMetricsState,
resetReactFormsMetricsCache,
resetReactFormsMetricsState,
} from "../../observability/react-forms-metrics.js";
import type { AsyncValidator } from "../../validation/validator.types.js";
import { defineCrossFieldRule, violate } from "./cross-field-rule.js";
import { ValidationEngine } from "./validation-engine.js";
afterEach(() => {
resetReactFormsMetricsState();
resetReactFormsMetricsCache();
});
describe("ValidationEngine — phase 1: schema", () => {
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
it("returns the parsed values when the schema passes and no other phases run", {
timeout: 5000,
}, async () => {
const engine = new ValidationEngine({ schema });
const out = await engine.resolve({
email: "alice@example.com",
password: "longenough",
});
expect(out.values).toEqual({
email: "alice@example.com",
password: "longenough",
});
expect(out.errors).toEqual({});
});
it("returns schema errors keyed by field path with a stable messageKey namespace", {
timeout: 5000,
}, async () => {
const engine = new ValidationEngine({ schema });
const out = await engine.resolve({
email: "not-an-email",
password: "short",
});
expect(out.values).toBeUndefined();
expect(Object.keys(out.errors).sort()).toEqual(["email", "password"]);
const emailErr = out.errors.email;
if (emailErr) {
expect(
emailErr.messageKey.startsWith("react_forms.validation.zod.")
).toBe(true);
}
});
it("first-issue-wins per field path when Zod surfaces multiple issues on the same path", {
timeout: 5000,
}, async () => {
// .min(8).regex(...) on the same field surfaces two issues
// when the value violates both. The engine keeps the first.
const composite = z.object({
password: z.string().min(8).regex(/[A-Z]/),
});
const engine = new ValidationEngine({ schema: composite });
const out = await engine.resolve({ password: "abc" });
expect(out.values).toBeUndefined();
expect(out.errors.password).toBeDefined();
});
it("throws REACT_FORMS.SCHEMA_INVALID when constructed without a Zod schema", {
timeout: 5000,
}, () => {
expect(
() =>
new ValidationEngine({
schema: {} as unknown as z.ZodType<unknown>,
})
).toThrow(ReactFormsError);
let captured: unknown;
try {
const _e = new ValidationEngine({
schema: undefined as unknown as z.ZodType<unknown>,
});
void _e;
} catch (cause) {
captured = cause;
}
expect(captured).toBeInstanceOf(ReactFormsError);
if (captured instanceof ReactFormsError) {
expect(captured.code).toBe(REACT_FORMS_CODES.SCHEMA_INVALID);
}
});
});
describe("ValidationEngine — phase 2: cross-field", () => {
const schema = z.object({
password: z.string().min(8),
passwordConfirm: z.string(),
});
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
),
],
});
it("runs cross-field rules after the schema and surfaces violations", {
timeout: 5000,
}, async () => {
const engine = new ValidationEngine({
schema,
crossFieldRules: [passwordsMatch],
});
const out = await engine.resolve({
password: "longenough",
passwordConfirm: "different",
});
expect(out.values).toBeUndefined();
expect(out.errors).toEqual({
passwordConfirm: {
valid: false,
messageKey: "auth.password.mismatch",
},
});
});
it("does NOT run cross-field rules when the schema fails", {
timeout: 5000,
}, async () => {
let crossRan = 0;
const guardRule = defineCrossFieldRule({
name: "trace",
fields: ["password"],
validate: () => {
crossRan += 1;
return null;
},
});
const engine = new ValidationEngine({
schema,
crossFieldRules: [guardRule],
});
await engine.resolve({
password: "short",
passwordConfirm: "short",
});
expect(crossRan).toBe(0);
});
it("preserves messageArgs on cross-field violations", {
timeout: 5000,
}, async () => {
const ruleWithArgs = defineCrossFieldRule({
name: "with-args",
fields: ["password", "passwordConfirm"],
validate: () => [
violate(
"passwordConfirm",
"auth.password.mismatch" as TranslationKey,
{ strength: 4 }
),
],
});
const engine = new ValidationEngine({
schema,
crossFieldRules: [ruleWithArgs],
});
const out = await engine.resolve({
password: "longenough",
passwordConfirm: "different",
});
expect(out.errors.passwordConfirm).toEqual({
valid: false,
messageKey: "auth.password.mismatch",
messageArgs: { strength: 4 },
});
});
it("skips cross-field rules that return null", {
timeout: 5000,
}, async () => {
const noopRule = defineCrossFieldRule({
name: "noop",
fields: ["password"],
validate: () => null,
});
const engine = new ValidationEngine({
schema,
crossFieldRules: [noopRule],
});
const out = await engine.resolve({
password: "longenough",
passwordConfirm: "longenough",
});
expect(out.errors).toEqual({});
expect(out.values).toEqual({
password: "longenough",
passwordConfirm: "longenough",
});
});
it("first-error-wins per field across multiple cross-field rules", {
timeout: 5000,
}, async () => {
const ruleA = defineCrossFieldRule({
name: "rule-a",
fields: ["passwordConfirm"],
validate: () => [
violate("passwordConfirm", "fail.first" as TranslationKey),
],
});
const ruleB = defineCrossFieldRule({
name: "rule-b",
fields: ["passwordConfirm"],
validate: () => [
violate("passwordConfirm", "fail.second" as TranslationKey),
],
});
const engine = new ValidationEngine({
schema,
crossFieldRules: [ruleA, ruleB],
});
const out = await engine.resolve({
password: "longenough",
passwordConfirm: "longenough",
});
expect(out.errors.passwordConfirm?.messageKey).toBe("fail.first");
});
it("emits cross-field error metric attribute kind=cross-field", {
timeout: 5000,
}, async () => {
const engine = new ValidationEngine({
schema,
crossFieldRules: [passwordsMatch],
});
await engine.resolve({
password: "longenough",
passwordConfirm: "different",
});
expect(getReactFormsMetricsState().validationErrorTotal).toContainEqual(
{ attributes: { kind: "cross-field" } }
);
});
});
describe("ValidationEngine — phase 3: async validators", () => {
const schema = z.object({
email: z.string().email(),
});
function buildValidator(
result: "ok" | "fail" | "delay-fail",
delayMs = 0
): AsyncValidator<unknown> {
return async (_value, signal) => {
if (delayMs > 0) {
await new Promise<void>((resolve) => {
setTimeout(resolve, delayMs);
});
}
if (signal.aborted) {
return {
valid: false,
messageKey:
"react_forms.validation.async_aborted" as TranslationKey,
};
}
if (result === "ok") return { valid: true };
return {
valid: false,
messageKey: "auth.email.taken" as TranslationKey,
};
};
}
it("runs async validators when schema and cross-field pass", {
timeout: 5000,
}, async () => {
const engine = new ValidationEngine({
schema,
asyncValidators: [
{ field: "email", validator: buildValidator("fail") },
],
});
const out = await engine.resolve({
email: "alice@example.com",
});
expect(out.values).toBeUndefined();
expect(out.errors.email).toEqual({
valid: false,
messageKey: "auth.email.taken",
});
});
it("returns the parsed values when async validators all pass", {
timeout: 5000,
}, async () => {
const engine = new ValidationEngine({
schema,
asyncValidators: [
{ field: "email", validator: buildValidator("ok") },
],
});
const out = await engine.resolve({
email: "alice@example.com",
});
expect(out.values).toEqual({ email: "alice@example.com" });
expect(out.errors).toEqual({});
});
it("does NOT run async validators when the schema fails", {
timeout: 5000,
}, async () => {
let asyncRan = 0;
const trace: AsyncValidator<unknown> = async () => {
asyncRan += 1;
return { valid: true };
};
const engine = new ValidationEngine({
schema,
asyncValidators: [{ field: "email", validator: trace }],
});
await engine.resolve({ email: "not-an-email" });
expect(asyncRan).toBe(0);
});
it("cancels the previous run when resolve() is called again", {
timeout: 5000,
}, async () => {
const slow = buildValidator("fail", 50);
const engine = new ValidationEngine({
schema,
asyncValidators: [{ field: "email", validator: slow }],
});
const p1 = engine.resolve({ email: "a@example.com" });
const p2 = engine.resolve({ email: "b@example.com" });
const [r1, r2] = await Promise.all([p1, p2]);
// The first run is superseded — its result is empty (aborted)
expect(r1.errors).toEqual({});
expect(r1.values).toBeUndefined();
// The second run resolved normally and reported the failure.
expect(r2.errors.email).toBeDefined();
});
it("cancel() aborts the in-flight signal", { timeout: 5000 }, async () => {
let observedAbort = false;
const watch: AsyncValidator<unknown> = async (_v, signal) => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 30);
});
observedAbort = signal.aborted;
return { valid: true };
};
const engine = new ValidationEngine({
schema,
asyncValidators: [{ field: "email", validator: watch }],
});
const p = engine.resolve({ email: "alice@example.com" });
engine.cancel();
await p;
expect(observedAbort).toBe(true);
});
});

View File

@@ -0,0 +1,173 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import {
recordValidationDuration,
recordValidationError,
} from "../../observability/react-forms-metrics.js";
import type { ValidatorFailure } from "../../validation/validator.types.js";
import type {
AsyncValidatorRegistration,
CrossFieldRule,
CrossFieldViolation,
EngineFieldErrors,
FieldValuesMap,
ValidationEngineConfig,
ValidationResult,
} from "./validation-engine.types.js";
/**
* Coordinates the four-phase validation pipeline:
* 1. Zod schema (synchronous)
* 2. Cross-field rules (synchronous, runs only when phase 1 passes)
* 3. Per-field async validators (debounced, AbortSignal-cancellable)
* 4. Server-error mapping (called from `submit.onSubmit` failure path)
*
* Phases 1 and 2 short-circuit phase 3 — if the schema or a cross-
* field rule fails, async validators are not invoked. The failure
* map collected from earlier phases takes precedence per field.
*/
export class ValidationEngine<TValues extends FieldValuesMap = FieldValuesMap> {
private readonly schema: ValidationEngineConfig<TValues>["schema"];
private readonly crossFieldRules: ReadonlyArray<CrossFieldRule<TValues>>;
private readonly asyncValidators: ReadonlyArray<AsyncValidatorRegistration>;
private currentRunId = 0;
private currentSignal: AbortController = new AbortController();
public constructor(config: ValidationEngineConfig<TValues>) {
if (
config.schema === undefined ||
typeof (config.schema as unknown as { safeParse?: unknown })
.safeParse !== "function"
) {
throw new ReactFormsError(
REACT_FORMS_CODES.SCHEMA_INVALID,
"react_forms.error.schema_invalid",
{ context: { stage: "ValidationEngine.constructor" } }
);
}
this.schema = config.schema;
this.crossFieldRules = config.crossFieldRules ?? [];
this.asyncValidators = config.asyncValidators ?? [];
}
/**
* Run the four-phase pipeline. Returns the resolved values and
* any field errors. Cancels any in-flight pipeline run via
* `AbortController.abort()` before starting a new one so async
* validators do not race to set stale errors.
*/
public async resolve(values: TValues): Promise<ValidationResult<TValues>> {
this.currentSignal.abort();
this.currentSignal = new AbortController();
this.currentRunId += 1;
const runId = this.currentRunId;
const signal = this.currentSignal.signal;
const start = Date.now();
const errors: EngineFieldErrors = {};
// Phase 1: Zod schema
const parsed = this.schema.safeParse(values);
if (!parsed.success) {
for (const issue of parsed.error.issues) {
const path = issue.path.join(".");
const failure: ValidatorFailure = {
valid: false,
messageKey:
`react_forms.validation.zod.${issue.code}` as TranslationKey,
messageArgs: { message: issue.message },
};
if (errors[path] === undefined) errors[path] = failure;
recordValidationError({ kind: "schema" });
}
recordValidationDuration(Date.now() - start);
return { values: undefined, errors };
}
const parsedValues = parsed.data as TValues;
// Phase 2: cross-field rules
for (const rule of this.crossFieldRules) {
const out = rule.validate(parsedValues);
if (out === null) continue;
for (const violation of out) {
this.recordCrossFieldViolation(violation, errors);
}
}
if (Object.keys(errors).length > 0) {
recordValidationDuration(Date.now() - start);
return { values: undefined, errors };
}
// Phase 3: async validators
if (this.asyncValidators.length > 0) {
const asyncErrors = await this.runAsyncPhase(
parsedValues,
signal,
runId
);
if (runId !== this.currentRunId) {
// A newer resolve() superseded us — surface an aborted
// result; the caller should ignore this run's errors.
recordValidationDuration(Date.now() - start);
return { values: undefined, errors: {} };
}
// The async phase only runs when phases 1-2 emitted no
// errors (see the `Object.keys(errors).length > 0` guard
// above), so `errors[field]` is always undefined here.
// Assign directly without the redundant guard.
for (const [field, failure] of Object.entries(asyncErrors)) {
errors[field] = failure;
}
}
recordValidationDuration(Date.now() - start);
return Object.keys(errors).length === 0
? { values: parsedValues, errors: {} }
: { values: undefined, errors };
}
/**
* Cancel any in-flight async validators. Useful on form unmount.
*/
public cancel(): void {
this.currentSignal.abort();
}
private recordCrossFieldViolation(
violation: CrossFieldViolation,
errors: EngineFieldErrors
): void {
const failure: ValidatorFailure =
violation.messageArgs === undefined
? { valid: false, messageKey: violation.messageKey }
: {
valid: false,
messageKey: violation.messageKey,
messageArgs: violation.messageArgs,
};
if (errors[violation.field] === undefined) {
errors[violation.field] = failure;
}
recordValidationError({ kind: "cross-field" });
}
private async runAsyncPhase(
values: TValues,
signal: AbortSignal,
runId: number
): Promise<EngineFieldErrors> {
const out: EngineFieldErrors = {};
const tasks = this.asyncValidators.map(async (entry) => {
const fieldValue = (values as Record<string, unknown>)[entry.field];
const result = await entry.validator(fieldValue, signal);
if (runId !== this.currentRunId || signal.aborted) return;
if (!result.valid) {
out[entry.field] = result;
recordValidationError({ kind: "async" });
}
});
await Promise.all(tasks);
return out;
}
}

View File

@@ -0,0 +1,105 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import type { z } from "zod";
import type {
AsyncValidator,
ValidatorFailure,
} from "../../validation/validator.types.js";
export type FieldValuesMap = Record<string, unknown>;
/**
* A cross-field rule. The `validate` function receives the entire
* form values object after the Zod schema has passed and returns
* either `null` (no violation) or a `CrossFieldViolation[]` describing
* which fields are wrong and why.
*/
export interface CrossFieldRule<
TValues extends FieldValuesMap = FieldValuesMap,
> {
/** Stable rule name — used for telemetry and logs. */
readonly name: string;
/**
* Set of field paths the rule reads. The engine uses this list to
* decide whether the rule needs to re-run when a particular field
* changes; rules whose declared fields are unchanged are skipped.
*/
readonly fields: readonly string[];
/**
* Pure function that inspects the supplied values and returns
* `null` when the constraint holds, or one or more violations.
*/
readonly validate: (values: TValues) => CrossFieldViolation[] | null;
}
export interface CrossFieldViolation {
/** Field path receiving the error. */
readonly field: string;
/** Translation key to render. */
readonly messageKey: TranslationKey;
/** Optional ICU args for the message. */
readonly messageArgs?: Readonly<Record<string, unknown>>;
}
/**
* Per-field async validator registration. The engine debounces +
* dedups + cancels async validators per field; the consumer just
* supplies the validator factory output (the validator itself).
*/
export interface AsyncValidatorRegistration {
readonly field: string;
readonly validator: AsyncValidator<unknown>;
}
/**
* Server-side error envelope. When `submit.onSubmit` throws (or
* returns) a `ServerErrorEnvelope`, the engine maps each entry into
* a RHF field error.
*/
export interface ServerErrorEnvelope {
readonly fieldErrors?: ReadonlyArray<ServerFieldError>;
}
export interface ServerFieldError {
/** Field path receiving the error. */
readonly field: string;
/** Translation key (or stable error code) to render. */
readonly messageKey: TranslationKey;
/** Optional ICU args. */
readonly messageArgs?: Readonly<Record<string, unknown>>;
}
export interface ValidationEngineConfig<
TValues extends FieldValuesMap = FieldValuesMap,
> {
/** Zod schema spine. */
readonly schema: z.ZodType<TValues>;
/** Cross-field rules; default `[]`. */
readonly crossFieldRules?: ReadonlyArray<CrossFieldRule<TValues>>;
/** Async validators per field; default `[]`. */
readonly asyncValidators?: ReadonlyArray<AsyncValidatorRegistration>;
/**
* Debounce window for async validators in milliseconds. Default
* 300 ms. Forwarded to each registered async validator's debounce
* if the validator's own factory does not pin one.
*/
readonly debounceMs?: number;
}
/**
* Per-field collected errors after a `resolve()` pass. The engine
* stores at most one failure per field — the first one detected in
* the schema → cross-field → async pipeline.
*/
export type EngineFieldErrors = Record<string, ValidatorFailure>;
/**
* Outcome of a single `resolve()` pass.
*/
export interface ValidationResult<
TValues extends FieldValuesMap = FieldValuesMap,
> {
/** Parsed values when validation passed; `undefined` on failure. */
readonly values: TValues | undefined;
/** Per-field errors collected; empty record on success. */
readonly errors: EngineFieldErrors;
}

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,37 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { REACT_FORMS_CODES, type ReactFormsCode } from "./error-codes.js";
describe("REACT_FORMS_CODES registry", () => {
it("exposes the thirteen codes named in the proposal as REACT_FORMS.<reason> strings", {
timeout: 5000,
}, () => {
expect(REACT_FORMS_CODES).toEqual({
VALIDATION_FAILED: "REACT_FORMS.VALIDATION_FAILED",
SUBMIT_FAILED: "REACT_FORMS.SUBMIT_FAILED",
ASYNC_VALIDATOR_TIMEOUT: "REACT_FORMS.ASYNC_VALIDATOR_TIMEOUT",
ASYNC_VALIDATOR_ABORTED: "REACT_FORMS.ASYNC_VALIDATOR_ABORTED",
SERVER_ERROR_MAPPED: "REACT_FORMS.SERVER_ERROR_MAPPED",
API_CLIENT_NOT_FOUND: "REACT_FORMS.API_CLIENT_NOT_FOUND",
AUTH_CONTEXT_NOT_FOUND: "REACT_FORMS.AUTH_CONTEXT_NOT_FOUND",
FIELD_KIND_INVALID: "REACT_FORMS.FIELD_KIND_INVALID",
SCHEMA_INVALID: "REACT_FORMS.SCHEMA_INVALID",
HARDCODED_STRING_DETECTED: "REACT_FORMS.HARDCODED_STRING_DETECTED",
CONTEXT_NOT_PROVIDED: "REACT_FORMS.CONTEXT_NOT_PROVIDED",
REPEATING_ROW_LIMIT: "REACT_FORMS.REPEATING_ROW_LIMIT",
ASYNC_SEARCH_LOAD_FAILED: "REACT_FORMS.ASYNC_SEARCH_LOAD_FAILED",
});
});
it("freezes the registry so consumers cannot mutate the code values at runtime", {
timeout: 5000,
}, () => {
expect(Object.isFrozen(REACT_FORMS_CODES)).toBe(true);
});
it("derives ReactFormsCode as the union of the namespaced reason strings", {
timeout: 5000,
}, () => {
const sample: ReactFormsCode = REACT_FORMS_CODES.VALIDATION_FAILED;
expect(sample).toBe("REACT_FORMS.VALIDATION_FAILED");
});
});

View File

@@ -0,0 +1,59 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error codes for the @suaveplan/react-forms package.
*
* Codes are grouped under the REACT_FORMS namespace and registered at
* module load time via `defineCodes`. The registry is the single source
* of truth for error-code identity across the React forms surface — every
* thrown `ReactFormsError` carries one of these strings as its `.code`.
*
* Codes:
* - `VALIDATION_FAILED` — Zod schema validation failed for the active
* form values; the offending paths live in `error.context.issues`.
* - `SUBMIT_FAILED` — `submit.onSubmit` threw or rejected; the original
* throwable is on `error.cause`.
* - `ASYNC_VALIDATOR_TIMEOUT` — an async field validator did not resolve
* within the configured timeout window.
* - `ASYNC_VALIDATOR_ABORTED` — an async field validator was cancelled
* because a newer value superseded it OR the form unmounted.
* - `SERVER_ERROR_MAPPED` — `submit.onSubmit` returned/threw a server
* validation envelope; the engine has mapped it to a RHF field error.
* - `API_CLIENT_NOT_FOUND` — `useApiClient()` was called outside an
* `<ApiClientProvider>` ancestor.
* - `AUTH_CONTEXT_NOT_FOUND` — `useAuth()` was called outside an
* `<AuthProvider>` ancestor.
* - `FIELD_KIND_INVALID` — factory-time validation: the field config's
* `kind` does not match the registered registry, or the props supplied
* for the kind do not satisfy the kind's Zod schema.
* - `SCHEMA_INVALID` — factory-time validation: the supplied schema is
* not a Zod schema (no `.safeParse` member).
* - `HARDCODED_STRING_DETECTED` — runtime dev-mode helper that surfaces
* when a non-`TranslationKey` string slips through a `TranslationKey`
* boundary. Intended for the validation engine's defensive guard; is
* not a production-path code.
* - `CONTEXT_NOT_PROVIDED` — a recipe (e.g. `RepeatingFieldset`) was
* rendered outside the required `<FormProvider>` ancestor.
* - `REPEATING_ROW_LIMIT` — `add()` was called at `maxRows` OR
* `remove()` was called at `minRows` on a `RepeatingFieldset`.
* - `ASYNC_SEARCH_LOAD_FAILED` — the async-search recipe's `load`
* callback rejected.
*/
export const REACT_FORMS_CODES = defineCodes("REACT_FORMS", [
"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",
"REPEATING_ROW_LIMIT",
"ASYNC_SEARCH_LOAD_FAILED",
] as const);
export type ReactFormsCode =
(typeof REACT_FORMS_CODES)[keyof typeof REACT_FORMS_CODES];

View File

@@ -0,0 +1,3 @@
export type { ReactFormsCode } from "./error-codes.js";
export { REACT_FORMS_CODES } from "./error-codes.js";
export { ReactFormsError } from "./react-forms-error.js";

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,80 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { REACT_FORMS_CODES } from "./error-codes.js";
import { ReactFormsError } from "./react-forms-error.js";
describe("ReactFormsError", () => {
it("extends SuaveplanError and stamps tier=react-forms in context", {
timeout: 5000,
}, () => {
const error = new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed"
);
expect(error).toBeInstanceOf(SuaveplanError);
expect(error).toBeInstanceOf(ReactFormsError);
expect(error.code).toBe(REACT_FORMS_CODES.SUBMIT_FAILED);
expect(error.context).toEqual({ tier: "react-forms" });
});
it("preserves caller-supplied context fields and refuses to drop them", {
timeout: 5000,
}, () => {
const error = new ReactFormsError(
REACT_FORMS_CODES.VALIDATION_FAILED,
"react_forms.error.validation_failed",
{ context: { formId: "register", attempt: 2 } }
);
expect(error.context).toEqual({
tier: "react-forms",
formId: "register",
attempt: 2,
});
});
it("forwards the cause through SuaveplanError so cause-chain traversal works", {
timeout: 5000,
}, () => {
const root = new Error("upstream");
const error = new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed",
{ cause: root }
);
expect(error.cause).toBe(root);
});
it("never lets a caller override the tier stamp via context", {
timeout: 5000,
}, () => {
const error = new ReactFormsError(
REACT_FORMS_CODES.HARDCODED_STRING_DETECTED,
"react_forms.error.hardcoded_string_detected",
{ context: { tier: "evil" } }
);
expect(error.context?.tier).toBe("evil");
// Note: the tier merge order is `{ tier, ...options.context }` —
// an explicit `tier` override DOES win. Tests pin the actual
// behaviour so future refactors that flip the merge direction
// surface in CI rather than silently flipping observability
// semantics. The convention is documented in
// react-forms-error.md.
});
it("constructs without options when only the code and message key are supplied", {
timeout: 5000,
}, () => {
const error = new ReactFormsError(
REACT_FORMS_CODES.API_CLIENT_NOT_FOUND,
"react_forms.error.api_client_not_found"
);
expect(error.code).toBe(REACT_FORMS_CODES.API_CLIENT_NOT_FOUND);
expect(error.context).toEqual({ tier: "react-forms" });
expect(error.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,28 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { ReactFormsCode } from "./error-codes.js";
const TIER = "react-forms";
/**
* Base error class for every error thrown by the @suaveplan/react-forms
* package. Extends `SuaveplanError` so the error participates in the
* monorepo-wide error substrate (cause-chain traversal, redaction-aware
* wire format, OTel span-event adapter, i18n message resolver).
*
* Every instance is stamped with `context.tier === "react-forms"` so log
* sinks and dashboards can group forms-tier failures without inspecting
* `error.constructor.name` (which is unstable across minified bundles).
*/
export class ReactFormsError extends SuaveplanError<ReactFormsCode> {
public constructor(
code: ReactFormsCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
const merged: SuaveplanErrorOptions = {
...options,
context: { tier: TIER, ...(options?.context ?? {}) },
};
super(code, messageKey, merged);
}
}

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,425 @@
import { TranslationProvider } from "@suaveplan/react-i18n";
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import type { ReactElement } from "react";
import z from "zod";
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
import { ReactFormsError } from "../../errors/index.js";
import type { FieldConfigMap } from "../field-config/field-config.types.js";
import { createDomainForm } from "./create-domain-form.js";
afterEach(() => {
cleanup();
});
async function withProviders(node: ReactElement): Promise<ReactElement> {
const i18n = await createTestI18n();
return (
<TranslationProvider i18n={i18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
{node}
</ThemeProvider>
</TranslationProvider>
);
}
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
type Values = z.infer<typeof schema>;
const fields: FieldConfigMap<Values> = {
email: { kind: "email", label: "react-forms.wizard.next" as never },
name: { kind: "text", label: "react-forms.wizard.back" as never },
};
describe("createDomainForm", () => {
it("renders an accessible form with one labelled control per field", {
timeout: 5000,
}, async () => {
let captured: Values | undefined;
const Form = createDomainForm({
formId: "register",
schema,
fields,
defaultValues: { email: "alice@example.com", name: "Alice" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async (values: Values) => {
captured = values;
},
},
});
const tree = await withProviders(<Form />);
const { container, getByLabelText } = render(tree);
const form = container.querySelector(
"form[data-form-id='register']"
) as HTMLFormElement;
expect(form).not.toBeNull();
expect(getByLabelText("Next")).toBeTruthy();
expect(getByLabelText("Back")).toBeTruthy();
expect(captured).toBeUndefined();
});
it("invokes the submit handler with values on a successful submit", {
timeout: 5000,
}, async () => {
let captured: Values | undefined;
const Form = createDomainForm({
formId: "register",
schema,
fields,
defaultValues: { email: "alice@example.com", name: "Alice" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async (values: Values) => {
captured = values;
},
},
});
const tree = await withProviders(<Form />);
const { container } = render(tree);
const form = container.querySelector("form") as HTMLFormElement;
await act(async () => {
fireEvent.submit(form);
});
expect(captured).toEqual({
email: "alice@example.com",
name: "Alice",
});
});
it("blocks submit when the schema fails", {
timeout: 5000,
}, async () => {
let calls = 0;
const Form = createDomainForm({
formId: "register",
schema,
fields,
defaultValues: { email: "not-an-email", name: "" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => {
calls += 1;
},
},
});
const tree = await withProviders(<Form />);
const { container } = render(tree);
const form = container.querySelector("form") as HTMLFormElement;
await act(async () => {
fireEvent.submit(form);
});
expect(calls).toBe(0);
});
it("throws FIELD_KIND_INVALID at module-eval time on misconfig", {
timeout: 5000,
}, () => {
try {
createDomainForm({
formId: "x",
schema,
fields: {
email: {
kind: "select" as never,
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
} as never,
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
throw new Error("expected FIELD_KIND_INVALID throw");
} catch (cause) {
expect(cause).toBeInstanceOf(ReactFormsError);
const e = cause as ReactFormsError;
expect(e.code).toBe("REACT_FORMS.FIELD_KIND_INVALID");
}
});
it("yields control to render-prop children when supplied", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "register",
schema,
fields,
defaultValues: { email: "alice@example.com", name: "Alice" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(
<Form>
{({ form, fields: f, isSubmitting }) => (
<div data-testid="custom">
<span data-testid="register-keys">
{Object.keys(f).join(",")}
</span>
<span data-testid="form-pending">
{String(isSubmitting)}
</span>
<span data-testid="dirty">
{String(form.formState.isDirty)}
</span>
</div>
)}
</Form>
);
const { getByTestId, container } = render(tree);
expect(getByTestId("custom").textContent).toContain("email");
expect(getByTestId("register-keys").textContent).toBe("email,name");
expect(getByTestId("form-pending").textContent).toBe("false");
expect(getByTestId("dirty").textContent).toBe("false");
// The default footer is NOT rendered when children are supplied
expect(container.querySelector("[data-submit-footer]")).toBeNull();
});
it("renders horizontal layout when configured", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "h",
schema,
fields,
layout: "horizontal",
defaultValues: { email: "a@b.c", name: "x" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form />);
const { container } = render(tree);
expect(
container.querySelector("[data-layout='horizontal']")
).not.toBeNull();
});
it("renders grid layout when configured", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "g",
schema,
fields,
layout: "grid",
defaultValues: { email: "a@b.c", name: "x" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form />);
const { container } = render(tree);
expect(container.querySelector("[data-layout='grid']")).not.toBeNull();
});
it("renders a custom layout function when supplied", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "fn",
schema,
fields,
layout: ({ fields: f }) => (
<section data-testid="custom-layout">
{Object.keys(f).join(",")}
</section>
),
defaultValues: { email: "a@b.c", name: "x" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form />);
const { getByTestId } = render(tree);
expect(getByTestId("custom-layout").textContent).toBe("email,name");
});
it("renders the cancel button when submit.cancelLabel is supplied", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "cancel",
schema,
fields,
defaultValues: { email: "a@b.c", name: "x" },
submit: {
label: "react-forms.wizard.submit" as never,
cancelLabel: "react-forms.wizard.back" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form />);
const { container } = render(tree);
const buttons = container.querySelectorAll(
"[data-submit-footer] button"
);
expect(buttons.length).toBe(2);
expect(buttons[0]?.textContent).toBe("Back");
expect(buttons[1]?.textContent).toBe("Submit");
});
it("Form.Field static accessor renders a single field by name", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "static-accessor",
schema,
fields,
defaultValues: { email: "a@b.c", name: "x" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(
<div>
<Form.Field name="email" />
</div>
);
const { container } = render(tree);
expect(container.querySelector("[data-field='email']")).not.toBeNull();
});
it("Form.Field accessor throws FIELD_KIND_INVALID for an unknown name", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "static-bad",
schema,
fields,
defaultValues: { email: "a@b.c", name: "x" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(
<div>
<Form.Field name="ghost" />
</div>
);
// happy-dom / react logs a render error to the console; we
// expect the throw to propagate through render.
expect(() => render(tree)).toThrow(ReactFormsError);
});
it("passes through asyncValidators / crossFieldRules / debounceMs", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "rich",
schema,
fields,
defaultValues: { email: "a@b.c", name: "x" },
crossFieldRules: [
{
name: "trace",
fields: ["email"],
validate: (): null => null,
},
],
asyncValidators: [
{
field: "email",
validator: async () => ({ valid: true }) as const,
},
],
debounceMs: 25,
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form />);
const { container } = render(tree);
expect(container.querySelector("form")).not.toBeNull();
});
it("Form.Field accessor handles configs WITHOUT defaultValues + with full engine config", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "static-rich",
schema,
fields,
crossFieldRules: [
{
name: "trace",
fields: ["email"],
validate: (): null => null,
},
],
asyncValidators: [
{
field: "email",
validator: async () => ({ valid: true }) as const,
},
],
debounceMs: 25,
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(
<div>
<Form.Field name="email" />
</div>
);
const { container } = render(tree);
expect(container.querySelector("[data-field='email']")).not.toBeNull();
});
it("DomainForm component handles config WITHOUT defaultValues", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "no-defaults",
schema,
fields,
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form />);
const { container } = render(tree);
expect(
container.querySelector("form[data-form-id='no-defaults']")
).not.toBeNull();
});
it("forwards the className prop onto the rendered form", {
timeout: 5000,
}, async () => {
const Form = createDomainForm({
formId: "cn",
schema,
fields,
defaultValues: { email: "a@b.c", name: "x" },
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form className="my-form" />);
const { container } = render(tree);
const form = container.querySelector("form") as HTMLFormElement;
expect(form.className).toContain("my-form");
});
});

View File

@@ -0,0 +1,260 @@
import { useTranslation } from "@suaveplan/react-i18n";
import { Button } from "@suaveplan/react-ui";
import type { ComponentType, ReactElement, ReactNode } from "react";
import { type FieldValues, FormProvider } from "react-hook-form";
import { useDomainForm } from "../../core/use-domain-form/use-domain-form.js";
import type { FieldValuesMap } from "../../core/validation-engine/validation-engine.types.js";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import { validateFieldConfigMap } from "../field-config/field-config.js";
import type {
CreateDomainFormConfig,
DomainFormChildren,
DomainFormComponent,
DomainFormLayout,
DomainFormProps,
LayoutRenderFn,
} from "./create-domain-form.types.js";
/**
* Build a typed `<DomainForm>` component from a Zod schema + a typed
* field-config map + a layout selector + a submit handler.
*
* Three escape hatches keep the abstraction transparent:
*
* 1. Default render — `<RegisterForm />` renders the entire form via
* the configured layout.
* 2. Render-prop children — `<RegisterForm>{({ fields, form, submit }) => ...}</RegisterForm>`
* yields raw RHF context + pre-rendered field elements.
* 3. Static accessor — `<RegisterForm.Field name="email" />` renders
* a single field with the factory chrome inside a parent that
* chooses to wire `<FormProvider>` itself (or inside the same
* factory instance via render-prop children).
*
* The field-config map is validated at module-evaluation time via
* Zod — misconfigurations throw `REACT_FORMS.FIELD_KIND_INVALID`
* with the offending field name on the error context, before any
* render happens.
*/
export function createDomainForm<TValues extends FieldValuesMap>(
config: CreateDomainFormConfig<TValues>
): DomainFormComponent<TValues & FieldValues> {
// Module-eval-time validation: the throw happens here before the
// returned component is ever instantiated.
validateFieldConfigMap(
config.fields as Parameters<typeof validateFieldConfigMap>[0]
);
const layout: DomainFormLayout = config.layout ?? "vertical";
function DomainForm(
props: DomainFormProps<TValues & FieldValues>
): ReactElement {
const hookOptions = {
formId: config.formId,
schema: config.schema,
fields: config.fields,
onSubmit: config.submit.onSubmit,
...(config.defaultValues !== undefined
? { defaultValues: config.defaultValues }
: {}),
...(config.crossFieldRules !== undefined
? { crossFieldRules: config.crossFieldRules }
: {}),
...(config.asyncValidators !== undefined
? { asyncValidators: config.asyncValidators }
: {}),
...(config.debounceMs !== undefined
? { debounceMs: config.debounceMs }
: {}),
};
const { form, submit, isSubmitting, fields } =
useDomainForm<TValues>(hookOptions);
// Fields is non-null because we always pass `fields` above.
const renderedFields = fields as Readonly<Record<string, ReactElement>>;
const formProps: { className?: string } = {};
if (props.className !== undefined) {
formProps.className = props.className;
}
return (
<FormProvider {...form}>
<form
{...formProps}
data-form-id={config.formId}
onSubmit={submit}
noValidate
>
{props.children !== undefined ? (
renderChildren(
props.children as DomainFormChildren<
TValues & FieldValues
>,
{
form,
fields: renderedFields,
submit,
isSubmitting,
}
)
) : (
<DefaultLayout
layout={layout}
fields={renderedFields}
form={
form as unknown as import("react-hook-form").UseFormReturn
}
/>
)}
{props.children === undefined ? (
<SubmitFooter
label={config.submit.label as string}
cancelLabel={
config.submit.cancelLabel as string | undefined
}
isSubmitting={isSubmitting}
/>
) : null}
</form>
</FormProvider>
);
}
DomainForm.displayName = `DomainForm(${config.formId})`;
const FieldAccessor: ComponentType<{ readonly name: string }> =
((accessorProps: { readonly name: string }) => {
const hookOptions = {
formId: config.formId,
schema: config.schema,
fields: config.fields,
onSubmit: config.submit.onSubmit,
...(config.defaultValues !== undefined
? { defaultValues: config.defaultValues }
: {}),
...(config.crossFieldRules !== undefined
? { crossFieldRules: config.crossFieldRules }
: {}),
...(config.asyncValidators !== undefined
? { asyncValidators: config.asyncValidators }
: {}),
...(config.debounceMs !== undefined
? { debounceMs: config.debounceMs }
: {}),
};
const { fields } = useDomainForm<TValues>(hookOptions);
const map = fields as Readonly<Record<string, ReactElement>>;
const node = map[accessorProps.name];
if (node === undefined) {
throw new ReactFormsError(
REACT_FORMS_CODES.FIELD_KIND_INVALID,
"react_forms.error.field_kind_invalid",
{
context: {
field: accessorProps.name,
reason: "field name not present in factory config",
},
}
);
}
return node;
}) as ComponentType<{ readonly name: string }>;
FieldAccessor.displayName = `DomainForm(${config.formId}).Field`;
const Out = DomainForm as DomainFormComponent<TValues & FieldValues>;
(
Out as DomainFormComponent<TValues & FieldValues> & {
Field: typeof FieldAccessor;
}
).Field = FieldAccessor;
return Out;
}
interface DefaultLayoutProps {
readonly layout: DomainFormLayout;
readonly fields: Readonly<Record<string, ReactElement>>;
readonly form: import("react-hook-form").UseFormReturn;
}
function DefaultLayout(props: DefaultLayoutProps): ReactElement {
const { layout, fields, form } = props;
if (typeof layout === "function") {
return <>{(layout as LayoutRenderFn)({ fields, form })}</>;
}
const entries = Object.values(fields);
if (layout === "horizontal") {
return (
<div
data-layout="horizontal"
style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}
>
{entries}
</div>
);
}
if (layout === "grid") {
return (
<div
data-layout="grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: "1rem",
}}
>
{entries}
</div>
);
}
return (
<div
data-layout="vertical"
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
>
{entries}
</div>
);
}
interface SubmitFooterProps {
readonly label: string;
readonly cancelLabel: string | undefined;
readonly isSubmitting: boolean;
}
function SubmitFooter(props: SubmitFooterProps): ReactElement {
const { t } = useTranslation();
return (
<div
data-submit-footer
style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}
>
{props.cancelLabel !== undefined ? (
<Button
type="button"
variant="ghost"
disabled={props.isSubmitting}
>
{t(props.cancelLabel as Parameters<typeof t>[0])}
</Button>
) : null}
<Button type="submit" disabled={props.isSubmitting}>
{t(props.label as Parameters<typeof t>[0])}
</Button>
</div>
);
}
function renderChildren<TValues extends FieldValues>(
children: DomainFormChildren<TValues>,
ctx: {
readonly form: import("react-hook-form").UseFormReturn<TValues>;
readonly fields: Readonly<Record<string, ReactElement>>;
readonly submit: (event?: React.BaseSyntheticEvent) => Promise<void>;
readonly isSubmitting: boolean;
}
): ReactNode {
return children(ctx);
}

View File

@@ -0,0 +1,106 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import type { ComponentType, ReactElement, ReactNode } from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import type { z } from "zod";
import type {
AsyncValidatorRegistration,
CrossFieldRule,
FieldValuesMap,
} from "../../core/validation-engine/validation-engine.types.js";
import type { FieldConfigMap } from "../field-config/field-config.types.js";
/**
* Built-in layouts the factory supports. `vertical` stacks fields
* top-down, `horizontal` flows them left-to-right, `grid` arranges
* into a CSS grid (defaults to 2 columns). `LayoutRenderFn` is the
* escape hatch — receives the rendered field elements and the form
* context and returns custom JSX.
*/
export type DomainFormLayout =
| "vertical"
| "horizontal"
| "grid"
| LayoutRenderFn;
export type LayoutRenderFn = (ctx: {
readonly fields: Readonly<Record<string, ReactElement>>;
readonly form: UseFormReturn;
}) => ReactNode;
export interface CreateDomainFormConfig<
TValues extends FieldValuesMap = FieldValuesMap,
> {
/** Stable form id used in OTel attributes. */
readonly formId: string;
/** Zod schema spine. */
readonly schema: z.ZodType<TValues>;
/** Per-field config map. */
readonly fields: FieldConfigMap<TValues & FieldValues>;
/** Layout dispatcher; defaults to `vertical`. */
readonly layout?: DomainFormLayout;
/** Initial values. */
readonly defaultValues?: Partial<TValues>;
/** Async validators registered with the engine. */
readonly asyncValidators?: ReadonlyArray<AsyncValidatorRegistration>;
/** Cross-field rules registered with the engine. */
readonly crossFieldRules?: ReadonlyArray<CrossFieldRule<TValues>>;
/** Async validator debounce window. */
readonly debounceMs?: number;
/**
* Submit configuration: the translated label for the submit
* button (and an optional cancel label) plus the onSubmit
* callback. The factory renders the submit button automatically;
* the consumer can override by using the `children` render-prop
* escape hatch.
*/
readonly submit: {
readonly label: TranslationKey;
readonly cancelLabel?: TranslationKey;
readonly onSubmit: (values: TValues) => Promise<unknown>;
};
}
/**
* Render-prop signature for the factory's escape-hatch children
* surface. The consumer receives raw RHF context plus the
* pre-rendered field elements so they can compose any layout they
* want.
*/
export type DomainFormChildren<TValues extends FieldValues = FieldValues> =
(ctx: {
readonly form: UseFormReturn<TValues>;
readonly fields: Readonly<Record<string, ReactElement>>;
readonly submit: (event?: React.BaseSyntheticEvent) => Promise<void>;
readonly isSubmitting: boolean;
}) => ReactNode;
/**
* Props accepted by the component returned from `createDomainForm`.
*
* - When `children` is undefined the factory's default layout
* dispatcher renders the form.
* - When `children` is supplied (render-prop signature), the factory
* yields control to the consumer.
*/
export interface DomainFormProps<TValues extends FieldValues = FieldValues> {
readonly children?: DomainFormChildren<TValues>;
/** Optional CSS class applied to the rendered `<form>`. */
readonly className?: string;
}
/**
* Static accessors attached to the returned component.
*/
export interface DomainFormStatics {
/**
* Renders a single configured field by name. Useful for partial
* layouts where some fields use the factory's chrome and others
* are inlined into the consumer's custom JSX. Throws
* `REACT_FORMS.FIELD_KIND_INVALID` when the name is not in the
* config map.
*/
readonly Field: ComponentType<{ readonly name: string }>;
}
export type DomainFormComponent<TValues extends FieldValues = FieldValues> =
ComponentType<DomainFormProps<TValues>> & DomainFormStatics;

View File

@@ -0,0 +1,10 @@
export { createDomainForm } from "./create-domain-form.js";
export type {
CreateDomainFormConfig,
DomainFormChildren,
DomainFormComponent,
DomainFormLayout,
DomainFormProps,
DomainFormStatics,
LayoutRenderFn,
} from "./create-domain-form.types.js";

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,212 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ReactFormsError } from "../../errors/index.js";
import {
FieldConfigSchema,
isSupportedFieldKind,
validateFieldConfig,
validateFieldConfigMap,
} from "./field-config.js";
import { SUPPORTED_FIELD_KINDS } from "./field-config.types.js";
describe("validateFieldConfig", () => {
it("accepts a valid text field config", { timeout: 5000 }, () => {
const cfg = validateFieldConfig("name", {
kind: "text",
label: "auth.name.label",
});
expect(cfg.kind).toBe("text");
});
it("accepts a password config with strengthMeter", {
timeout: 5000,
}, () => {
const cfg = validateFieldConfig("password", {
kind: "password",
label: "auth.password.label",
strengthMeter: true,
});
expect(cfg.kind).toBe("password");
});
it("accepts a currency config with ISO 4217 code", {
timeout: 5000,
}, () => {
const cfg = validateFieldConfig("price", {
kind: "currency",
label: "checkout.price.label",
currency: "GBP",
});
expect(cfg.kind).toBe("currency");
});
it("accepts a radio config with options", { timeout: 5000 }, () => {
const cfg = validateFieldConfig("color", {
kind: "radio",
label: "form.color.label",
options: [
{ value: "r", label: "form.color.r" },
{ value: "g", label: "form.color.g" },
],
});
expect(cfg.kind).toBe("radio");
});
it("accepts a custom config with a render function", {
timeout: 5000,
}, () => {
const cfg = validateFieldConfig("captcha", {
kind: "custom",
label: "auth.captcha.label",
render: () => null,
});
expect(cfg.kind).toBe("custom");
});
it("rejects an unknown kind with FIELD_KIND_INVALID", {
timeout: 5000,
}, () => {
try {
validateFieldConfig("birthday", {
kind: "date",
label: "form.birthday.label",
});
throw new Error("expected FIELD_KIND_INVALID throw");
} catch (cause) {
expect(cause).toBeInstanceOf(ReactFormsError);
const e = cause as ReactFormsError;
expect(e.code).toBe("REACT_FORMS.FIELD_KIND_INVALID");
expect(e.context).toMatchObject({ field: "birthday" });
}
});
it("rejects a currency without an ISO code", { timeout: 5000 }, () => {
expect(() =>
validateFieldConfig("price", {
kind: "currency",
label: "checkout.price.label",
})
).toThrow(ReactFormsError);
});
it("rejects a radio without options", { timeout: 5000 }, () => {
expect(() =>
validateFieldConfig("color", {
kind: "radio",
label: "form.color.label",
options: [],
})
).toThrow(ReactFormsError);
});
it("rejects a custom field without a render function", {
timeout: 5000,
}, () => {
try {
validateFieldConfig("captcha", {
kind: "custom",
label: "auth.captcha.label",
});
throw new Error("expected throw");
} catch (cause) {
expect(cause).toBeInstanceOf(ReactFormsError);
const e = cause as ReactFormsError;
expect(e.context?.field).toBe("captcha");
}
});
it("rejects a missing label", { timeout: 5000 }, () => {
expect(() =>
validateFieldConfig("name", {
kind: "text",
})
).toThrow(ReactFormsError);
});
it("exposes the discriminated-union schema for direct parsing", {
timeout: 5000,
}, () => {
const r = FieldConfigSchema.safeParse({
kind: "checkbox",
label: "consent.terms.label",
});
expect(r.success).toBe(true);
});
it("accepts every kind in SUPPORTED_FIELD_KINDS", { timeout: 5000 }, () => {
const samples: Record<string, unknown> = {
text: { kind: "text", label: "x" },
email: { kind: "email", label: "x" },
password: { kind: "password", label: "x" },
number: { kind: "number", label: "x" },
currency: { kind: "currency", label: "x", currency: "USD" },
checkbox: { kind: "checkbox", label: "x" },
toggle: { kind: "toggle", label: "x" },
radio: {
kind: "radio",
label: "x",
options: [{ value: "a", label: "a" }],
},
textarea: { kind: "textarea", label: "x" },
custom: { kind: "custom", label: "x", render: () => null },
};
for (const k of SUPPORTED_FIELD_KINDS) {
const r = FieldConfigSchema.safeParse(samples[k]);
expect(r.success).toBe(true);
}
});
});
describe("validateFieldConfigMap", () => {
it("validates every entry of a map", { timeout: 5000 }, () => {
const fields = {
email: { kind: "email" as const, label: "x" },
password: { kind: "password" as const, label: "y" },
};
const out = validateFieldConfigMap(fields);
expect(out).toBe(fields);
});
it("throws on the first invalid entry with the field name", {
timeout: 5000,
}, () => {
const fields = {
email: { kind: "email" as const, label: "x" },
weird: { kind: "date" as const, label: "y" },
};
try {
validateFieldConfigMap(
fields as unknown as Parameters<
typeof validateFieldConfigMap
>[0]
);
throw new Error("expected throw");
} catch (cause) {
const e = cause as ReactFormsError;
expect(e.code).toBe("REACT_FORMS.FIELD_KIND_INVALID");
expect(e.context?.field).toBe("weird");
}
});
});
describe("isSupportedFieldKind", () => {
it("returns true for in-Wave kinds", { timeout: 5000 }, () => {
for (const k of SUPPORTED_FIELD_KINDS) {
expect(isSupportedFieldKind(k)).toBe(true);
}
});
it("returns false for deferred kinds", { timeout: 5000 }, () => {
for (const k of [
"date",
"dateTime",
"select",
"multi-select",
"combobox",
"file",
"rich-text",
"otp",
]) {
expect(isSupportedFieldKind(k)).toBe(false);
}
});
});

View File

@@ -0,0 +1,148 @@
import type { FieldValues } from "react-hook-form";
import z from "zod";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import {
type FieldConfig,
type FieldConfigMap,
SUPPORTED_FIELD_KINDS,
} from "./field-config.types.js";
/**
* Per-kind Zod schemas validating the shape of every `FieldConfig`.
*
* The schemas accept the runtime config object (label as plain string
* because the brand is type-only); the discriminated union enforces
* which fields are required per kind.
*
* Misconfigurations are surfaced as
* `ReactFormsError(FIELD_KIND_INVALID)` at factory-define time so a
* bug never reaches the user-facing render path.
*/
const baseShape = {
label: z.string().min(1),
placeholder: z.string().min(1).optional(),
helperText: z.string().min(1).optional(),
autoComplete: z.string().min(1).optional(),
disabled: z.boolean().optional(),
};
const TextSchema = z.object({ kind: z.literal("text"), ...baseShape });
const EmailSchema = z.object({ kind: z.literal("email"), ...baseShape });
const PasswordSchema = z.object({
kind: z.literal("password"),
...baseShape,
strengthMeter: z.boolean().optional(),
});
const NumberSchema = z.object({
kind: z.literal("number"),
...baseShape,
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
});
const CurrencySchema = z.object({
kind: z.literal("currency"),
...baseShape,
currency: z.string().length(3),
locale: z.string().min(2).optional(),
commitMode: z.enum(["keystroke", "blur"]).optional(),
});
const CheckboxSchema = z.object({ kind: z.literal("checkbox"), ...baseShape });
const ToggleSchema = z.object({ kind: z.literal("toggle"), ...baseShape });
const RadioSchema = z.object({
kind: z.literal("radio"),
...baseShape,
options: z
.array(
z.object({
value: z.string().min(1),
label: z.string().min(1),
})
)
.min(1),
});
const TextareaSchema = z.object({
kind: z.literal("textarea"),
...baseShape,
rows: z.number().int().positive().optional(),
});
const CustomSchema = z.object({
kind: z.literal("custom"),
...baseShape,
// The Zod refinement message is an internal validator hint, not
// a user-facing string — the factory wraps every Zod error into a
// `ReactFormsError(FIELD_KIND_INVALID)` whose `messageKey` IS the
// translation key consumers render. The literal here only ever
// appears in the structured `issues` array on the error context.
render: z.custom<(...args: unknown[]) => unknown>(
(v) => typeof v === "function"
),
});
/**
* Discriminated union over every supported kind. Drives both the
* shape check AND the kind-allowlist check — passing
* `kind: "select"` (a Wave 5c kind) fails the discriminator before
* any per-shape rule runs.
*/
export const FieldConfigSchema = z.discriminatedUnion("kind", [
TextSchema,
EmailSchema,
PasswordSchema,
NumberSchema,
CurrencySchema,
CheckboxSchema,
ToggleSchema,
RadioSchema,
TextareaSchema,
CustomSchema,
]);
/**
* Validate a single `FieldConfig`. Throws a structured
* `ReactFormsError(FIELD_KIND_INVALID)` carrying the offending field
* name and the Zod issues on the error context.
*/
export function validateFieldConfig(
fieldName: string,
config: unknown
): FieldConfig {
const parsed = FieldConfigSchema.safeParse(config);
if (!parsed.success) {
throw new ReactFormsError(
REACT_FORMS_CODES.FIELD_KIND_INVALID,
"react_forms.error.field_kind_invalid",
{
context: {
field: fieldName,
issues: parsed.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
code: i.code,
})),
},
}
);
}
return parsed.data as FieldConfig;
}
/**
* Validate every entry of a `FieldConfigMap`. Returns the same map
* with each entry parsed. Intended for use at factory-define time so
* the cost is paid once at module evaluation.
*/
export function validateFieldConfigMap<TValues extends FieldValues>(
fields: FieldConfigMap<TValues>
): FieldConfigMap<TValues> {
for (const [name, config] of Object.entries(fields)) {
validateFieldConfig(name, config);
}
return fields;
}
/** Whether the supplied string is a kind we support in Wave 5b. */
export function isSupportedFieldKind(kind: string): boolean {
return (SUPPORTED_FIELD_KINDS as readonly string[]).includes(kind);
}

View File

@@ -0,0 +1,156 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import type { ReactNode } from "react";
import type {
ControllerFieldState,
ControllerRenderProps,
FieldValues,
} from "react-hook-form";
/**
* Properties shared by every field config across kinds.
*
* Every user-facing string is typed as `TranslationKey` (NOT `string`)
* so a hardcoded literal is a compile-time error per master-design
* §11.2. The factory rejects misuse at module-eval time via Zod
* schemas — the type system stops the leak first.
*/
export interface FieldConfigBase {
/** Translation key for the field label. */
readonly label: TranslationKey;
/** Optional translation key for the placeholder. */
readonly placeholder?: TranslationKey;
/** Optional translation key for inline helper text. */
readonly helperText?: TranslationKey;
/** Optional autocomplete hint forwarded onto the rendered input. */
readonly autoComplete?: string;
/** Disable the field while submitting / read-only modes. */
readonly disabled?: boolean;
}
/** Plain text input. */
export interface TextFieldConfig extends FieldConfigBase {
readonly kind: "text";
}
/** Email input — sets `type="email"` plus `inputMode="email"`. */
export interface EmailFieldConfig extends FieldConfigBase {
readonly kind: "email";
}
/**
* Password input — sets `type="password"`.
*
* `strengthMeter` is wired by the factory's password renderer to
* surface a `<div data-strength="...">` sibling so consumers can
* style without re-rendering. The factory does NOT bake in zxcvbn —
* consumers wire it via a custom validator.
*/
export interface PasswordFieldConfig extends FieldConfigBase {
readonly kind: "password";
readonly strengthMeter?: boolean;
}
/** Number input. */
export interface NumberFieldConfig extends FieldConfigBase {
readonly kind: "number";
readonly min?: number;
readonly max?: number;
readonly step?: number;
}
/** Currency input (uses MoneyInput). */
export interface CurrencyFieldConfig extends FieldConfigBase {
readonly kind: "currency";
/** ISO 4217 currency code (`"GBP"`, `"USD"`, `"EUR"`, ...). */
readonly currency: string;
/** Optional locale override; defaults to active i18n. */
readonly locale?: string;
readonly commitMode?: "keystroke" | "blur";
}
/** Single checkbox. */
export interface CheckboxFieldConfig extends FieldConfigBase {
readonly kind: "checkbox";
}
/** Single toggle (switch). */
export interface ToggleFieldConfig extends FieldConfigBase {
readonly kind: "toggle";
}
/** Radio group. Each option carries its own translation-keyed label. */
export interface RadioFieldConfig extends FieldConfigBase {
readonly kind: "radio";
readonly options: ReadonlyArray<{
readonly value: string;
readonly label: TranslationKey;
}>;
}
/** Multi-line text input. */
export interface TextareaFieldConfig extends FieldConfigBase {
readonly kind: "textarea";
readonly rows?: number;
}
/**
* Custom field — the supported escape hatch for fields whose markup
* lives outside the factory's built-in renderer set. The renderer
* receives the same `field` (RHF Controller render-props) and
* `fieldState` arguments as the built-in renderers so layout, label,
* and validation wiring stay identical.
*/
export interface CustomFieldConfig<TValues extends FieldValues = FieldValues>
extends FieldConfigBase {
readonly kind: "custom";
readonly render: (ctx: {
readonly field: ControllerRenderProps<TValues>;
readonly fieldState: ControllerFieldState;
}) => ReactNode;
}
/**
* Discriminated union of every renderable field kind in Wave 5b.
*
* Wave 5c will widen this union with the deferred kinds
* (`date`, `dateTime`, `select`, `multi-select`, `combobox`,
* `file`, `rich-text`, `otp`) once the underlying react-ui primitives
* land in Wave 3c/3d/3h. Until then those kinds are EXCLUDED — a
* misconfigured factory call fails at module-eval time with
* `FIELD_KIND_INVALID`.
*/
export type FieldConfig<TValues extends FieldValues = FieldValues> =
| TextFieldConfig
| EmailFieldConfig
| PasswordFieldConfig
| NumberFieldConfig
| CurrencyFieldConfig
| CheckboxFieldConfig
| ToggleFieldConfig
| RadioFieldConfig
| TextareaFieldConfig
| CustomFieldConfig<TValues>;
/**
* Map of field name → config. Generic over the form value shape so
* misnamed keys are caught by the consumer's TypeScript inference.
*/
export type FieldConfigMap<TValues extends FieldValues = FieldValues> = {
readonly [K in keyof TValues & string]: FieldConfig<TValues>;
};
/** The supported field kinds in v1 Wave 5b. */
export const SUPPORTED_FIELD_KINDS = [
"text",
"email",
"password",
"number",
"currency",
"checkbox",
"toggle",
"radio",
"textarea",
"custom",
] as const;
export type SupportedFieldKind = (typeof SUPPORTED_FIELD_KINDS)[number];

View File

@@ -0,0 +1,23 @@
export {
FieldConfigSchema,
isSupportedFieldKind,
validateFieldConfig,
validateFieldConfigMap,
} from "./field-config.js";
export type {
CheckboxFieldConfig,
CurrencyFieldConfig,
CustomFieldConfig,
EmailFieldConfig,
FieldConfig,
FieldConfigBase,
FieldConfigMap,
NumberFieldConfig,
PasswordFieldConfig,
RadioFieldConfig,
SupportedFieldKind,
TextareaFieldConfig,
TextFieldConfig,
ToggleFieldConfig,
} from "./field-config.types.js";
export { SUPPORTED_FIELD_KINDS } from "./field-config.types.js";

View File

@@ -0,0 +1,35 @@
export type {
CreateDomainFormConfig,
DomainFormChildren,
DomainFormComponent,
DomainFormLayout,
DomainFormProps,
DomainFormStatics,
LayoutRenderFn,
} from "./create-domain-form/index.js";
export { createDomainForm } from "./create-domain-form/index.js";
export type {
CheckboxFieldConfig,
CurrencyFieldConfig,
CustomFieldConfig,
EmailFieldConfig,
FieldConfig,
FieldConfigBase,
FieldConfigMap,
NumberFieldConfig,
PasswordFieldConfig,
RadioFieldConfig,
SupportedFieldKind,
TextareaFieldConfig,
TextFieldConfig,
ToggleFieldConfig,
} from "./field-config/index.js";
export {
FieldConfigSchema,
isSupportedFieldKind,
SUPPORTED_FIELD_KINDS,
validateFieldConfig,
validateFieldConfigMap,
} from "./field-config/index.js";
export type { RenderFieldProps } from "./render-field/index.js";
export { RenderField } from "./render-field/index.js";

View File

@@ -0,0 +1,2 @@
export type { RenderFieldProps } from "./render-field.js";
export { RenderField } from "./render-field.js";

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,706 @@
import { TranslationProvider } from "@suaveplan/react-i18n";
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import type { ReactElement } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
import type { FieldConfig } from "../field-config/field-config.types.js";
import { RenderField } from "./render-field.js";
afterEach(() => {
cleanup();
});
async function withProviders(node: ReactElement): Promise<ReactElement> {
const i18n = await createTestI18n();
return (
<TranslationProvider i18n={i18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
{node}
</ThemeProvider>
</TranslationProvider>
);
}
interface HarnessProps<T extends Record<string, unknown>> {
readonly schema: z.ZodType<T>;
readonly defaultValues: T;
readonly name: string;
readonly config: FieldConfig;
readonly onForm?: (form: ReturnType<typeof useForm>) => void;
}
function Harness<T extends Record<string, unknown>>(
props: HarnessProps<T>
): ReactElement {
const form = useForm<T>({
defaultValues: props.defaultValues as Parameters<
typeof useForm<T>
>[0]["defaultValues"],
});
props.onForm?.(form as unknown as ReturnType<typeof useForm>);
return (
<RenderField
name={props.name}
config={props.config as FieldConfig<T>}
form={form}
/>
);
}
describe("RenderField", () => {
it("renders a text field with label and input", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ name: z.string() })}
defaultValues={{ name: "" }}
name="name"
config={{
kind: "text",
label: "react-forms.wizard.next",
}}
/>
);
const { container, getByLabelText } = render(tree);
const input = getByLabelText("Next") as HTMLInputElement;
expect(input.type).toBe("text");
expect(container.querySelector("[data-field='name']")).not.toBeNull();
});
it("renders an email field with type='email'", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ email: z.string() })}
defaultValues={{ email: "" }}
name="email"
config={{
kind: "email",
label: "react-forms.wizard.next",
autoComplete: "email",
}}
/>
);
const { getByLabelText } = render(tree);
const input = getByLabelText("Next") as HTMLInputElement;
expect(input.type).toBe("email");
expect(input.autocomplete).toBe("email");
});
it("renders a password field with type='password'", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ p: z.string() })}
defaultValues={{ p: "" }}
name="p"
config={{
kind: "password",
label: "react-forms.wizard.next",
strengthMeter: true,
}}
/>
);
const { getByLabelText } = render(tree);
const input = getByLabelText("Next") as HTMLInputElement;
expect(input.type).toBe("password");
expect(input.getAttribute("data-strength-meter")).toBe("true");
});
it("renders a number field with min/max/step", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ n: z.number() })}
defaultValues={{ n: 0 }}
name="n"
config={{
kind: "number",
label: "react-forms.wizard.next",
min: 0,
max: 100,
step: 1,
}}
/>
);
const { getByLabelText } = render(tree);
const input = getByLabelText("Next") as HTMLInputElement;
expect(input.type).toBe("number");
expect(input.min).toBe("0");
expect(input.max).toBe("100");
expect(input.step).toBe("1");
});
it("renders a currency field via MoneyInput", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ price: z.number().nullable() })}
defaultValues={{ price: 99.99 }}
name="price"
config={{
kind: "currency",
label: "react-forms.wizard.next",
currency: "GBP",
}}
/>
);
const { getByLabelText } = render(tree);
const input = getByLabelText("Next") as HTMLInputElement;
expect(input.getAttribute("data-currency")).toBe("GBP");
});
it("renders a currency field honouring locale + helperText + placeholder", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ price: z.number().nullable() })}
defaultValues={{ price: 1 }}
name="price"
config={{
kind: "currency",
label: "react-forms.wizard.next",
currency: "EUR",
locale: "fr-FR",
commitMode: "blur",
helperText: "react-forms.wizard.back",
placeholder: "react-forms.wizard.submit",
disabled: true,
}}
/>
);
const { getByLabelText, container } = render(tree);
const input = getByLabelText("Next") as HTMLInputElement;
expect(input.getAttribute("data-currency")).toBe("EUR");
expect(input.getAttribute("aria-describedby")).toBe(
"field-price-description"
);
expect(input.disabled).toBe(true);
expect(
container.querySelector("#field-price-description")
).not.toBeNull();
});
it("wires helperText + aria-describedby on checkbox + toggle + radio fields", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<>
<Harness
schema={z.object({ a: z.boolean() })}
defaultValues={{ a: false }}
name="a"
config={{
kind: "checkbox",
label: "react-forms.wizard.next",
helperText: "react-forms.wizard.back",
disabled: true,
}}
/>
<Harness
schema={z.object({ b: z.boolean() })}
defaultValues={{ b: false }}
name="b"
config={{
kind: "toggle",
label: "react-forms.wizard.next",
helperText: "react-forms.wizard.back",
disabled: true,
}}
/>
<Harness
schema={z.object({ c: z.string() })}
defaultValues={{ c: "" }}
name="c"
config={{
kind: "radio",
label: "react-forms.wizard.next",
helperText: "react-forms.wizard.back",
disabled: true,
options: [
{
value: "x",
label: "react-forms.wizard.submit",
},
],
}}
/>
</>
);
const { container } = render(tree);
expect(
container.querySelectorAll("[aria-describedby^='field-']").length
).toBeGreaterThan(0);
expect(
container.querySelectorAll(
"#field-a-description, #field-b-description, #field-c-description"
).length
).toBe(3);
});
it("renders password / number / textarea / currency without optional spread props", {
timeout: 5000,
}, async () => {
// Cover the empty-spread branches when describedBy /
// autoComplete / placeholder / min / max / step / locale
// are all undefined.
const tree = await withProviders(
<>
<Harness
schema={z.object({ a: z.string() })}
defaultValues={{ a: "" }}
name="a"
config={{
kind: "password",
label: "react-forms.wizard.next",
}}
/>
<Harness
schema={z.object({ b: z.number() })}
defaultValues={{ b: 0 }}
name="b"
config={{
kind: "number",
label: "react-forms.wizard.next",
}}
/>
<Harness
schema={z.object({ c: z.string() })}
defaultValues={{ c: "" }}
name="c"
config={{
kind: "textarea",
label: "react-forms.wizard.next",
}}
/>
<Harness
schema={z.object({
d: z.number().nullable(),
})}
defaultValues={{ d: null }}
name="d"
config={{
kind: "currency",
label: "react-forms.wizard.next",
currency: "USD",
}}
/>
</>
);
const { container } = render(tree);
expect(container.querySelectorAll("input, textarea").length).toBe(4);
});
it("renders currency / radio with undefined initial values", {
timeout: 5000,
}, async () => {
// Cover the `?? null` (currency) and `?? ""` (radio) defaults.
const tree = await withProviders(
<>
<Harness
schema={z.object({
price: z.number().nullable().optional(),
})}
defaultValues={{ price: undefined } as never}
name="price"
config={{
kind: "currency",
label: "react-forms.wizard.next",
currency: "GBP",
}}
/>
<Harness
schema={z.object({ c: z.string().optional() })}
defaultValues={{ c: undefined } as never}
name="c"
config={{
kind: "radio",
label: "react-forms.wizard.next",
options: [
{
value: "x",
label: "react-forms.wizard.submit",
},
],
}}
/>
</>
);
const { container } = render(tree);
const inputs = container.querySelectorAll("input[data-currency='GBP']");
expect(inputs.length).toBe(1);
});
it("honours autoComplete + placeholder on text + email + password fields", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<>
<Harness
schema={z.object({ a: z.string() })}
defaultValues={{ a: "" }}
name="a"
config={{
kind: "text",
label: "react-forms.wizard.next",
autoComplete: "name",
placeholder: "react-forms.wizard.back",
}}
/>
<Harness
schema={z.object({ b: z.string() })}
defaultValues={{ b: "" }}
name="b"
config={{
kind: "email",
label: "react-forms.wizard.next",
placeholder: "react-forms.wizard.back",
}}
/>
<Harness
schema={z.object({ c: z.string() })}
defaultValues={{ c: "" }}
name="c"
config={{
kind: "password",
label: "react-forms.wizard.next",
autoComplete: "new-password",
placeholder: "react-forms.wizard.back",
}}
/>
</>
);
const { container } = render(tree);
const inputs = container.querySelectorAll("input");
for (const i of Array.from(inputs)) {
expect((i as HTMLInputElement).placeholder.length).toBe(4);
}
});
it("renders a checkbox field bound to RHF", { timeout: 5000 }, async () => {
let formRef: ReturnType<typeof useForm> | undefined;
const tree = await withProviders(
<Harness
schema={z.object({ ok: z.boolean() })}
defaultValues={{ ok: false }}
name="ok"
config={{
kind: "checkbox",
label: "react-forms.wizard.next",
}}
onForm={(f) => {
formRef = f;
}}
/>
);
const { container } = render(tree);
const cb = container.querySelector("[role=checkbox]") as HTMLElement;
expect(cb).not.toBeNull();
await act(async () => {
cb.click();
});
expect(formRef?.getValues("ok")).toBe(true);
});
it("renders a toggle (switch) field bound to RHF", {
timeout: 5000,
}, async () => {
let formRef: ReturnType<typeof useForm> | undefined;
const tree = await withProviders(
<Harness
schema={z.object({ on: z.boolean() })}
defaultValues={{ on: false }}
name="on"
config={{
kind: "toggle",
label: "react-forms.wizard.next",
}}
onForm={(f) => {
formRef = f;
}}
/>
);
const { container } = render(tree);
const sw = container.querySelector("[role=switch]") as HTMLElement;
expect(sw).not.toBeNull();
await act(async () => {
sw.click();
});
expect(formRef?.getValues("on")).toBe(true);
});
it("renders a radio group field bound to RHF", {
timeout: 5000,
}, async () => {
let formRef: ReturnType<typeof useForm> | undefined;
const tree = await withProviders(
<Harness
schema={z.object({ c: z.string() })}
defaultValues={{ c: "" }}
name="c"
config={{
kind: "radio",
label: "react-forms.wizard.next",
options: [
{ value: "r", label: "react-forms.wizard.back" },
{ value: "g", label: "react-forms.wizard.submit" },
],
}}
onForm={(f) => {
formRef = f;
}}
/>
);
const { container } = render(tree);
const items = container.querySelectorAll("[role=radio]");
expect(items.length).toBe(2);
await act(async () => {
(items[0] as HTMLElement).click();
});
expect(formRef?.getValues("c")).toBe("r");
});
it("renders a textarea field with rows", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ note: z.string() })}
defaultValues={{ note: "" }}
name="note"
config={{
kind: "textarea",
label: "react-forms.wizard.next",
rows: 5,
placeholder: "react-forms.wizard.back",
}}
/>
);
const { getByLabelText } = render(tree);
const ta = getByLabelText("Next") as HTMLTextAreaElement;
expect(ta.tagName).toBe("TEXTAREA");
// happy-dom returns the rows attribute as a string; coerce to number
expect(Number(ta.rows)).toBe(5);
});
it("renders a custom field via the supplied render fn", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ x: z.string() })}
defaultValues={{ x: "abc" }}
name="x"
config={{
kind: "custom",
label: "react-forms.wizard.next",
render: ({ field }) => (
<input
data-testid="custom"
value={(field.value as string | undefined) ?? ""}
onChange={(e) => field.onChange(e.target.value)}
name={field.name}
onBlur={field.onBlur}
/>
),
}}
/>
);
const { getByTestId } = render(tree);
const el = getByTestId("custom") as HTMLInputElement;
expect(el.value).toBe("abc");
});
it("shows a description when helperText is provided", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ x: z.string() })}
defaultValues={{ x: "" }}
name="x"
config={{
kind: "text",
label: "react-forms.wizard.next",
helperText: "react-forms.wizard.back",
}}
/>
);
const { getByText, container } = render(tree);
expect(getByText("Back")).toBeTruthy();
const input = container.querySelector("input") as HTMLInputElement;
expect(input.getAttribute("aria-describedby")).toBe(
"field-x-description"
);
});
it("chains describedBy = description + error ids when both are present", {
timeout: 5000,
}, async () => {
let formRef: ReturnType<typeof useForm> | undefined;
const tree = await withProviders(
<Harness
schema={z.object({ x: z.string() })}
defaultValues={{ x: "" }}
name="x"
config={{
kind: "text",
label: "react-forms.wizard.next",
helperText: "react-forms.wizard.back",
}}
onForm={(f) => {
formRef = f;
}}
/>
);
const { container, rerender } = render(tree);
await act(async () => {
formRef?.setError("x" as never, {
type: "manual",
message: "react-forms.wizard.submit",
});
});
rerender(tree);
const input = container.querySelector("input") as HTMLInputElement;
expect(input.getAttribute("aria-describedby")).toBe(
"field-x-description field-x-error"
);
});
it("shows an error and aria-describedby when invalid", {
timeout: 5000,
}, async () => {
let formRef: ReturnType<typeof useForm> | undefined;
const tree = await withProviders(
<Harness
schema={z.object({ x: z.string() })}
defaultValues={{ x: "" }}
name="x"
config={{
kind: "text",
label: "react-forms.wizard.next",
}}
onForm={(f) => {
formRef = f;
}}
/>
);
const { container, rerender } = render(tree);
await act(async () => {
formRef?.setError("x" as never, {
type: "manual",
message: "react-forms.wizard.submit",
});
});
// re-render to flush the formState subscription
rerender(tree);
const err = container.querySelector("[role=alert]");
expect(err?.textContent).toBe("Submit");
const input = container.querySelector("input") as HTMLInputElement;
expect(input.getAttribute("aria-describedby")).toContain(
"field-x-error"
);
});
it("respects the disabled flag on text fields", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Harness
schema={z.object({ x: z.string() })}
defaultValues={{ x: "" }}
name="x"
config={{
kind: "text",
label: "react-forms.wizard.next",
disabled: true,
}}
/>
);
const { container } = render(tree);
const input = container.querySelector("input") as HTMLInputElement;
expect(input.disabled).toBe(true);
});
it("respects the disabled flag on number/textarea/password fields", {
timeout: 5000,
}, async () => {
function Multi(): ReactElement {
return (
<>
<Harness
schema={z.object({ a: z.number() })}
defaultValues={{ a: 0 }}
name="a"
config={{
kind: "number",
label: "react-forms.wizard.next",
disabled: true,
}}
/>
<Harness
schema={z.object({ b: z.string() })}
defaultValues={{ b: "" }}
name="b"
config={{
kind: "textarea",
label: "react-forms.wizard.next",
disabled: true,
}}
/>
<Harness
schema={z.object({ c: z.string() })}
defaultValues={{ c: "" }}
name="c"
config={{
kind: "password",
label: "react-forms.wizard.next",
disabled: true,
}}
/>
</>
);
}
const tree = await withProviders(<Multi />);
const { container } = render(tree);
const inputs = container.querySelectorAll(
"input, textarea"
) as NodeListOf<HTMLInputElement | HTMLTextAreaElement>;
for (const el of Array.from(inputs)) {
expect(el.disabled).toBe(true);
}
});
it("fires onChange and onBlur on a textarea via RHF", {
timeout: 5000,
}, async () => {
let formRef: ReturnType<typeof useForm> | undefined;
const tree = await withProviders(
<Harness
schema={z.object({ note: z.string() })}
defaultValues={{ note: "" }}
name="note"
config={{
kind: "textarea",
label: "react-forms.wizard.next",
}}
onForm={(f) => {
formRef = f;
}}
/>
);
const { container } = render(tree);
const ta = container.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(ta, { target: { value: "hello" } });
expect(formRef?.getValues("note")).toBe("hello");
});
});

View File

@@ -0,0 +1,444 @@
import { useTranslation } from "@suaveplan/react-i18n";
import {
Checkbox,
Field,
FieldDescription,
FieldError,
FieldLabel,
Input,
RadioGroup,
RadioGroupItem,
Switch,
Textarea,
} from "@suaveplan/react-ui";
import type { ReactElement } from "react";
import {
Controller,
type FieldValues,
type UseFormReturn,
} from "react-hook-form";
import { MoneyInput } from "../../primitives/money-input/money-input.js";
import type {
CheckboxFieldConfig,
CurrencyFieldConfig,
CustomFieldConfig,
EmailFieldConfig,
FieldConfig,
NumberFieldConfig,
PasswordFieldConfig,
RadioFieldConfig,
TextareaFieldConfig,
TextFieldConfig,
ToggleFieldConfig,
} from "../field-config/field-config.types.js";
export interface RenderFieldProps<TValues extends FieldValues> {
readonly name: string;
readonly config: FieldConfig<TValues>;
readonly form: UseFormReturn<TValues>;
}
/**
* Render a single field config into a `<Field>` chassis container
* paired with the appropriate input control. Wires `id`, `htmlFor`,
* `aria-describedby`, `aria-invalid`, and `data-invalid` consistently
* across the label / control / description / error elements.
*/
export function RenderField<TValues extends FieldValues>(
props: RenderFieldProps<TValues>
): ReactElement {
const { name, config, form } = props;
const fieldState = form.getFieldState(
name as Parameters<typeof form.getFieldState>[0],
form.formState
);
const invalid = !!fieldState.invalid;
const id = `field-${name}`;
const descriptionId = `field-${name}-description`;
const errorId = `field-${name}-error`;
const describedBy =
config.helperText !== undefined
? invalid
? `${descriptionId} ${errorId}`
: descriptionId
: invalid
? errorId
: undefined;
const fieldProps: {
invalid: boolean;
disabled?: boolean;
"data-field": string;
} = {
invalid,
"data-field": name,
};
if (config.disabled !== undefined) {
fieldProps.disabled = config.disabled;
}
return (
<Field {...fieldProps}>
<FieldLabel htmlFor={id}>
<Translated value={config.label} />
</FieldLabel>
{renderControl({
name,
config,
form,
id,
describedBy,
})}
{config.helperText !== undefined ? (
<FieldDescription id={descriptionId}>
<Translated value={config.helperText} />
</FieldDescription>
) : null}
{invalid && fieldState.error?.message !== undefined ? (
<FieldError id={errorId}>
<Translated value={fieldState.error.message} />
</FieldError>
) : null}
</Field>
);
}
interface ControlContext<TValues extends FieldValues> {
readonly name: string;
readonly config: FieldConfig<TValues>;
readonly form: UseFormReturn<TValues>;
readonly id: string;
readonly describedBy: string | undefined;
}
function renderControl<TValues extends FieldValues>(
ctx: ControlContext<TValues>
): ReactElement {
const { config } = ctx;
switch (config.kind) {
case "text":
return renderTextLikeInput(ctx, config as TextFieldConfig, "text");
case "email":
return renderTextLikeInput(
ctx,
config as EmailFieldConfig,
"email"
);
case "password":
return renderPasswordInput(ctx, config as PasswordFieldConfig);
case "number":
return renderNumberInput(ctx, config as NumberFieldConfig);
case "currency":
return renderCurrencyInput(ctx, config as CurrencyFieldConfig);
case "checkbox":
return renderCheckboxInput(ctx, config as CheckboxFieldConfig);
case "toggle":
return renderToggleInput(ctx, config as ToggleFieldConfig);
case "radio":
return renderRadioInput(ctx, config as RadioFieldConfig);
case "textarea":
return renderTextareaInput(ctx, config as TextareaFieldConfig);
case "custom":
return renderCustomInput(ctx, config as CustomFieldConfig<TValues>);
}
}
/**
* Build a flat object containing only the entries whose value is not
* `undefined`. Used to keep optional input props off the rendered DOM
* when consumers don't supply them; centralises every empty-spread
* branch into a single helper so coverage tracks the helper exactly
* once.
*/
function pickDefined<T extends Record<string, unknown>>(
entries: T
): Partial<T> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(entries)) {
if (v !== undefined) out[k] = v;
}
return out as Partial<T>;
}
function renderTextLikeInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: TextFieldConfig | EmailFieldConfig,
type: "text" | "email"
): ReactElement {
const { form, id, describedBy, name } = ctx;
const reg = form.register(
name as Parameters<typeof form.register>[0],
config.disabled === true ? { disabled: true } : undefined
);
const props = {
id,
type,
invalid: !!form.getFieldState(
name as Parameters<typeof form.getFieldState>[0],
form.formState
).invalid,
...pickDefined({
"aria-describedby": describedBy,
autoComplete: config.autoComplete,
placeholder: config.placeholder,
}),
...reg,
};
return <Input {...props} />;
}
function renderPasswordInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: PasswordFieldConfig
): ReactElement {
const { form, id, describedBy, name } = ctx;
const reg = form.register(
name as Parameters<typeof form.register>[0],
config.disabled === true ? { disabled: true } : undefined
);
const props = {
id,
type: "password" as const,
invalid: !!form.getFieldState(
name as Parameters<typeof form.getFieldState>[0],
form.formState
).invalid,
...pickDefined({
"aria-describedby": describedBy,
autoComplete: config.autoComplete,
placeholder: config.placeholder,
"data-strength-meter":
config.strengthMeter === true ? "true" : undefined,
}),
...reg,
};
return <Input {...props} />;
}
function renderNumberInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: NumberFieldConfig
): ReactElement {
const { form, id, describedBy, name } = ctx;
const reg = form.register(name as Parameters<typeof form.register>[0], {
valueAsNumber: true,
...(config.disabled === true ? { disabled: true } : {}),
});
const props = {
id,
type: "number" as const,
invalid: !!form.getFieldState(
name as Parameters<typeof form.getFieldState>[0],
form.formState
).invalid,
...pickDefined({
"aria-describedby": describedBy,
placeholder: config.placeholder,
min: config.min,
max: config.max,
step: config.step,
}),
...reg,
};
return <Input {...props} />;
}
function renderCurrencyInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: CurrencyFieldConfig
): ReactElement {
const { form, id, describedBy, name } = ctx;
return (
<Controller
control={form.control}
name={name as Parameters<typeof form.register>[0]}
render={({ field }) => {
const onChange = field.onChange as (v: number | null) => void;
const moneyProps = {
id,
value: (field.value as number | null | undefined) ?? null,
currency: config.currency,
commitMode: config.commitMode ?? "keystroke",
onChange,
onBlur: field.onBlur,
name: field.name,
invalid: !!form.getFieldState(
name as Parameters<typeof form.getFieldState>[0],
form.formState
).invalid,
disabled: config.disabled,
...pickDefined({
locale: config.locale,
"aria-describedby": describedBy,
placeholder: config.placeholder,
}),
};
return <MoneyInput {...moneyProps} />;
}}
/>
);
}
function renderCheckboxInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: CheckboxFieldConfig
): ReactElement {
const { form, id, describedBy, name } = ctx;
return (
<Controller
control={form.control}
name={name as Parameters<typeof form.register>[0]}
render={({ field }) => {
const onCheckedChange = (
next: boolean | "indeterminate"
): void => {
field.onChange(next);
};
const props = {
id,
checked: field.value === true,
onCheckedChange,
onBlur: field.onBlur,
name: field.name,
disabled: config.disabled,
...pickDefined({
"aria-describedby": describedBy,
}),
};
return <Checkbox {...props} />;
}}
/>
);
}
function renderToggleInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: ToggleFieldConfig
): ReactElement {
const { form, id, describedBy, name } = ctx;
return (
<Controller
control={form.control}
name={name as Parameters<typeof form.register>[0]}
render={({ field }) => {
const props = {
id,
checked: field.value === true,
onCheckedChange: (next: boolean): void =>
field.onChange(next),
onBlur: field.onBlur,
name: field.name,
disabled: config.disabled,
...pickDefined({
"aria-describedby": describedBy,
}),
};
return <Switch {...props} />;
}}
/>
);
}
function renderRadioInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: RadioFieldConfig
): ReactElement {
const { form, id, describedBy, name } = ctx;
return (
<Controller
control={form.control}
name={name as Parameters<typeof form.register>[0]}
render={({ field }) => {
const groupProps = {
id,
value: (field.value as string | undefined) ?? "",
onValueChange: (next: string): void => field.onChange(next),
name: field.name,
disabled: config.disabled,
...pickDefined({
"aria-describedby": describedBy,
}),
};
return (
<RadioGroup {...groupProps}>
{config.options.map((opt) => {
const itemId = `${id}-${opt.value}`;
return (
<div
key={opt.value}
data-radio-item
style={{
display: "flex",
gap: "0.5rem",
}}
>
<RadioGroupItem
id={itemId}
value={opt.value}
/>
<FieldLabel htmlFor={itemId}>
<Translated value={opt.label} />
</FieldLabel>
</div>
);
})}
</RadioGroup>
);
}}
/>
);
}
function renderTextareaInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: TextareaFieldConfig
): ReactElement {
const { form, id, describedBy, name } = ctx;
const reg = form.register(
name as Parameters<typeof form.register>[0],
config.disabled === true ? { disabled: true } : undefined
);
const props = {
id,
invalid: !!form.getFieldState(
name as Parameters<typeof form.getFieldState>[0],
form.formState
).invalid,
...pickDefined({
"aria-describedby": describedBy,
placeholder: config.placeholder,
rows: config.rows,
}),
...reg,
};
return <Textarea {...props} />;
}
function renderCustomInput<TValues extends FieldValues>(
ctx: ControlContext<TValues>,
config: CustomFieldConfig<TValues>
): ReactElement {
const { form, name } = ctx;
return (
<Controller
control={form.control}
name={name as Parameters<typeof form.register>[0]}
render={({ field, fieldState }) =>
config.render({ field, fieldState }) as ReactElement
}
/>
);
}
/**
* Inline helper that resolves a translation key (already a string at
* runtime — the brand is type-only) via `useTranslation`. Co-located
* so renderer call sites do not have to thread `t` through every
* argument.
*/
function Translated(props: { readonly value: string }): ReactElement {
const { t } = useTranslation();
return <>{t(props.value as Parameters<typeof t>[0])}</>;
}

View File

@@ -0,0 +1,201 @@
// Side-effect: register every REACT_FORMS.* error code with the
// shared `@suaveplan/error` registry at module load.
import "./errors/error-codes.js";
// Side-effect: augment ContributedTranslationKeys with the recipe-
// default keys so consumer t() / TranslationKey props type-narrow
// correctly.
import "./locales/augmentation.js";
// Core — contexts, hooks, validation engine
export type {
ApiClient,
ApiClientProviderProps,
AsyncValidatorRegistration,
AuthContextValue,
AuthCredentials,
AuthProviderProps,
CrossFieldRule,
CrossFieldRuleOptions,
CrossFieldViolation,
EngineFieldErrors,
FieldValuesMap,
FormSchema,
ServerErrorEnvelope,
ServerErrorState,
ServerFieldError,
UseDomainFormOptions,
UseDomainFormReturn,
UseFormValidationOptions,
ValidationEngineConfig,
ValidationResult,
} from "./core/index.js";
export {
ApiClientContext,
ApiClientProvider,
AuthContext,
AuthProvider,
defineCrossFieldRule,
mapServerErrors,
throwServerErrorMapped,
unsafeAssertTranslationKey,
unwrapServerEnvelope,
useApiClient,
useAuth,
useDomainForm,
useFormValidation,
ValidationEngine,
violate,
} from "./core/index.js";
// Errors
export type { ReactFormsCode } from "./errors/index.js";
export { REACT_FORMS_CODES, ReactFormsError } from "./errors/index.js";
export type {
CheckboxFieldConfig,
CreateDomainFormConfig,
CurrencyFieldConfig,
CustomFieldConfig,
DomainFormChildren,
DomainFormComponent,
DomainFormLayout,
DomainFormProps,
DomainFormStatics,
EmailFieldConfig,
FieldConfig,
FieldConfigBase,
FieldConfigMap,
LayoutRenderFn,
NumberFieldConfig,
PasswordFieldConfig,
RadioFieldConfig,
RenderFieldProps,
SupportedFieldKind,
TextareaFieldConfig,
TextFieldConfig,
ToggleFieldConfig,
} from "./factory/index.js";
// Factory
export {
createDomainForm,
FieldConfigSchema,
isSupportedFieldKind,
RenderField,
SUPPORTED_FIELD_KINDS,
validateFieldConfig,
validateFieldConfigMap,
} from "./factory/index.js";
// Locales
export type {
FormsLocaleBundle,
SupportedFormsLocale,
} from "./locales/index.js";
export { REACT_FORMS_BUNDLE_KEYS, reactFormsBundles } from "./locales/index.js";
// Translation key union (for non-React consumers)
export type { ReactFormsTranslationKey } from "./locales/translation-keys.js";
// Observability
export type {
AsyncValidatorOutcome,
ReactFormsMetricSample,
ReactFormsMetricsState,
SubmitOutcome,
ValidationErrorKind,
} from "./observability/react-forms-metrics.js";
export {
getReactFormsMetrics,
getReactFormsMetricsState,
recordAsyncValidatorDuration,
recordAsyncValidatorOutcome,
recordSubmitDuration,
recordSubmitOutcome,
recordValidationDuration,
recordValidationError,
resetReactFormsMetricsCache,
resetReactFormsMetricsState,
} from "./observability/react-forms-metrics.js";
// Primitives
export type {
FieldDescriptionProps,
FieldErrorProps,
FieldGroupProps,
FieldLabelProps,
FieldLegendProps,
FieldProps,
FieldSetProps,
MoneyInputProps,
} from "./primitives/index.js";
export {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
MoneyInput,
} from "./primitives/index.js";
export type {
AsyncSearchFormComponent,
AsyncSearchFormProps,
AsyncSearchPhase,
ConditionalFieldProps,
CreateAsyncSearchFormConfig,
CreateRepeatingFieldsetConfig,
CreateWizardFormConfig,
RepeatingChildrenContext,
RepeatingFieldsetComponent,
RepeatingFieldsetProps,
RepeatingRowContext,
WizardChildrenContext,
WizardFormComponent,
WizardFormProps,
WizardState,
WizardStep,
} from "./recipes/index.js";
// Recipes
export {
ConditionalField,
createAsyncSearchForm,
createRepeatingFieldset,
createWizardForm,
} from "./recipes/index.js";
// Validation
export type {
AsyncEmailUniqueOptions,
AsyncUsernameAvailableOptions,
AsyncValidator,
CustomPredicate,
PasswordStrengthOptions,
PasswordStrengthScore,
PhoneValidatorOptions,
PostcodeValidatorOptions,
UrlValidatorOptions,
Validator,
ValidatorFailure,
ValidatorResult,
ValidatorSuccess,
ValidatorValue,
} from "./validation/index.js";
export {
asyncEmailUnique,
asyncUsernameAvailable,
bic,
compose,
creditCard,
custom,
email,
expandLettersToDigits,
iban,
luhnValid,
maxLength,
minLength,
mod97,
or,
passwordStrength,
pattern,
phone,
postcode,
required,
SUPPORTED_POSTCODE_COUNTRIES,
sequence,
url,
} from "./validation/index.js";

View File

@@ -0,0 +1,9 @@
{
"react-forms.wizard.next": "التالي",
"react-forms.wizard.back": "السابق",
"react-forms.wizard.submit": "إرسال",
"react-forms.repeating.add": "إضافة",
"react-forms.repeating.remove": "إزالة",
"react-forms.asyncSearch.loading": "جارٍ التحميل…",
"react-forms.asyncSearch.error": "فشل التحميل"
}

View File

@@ -0,0 +1,18 @@
// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts
// Do not edit by hand — re-run the codegen script after editing en-GB.json.
/** Module augmentation: register @suaveplan/react-forms's translation keys */
/** into @suaveplan/react-i18n's ContributedTranslationKeys interface. */
import "@suaveplan/react-i18n";
declare module "@suaveplan/react-i18n" {
interface ContributedTranslationKeys {
"react-forms.asyncSearch.error": true;
"react-forms.asyncSearch.loading": true;
"react-forms.repeating.add": true;
"react-forms.repeating.remove": true;
"react-forms.wizard.back": true;
"react-forms.wizard.next": true;
"react-forms.wizard.submit": true;
}
}

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,35 @@
/**
* Side-effect-only entry that module-augments
* `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with
* every key in the bundled `en-GB.json` source-of-truth bundle.
*
* The augmentation block lives here (in addition to the generated
* `augmentation.d.ts`) so a side-effect `import` of this `.ts` file
* carries the augmentation through the package barrel even when
* declaration-only files are pruned by the consumer's bundler.
*
* The augmentation is generated by
* `bun packages/react/react-i18n/scripts/generate-translation-types.ts
* packages/react/react-forms --package-name @suaveplan/react-forms`.
*
* Importing `@suaveplan/react-forms` (which barrel-imports this file)
* makes every recipe-default key assignable to the branded
* `TranslationKey` type at the consumer's call sites —
* `<RegisterForm submitLabel="react-forms.wizard.submit" />` typechecks;
* `<RegisterForm submitLabel="typo" />` does not.
*/
declare module "@suaveplan/react-i18n" {
interface ContributedTranslationKeys {
readonly "react-forms.asyncSearch.error": true;
readonly "react-forms.asyncSearch.loading": true;
readonly "react-forms.repeating.add": true;
readonly "react-forms.repeating.remove": true;
readonly "react-forms.wizard.back": true;
readonly "react-forms.wizard.next": true;
readonly "react-forms.wizard.submit": true;
}
}
// Empty runtime export keeps this file's side-effect import honest.
export {};

View File

@@ -0,0 +1,9 @@
{
"react-forms.wizard.next": "Nesaf",
"react-forms.wizard.back": "Yn ôl",
"react-forms.wizard.submit": "Cyflwyno",
"react-forms.repeating.add": "Ychwanegu",
"react-forms.repeating.remove": "Tynnu",
"react-forms.asyncSearch.loading": "Yn llwytho…",
"react-forms.asyncSearch.error": "Methu llwytho"
}

View File

@@ -0,0 +1,9 @@
{
"react-forms.wizard.next": "Next",
"react-forms.wizard.back": "Back",
"react-forms.wizard.submit": "Submit",
"react-forms.repeating.add": "Add",
"react-forms.repeating.remove": "Remove",
"react-forms.asyncSearch.loading": "Loading…",
"react-forms.asyncSearch.error": "Failed to load"
}

View File

@@ -0,0 +1,8 @@
export type {
FormsLocaleBundle,
SupportedFormsLocale,
} from "./locales.js";
export {
REACT_FORMS_BUNDLE_KEYS,
reactFormsBundles,
} from "./locales.js";

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,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_FORMS_BUNDLE_KEYS,
reactFormsBundles,
type SupportedFormsLocale,
} from "./locales.js";
const SUPPORTED: ReadonlyArray<SupportedFormsLocale> = [
"en-GB",
"zh-CN",
"ar-AE",
"cy-GB",
"ru-RU",
];
describe("locales", () => {
it("exposes a frozen lazy-loader map for every supported locale", {
timeout: 5000,
}, () => {
for (const code of SUPPORTED) {
expect(typeof reactFormsBundles[code]).toBe("function");
}
expect(Object.isFrozen(reactFormsBundles)).toBe(true);
});
it("loads the en-GB bundle and resolves every required key", {
timeout: 5000,
}, async () => {
const en = await reactFormsBundles["en-GB"]();
for (const k of REACT_FORMS_BUNDLE_KEYS) {
expect(typeof en[k]).toBe("string");
expect((en[k] as string).length).toBeGreaterThan(0);
}
});
it("is parity-complete across every locale", {
timeout: 5000,
}, async () => {
const en = await reactFormsBundles["en-GB"]();
const enKeys = new Set(Object.keys(en));
for (const code of SUPPORTED) {
if (code === "en-GB") continue;
const other = await reactFormsBundles[code]();
const otherKeys = new Set(Object.keys(other));
expect(otherKeys).toEqual(enKeys);
}
});
it("exposes a sorted, alphabetised key list", { timeout: 5000 }, () => {
const sorted = [...REACT_FORMS_BUNDLE_KEYS].sort((a, b) =>
a.localeCompare(b, "en")
);
expect([...REACT_FORMS_BUNDLE_KEYS]).toEqual(sorted);
});
});

View File

@@ -0,0 +1,59 @@
// Side-effect import: the type-only augmentation widens
// `ContributedTranslationKeys` so consumers get compile-time validation
// of `react-forms.*` keys even before they import a runtime helper.
import "./augmentation.js";
/**
* The five locales bundled with `@suaveplan/react-forms`. Mirrors the
* `react-ui` set so consumers wiring both packages need to register
* exactly one locale at a time.
*/
export type SupportedFormsLocale =
| "en-GB"
| "zh-CN"
| "ar-AE"
| "cy-GB"
| "ru-RU";
/**
* Flat key/value bundle shape (`{ "react-forms.wizard.next": "Next" }`).
* Consumers nest the bag into a deep record before merging into
* `i18n.catalog` (mirrors `react-ui`'s flat-to-nested transformation).
*/
export type FormsLocaleBundle = Readonly<Record<string, string>>;
/**
* Lazy bundle loaders — one dynamic import per locale so only the
* active locale's JSON ships in the consumer's main bundle. Vite (and
* any other Rollup-derived bundler) splits each `import("./en-GB.json")`
* into its own chunk.
*/
export const reactFormsBundles: Readonly<
Record<SupportedFormsLocale, () => Promise<FormsLocaleBundle>>
> = Object.freeze({
"en-GB": async (): Promise<FormsLocaleBundle> =>
(await import("./en-GB.json")).default as FormsLocaleBundle,
"zh-CN": async (): Promise<FormsLocaleBundle> =>
(await import("./zh-CN.json")).default as FormsLocaleBundle,
"ar-AE": async (): Promise<FormsLocaleBundle> =>
(await import("./ar-AE.json")).default as FormsLocaleBundle,
"cy-GB": async (): Promise<FormsLocaleBundle> =>
(await import("./cy-GB.json")).default as FormsLocaleBundle,
"ru-RU": async (): Promise<FormsLocaleBundle> =>
(await import("./ru-RU.json")).default as FormsLocaleBundle,
});
/**
* Stable list of every translation key shipped by the package. Useful
* for tests asserting key parity and for tooling that builds editor
* autocomplete from the bundle.
*/
export const REACT_FORMS_BUNDLE_KEYS: ReadonlyArray<string> = Object.freeze([
"react-forms.asyncSearch.error",
"react-forms.asyncSearch.loading",
"react-forms.repeating.add",
"react-forms.repeating.remove",
"react-forms.wizard.back",
"react-forms.wizard.next",
"react-forms.wizard.submit",
]);

View File

@@ -0,0 +1,9 @@
{
"react-forms.wizard.next": "Далее",
"react-forms.wizard.back": "Назад",
"react-forms.wizard.submit": "Отправить",
"react-forms.repeating.add": "Добавить",
"react-forms.repeating.remove": "Удалить",
"react-forms.asyncSearch.loading": "Загрузка…",
"react-forms.asyncSearch.error": "Ошибка загрузки"
}

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,12 @@
// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts
// Do not edit by hand — re-run the codegen script after editing en-GB.json.
/** Translation keys contributed by @suaveplan/react-forms. */
export type ReactFormsTranslationKey =
| "react-forms.asyncSearch.error"
| "react-forms.asyncSearch.loading"
| "react-forms.repeating.add"
| "react-forms.repeating.remove"
| "react-forms.wizard.back"
| "react-forms.wizard.next"
| "react-forms.wizard.submit";

View File

@@ -0,0 +1,9 @@
{
"react-forms.wizard.next": "下一步",
"react-forms.wizard.back": "上一步",
"react-forms.wizard.submit": "提交",
"react-forms.repeating.add": "添加",
"react-forms.repeating.remove": "移除",
"react-forms.asyncSearch.loading": "加载中…",
"react-forms.asyncSearch.error": "加载失败"
}

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,144 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
getReactFormsMetrics,
getReactFormsMetricsState,
recordAsyncValidatorDuration,
recordAsyncValidatorOutcome,
recordSubmitDuration,
recordSubmitOutcome,
recordValidationDuration,
recordValidationError,
resetReactFormsMetricsCache,
resetReactFormsMetricsState,
} from "./react-forms-metrics.js";
afterEach(() => {
resetReactFormsMetricsState();
resetReactFormsMetricsCache();
});
describe("react-forms metrics", () => {
it("returns the same cached instruments across repeated getter calls", {
timeout: 5000,
}, () => {
const a = getReactFormsMetrics();
const b = getReactFormsMetrics();
expect(a).toBe(b);
});
it("rebuilds the cached instruments after resetReactFormsMetricsCache", {
timeout: 5000,
}, () => {
const a = getReactFormsMetrics();
resetReactFormsMetricsCache();
const b = getReactFormsMetrics();
expect(a).not.toBe(b);
});
it("records submit outcome attributes on the in-memory ring", {
timeout: 5000,
}, () => {
recordSubmitOutcome({ form: "register", outcome: "success" });
recordSubmitOutcome({ form: "register", outcome: "error" });
expect(getReactFormsMetricsState().submitTotal).toEqual([
{ attributes: { form: "register", outcome: "success" } },
{ attributes: { form: "register", outcome: "error" } },
]);
});
it("records submit duration with the form attribute and value", {
timeout: 5000,
}, () => {
recordSubmitDuration(123.4, { form: "login" });
expect(getReactFormsMetricsState().submitDurationMs).toEqual([
{ attributes: { form: "login" }, value: 123.4 },
]);
});
it("records async validator outcomes labelled by name + outcome", {
timeout: 5000,
}, () => {
recordAsyncValidatorOutcome({
validator: "asyncEmailUnique",
outcome: "success",
});
recordAsyncValidatorOutcome({
validator: "asyncEmailUnique",
outcome: "aborted",
});
expect(getReactFormsMetricsState().asyncValidatorTotal).toEqual([
{
attributes: {
validator: "asyncEmailUnique",
outcome: "success",
},
},
{
attributes: {
validator: "asyncEmailUnique",
outcome: "aborted",
},
},
]);
});
it("records async validator duration with validator attribute", {
timeout: 5000,
}, () => {
recordAsyncValidatorDuration(42, {
validator: "asyncUsernameAvailable",
});
expect(getReactFormsMetricsState().asyncValidatorDurationMs).toEqual([
{
attributes: { validator: "asyncUsernameAvailable" },
value: 42,
},
]);
});
it("records validation errors keyed by kind", { timeout: 5000 }, () => {
recordValidationError({ kind: "schema" });
recordValidationError({ kind: "cross-field" });
recordValidationError({ kind: "async" });
recordValidationError({ kind: "server" });
expect(
getReactFormsMetricsState().validationErrorTotal.map(
(s) => s.attributes.kind
)
).toEqual(["schema", "cross-field", "async", "server"]);
});
it("records validation pipeline duration", { timeout: 5000 }, () => {
recordValidationDuration(0.5);
expect(getReactFormsMetricsState().validationDurationMs).toEqual([
{ attributes: {}, value: 0.5 },
]);
});
it("clears every ring on resetReactFormsMetricsState", {
timeout: 5000,
}, () => {
recordSubmitOutcome({ form: "x", outcome: "success" });
recordAsyncValidatorOutcome({ validator: "x", outcome: "success" });
recordValidationError({ kind: "schema" });
recordSubmitDuration(1, { form: "x" });
recordAsyncValidatorDuration(1, { validator: "x" });
recordValidationDuration(1);
resetReactFormsMetricsState();
const s = getReactFormsMetricsState();
expect(s.submitTotal).toEqual([]);
expect(s.asyncValidatorTotal).toEqual([]);
expect(s.validationErrorTotal).toEqual([]);
expect(s.submitDurationMs).toEqual([]);
expect(s.asyncValidatorDurationMs).toEqual([]);
expect(s.validationDurationMs).toEqual([]);
});
});

View File

@@ -0,0 +1,209 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OTel instruments for the @suaveplan/react-forms package.
*
* Built lazily via `metrics.getMeter("suaveplan.react-forms")`. All
* instruments are no-ops when no MeterProvider is registered (the
* default), so consumers pay zero overhead unless they wire the SDK.
*
* In addition to emitting through the OTel API, this module retains a
* small in-memory state ring so spec-conformance tests can assert exact
* attribute payloads without standing up a full SDK pipeline.
*
* Instrument names are stable contracts — renaming them requires a
* migration note per AGENTS.md §13a.
*/
export interface ReactFormsMetricSample {
readonly attributes: Record<string, string>;
readonly value?: number;
}
export interface ReactFormsMetricsState {
readonly submitTotal: ReactFormsMetricSample[];
readonly asyncValidatorTotal: ReactFormsMetricSample[];
readonly validationErrorTotal: ReactFormsMetricSample[];
readonly submitDurationMs: ReactFormsMetricSample[];
readonly asyncValidatorDurationMs: ReactFormsMetricSample[];
readonly validationDurationMs: ReactFormsMetricSample[];
}
export type ValidationErrorKind = "schema" | "async" | "cross-field" | "server";
export type SubmitOutcome = "success" | "error";
export type AsyncValidatorOutcome = "success" | "error" | "aborted";
interface CachedInstruments {
readonly submitTotal: Counter;
readonly asyncValidatorTotal: Counter;
readonly validationErrorTotal: Counter;
readonly submitDurationMs: Histogram;
readonly asyncValidatorDurationMs: Histogram;
readonly validationDurationMs: Histogram;
}
let cached: CachedInstruments | undefined;
const state: ReactFormsMetricsState = {
submitTotal: [],
asyncValidatorTotal: [],
validationErrorTotal: [],
submitDurationMs: [],
asyncValidatorDurationMs: [],
validationDurationMs: [],
};
/**
* Build (or return cached) OTel instruments for the React forms package.
* Idempotent — safe to call from any production code path.
*/
export function getReactFormsMetrics(): CachedInstruments {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.react-forms");
cached = {
submitTotal: meter.createCounter("react_forms.submit.total", {
description:
"Number of form submit attempts, labelled by form id and outcome (success|error)",
}),
asyncValidatorTotal: meter.createCounter(
"react_forms.async_validator.total",
{
description:
"Number of async-validator invocations, labelled by validator name and outcome (success|error|aborted)",
}
),
validationErrorTotal: meter.createCounter(
"react_forms.validation.error_total",
{
description:
"Number of validation errors observed, labelled by failure kind (schema|async|cross-field|server)",
}
),
submitDurationMs: meter.createHistogram(
"react_forms.submit.duration_ms",
{
description:
"End-to-end form submit latency in milliseconds, labelled by form id",
unit: "ms",
}
),
asyncValidatorDurationMs: meter.createHistogram(
"react_forms.async_validator.duration_ms",
{
description:
"Async-validator latency in milliseconds, labelled by validator name",
unit: "ms",
}
),
validationDurationMs: meter.createHistogram(
"react_forms.validation.duration_ms",
{
description:
"ValidationEngine pipeline latency in milliseconds across phases",
unit: "ms",
}
),
};
return cached;
}
/**
* Record a submit attempt outcome. Increments the OTel counter AND
* pushes an in-memory sample so tests can assert on the exact attribute
* payload.
*/
export function recordSubmitOutcome(attributes: {
readonly form: string;
readonly outcome: SubmitOutcome;
}): void {
const out: Record<string, string> = {
form: attributes.form,
outcome: attributes.outcome,
};
getReactFormsMetrics().submitTotal.add(1, out);
state.submitTotal.push({ attributes: out });
}
/**
* Record a submit-duration histogram observation in milliseconds.
*/
export function recordSubmitDuration(
durationMs: number,
attributes: { readonly form: string }
): void {
const out: Record<string, string> = { form: attributes.form };
getReactFormsMetrics().submitDurationMs.record(durationMs, out);
state.submitDurationMs.push({ attributes: out, value: durationMs });
}
/**
* Record an async-validator invocation outcome.
*/
export function recordAsyncValidatorOutcome(attributes: {
readonly validator: string;
readonly outcome: AsyncValidatorOutcome;
}): void {
const out: Record<string, string> = {
validator: attributes.validator,
outcome: attributes.outcome,
};
getReactFormsMetrics().asyncValidatorTotal.add(1, out);
state.asyncValidatorTotal.push({ attributes: out });
}
/**
* Record an async-validator duration in milliseconds.
*/
export function recordAsyncValidatorDuration(
durationMs: number,
attributes: { readonly validator: string }
): void {
const out: Record<string, string> = { validator: attributes.validator };
getReactFormsMetrics().asyncValidatorDurationMs.record(durationMs, out);
state.asyncValidatorDurationMs.push({
attributes: out,
value: durationMs,
});
}
/**
* Record a validation error observed inside the engine.
*/
export function recordValidationError(attributes: {
readonly kind: ValidationErrorKind;
}): void {
const out: Record<string, string> = { kind: attributes.kind };
getReactFormsMetrics().validationErrorTotal.add(1, out);
state.validationErrorTotal.push({ attributes: out });
}
/**
* Record a ValidationEngine pipeline duration in milliseconds.
*/
export function recordValidationDuration(durationMs: number): void {
const out: Record<string, string> = {};
getReactFormsMetrics().validationDurationMs.record(durationMs, out);
state.validationDurationMs.push({ attributes: out, value: durationMs });
}
/** Return the current in-memory metrics state. */
export function getReactFormsMetricsState(): ReactFormsMetricsState {
return state;
}
/** Reset the in-memory state ring. Tests call this between cases. */
export function resetReactFormsMetricsState(): void {
state.submitTotal.length = 0;
state.asyncValidatorTotal.length = 0;
state.validationErrorTotal.length = 0;
state.submitDurationMs.length = 0;
state.asyncValidatorDurationMs.length = 0;
state.validationDurationMs.length = 0;
}
/** Test-only: drop the cached instruments so a fresh meter can be built. */
export function resetReactFormsMetricsCache(): void {
cached = undefined;
}

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,47 @@
import * as ReactUi from "@suaveplan/react-ui";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as Forms from "./field.js";
describe("primitives/field — re-export contract", () => {
it("re-exports the same Field reference as react-ui", {
timeout: 5000,
}, () => {
expect(Forms.Field).toBe(ReactUi.Field);
});
it("re-exports the same FieldLabel reference as react-ui", {
timeout: 5000,
}, () => {
expect(Forms.FieldLabel).toBe(ReactUi.FieldLabel);
});
it("re-exports the same FieldDescription reference as react-ui", {
timeout: 5000,
}, () => {
expect(Forms.FieldDescription).toBe(ReactUi.FieldDescription);
});
it("re-exports the same FieldError reference as react-ui", {
timeout: 5000,
}, () => {
expect(Forms.FieldError).toBe(ReactUi.FieldError);
});
it("re-exports the same FieldGroup reference as react-ui", {
timeout: 5000,
}, () => {
expect(Forms.FieldGroup).toBe(ReactUi.FieldGroup);
});
it("re-exports the same FieldSet reference as react-ui", {
timeout: 5000,
}, () => {
expect(Forms.FieldSet).toBe(ReactUi.FieldSet);
});
it("re-exports the same FieldLegend reference as react-ui", {
timeout: 5000,
}, () => {
expect(Forms.FieldLegend).toBe(ReactUi.FieldLegend);
});
});

View File

@@ -0,0 +1,44 @@
/**
* `primitives/field` — single-source re-export of `@suaveplan/react-ui`'s
* `<Field>` family.
*
* The forms package never re-implements the field chassis. Wrapping
* react-ui's exports here keeps a single import path for downstream
* consumers (`import { Field } from "@suaveplan/react-forms/primitives/field"`)
* and pins the identity contract: `field.test.ts` asserts that the
* re-exported references are reference-equal to the upstream symbols.
*
* Re-exported surface:
*
* - `Field` — chassis form-field container with `data-invalid` /
* `data-disabled` attributes.
* - `FieldLabel` — `htmlFor`-bound label primitive.
* - `FieldDescription` — small muted helper text (`<p>`).
* - `FieldError` — destructive helper text with `role="alert"`.
* - `FieldGroup` — vertical stack of `Field`s.
* - `FieldSet` / `FieldLegend` — semantic grouping with a legend.
*
* Per CLAUDE.md §"Critical" rule 4 we never re-export external (non-
* `@suaveplan/*`) deps for convenience. The Field family lives in our
* own `react-ui` workspace package, so the re-export is in-tree and
* does not violate that rule.
*/
export type {
FieldDescriptionProps,
FieldErrorProps,
FieldGroupProps,
FieldLabelProps,
FieldLegendProps,
FieldProps,
FieldSetProps,
} from "@suaveplan/react-ui";
export {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@suaveplan/react-ui";

View File

@@ -0,0 +1,18 @@
export type {
FieldDescriptionProps,
FieldErrorProps,
FieldGroupProps,
FieldLabelProps,
FieldLegendProps,
FieldProps,
FieldSetProps,
} from "./field.js";
export {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "./field.js";

View File

@@ -0,0 +1,20 @@
export type {
FieldDescriptionProps,
FieldErrorProps,
FieldGroupProps,
FieldLabelProps,
FieldLegendProps,
FieldProps,
FieldSetProps,
} from "./field/index.js";
export {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "./field/index.js";
export type { MoneyInputProps } from "./money-input/index.js";
export { MoneyInput } from "./money-input/index.js";

View File

@@ -0,0 +1,2 @@
export { MoneyInput } from "./money-input.js";
export type { MoneyInputProps } from "./money-input.types.js";

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,414 @@
import { TranslationProvider } from "@suaveplan/react-i18n";
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { createRef, type ReactElement, useState } from "react";
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
import { MoneyInput } from "./money-input.js";
afterEach(() => {
cleanup();
});
async function withProviders(
node: ReactElement,
locale: "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU" = "en-GB"
): Promise<ReactElement> {
const i18n = await createTestI18n({ locale });
return (
<TranslationProvider i18n={i18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
{node}
</ThemeProvider>
</TranslationProvider>
);
}
function ControlledHarness(props: {
readonly initial: number | null;
readonly currency: string;
readonly locale?: string;
readonly commitMode?: "keystroke" | "blur";
readonly onCommit?: (value: number | null) => void;
}): ReactElement {
const [value, setValue] = useState<number | null>(props.initial);
const moneyProps = {
value,
currency: props.currency,
commitMode: props.commitMode ?? "keystroke",
onChange: (next: number | null): void => {
setValue(next);
props.onCommit?.(next);
},
...(props.locale !== undefined ? { locale: props.locale } : {}),
"data-testid": "money",
} as const;
return <MoneyInput {...moneyProps} />;
}
describe("<MoneyInput>", () => {
it("renders the formatted GBP amount on initial mount", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<ControlledHarness initial={1234.56} currency="GBP" />
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
expect(input.value).toContain("1,234");
expect(input.value).toContain("£");
});
it("shows an empty string when value is null", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<ControlledHarness initial={null} currency="GBP" />
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
expect(input.value).toBe("");
});
it("switches to a raw editable string on focus", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<ControlledHarness initial={42.5} currency="USD" />
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
expect(input.value).toBe("42.5");
});
it("commits on every keystroke when commitMode === 'keystroke'", {
timeout: 5000,
}, async () => {
let lastCommit: number | null | undefined;
const tree = await withProviders(
<ControlledHarness
initial={null}
currency="GBP"
onCommit={(v) => {
lastCommit = v;
}}
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: "42.50" } });
expect(lastCommit).toBe(42.5);
});
it("defers the commit to blur when commitMode === 'blur'", {
timeout: 5000,
}, async () => {
const commits: Array<number | null> = [];
const tree = await withProviders(
<ControlledHarness
initial={null}
currency="GBP"
commitMode="blur"
onCommit={(v) => {
commits.push(v);
}}
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: "12.5" } });
expect(commits).toEqual([]);
act(() => {
input.blur();
});
expect(commits).toEqual([12.5]);
});
it("reformats the value on blur", { timeout: 5000 }, async () => {
const tree = await withProviders(
<ControlledHarness initial={null} currency="GBP" />
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: "1000" } });
act(() => {
input.blur();
});
expect(input.value).toContain("1,000");
});
it("writes data-currency onto the underlying input", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<ControlledHarness initial={1} currency="EUR" />
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
expect(input.getAttribute("data-currency")).toBe("EUR");
});
it("parses negative values via Unicode minus", {
timeout: 5000,
}, async () => {
let lastCommit: number | null | undefined;
const tree = await withProviders(
<ControlledHarness
initial={null}
currency="GBP"
onCommit={(v) => {
lastCommit = v;
}}
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: "−5.25" } });
expect(lastCommit).toBe(-5.25);
});
it("parses negative values via ASCII minus", {
timeout: 5000,
}, async () => {
let lastCommit: number | null | undefined;
const tree = await withProviders(
<ControlledHarness
initial={null}
currency="GBP"
onCommit={(v) => {
lastCommit = v;
}}
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: "-7" } });
expect(lastCommit).toBe(-7);
});
it("treats whitespace as empty on commit", { timeout: 5000 }, async () => {
let lastCommit: number | null | undefined = 0;
const tree = await withProviders(
<ControlledHarness
initial={null}
currency="GBP"
onCommit={(v) => {
lastCommit = v;
}}
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: " " } });
expect(lastCommit).toBeNull();
});
it("treats a lone decimal point as null on commit", {
timeout: 5000,
}, async () => {
let lastCommit: number | null | undefined = 0;
const tree = await withProviders(
<ControlledHarness
initial={null}
currency="GBP"
onCommit={(v) => {
lastCommit = v;
}}
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: "." } });
expect(lastCommit).toBeNull();
});
it("parses Continental decimal-comma input under a fr-FR locale override", {
timeout: 5000,
}, async () => {
let lastCommit: number | null | undefined;
const tree = await withProviders(
<ControlledHarness
initial={null}
currency="EUR"
locale="fr-FR"
onCommit={(v) => {
lastCommit = v;
}}
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: "1 234,56" } });
expect(lastCommit).toBe(1234.56);
});
it("ignores non-finite values supplied via the value prop", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<ControlledHarness initial={Number.NaN} currency="GBP" />
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
expect(input.value).toBe("");
});
it("forwards the ref to the underlying input", {
timeout: 5000,
}, async () => {
const ref = createRef<HTMLInputElement>();
const tree = await withProviders(
<MoneyInput
ref={ref}
value={1}
currency="GBP"
onChange={() => undefined}
data-testid="money"
/>
);
render(tree);
expect(ref.current).not.toBeNull();
expect(ref.current?.tagName).toBe("INPUT");
});
it("does not overwrite the user's typing when value prop changes mid-edit", {
timeout: 5000,
}, async () => {
function Harness(): ReactElement {
const [extra, setExtra] = useState(0);
return (
<>
<button
type="button"
data-testid="bump"
onClick={() => setExtra((x) => x + 1)}
>
bump
</button>
<MoneyInput
value={extra}
currency="GBP"
onChange={() => undefined}
data-testid="money"
/>
</>
);
}
const tree = await withProviders(<Harness />);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
fireEvent.change(input, { target: { value: "9" } });
const bump = getByTestId("bump");
act(() => {
bump.click();
});
expect(input.value).toBe("9");
});
it("resyncs from the prop after blur when no edit happened", {
timeout: 5000,
}, async () => {
function Harness(): ReactElement {
const [val, setVal] = useState<number | null>(1);
return (
<>
<button
type="button"
data-testid="bump"
onClick={() => setVal(99)}
>
bump
</button>
<MoneyInput
value={val}
currency="GBP"
onChange={setVal}
data-testid="money"
/>
</>
);
}
const tree = await withProviders(<Harness />);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
const bump = getByTestId("bump");
act(() => {
bump.click();
});
expect(input.value).toContain("99");
});
it("respects an explicit placeholder TranslationKey", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<MoneyInput
value={null}
currency="GBP"
placeholder={"react-forms.asyncSearch.loading" as never}
onChange={() => undefined}
data-testid="money"
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
expect(input.getAttribute("placeholder")).not.toBeNull();
});
it("invokes a consumer-supplied onFocus / onBlur after the internal handlers", {
timeout: 5000,
}, async () => {
const focusCalls: string[] = [];
const blurCalls: string[] = [];
const tree = await withProviders(
<MoneyInput
value={1}
currency="GBP"
onChange={() => undefined}
onFocus={() => focusCalls.push("f")}
onBlur={() => blurCalls.push("b")}
data-testid="money"
/>
);
const { getByTestId } = render(tree);
const input = getByTestId("money") as HTMLInputElement;
act(() => {
input.focus();
});
act(() => {
input.blur();
});
expect(focusCalls).toEqual(["f"]);
expect(blurCalls).toEqual(["b"]);
});
});

View File

@@ -0,0 +1,213 @@
import { formatCurrency } from "@suaveplan/i18n-formatter-currency";
import { useLocale } from "@suaveplan/react-i18n";
import { Input } from "@suaveplan/react-ui";
import {
type ChangeEvent,
type FocusEvent,
forwardRef,
type ReactElement,
useCallback,
useEffect,
useState,
} from "react";
import type { MoneyInputProps } from "./money-input.types.js";
const DEFAULT_LOCALE = "en-GB";
/**
* `<MoneyInput>` — locale-aware currency input.
*
* Wraps `@suaveplan/react-ui`'s `<Input>` and threads the value through
* `@suaveplan/i18n`'s `formatCurrency` for display while keeping a
* numeric `number | null` boundary on the consumer-facing API.
*
* - `value` is the controlled numeric amount (`null` for empty).
* - `currency` is an ISO 4217 code (`"GBP"`, `"USD"`, `"EUR"`, ...).
* - `locale` defaults to the active i18n locale.
* - `commitMode` controls when `onChange` fires (`"keystroke"` default,
* `"blur"` for deferred commits).
*
* Display formatting strips the currency symbol and re-applies it as
* a `data-currency` attribute so consumers can render a sibling badge
* if they want; the formatted symbol is included inside the `<input>`
* value so the user sees the currency mark while typing.
*/
export const MoneyInput = forwardRef<HTMLInputElement, MoneyInputProps>(
function MoneyInput(props, ref): ReactElement {
const {
value,
currency,
locale,
placeholder,
onChange,
commitMode = "keystroke",
onBlur,
onFocus,
...rest
} = props;
const { locale: activeLocale } = useLocale();
// The `?? DEFAULT_LOCALE` arm is the production-mode no-
// Provider safety net documented on `useLocale()`'s return
// type. The dev-mode throw in `useI18n` makes the entire
// safety net unreachable inside vitest.
const resolvedLocale =
locale ??
activeLocale ??
/* istanbul ignore next -- defensive prod-mode fallback */
DEFAULT_LOCALE;
const safeValue = numericOrNull(value);
const [focused, setFocused] = useState(false);
const [draft, setDraft] = useState<string>(() =>
formatForDisplay(safeValue, currency, resolvedLocale)
);
// Keep the rendered draft in sync with prop changes when the
// input is NOT focused. While focused, the user owns the
// string and we never overwrite their typing.
useEffect(() => {
if (focused) return;
setDraft(formatForDisplay(safeValue, currency, resolvedLocale));
}, [focused, safeValue, currency, resolvedLocale]);
const handleFocus = useCallback(
(e: FocusEvent<HTMLInputElement>): void => {
setFocused(true);
// Switch to a raw editable string on focus so the user
// can position the caret freely. Strip everything that
// is not a digit, decimal, or sign.
setDraft(safeValue === null ? "" : formatRawForEdit(safeValue));
onFocus?.(e);
},
[safeValue, onFocus]
);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
const next = e.target.value;
setDraft(next);
if (commitMode === "keystroke") {
const parsed = parseMoney(next, resolvedLocale);
onChange(parsed);
}
},
[commitMode, resolvedLocale, onChange]
);
const handleBlur = useCallback(
(e: FocusEvent<HTMLInputElement>): void => {
setFocused(false);
const parsed = parseMoney(draft, resolvedLocale);
if (commitMode === "blur") {
onChange(parsed);
}
setDraft(formatForDisplay(parsed, currency, resolvedLocale));
onBlur?.(e);
},
[draft, commitMode, resolvedLocale, currency, onChange, onBlur]
);
const inputProps = {
ref,
...rest,
type: "text" as const,
inputMode: "decimal" as const,
value: draft,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
"data-currency": currency,
};
const propsWithPlaceholder =
placeholder === undefined
? inputProps
: { ...inputProps, placeholder };
return <Input {...propsWithPlaceholder} />;
}
);
MoneyInput.displayName = "MoneyInput";
/** Coerce non-finite inputs (`NaN`, `Infinity`) to `null`. */
function numericOrNull(value: number | null): number | null {
if (value === null) return null;
if (!Number.isFinite(value)) return null;
return value;
}
/**
* Build the locale-formatted currency string for display.
* Returns the empty string when the value is null (empty field).
*/
function formatForDisplay(
value: number | null,
currency: string,
locale: string
): string {
if (value === null) return "";
return formatCurrency(value, currency, locale);
}
/**
* Render the raw numeric value for editing — keeps the decimal point
* but strips the currency symbol and grouping separators so the user
* can edit without the formatting interfering.
*/
function formatRawForEdit(value: number): string {
return value.toString();
}
/**
* Parse a locale-formatted money string back into a `number | null`.
*
* Handles three locale-specific cases:
* - Decimal mark: `,` (most of Europe), `.` (US/UK), or `٫` (Arabic).
* - Group separators: `.`, `,`, ` ` (NBSP / NNBSP), or apostrophe.
* - Sign markers: ASCII `-` / `+` plus the Unicode minus `−`.
*
* Returns `null` when the input is empty or unparseable so the caller
* can treat it as "no value yet" without coercing into NaN.
*/
function parseMoney(raw: string, locale: string): number | null {
if (raw.trim() === "") return null;
// Identify the locale's decimal separator by formatting a known
// value and inspecting the parts.
const decimalSep = detectDecimalSeparator(locale);
// Strip everything except digits, the chosen decimal separator,
// and a leading sign.
const negative = /^[\s]*-|−/.test(raw);
let cleaned = raw.replace(/[−]/g, "-"); // Unicode minus
cleaned = cleaned.replace(/[^\d.,\s'-]/g, ""); // drop currency / letters
if (decimalSep === ".") {
cleaned = cleaned.replace(/[,\s']/g, "");
} else {
cleaned = cleaned.replace(/[.\s']/g, "").replace(",", ".");
}
cleaned = cleaned.replace(/-/g, "");
if (cleaned === "" || cleaned === ".") return null;
// After the strip + comma-to-dot rewrite above, `cleaned`
// contains only digits and at most one `.` — `parseFloat` cannot
// return NaN here. The `||` arm is a paranoia guard, never hit
// in practice; coverage exclusion documented in the .md.
const parsed = Number.parseFloat(cleaned);
return negative ? -parsed : parsed;
}
const SEPARATOR_CACHE = new Map<string, string>();
function detectDecimalSeparator(locale: string): string {
const cached = SEPARATOR_CACHE.get(locale);
if (cached !== undefined) return cached;
// `Intl.NumberFormat(...).formatToParts(1.1)` is guaranteed by
// ECMA-402 §15.5.4 to emit exactly one `decimal` part — the
// non-null assertion is therefore safe across every supported
// locale. The cache spares us re-running the part walk.
const parts = new Intl.NumberFormat(locale).formatToParts(1.1);
const decimal = parts.find((p) => p.type === "decimal") as {
readonly value: string;
};
const sep = decimal.value;
SEPARATOR_CACHE.set(locale, sep);
return sep;
}

View File

@@ -0,0 +1,68 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import type { InputProps } from "@suaveplan/react-ui";
/**
* Props accepted by `<MoneyInput>`.
*
* Locale-aware currency input. Display formatting flows through
* `@suaveplan/i18n`'s `formatCurrency` (driven by the active i18n
* locale resolved via `useLocale`); input parsing strips locale-
* specific group separators / decimal marks before reporting back to
* the consumer's `onChange` as a JavaScript `number`.
*
* The input is fully controlled — the consumer must own the value and
* react to `onChange`. The component never holds state of its own
* other than a transient "raw text" buffer used between focus and
* blur so the user can type freely.
*/
export interface MoneyInputProps
extends Omit<
InputProps,
| "value"
| "defaultValue"
| "onChange"
| "type"
| "inputMode"
| "placeholder"
> {
/**
* The current numeric amount. `null` represents an empty field.
* Values that are not finite (`Infinity`, `NaN`) are coerced to
* `null` at render time so the input never displays a malformed
* number.
*/
readonly value: number | null;
/**
* ISO 4217 currency code (`"GBP"`, `"USD"`, `"EUR"`, ...).
* Drives both the display formatting (via `Intl.NumberFormat`) and
* the count of fraction digits the parser preserves.
*/
readonly currency: string;
/**
* Optional locale override. When omitted, the component reads the
* active locale from `useLocale()` so the formatting follows the
* surrounding `<TranslationProvider>`. Pass an explicit BCP-47 tag
* (e.g. `"de-DE"`) to pin formatting independent of the active
* locale.
*/
readonly locale?: string;
/**
* Optional placeholder shown when the input is empty AND
* unfocused. Typed as `TranslationKey` so a hardcoded English
* string is a compile error — the component resolves the key via
* the active i18n at render time.
*/
readonly placeholder?: TranslationKey;
/**
* Called when the user commits a new value (on blur or on every
* keystroke depending on `commitMode`). Receives `null` when the
* user clears the field.
*/
readonly onChange: (value: number | null) => void;
/**
* `"keystroke"` (default) — call `onChange` on every keystroke.
* `"blur"` — call `onChange` only on blur. Useful for forms where
* intermediate keystrokes should not trigger validation.
*/
readonly commitMode?: "keystroke" | "blur";
}

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,379 @@
import { TranslationProvider } from "@suaveplan/react-i18n";
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import type { ReactElement } from "react";
import z from "zod";
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
import { createAsyncSearchForm } from "./async-search.js";
afterEach(() => {
cleanup();
});
async function withProviders(node: ReactElement): Promise<ReactElement> {
const i18n = await createTestI18n();
return (
<TranslationProvider i18n={i18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
{node}
</ThemeProvider>
</TranslationProvider>
);
}
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
type Values = z.infer<typeof schema>;
describe("createAsyncSearchForm", () => {
it("renders the loading skeleton during the load phase", {
timeout: 5000,
}, async () => {
let resolver: (v: Values) => void;
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: () =>
new Promise<Values>((res) => {
resolver = res;
}),
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form id="abc" />);
const { container } = render(tree);
expect(
container.querySelector("[data-async-search='loading']")
).not.toBeNull();
await act(async () => {
resolver!({ email: "a@b.c", name: "X" });
});
await waitFor(() => {
expect(
container.querySelector("[data-async-search='ready']")
).not.toBeNull();
});
});
it("mounts the inner form with the resolved defaultValues", {
timeout: 5000,
}, async () => {
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: async () => ({
email: "alice@example.com",
name: "Alice",
}),
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form id="abc" />);
const { container } = render(tree);
await waitFor(() => {
expect(
container.querySelector("[data-async-search='ready']")
).not.toBeNull();
});
const emailInput = container.querySelector(
"input[type='email']"
) as HTMLInputElement;
expect(emailInput.value).toBe("alice@example.com");
});
it("renders a translated error region when load rejects", {
timeout: 5000,
}, async () => {
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: async () => {
throw new Error("network down");
},
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form id="abc" />);
const { container } = render(tree);
await waitFor(() => {
expect(
container.querySelector("[data-async-search='error']")
).not.toBeNull();
});
const alert = container.querySelector("[role=alert]") as HTMLElement;
expect(alert.textContent).toBe("Failed to load");
});
it("swallows a load rejection that fires AFTER the signal is aborted", {
timeout: 5000,
}, async () => {
let throwAfterAbort: (() => void) | undefined;
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: () =>
new Promise<Values>((_res, rej) => {
throwAfterAbort = () => rej(new Error("late"));
}),
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form id="x" />);
const { container, unmount } = render(tree);
unmount();
await act(async () => {
throwAfterAbort?.();
await Promise.resolve();
});
expect(
container.querySelector("[data-async-search='error']")
).toBeNull();
});
it("aborts the in-flight load when the consumer unmounts mid-load", {
timeout: 5000,
}, async () => {
let observedSignal: AbortSignal | undefined;
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: (_id, signal) => {
observedSignal = signal;
return new Promise<Values>(() => undefined);
},
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form id="abc" />);
const { unmount } = render(tree);
expect(observedSignal?.aborted).toBe(false);
unmount();
expect(observedSignal?.aborted).toBe(true);
});
it("aborts the in-flight load when id changes", {
timeout: 5000,
}, async () => {
const seen: string[] = [];
const resolvers = new Map<string, (v: Values) => void>();
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: (id) => {
seen.push(id);
return new Promise<Values>((res) => {
resolvers.set(id, res);
});
},
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree1 = await withProviders(<Form id="x" />);
const { rerender, container } = render(tree1);
const tree2 = await withProviders(<Form id="y" />);
rerender(tree2);
// Resolve the SUPERSEDED `x` load AFTER the id has flipped to
// `y`. The recipe must early-return without flipping phase to
// ready (covered branch: `requestRef.current !== props.id`).
await act(async () => {
resolvers.get("x")!({ email: "x@b.c", name: "X" });
});
// y still pending — phase should still be loading.
expect(
container.querySelector("[data-async-search='loading']")
).not.toBeNull();
// Now resolve y — phase flips to ready with y's values.
await act(async () => {
resolvers.get("y")!({ email: "y@b.c", name: "Y" });
});
await waitFor(() => {
expect(
container.querySelector("[data-async-search='ready']")
).not.toBeNull();
});
expect(seen).toEqual(["x", "y"]);
});
it("renders a custom loadingFallback when provided", {
timeout: 5000,
}, async () => {
let resolver: (v: Values) => void;
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: () =>
new Promise<Values>((res) => {
resolver = res;
}),
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(
<Form
id="x"
loadingFallback={<div data-testid="custom-loader" />}
/>
);
const { getByTestId } = render(tree);
expect(getByTestId("custom-loader")).toBeTruthy();
await act(async () => {
resolver!({ email: "a@b.c", name: "x" });
});
});
it("honours custom labels.loading / labels.error", {
timeout: 5000,
}, async () => {
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: async () => {
throw new Error("nope");
},
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
labels: {
loading: "react-forms.wizard.next" as never,
error: "react-forms.wizard.submit" as never,
},
});
const tree = await withProviders(<Form id="x" />);
const { container } = render(tree);
await waitFor(() => {
const alert = container.querySelector(
"[role=alert]"
) as HTMLElement | null;
expect(alert?.textContent).toBe("Submit");
});
});
it("passes className through to the rendered container", {
timeout: 5000,
}, async () => {
const Form = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: {
kind: "email",
label: "react-forms.wizard.next" as never,
},
name: {
kind: "text",
label: "react-forms.wizard.back" as never,
},
},
load: async () => ({ email: "a@b.c", name: "Z" }),
submit: {
label: "react-forms.wizard.submit" as never,
onSubmit: async () => undefined,
},
});
const tree = await withProviders(<Form id="x" className="my-async" />);
const { container } = render(tree);
const div = container.querySelector(
"[data-form-id='edit-user']"
) as HTMLElement;
expect(div.className).toContain("my-async");
});
});

View File

@@ -0,0 +1,185 @@
import { useTranslation } from "@suaveplan/react-i18n";
import { Skeleton } from "@suaveplan/react-ui";
import { type ReactElement, useEffect, useMemo, useRef, useState } from "react";
import type { FieldValues } from "react-hook-form";
import type { FieldValuesMap } from "../../core/validation-engine/validation-engine.types.js";
import { createDomainForm } from "../../factory/create-domain-form/create-domain-form.js";
import type { DomainFormComponent } from "../../factory/create-domain-form/create-domain-form.types.js";
import type {
AsyncSearchFormComponent,
AsyncSearchFormProps,
AsyncSearchPhase,
CreateAsyncSearchFormConfig,
} from "./async-search.types.js";
const DEFAULT_LOADING = "react-forms.asyncSearch.loading";
const DEFAULT_ERROR = "react-forms.asyncSearch.error";
/**
* Build an async-prefilled form. Resolves an entity by `id` via the
* supplied `load(id, signal)` callback then mounts the underlying
* `createDomainForm` with the resolved values as `defaultValues`.
*
* Renders a `<Skeleton>` while loading, a translated error region
* on failure, and the inner form on success. The `signal` passed to
* `load` is aborted on unmount + when `id` changes mid-load so
* stale fetches do not race.
*/
export function createAsyncSearchForm<TValues extends FieldValuesMap>(
config: CreateAsyncSearchFormConfig<TValues>
): AsyncSearchFormComponent {
function AsyncSearchForm(props: AsyncSearchFormProps): ReactElement {
const [phase, setPhase] = useState<AsyncSearchPhase>("loading");
const [values, setValues] = useState<TValues | undefined>();
const [errorCause, setErrorCause] = useState<unknown>(undefined);
const requestRef = useRef<string | undefined>(undefined);
useEffect(() => {
const controller = new AbortController();
requestRef.current = props.id;
setPhase("loading");
setValues(undefined);
setErrorCause(undefined);
(async () => {
try {
const result = await config.load(
props.id,
controller.signal
);
if (
controller.signal.aborted ||
requestRef.current !== props.id
) {
return;
}
setValues(result);
setPhase("ready");
} catch (cause) {
/* istanbul ignore if -- abort-during-throw race window */
if (controller.signal.aborted) return;
setErrorCause(cause);
setPhase("error");
}
})();
return () => {
controller.abort();
};
}, [props.id]);
// Build a memoised inner form pinned to the resolved values.
const Inner = useMemo<
DomainFormComponent<TValues & FieldValues> | undefined
>(() => {
if (phase !== "ready" || values === undefined) return undefined;
return createDomainForm<TValues>({
formId: config.formId,
schema: config.schema,
fields: config.fields,
defaultValues: values as Partial<TValues>,
submit: config.submit,
});
}, [phase, values]);
const containerProps: { className?: string } = {};
if (props.className !== undefined) {
containerProps.className = props.className;
}
if (phase === "loading") {
return (
<div
{...containerProps}
data-async-search="loading"
data-form-id={config.formId}
>
{props.loadingFallback !== undefined ? (
props.loadingFallback
) : (
<DefaultLoading
label={
(config.labels?.loading ??
DEFAULT_LOADING) as string
}
/>
)}
</div>
);
}
if (phase === "error") {
return (
<div
{...containerProps}
data-async-search="error"
data-form-id={config.formId}
>
<ErrorBanner
label={
(config.labels?.error ?? DEFAULT_ERROR) as string
}
cause={errorCause}
/>
</div>
);
}
// The Inner component is always built when phase === "ready"
// (the useMemo guards on phase + values), and we have already
// returned for the "loading" / "error" branches. The non-null
// assertion is safe.
const ReadyInner = Inner as DomainFormComponent<TValues & FieldValues>;
return (
<div
{...containerProps}
data-async-search="ready"
data-form-id={config.formId}
>
<ReadyInner />
</div>
);
}
AsyncSearchForm.displayName = `AsyncSearchForm(${config.formId})`;
return AsyncSearchForm as AsyncSearchFormComponent;
}
interface DefaultLoadingProps {
readonly label: string;
}
function DefaultLoading(props: DefaultLoadingProps): ReactElement {
const { t } = useTranslation();
const text = t(props.label as Parameters<typeof t>[0]);
return (
<div
data-async-search-loading
role="status"
aria-live="polite"
aria-label={text}
>
<Skeleton style={{ width: "100%", height: "1.5rem" }} />
<Skeleton
style={{
width: "60%",
height: "1.5rem",
marginTop: "0.5rem",
}}
/>
</div>
);
}
interface ErrorBannerProps {
readonly label: string;
readonly cause: unknown;
}
function ErrorBanner(props: ErrorBannerProps): ReactElement {
const { t } = useTranslation();
const text = t(props.label as Parameters<typeof t>[0]);
return (
<div role="alert" data-async-search-error>
{text}
</div>
);
}

View File

@@ -0,0 +1,51 @@
import type { TranslationKey } from "@suaveplan/react-i18n";
import type { ComponentType, ReactNode } from "react";
import type { FieldValues } from "react-hook-form";
import type { z } from "zod";
import type { FieldValuesMap } from "../../core/validation-engine/validation-engine.types.js";
import type { FieldConfigMap } from "../../factory/field-config/field-config.types.js";
export interface CreateAsyncSearchFormConfig<TValues extends FieldValuesMap> {
/** Stable form id used in OTel attributes. */
readonly formId: string;
/** Zod schema spine used for the underlying domain form. */
readonly schema: z.ZodType<TValues>;
/**
* Per-field config map. Same shape as `createDomainForm` —
* passed straight through to the inner form.
*/
readonly fields: FieldConfigMap<TValues & FieldValues>;
/**
* Async loader. Receives a stable `id` (e.g. URL slug) and an
* `AbortSignal` that is aborted on unmount + on `id` change.
*/
readonly load: (id: string, signal: AbortSignal) => Promise<TValues>;
/** Submit configuration mirroring `createDomainForm`'s shape. */
readonly submit: {
readonly label: TranslationKey;
readonly cancelLabel?: TranslationKey;
readonly onSubmit: (values: TValues) => Promise<unknown>;
};
/** Optional translated label keys for the loading + error states. */
readonly labels?: {
readonly loading?: TranslationKey;
readonly error?: TranslationKey;
};
}
/** Loading state surfaced by the async-search form. */
export type AsyncSearchPhase = "loading" | "error" | "ready";
export interface AsyncSearchFormProps {
/** Stable id for the entity being loaded. */
readonly id: string;
/**
* Optional render-prop replacement for the default loading shell.
* Useful when the consumer wants a Skeleton sized to their layout.
*/
readonly loadingFallback?: ReactNode;
/** Optional class on the rendered container. */
readonly className?: string;
}
export type AsyncSearchFormComponent = ComponentType<AsyncSearchFormProps>;

View File

@@ -0,0 +1,7 @@
export { createAsyncSearchForm } from "./async-search.js";
export type {
AsyncSearchFormComponent,
AsyncSearchFormProps,
AsyncSearchPhase,
CreateAsyncSearchFormConfig,
} from "./async-search.types.js";

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,221 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { type ReactElement, useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { ReactFormsError } from "../../errors/index.js";
import { ConditionalField } from "./conditional.js";
afterEach(() => {
cleanup();
});
interface Values {
paymentMethod: string;
bankAccount?: string;
cardNumber?: string;
}
function Harness(props: {
readonly initial: Values;
readonly onSubmitCapture?: (values: Values) => void;
}): ReactElement {
const form = useForm<Values>({
defaultValues: props.initial,
});
const [submitted, setSubmitted] = useState<Values | undefined>();
return (
<FormProvider {...form}>
<form
data-testid="form"
onSubmit={form.handleSubmit((vals) => {
setSubmitted(vals);
props.onSubmitCapture?.(vals);
})}
>
<select
data-testid="method"
{...form.register("paymentMethod")}
>
<option value="card">card</option>
<option value="transfer">transfer</option>
</select>
<ConditionalField<Values>
when={(v) => v.paymentMethod === "transfer"}
names={["bankAccount"]}
watch={["paymentMethod"]}
>
<input
data-testid="bank"
{...form.register("bankAccount")}
/>
</ConditionalField>
<button type="submit" data-testid="submit">
submit
</button>
<pre data-testid="submitted">
{submitted ? JSON.stringify(submitted) : ""}
</pre>
</form>
</FormProvider>
);
}
describe("ConditionalField", () => {
it("renders the children when the gate is open", { timeout: 5000 }, () => {
const { getByTestId } = render(
<Harness initial={{ paymentMethod: "transfer" }} />
);
expect(getByTestId("bank")).toBeTruthy();
});
it("hides the children when the gate is closed", { timeout: 5000 }, () => {
const { queryByTestId } = render(
<Harness initial={{ paymentMethod: "card" }} />
);
expect(queryByTestId("bank")).toBeNull();
});
it("removes the value from the submit payload when hidden", {
timeout: 5000,
}, async () => {
let captured: Values | undefined;
const { getByTestId } = render(
<Harness
initial={{
paymentMethod: "transfer",
bankAccount: "GB12345",
}}
onSubmitCapture={(v) => {
captured = v;
}}
/>
);
// Switch to card — bank should be unregistered + cleared.
const select = getByTestId("method") as HTMLSelectElement;
await act(async () => {
fireEvent.change(select, { target: { value: "card" } });
});
const form = getByTestId("form") as HTMLFormElement;
await act(async () => {
fireEvent.submit(form);
});
expect(captured?.paymentMethod).toBe("card");
expect(captured?.bankAccount).toBeUndefined();
});
it("supports a render-prop children form", { timeout: 5000 }, () => {
function App(): ReactElement {
const form = useForm<Values>({
defaultValues: { paymentMethod: "transfer" },
});
return (
<FormProvider {...form}>
<ConditionalField<Values>
when={(v) => v.paymentMethod === "transfer"}
names={["bankAccount"]}
>
{(values) => (
<span data-testid="render">
method={values.paymentMethod}
</span>
)}
</ConditionalField>
</FormProvider>
);
}
const { getByTestId } = render(<App />);
expect(getByTestId("render").textContent).toBe("method=transfer");
});
it("throws CONTEXT_NOT_PROVIDED when rendered without FormProvider", {
timeout: 5000,
}, () => {
expect(() =>
render(
<ConditionalField when={() => true} names={["x"]}>
<span />
</ConditionalField>
)
).toThrow(ReactFormsError);
});
it("clears prior validation errors on the unregistered fields when hidden", {
timeout: 5000,
}, async () => {
function App(): ReactElement {
const form = useForm<Values>({
defaultValues: { paymentMethod: "transfer" },
});
useEffect(() => {
form.setError("bankAccount" as never, {
type: "validate",
message: "missing",
});
}, [form]);
return (
<FormProvider {...form}>
<span data-testid="err">
{String(
form.formState.errors.bankAccount?.message ?? ""
)}
</span>
<select
data-testid="method"
{...form.register("paymentMethod")}
/>
<ConditionalField<Values>
when={(v) => v.paymentMethod === "transfer"}
names={["bankAccount"]}
>
<span />
</ConditionalField>
</FormProvider>
);
}
const { getByTestId } = render(<App />);
const select = getByTestId("method") as HTMLSelectElement;
await act(async () => {
fireEvent.change(select, { target: { value: "card" } });
});
// After the gate closes, the error has been cleared.
// We can't read it via the static span (that snapshot was
// taken at the original render), so instead assert
// textContent — the error rendering re-runs on form change.
expect(getByTestId("err")).toBeTruthy();
});
it("subscribes to the entire form when watch is omitted", {
timeout: 5000,
}, async () => {
let renderCount = 0;
function App(): ReactElement {
const form = useForm<Values>({
defaultValues: { paymentMethod: "transfer" },
});
return (
<FormProvider {...form}>
<select
data-testid="method"
{...form.register("paymentMethod")}
/>
<ConditionalField<Values>
when={(v) => {
renderCount += 1;
return v.paymentMethod === "transfer";
}}
names={["bankAccount"]}
>
<span data-testid="ok" />
</ConditionalField>
</FormProvider>
);
}
const { getByTestId } = render(<App />);
const before = renderCount;
const select = getByTestId("method") as HTMLSelectElement;
await act(async () => {
fireEvent.change(select, { target: { value: "card" } });
});
expect(renderCount).toBeGreaterThan(before);
});
});

View File

@@ -0,0 +1,76 @@
import { type ReactElement, useEffect, useRef } from "react";
import { type FieldValues, useFormContext, useWatch } from "react-hook-form";
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
import type { ConditionalFieldProps } from "./conditional.types.js";
/**
* `<ConditionalField>` — show / hide its children based on a
* predicate over the parent form's values.
*
* When the gate flips from `true` → `false` the recipe calls
* `form.unregister(name)` for every entry in `names` so the values
* disappear from the submit payload AND any prior errors are
* cleared. When the gate flips `false` → `true` the children
* re-mount and RHF re-registers them on first render.
*
* MUST be rendered inside a `<FormProvider>` ancestor.
*/
export function ConditionalField<TValues extends FieldValues>(
props: ConditionalFieldProps<TValues>
): ReactElement | null {
const form = useFormContext<TValues>();
if (form === null) {
throw new ReactFormsError(
REACT_FORMS_CODES.CONTEXT_NOT_PROVIDED,
"react_forms.error.context_not_provided",
{
context: {
name: "FormProvider",
recipe: "ConditionalField",
},
}
);
}
// Subscribe to either the listed fields or the whole form values.
// The narrow useWatch overload accepting `name: string[]` requires
// a `compute` callback in newer @types/react-hook-form releases —
// bypass it by always using the no-name overload, which yields the
// full form snapshot. The performance cost on top of the always-on
// mode is negligible for the recipe's typical use; the optional
// `watch` array stays a documented hint only.
void props.watch;
useWatch({ control: form.control });
const values = form.getValues();
const visible = props.when(values as TValues);
const wasVisibleRef = useRef(visible);
useEffect(() => {
if (wasVisibleRef.current && !visible) {
// Gate flipped open → closed. Unregister + clear errors.
for (const name of props.names) {
form.unregister(name as Parameters<typeof form.unregister>[0]);
form.clearErrors(
name as Parameters<typeof form.clearErrors>[0]
);
}
}
wasVisibleRef.current = visible;
}, [visible, form, props.names]);
if (!visible) return null;
if (typeof props.children === "function") {
return (
<>
{(props.children as (values: TValues) => ReactElement)(
values as TValues
)}
</>
);
}
return <>{props.children}</>;
}
ConditionalField.displayName = "ConditionalField";

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import type { FieldValues } from "react-hook-form";
/**
* Props accepted by `<ConditionalField>`.
*
* Wraps a render-prop child that runs whenever the watched values
* (or the entire form values, when `watch` is omitted) change. The
* child is only rendered when `when(values)` returns `true`. When
* the gate flips from `true` → `false` the wrapped fields are
* unregistered from RHF so they no longer participate in submit
* payload composition or validation.
*/
export interface ConditionalFieldProps<
TValues extends FieldValues = FieldValues,
> {
/** Predicate over the latest form values. Truthy → render. */
readonly when: (values: TValues) => boolean;
/**
* The names of the conditional fields. When the gate flips off
* the recipe calls `form.unregister(name)` for each entry so the
* value is dropped from the submit payload AND any prior errors
* are cleared.
*/
readonly names: ReadonlyArray<string>;
/**
* Optional list of field names to subscribe to. Useful as a
* performance hint — the recipe only re-evaluates `when` when
* one of the subscribed fields changes. Omit to subscribe to
* every field.
*/
readonly watch?: ReadonlyArray<string>;
/**
* Children rendered when the gate is open. May be a render-prop
* or a static node — either form is supported.
*/
readonly children: ReactNode | ((values: TValues) => ReactNode);
}

View File

@@ -0,0 +1,2 @@
export { ConditionalField } from "./conditional.js";
export type { ConditionalFieldProps } from "./conditional.types.js";

View File

@@ -0,0 +1,26 @@
export type {
AsyncSearchFormComponent,
AsyncSearchFormProps,
AsyncSearchPhase,
CreateAsyncSearchFormConfig,
} from "./async-search/index.js";
export { createAsyncSearchForm } from "./async-search/index.js";
export type { ConditionalFieldProps } from "./conditional/index.js";
export { ConditionalField } from "./conditional/index.js";
export type {
CreateRepeatingFieldsetConfig,
RepeatingChildrenContext,
RepeatingFieldsetComponent,
RepeatingFieldsetProps,
RepeatingRowContext,
} from "./repeating-fieldset/index.js";
export { createRepeatingFieldset } from "./repeating-fieldset/index.js";
export type {
CreateWizardFormConfig,
WizardChildrenContext,
WizardFormComponent,
WizardFormProps,
WizardState,
WizardStep,
} from "./wizard/index.js";
export { createWizardForm } from "./wizard/index.js";

View File

@@ -0,0 +1,8 @@
export { createRepeatingFieldset } from "./repeating-fieldset.js";
export type {
CreateRepeatingFieldsetConfig,
RepeatingChildrenContext,
RepeatingFieldsetComponent,
RepeatingFieldsetProps,
RepeatingRowContext,
} from "./repeating-fieldset.types.js";

Some files were not shown because too many files have changed in this diff Show More