# react-forms โ€” Spec Delta ## ADDED Requirements ### Requirement: Layered package surface The `@suaveplan/react-forms` package SHALL expose its public surface in five layers โ€” `primitives/`, `core/`, `factory/`, `recipes/`, and `validation/` โ€” with each layer building only on the ones above it. #### Scenario: Layer dependencies are unidirectional - **GIVEN** the package source tree - **WHEN** `bunx turbo build --filter=@suaveplan/react-forms` runs - **THEN** no module under `primitives/` imports from `core/`, `factory/`, `recipes/`, or `validation/` - **AND** no module under `core/` imports from `factory/`, `recipes/`, or `validation/` - **AND** no module under `factory/` imports from `recipes/` - **AND** subpath exports allow tree-shaking each layer independently. **Verified by:** `tests/architecture/layer-deps.test.ts` (parses imports and asserts the dependency direction). --- ### Requirement: `createDomainForm` factory contract The factory SHALL accept a Zod schema, a typed field-config map, a layout selector, and a submit handler, and SHALL return a `ComponentType` plus a `.Field` static accessor and a `.useFormContext` re-export of react-hook-form's `useFormContext`. #### Scenario: Successful factory call returns a working component - **GIVEN** a valid `RegisterRequestSchema` from `@suaveplan/api-schemas` - **AND** a field-config map covering every field in the schema with `TranslationKey`-typed labels - **WHEN** the consumer calls `createDomainForm({ schema, fields, layout, submit })` - **THEN** the returned component renders without throwing - **AND** the rendered DOM contains an accessible `
` with one labelled control per field - **AND** submitting with valid values fires `submit.onSubmit({ values, ctx })` once. **Verified by:** `factory/create-domain-form/create-domain-form.test.tsx`. #### Scenario: Misconfigured field fails at module evaluation - **GIVEN** a `select` field config without an `options` property - **WHEN** the consumer calls `createDomainForm(...)` at module evaluation - **THEN** the call throws `ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID)` with the offending field name in the error context. **Verified by:** `factory/field-config/field-config.test.ts`. #### Scenario: Three escape hatches preserve direct RHF access - **GIVEN** a form built with `createDomainForm(...)` - **WHEN** the consumer renders `{({ form, fields, submit }) => }` - **THEN** `form` is the same `UseFormReturn` reference that the underlying `useDomainForm()` returns - **AND** the static accessor `` renders the `email` field with its factory chrome - **AND** calling `useDomainForm(config)` standalone produces the same `form` reference shape. **Verified by:** `factory/create-domain-form/escape-hatches.test.tsx`. --- ### Requirement: `FieldConfig` strings are `TranslationKey`-typed Every user-facing string property in a `FieldConfig` SHALL be typed as `TranslationKey` (from `@suaveplan/react-i18n`), never plain `string`. Properties covered: `label`, `placeholder`, `helperText`, `errorMessage` (or its `{ [code]: TranslationKey }` map form), `submit.label`, `submit.cancelLabel`, recipe step `title`, and recipe conditional-field gate labels. #### Scenario: Plain-string label is a type error - **GIVEN** a TypeScript file containing `createDomainForm({ fields: { email: { kind: "email", label: "Email" } } })` - **WHEN** `bunx turbo typecheck --filter=@suaveplan/react-forms` runs - **THEN** the compiler reports a type error on the literal `"Email"` because it is not a registered `TranslationKey`. **Verified by:** `factory/field-config/field-config.types.test.ts` (uses `expectTypeOf` from `@suaveplan/testing/runner`). #### Scenario: Registered translation key compiles - **GIVEN** the consumer's `app.translations.augmentation.d.ts` registers `auth.email.label` - **WHEN** the consumer writes `createDomainForm({ fields: { email: { kind: "email", label: "auth.email.label" } } })` - **THEN** the compile passes - **AND** the rendered label resolves via `useTranslation()` against the active locale. **Verified by:** `factory/field-config/field-config.types.test.ts`. --- ### Requirement: `ValidationEngine` four-phase pipeline The `ValidationEngine` SHALL run validation in four ordered phases on every form mutation: (1) Zod schema, (2) cross-field rules, (3) per-field async validators, (4) server-error mapping. Phases 1 and 2 are synchronous; phase 3 is debounced and `AbortSignal`-cancellable; phase 4 is invoked when `onSubmit` throws a `ReactFormsError(REACT_FORMS.SERVER_VALIDATION)`. #### Scenario: Schema runs first and short-circuits cross-field rules - **GIVEN** a form with a Zod schema requiring `email` to be a valid email - **AND** a cross-field rule requiring `passwordConfirm` to match `password` - **WHEN** the user submits `{ email: "not-an-email", password: "a", passwordConfirm: "b" }` - **THEN** the validation result contains a schema error on `email` - **AND** the cross-field rule does not fire (or its result is discarded) - **AND** the rendered error message is `t(messageKey)` with the active locale. **Verified by:** `core/validation-engine/phases.test.ts`. #### Scenario: Async validator is debounced and cancelled on supersede - **GIVEN** an `asyncEmailUnique` validator with `debounceMs: 300` - **AND** an injectable `clock` from `@suaveplan/testing` allowing time advance - **WHEN** the user types `a` then `ab` then `abc` within 100 ms each - **THEN** only the final `check("abc", signal)` call resolves - **AND** the earlier two calls receive an aborted `AbortSignal` (raised before the network mock fires). **Verified by:** `core/validation-engine/async.test.ts`. #### Scenario: Server error maps to RHF field error - **GIVEN** `submit.onSubmit` throws `ReactFormsError(REACT_FORMS.SERVER_VALIDATION, { field: "email", code: "auth.email.taken" })` - **WHEN** the engine processes the rejection - **THEN** `form.formState.errors.email.message` equals `t("auth.email.taken")` - **AND** the `form.error_total` counter increments with `kind: "server"`. **Verified by:** `core/validation-engine/server-error.test.ts`. --- ### Requirement: `ApiClientContext` and `AuthContext` interfaces The package SHALL export `` + `useApiClient()` and `` + `useAuth()` as generic context wrappers. Both hooks SHALL throw `ReactFormsError(REACT_FORMS.CONTEXT_NOT_PROVIDED)` when called outside their provider. Neither context bakes in a specific transport or auth implementation. #### Scenario: Provider value is consumed by the factory's submit handler - **GIVEN** a `` wrapping a form built with `createDomainForm(...)` - **WHEN** the form submits successfully - **THEN** `submit.onSubmit({ values, ctx })` is called with `ctx.api === apiInstance`. **Verified by:** `core/api-client-context/api-client-context.test.tsx`. #### Scenario: Hook outside provider throws `CONTEXT_NOT_PROVIDED` - **GIVEN** a component calling `useApiClient()` rendered without an `` ancestor - **WHEN** the component mounts - **THEN** rendering throws `ReactFormsError` with `code === "REACT_FORMS.CONTEXT_NOT_PROVIDED"` and `context.name === "ApiClient"`. **Verified by:** `core/api-client-context/api-client-context.test.tsx`. --- ### Requirement: Wizard recipe contract `createWizardForm({ steps, onComplete })` SHALL produce a multi-step form where each step has its own Zod sub-schema, navigating forward validates the current step, navigating backward preserves entered data, and `onComplete` runs after the final step's validation succeeds. #### Scenario: Forward navigation blocks on invalid step - **GIVEN** a wizard with two steps; step 1 requires `email` - **WHEN** the user clicks `Next` without entering an email - **THEN** the wizard remains on step 1 - **AND** an error message is rendered on the `email` field - **AND** `useWizardState().currentStep.id === "step-1"`. **Verified by:** `recipes/wizard/wizard.test.tsx`. #### Scenario: Backward navigation preserves entered data - **GIVEN** a wizard the user has filled through step 2 - **WHEN** the user clicks `Previous` then `Next` - **THEN** step 2's previously entered values are still present in the form state. **Verified by:** `recipes/wizard/wizard.test.tsx`. --- ### Requirement: Repeating-fieldset recipe contract `createRepeatingFieldset({ name, minRows, maxRows, fieldset })` SHALL render a dynamic-array field backed by RHF's `useFieldArray`, and SHALL throw `ReactFormsError(REACT_FORMS.REPEATING_ROW_LIMIT)` when an `add()` call is made at `maxRows` or a `remove()` call is made at `minRows`. #### Scenario: Add at max rows throws - **GIVEN** a fieldset with `maxRows: 3` already containing three rows - **WHEN** the consumer calls `add()` - **THEN** the call throws `ReactFormsError` with `code === "REACT_FORMS.REPEATING_ROW_LIMIT"` and `context.action === "add"`. **Verified by:** `recipes/repeating-fieldset/repeating-fieldset.test.tsx`. --- ### Requirement: Conditional-field recipe contract `createConditionalField({ when, render })` SHALL hide its rendered field when `when(values)` returns `false`, AND SHALL exclude the hidden field's value from the submitted payload, AND SHALL clear any validation errors for the hidden field. #### Scenario: Hidden field is not in submit payload - **GIVEN** a conditional field `bankAccount` shown only when `paymentMethod === "transfer"` - **AND** the user has selected `paymentMethod: "card"` - **WHEN** the form submits - **THEN** the values object passed to `submit.onSubmit` does NOT contain a `bankAccount` key - **AND** any prior validation error on `bankAccount` is cleared. **Verified by:** `recipes/conditional/conditional.test.tsx`. --- ### Requirement: Async-search recipe contract `createAsyncSearchForm({ load, schema, fields })` SHALL render a `` placeholder while `load(id, signal)` resolves, mount the underlying form with the resolved values as `defaultValues` on success, and surface a translated error message via `useTranslation` on failure. The `signal` SHALL be aborted if the consumer unmounts the form during the load. #### Scenario: Load failure renders translated error - **GIVEN** a `load` that rejects with a thrown `Error` - **WHEN** the form renders - **THEN** the user sees a region with the translated content of `t("react-forms.async-search.load-failed")` - **AND** the underlying form does not mount. **Verified by:** `recipes/async-search/async-search.test.tsx`. #### Scenario: Unmount during load aborts the signal - **GIVEN** a `load` that awaits a `signal.aborted` flag - **WHEN** the consumer unmounts the form before `load` resolves - **THEN** the `signal.aborted` flag becomes `true` - **AND** the load is not retained in memory after unmount. **Verified by:** `recipes/async-search/async-search.test.tsx`. --- ### Requirement: Validator return shape Every validator (synchronous and asynchronous) SHALL return a discriminated union: `{ valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: Record }`. Plain-string returns SHALL be a TypeScript error. #### Scenario: Validator failure carries a translatable key - **GIVEN** the `email` validator called with the value `"not-an-email"` - **WHEN** the validator runs - **THEN** the result equals `{ valid: false, messageKey: "react-forms.validation.email" }` - **AND** `messageKey` typechecks as `TranslationKey` (registered in the package's `locales/en-GB.json`). **Verified by:** `validation/validators/email/email.test.ts`. #### Scenario: Composed `or` short-circuits on first success - **GIVEN** `or(emailValidator, urlValidator)` - **WHEN** the composed validator is called with a valid email - **THEN** the result is `{ valid: true }` - **AND** the `urlValidator` was not called. **Verified by:** `validation/compose/or/or.test.ts`. --- ### Requirement: Field-kind validation at factory-define time Every field kind in v1 (`text`, `email`, `password`, `number`, `currency`, `date`, `dateTime`, `select`, `multi-select`, `combobox`, `checkbox`, `radio`, `toggle`, `textarea`, `rich-text`, `file`, `tag-input`, `otp`, `custom`) SHALL have a sibling Zod schema validating its `FieldConfig` shape. Misconfigured fields SHALL throw `ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID)` at module evaluation time, before the component renders. #### Scenario: `kind: "custom"` requires `render` - **GIVEN** a field config `{ kind: "custom", label: "auth.captcha.label" }` with no `render` property - **WHEN** `createDomainForm(...)` is called - **THEN** the call throws `ReactFormsError` with `code === "REACT_FORMS.CUSTOM_FIELD_NO_RENDER"` and `context.field === ""`. **Verified by:** `factory/field-config/custom-field.test.ts`. --- ### Requirement: No hardcoded user-facing strings invariant No source file in `packages/forms/react-forms/src/` (excluding `*.test.ts(x)`, `*.spec.ts`, `*.stories.tsx`) SHALL contain a hardcoded user-facing string. Every user-perceivable string SHALL be a `TranslationKey` resolved at render time via `useTranslation` or ``. #### Scenario: CI gate flags literal `aria-label` - **GIVEN** a source file containing `