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

34
packages/ui/README.md Normal file
View File

@@ -0,0 +1,34 @@
# `ui`
React components, forms, and user-interface primitives.
<!-- begin:auto-docs -->
## Subcategories
2 subcategories, 11 packages.
### `forms`
Form builders, validation, and React form components.
| Package | Description |
|---|---|
| [`@suaveplan/form-builder`](./form-builder/README.md) | Universal dynamic form schema engine. Renderer-agnostic — no DOM, no React, no Node-specific globals. Drives every dynamic form surface in the Suaveplan. |
| [`@suaveplan/react-forms`](./react-forms/README.md) | React forms library for the Suaveplan ecosystem. Wraps react-hook-form + Zod with a primitives + factory + recipes architecture: every user-facing string is ty… |
### `react`
React hooks and components for audio, auth, and UI surfaces.
| Package | Description |
|---|---|
| [`@suaveplan/react-audio`](./react-audio/README.md) | React surface for the Suaveplan audio family. **Phase 1 of 3 — foundation** (provider + hooks + stores + error codes + a11y helpers). Components ship in Phase… |
| [`@suaveplan/react-audio-fingerprint`](./react-audio-fingerprint/README.md) | React 19 surface for client-side audio fingerprinting and identity matching. The package implements the classic Shazam-style constellation algorithm (2D-peak d… |
| [`@suaveplan/react-audio-player`](./react-audio-player/README.md) | Track-style media-player surface for the Suaveplan audio family. The package ships two kinds of exports: |
| [`@suaveplan/react-audio-stream`](./react-audio-stream/README.md) | React 19 surface for live audio streaming sessions: a Provider, six hooks, and four accessible components. Chunk C of the four-chunk `enterprise-react-audio-st… |
| [`@suaveplan/react-auth`](./react-auth/README.md) | React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `u… |
| [`@suaveplan/react-auth-whitelabel`](./react-auth-whitelabel/README.md) | White-label authentication React components with tenant branding wired through the `@suaveplan/react-ui` runtime theme-token mutator. |
| [`@suaveplan/react-control-surface`](./react-control-surface/README.md) | React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`. |
| [`@suaveplan/react-midi`](./react-midi/README.md) | React 19 adapter for the Web MIDI API. Provides a strict-mode-safe `<MidiProvider>` that requests `MIDIAccess` exactly once, seven typed hooks for common consu… |
| [`@suaveplan/react-ui`](./react-ui/README.md) | React UI component library — opinionated providers, theme/pack plugin contracts, bundled translations, telemetry, shadcn-compatible registry generator. |
<!-- end:auto-docs -->

3
packages/ui/_intro.md Normal file
View File

@@ -0,0 +1,3 @@
# `ui`
React components, forms, and user-interface primitives.

View File

@@ -0,0 +1,14 @@
# `forms`
Form builders, validation, and React form components.
<!-- begin:auto-docs -->
## Packages in `forms`
2 packages.
| Package | Description |
|---|---|
| [`@suaveplan/form-builder`](./form-builder/README.md) | Universal dynamic form schema engine. Renderer-agnostic — no DOM, no React, no Node-specific globals. Drives every dynamic form surface in the Suaveplan. |
| [`@suaveplan/react-forms`](./react-forms/README.md) | React forms library for the Suaveplan ecosystem. Wraps react-hook-form + Zod with a primitives + factory + recipes architecture: every user-facing string is ty… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Form builders, validation, and React form components.

View File

@@ -0,0 +1,128 @@
# @suaveplan/form-builder - Universal dynamic form schema engine with conditional logic, async validation, dependency graph, undo/redo, Zod-schema generation, OTel instrumentation, and pluggable field-type registry.
Universal dynamic form schema engine. Renderer-agnostic — no DOM, no
React, no Node-specific globals. Drives every dynamic form surface in the
Suaveplan.
<!-- begin:auto-docs -->
## Documentation tree
- [form-builder](./src/builder/form-builder/form-builder.md) — `form-builder.ts` exposes the top-level `FormBuilder` class — the public entry point for `@suaveplan/form-builder`. The…
- [form-builder](./src/builder/form-builder/form-builder.md) — `form-builder.ts` exposes the top-level `FormBuilder` class — the public entry point for `@suaveplan/form-builder`. The…
- [conditional](./src/conditional/conditional/conditional.md) — `conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`. It supports nine leaf operators (`eq`…
- [conditional](./src/conditional/conditional/conditional.md) — `conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`. It supports nine leaf operators (`eq`…
- [dependency-graph](./src/conditional/dependency-graph/dependency-graph.md) — `dependency-graph.ts` builds a directed acyclic graph of field-id dependencies derived from `FieldDefinition.conditiona…
- [dependency-graph](./src/conditional/dependency-graph/dependency-graph.md) — `dependency-graph.ts` builds a directed acyclic graph of field-id dependencies derived from `FieldDefinition.conditiona…
- [form-error](./src/errors/form-error/form-error.md) — `form-error.ts` owns the stable error-code registry and the typed `FormError` class for the entire `@suaveplan/form-bui…
- [form-error](./src/errors/form-error/form-error.md) — `form-error.ts` owns the stable error-code registry and the typed `FormError` class for the entire `@suaveplan/form-bui…
- [field-registry](./src/fields/field-registry/field-registry.md) — `field-registry.ts` is the extensible registry of field-type descriptors used by `FormBuilder`. It ships seventeen buil…
- [field-registry](./src/fields/field-registry/field-registry.md) — `field-registry.ts` is the extensible registry of field-type descriptors used by `FormBuilder`. It ships seventeen buil…
- [instruments](./src/otel/instruments/instruments.md) — `instruments.ts` lazily resolves the OpenTelemetry meter and the five instruments emitted across the package: three cou…
- [instruments](./src/otel/instruments/instruments.md) — `instruments.ts` lazily resolves the OpenTelemetry meter and the five instruments emitted across the package: three cou…
- [schema](./src/schema/schema/schema.md) — `schema.ts` is the Zod boundary for the public `FormSchema`, `FieldDefinition`, and `FormSection` types. It exposes par…
- [schema](./src/schema/schema/schema.md) — `schema.ts` is the Zod boundary for the public `FormSchema`, `FieldDefinition`, and `FormSection` types. It exposes par…
- [serialise](./src/serialise/serialise/serialise.md) — `serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is the bridge between the in-memory schema engin…
- [serialise](./src/serialise/serialise/serialise.md) — `serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is the bridge between the in-memory schema engin…
- [form-state](./src/state/form-state/form-state.md) — `form-state.ts` is the mutable holder for the runtime state of a form instance: the current value map, the most recent…
- [form-state](./src/state/form-state/form-state.md) — `form-state.ts` is the mutable holder for the runtime state of a form instance: the current value map, the most recent…
- [undo-redo](./src/state/undo-redo/undo-redo.md) — `undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is the substrate for `FormBuilder.enableHistory`…
- [undo-redo](./src/state/undo-redo/undo-redo.md) — `undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is the substrate for `FormBuilder.enableHistory`…
- [submission](./src/submission/submission/submission.md) — `submission.ts` is the orchestration layer that runs the canonical `prepare → validate → transform → submit` pipeline f…
- [submission](./src/submission/submission/submission.md) — `submission.ts` is the orchestration layer that runs the canonical `prepare → validate → transform → submit` pipeline f…
- [validation](./src/validation/validation/validation.md) — `validation.ts` is the iteration layer that runs every rule attached to a field or form and aggregates the resulting `V…
- [validation](./src/validation/validation/validation.md) — `validation.ts` is the iteration layer that runs every rule attached to a field or form and aggregates the resulting `V…
- [validators](./src/validation/validators/validators.md) — `validators.ts` provides the eleven built-in rule runners used by the validation engine. Each runner takes the candidat…
- [validators](./src/validation/validators/validators.md) — `validators.ts` provides the eleven built-in rule runners used by the validation engine. Each runner takes the candidat…
- [zod-schema](./src/zod-schema/zod-schema/zod-schema.md) — `zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a `FormSchema` into a `z.ZodObject` whose `safeP…
- [zod-schema](./src/zod-schema/zod-schema/zod-schema.md) — `zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a `FormSchema` into a `z.ZodObject` whose `safeP…
<!-- end:auto-docs -->
## Features
- 17 built-in field types (`text`, `number`, `email`, `url`, `tel`,
`password`, `select`, `multiselect`, `checkbox`, `radio`, `textarea`,
`date`, `file`, `range`, `hidden`, `group`, `array`) via an extensible
`FieldRegistry`.
- 11 built-in validation rules (`required`, `minLength`, `maxLength`, `min`,
`max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, `custom`).
Async-friendly via `Promise<boolean>` predicates.
- Conditional logic with 9 leaf operators (`eq`, `neq`, `in`, `notIn`,
`gt`, `lt`, `gte`, `lte`, `contains`) and boolean composition
(`and` / `or` / `not`).
- Field-level dependency graph with topological evaluation and cycle
detection (`FORM_BUILDER.CYCLIC_DEPENDENCY`).
- Bounded undo / redo on schema mutations.
- Typed event bus via `EventBus<FormEventMap>` from `@suaveplan/events`.
- Versioned JSON serialise / deserialise round-trip.
- `toZodSchema(form)` generator for `react-hook-form` consumers.
- Submission pipeline: `prepare → validate → transform → submit`.
- Six stable `FORM_BUILDER.*` error codes via `defineCodes`.
- Three OTel counters and two histograms emitted on every code path.
- Injectable `Clock` + `IdGenerator` for deterministic tests.
## Install
```bash
bun add @suaveplan/form-builder
```
## Usage
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const builder = new FormBuilder().enableHistory();
builder.addField({
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
});
builder.addField({
id: "subscribe",
name: "subscribe",
label: "Subscribe to newsletter",
type: "checkbox",
});
builder.addField({
id: "frequency",
name: "frequency",
label: "Frequency",
type: "select",
options: [
{ label: "Weekly", value: "weekly" },
{ label: "Monthly", value: "monthly" },
],
conditional: { field: "subscribe", operator: "eq", value: true },
});
builder.bus.on("values.changed", (values) =>
console.log("changed", values)
);
const schema = builder.createSchema("signup", "Sign-up form");
```
## Submission
```ts
import { submit } from "@suaveplan/form-builder";
await submit({
fields: schema.fields,
values: { name: "alice", subscribe: true, frequency: "weekly" },
submitter: async (values, signal) =>
fetch("/api/forms", {
method: "POST",
signal,
body: JSON.stringify(values),
}),
});
```
## License
MIT.

View File

@@ -0,0 +1,144 @@
{
"name": "@suaveplan/form-builder",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal dynamic form schema engine with conditional logic, async validation, dependency graph, undo/redo, Zod-schema generation, OTel instrumentation, and pluggable field-type registry.",
"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"
}
},
"./schema/schema/schema": {
"import": {
"types": "./dist/schema/schema/schema.d.ts",
"default": "./dist/schema/schema/schema.js"
}
},
"./fields/field-registry/field-registry": {
"import": {
"types": "./dist/fields/field-registry/field-registry.d.ts",
"default": "./dist/fields/field-registry/field-registry.js"
}
},
"./validation/validation/validation": {
"import": {
"types": "./dist/validation/validation/validation.d.ts",
"default": "./dist/validation/validation/validation.js"
}
},
"./validation/validators/validators": {
"import": {
"types": "./dist/validation/validators/validators.d.ts",
"default": "./dist/validation/validators/validators.js"
}
},
"./conditional/conditional/conditional": {
"import": {
"types": "./dist/conditional/conditional/conditional.d.ts",
"default": "./dist/conditional/conditional/conditional.js"
}
},
"./conditional/dependency-graph/dependency-graph": {
"import": {
"types": "./dist/conditional/dependency-graph/dependency-graph.d.ts",
"default": "./dist/conditional/dependency-graph/dependency-graph.js"
}
},
"./state/form-state/form-state": {
"import": {
"types": "./dist/state/form-state/form-state.d.ts",
"default": "./dist/state/form-state/form-state.js"
}
},
"./state/undo-redo/undo-redo": {
"import": {
"types": "./dist/state/undo-redo/undo-redo.d.ts",
"default": "./dist/state/undo-redo/undo-redo.js"
}
},
"./builder/form-builder/form-builder": {
"import": {
"types": "./dist/builder/form-builder/form-builder.d.ts",
"default": "./dist/builder/form-builder/form-builder.js"
}
},
"./submission/submission/submission": {
"import": {
"types": "./dist/submission/submission/submission.d.ts",
"default": "./dist/submission/submission/submission.js"
}
},
"./serialise/serialise/serialise": {
"import": {
"types": "./dist/serialise/serialise/serialise.d.ts",
"default": "./dist/serialise/serialise/serialise.js"
}
},
"./zod-schema/zod-schema/zod-schema": {
"import": {
"types": "./dist/zod-schema/zod-schema/zod-schema.d.ts",
"default": "./dist/zod-schema/zod-schema/zod-schema.js"
}
},
"./errors/form-error/form-error": {
"import": {
"types": "./dist/errors/form-error/form-error.d.ts",
"default": "./dist/errors/form-error/form-error.js"
}
},
"./otel/instruments/instruments": {
"import": {
"types": "./dist/otel/instruments/instruments.d.ts",
"default": "./dist/otel/instruments/instruments.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check 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/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "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,80 @@
# form-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-builder.ts` exposes the top-level `FormBuilder` class — the public
entry point for `@suaveplan/form-builder`. The class aggregates every
subsystem (schema, state, validation, conditional logic, dependency
graph, undo/redo, event bus, OTel) behind a renderer-agnostic, universal-
tier façade. It is the only class downstream packages need to instantiate
to model a dynamic form end-to-end.
The builder emits typed events through an `EventBus<FormEventMap>` from
`@suaveplan/events` so consumers (React adapters, debug overlays, audit
logs) can subscribe to lifecycle changes without polling. Optional
injectable `Clock` and `IdGenerator` make the package deterministic for
snapshot tests and golden-master fixtures.
## Features
- Full upstream public surface preserved (`createSchema`, `addField`,
`removeField`, `updateField`, `addSection`, `getField`, `getSchema`,
`validateField`, `validateForm`, `getVisibleFields`, `setValues`,
`resetForm`, `getState`, `toJSON`, `getSectionById`).
- New: `removeSection`, `enableHistory`, `undo`, `redo`, `newId`.
- `EventBus<FormEventMap>` emission for every mutation + lifecycle event.
- Dependency-graph driven conditional evaluation.
- Optional bounded undo/redo with linear history semantics.
- Injectable `Clock` + `IdGenerator` for determinism.
## Usage: Basic
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const builder = new FormBuilder();
builder.addField({ id: "email", name: "email", label: "Email", type: "email", validations: [{ type: "required" }] });
const schema = builder.createSchema("contact", "Contact form");
```
## Usage: Advanced
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const clock = () => 1700_000_000_000;
const builder = new FormBuilder({ clock }).enableHistory(50);
builder.addField({ id: "a", name: "a", label: "A", type: "text" });
builder.bus.on("field.added", (field) => console.log("added", field.id));
builder.undo(); // removes the field, fires field.removed
builder.redo(); // re-adds it
```
## API Reference
- `new FormBuilder(options?)` — `{ clock?, idGenerator?, bus? }`.
- `bus: EventBus<FormEventMap>` — public event bus.
- `createSchema(id, name, options?): FormSchema`.
- `addField(field)`, `removeField(id)`, `updateField(id, patch)`.
- `addSection(section)`, `removeSection(id)`, `getSectionById(id)`.
- `getField(id)`, `getSchema()`.
- `validateField(id, value, signal?)`, `validateForm(values, signal?)`.
- `getVisibleFields(values)`.
- `setValues(values, signal?)`, `resetForm()`, `getState()`.
- `toJSON()`, `newId()`.
- `enableHistory(capacity?)`, `undo()`, `redo()`.
## Implementation Notes
`getVisibleFields` builds a fresh `DependencyGraph` on each call rather
than caching, because field additions/removals invalidate the cache and
the graph build is O(V + E) — measurably under 1 ms for forms of any
realistic size. Undo ops are paired symmetrically: `addField`'s reverse-op
removes the field, whose reverse-op re-adds it. Pushing onto history while
history is disabled is a no-op so the class is safe to use with or without
history enabled.

View File

@@ -0,0 +1,300 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FieldDefinition, FormEventMap } from "../../types/form.types.js";
import { FormBuilder } from "./form-builder.js";
const text = (
id: string,
extra: Partial<FieldDefinition> = {}
): FieldDefinition => ({
id,
name: id,
label: id,
type: "text",
...extra,
});
describe("FormBuilder — schema mutation", () => {
it("raises DUPLICATE_ID on duplicate addField", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a"));
try {
b.addField(text("a"));
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.DUPLICATE_ID
);
}
});
it("raises FIELD_NOT_FOUND on missing removeField", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(() => b.removeField("nope")).toThrow(FormError);
});
it("raises FIELD_NOT_FOUND on missing updateField", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(() => b.updateField("nope", { label: "x" })).toThrow(FormError);
});
it("raises FIELD_NOT_FOUND on missing getField", { timeout: 5000 }, () => {
const b = new FormBuilder();
expect(() => b.getField("nope")).toThrow(FormError);
});
it("raises SECTION_NOT_FOUND on missing removeSection", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(() => b.removeSection("nope")).toThrow(FormError);
});
it("raises SECTION_NOT_FOUND on missing getSectionById", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(() => b.getSectionById("nope")).toThrow(FormError);
});
it("raises DUPLICATE_ID on duplicate addSection", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addSection({ id: "s", title: "S" });
expect(() => b.addSection({ id: "s", title: "S" })).toThrow(FormError);
});
it("addSection then removeSection works", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addSection({ id: "s", title: "S" });
expect(b.getSectionById("s").title).toBe("S");
b.removeSection("s");
expect(() => b.getSectionById("s")).toThrow(FormError);
});
it("updateField returns the updated field", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a"));
const updated = b.updateField("a", { label: "Renamed" });
expect(updated.label).toBe("Renamed");
expect(b.getField("a").label).toBe("Renamed");
});
});
describe("FormBuilder — createSchema", () => {
it("requires id and name", { timeout: 5000 }, () => {
const b = new FormBuilder();
expect(() => b.createSchema("", "x")).toThrow(FormError);
expect(() => b.createSchema("x", "")).toThrow(FormError);
});
it("uses injected clock for timestamps", { timeout: 5000 }, () => {
const b = new FormBuilder({ clock: () => 0 });
const s = b.createSchema("id", "name");
expect(s.createdAt).toBe("1970-01-01T00:00:00.000Z");
expect(s.updatedAt).toBe("1970-01-01T00:00:00.000Z");
});
it("includes optional description and submitLabel", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
const s = b.createSchema("id", "name", {
description: "desc",
submitLabel: "Send",
});
expect(s.description).toBe("desc");
expect(s.submitLabel).toBe("Send");
});
it("includes sections when present", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addSection({ id: "s", title: "S" });
const s = b.createSchema("id", "name");
expect(s.sections?.[0]?.id).toBe("s");
});
});
describe("FormBuilder — visibility + state", () => {
it("hides field when conditional is false", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a"));
b.addField(
text("b", {
conditional: { field: "a", operator: "eq", value: "yes" },
})
);
const visible = b.getVisibleFields({ a: "no" });
expect(visible.map((f) => f.id)).toEqual(["a"]);
});
it("getVisibleFields skips orphan ids (equalsField to missing field)", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
b.addField(
text("confirm", {
validations: [{ type: "equalsField", value: "missing" }],
})
);
// The dep-graph contains "missing" as a node but the builder has no
// FieldDefinition for it; getVisibleFields must skip the orphan.
const visible = b.getVisibleFields({});
expect(visible.map((f) => f.id)).toEqual(["confirm"]);
});
it("includes fields with no defaultValue when resetForm runs", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
b.addField(text("a"));
const state = b.resetForm();
expect(state.values.a).toBeUndefined();
});
it("respects field.hidden flag", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a", { hidden: true }));
expect(b.getVisibleFields({})).toHaveLength(0);
});
it("emits values.changed on setValues", { timeout: 5000 }, async () => {
const b = new FormBuilder();
b.addField(text("a"));
let captured: Record<string, unknown> | undefined;
b.bus.on("values.changed", (values) => {
captured = values;
});
await b.setValues({ a: 1 });
expect(captured?.a).toBe(1);
});
it("resetForm applies defaultValue and emits form.reset", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
b.addField(text("a", { defaultValue: "x" }));
let resetFired = false;
b.bus.on("form.reset", () => {
resetFired = true;
});
const state = b.resetForm();
expect(state.values.a).toBe("x");
expect(resetFired).toBe(true);
});
it("validateForm emits form.validated", { timeout: 5000 }, async () => {
const b = new FormBuilder();
b.addField(text("a", { validations: [{ type: "required" }] }));
let captured: readonly unknown[] | undefined;
b.bus.on("form.validated", (errors) => {
captured = errors;
});
await b.validateForm({ a: "" });
expect(captured?.length).toBe(1);
});
it("validateField runs only that field's rules", {
timeout: 5000,
}, async () => {
const b = new FormBuilder();
b.addField(text("a", { validations: [{ type: "required" }] }));
const errors = await b.validateField("a", "");
expect(errors).toHaveLength(1);
});
it("getState returns the current snapshot", { timeout: 5000 }, () => {
const b = new FormBuilder();
expect(b.getState().dirty).toBe(false);
});
it("toJSON serialises fields + sections", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a"));
b.addSection({ id: "s", title: "S" });
const json = JSON.parse(b.toJSON()) as {
fields: unknown[];
sections: unknown[];
};
expect(json.fields).toHaveLength(1);
expect(json.sections).toHaveLength(1);
});
it("newId emits a non-empty string", { timeout: 5000 }, () => {
const b = new FormBuilder({ idGenerator: () => "abc" });
expect(b.newId()).toBe("abc");
});
it("default idGenerator emits a non-empty string", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(b.newId().length).toBeGreaterThan(0);
});
it("default clock advances on each call", { timeout: 5000 }, () => {
const b = new FormBuilder();
const s = b.createSchema("a", "b");
expect(typeof s.createdAt).toBe("string");
});
it("uses a caller-supplied EventBus", { timeout: 5000 }, () => {
const bus = new EventBus<FormEventMap>();
const b = new FormBuilder({ bus });
expect(b.bus).toBe(bus);
});
});
describe("FormBuilder — undo/redo", () => {
it("undo reverses addField", { timeout: 5000 }, () => {
const b = new FormBuilder().enableHistory();
b.addField(text("a"));
expect(b.getSchema().fields).toHaveLength(1);
expect(b.undo()).toBe(true);
expect(b.getSchema().fields).toHaveLength(0);
expect(b.redo()).toBe(true);
expect(b.getSchema().fields).toHaveLength(1);
});
it("undo without history returns false", { timeout: 5000 }, () => {
const b = new FormBuilder();
expect(b.undo()).toBe(false);
expect(b.redo()).toBe(false);
});
it("enableHistory accepts a capacity", { timeout: 5000 }, () => {
const b = new FormBuilder().enableHistory(5);
b.addField(text("a"));
expect(b.undo()).toBe(true);
});
it("undo and redo of updateField round-trip", { timeout: 5000 }, () => {
const b = new FormBuilder().enableHistory();
b.addField(text("a", { label: "first" }));
b.updateField("a", { label: "second" });
expect(b.getField("a").label).toBe("second");
b.undo();
expect(b.getField("a").label).toBe("first");
b.redo();
expect(b.getField("a").label).toBe("second");
});
it("undo and redo of removeField round-trip", { timeout: 5000 }, () => {
const b = new FormBuilder().enableHistory();
b.addField(text("a"));
b.removeField("a");
expect(b.getSchema().fields).toHaveLength(0);
b.undo();
expect(b.getSchema().fields).toHaveLength(1);
b.redo();
expect(b.getSchema().fields).toHaveLength(0);
});
});

View File

@@ -0,0 +1,329 @@
import { EventBus } from "@suaveplan/events";
import { evaluateConditional } from "../../conditional/conditional/conditional.js";
import { DependencyGraph } from "../../conditional/dependency-graph/dependency-graph.js";
import {
configInvalid,
duplicateId,
fieldNotFound,
sectionNotFound,
} from "../../errors/form-error/form-error.js";
import { fieldMutatedCounter } from "../../otel/instruments/instruments.js";
import { FormStateMachine } from "../../state/form-state/form-state.js";
import type { UndoOp } from "../../state/undo-redo/undo-redo.js";
import { UndoRedoStack } from "../../state/undo-redo/undo-redo.js";
import type {
FieldDefinition,
FormEventMap,
FormSchema,
FormSection,
FormState,
FormValues,
ValidationError,
} from "../../types/form.types.js";
import {
runFieldValidation,
runFormValidation,
} from "../../validation/validation/validation.js";
/** Clock signature compatible with `@suaveplan/types`. */
export type Clock = () => number;
/** Id-generator signature compatible with `@suaveplan/types`. */
export type IdGenerator = () => string;
const defaultClock: Clock = () => Date.now();
const defaultIdGenerator: IdGenerator = () =>
Math.random().toString(36).slice(2);
export interface FormBuilderOptions {
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
readonly bus?: EventBus<FormEventMap>;
}
/**
* Top-level façade aggregating schema, state, validation, undo/redo,
* dependency graph, and event emission. Universal — zero DOM,
* zero React, zero Node-specific globals.
*/
export class FormBuilder {
public readonly bus: EventBus<FormEventMap>;
private readonly fields: FieldDefinition[] = [];
private readonly sections: FormSection[] = [];
private readonly clock: Clock;
private readonly idGenerator: IdGenerator;
private readonly state = new FormStateMachine();
private history?: UndoRedoStack;
public constructor(options: FormBuilderOptions = {}) {
this.clock = options.clock ?? defaultClock;
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
this.bus = options.bus ?? new EventBus<FormEventMap>();
}
/** Produce a `FormSchema` snapshot from the current fields + sections. */
public createSchema(
id: string,
name: string,
options: {
readonly description?: string;
readonly submitLabel?: string;
} = {}
): FormSchema {
if (!id || !name) {
throw configInvalid("id and name are required");
}
const ts = new Date(this.clock()).toISOString();
const base: FormSchema = {
id,
name,
version: 1,
fields: [...this.fields],
createdAt: ts,
updatedAt: ts,
...(options.description !== undefined
? { description: options.description }
: {}),
...(options.submitLabel !== undefined
? { submitLabel: options.submitLabel }
: {}),
...(this.sections.length > 0
? { sections: [...this.sections] }
: {}),
};
return base;
}
/** Register a new field. Emits `field.added`. */
public addField(field: FieldDefinition): this {
this.addFieldInternal(field);
this.pushUndoPair(
() => this.removeFieldInternal(field.id),
() => this.addFieldInternal(field)
);
return this;
}
private addFieldInternal(field: FieldDefinition): void {
if (this.fields.some((f) => f.id === field.id)) {
throw duplicateId("field", field.id);
}
this.fields.push(field);
this.bus.emit("field.added", field);
fieldMutatedCounter().add(1, { op: "add" });
}
/** Remove a field by id. Emits `field.removed`. */
public removeField(fieldId: string): void {
const field = this.fields.find((f) => f.id === fieldId);
if (field === undefined) throw fieldNotFound(fieldId);
this.removeFieldInternal(fieldId);
this.pushUndoPair(
() => this.addFieldInternal(field),
() => this.removeFieldInternal(fieldId)
);
}
private removeFieldInternal(fieldId: string): void {
const idx = this.fields.findIndex((f) => f.id === fieldId);
this.fields.splice(idx, 1);
this.bus.emit("field.removed", fieldId);
fieldMutatedCounter().add(1, { op: "remove" });
}
/** Update an existing field's fragment. Emits `field.updated`. */
public updateField(
fieldId: string,
updates: Partial<Omit<FieldDefinition, "id">>
): FieldDefinition {
const idx = this.fields.findIndex((f) => f.id === fieldId);
if (idx === -1) throw fieldNotFound(fieldId);
const previous = this.fields[idx] as FieldDefinition;
const next = { ...previous, ...updates } as FieldDefinition;
this.applyUpdate(idx, next);
this.pushUndoPair(
() => this.applyUpdate(idx, previous),
() => this.applyUpdate(idx, next)
);
return next;
}
private applyUpdate(idx: number, field: FieldDefinition): void {
this.fields[idx] = field;
this.bus.emit("field.updated", field);
fieldMutatedCounter().add(1, { op: "update" });
}
/** Register a section. Emits `section.added`. */
public addSection(section: FormSection): this {
if (this.sections.some((s) => s.id === section.id)) {
throw duplicateId("section", section.id);
}
this.sections.push(section);
this.bus.emit("section.added", section);
return this;
}
/** Remove a section by id. Emits `section.removed`. */
public removeSection(sectionId: string): void {
const idx = this.sections.findIndex((s) => s.id === sectionId);
if (idx === -1) throw sectionNotFound(sectionId);
this.sections.splice(idx, 1);
this.bus.emit("section.removed", sectionId);
}
/** Lookup a field by id. */
public getField(fieldId: string): FieldDefinition {
const field = this.fields.find((f) => f.id === fieldId);
if (field === undefined) throw fieldNotFound(fieldId);
return field;
}
/** Lookup a section by id. */
public getSectionById(sectionId: string): FormSection {
const section = this.sections.find((s) => s.id === sectionId);
if (section === undefined) throw sectionNotFound(sectionId);
return section;
}
/** Snapshot the current fields + sections. */
public getSchema(): {
readonly fields: readonly FieldDefinition[];
readonly sections: readonly FormSection[];
} {
return {
fields: [...this.fields],
sections: [...this.sections],
};
}
/** Validate a single field. */
public async validateField(
fieldId: string,
value: unknown,
signal?: AbortSignal
): Promise<readonly ValidationError[]> {
const field = this.getField(fieldId);
return runFieldValidation(
field,
value,
this.state.getState().values,
signal,
this.clock
);
}
/** Validate the entire form. Emits `form.validated`. */
public async validateForm(
values: FormValues,
signal?: AbortSignal
): Promise<readonly ValidationError[]> {
const errors = await runFormValidation(
this.fields,
values,
signal,
this.clock
);
this.state.setErrors(errors);
this.bus.emit("form.validated", errors);
return errors;
}
/** Return only the fields whose conditional rule evaluates true. */
public getVisibleFields(values: FormValues): readonly FieldDefinition[] {
const graph = new DependencyGraph(this.fields);
const order = graph.topoSort();
const indexedById = new Map<string, FieldDefinition>();
for (const field of this.fields) indexedById.set(field.id, field);
const visible: FieldDefinition[] = [];
for (const id of order) {
const field = indexedById.get(id);
if (field === undefined) continue;
if (field.hidden === true) continue;
if (
field.conditional !== undefined &&
!evaluateConditional(field.conditional, values)
) {
continue;
}
visible.push(field);
}
return visible;
}
/** Replace the current values with a new map. */
public async setValues(
values: FormValues,
signal?: AbortSignal
): Promise<FormState> {
this.state.setValues(values);
const errors = await this.validateForm(values, signal);
this.state.setErrors(errors);
const snapshot = this.state.getState();
this.bus.emit("values.changed", snapshot.values);
return snapshot;
}
/** Reset the form to its defaults (taken from each field's `defaultValue`). */
public resetForm(): FormState {
const defaults: FormValues = {};
for (const field of this.fields) {
if (field.defaultValue !== undefined) {
defaults[field.id] = field.defaultValue;
}
}
this.state.reset(defaults);
this.bus.emit("form.reset", null);
return this.state.getState();
}
/** Snapshot the current runtime state. */
public getState(): FormState {
return this.state.getState();
}
/** JSON serialisation of the current fields + sections. */
public toJSON(): string {
return JSON.stringify({
fields: this.fields,
sections: this.sections,
});
}
/** Enable bounded undo/redo recording for schema mutations. */
public enableHistory(capacity?: number): this {
this.history =
capacity === undefined
? new UndoRedoStack()
: new UndoRedoStack(capacity);
return this;
}
/** Apply the most-recent undo op (no-op if history disabled). */
public undo(): boolean {
if (this.history === undefined) return false;
return this.history.undo() !== undefined;
}
/** Apply the most-recent redo op (no-op if history disabled). */
public redo(): boolean {
if (this.history === undefined) return false;
return this.history.redo() !== undefined;
}
/** Generate an id via the injected `IdGenerator`. */
public newId(): string {
return this.idGenerator();
}
private pushUndoPair(undoApply: () => void, redoApply: () => void): void {
if (this.history === undefined) return;
const undoOp: UndoOp = {
apply: undoApply,
inverse: undefined as unknown as UndoOp,
};
const redoOp: UndoOp = { apply: redoApply, inverse: undoOp };
(undoOp as { inverse: UndoOp }).inverse = redoOp;
this.history.push(undoOp);
}
}

View File

@@ -0,0 +1,6 @@
export {
type Clock,
FormBuilder,
type FormBuilderOptions,
type IdGenerator,
} from "./form-builder.js";

View File

@@ -0,0 +1,70 @@
# conditional
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`.
It supports nine leaf operators (`eq`, `neq`, `in`, `notIn`, `gt`, `lt`,
`gte`, `lte`, `contains`) and three boolean combinators (`and`, `or`,
`not`). It also normalises the legacy upstream shape
(`{ field, operator, value }`) into the tagged-leaf representation so
schemas authored against the original `form-builder` continue to work
unchanged.
The same file exposes `fieldsReferencedBy`, a recursive walker that returns
every field id referenced by a conditional rule. The dependency-graph
builder consumes that walker to derive evaluation order for chained
conditionals.
## Features
- Nine leaf operators.
- Boolean combinators with short-circuit semantics.
- Legacy upstream shape accepted (no migration step).
- Pure: no side effects, no IO, deterministic per `(rule, values)` pair.
## Usage: Basic
```ts
import { evaluateConditional } from "@suaveplan/form-builder";
evaluateConditional(
{ field: "country", operator: "eq", value: "US" },
{ country: "US" }
); // true
```
## Usage: Advanced
```ts
import { evaluateConditional } from "@suaveplan/form-builder";
const rule = {
kind: "and" as const,
rules: [
{ kind: "leaf" as const, field: "age", operator: "gte" as const, value: 18 },
{ kind: "or" as const, rules: [
{ kind: "leaf" as const, field: "country", operator: "in" as const, value: ["US", "CA"] },
{ kind: "not" as const, rule: { kind: "leaf" as const, field: "blocked", operator: "eq" as const, value: true } },
]},
],
};
evaluateConditional(rule, { age: 21, country: "GB", blocked: false }); // true
```
## API Reference
- `evaluateConditional(rule, values): boolean`.
- `fieldsReferencedBy(rule): readonly string[]` — unique field ids.
## Implementation Notes
`and` returns `false` on the first false child; `or` returns `true` on the
first true child. Numeric operators (`gt`, `lt`, `gte`, `lte`) return
`false` when either side is not a `number`. `contains` works for both
strings (substring) and arrays (membership). The function is recursive but
form schemas in practice have shallow conditional trees, so the recursion
depth is bounded.

View File

@@ -0,0 +1,289 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { evaluateConditional, fieldsReferencedBy } from "./conditional.js";
describe("evaluateConditional — leaf operators", () => {
it("leaf eq evaluates equal", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "eq", value: 1 },
{ x: 1 }
)
).toBe(true);
});
it("leaf neq evaluates not-equal", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "neq", value: 2 },
{ x: 1 }
)
).toBe(true);
});
it("leaf in / notIn behave inversely", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "in", value: [1, 2] },
{ x: 1 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "notIn", value: [1, 2] },
{ x: 3 }
)
).toBe(true);
expect(
evaluateConditional(
{
kind: "leaf",
field: "x",
operator: "in",
value: "nope" as unknown as readonly unknown[],
},
{ x: 1 }
)
).toBe(false);
expect(
evaluateConditional(
{
kind: "leaf",
field: "x",
operator: "notIn",
value: "nope" as unknown as readonly unknown[],
},
{ x: 1 }
)
).toBe(false);
});
it("leaf gt / lt / gte / lte work for numbers and reject non-numbers", {
timeout: 5000,
}, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "gt", value: 1 },
{ x: 2 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "gt", value: "1" },
{ x: 2 }
)
).toBe(false);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "lt", value: 2 },
{ x: 1 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "lt", value: "x" },
{ x: 1 }
)
).toBe(false);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "gte", value: 1 },
{ x: 1 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "gte", value: "x" },
{ x: 1 }
)
).toBe(false);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "lte", value: 1 },
{ x: 1 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "lte", value: "x" },
{ x: 1 }
)
).toBe(false);
});
it("contains works for strings and arrays", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "contains", value: "lo" },
{ x: "hello" }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "contains", value: 2 },
{ x: [1, 2, 3] }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "contains", value: 9 },
{ x: 5 }
)
).toBe(false);
});
});
describe("evaluateConditional — boolean composition", () => {
it("and short-circuits on first false", { timeout: 5000 }, () => {
let called = 0;
const rule = {
kind: "and" as const,
rules: [
{
kind: "leaf" as const,
field: "x",
operator: "eq" as const,
value: 1,
},
{
kind: "leaf" as const,
field: "spy",
operator: "eq" as const,
get value(): number {
called++;
return 2;
},
},
],
};
const result = evaluateConditional(rule, { x: 9, spy: 2 });
expect(result).toBe(false);
expect(called).toBe(0);
});
it("and returns true when all rules pass", { timeout: 5000 }, () => {
const rule = {
kind: "and" as const,
rules: [
{
kind: "leaf" as const,
field: "x",
operator: "eq" as const,
value: 1,
},
{
kind: "leaf" as const,
field: "y",
operator: "eq" as const,
value: 2,
},
],
};
expect(evaluateConditional(rule, { x: 1, y: 2 })).toBe(true);
});
it("or evaluates true when any rule passes", { timeout: 5000 }, () => {
const rule = {
kind: "or" as const,
rules: [
{
kind: "leaf" as const,
field: "x",
operator: "eq" as const,
value: 1,
},
{
kind: "leaf" as const,
field: "y",
operator: "eq" as const,
value: 2,
},
],
};
expect(evaluateConditional(rule, { x: 9, y: 2 })).toBe(true);
});
it("or evaluates false when no rule passes", { timeout: 5000 }, () => {
const rule = {
kind: "or" as const,
rules: [
{
kind: "leaf" as const,
field: "x",
operator: "eq" as const,
value: 1,
},
],
};
expect(evaluateConditional(rule, { x: 9 })).toBe(false);
});
it("not inverts the inner rule", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{
kind: "not",
rule: {
kind: "leaf",
field: "x",
operator: "eq",
value: 1,
},
},
{ x: 9 }
)
).toBe(true);
});
});
describe("evaluateConditional — legacy shape", () => {
it("accepts legacy leaf shape", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ field: "x", operator: "eq", value: 1 },
{ x: 1 }
)
).toBe(true);
});
});
describe("fieldsReferencedBy", () => {
it("collects leaf field ids", { timeout: 5000 }, () => {
expect(
fieldsReferencedBy({
kind: "leaf",
field: "x",
operator: "eq",
value: 1,
})
).toEqual(["x"]);
});
it("collects deduped ids from boolean trees", { timeout: 5000 }, () => {
const ids = fieldsReferencedBy({
kind: "and",
rules: [
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
{
kind: "or",
rules: [
{ kind: "leaf", field: "b", operator: "eq", value: 2 },
{
kind: "not",
rule: {
kind: "leaf",
field: "a",
operator: "eq",
value: 3,
},
},
],
},
],
});
expect(ids.sort()).toEqual(["a", "b"]);
});
it("collects from a bare not rule", { timeout: 5000 }, () => {
expect(
fieldsReferencedBy({
kind: "not",
rule: { kind: "leaf", field: "x", operator: "eq", value: 1 },
})
).toEqual(["x"]);
});
it("collects from legacy shape", { timeout: 5000 }, () => {
expect(
fieldsReferencedBy({ field: "x", operator: "eq", value: 1 })
).toEqual(["x"]);
});
});

View File

@@ -0,0 +1,133 @@
import type {
ConditionalRule,
FormValues,
LeafOperator,
} from "../../types/form.types.js";
interface NormalisedLeaf {
readonly kind: "leaf";
readonly field: string;
readonly operator: LeafOperator;
readonly value: unknown;
}
function normaliseLeaf(rule: ConditionalRule): NormalisedLeaf | null {
if ("kind" in rule) {
return rule.kind === "leaf" ? rule : null;
}
return {
kind: "leaf",
field: rule.field,
operator: rule.operator,
value: rule.value,
};
}
function compareLeaf(leaf: NormalisedLeaf, values: FormValues): boolean {
const fieldValue = values[leaf.field];
switch (leaf.operator) {
case "eq":
return fieldValue === leaf.value;
case "neq":
return fieldValue !== leaf.value;
case "in":
return Array.isArray(leaf.value) && leaf.value.includes(fieldValue);
case "notIn":
return (
Array.isArray(leaf.value) && !leaf.value.includes(fieldValue)
);
case "gt":
return (
typeof fieldValue === "number" &&
typeof leaf.value === "number" &&
fieldValue > leaf.value
);
case "lt":
return (
typeof fieldValue === "number" &&
typeof leaf.value === "number" &&
fieldValue < leaf.value
);
case "gte":
return (
typeof fieldValue === "number" &&
typeof leaf.value === "number" &&
fieldValue >= leaf.value
);
case "lte":
return (
typeof fieldValue === "number" &&
typeof leaf.value === "number" &&
fieldValue <= leaf.value
);
case "contains":
if (
typeof fieldValue === "string" &&
typeof leaf.value === "string"
) {
return fieldValue.includes(leaf.value);
}
return Array.isArray(fieldValue) && fieldValue.includes(leaf.value);
}
}
/**
* Evaluate a `ConditionalRule` against the current `FormValues`.
* Returns `true` when the condition holds. Boolean nodes short-circuit
* on the first decisive child.
*/
export function evaluateConditional(
rule: ConditionalRule,
values: FormValues
): boolean {
const leaf = normaliseLeaf(rule);
if (leaf !== null) return compareLeaf(leaf, values);
const tagged = rule as { kind: "and" | "or" | "not" } & ConditionalRule;
if (tagged.kind === "and") {
for (const child of (tagged as { rules: readonly ConditionalRule[] })
.rules) {
if (!evaluateConditional(child, values)) return false;
}
return true;
}
if (tagged.kind === "or") {
for (const child of (tagged as { rules: readonly ConditionalRule[] })
.rules) {
if (evaluateConditional(child, values)) return true;
}
return false;
}
return !evaluateConditional(
(tagged as { rule: ConditionalRule }).rule,
values
);
}
/**
* Return every field id referenced by a conditional rule (recursively).
* Used by the dependency-graph builder.
*/
export function fieldsReferencedBy(rule: ConditionalRule): readonly string[] {
const out = new Set<string>();
const walk = (r: ConditionalRule): void => {
const leaf = normaliseLeaf(r);
if (leaf !== null) {
out.add(leaf.field);
return;
}
const tagged = r as { kind: "and" | "or" | "not" } & ConditionalRule;
if (tagged.kind === "not") {
walk((tagged as { rule: ConditionalRule }).rule);
return;
}
for (const child of (
tagged as {
rules: readonly ConditionalRule[];
}
).rules) {
walk(child);
}
};
walk(rule);
return Array.from(out);
}

View File

@@ -0,0 +1,4 @@
export {
evaluateConditional,
fieldsReferencedBy,
} from "./conditional.js";

View File

@@ -0,0 +1,82 @@
# dependency-graph
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`dependency-graph.ts` builds a directed acyclic graph of field-id
dependencies derived from `FieldDefinition.conditional` and `equalsField`
validators. The graph is the substrate for `FormBuilder.getVisibleFields`,
which evaluates conditional fields in topological order so chained
dependencies (`a → b → c`) settle in a single pass instead of converging
over multiple iterations.
`topoSort()` runs Kahn's algorithm and emits a stable ordering; if it
detects a cycle, it throws a `FormError` carrying
`FORM_BUILDER.CYCLIC_DEPENDENCY` and the unresolved set of field ids.
Forms with cyclic visibility rules are by definition undefined, so the
package treats them as a configuration error rather than silently
diverging.
## Features
- Builds edges from both conditional rules and `equalsField` validators.
- Kahn's topological sort with deterministic insertion order.
- `cyclicDependency` factory raises `FORM_BUILDER.CYCLIC_DEPENDENCY` with
the unresolved id list in `context.cycle`.
- O(V + E) time, O(V) space.
## Usage: Basic
```ts
import { DependencyGraph } from "@suaveplan/form-builder";
const graph = new DependencyGraph([
{ id: "a", name: "a", label: "A", type: "text" },
{
id: "b",
name: "b",
label: "B",
type: "text",
conditional: { field: "a", operator: "eq", value: "yes" },
},
]);
graph.topoSort(); // ["a", "b"]
```
## Usage: Advanced
```ts
import { DependencyGraph } from "@suaveplan/form-builder";
const graph = new DependencyGraph();
graph.addField({ id: "first", name: "first", label: "First", type: "text" });
graph.addField({
id: "confirm",
name: "confirm",
label: "Confirm",
type: "text",
validations: [{ type: "equalsField", value: "first" }],
});
graph.dependents("first"); // ["confirm"]
```
## API Reference
- `new DependencyGraph(fields?)` — optionally seeded.
- `addField(field)` — register a node and its edges.
- `nodes(): readonly string[]`.
- `dependents(from): readonly string[]`.
- `topoSort(): readonly string[]` — may throw `FormError`.
## Implementation Notes
Cycle detection compares the sorted order's length to the registered node
count; any divergence implies a cycle, and the remaining nodes form the
reported cycle context. The graph stores edges in
`Map<string, Set<string>>` to keep edge insertion O(1) and dedup automatic.
Nodes referenced only from `equalsField` (e.g. the password field
referenced by a confirm field) are added as bare nodes so the topo sort
can place them before their dependents.

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FieldDefinition } from "../../types/form.types.js";
import { DependencyGraph } from "./dependency-graph.js";
const f = (
id: string,
extra: Partial<FieldDefinition> = {}
): FieldDefinition => ({
id,
name: id,
label: id,
type: "text",
...extra,
});
describe("DependencyGraph", () => {
it("topo-sorts linear chain", { timeout: 5000 }, () => {
const graph = new DependencyGraph([
f("a"),
f("b", {
conditional: { field: "a", operator: "eq", value: "yes" },
}),
f("c", {
conditional: { field: "b", operator: "eq", value: "yes" },
}),
]);
const order = graph.topoSort();
expect(order.indexOf("a")).toBeLessThan(order.indexOf("b"));
expect(order.indexOf("b")).toBeLessThan(order.indexOf("c"));
});
it("derives edges from equalsField validators", {
timeout: 5000,
}, () => {
const graph = new DependencyGraph([
f("password"),
f("confirm", {
validations: [{ type: "equalsField", value: "password" }],
}),
]);
expect(graph.dependents("password")).toContain("confirm");
});
it("throws on cycle", { timeout: 5000 }, () => {
const graph = new DependencyGraph([
f("a", {
conditional: { field: "b", operator: "eq", value: "yes" },
}),
f("b", {
conditional: { field: "a", operator: "eq", value: "yes" },
}),
]);
try {
graph.topoSort();
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.CYCLIC_DEPENDENCY
);
}
});
it("nodes() returns every registered id", { timeout: 5000 }, () => {
const graph = new DependencyGraph([f("a"), f("b")]);
expect(graph.nodes().sort()).toEqual(["a", "b"]);
});
it("addField is idempotent for the same id", { timeout: 5000 }, () => {
const graph = new DependencyGraph();
graph.addField(f("a"));
graph.addField(f("a"));
expect(graph.nodes()).toEqual(["a"]);
});
it("dependents returns [] for unknown source", { timeout: 5000 }, () => {
const graph = new DependencyGraph([f("a")]);
expect(graph.dependents("nope")).toEqual([]);
});
it("ignores equalsField with a non-string value", { timeout: 5000 }, () => {
const graph = new DependencyGraph([
f("confirm", {
validations: [
{ type: "equalsField", value: 5 as unknown as string },
],
}),
]);
expect(graph.nodes()).toEqual(["confirm"]);
});
it("ignores non-equalsField validators when building edges", {
timeout: 5000,
}, () => {
const graph = new DependencyGraph([
f("name", { validations: [{ type: "required" }] }),
]);
expect(graph.nodes()).toEqual(["name"]);
});
it("topo-sorts a diamond (node with multiple inbound edges)", {
timeout: 5000,
}, () => {
const graph = new DependencyGraph([
f("a"),
f("b"),
f("c", {
conditional: {
kind: "and",
rules: [
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
{ kind: "leaf", field: "b", operator: "eq", value: 1 },
],
},
}),
]);
const order = graph.topoSort();
expect(order.indexOf("c")).toBeGreaterThan(order.indexOf("a"));
expect(order.indexOf("c")).toBeGreaterThan(order.indexOf("b"));
});
it("orders bare nodes deterministically", { timeout: 5000 }, () => {
const graph = new DependencyGraph([f("a"), f("b"), f("c")]);
const order = graph.topoSort();
expect(order).toHaveLength(3);
});
});

View File

@@ -0,0 +1,96 @@
import { cyclicDependency } from "../../errors/form-error/form-error.js";
import type { FieldDefinition } from "../../types/form.types.js";
import { fieldsReferencedBy } from "../conditional/conditional.js";
/**
* Adjacency-list DAG keyed by field id. Edges point from a referenced
* field to the field that references it: if `b.conditional` references
* `a`, then the edge is `a → b`, and `topoSort` returns `a` before `b`.
*/
export class DependencyGraph {
private readonly adjacency = new Map<string, Set<string>>();
private readonly ids = new Set<string>();
public constructor(fields: readonly FieldDefinition[] = []) {
for (const field of fields) {
this.addField(field);
}
}
/** Register a field and every edge derived from its rules. */
public addField(field: FieldDefinition): void {
this.ids.add(field.id);
if (!this.adjacency.has(field.id)) {
this.adjacency.set(field.id, new Set());
}
if (field.conditional !== undefined) {
for (const referenced of fieldsReferencedBy(field.conditional)) {
this.ensureNode(referenced).add(field.id);
}
}
const validations = field.validations ?? [];
for (const rule of validations) {
if (rule.type !== "equalsField") continue;
if (typeof rule.value !== "string") continue;
this.ensureNode(rule.value).add(field.id);
}
}
/** Snapshot list of every registered field id. */
public nodes(): readonly string[] {
return Array.from(this.ids);
}
/** Outgoing edges from `from`. */
public dependents(from: string): readonly string[] {
const set = this.adjacency.get(from);
if (set === undefined) return [];
return Array.from(set);
}
/**
* Kahn's topological sort. Throws
* `FORM_BUILDER.CYCLIC_DEPENDENCY` if a cycle is detected.
*/
public topoSort(): readonly string[] {
const indegree = new Map<string, number>();
for (const id of this.ids) indegree.set(id, 0);
for (const [, outgoing] of this.adjacency) {
for (const target of outgoing) {
indegree.set(target, (indegree.get(target) as number) + 1);
}
}
const queue: string[] = [];
for (const [id, degree] of indegree) {
if (degree === 0) queue.push(id);
}
const order: string[] = [];
while (queue.length > 0) {
const next = queue.shift() as string;
order.push(next);
const outgoing = this.adjacency.get(next) as Set<string>;
for (const target of outgoing) {
const newDegree = (indegree.get(target) as number) - 1;
indegree.set(target, newDegree);
if (newDegree === 0) queue.push(target);
}
}
if (order.length !== this.ids.size) {
const remaining = Array.from(this.ids).filter(
(id) => !order.includes(id)
);
throw cyclicDependency(remaining);
}
return order;
}
private ensureNode(id: string): Set<string> {
let edges = this.adjacency.get(id);
if (edges === undefined) {
edges = new Set();
this.adjacency.set(id, edges);
}
this.ids.add(id);
return edges;
}
}

View File

@@ -0,0 +1 @@
export { DependencyGraph } from "./dependency-graph.js";

View File

@@ -0,0 +1,60 @@
import type {
FieldType,
LeafOperator,
ValidationRuleType,
} from "../types/form.types.js";
/** Closed list of built-in field types. */
export const FIELD_TYPES: readonly FieldType[] = [
"text",
"number",
"email",
"url",
"tel",
"password",
"select",
"multiselect",
"checkbox",
"radio",
"textarea",
"date",
"file",
"range",
"hidden",
"group",
"array",
] as const;
/** Closed list of built-in leaf operators. */
export const LEAF_OPERATORS: readonly LeafOperator[] = [
"eq",
"neq",
"in",
"notIn",
"gt",
"lt",
"gte",
"lte",
"contains",
] as const;
/** Closed list of built-in rule kinds. */
export const BUILT_IN_RULES: readonly ValidationRuleType[] = [
"required",
"minLength",
"maxLength",
"min",
"max",
"pattern",
"email",
"url",
"oneOf",
"equalsField",
"custom",
] as const;
/** Default capacity for the undo/redo stack. */
export const DEFAULT_UNDO_CAPACITY = 100;
/** OTel meter name used across the package. */
export const METER_NAME = "@suaveplan/form-builder";

View File

@@ -0,0 +1,84 @@
# form-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-error.ts` owns the stable error-code registry and the typed `FormError`
class for the entire `@suaveplan/form-builder` package. Every failure mode
exposed by the public API surfaces as a `FormError` carrying one of six
literal codes registered through `defineCodes("FORM_BUILDER", …)`, so
downstream consumers (loggers, problem+json responders, retry wrappers, i18n
adapters) can pattern-match on `error.code` without re-implementing the
mapping.
The module also exports six factory helpers — `fieldNotFound`,
`sectionNotFound`, `duplicateId`, `configInvalid`, `validationFailed`, and
`cyclicDependency` — that build pre-shaped errors with the appropriate
context payload (field id, section id, cycle path, aggregated validation
errors, etc.). These are the only constructors used inside the package.
## Features
- Codes defined via `@suaveplan/error`'s `defineCodes`, giving fully
qualified strings such as `"FORM_BUILDER.FIELD_NOT_FOUND"`.
- `FormError extends SuaveplanError<FormBuilderCode>` so `instanceof
FormError` narrows the union.
- Strongly-typed `FormErrorContext` with optional `fieldId`, `sectionId`,
`schemaId`, `cycle`, and aggregated `errors`.
- Factory helpers keep error construction consistent and ensure every
registered code is reachable from at least one production call site.
## Usage: Basic
```ts
import { fieldNotFound, FORM_BUILDER_CODES } from "@suaveplan/form-builder";
try {
throw fieldNotFound("email");
} catch (err) {
if (err instanceof Error && "code" in err) {
// err.code === FORM_BUILDER_CODES.FIELD_NOT_FOUND
}
}
```
## Usage: Advanced
```ts
import { cyclicDependency, FormError, validationFailed } from "@suaveplan/form-builder";
function safeTopo(cycle: readonly string[]): never {
throw cyclicDependency(cycle);
}
const aggregated = validationFailed([
{ fieldId: "name", rule: "required", message: "Name is required" },
]);
if (aggregated instanceof FormError) {
// aggregated.context?.errors contains the per-field details
}
```
## API Reference
- `FORM_BUILDER_CODES` — frozen code table; six string members.
- `FormBuilderCode` — union of every registered code value.
- `FormError(code, message, options?)` — base error class.
- `fieldNotFound(fieldId)` — `FORM_BUILDER.FIELD_NOT_FOUND`.
- `sectionNotFound(sectionId)` — `FORM_BUILDER.SECTION_NOT_FOUND`.
- `duplicateId(kind, id)` — `FORM_BUILDER.DUPLICATE_ID`.
- `configInvalid(message, cause?)` — `FORM_BUILDER.CONFIG_INVALID`.
- `validationFailed(errors)` — `FORM_BUILDER.VALIDATION_FAILED`.
- `cyclicDependency(cycle)` — `FORM_BUILDER.CYCLIC_DEPENDENCY`.
## Implementation Notes
`SuaveplanError` is an abstract class. `FormError` declares a public
constructor so callers may throw new instances directly without subclassing
further. The factory helpers are pure functions and do not allocate event
buses or otel handles, so they are safe to call from hot validation loops.
The code registry is intentionally closed; adding a code requires an
OpenSpec change that mirrors the bump in this file.

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
configInvalid,
cyclicDependency,
duplicateId,
FORM_BUILDER_CODES,
FormError,
fieldNotFound,
sectionNotFound,
validationFailed,
} from "./form-error.js";
describe("FormError", () => {
it("constructs with code, message, and context", { timeout: 5000 }, () => {
const err = new FormError(
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
"missing",
{ context: { fieldId: "x" } }
);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe(FORM_BUILDER_CODES.FIELD_NOT_FOUND);
expect(err.message).toBe("missing");
expect(err.context?.fieldId).toBe("x");
expect(err.name).toBe("FormError");
});
it("works with no options argument", { timeout: 5000 }, () => {
const err = new FormError(
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
"missing"
);
expect(err.code).toBe(FORM_BUILDER_CODES.FIELD_NOT_FOUND);
expect(err.context).toBeUndefined();
});
it("preserves the cause when supplied", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new FormError(FORM_BUILDER_CODES.CONFIG_INVALID, "bad", {
cause,
});
expect(err.cause).toBe(cause);
});
});
describe("error factories", () => {
it("every code is reachable from a factory", { timeout: 5000 }, () => {
const codes = new Set<string>();
codes.add(fieldNotFound("a").code);
codes.add(sectionNotFound("a").code);
codes.add(duplicateId("field", "a").code);
codes.add(configInvalid("oops").code);
codes.add(
validationFailed([
{ fieldId: "x", rule: "required", message: "no" },
]).code
);
codes.add(cyclicDependency(["a", "b"]).code);
const expected = Object.values(FORM_BUILDER_CODES);
for (const code of expected) {
expect(codes.has(code)).toBe(true);
}
});
it("duplicateId for sections sets sectionId in context", {
timeout: 5000,
}, () => {
const err = duplicateId("section", "general");
expect(err.context?.sectionId).toBe("general");
});
it("validationFailed reports correct singular wording", {
timeout: 5000,
}, () => {
const err = validationFailed([
{ fieldId: "x", rule: "required", message: "no" },
]);
expect(err.message).toContain("1 error");
});
it("validationFailed reports plural wording", { timeout: 5000 }, () => {
const err = validationFailed([
{ fieldId: "x", rule: "required", message: "no" },
{ fieldId: "y", rule: "required", message: "no" },
]);
expect(err.message).toContain("2 errors");
});
it("cyclicDependency lists the cycle", { timeout: 5000 }, () => {
const err = cyclicDependency(["a", "b"]);
expect(err.message).toContain("a → b");
expect(err.context?.cycle).toEqual(["a", "b"]);
});
it("configInvalid accepts a cause", { timeout: 5000 }, () => {
const cause = new Error("zod");
const err = configInvalid("nope", cause);
expect(err.cause).toBe(cause);
});
it("configInvalid without a cause leaves it undefined", {
timeout: 5000,
}, () => {
const err = configInvalid("nope");
expect(err.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,110 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/form-builder`.
*
* Every public-API failure surfaces as a {@link FormError} carrying one of
* these literal codes. The registry uses `defineCodes` from
* `@suaveplan/error` so consumers can pattern-match on the fully-qualified
* code (`"FORM_BUILDER.FIELD_NOT_FOUND"` and friends).
*/
export const FORM_BUILDER_CODES = defineCodes("FORM_BUILDER", [
"FIELD_NOT_FOUND",
"SECTION_NOT_FOUND",
"DUPLICATE_ID",
"CONFIG_INVALID",
"VALIDATION_FAILED",
"CYCLIC_DEPENDENCY",
] as const);
export type FormBuilderCode =
(typeof FORM_BUILDER_CODES)[keyof typeof FORM_BUILDER_CODES];
export interface FormErrorContext extends Record<string, unknown> {
readonly fieldId?: string;
readonly sectionId?: string;
readonly schemaId?: string;
readonly cycle?: readonly string[];
readonly errors?: ReadonlyArray<{
readonly fieldId: string;
readonly rule: string;
readonly message: string;
}>;
}
/** Typed error class for every `@suaveplan/form-builder` failure. */
export class FormError extends SuaveplanError<FormBuilderCode> {
public constructor(
code: FormBuilderCode,
message: string,
options: {
readonly context?: FormErrorContext;
readonly cause?: Error;
} = {}
) {
super(code, message, options);
this.name = "FormError";
}
}
/** Throws when a referenced field id is missing from the schema. */
export function fieldNotFound(fieldId: string): FormError {
return new FormError(
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
`Field "${fieldId}" not found`,
{ context: { fieldId } }
);
}
/** Throws when a referenced section id is missing from the schema. */
export function sectionNotFound(sectionId: string): FormError {
return new FormError(
FORM_BUILDER_CODES.SECTION_NOT_FOUND,
`Section "${sectionId}" not found`,
{ context: { sectionId } }
);
}
/** Thrown when the same field or section id is registered twice. */
export function duplicateId(kind: "field" | "section", id: string): FormError {
return new FormError(
FORM_BUILDER_CODES.DUPLICATE_ID,
`Duplicate ${kind} id "${id}"`,
{
context: kind === "field" ? { fieldId: id } : { sectionId: id },
}
);
}
/** Thrown when a public input fails Zod boundary validation. */
export function configInvalid(message: string, cause?: Error): FormError {
return new FormError(
FORM_BUILDER_CODES.CONFIG_INVALID,
`Invalid form schema: ${message}`,
cause === undefined ? {} : { cause }
);
}
/** Thrown when `submit` aborts because validation produced errors. */
export function validationFailed(
errors: ReadonlyArray<{
readonly fieldId: string;
readonly rule: string;
readonly message: string;
}>
): FormError {
return new FormError(
FORM_BUILDER_CODES.VALIDATION_FAILED,
`Form validation failed with ${errors.length} error${errors.length === 1 ? "" : "s"}`,
{ context: { errors } }
);
}
/** Thrown when the dependency graph detects a cycle. */
export function cyclicDependency(cycle: readonly string[]): FormError {
return new FormError(
FORM_BUILDER_CODES.CYCLIC_DEPENDENCY,
`Cyclic field dependency detected: ${cycle.join(" → ")}`,
{ context: { cycle } }
);
}

View File

@@ -0,0 +1,12 @@
export {
configInvalid,
cyclicDependency,
duplicateId,
FORM_BUILDER_CODES,
type FormBuilderCode,
FormError,
type FormErrorContext,
fieldNotFound,
sectionNotFound,
validationFailed,
} from "./form-error.js";

View File

@@ -0,0 +1,69 @@
# field-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`field-registry.ts` is the extensible registry of field-type descriptors
used by `FormBuilder`. It ships seventeen built-in descriptors (`text`,
`number`, `email`, `url`, `tel`, `password`, `select`, `multiselect`,
`checkbox`, `radio`, `textarea`, `date`, `file`, `range`, `hidden`, `group`,
`array`) and exposes `register`, `unregister`, `has`, `get`, and `list` so
downstream packages can contribute additional field types without forking
the form-builder.
Descriptors carry a human-readable label, a default value, a `hasOptions`
flag (used by select-family fields), and an optional `coerce` function that
normalises raw text input (e.g. `"42"` → `42`) into the canonical runtime
representation.
## Features
- 17 built-in field types registered out of the box.
- Pluggable: third-party packages can `register("rating", { defaultValue:
0 })` to add custom types.
- Coercers normalise external string inputs into the runtime shape.
- A singleton `defaultFieldRegistry` is exported for callers who only need
the built-ins.
## Usage: Basic
```ts
import { defaultFieldRegistry } from "@suaveplan/form-builder";
const desc = defaultFieldRegistry.get("number");
desc?.coerce?.("42"); // → 42
```
## Usage: Advanced
```ts
import { FieldRegistry } from "@suaveplan/form-builder";
const registry = new FieldRegistry();
registry.register("rating", { label: "Rating", defaultValue: 0, hasOptions: false });
registry.has("rating"); // true
```
## API Reference
- `FieldRegistry` — registry class.
- `FieldRegistry#register(type, descriptor)` — chainable.
- `FieldRegistry#unregister(type)`.
- `FieldRegistry#has(type)`.
- `FieldRegistry#get(type)`.
- `FieldRegistry#list()` — snapshot of registered ids.
- `FieldTypeDescriptor` — `{ label, defaultValue, hasOptions, coerce? }`.
- `defaultFieldRegistry` — singleton with the 17 built-ins.
## Implementation Notes
The registry stores descriptors in a `Map<string, FieldTypeDescriptor>` and
the built-in identifiers come from the `FIELD_TYPES` constant so the union
type, the Zod schema, and the registry stay in sync. Custom types may
overwrite built-ins (useful for theming the default `text` field). The
registry deliberately has no opinions about rendering — `defaultValue`
exists so consumers can derive `resetForm` defaults when a field declares
no explicit `defaultValue`.

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FIELD_TYPES } from "../../constants/index.js";
import { defaultFieldRegistry, FieldRegistry } from "./field-registry.js";
describe("FieldRegistry", () => {
it("exposes 17 built-in field types", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
expect(registry.list().length).toBe(FIELD_TYPES.length);
expect(registry.has("text")).toBe(true);
expect(registry.has("array")).toBe(true);
});
it("number descriptor coerces strings", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
const desc = registry.get("number");
expect(desc?.coerce?.("42")).toBe(42);
expect(desc?.coerce?.("")).toBeNull();
expect(desc?.coerce?.(null)).toBeNull();
expect(desc?.coerce?.(undefined)).toBeNull();
expect(desc?.coerce?.("abc")).toBe("abc");
});
it("range descriptor coerces strings with fallback to 0", {
timeout: 5000,
}, () => {
const registry = new FieldRegistry();
const desc = registry.get("range");
expect(desc?.coerce?.("5")).toBe(5);
expect(desc?.coerce?.("")).toBe(0);
expect(desc?.coerce?.(null)).toBe(0);
expect(desc?.coerce?.(undefined)).toBe(0);
expect(desc?.coerce?.("not-a-number")).toBe(0);
});
it("accepts custom field-type registration", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
registry.register("rating", {
label: "Rating",
defaultValue: 0,
hasOptions: false,
});
expect(registry.has("rating")).toBe(true);
expect(registry.get("rating")?.defaultValue).toBe(0);
});
it("unregister removes a type", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
expect(registry.unregister("text")).toBe(true);
expect(registry.has("text")).toBe(false);
expect(registry.unregister("nope")).toBe(false);
});
it("get returns undefined for unknown types", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
expect(registry.get("nope")).toBeUndefined();
});
it("defaultFieldRegistry exposes the built-ins", { timeout: 5000 }, () => {
expect(defaultFieldRegistry.has("text")).toBe(true);
});
});

View File

@@ -0,0 +1,99 @@
import { FIELD_TYPES } from "../../constants/index.js";
import type { FieldType } from "../../types/form.types.js";
/** Descriptor backing a field type in the registry. */
export interface FieldTypeDescriptor {
/** Human-readable name (`"Single-line text"`). */
readonly label: string;
/** Value returned by `getDefault()` when no `defaultValue` is set. */
readonly defaultValue: unknown;
/** Whether the field type natively expects `options[]`. */
readonly hasOptions: boolean;
/**
* Optional coercer used to normalise raw input from a form into the
* canonical runtime shape (e.g. `"42"` → `42` for number fields).
*/
readonly coerce?: (raw: unknown) => unknown;
}
const BUILT_IN_DESCRIPTORS: Record<FieldType, FieldTypeDescriptor> = {
text: { label: "Text", defaultValue: "", hasOptions: false },
number: {
label: "Number",
defaultValue: null,
hasOptions: false,
coerce: (raw) => {
if (raw === null || raw === undefined || raw === "") return null;
const n = Number(raw);
return Number.isFinite(n) ? n : raw;
},
},
email: { label: "Email", defaultValue: "", hasOptions: false },
url: { label: "URL", defaultValue: "", hasOptions: false },
tel: { label: "Telephone", defaultValue: "", hasOptions: false },
password: { label: "Password", defaultValue: "", hasOptions: false },
select: { label: "Single select", defaultValue: null, hasOptions: true },
multiselect: {
label: "Multi select",
defaultValue: [],
hasOptions: true,
},
checkbox: { label: "Checkbox", defaultValue: false, hasOptions: false },
radio: { label: "Radio", defaultValue: null, hasOptions: true },
textarea: { label: "Long text", defaultValue: "", hasOptions: false },
date: { label: "Date", defaultValue: null, hasOptions: false },
file: { label: "File", defaultValue: null, hasOptions: false },
range: {
label: "Range",
defaultValue: 0,
hasOptions: false,
coerce: (raw) => {
if (raw === null || raw === undefined || raw === "") return 0;
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
},
},
hidden: { label: "Hidden", defaultValue: null, hasOptions: false },
group: { label: "Group", defaultValue: {}, hasOptions: false },
array: { label: "Array", defaultValue: [], hasOptions: false },
};
/** Registry for both built-in and custom field types. */
export class FieldRegistry {
private readonly entries = new Map<string, FieldTypeDescriptor>();
public constructor() {
for (const type of FIELD_TYPES) {
this.entries.set(type, BUILT_IN_DESCRIPTORS[type]);
}
}
/** Register a new field type (or overwrite an existing one). */
public register(type: string, descriptor: FieldTypeDescriptor): this {
this.entries.set(type, descriptor);
return this;
}
/** Remove a field type. Returns `true` if a descriptor was removed. */
public unregister(type: string): boolean {
return this.entries.delete(type);
}
/** Whether the registry knows the given type. */
public has(type: string): boolean {
return this.entries.has(type);
}
/** Lookup the descriptor for a type, or `undefined` if unknown. */
public get(type: string): FieldTypeDescriptor | undefined {
return this.entries.get(type);
}
/** Snapshot list of all registered type identifiers. */
public list(): readonly string[] {
return Array.from(this.entries.keys());
}
}
/** Singleton registry seeded with the 17 built-ins. */
export const defaultFieldRegistry = new FieldRegistry();

View File

@@ -0,0 +1,5 @@
export {
defaultFieldRegistry,
FieldRegistry,
type FieldTypeDescriptor,
} from "./field-registry.js";

View File

@@ -0,0 +1,88 @@
export {
type Clock,
FormBuilder,
type FormBuilderOptions,
type IdGenerator,
} from "./builder/form-builder/form-builder.js";
export {
evaluateConditional,
fieldsReferencedBy,
} from "./conditional/conditional/conditional.js";
export { DependencyGraph } from "./conditional/dependency-graph/dependency-graph.js";
export {
BUILT_IN_RULES,
DEFAULT_UNDO_CAPACITY,
FIELD_TYPES,
LEAF_OPERATORS,
METER_NAME,
} from "./constants/index.js";
export {
configInvalid,
cyclicDependency,
duplicateId,
FORM_BUILDER_CODES,
type FormBuilderCode,
FormError,
type FormErrorContext,
fieldNotFound,
sectionNotFound,
validationFailed,
} from "./errors/form-error/form-error.js";
export {
defaultFieldRegistry,
FieldRegistry,
type FieldTypeDescriptor,
} from "./fields/field-registry/field-registry.js";
export {
_resetInstrumentsForTests,
fieldMutatedCounter,
submissionDurationHistogram,
submissionTotalCounter,
validationDurationHistogram,
validationRunCounter,
} from "./otel/instruments/instruments.js";
export {
parseFieldDefinition,
parseFormSchema,
parseFormSection,
schemas,
} from "./schema/schema/schema.js";
export {
deserialiseForm,
SERIALISE_VERSION,
serialiseForm,
} from "./serialise/serialise/serialise.js";
export { FormStateMachine } from "./state/form-state/form-state.js";
export { type UndoOp, UndoRedoStack } from "./state/undo-redo/undo-redo.js";
export {
type SubmitOptions,
type Submitter,
submit,
type Transformer,
} from "./submission/submission/submission.js";
export type {
ConditionalRule,
FieldDefinition,
FieldType,
FormEventBus,
FormEventMap,
FormSchema,
FormSection,
FormState,
FormValues,
LeafOperator,
LegacyConditionalRule,
SelectOption,
ValidationError,
ValidationRule,
ValidationRuleType,
} from "./types/form.types.js";
export {
runFieldValidation,
runFormValidation,
} from "./validation/validation/validation.js";
export {
type CustomRulePredicate,
runRule,
} from "./validation/validators/validators.js";
export { toZodSchema } from "./zod-schema/zod-schema/zod-schema.js";

View File

@@ -0,0 +1,8 @@
export {
_resetInstrumentsForTests,
fieldMutatedCounter,
submissionDurationHistogram,
submissionTotalCounter,
validationDurationHistogram,
validationRunCounter,
} from "./instruments.js";

View File

@@ -0,0 +1,65 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments.ts` lazily resolves the OpenTelemetry meter and the five
instruments emitted across the package: three counters
(`form_builder.field.mutated_total`, `form_builder.validation.run_total`,
`form_builder.submission.total`) and two histograms
(`form_builder.validation.duration_ms`, `form_builder.submission.duration_ms`).
Lazy resolution lets test environments install a custom `MeterProvider`
via `metrics.setGlobalMeterProvider` *after* the module is imported and
still observe production emissions.
A test-only `_resetInstrumentsForTests` is exported so the lazy cache can
be cleared between tests, ensuring each test fixture starts from a clean
slate. The leading underscore signals "do not call from production code".
## Features
- Lazy meter + instrument resolution.
- Stable instrument names that double as OTel collector route keys.
- Test-only reset hook for fixture isolation.
- No imports outside `@opentelemetry/api`.
## Usage: Basic
```ts
import { fieldMutatedCounter } from "@suaveplan/form-builder";
fieldMutatedCounter().add(1, { op: "add" });
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { _resetInstrumentsForTests, validationRunCounter } from "@suaveplan/form-builder";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
_resetInstrumentsForTests();
validationRunCounter().add(1, { scope: "field", result: "ok" });
```
## API Reference
- `fieldMutatedCounter(): Counter`.
- `validationRunCounter(): Counter`.
- `submissionTotalCounter(): Counter`.
- `validationDurationHistogram(): Histogram`.
- `submissionDurationHistogram(): Histogram`.
- `_resetInstrumentsForTests()` — test-only.
## Implementation Notes
The module-level `_meter` cache is the only piece of mutable state in the
file. Multiple producers (the validation engine, the builder, the
submission pipeline) call into these helpers from independent code paths;
sharing the cache avoids creating duplicate instruments per call site and
preserves correct accumulation in the OTel SDK.

View File

@@ -0,0 +1,122 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
type CollectionResult,
MeterProvider,
MetricReader,
type ResourceMetrics,
} from "@opentelemetry/sdk-metrics";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { FormBuilder } from "../../builder/form-builder/form-builder.js";
import { submit } from "../../submission/submission/submission.js";
import {
_resetInstrumentsForTests,
fieldMutatedCounter,
submissionDurationHistogram,
submissionTotalCounter,
validationDurationHistogram,
validationRunCounter,
} from "./instruments.js";
class CollectingReader extends MetricReader {
constructor() {
super({
aggregationTemporalitySelector: () =>
AggregationTemporality.CUMULATIVE,
});
}
protected override async onForceFlush(): Promise<void> {}
protected override async onShutdown(): Promise<void> {}
async snapshot(): Promise<CollectionResult | undefined> {
return this.collect();
}
}
let reader: CollectingReader;
let provider: MeterProvider;
beforeEach(() => {
metrics.disable();
reader = new CollectingReader();
provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
_resetInstrumentsForTests();
});
afterEach(async () => {
await provider.shutdown();
});
async function collect(): Promise<ResourceMetrics[]> {
const r = await reader.snapshot();
return r ? [r.resourceMetrics] : [];
}
describe("otel instruments", () => {
it("exposes the five instruments", { timeout: 5000 }, () => {
expect(fieldMutatedCounter()).toBeDefined();
expect(validationRunCounter()).toBeDefined();
expect(submissionTotalCounter()).toBeDefined();
expect(validationDurationHistogram()).toBeDefined();
expect(submissionDurationHistogram()).toBeDefined();
});
it("every instrument records at least one observation", {
timeout: 30000,
}, async () => {
const builder = new FormBuilder();
builder.addField({
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
});
builder.updateField("name", { label: "Renamed" });
builder.removeField("name");
builder.addField({
id: "email",
name: "email",
label: "Email",
type: "email",
validations: [{ type: "required" }],
});
await builder.validateForm({ email: "a@b.co" });
await submit({
fields: builder.getSchema().fields,
values: { email: "a@b.co" },
submitter: async () => undefined,
});
// Force an invalid submission to record `result: invalid`.
await submit({
fields: builder.getSchema().fields,
values: { email: "" },
submitter: async () => undefined,
}).catch(() => undefined);
// Force a failed submission to record `result: failed`.
await submit({
fields: builder.getSchema().fields,
values: { email: "x@x.io" },
submitter: async () => {
throw new Error("boom");
},
}).catch(() => undefined);
const collected = await collect();
const all = collected
.flatMap((r) => r.scopeMetrics)
.flatMap((s) => s.metrics);
const names = all.map((m) => m.descriptor.name);
expect(names).toContain("form_builder.field.mutated_total");
expect(names).toContain("form_builder.validation.run_total");
expect(names).toContain("form_builder.submission.total");
expect(names).toContain("form_builder.validation.duration_ms");
expect(names).toContain("form_builder.submission.duration_ms");
});
});

View File

@@ -0,0 +1,94 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import { METER_NAME } from "../../constants/index.js";
/**
* Lazily resolves the meter and five OTel instruments emitted by the
* package. Resolving at first call lets tests install a custom
* `MeterProvider` after import and still observe production emissions.
*/
let _meter: Meter | undefined;
let _fieldMutated: Counter | undefined;
let _validationRun: Counter | undefined;
let _submissionTotal: Counter | undefined;
let _validationDuration: Histogram | undefined;
let _submissionDuration: Histogram | undefined;
function meter(): Meter {
if (!_meter) _meter = metrics.getMeter(METER_NAME);
return _meter;
}
/** Counter: incremented on every schema-mutation operation. */
export function fieldMutatedCounter(): Counter {
if (!_fieldMutated) {
_fieldMutated = meter().createCounter(
"form_builder.field.mutated_total",
{
description:
"Count of field/section mutations performed via FormBuilder.",
}
);
}
return _fieldMutated;
}
/** Counter: incremented on every validation invocation. */
export function validationRunCounter(): Counter {
if (!_validationRun) {
_validationRun = meter().createCounter(
"form_builder.validation.run_total",
{ description: "Count of validation invocations." }
);
}
return _validationRun;
}
/** Counter: incremented on every submission attempt. */
export function submissionTotalCounter(): Counter {
if (!_submissionTotal) {
_submissionTotal = meter().createCounter(
"form_builder.submission.total",
{ description: "Count of submission attempts." }
);
}
return _submissionTotal;
}
/** Histogram: time spent in `runFieldValidation` / `runFormValidation`. */
export function validationDurationHistogram(): Histogram {
if (!_validationDuration) {
_validationDuration = meter().createHistogram(
"form_builder.validation.duration_ms",
{
description: "Validation execution duration.",
unit: "ms",
}
);
}
return _validationDuration;
}
/** Histogram: time spent in the submission pipeline. */
export function submissionDurationHistogram(): Histogram {
if (!_submissionDuration) {
_submissionDuration = meter().createHistogram(
"form_builder.submission.duration_ms",
{
description: "Submission pipeline duration.",
unit: "ms",
}
);
}
return _submissionDuration;
}
/** Test-only: reset all cached instruments. */
export function _resetInstrumentsForTests(): void {
_meter = undefined;
_fieldMutated = undefined;
_validationRun = undefined;
_submissionTotal = undefined;
_validationDuration = undefined;
_submissionDuration = undefined;
}

View File

@@ -0,0 +1,6 @@
export {
parseFieldDefinition,
parseFormSchema,
parseFormSection,
schemas,
} from "./schema.js";

View File

@@ -0,0 +1,67 @@
# schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schema.ts` is the Zod boundary for the public `FormSchema`,
`FieldDefinition`, and `FormSection` types. It exposes parse functions that
validate untrusted input (JSON from disk, network requests, persisted
storage) and produces the canonical runtime shape used by the rest of the
package. The schema is the single source of truth for what a "valid form"
looks like; every other module assumes the values it receives have already
passed through this layer.
Parse failures uniformly raise `FormError` with code
`FORM_BUILDER.CONFIG_INVALID`, so consumers do not have to special-case Zod
issue trees. Field-id uniqueness is checked on top of the structural Zod
parse because Zod arrays do not natively support cross-element invariants
without `refine`, and emitting a typed `FormError` is friendlier to
problem+json responders than a generic `ZodError`.
## Features
- Strict Zod schemas (`.strict()` blocks unknown keys at every level).
- Recursive support for nested `FieldDefinition.fields` (group/array types).
- Lazy conditional-rule schema covering the legacy upstream shape, the
tagged `leaf`, and boolean composition (`and` / `or` / `not`).
- Re-exports `schemas` namespace for callers that want to compose.
## Usage: Basic
```ts
import { parseFormSchema } from "@suaveplan/form-builder";
const form = parseFormSchema(JSON.parse(rawJsonFromDisk));
```
## Usage: Advanced
```ts
import { schemas } from "@suaveplan/form-builder";
import z from "zod";
const FormWithMetadata = schemas.formSchema.extend({
tenant: z.string().uuid(),
});
type FormWithMetadata = z.infer<typeof FormWithMetadata>;
```
## API Reference
- `parseFormSchema(raw): FormSchema` — full validation incl. unique ids.
- `parseFieldDefinition(raw): FieldDefinition`.
- `parseFormSection(raw): FormSection`.
- `schemas` — namespace with all underlying `z.ZodType`s.
## Implementation Notes
The conditional-rule schema is `z.lazy` because the type is self-recursive
(`and` / `or` / `not` carry sub-rules). `isoDateString` is a lightweight
refinement around `Date.parse`; we deliberately do not import a heavyweight
date library because the package is universal with zero browser/Node
APIs assumed in callers. Boundary errors are thrown — never returned —
because every caller in this package raises rather than returns, and the
schema layer should match.

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FormSchema } from "../../types/form.types.js";
import {
parseFieldDefinition,
parseFormSchema,
parseFormSection,
schemas,
} from "./schema.js";
const NOW = "2026-01-01T00:00:00.000Z";
function validSchema(overrides: Partial<FormSchema> = {}): FormSchema {
return {
id: "f1",
name: "form one",
version: 1,
fields: [{ id: "a", name: "a", label: "A", type: "text" }],
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
describe("parseFormSchema", () => {
it("parses a valid schema", { timeout: 5000 }, () => {
const result = parseFormSchema(validSchema());
expect(result.id).toBe("f1");
expect(result.fields).toHaveLength(1);
});
it("rejects empty name", { timeout: 5000 }, () => {
try {
parseFormSchema(validSchema({ name: "" }));
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.CONFIG_INVALID
);
}
});
it("rejects duplicate field ids", { timeout: 5000 }, () => {
const schema = validSchema({
fields: [
{ id: "a", name: "a", label: "A", type: "text" },
{ id: "a", name: "a2", label: "A2", type: "text" },
],
});
expect(() => parseFormSchema(schema)).toThrow(FormError);
});
it("rejects invalid timestamps", { timeout: 5000 }, () => {
expect(() =>
parseFormSchema(validSchema({ createdAt: "not-a-date" }))
).toThrow(FormError);
});
it("rejects unknown top-level keys", { timeout: 5000 }, () => {
expect(() =>
parseFormSchema({ ...validSchema(), extra: 1 } as unknown)
).toThrow(FormError);
});
});
describe("parseFieldDefinition", () => {
it("accepts a minimal field", { timeout: 5000 }, () => {
const f = parseFieldDefinition({
id: "a",
name: "a",
label: "A",
type: "text",
});
expect(f.type).toBe("text");
});
it("rejects unknown field type", { timeout: 5000 }, () => {
expect(() =>
parseFieldDefinition({
id: "a",
name: "a",
label: "A",
type: "rocket",
})
).toThrow(FormError);
});
it("accepts conditional rules and nested fields", {
timeout: 5000,
}, () => {
const f = parseFieldDefinition({
id: "g",
name: "g",
label: "G",
type: "group",
conditional: {
kind: "and",
rules: [
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
{
kind: "or",
rules: [
{
kind: "leaf",
field: "b",
operator: "eq",
value: 2,
},
{
kind: "not",
rule: {
kind: "leaf",
field: "c",
operator: "eq",
value: 3,
},
},
],
},
],
},
fields: [{ id: "nested", name: "n", label: "N", type: "text" }],
validations: [{ type: "required" }],
options: [{ label: "A", value: "a" }],
});
expect(f.id).toBe("g");
});
});
describe("parseFormSection", () => {
it("accepts a minimal section", { timeout: 5000 }, () => {
const s = parseFormSection({ id: "s", title: "S" });
expect(s.id).toBe("s");
});
it("rejects an empty id", { timeout: 5000 }, () => {
expect(() => parseFormSection({ id: "", title: "x" })).toThrow(
FormError
);
});
});
describe("schemas namespace", () => {
it("exposes every sub-schema", { timeout: 5000 }, () => {
expect(schemas.formSchema).toBeDefined();
expect(schemas.fieldDefinition).toBeDefined();
expect(schemas.formSection).toBeDefined();
expect(schemas.validationRule).toBeDefined();
expect(schemas.conditionalRule).toBeDefined();
expect(schemas.selectOption).toBeDefined();
});
});

View File

@@ -0,0 +1,218 @@
import z from "zod";
import { configInvalid } from "../../errors/form-error/form-error.js";
import type {
FieldDefinition,
FormSchema,
FormSection,
} from "../../types/form.types.js";
function formatZodIssues(issues: readonly z.core.$ZodIssue[]): string {
// Zod guarantees `issues.length >= 1` whenever safeParse fails.
return (issues[0] as z.core.$ZodIssue).message;
}
const fieldTypeSchema = z.enum([
"text",
"number",
"email",
"url",
"tel",
"password",
"select",
"multiselect",
"checkbox",
"radio",
"textarea",
"date",
"file",
"range",
"hidden",
"group",
"array",
]);
const leafOperatorSchema = z.enum([
"eq",
"neq",
"in",
"notIn",
"gt",
"lt",
"gte",
"lte",
"contains",
]);
const validationRuleTypeSchema = z.enum([
"required",
"minLength",
"maxLength",
"min",
"max",
"pattern",
"email",
"url",
"oneOf",
"equalsField",
"custom",
]);
const validationRuleSchema = z
.object({
type: validationRuleTypeSchema,
value: z.unknown().optional(),
message: z.string().min(1).optional(),
})
.strict();
const selectOptionSchema = z
.object({
label: z.string().min(1),
value: z.string(),
disabled: z.boolean().optional(),
})
.strict();
const legacyConditionalSchema = z
.object({
field: z.string().min(1),
operator: leafOperatorSchema,
value: z.unknown(),
})
.strict();
const conditionalLeafSchema = z
.object({
kind: z.literal("leaf"),
field: z.string().min(1),
operator: leafOperatorSchema,
value: z.unknown(),
})
.strict();
const conditionalRuleSchema: z.ZodType = z.lazy(() =>
z.union([
legacyConditionalSchema,
conditionalLeafSchema,
z
.object({
kind: z.literal("and"),
rules: z.array(conditionalRuleSchema).min(1),
})
.strict(),
z
.object({
kind: z.literal("or"),
rules: z.array(conditionalRuleSchema).min(1),
})
.strict(),
z
.object({
kind: z.literal("not"),
rule: conditionalRuleSchema,
})
.strict(),
])
);
const fieldDefinitionSchema: z.ZodType = z.lazy(() =>
z
.object({
id: z.string().min(1),
name: z.string().min(1),
label: z.string().min(1),
type: fieldTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.unknown().optional(),
options: z.array(selectOptionSchema).optional(),
validations: z.array(validationRuleSchema).optional(),
conditional: conditionalRuleSchema.optional(),
order: z.number().int().optional(),
section: z.string().min(1).optional(),
description: z.string().optional(),
disabled: z.boolean().optional(),
hidden: z.boolean().optional(),
fields: z.array(fieldDefinitionSchema).optional(),
})
.strict()
);
const formSectionSchema = z
.object({
id: z.string().min(1),
title: z.string().min(1),
description: z.string().optional(),
order: z.number().int().optional(),
})
.strict();
const isoDateString = z.string().refine(
(s) => {
const t = Date.parse(s);
return Number.isFinite(t);
},
{ message: "must be an ISO-8601 timestamp" }
);
const formSchemaShape = z
.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
version: z.number().int().min(1),
fields: z.array(fieldDefinitionSchema),
sections: z.array(formSectionSchema).optional(),
submitLabel: z.string().min(1).optional(),
createdAt: isoDateString,
updatedAt: isoDateString,
})
.strict();
/**
* Validate an arbitrary value against the canonical {@link FormSchema}
* shape and check uniqueness of field ids. Throws a `FormError` carrying
* `FORM_BUILDER.CONFIG_INVALID` on failure.
*/
export function parseFormSchema(raw: unknown): FormSchema {
const parsed = formSchemaShape.safeParse(raw);
if (!parsed.success) {
throw configInvalid(formatZodIssues(parsed.error.issues));
}
const data = parsed.data as unknown as FormSchema;
const ids = new Set<string>();
for (const field of data.fields) {
if (ids.has(field.id)) {
throw configInvalid(`duplicate field id "${field.id}"`);
}
ids.add(field.id);
}
return data;
}
/** Parse and return a single `FieldDefinition`. */
export function parseFieldDefinition(raw: unknown): FieldDefinition {
const parsed = fieldDefinitionSchema.safeParse(raw);
if (!parsed.success) {
throw configInvalid(formatZodIssues(parsed.error.issues));
}
return parsed.data as unknown as FieldDefinition;
}
/** Parse and return a single `FormSection`. */
export function parseFormSection(raw: unknown): FormSection {
const parsed = formSectionSchema.safeParse(raw);
if (!parsed.success) {
throw configInvalid(formatZodIssues(parsed.error.issues));
}
return parsed.data as unknown as FormSection;
}
/** Re-exported schemas for downstream consumers wanting to compose. */
export const schemas = {
fieldDefinition: fieldDefinitionSchema,
formSchema: formSchemaShape,
formSection: formSectionSchema,
validationRule: validationRuleSchema,
conditionalRule: conditionalRuleSchema,
selectOption: selectOptionSchema,
} as const;

View File

@@ -0,0 +1,5 @@
export {
deserialiseForm,
SERIALISE_VERSION,
serialiseForm,
} from "./serialise.js";

View File

@@ -0,0 +1,68 @@
# serialise
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is
the bridge between the in-memory schema engine and persisted forms in a
database, blob store, or message bus. The wire format is a small envelope
carrying a `$version` discriminator and the schema body; bumping the
discriminator in a future change lets consumers migrate forward without
ambiguity.
`deserialiseForm` performs three checks in order: JSON parse,
envelope-shape match (including `$version` equality), and full
`parseFormSchema` validation (which itself enforces Zod conformance plus
field-id uniqueness). Every failure mode raises `FormError` carrying
`FORM_BUILDER.CONFIG_INVALID`, so callers can rely on a single typed
exception path.
## Features
- Versioned wire format (`$version: 1`).
- Symmetric: `deserialiseForm(serialiseForm(s))` returns a value
structurally equal to `s`.
- Single error code (`FORM_BUILDER.CONFIG_INVALID`) for every failure.
- Pure: deterministic; no IO.
## Usage: Basic
```ts
import { serialiseForm, deserialiseForm } from "@suaveplan/form-builder";
const wire = serialiseForm(schema);
const restored = deserialiseForm(wire);
```
## Usage: Advanced
```ts
import { deserialiseForm, FORM_BUILDER_CODES, FormError } from "@suaveplan/form-builder";
try {
deserialiseForm("{not json");
} catch (err) {
if (err instanceof FormError && err.code === FORM_BUILDER_CODES.CONFIG_INVALID) {
// record the cause + telemetry
}
}
```
## API Reference
- `serialiseForm(schema): string`.
- `deserialiseForm(json): FormSchema`.
- `SERIALISE_VERSION = 1` — wire version constant.
## Implementation Notes
The envelope wraps the schema rather than annotating it inline because that
keeps the schema body untouched and lets the version discriminator be a
plain object key rather than a magic field embedded in the schema. The
implementation deliberately performs the version check *before* delegating
to `parseFormSchema`, so callers see a clear "wrong version" error rather
than a flood of Zod-issue noise when the inner shape is for a future
release.

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FormSchema } from "../../types/form.types.js";
import {
deserialiseForm,
SERIALISE_VERSION,
serialiseForm,
} from "./serialise.js";
const NOW = "2026-01-01T00:00:00.000Z";
const sample: FormSchema = {
id: "f1",
name: "Form 1",
version: 1,
fields: [{ id: "a", name: "a", label: "A", type: "text" }],
createdAt: NOW,
updatedAt: NOW,
};
describe("serialise / deserialise", () => {
it("round-trips a schema", { timeout: 5000 }, () => {
const wire = serialiseForm(sample);
const restored = deserialiseForm(wire);
expect(restored).toEqual(sample);
});
it("raises on malformed json", { timeout: 5000 }, () => {
try {
deserialiseForm("{not json");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.CONFIG_INVALID
);
}
});
it("rejects wrong envelope shape", { timeout: 5000 }, () => {
expect(() => deserialiseForm("[]")).toThrow(FormError);
expect(() => deserialiseForm("null")).toThrow(FormError);
expect(() =>
deserialiseForm(JSON.stringify({ schema: sample }))
).toThrow(FormError);
});
it("rejects unsupported version", { timeout: 5000 }, () => {
const wire = JSON.stringify({ $version: 99, schema: sample });
expect(() => deserialiseForm(wire)).toThrow(FormError);
});
it("exposes SERIALISE_VERSION as 1", { timeout: 5000 }, () => {
expect(SERIALISE_VERSION).toBe(1);
});
it("rejects schema with invalid inner shape", { timeout: 5000 }, () => {
const wire = JSON.stringify({
$version: 1,
schema: { ...sample, name: "" },
});
expect(() => deserialiseForm(wire)).toThrow(FormError);
});
});

View File

@@ -0,0 +1,49 @@
import { configInvalid } from "../../errors/form-error/form-error.js";
import { parseFormSchema } from "../../schema/schema/schema.js";
import type { FormSchema } from "../../types/form.types.js";
/** Wire-format version emitted by `serialiseForm`. */
export const SERIALISE_VERSION = 1 as const;
interface WireEnvelope {
readonly $version: typeof SERIALISE_VERSION;
readonly schema: FormSchema;
}
/** Serialise a schema into a JSON string carrying a wire-format version. */
export function serialiseForm(schema: FormSchema): string {
const envelope: WireEnvelope = {
$version: SERIALISE_VERSION,
schema,
};
return JSON.stringify(envelope);
}
/**
* Inverse of `serialiseForm`. Performs JSON parse + Zod validation +
* field-id uniqueness check. Throws `FORM_BUILDER.CONFIG_INVALID` on
* any failure mode (malformed JSON, wrong shape, duplicate ids, etc.).
*/
export function deserialiseForm(json: string): FormSchema {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (cause) {
throw configInvalid("malformed JSON", cause as Error);
}
if (
parsed === null ||
typeof parsed !== "object" ||
!("schema" in parsed) ||
!("$version" in parsed)
) {
throw configInvalid("missing schema or $version");
}
const envelope = parsed as Partial<WireEnvelope>;
if (envelope.$version !== SERIALISE_VERSION) {
throw configInvalid(
`unsupported wire version ${String(envelope.$version)}`
);
}
return parseFormSchema(envelope.schema);
}

View File

@@ -0,0 +1,67 @@
# form-state
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-state.ts` is the mutable holder for the runtime state of a form
instance: the current value map, the most recent validation errors, the
set of touched field ids, the dirty flag, and the valid flag. It is the
structure returned from `FormBuilder.getState` and emitted on the
`values.changed` event. The state machine is intentionally minimal — it
owns no validation logic of its own; the builder asks `runFormValidation`
for errors and forwards them via `setErrors`.
The state is treated as a write-through cache: every mutation produces a
new value map (via spread) so consumers that snapshot via `getState` always
get an independent copy and cannot mutate internal state by reference.
## Features
- `setValue(id, value)` — single-field update, marks dirty + touched.
- `setValues(values)` — bulk replace, marks every key touched.
- `touch(id)` — record visit without changing values.
- `reset(defaults?)` — restore defaults, clear dirty + touched.
- `setErrors(errors)` — update errors and recompute `valid`.
- `getState()` — immutable snapshot.
## Usage: Basic
```ts
import { FormStateMachine } from "@suaveplan/form-builder";
const state = new FormStateMachine({ name: "" });
state.setValue("name", "alice");
state.getState().dirty; // true
```
## Usage: Advanced
```ts
import { FormStateMachine } from "@suaveplan/form-builder";
const state = new FormStateMachine();
state.setErrors([
{ fieldId: "email", rule: "email", message: "Not a valid email" },
]);
state.getState().valid; // false
state.reset({ email: "" });
state.getState().valid; // true
state.getState().errors.length; // 0
```
## API Reference
- `FormStateMachine(initial?)`.
- `setValue(fieldId, value)`, `setValues(values)`, `touch(fieldId)`.
- `reset(defaults?)`, `setErrors(errors)`.
- `getState(): FormState`.
## Implementation Notes
`getState` returns a fresh object on every call, including a new `Set`
copy of touched ids, so consumers cannot influence subsequent mutations
by holding a reference. The class is intentionally not an `EventEmitter`
— event emission lives on `FormBuilder` where the public bus is wired up.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FormStateMachine } from "./form-state.js";
describe("FormStateMachine", () => {
it("setValue marks dirty and touched", { timeout: 5000 }, () => {
const state = new FormStateMachine({ name: "" });
state.setValue("name", "alice");
const snap = state.getState();
expect(snap.dirty).toBe(true);
expect(snap.touched.has("name")).toBe(true);
expect(snap.values.name).toBe("alice");
});
it("setValues marks every key touched", { timeout: 5000 }, () => {
const state = new FormStateMachine();
state.setValues({ a: 1, b: 2 });
const snap = state.getState();
expect(snap.touched.has("a")).toBe(true);
expect(snap.touched.has("b")).toBe(true);
expect(snap.dirty).toBe(true);
});
it("touch records visit without changing values", { timeout: 5000 }, () => {
const state = new FormStateMachine({ name: "" });
state.touch("name");
expect(state.getState().touched.has("name")).toBe(true);
expect(state.getState().dirty).toBe(false);
});
it("reset clears state", { timeout: 5000 }, () => {
const state = new FormStateMachine({ name: "alice" });
state.setValue("name", "bob");
state.reset({ name: "default" });
const snap = state.getState();
expect(snap.values.name).toBe("default");
expect(snap.dirty).toBe(false);
expect(snap.touched.size).toBe(0);
expect(snap.valid).toBe(true);
});
it("setErrors flips valid", { timeout: 5000 }, () => {
const state = new FormStateMachine();
state.setErrors([{ fieldId: "x", rule: "required", message: "no" }]);
expect(state.getState().valid).toBe(false);
state.setErrors([]);
expect(state.getState().valid).toBe(true);
});
it("getState returns an independent copy", { timeout: 5000 }, () => {
const state = new FormStateMachine({ a: 1 });
const snap = state.getState();
(snap.values as Record<string, unknown>).a = 99;
expect(state.getState().values.a).toBe(1);
});
it("constructs with empty initial values by default", {
timeout: 5000,
}, () => {
const state = new FormStateMachine();
expect(state.getState().values).toEqual({});
});
it("reset with no args clears to empty", { timeout: 5000 }, () => {
const state = new FormStateMachine({ a: 1 });
state.reset();
expect(state.getState().values).toEqual({});
});
});

View File

@@ -0,0 +1,72 @@
import type {
FormState,
FormValues,
ValidationError,
} from "../../types/form.types.js";
/**
* Mutable holder for the runtime state of a form instance.
*
* The state mirrors the upstream `FormState` shape (values, errors,
* touched, dirty, valid) and is the structure emitted on
* `values.changed` and returned from `FormBuilder.getState`.
*/
export class FormStateMachine {
private values: FormValues;
private errors: readonly ValidationError[];
private readonly touched = new Set<string>();
private dirty = false;
private valid = true;
public constructor(initial: FormValues = {}) {
this.values = { ...initial };
this.errors = [];
}
/** Update a single field's value (marks dirty + touched). */
public setValue(fieldId: string, value: unknown): void {
this.values = { ...this.values, [fieldId]: value };
this.touched.add(fieldId);
this.dirty = true;
}
/** Replace the entire value map (marks every key touched). */
public setValues(values: FormValues): void {
this.values = { ...values };
for (const key of Object.keys(values)) {
this.touched.add(key);
}
this.dirty = true;
}
/** Record that a field has been visited by the user. */
public touch(fieldId: string): void {
this.touched.add(fieldId);
}
/** Reset to the supplied defaults (or empty), clearing dirty + touched. */
public reset(defaults: FormValues = {}): void {
this.values = { ...defaults };
this.errors = [];
this.touched.clear();
this.dirty = false;
this.valid = true;
}
/** Update the cached error list and recompute `valid`. */
public setErrors(errors: readonly ValidationError[]): void {
this.errors = errors;
this.valid = errors.length === 0;
}
/** Immutable snapshot of the current state. */
public getState(): FormState {
return {
values: { ...this.values },
errors: this.errors,
touched: new Set(this.touched),
dirty: this.dirty,
valid: this.valid,
};
}
}

View File

@@ -0,0 +1 @@
export { FormStateMachine } from "./form-state.js";

View File

@@ -0,0 +1 @@
export { type UndoOp, UndoRedoStack } from "./undo-redo.js";

View File

@@ -0,0 +1,70 @@
# undo-redo
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is
the substrate for `FormBuilder.enableHistory` — when history is enabled,
every schema-mutation method (`addField`, `removeField`, `updateField`,
`addSection`, `removeSection`) pushes a paired `UndoOp` whose `apply`
reverses the operation and whose `inverse` re-applies it. Calling `undo`
pops the most recent reverse-op, applies it, and pushes its inverse onto
the redo stack; calling `redo` reverses the process. Any new mutation
clears the redo stack to preserve linear history.
The capacity bound is enforced by dropping the oldest undo entry when a
push exceeds the limit, mirroring the canonical "infinite-undo with
ceiling" behaviour users expect from desktop applications. The default
capacity is 100, configured via `DEFAULT_UNDO_CAPACITY`.
## Features
- Bounded LIFO with FIFO-eviction on overflow.
- Redo stack cleared on new mutation (linear-history semantics).
- `canUndo` / `canRedo` introspection.
- `clear()` resets both stacks.
## Usage: Basic
```ts
import { UndoRedoStack } from "@suaveplan/form-builder";
const stack = new UndoRedoStack(2);
let value = 0;
const inc: import("@suaveplan/form-builder").UndoOp = {
apply: () => { value -= 1; },
inverse: undefined as any,
};
// (set up the pairing yourself for non-builder code)
```
## Usage: Advanced
```ts
import { UndoRedoStack } from "@suaveplan/form-builder";
const stack = new UndoRedoStack();
stack.push(myUndoOp);
stack.undo();
stack.redo();
stack.canUndo(); // true again
```
## API Reference
- `new UndoRedoStack(capacity = 100)`.
- `push(op)`, `undo()`, `redo()`.
- `canUndo()`, `canRedo()`, `clear()`.
- `UndoOp = { apply: () => void; inverse: UndoOp }`.
## Implementation Notes
The capacity is validated at construction time and a `RangeError` is raised
if it is not a positive integer. The implementation uses native arrays and
`shift` / `pop` because the expected capacity ceiling (≤ 100 by default)
makes the amortised cost negligible. Switching to a ring buffer would only
matter if capacities reached the tens of thousands, which is not a
supported use case.

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { UndoOp } from "./undo-redo.js";
import { UndoRedoStack } from "./undo-redo.js";
function pair(state: { value: number }, delta: number): UndoOp {
const apply: UndoOp = {
apply: () => {
state.value -= delta;
},
inverse: undefined as unknown as UndoOp,
};
const inverse: UndoOp = {
apply: () => {
state.value += delta;
},
inverse: apply,
};
(apply as { inverse: UndoOp }).inverse = inverse;
return apply;
}
describe("UndoRedoStack", () => {
it("drops oldest when capacity is exceeded", { timeout: 5000 }, () => {
const stack = new UndoRedoStack(2);
const state = { value: 0 };
stack.push(pair(state, 1));
stack.push(pair(state, 2));
stack.push(pair(state, 3));
// Now only the last two ops remain — undo twice and try a third
stack.undo();
stack.undo();
expect(stack.canUndo()).toBe(false);
});
it("new push clears redo stack", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
const state = { value: 0 };
stack.push(pair(state, 1));
stack.undo();
expect(stack.canRedo()).toBe(true);
stack.push(pair(state, 5));
expect(stack.canRedo()).toBe(false);
});
it("undo and redo apply ops symmetrically", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
const state = { value: 0 };
const op = pair(state, 4);
op.inverse.apply();
expect(state.value).toBe(4);
stack.push(op);
stack.undo();
expect(state.value).toBe(0);
stack.redo();
expect(state.value).toBe(4);
});
it("undo and redo no-op when empty", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
expect(stack.undo()).toBeUndefined();
expect(stack.redo()).toBeUndefined();
});
it("canUndo and canRedo reflect stack state", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
expect(stack.canUndo()).toBe(false);
expect(stack.canRedo()).toBe(false);
const state = { value: 0 };
stack.push(pair(state, 1));
expect(stack.canUndo()).toBe(true);
});
it("clear empties both stacks", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
const state = { value: 0 };
stack.push(pair(state, 1));
stack.undo();
stack.clear();
expect(stack.canUndo()).toBe(false);
expect(stack.canRedo()).toBe(false);
});
it("rejects non-positive integer capacity", { timeout: 5000 }, () => {
expect(() => new UndoRedoStack(0)).toThrow(RangeError);
expect(() => new UndoRedoStack(1.5)).toThrow(RangeError);
expect(() => new UndoRedoStack(-1)).toThrow(RangeError);
});
});

View File

@@ -0,0 +1,70 @@
import { DEFAULT_UNDO_CAPACITY } from "../../constants/index.js";
/**
* Op recorded on the undo stack. `apply` performs the reverse operation
* on the host; `inverse` is the forward operation pushed onto the redo
* stack when `undo` consumes this entry.
*/
export interface UndoOp {
readonly apply: () => void;
readonly inverse: UndoOp;
}
/** Bounded LIFO with redo support. */
export class UndoRedoStack {
private readonly undoStack: UndoOp[] = [];
private readonly redoStack: UndoOp[] = [];
private readonly capacity: number;
public constructor(capacity: number = DEFAULT_UNDO_CAPACITY) {
if (!Number.isInteger(capacity) || capacity < 1) {
throw new RangeError(
"UndoRedoStack capacity must be a positive integer"
);
}
this.capacity = capacity;
}
/** Push an op onto undo, clearing the redo stack. */
public push(op: UndoOp): void {
this.undoStack.push(op);
if (this.undoStack.length > this.capacity) {
this.undoStack.shift();
}
this.redoStack.length = 0;
}
/** Pop and apply the most recent undo op. */
public undo(): UndoOp | undefined {
const op = this.undoStack.pop();
if (op === undefined) return undefined;
op.apply();
this.redoStack.push(op.inverse);
return op;
}
/** Pop and apply the most recent redo op. */
public redo(): UndoOp | undefined {
const op = this.redoStack.pop();
if (op === undefined) return undefined;
op.apply();
this.undoStack.push(op.inverse);
return op;
}
/** Whether `undo()` would have effect. */
public canUndo(): boolean {
return this.undoStack.length > 0;
}
/** Whether `redo()` would have effect. */
public canRedo(): boolean {
return this.redoStack.length > 0;
}
/** Drop every op on both stacks. */
public clear(): void {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
}

View File

@@ -0,0 +1,6 @@
export {
type SubmitOptions,
type Submitter,
submit,
type Transformer,
} from "./submission.js";

View File

@@ -0,0 +1,79 @@
# submission
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`submission.ts` is the orchestration layer that runs the canonical
`prepare → validate → transform → submit` pipeline for a fully-prepared
form. It validates the values against every rule, refuses to call the
submitter if validation produces errors (raising `FORM_BUILDER.VALIDATION_FAILED`),
optionally rewrites the payload via a pure-function `Transformer`, and
forwards the resulting map and the caller-supplied `AbortSignal` to the
async `Submitter` function.
Every code path emits two OpenTelemetry instruments:
`form_builder.submission.total` (counter) and
`form_builder.submission.duration_ms` (histogram) tagged with
`result: "ok" | "invalid" | "failed"`. That lets dashboards split error
budgets by terminal state.
## Features
- Strict gating: invalid values short-circuit before the submitter runs.
- Optional pure-function `Transformer` between validate and submit.
- `AbortSignal` threaded into the submitter.
- OTel counter + histogram on every terminal state.
## Usage: Basic
```ts
import { submit } from "@suaveplan/form-builder";
await submit({
fields,
values: { email: "a@example.com" },
submitter: async (values) => fetch("/api/forms", {
method: "POST",
body: JSON.stringify(values),
}),
});
```
## Usage: Advanced
```ts
import { submit, FORM_BUILDER_CODES, FormError } from "@suaveplan/form-builder";
try {
await submit({
fields,
values,
transform: (v) => ({ ...v, normalisedEmail: String(v.email).trim().toLowerCase() }),
submitter: postSubmission,
signal,
});
} catch (err) {
if (err instanceof FormError && err.code === FORM_BUILDER_CODES.VALIDATION_FAILED) {
// err.context?.errors is the aggregated per-field error list
}
}
```
## API Reference
- `submit<TResult = void>(options): Promise<TResult>`.
- `Submitter<TResult> = (values, signal?) => Promise<TResult>`.
- `Transformer = (values) => FormValues`.
- `SubmitOptions<TResult>` — `{ fields, values, submitter, transform?, signal?, clock? }`.
## Implementation Notes
`submit` clamps elapsed durations at zero to defend against fake clocks
producing negative deltas. Errors thrown by the submitter are re-thrown
verbatim after recording the `result: "failed"` counter — the submission
layer makes no attempt to translate them, because submitters are an
extensibility seam and downstream code chooses how to surface its own
errors.

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FieldDefinition } from "../../types/form.types.js";
import { submit } from "./submission.js";
const requiredText = (id: string): FieldDefinition => ({
id,
name: id,
label: id,
type: "text",
validations: [{ type: "required" }],
});
describe("submit", () => {
it("rejects when validation fails", { timeout: 5000 }, async () => {
let called = false;
try {
await submit({
fields: [requiredText("a")],
values: { a: "" },
submitter: async () => {
called = true;
},
});
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.VALIDATION_FAILED
);
}
expect(called).toBe(false);
});
it("invokes submitter on ok path", { timeout: 5000 }, async () => {
let captured: Record<string, unknown> | undefined;
await submit({
fields: [requiredText("a")],
values: { a: "alice" },
submitter: async (values) => {
captured = values;
},
});
expect(captured?.a).toBe("alice");
});
it("applies the transformer between validate and submit", {
timeout: 5000,
}, async () => {
let captured: Record<string, unknown> | undefined;
await submit({
fields: [requiredText("a")],
values: { a: "Alice" },
transform: (v) => ({ ...v, a: String(v.a).toLowerCase() }),
submitter: async (values) => {
captured = values;
},
});
expect(captured?.a).toBe("alice");
});
it("rethrows submitter failures and emits failed counter", {
timeout: 5000,
}, async () => {
await expect(
submit({
fields: [requiredText("a")],
values: { a: "alice" },
submitter: async () => {
throw new Error("upstream");
},
})
).rejects.toThrow("upstream");
});
it("returns the submitter's result", { timeout: 5000 }, async () => {
const result = await submit<number>({
fields: [requiredText("a")],
values: { a: "x" },
submitter: async () => 42,
});
expect(result).toBe(42);
});
it("threads the signal into the submitter", { timeout: 5000 }, async () => {
const controller = new AbortController();
let received: AbortSignal | undefined;
const promise = submit({
fields: [requiredText("a")],
values: { a: "x" },
signal: controller.signal,
submitter: async (_values, signal) => {
received = signal;
},
});
await promise;
expect(received).toBe(controller.signal);
});
it("accepts an injectable clock", { timeout: 5000 }, async () => {
let n = 0;
await submit({
fields: [requiredText("a")],
values: { a: "x" },
clock: () => ++n,
submitter: async () => undefined,
});
expect(n).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,70 @@
import { validationFailed } from "../../errors/form-error/form-error.js";
import {
submissionDurationHistogram,
submissionTotalCounter,
} from "../../otel/instruments/instruments.js";
import type { FieldDefinition, FormValues } from "../../types/form.types.js";
import {
type Clock,
runFormValidation,
} from "../../validation/validation/validation.js";
/** Async predicate the submission pipeline invokes after validation. */
export type Submitter<TResult = void> = (
values: FormValues,
signal?: AbortSignal
) => Promise<TResult>;
/** Optional pure-function pre-submit transformer. */
export type Transformer = (values: FormValues) => FormValues;
export interface SubmitOptions<TResult = void> {
readonly fields: readonly FieldDefinition[];
readonly values: FormValues;
readonly submitter: Submitter<TResult>;
readonly transform?: Transformer;
readonly signal?: AbortSignal;
readonly clock?: Clock;
}
const defaultClock: Clock = () => Date.now();
/**
* `prepare → validate → transform → submit`. Throws
* `FORM_BUILDER.VALIDATION_FAILED` if validation errors are present.
* Records `form_builder.submission.total` and
* `form_builder.submission.duration_ms` with `result: ok|invalid|failed`.
*/
export async function submit<TResult = void>(
options: SubmitOptions<TResult>
): Promise<TResult> {
const clock = options.clock ?? defaultClock;
const start = clock();
const errors = await runFormValidation(
options.fields,
options.values,
options.signal,
clock
);
if (errors.length > 0) {
const elapsed = Math.max(0, clock() - start);
submissionTotalCounter().add(1, { result: "invalid" });
submissionDurationHistogram().record(elapsed, { result: "invalid" });
throw validationFailed(errors);
}
const transformed = options.transform
? options.transform(options.values)
: options.values;
try {
const result = await options.submitter(transformed, options.signal);
const elapsed = Math.max(0, clock() - start);
submissionTotalCounter().add(1, { result: "ok" });
submissionDurationHistogram().record(elapsed, { result: "ok" });
return result;
} catch (cause) {
const elapsed = Math.max(0, clock() - start);
submissionTotalCounter().add(1, { result: "failed" });
submissionDurationHistogram().record(elapsed, { result: "failed" });
throw cause;
}
}

View File

@@ -0,0 +1,156 @@
import type { EventBus } from "@suaveplan/events";
/** Closed vocabulary of built-in field types. */
export type FieldType =
| "text"
| "number"
| "email"
| "url"
| "tel"
| "password"
| "select"
| "multiselect"
| "checkbox"
| "radio"
| "textarea"
| "date"
| "file"
| "range"
| "hidden"
| "group"
| "array";
/** Built-in leaf operator vocabulary for conditional rules. */
export type LeafOperator =
| "eq"
| "neq"
| "in"
| "notIn"
| "gt"
| "lt"
| "gte"
| "lte"
| "contains";
/** Built-in rule kinds the validation engine knows how to run. */
export type ValidationRuleType =
| "required"
| "minLength"
| "maxLength"
| "min"
| "max"
| "pattern"
| "email"
| "url"
| "oneOf"
| "equalsField"
| "custom";
/** A single validation rule. `value` is interpreted per rule kind. */
export interface ValidationRule {
readonly type: ValidationRuleType;
readonly value?: unknown;
readonly message?: string;
}
/** Option used by `select`, `multiselect`, and `radio` fields. */
export interface SelectOption {
readonly label: string;
readonly value: string;
readonly disabled?: boolean;
}
/** Legacy upstream conditional shape — accepted for backwards compatibility. */
export interface LegacyConditionalRule {
readonly field: string;
readonly operator: LeafOperator;
readonly value: unknown;
}
/** Discriminated conditional rule. */
export type ConditionalRule =
| LegacyConditionalRule
| {
readonly kind: "leaf";
readonly field: string;
readonly operator: LeafOperator;
readonly value: unknown;
}
| { readonly kind: "and"; readonly rules: readonly ConditionalRule[] }
| { readonly kind: "or"; readonly rules: readonly ConditionalRule[] }
| { readonly kind: "not"; readonly rule: ConditionalRule };
/** Definition of a single form field. */
export interface FieldDefinition {
readonly id: string;
readonly name: string;
readonly label: string;
readonly type: FieldType;
readonly placeholder?: string;
readonly defaultValue?: unknown;
readonly options?: readonly SelectOption[];
readonly validations?: readonly ValidationRule[];
readonly conditional?: ConditionalRule;
readonly order?: number;
readonly section?: string;
readonly description?: string;
readonly disabled?: boolean;
readonly hidden?: boolean;
readonly fields?: readonly FieldDefinition[];
}
/** Logical grouping of fields. */
export interface FormSection {
readonly id: string;
readonly title: string;
readonly description?: string;
readonly order?: number;
}
/** Top-level schema produced by `FormBuilder`. */
export interface FormSchema {
readonly id: string;
readonly name: string;
readonly description?: string;
readonly version: number;
readonly fields: readonly FieldDefinition[];
readonly sections?: readonly FormSection[];
readonly submitLabel?: string;
readonly createdAt: string;
readonly updatedAt: string;
}
/** A single per-field validation error. */
export interface ValidationError {
readonly fieldId: string;
readonly rule: string;
readonly message: string;
}
/** Map of field id → arbitrary value. */
export type FormValues = Record<string, unknown>;
/** Form-state snapshot. */
export interface FormState {
readonly values: FormValues;
readonly errors: readonly ValidationError[];
readonly touched: ReadonlySet<string>;
readonly dirty: boolean;
readonly valid: boolean;
}
/** Typed event map for `EventBus<FormEventMap>`. */
export interface FormEventMap {
readonly "field.added": FieldDefinition;
readonly "field.removed": string;
readonly "field.updated": FieldDefinition;
readonly "section.added": FormSection;
readonly "section.removed": string;
readonly "values.changed": FormValues;
readonly "form.validated": readonly ValidationError[];
readonly "form.reset": null;
readonly [k: string]: unknown;
}
/** Convenience alias for the form-builder event bus. */
export type FormEventBus = EventBus<FormEventMap>;

View File

@@ -0,0 +1,5 @@
export {
type Clock,
runFieldValidation,
runFormValidation,
} from "./validation.js";

View File

@@ -0,0 +1,69 @@
# validation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validation.ts` is the iteration layer that runs every rule attached to a
field or form and aggregates the resulting `ValidationError` array. It
honours `AbortSignal` for cancellation (so callers can race a slow async
custom rule against a debounce timer) and emits two OpenTelemetry
instruments — `form_builder.validation.run_total` counter and
`form_builder.validation.duration_ms` histogram — on every invocation so
operations dashboards can watch validation latency and error rates.
The engine deliberately knows nothing about the rule semantics; it
delegates to `runRule` from `../validators/validators.ts`. That keeps the
two layers independently testable: the validators cover rule semantics, the
engine covers iteration, cancellation, and observability.
## Features
- `runFieldValidation(field, value, values, signal?)` — single field.
- `runFormValidation(fields, values, signal?)` — every field.
- `AbortSignal` short-circuits both functions and returns `[]`.
- Async-safe: awaits any promise returned by `runRule`.
- OTel counter with `{ scope, result }` attributes and histogram with
`{ scope }`.
## Usage: Basic
```ts
import { runFieldValidation } from "@suaveplan/form-builder";
const errors = await runFieldValidation(
{ id: "name", name: "name", label: "Name", type: "text", validations: [{ type: "required" }] },
"",
{ name: "" }
);
// errors[0]?.rule === "required"
```
## Usage: Advanced
```ts
import { runFormValidation } from "@suaveplan/form-builder";
const controller = new AbortController();
const promise = runFormValidation(fields, values, controller.signal);
controller.abort();
const errors = await promise; // []
```
## API Reference
- `runFieldValidation(field, value, values, signal?, clock?): Promise<readonly ValidationError[]>`.
- `runFormValidation(fields, values, signal?, clock?): Promise<readonly ValidationError[]>`.
- `Clock = () => number` — injectable for deterministic histograms.
## Implementation Notes
Both functions check the signal before every rule (not just at the start)
so a long-running form aborts promptly. Histogram durations clamp at zero
to defend against monotonic-clock skew under fake clocks. Counters use the
`{ scope: "field"|"form", result: "ok"|"errors" }` attribute pair so
dashboards can split error rate by scope. We intentionally do not throw
on individual rule failures — validation failures are data, not
exceptions.

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FieldDefinition } from "../../types/form.types.js";
import { runFieldValidation, runFormValidation } from "./validation.js";
const textField = (
id: string,
validations: FieldDefinition["validations"] = []
): FieldDefinition => ({
id,
name: id,
label: id.toUpperCase(),
type: "text",
validations,
});
describe("runFieldValidation", () => {
it("aggregates per-field errors", { timeout: 5000 }, async () => {
const field = textField("name", [
{ type: "required" },
{ type: "minLength", value: 3 },
]);
const errors = await runFieldValidation(field, "", {});
expect(errors).toHaveLength(2);
});
it("honours abort signal", { timeout: 5000 }, async () => {
const controller = new AbortController();
controller.abort();
const field = textField("name", [{ type: "required" }]);
const errors = await runFieldValidation(
field,
"",
{},
controller.signal
);
expect(errors).toHaveLength(0);
});
it("awaits async custom rules", { timeout: 5000 }, async () => {
const field = textField("x", [
{ type: "custom", value: async () => false },
]);
const errors = await runFieldValidation(field, "v", {});
expect(errors).toHaveLength(1);
});
it("returns empty array when no validations are set", {
timeout: 5000,
}, async () => {
const field = textField("name");
const errors = await runFieldValidation(field, "", {});
expect(errors).toHaveLength(0);
});
it("emits counter and histogram", { timeout: 5000 }, async () => {
// Instruments are real; just exercise the path to ensure no throws.
const field = textField("name", [{ type: "required" }]);
await runFieldValidation(field, "alice", {});
});
it("stops iterating when signal aborts mid-flight", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
let count = 0;
const field = textField("x", [
{
type: "custom",
value: () => {
count++;
controller.abort();
return true;
},
},
{ type: "required" },
]);
await runFieldValidation(field, "ok", {}, controller.signal);
expect(count).toBe(1);
});
});
describe("runFormValidation", () => {
it("aggregates per-field errors", { timeout: 5000 }, async () => {
const fields = [
textField("a", [{ type: "required" }]),
textField("b", [{ type: "required" }]),
];
const errors = await runFormValidation(fields, {});
expect(errors).toHaveLength(2);
expect(errors.map((e) => e.fieldId).sort()).toEqual(["a", "b"]);
});
it("returns empty array when aborted before invocation", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const errors = await runFormValidation(
[textField("a", [{ type: "required" }])],
{},
controller.signal
);
expect(errors).toHaveLength(0);
});
it("stops mid-form on abort", { timeout: 5000 }, async () => {
const controller = new AbortController();
const fields = [
textField("a", [
{
type: "custom",
value: () => {
controller.abort();
return true;
},
},
]),
textField("b", [{ type: "required" }]),
];
const errors = await runFormValidation(
fields,
{ a: "ok" },
controller.signal
);
expect(errors).toHaveLength(0);
});
it("returns ok counter result for clean run", {
timeout: 5000,
}, async () => {
const fields = [textField("a", [{ type: "required" }])];
const errors = await runFormValidation(fields, { a: "ok" });
expect(errors).toHaveLength(0);
});
it("accepts an injectable clock", { timeout: 5000 }, async () => {
let n = 0;
const clock = () => ++n;
const fields = [textField("a", [{ type: "required" }])];
const errors = await runFormValidation(
fields,
{ a: "ok" },
undefined,
clock
);
expect(errors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,77 @@
import {
validationDurationHistogram,
validationRunCounter,
} from "../../otel/instruments/instruments.js";
import type {
FieldDefinition,
FormValues,
ValidationError,
} from "../../types/form.types.js";
import { runRule } from "../validators/validators.js";
/** A `Clock` returning the current epoch-millis timestamp. */
export type Clock = () => number;
const defaultClock: Clock = () => Date.now();
/**
* Run every rule attached to a single `FieldDefinition`. Returns the
* collected errors. Honours `AbortSignal`: if the signal is aborted
* before invocation, an empty array is returned and no rule executes.
* The validation counter and histogram are emitted on every call.
*/
export async function runFieldValidation(
field: FieldDefinition,
value: unknown,
values: FormValues,
signal?: AbortSignal,
clock: Clock = defaultClock
): Promise<readonly ValidationError[]> {
if (signal?.aborted) return [];
const start = clock();
const errors: ValidationError[] = [];
const rules = field.validations ?? [];
for (const rule of rules) {
if (signal?.aborted) break;
const result = runRule(rule, value, values, field.id);
const resolved = result instanceof Promise ? await result : result;
if (resolved !== null) errors.push(resolved);
}
const elapsed = Math.max(0, clock() - start);
const result = errors.length === 0 ? "ok" : "errors";
validationRunCounter().add(1, { scope: "field", result });
validationDurationHistogram().record(elapsed, { scope: "field" });
return errors;
}
/**
* Run validation across every field in the given list. Aggregates errors
* in field order, honours `AbortSignal`, and emits the validation
* counter + histogram with `scope: "form"`.
*/
export async function runFormValidation(
fields: readonly FieldDefinition[],
values: FormValues,
signal?: AbortSignal,
clock: Clock = defaultClock
): Promise<readonly ValidationError[]> {
if (signal?.aborted) return [];
const start = clock();
const errors: ValidationError[] = [];
for (const field of fields) {
if (signal?.aborted) break;
const fieldErrors = await runFieldValidation(
field,
values[field.id],
values,
signal,
clock
);
errors.push(...fieldErrors);
}
const elapsed = Math.max(0, clock() - start);
const result = errors.length === 0 ? "ok" : "errors";
validationRunCounter().add(1, { scope: "form", result });
validationDurationHistogram().record(elapsed, { scope: "form" });
return errors;
}

View File

@@ -0,0 +1 @@
export { type CustomRulePredicate, runRule } from "./validators.js";

View File

@@ -0,0 +1,75 @@
# validators
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validators.ts` provides the eleven built-in rule runners used by the
validation engine. Each runner takes the candidate value, the surrounding
`FormValues` (so `equalsField` can cross-reference), and the originating
field id; it returns either `null` (passed) or a `ValidationError` shaped
`{ fieldId, rule, message }`. `custom` rules may return a
`Promise<boolean>`, in which case `runRule` also returns a promise — the
upstream engine awaits the result transparently.
This file is the only place where rule semantics live. Adding a new
built-in rule means adding a case here, extending `BUILT_IN_RULES`, and
updating the Zod schema; everything else (the engine, the Zod generator,
the builder) consumes the rule kind opaquely.
## Features
- Eleven built-in rules: `required`, `minLength`, `maxLength`, `min`,
`max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, `custom`.
- `required` treats `null`, `undefined`, empty string, and empty array as
absent.
- `pattern` accepts a string source; constructed into `RegExp` per call.
- `url` validates via the standard `URL` constructor.
- `custom` supports synchronous boolean predicates and async predicates.
## Usage: Basic
```ts
import { runRule } from "@suaveplan/form-builder";
const error = runRule(
{ type: "required" },
"",
{},
"name"
);
// error.rule === "required"
```
## Usage: Advanced
```ts
import { runRule } from "@suaveplan/form-builder";
const asyncCustom = runRule(
{
type: "custom",
value: async (v: unknown) => v !== "blocked",
},
"blocked",
{},
"username"
);
// asyncCustom is a Promise<ValidationError | null>
```
## API Reference
- `runRule(rule, value, values, fieldId): ValidationError | null |
Promise<ValidationError | null>`.
- `CustomRulePredicate = (value, values) => boolean | Promise<boolean>`.
## Implementation Notes
Unknown rule kinds return `null` — the engine treats unknown rules as
no-ops so a registry mismatch never crashes a hot loop. `equalsField`
compares with `!==` (strict equality), which is the right behaviour for
the canonical "password confirmation" use case. `url` constructs a `URL`
inside a try/catch; this is cheap and avoids regex traps for IPv6 hosts.

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
ValidationError,
ValidationRule,
} from "../../types/form.types.js";
import { runRule } from "./validators.js";
function sync(
rule: ValidationRule,
value: unknown,
values: Record<string, unknown> = {},
fieldId = "x"
): ValidationError | null {
const r = runRule(rule, value, values, fieldId);
if (r instanceof Promise) throw new Error("expected sync");
return r;
}
describe("runRule — passing paths", () => {
it("minLength passes when long enough", { timeout: 5000 }, () => {
expect(sync({ type: "minLength", value: 3 }, "abc")).toBeNull();
});
it("maxLength passes when short enough", { timeout: 5000 }, () => {
expect(sync({ type: "maxLength", value: 5 }, "abc")).toBeNull();
});
it("min passes when large enough", { timeout: 5000 }, () => {
expect(sync({ type: "min", value: 5 }, 10)).toBeNull();
});
it("max passes when small enough", { timeout: 5000 }, () => {
expect(sync({ type: "max", value: 5 }, 1)).toBeNull();
});
});
describe("runRule — required", () => {
it("required passes on present value", { timeout: 5000 }, () => {
expect(sync({ type: "required" }, "alice")).toBeNull();
});
it("required fails on empty", { timeout: 5000 }, () => {
expect(sync({ type: "required" }, "")?.rule).toBe("required");
expect(sync({ type: "required" }, null)?.rule).toBe("required");
expect(sync({ type: "required" }, undefined)?.rule).toBe("required");
expect(sync({ type: "required" }, [])?.rule).toBe("required");
});
});
describe("runRule — minLength / maxLength", () => {
it("minLength fails when shorter", { timeout: 5000 }, () => {
expect(sync({ type: "minLength", value: 3 }, "ab")?.rule).toBe(
"minLength"
);
});
it("minLength is a no-op for non-strings or missing config", {
timeout: 5000,
}, () => {
expect(sync({ type: "minLength", value: 3 }, 4)).toBeNull();
expect(sync({ type: "minLength" }, "abc")).toBeNull();
});
it("maxLength fails when longer", { timeout: 5000 }, () => {
expect(sync({ type: "maxLength", value: 2 }, "abc")?.rule).toBe(
"maxLength"
);
});
it("maxLength is a no-op for non-strings or missing config", {
timeout: 5000,
}, () => {
expect(sync({ type: "maxLength", value: 2 }, 1)).toBeNull();
expect(sync({ type: "maxLength" }, "abc")).toBeNull();
});
});
describe("runRule — min / max", () => {
it("min fails when smaller", { timeout: 5000 }, () => {
expect(sync({ type: "min", value: 5 }, 1)?.rule).toBe("min");
});
it("min is a no-op for non-numbers", { timeout: 5000 }, () => {
expect(sync({ type: "min", value: 5 }, "a")).toBeNull();
expect(sync({ type: "min" }, 5)).toBeNull();
});
it("max fails when larger", { timeout: 5000 }, () => {
expect(sync({ type: "max", value: 5 }, 10)?.rule).toBe("max");
});
it("max is a no-op for non-numbers", { timeout: 5000 }, () => {
expect(sync({ type: "max", value: 5 }, "a")).toBeNull();
expect(sync({ type: "max" }, 5)).toBeNull();
});
});
describe("runRule — pattern", () => {
it("fails when pattern does not match", { timeout: 5000 }, () => {
expect(sync({ type: "pattern", value: "^abc$" }, "xyz")?.rule).toBe(
"pattern"
);
});
it("passes when pattern matches", { timeout: 5000 }, () => {
expect(sync({ type: "pattern", value: "^abc$" }, "abc")).toBeNull();
});
it("is a no-op for non-string value or missing pattern", {
timeout: 5000,
}, () => {
expect(sync({ type: "pattern", value: "^a$" }, 1)).toBeNull();
expect(sync({ type: "pattern" }, "abc")).toBeNull();
});
});
describe("runRule — email and url", () => {
it("email fails on bad strings", { timeout: 5000 }, () => {
expect(sync({ type: "email" }, "bad")?.rule).toBe("email");
});
it("email passes on valid string", { timeout: 5000 }, () => {
expect(sync({ type: "email" }, "a@b.co")).toBeNull();
});
it("email is a no-op for non-strings", { timeout: 5000 }, () => {
expect(sync({ type: "email" }, 1)).toBeNull();
});
it("url passes on valid url", { timeout: 5000 }, () => {
expect(sync({ type: "url" }, "https://example.com")).toBeNull();
});
it("url fails on garbage", { timeout: 5000 }, () => {
expect(sync({ type: "url" }, "not a url")?.rule).toBe("url");
});
it("url is a no-op for non-strings", { timeout: 5000 }, () => {
expect(sync({ type: "url" }, 1)).toBeNull();
});
});
describe("runRule — oneOf", () => {
it("fails when value not in list", { timeout: 5000 }, () => {
expect(sync({ type: "oneOf", value: ["a", "b"] }, "c")?.rule).toBe(
"oneOf"
);
});
it("passes when value is allowed", { timeout: 5000 }, () => {
expect(sync({ type: "oneOf", value: ["a", "b"] }, "a")).toBeNull();
});
it("is a no-op when value is not an array", { timeout: 5000 }, () => {
expect(sync({ type: "oneOf", value: "nope" }, "a")).toBeNull();
});
});
describe("runRule — equalsField", () => {
it("equalsField detects mismatch", { timeout: 5000 }, () => {
expect(
sync(
{ type: "equalsField", value: "password" },
"abc",
{ password: "xyz" },
"confirm"
)?.rule
).toBe("equalsField");
});
it("equalsField passes when equal", { timeout: 5000 }, () => {
expect(
sync(
{ type: "equalsField", value: "password" },
"abc",
{ password: "abc" },
"confirm"
)
).toBeNull();
});
it("is a no-op when value is not a string id", { timeout: 5000 }, () => {
expect(
sync({ type: "equalsField", value: 5 }, "x", { password: "y" })
).toBeNull();
});
});
describe("runRule — custom", () => {
it("custom rule passes when predicate is true", { timeout: 5000 }, () => {
expect(sync({ type: "custom", value: () => true }, "x")).toBeNull();
});
it("custom rule fails when predicate is false", { timeout: 5000 }, () => {
expect(sync({ type: "custom", value: () => false }, "x")?.rule).toBe(
"custom"
);
});
it("custom rule supports async", { timeout: 5000 }, async () => {
const r = runRule(
{ type: "custom", value: async () => false },
"x",
{},
"x"
);
const resolved = r instanceof Promise ? await r : r;
expect(resolved?.rule).toBe("custom");
});
it("custom rule async resolves to null on pass", {
timeout: 5000,
}, async () => {
const r = runRule(
{ type: "custom", value: async () => true },
"x",
{},
"x"
);
const resolved = r instanceof Promise ? await r : r;
expect(resolved).toBeNull();
});
it("custom is a no-op when value is not a function", {
timeout: 5000,
}, () => {
expect(sync({ type: "custom", value: 1 }, "x")).toBeNull();
});
});
describe("runRule — unknown", () => {
it("returns null on unknown rule kind", { timeout: 5000 }, () => {
const result = sync(
{ type: "nope" as unknown as ValidationRule["type"] },
"x"
);
expect(result).toBeNull();
});
});
describe("runRule — custom message", () => {
it("uses custom message when provided", { timeout: 5000 }, () => {
const err = sync({ type: "required", message: "Please fill" }, "");
expect(err?.message).toBe("Please fill");
});
});

View File

@@ -0,0 +1,134 @@
import type {
FormValues,
ValidationError,
ValidationRule,
} from "../../types/form.types.js";
/** Custom-rule predicate signature. May be sync or async. */
export type CustomRulePredicate = (
value: unknown,
values: FormValues
) => boolean | Promise<boolean>;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function isPresent(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "string" && value.length === 0) return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
}
function error(
fieldId: string,
rule: ValidationRule,
fallback: string
): ValidationError {
return {
fieldId,
rule: rule.type,
message: rule.message ?? fallback,
};
}
/**
* Execute a single validation rule against a candidate `value`. Returns
* `null` when the rule passes; otherwise a `ValidationError`. `custom`
* rules may return a `Promise<boolean>`, in which case the returned
* value is also a promise.
*/
export function runRule(
rule: ValidationRule,
value: unknown,
values: FormValues,
fieldId: string
): ValidationError | null | Promise<ValidationError | null> {
switch (rule.type) {
case "required":
if (!isPresent(value))
return error(fieldId, rule, "Field is required");
return null;
case "minLength": {
if (typeof value !== "string") return null;
if (typeof rule.value !== "number") return null;
if (value.length < rule.value)
return error(
fieldId,
rule,
`Must be at least ${rule.value} characters`
);
return null;
}
case "maxLength": {
if (typeof value !== "string") return null;
if (typeof rule.value !== "number") return null;
if (value.length > rule.value)
return error(
fieldId,
rule,
`Must be at most ${rule.value} characters`
);
return null;
}
case "min": {
if (typeof value !== "number") return null;
if (typeof rule.value !== "number") return null;
if (value < rule.value)
return error(fieldId, rule, `Must be ≥ ${rule.value}`);
return null;
}
case "max": {
if (typeof value !== "number") return null;
if (typeof rule.value !== "number") return null;
if (value > rule.value)
return error(fieldId, rule, `Must be ≤ ${rule.value}`);
return null;
}
case "pattern": {
if (typeof value !== "string") return null;
if (typeof rule.value !== "string") return null;
if (!new RegExp(rule.value).test(value))
return error(fieldId, rule, "Does not match pattern");
return null;
}
case "email":
if (typeof value !== "string") return null;
if (!EMAIL_RE.test(value))
return error(fieldId, rule, "Not a valid email");
return null;
case "url":
if (typeof value !== "string") return null;
try {
new URL(value);
return null;
} catch {
return error(fieldId, rule, "Not a valid URL");
}
case "oneOf": {
if (!Array.isArray(rule.value)) return null;
if (!rule.value.includes(value))
return error(fieldId, rule, "Value is not allowed");
return null;
}
case "equalsField": {
if (typeof rule.value !== "string") return null;
const other = values[rule.value];
if (other !== value)
return error(fieldId, rule, `Must equal field "${rule.value}"`);
return null;
}
case "custom": {
if (typeof rule.value !== "function") return null;
const predicate = rule.value as CustomRulePredicate;
const result = predicate(value, values);
if (result instanceof Promise) {
return result.then((ok) =>
ok ? null : error(fieldId, rule, "Custom rule failed")
);
}
return result ? null : error(fieldId, rule, "Custom rule failed");
}
default:
return null;
}
}

View File

@@ -0,0 +1 @@
export { toZodSchema } from "./zod-schema.js";

View File

@@ -0,0 +1,60 @@
# zod-schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a
`FormSchema` into a `z.ZodObject` whose `safeParse` behaviour matches the
package's own `runFormValidation` for the eleven built-in rule kinds.
Downstream consumers using `react-hook-form` with
`@hookform/resolvers/zod` get identical validation semantics in the
browser without re-implementing the rule engine in two places.
Conditional required rules are honoured: the generated schema uses
`superRefine` to skip the "required" check when a field's conditional rule
evaluates to false. Cross-field `equalsField` rules are emitted as
`superRefine` issues so the Zod path-based error trees line up with the
field-id keys used elsewhere in the package.
## Features
- Generates a `ZodObject` from any `FormSchema`.
- Respects conditional `required` — optional when invisible.
- Maps `min`, `max`, `minLength`, `maxLength`, `pattern`, `email`, `url`,
and `oneOf` into native Zod constraints where possible.
- Cross-field `equalsField` reported via `superRefine`.
## Usage: Basic
```ts
import { toZodSchema } from "@suaveplan/form-builder";
const schema = toZodSchema(form);
const result = schema.safeParse({ name: "" });
// result.success === false (name is required)
```
## Usage: Advanced
```ts
import { toZodSchema } from "@suaveplan/form-builder";
// const resolver = zodResolver(toZodSchema(form));
// const { register, handleSubmit } = useForm({ resolver });
```
## API Reference
- `toZodSchema(form): z.ZodType<FormValues>`.
## Implementation Notes
Every field is wrapped in `.optional()` so the generated schema accepts
partially-filled drafts; the `superRefine` step is the canonical layer
that decides whether a value is structurally required. This means the
schema validates against the *current* values being parsed, which is what
`react-hook-form` consumers expect for conditional UIs. We deliberately
avoid `z.discriminatedUnion` over field types because field types are an
attribute of the schema, not the value space.

View File

@@ -0,0 +1,324 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FormSchema } from "../../types/form.types.js";
import { toZodSchema } from "./zod-schema.js";
const NOW = "2026-01-01T00:00:00.000Z";
function form(fields: FormSchema["fields"]): FormSchema {
return {
id: "f",
name: "F",
version: 1,
fields,
createdAt: NOW,
updatedAt: NOW,
};
}
describe("toZodSchema", () => {
it("enforces required", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
},
])
);
expect(schema.safeParse({}).success).toBe(false);
expect(schema.safeParse({ name: "alice" }).success).toBe(true);
});
it("respects conditional required", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{ id: "a", name: "a", label: "A", type: "text" },
{
id: "b",
name: "b",
label: "B",
type: "text",
validations: [{ type: "required" }],
conditional: {
kind: "leaf",
field: "a",
operator: "eq",
value: "yes",
},
},
])
);
expect(schema.safeParse({ a: "no" }).success).toBe(true);
expect(schema.safeParse({ a: "yes" }).success).toBe(false);
});
it("applies number min and max", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "age",
name: "age",
label: "Age",
type: "number",
validations: [
{ type: "min", value: 18 },
{ type: "max", value: 65 },
],
},
])
);
expect(schema.safeParse({ age: 12 }).success).toBe(false);
expect(schema.safeParse({ age: 70 }).success).toBe(false);
expect(schema.safeParse({ age: 30 }).success).toBe(true);
});
it("applies string min, max, email, url, pattern", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [
{ type: "minLength", value: 2 },
{ type: "maxLength", value: 5 },
],
},
{
id: "email",
name: "email",
label: "Email",
type: "email",
validations: [{ type: "email" }],
},
{
id: "site",
name: "site",
label: "Site",
type: "url",
validations: [{ type: "url" }],
},
{
id: "code",
name: "code",
label: "Code",
type: "text",
validations: [{ type: "pattern", value: "^[a-z]+$" }],
},
])
);
expect(schema.safeParse({ name: "x" }).success).toBe(false);
expect(schema.safeParse({ name: "abcdef" }).success).toBe(false);
expect(schema.safeParse({ name: "abc", email: "bad" }).success).toBe(
false
);
expect(
schema.safeParse({
name: "abc",
email: "a@b.co",
site: "https://x.io",
code: "abc",
}).success
).toBe(true);
});
it("applies oneOf via refine", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "color",
name: "color",
label: "Color",
type: "select",
validations: [{ type: "oneOf", value: ["red", "blue"] }],
},
])
);
expect(schema.safeParse({ color: "green" }).success).toBe(false);
expect(schema.safeParse({ color: "red" }).success).toBe(true);
});
it("supports equalsField via superRefine", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "password",
name: "password",
label: "Password",
type: "password",
},
{
id: "confirm",
name: "confirm",
label: "Confirm",
type: "password",
validations: [{ type: "equalsField", value: "password" }],
},
])
);
expect(schema.safeParse({ password: "a", confirm: "b" }).success).toBe(
false
);
expect(schema.safeParse({ password: "a", confirm: "a" }).success).toBe(
true
);
});
it("validates checkbox, multiselect, group, range, array", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{ id: "a", name: "a", label: "A", type: "checkbox" },
{ id: "b", name: "b", label: "B", type: "multiselect" },
{ id: "c", name: "c", label: "C", type: "group" },
{
id: "d",
name: "d",
label: "D",
type: "range",
validations: [{ type: "min", value: 0 }],
},
{ id: "e", name: "e", label: "E", type: "array" },
])
);
expect(
schema.safeParse({
a: true,
b: ["x"],
c: { k: 1 },
d: 5,
e: [1, 2],
}).success
).toBe(true);
});
it("respects conditional required with explicit empty value", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required", message: "Need it" }],
},
])
);
const result = schema.safeParse({ name: "" });
expect(result.success).toBe(false);
});
it("uses default required message when not provided", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
},
])
);
const result = schema.safeParse({ name: null });
expect(result.success).toBe(false);
});
it("skips min/max with non-number value", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "n",
name: "n",
label: "N",
type: "number",
validations: [
{ type: "min", value: "x" },
{ type: "max", value: null },
],
},
])
);
expect(schema.safeParse({ n: 100 }).success).toBe(true);
});
it("skips minLength/maxLength/pattern with non-string value", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "s",
name: "s",
label: "S",
type: "text",
validations: [
{ type: "minLength", value: "x" },
{ type: "maxLength", value: null },
{ type: "pattern", value: 99 },
],
},
])
);
expect(schema.safeParse({ s: "abc" }).success).toBe(true);
});
it("skips oneOf with a non-array value", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "x",
name: "x",
label: "X",
type: "select",
validations: [{ type: "oneOf", value: "nope" }],
},
])
);
expect(schema.safeParse({ x: "anything" }).success).toBe(true);
});
it("skips equalsField with a non-string value", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "a",
name: "a",
label: "A",
type: "text",
validations: [{ type: "equalsField", value: 5 }],
},
])
);
expect(schema.safeParse({ a: "x" }).success).toBe(true);
});
it("treats empty array as missing for required", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "tags",
name: "tags",
label: "Tags",
type: "multiselect",
validations: [{ type: "required" }],
},
])
);
expect(schema.safeParse({ tags: [] }).success).toBe(false);
expect(schema.safeParse({ tags: ["x"] }).success).toBe(true);
});
});

View File

@@ -0,0 +1,132 @@
import z from "zod";
import { evaluateConditional } from "../../conditional/conditional/conditional.js";
import type {
FieldDefinition,
FormSchema,
FormValues,
ValidationRule,
} from "../../types/form.types.js";
function ruleHasType(
rules: readonly ValidationRule[] | undefined,
type: ValidationRule["type"]
): ValidationRule | undefined {
return rules?.find((r) => r.type === type);
}
function buildFieldSchema(field: FieldDefinition): z.ZodTypeAny {
const rules = field.validations ?? [];
let base: z.ZodTypeAny;
switch (field.type) {
case "number":
case "range": {
let n: z.ZodNumber = z.number();
const min = ruleHasType(rules, "min");
if (min && typeof min.value === "number") n = n.min(min.value);
const max = ruleHasType(rules, "max");
if (max && typeof max.value === "number") n = n.max(max.value);
base = n;
break;
}
case "checkbox":
base = z.boolean();
break;
case "multiselect":
case "array":
base = z.array(z.unknown());
break;
case "group":
base = z.record(z.string(), z.unknown());
break;
default: {
let s: z.ZodString = z.string();
const minLen = ruleHasType(rules, "minLength");
if (minLen && typeof minLen.value === "number")
s = s.min(minLen.value);
const maxLen = ruleHasType(rules, "maxLength");
if (maxLen && typeof maxLen.value === "number")
s = s.max(maxLen.value);
const email = ruleHasType(rules, "email");
if (email) s = s.email();
const url = ruleHasType(rules, "url");
if (url) s = s.url();
const pattern = ruleHasType(rules, "pattern");
if (pattern && typeof pattern.value === "string")
s = s.regex(new RegExp(pattern.value));
base = s;
break;
}
}
const oneOf = ruleHasType(rules, "oneOf");
if (oneOf && Array.isArray(oneOf.value)) {
const allowed = oneOf.value as readonly unknown[];
base = base.refine((v) => allowed.includes(v), {
message: oneOf.message ?? "Value is not allowed",
});
}
return base;
}
/**
* Generate a `z.ZodObject` whose `safeParse` matches `runFormValidation`
* for the canonical 11 rule kinds (where statically expressible). A
* conditional `required` is applied via `superRefine`: the field is
* required only when its `conditional` rule evaluates to `true` against
* the candidate `values`.
*/
export function toZodSchema(form: FormSchema): z.ZodType<FormValues> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const field of form.fields) {
shape[field.id] = buildFieldSchema(field).optional();
}
const object = z.object(shape);
return object.superRefine((values, ctx) => {
for (const field of form.fields) {
if (field.conditional !== undefined) {
const visible = evaluateConditional(
field.conditional,
values as FormValues
);
if (!visible) continue;
}
const isRequired = field.validations?.some(
(r) => r.type === "required"
);
if (isRequired) {
const v = (values as FormValues)[field.id];
const empty =
v === undefined ||
v === null ||
v === "" ||
(Array.isArray(v) && v.length === 0);
if (empty) {
ctx.addIssue({
code: "custom",
message:
field.validations?.find(
(r) => r.type === "required"
)?.message ?? "Field is required",
path: [field.id],
});
}
}
const equalsField = field.validations?.find(
(r) => r.type === "equalsField"
);
if (
equalsField &&
typeof equalsField.value === "string" &&
(values as FormValues)[field.id] !==
(values as FormValues)[equalsField.value]
) {
ctx.addIssue({
code: "custom",
message:
equalsField.message ??
`Must equal field "${equalsField.value}"`,
path: [field.id],
});
}
}
}) as unknown as z.ZodType<FormValues>;
}

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/form-builder",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/types/**",
"src/constants/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});

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";

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