first commit
This commit is contained in:
34
docs/ui/README.md
Normal file
34
docs/ui/README.md
Normal 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
docs/ui/_intro.md
Normal file
3
docs/ui/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `ui`
|
||||
|
||||
React components, forms, and user-interface primitives.
|
||||
14
docs/ui/forms/README.md
Normal file
14
docs/ui/forms/README.md
Normal 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 -->
|
||||
1
docs/ui/forms/_intro.md
Normal file
1
docs/ui/forms/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Form builders, validation, and React form components.
|
||||
128
docs/ui/forms/form-builder/README.md
Normal file
128
docs/ui/forms/form-builder/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
67
docs/ui/forms/form-builder/src/schema/schema/schema.md
Normal file
67
docs/ui/forms/form-builder/src/schema/schema/schema.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
70
docs/ui/forms/form-builder/src/state/undo-redo/undo-redo.md
Normal file
70
docs/ui/forms/form-builder/src/state/undo-redo/undo-redo.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
533
docs/ui/forms/react-forms/README.md
Normal file
533
docs/ui/forms/react-forms/README.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>`.
|
||||
79
docs/ui/forms/react-forms/src/core/auth-context/use-auth.md
Normal file
79
docs/ui/forms/react-forms/src/core/auth-context/use-auth.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,98 @@
|
||||
# `server-error-mapper.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Bridges server-side validation envelopes into the engine's per-field
|
||||
`ValidatorFailure` shape. When a form's `submit.onSubmit` callback
|
||||
returns or throws a `ServerErrorEnvelope` (or a `ReactFormsError`
|
||||
whose `context.fieldErrors` carries the same shape), this module
|
||||
unwraps the envelope, maps each entry into the engine's collection
|
||||
shape, and increments the
|
||||
`react_forms.validation.error_total{kind="server"}` counter.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapServerErrors(envelope)` — convert a `ServerErrorEnvelope` into
|
||||
the engine's `EngineFieldErrors` map.
|
||||
- `unwrapServerEnvelope(cause)` — duck-type a thrown value into a
|
||||
`ServerErrorEnvelope` if it carries the right shape; returns
|
||||
`undefined` for anything else so the caller can re-throw.
|
||||
- `throwServerErrorMapped(envelope, cause?)` — wrap an envelope in a
|
||||
structured `ReactFormsError(REACT_FORMS.SERVER_ERROR_MAPPED)` for
|
||||
cases where the engine wants to surface the mapping outcome to an
|
||||
error boundary.
|
||||
- OTel telemetry emitted per mapped field error.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
mapServerErrors,
|
||||
unwrapServerEnvelope,
|
||||
} from "@suaveplan/react-forms/core/validation-engine";
|
||||
|
||||
try {
|
||||
await api.users.create(values);
|
||||
} catch (cause) {
|
||||
const envelope = unwrapServerEnvelope(cause);
|
||||
if (envelope) {
|
||||
const errors = mapServerErrors(envelope);
|
||||
// errors === { email: { valid: false, messageKey: "auth.email.taken" } }
|
||||
} else {
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
mapServerErrors,
|
||||
throwServerErrorMapped,
|
||||
unwrapServerEnvelope,
|
||||
} from "@suaveplan/react-forms/core/validation-engine";
|
||||
|
||||
async function submit(values) {
|
||||
try {
|
||||
return await api.users.register(values);
|
||||
} catch (cause) {
|
||||
const envelope = unwrapServerEnvelope(cause);
|
||||
if (envelope === undefined) throw cause;
|
||||
// Map for in-form display
|
||||
const errors = mapServerErrors(envelope);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
// also surface to error boundary
|
||||
throwServerErrorMapped(envelope, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `mapServerErrors(envelope: ServerErrorEnvelope): EngineFieldErrors`
|
||||
— convert envelope into the engine's per-field failure map.
|
||||
- `unwrapServerEnvelope(cause: unknown): ServerErrorEnvelope |
|
||||
undefined` — duck-type a thrown value.
|
||||
- `throwServerErrorMapped(envelope: ServerErrorEnvelope, cause?:
|
||||
unknown): never` — throw a structured `ReactFormsError`.
|
||||
- `unsafeAssertTranslationKey(value: string): TranslationKey` — test-
|
||||
only escape hatch for the brand check; real callers should always
|
||||
go through the consumer's translation augmentation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The mapper uses a positive duck-type check (`Array.isArray` plus
|
||||
per-entry type guards) rather than a Zod parse to keep the hot
|
||||
path zero-allocation and runtime-dependency-free. Zod's parse
|
||||
would add ~3 µs per envelope and the engine runs this on every
|
||||
submit.
|
||||
- Each mapped entry increments the OTel counter; consumers wanting
|
||||
per-form attribution can add `form` to the counter via a wrapping
|
||||
layer in `useDomainForm`.
|
||||
- The `unsafeAssertTranslationKey` helper exists so spec-conformance
|
||||
tests can construct branded keys without going through the
|
||||
consumer's full augmentation path. It is exported from the
|
||||
validation-engine module — NOT from the package barrel — so
|
||||
application code never sees it.
|
||||
@@ -0,0 +1,131 @@
|
||||
# `validation-engine.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Coordinates the four-phase validation pipeline that turns a Zod schema
|
||||
plus optional cross-field rules and async validators into a single
|
||||
`ValidationResult` consumable by react-hook-form's resolver. The
|
||||
engine is the synchronisation point that ensures phases run in the
|
||||
right order, that schema errors short-circuit cross-field rules, that
|
||||
schema or cross-field errors short-circuit async validators (no
|
||||
network calls when the form is structurally invalid), and that an
|
||||
in-flight pipeline run is cancelled before a newer one starts so
|
||||
async validators do not race to set stale errors.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-phase pipeline: Zod schema → cross-field rules → async
|
||||
validators → server-error mapping (the last phase is invoked
|
||||
separately by `useDomainForm`).
|
||||
- AbortSignal cancellation between runs — calling `resolve(values)` a
|
||||
second time aborts the first.
|
||||
- Strict precedence — earlier-phase failures take priority per field;
|
||||
later phases never overwrite an earlier failure.
|
||||
- OTel telemetry — `react_forms.validation.duration_ms` per run,
|
||||
`react_forms.validation.error_total{kind}` per failure.
|
||||
- Throws `ReactFormsError(REACT_FORMS.SCHEMA_INVALID)` from the
|
||||
constructor when the supplied schema is not a Zod schema (no
|
||||
`.safeParse` member).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { ValidationEngine } from "@suaveplan/react-forms/core/validation-engine";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
const engine = new ValidationEngine({ schema });
|
||||
const result = await engine.resolve({ email: "x", password: "short" });
|
||||
// result.values === undefined
|
||||
// result.errors.email && result.errors.password
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import {
|
||||
asyncEmailUnique,
|
||||
} from "@suaveplan/react-forms/validation/async";
|
||||
import {
|
||||
defineCrossFieldRule,
|
||||
ValidationEngine,
|
||||
violate,
|
||||
} from "@suaveplan/react-forms/core/validation-engine";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
passwordConfirm: z.string(),
|
||||
});
|
||||
|
||||
const passwordsMatch = defineCrossFieldRule({
|
||||
name: "passwords-match",
|
||||
fields: ["password", "passwordConfirm"],
|
||||
validate: ({ password, passwordConfirm }) =>
|
||||
password === passwordConfirm
|
||||
? null
|
||||
: [
|
||||
violate(
|
||||
"passwordConfirm",
|
||||
"auth.password.mismatch" as TranslationKey
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const checkUnique = asyncEmailUnique({
|
||||
check: async (email, signal) => {
|
||||
const r = await fetch(`/api/email/check?e=${email}`, {
|
||||
signal,
|
||||
});
|
||||
return r.status === 404;
|
||||
},
|
||||
});
|
||||
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
crossFieldRules: [passwordsMatch],
|
||||
asyncValidators: [{ field: "email", validator: checkUnique }],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ValidationEngine<TValues>` — pipeline coordinator.
|
||||
- `new ValidationEngine(config: ValidationEngineConfig<TValues>)` —
|
||||
throws `REACT_FORMS.SCHEMA_INVALID` on a non-Zod schema.
|
||||
- `engine.resolve(values: TValues): Promise<ValidationResult<TValues>>`
|
||||
— run the pipeline; cancels the previous run.
|
||||
- `engine.cancel(): void` — cancel any in-flight async validators
|
||||
(call on form unmount).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The engine carries its own `AbortController` instance and replaces
|
||||
it on every `resolve()` call. The previous controller is aborted
|
||||
first so async validators that observe their `signal` can short-
|
||||
circuit; validators that don't observe the signal will still
|
||||
resolve, but the engine guards via `runId` checks so their results
|
||||
are dropped.
|
||||
- Zod errors are mapped to a stable namespaced key
|
||||
(`react_forms.validation.zod.<issue.code>`) so consumer catalogs
|
||||
can register one entry per Zod issue family rather than per error.
|
||||
The original Zod message is forwarded via `messageArgs.message` so
|
||||
the catalog template can interpolate it when human copy is
|
||||
desired.
|
||||
- The engine deliberately does NOT debounce `resolve()` calls — that
|
||||
is the resolver's responsibility (RHF's `mode: "onBlur" |
|
||||
"onChange"` controls how often `resolve` is invoked). Debouncing
|
||||
is layered on top of the engine, not inside it.
|
||||
- The pipeline cancels in-flight pipelines via the `runId` discriminator
|
||||
so a slow async validator from the previous run cannot overwrite
|
||||
the current run's errors. The `runId !== this.currentRunId` check
|
||||
is what gives the engine its "stale-result safe" guarantee.
|
||||
90
docs/ui/forms/react-forms/src/errors/error-codes.md
Normal file
90
docs/ui/forms/react-forms/src/errors/error-codes.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Registers the thirteen stable error codes used by
|
||||
`@suaveplan/react-forms` under the `REACT_FORMS` namespace via
|
||||
`defineCodes` from `@suaveplan/error`. These codes are the single
|
||||
source of truth for error identity across the React forms surface —
|
||||
every `ReactFormsError` carries one of them as its `.code`, every
|
||||
spec-conformance test asserts against them, and every observability
|
||||
sink (OTel attributes, structured log fields, error-wire JSON) uses the
|
||||
fully-qualified `REACT_FORMS.<reason>` string.
|
||||
|
||||
The registry must run at module-import time so the codes are present in
|
||||
the global registry before any hook, component, or validator throws. The
|
||||
barrel imports the file as a side-effect-only entry to guarantee the
|
||||
registration sequence regardless of which subpath the consumer imports
|
||||
first.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `REACT_FORMS_CODES` record with thirteen entries —
|
||||
added/removed only through an OpenSpec change.
|
||||
- `ReactFormsCode` literal-union type derived from the registry, used as
|
||||
the `TCode` parameter of `ReactFormsError`.
|
||||
- Side-effect-free constant declaration: no logger calls, no global
|
||||
mutation beyond what `defineCodes` performs internally on the shared
|
||||
registry.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { REACT_FORMS_CODES } from "@suaveplan/react-forms/errors";
|
||||
|
||||
console.log(REACT_FORMS_CODES.VALIDATION_FAILED);
|
||||
// → "REACT_FORMS.VALIDATION_FAILED"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_FORMS_CODES,
|
||||
ReactFormsError,
|
||||
} from "@suaveplan/react-forms/errors";
|
||||
|
||||
function unwrapApi(api: unknown): unknown {
|
||||
if (api === undefined) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.API_CLIENT_NOT_FOUND,
|
||||
"react_forms.error.api_client_not_found",
|
||||
{ context: { hook: "useApiClient" } }
|
||||
);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_FORMS_CODES` — frozen `Record<Reason, "REACT_FORMS.<Reason>">`
|
||||
produced by `defineCodes("REACT_FORMS", [...])`. Reasons:
|
||||
`VALIDATION_FAILED`, `SUBMIT_FAILED`, `ASYNC_VALIDATOR_TIMEOUT`,
|
||||
`ASYNC_VALIDATOR_ABORTED`, `SERVER_ERROR_MAPPED`,
|
||||
`API_CLIENT_NOT_FOUND`, `AUTH_CONTEXT_NOT_FOUND`,
|
||||
`FIELD_KIND_INVALID`, `SCHEMA_INVALID`,
|
||||
`HARDCODED_STRING_DETECTED`, `CONTEXT_NOT_PROVIDED` (recipe needs
|
||||
a missing provider), `REPEATING_ROW_LIMIT` (`add()` past `maxRows`
|
||||
or `remove()` past `minRows`), `ASYNC_SEARCH_LOAD_FAILED`
|
||||
(async-search recipe's `load(id, signal)` rejected).
|
||||
- `ReactFormsCode` — `(typeof REACT_FORMS_CODES)[keyof typeof
|
||||
REACT_FORMS_CODES]`. Use as the `TCode` parameter of
|
||||
`SuaveplanError<TCode>` extensions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The file is intentionally tiny — its only responsibility is to call
|
||||
`defineCodes(...)` and re-export the resulting record + code union.
|
||||
Keeping it side-effect-free past the registration call lets bundlers
|
||||
prove a no-mutation tree and lets test harnesses import it without
|
||||
perturbing other modules' state.
|
||||
- `as const` on the reasons array preserves the literal types so
|
||||
`ReactFormsCode` becomes a precise discriminated union rather than a
|
||||
widened `string`.
|
||||
- The package barrel imports this file as a side-effect entry
|
||||
(`import "./errors/error-codes.js";`) so the codes register before the
|
||||
consumer's first throw site is reached.
|
||||
- New codes require an OpenSpec change — adding a code is a public-API
|
||||
change because consumers may switch on it. Removing a code is a
|
||||
breaking change.
|
||||
90
docs/ui/forms/react-forms/src/errors/react-forms-error.md
Normal file
90
docs/ui/forms/react-forms/src/errors/react-forms-error.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# `react-forms-error.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the base `ReactFormsError` class shared by every error path in
|
||||
`@suaveplan/react-forms`. Extending the project-wide `SuaveplanError`
|
||||
plugs the forms tier into the cause-chain traversal, redaction-aware
|
||||
JSON wire format, OTel span-event adapter, and i18n message-resolver
|
||||
hook implemented by `@suaveplan/error`.
|
||||
|
||||
The constructor stamps `context.tier === "react-forms"` on every
|
||||
instance so observability sinks can filter forms-layer failures without
|
||||
inspecting `error.constructor.name` (which is rewritten by minifiers).
|
||||
|
||||
## Features
|
||||
|
||||
- One concrete class for every error code in the registry — caller code
|
||||
always throws `new ReactFormsError(code, messageKey, options)`.
|
||||
- Auto-injected `context.tier === "react-forms"` for observability.
|
||||
- Generic-typed `code` field constrained to `ReactFormsCode` so
|
||||
`instanceof` narrows the union for TypeScript-aware consumers.
|
||||
- Forwards `cause`, `traceId`, `clock`, and any caller-supplied context
|
||||
unchanged through `SuaveplanError`'s wire format.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_FORMS_CODES,
|
||||
ReactFormsError,
|
||||
} from "@suaveplan/react-forms/errors";
|
||||
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_FORMS_CODES,
|
||||
ReactFormsError,
|
||||
} from "@suaveplan/react-forms/errors";
|
||||
|
||||
try {
|
||||
await submit(values);
|
||||
} catch (cause) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed",
|
||||
{
|
||||
cause,
|
||||
context: { formId: "register", attempt: 2 },
|
||||
traceId: spanContext.traceId,
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ReactFormsError extends SuaveplanError<ReactFormsCode>` — the
|
||||
base error class.
|
||||
- `new ReactFormsError(code, messageKey, options?)` — constructs an
|
||||
error.
|
||||
- `code: ReactFormsCode` — one of the values in `REACT_FORMS_CODES`.
|
||||
- `messageKey: string` — i18n catalog key resolved at render time by
|
||||
the consumer's `useErrorMessage` / `t(key)` pipeline.
|
||||
- `options?: SuaveplanErrorOptions` — optional `cause`, `context`,
|
||||
`clock`, `traceId`. The constructor merges
|
||||
`context = { tier: "react-forms", ...options.context }` so the tier
|
||||
cannot be overwritten by accident.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class is intentionally minimal — any forms-specific behaviour that
|
||||
needs to live on the error (e.g. `error.context.field`,
|
||||
`error.context.fieldErrors`) is supplied by the caller through the
|
||||
`context` option, not via subclassing. One class plus N codes keeps
|
||||
the surface auditable.
|
||||
- `messageKey` is intentionally typed as `string` rather than
|
||||
`TranslationKey`. The `TranslationKey` brand is enforced at the public
|
||||
prop boundary (validators, FieldConfig); errors thrown from internal
|
||||
defensive guards may use raw catalog keys outside the branded
|
||||
namespace.
|
||||
- The `tier: "react-forms"` stamp is the same convention used by every
|
||||
other React-tier error class in the project (e.g.
|
||||
`ReactI18nError`'s `tier: "react-i18n"`).
|
||||
@@ -0,0 +1,187 @@
|
||||
# `createDomainForm`
|
||||
|
||||
Declarative typed-form factory. Wraps `useDomainForm` + `<RHF
|
||||
FormProvider>` + the layout dispatcher into a single component while
|
||||
preserving direct access to react-hook-form via three escape
|
||||
hatches.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createDomainForm` is the factory consumers reach for to build a form
|
||||
in three lines: declare a Zod schema, declare a typed field-config
|
||||
map, declare a submit handler. The factory returns a working
|
||||
component plus a `.Field` static accessor for partial layouts. The
|
||||
underlying RHF context is never hidden — the render-prop children
|
||||
escape hatch yields the raw `useFormReturn`, the standalone
|
||||
`useDomainForm` hook is exported for consumers who need a fully
|
||||
custom shell, and `<RegisterForm.Field name="email" />` lets the
|
||||
consumer mix factory-rendered fields into their own JSX.
|
||||
|
||||
Field-config validation runs at module-evaluation time via Zod —
|
||||
misconfigurations (`select` without `options`, `currency` without
|
||||
an ISO code) throw `REACT_FORMS.FIELD_KIND_INVALID` before the
|
||||
component ever renders, so a typo never reaches a user-facing
|
||||
surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Three escape hatches: default render, render-prop children, static
|
||||
`Form.Field` accessor.
|
||||
- Built-in layouts: `vertical` (default), `horizontal`, `grid`. Plus
|
||||
a `LayoutRenderFn` escape hatch for custom JSX.
|
||||
- Auto-rendered submit footer with translated label and optional
|
||||
cancel button.
|
||||
- Wraps RHF's `<FormProvider>` automatically so consumers using
|
||||
`useFormContext()` deep in the tree work without extra wiring.
|
||||
- Passes through every option to the underlying `useDomainForm`:
|
||||
`crossFieldRules`, `asyncValidators`, `debounceMs`, `defaultValues`.
|
||||
- `data-form-id` on the rendered form so OTel-instrumented dashboards
|
||||
can correlate DOM events to telemetry counters.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { createDomainForm } from "@suaveplan/react-forms/factory/create-domain-form";
|
||||
import { RegisterInput } from "@suaveplan/api-schemas/auth/register";
|
||||
|
||||
const RegisterForm = createDomainForm({
|
||||
formId: "register",
|
||||
schema: RegisterInput,
|
||||
fields: {
|
||||
email: { kind: "email", label: "auth.email.label" as never },
|
||||
password: {
|
||||
kind: "password",
|
||||
label: "auth.password.label" as never,
|
||||
strengthMeter: true,
|
||||
},
|
||||
name: { kind: "text", label: "auth.name.label" as never },
|
||||
},
|
||||
defaultValues: { email: "", password: "", name: "" },
|
||||
submit: {
|
||||
label: "auth.register.submit" as never,
|
||||
onSubmit: async (values) => {
|
||||
await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function RegisterPage(): JSX.Element {
|
||||
return <RegisterForm />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drop down to render-prop children for a custom shell (e.g. a two-pane
|
||||
layout with a sidebar of additional context):
|
||||
|
||||
```tsx
|
||||
import { Button } from "@suaveplan/react-ui";
|
||||
|
||||
export function RegisterPageWithSidebar(): JSX.Element {
|
||||
return (
|
||||
<RegisterForm>
|
||||
{({ form, fields, submit, isSubmitting }) => (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
|
||||
<div>
|
||||
{fields.email}
|
||||
{fields.password}
|
||||
{fields.name}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</div>
|
||||
<aside data-form-pristine={String(!form.formState.isDirty)}>
|
||||
<p>By signing up you agree to the terms.</p>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</RegisterForm>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Or use the `Form.Field` static accessor for a partial layout where
|
||||
the consumer wants the factory's chrome on some fields but renders
|
||||
others themselves:
|
||||
|
||||
```tsx
|
||||
export function RegisterMixed(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<RegisterForm.Field name="email" />
|
||||
<RegisterForm.Field name="password" />
|
||||
<p>Show your name in your public profile?</p>
|
||||
<RegisterForm.Field name="name" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createDomainForm(config)`
|
||||
|
||||
Returns a `DomainFormComponent<TValues>` — a React component plus a
|
||||
`.Field` static accessor.
|
||||
|
||||
Config (`CreateDomainFormConfig<TValues>`):
|
||||
|
||||
- `formId: string` — stable identifier used in OTel attributes.
|
||||
- `schema: ZodType<TValues>` — Zod schema spine; defines `TValues`.
|
||||
- `fields: FieldConfigMap<TValues>` — per-field config map.
|
||||
- `layout?: DomainFormLayout` — `"vertical"` (default), `"horizontal"`,
|
||||
`"grid"`, or a `LayoutRenderFn`.
|
||||
- `defaultValues?: Partial<TValues>` — initial form values.
|
||||
- `submit: { label: TranslationKey; cancelLabel?: TranslationKey; onSubmit: (values: TValues) => Promise<unknown> }` — submit
|
||||
configuration.
|
||||
- `asyncValidators?: ReadonlyArray<AsyncValidatorRegistration>` —
|
||||
per-field async validators.
|
||||
- `crossFieldRules?: ReadonlyArray<CrossFieldRule<TValues>>` —
|
||||
cross-field rules.
|
||||
- `debounceMs?: number` — async-validator debounce window.
|
||||
|
||||
### `<DomainForm>` props (the returned component)
|
||||
|
||||
- `children?: DomainFormChildren` — render-prop callback. When
|
||||
supplied, the factory yields control and the default footer is
|
||||
NOT rendered.
|
||||
- `className?: string` — class applied to the underlying `<form>`.
|
||||
|
||||
### `<DomainForm.Field name>` static accessor
|
||||
|
||||
Renders a single field by name with the factory chrome. Throws
|
||||
`REACT_FORMS.FIELD_KIND_INVALID` when the name is not in the config
|
||||
map.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `validateFieldConfigMap` call lives at module-evaluation time
|
||||
inside the `createDomainForm` body so misconfigurations fail at
|
||||
import time, not on first render.
|
||||
- The `<FormProvider>` wrapper ensures hooks like `useFormContext` and
|
||||
`useWatch` work for descendants without consumers having to wire it
|
||||
themselves.
|
||||
- The factory's component does NOT memoise `useDomainForm`'s returned
|
||||
`fields` between the default render and the children render-prop
|
||||
branch — the same pre-rendered elements are passed through both
|
||||
paths via the same memoised reference held inside the hook.
|
||||
- When children are supplied, the default submit footer is omitted —
|
||||
the consumer controls the entire sub-tree below the `<form>` tag,
|
||||
including any submit button.
|
||||
- `Form.Field` calls `useDomainForm` independently to render its
|
||||
single field. This means a parent that uses both `<RegisterForm />`
|
||||
and `<RegisterForm.Field name="email" />` will instantiate two
|
||||
hook trees with independent state. For combined usage, prefer the
|
||||
render-prop children API which shares one hook instance.
|
||||
@@ -0,0 +1,120 @@
|
||||
# `field-config`
|
||||
|
||||
`FieldConfig` discriminated union, per-kind Zod schemas, and the
|
||||
`validateFieldConfig` / `validateFieldConfigMap` helpers used by the
|
||||
factory at module-evaluation time.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every form built via `createDomainForm` declares a typed
|
||||
`FieldConfigMap<TValues>` mapping field name → kind-specific config.
|
||||
The map drives label rendering, input selection, validation wiring,
|
||||
and submit payload construction. Misconfigurations (`select` without
|
||||
`options`, `currency` without an ISO code, `custom` without a
|
||||
`render` callback) MUST fail at factory-define time so a bug never
|
||||
reaches the user-facing render path. The `FieldConfigSchema` Zod
|
||||
discriminated union enforces those invariants for every kind shipped
|
||||
in Wave 5b.
|
||||
|
||||
The supported kinds in Wave 5b are: `text`, `email`, `password`,
|
||||
`number`, `currency`, `checkbox`, `toggle`, `radio`, `textarea`,
|
||||
`custom`. Deferred kinds (`date`, `dateTime`, `select`, `multi-select`,
|
||||
`combobox`, `file`, `rich-text`, `otp`) are NOT in the discriminator
|
||||
yet — they land in Wave 5c when the underlying react-ui primitives
|
||||
ship. Passing a deferred kind today fails the discriminator and
|
||||
throws `REACT_FORMS.FIELD_KIND_INVALID` with the offending field name
|
||||
on the error context.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated union over every kind so TypeScript narrows per kind.
|
||||
- Per-kind Zod schema validating the runtime shape (currency code is
|
||||
ISO 4217 length 3, radio has at least one option, custom has a
|
||||
function `render`).
|
||||
- `validateFieldConfig` throws `REACT_FORMS.FIELD_KIND_INVALID` with
|
||||
the offending field name and the structured Zod issues on the
|
||||
error context.
|
||||
- `validateFieldConfigMap` walks every entry of the map so the factory
|
||||
can fail-fast at define time.
|
||||
- `SUPPORTED_FIELD_KINDS` exposes the in-Wave kind allowlist for
|
||||
tooling / introspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateFieldConfig } from "@suaveplan/react-forms/factory/field-config";
|
||||
|
||||
const config = validateFieldConfig("email", {
|
||||
kind: "email",
|
||||
label: "auth.email.label",
|
||||
});
|
||||
console.log(config.kind); // "email"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
validateFieldConfigMap,
|
||||
type FieldConfigMap,
|
||||
} from "@suaveplan/react-forms/factory/field-config";
|
||||
|
||||
interface RegisterValues {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
const fields: FieldConfigMap<RegisterValues> = {
|
||||
email: { kind: "email", label: "auth.email.label" as never },
|
||||
password: {
|
||||
kind: "password",
|
||||
label: "auth.password.label" as never,
|
||||
strengthMeter: true,
|
||||
},
|
||||
name: { kind: "text", label: "auth.name.label" as never },
|
||||
};
|
||||
|
||||
const validated = validateFieldConfigMap(fields);
|
||||
// On failure: throws ReactFormsError(REACT_FORMS.FIELD_KIND_INVALID)
|
||||
// carrying { field, issues }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FieldConfigSchema` — Zod discriminated union over every kind.
|
||||
Use this for ad-hoc parsing if not running through the helper
|
||||
functions.
|
||||
- `validateFieldConfig(fieldName, config)` — parse a single config.
|
||||
Throws `ReactFormsError(REACT_FORMS.FIELD_KIND_INVALID)` on failure.
|
||||
Returns the parsed `FieldConfig` on success.
|
||||
- `validateFieldConfigMap(fields)` — parse every entry of a map.
|
||||
Returns the same map on success; throws on the first invalid entry.
|
||||
- `isSupportedFieldKind(kind)` — boolean predicate over the in-Wave
|
||||
kind allowlist. Useful for runtime guards in custom factory wrappers.
|
||||
- `FieldConfig`, `FieldConfigMap`, `SUPPORTED_FIELD_KINDS`,
|
||||
`SupportedFieldKind`, plus the per-kind config interfaces — declared
|
||||
in `field-config.types.ts` and re-exported via the index barrel.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Zod schemas accept plain `string` for every label / placeholder /
|
||||
helperText property because the `TranslationKey` brand exists only
|
||||
in TypeScript's type space — the brand cannot be observed at
|
||||
runtime. The compile-time check is the gate; the runtime check
|
||||
exists to catch shape misuse (missing required fields, wrong types)
|
||||
rather than translation-key registration errors.
|
||||
- The discriminator is `kind`, matching the master-design §9.5 surface
|
||||
exactly. Wave 5c will widen the discriminator without breaking
|
||||
consumers — the existing entries stay valid.
|
||||
- `custom` accepts any function as `render` because the factory only
|
||||
invokes it inside a Controller with the correct argument shape.
|
||||
Type-narrowing the function signature inside Zod produces unhelpful
|
||||
unknown-arg errors; the `CustomFieldConfig` TypeScript type is the
|
||||
consumer-facing contract.
|
||||
- The supported-kind allowlist is exported as a frozen tuple
|
||||
(`as const`) so the literal types stay narrowed.
|
||||
@@ -0,0 +1,162 @@
|
||||
# `RenderField`
|
||||
|
||||
Single-field renderer used by `useDomainForm` and `createDomainForm` to
|
||||
turn a typed `FieldConfig` into a `<Field>` chassis container paired
|
||||
with the appropriate input control.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Maps a `FieldConfig<TValues>` discriminated-union entry to a complete
|
||||
`<Field>` subtree (label / control / description / error) wired to a
|
||||
react-hook-form `UseFormReturn`. Drives the auto-render pathway used
|
||||
by both the standalone `useDomainForm` hook and the higher-level
|
||||
`createDomainForm` factory. Consumers should rarely import this
|
||||
directly — the factory is the public surface — but it is exposed via
|
||||
the `factory/render-field` subpath for advanced cases that compose
|
||||
their own layout while still wanting per-field auto-rendering.
|
||||
|
||||
The renderer wires `id`, `htmlFor`, `aria-describedby`, `aria-invalid`,
|
||||
and `data-invalid` consistently across every kind so the output passes
|
||||
WCAG 2.2 AA without consumer wiring. It uses RHF's `register` for
|
||||
plain inputs (text, email, password, number, textarea) and
|
||||
`Controller` for inputs with non-DOM event shapes (currency,
|
||||
checkbox, toggle, radio, custom).
|
||||
|
||||
## Features
|
||||
|
||||
- One JSX subtree per Wave 5b field kind: text, email, password,
|
||||
number, currency, checkbox, toggle, radio, textarea, custom.
|
||||
- Consistent ARIA wiring: `htmlFor`, `aria-describedby`, `aria-invalid`,
|
||||
`data-invalid`, `data-disabled`, `role="alert"` on errors.
|
||||
- Inline `<Translated>` helper resolves every `TranslationKey` against
|
||||
the active i18n at render time so no consumer has to thread `t`
|
||||
through the renderer.
|
||||
- Honours `config.disabled` everywhere — register-bound inputs receive
|
||||
`{ disabled: true }`, controller-bound inputs forward the prop.
|
||||
- `data-strength-meter="true"` written on password fields whose config
|
||||
declares `strengthMeter: true` so a sibling visualiser can subscribe
|
||||
to the value via `useFormContext()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { RenderField } from "@suaveplan/react-forms/factory/create-domain-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface Values {
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
function EmailFieldShell(): JSX.Element {
|
||||
const form = useForm<Values>({ defaultValues: { email: "" } });
|
||||
return (
|
||||
<RenderField
|
||||
name="email"
|
||||
config={{ kind: "email", label: "auth.email.label" as never }}
|
||||
form={form}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive a multi-section layout by selecting which fields to render
|
||||
inline vs which to render in a sidebar:
|
||||
|
||||
```tsx
|
||||
import { RenderField } from "@suaveplan/react-forms/factory/create-domain-form";
|
||||
import { FieldGroup } from "@suaveplan/react-ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface Values {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly captcha: string;
|
||||
}
|
||||
|
||||
const fields = {
|
||||
email: { kind: "email" as const, label: "auth.email.label" as never },
|
||||
password: {
|
||||
kind: "password" as const,
|
||||
label: "auth.password.label" as never,
|
||||
strengthMeter: true,
|
||||
},
|
||||
captcha: {
|
||||
kind: "custom" as const,
|
||||
label: "auth.captcha.label" as never,
|
||||
render: ({ field }: { readonly field: { readonly name: string } }) => (
|
||||
<div data-captcha={field.name} />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export function SplitLayout(): JSX.Element {
|
||||
const form = useForm<Values>({
|
||||
defaultValues: { email: "", password: "", captcha: "" },
|
||||
});
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "1rem" }}>
|
||||
<FieldGroup style={{ flex: 1 }}>
|
||||
<RenderField name="email" config={fields.email} form={form} />
|
||||
<RenderField
|
||||
name="password"
|
||||
config={fields.password}
|
||||
form={form}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<aside>
|
||||
<RenderField
|
||||
name="captcha"
|
||||
config={fields.captcha}
|
||||
form={form}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<RenderField>` (component)
|
||||
|
||||
Props:
|
||||
|
||||
- `name: string` — field name (must match a key in `form`'s value
|
||||
shape).
|
||||
- `config: FieldConfig<TValues>` — discriminated-union field config.
|
||||
- `form: UseFormReturn<TValues>` — RHF context returned by
|
||||
`useDomainForm` or `useForm`.
|
||||
|
||||
The component subscribes to `form.formState` via
|
||||
`form.getFieldState(name, formState)` so error messages and
|
||||
`data-invalid` flags re-render on the same React commit cycle as the
|
||||
underlying state change.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The renderer uses RHF's `register` for plain inputs whose native
|
||||
`change` events flow through normally (text, email, password,
|
||||
number, textarea). `Controller` is used for inputs with non-DOM
|
||||
event shapes: currency (number-shaped value), checkbox / toggle
|
||||
(boolean), radio (string), and custom (consumer-defined).
|
||||
- The internal `<Translated>` helper is co-located rather than
|
||||
factored out so the renderer file remains self-contained and the
|
||||
`useTranslation()` subscription is established once per field.
|
||||
- `aria-describedby` chains description and error ids when both are
|
||||
present (`"field-x-description field-x-error"`); when only one is
|
||||
active, the chain collapses to that single id. This matches the
|
||||
WAI-ARIA APG recommendation for grouped helper text + alerts.
|
||||
- The renderer never reads `config.errorMessage` directly — RHF's
|
||||
`fieldState.error?.message` is the source of truth for the
|
||||
rendered error text. The validator suite produces `messageKey`s
|
||||
that flow through unchanged.
|
||||
- The custom renderer cast on `render({ field, fieldState })` returns
|
||||
`ReactNode` cast to `ReactElement` because `Controller`'s
|
||||
`RenderFunction` requires `ReactElement`. Consumer functions are
|
||||
free to return any `ReactNode`; the wrapper coerces.
|
||||
72
docs/ui/forms/react-forms/src/locales/augmentation.md
Normal file
72
docs/ui/forms/react-forms/src/locales/augmentation.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# `augmentation` — `ContributedTranslationKeys` registration
|
||||
|
||||
## Purpose
|
||||
|
||||
Side-effect-only TypeScript module that augments
|
||||
`@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with
|
||||
every key shipped by `@suaveplan/react-forms`. The augmentation runs
|
||||
purely in TypeScript's type space — there is no runtime value to
|
||||
import — but the file is included as a side-effect import from
|
||||
`src/locales/locales.ts` so consumers' `tsconfig` picks up the
|
||||
declaration when they `import "@suaveplan/react-forms"`.
|
||||
|
||||
The augmentation widens the branded `TranslationKey` union so a
|
||||
recipe's `next` button label is statically known to be a registered
|
||||
key. Misuse (`labels: { next: "typo" }`) is a TypeScript error before
|
||||
the form ever mounts.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure type-space augmentation — zero bytes of JavaScript runtime cost.
|
||||
- Mirrors the keys in the generated `augmentation.d.ts` exactly so
|
||||
side-effect import paths and declaration paths stay consistent.
|
||||
- Imported as a side effect from `src/locales/locales.ts` so the
|
||||
package barrel carries the augmentation transitively.
|
||||
- Empty runtime export keeps tooling that strips type-only files from
|
||||
side-effect chains honest.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
The augmentation is automatically applied when the package is imported.
|
||||
No explicit usage is required.
|
||||
|
||||
```ts
|
||||
import "@suaveplan/react-forms";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const k: TranslationKey = "react-forms.wizard.next"; // ✅ typechecks
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
If the consumer is building a server-side helper that does not import
|
||||
the package barrel but still wants the augmentation widened, pull the
|
||||
augmentation alone:
|
||||
|
||||
```ts
|
||||
import "@suaveplan/react-forms/locales/augmentation";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
declare const fromConfig: string;
|
||||
const key = fromConfig as TranslationKey;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This module exports a single empty object literal (`export {}`) so the
|
||||
TypeScript compiler treats it as a module and applies the
|
||||
`declare module` augmentation. There are no runtime exports.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The file MUST stay in sync with `augmentation.d.ts`; both are
|
||||
generated from `en-GB.json` by `bun
|
||||
packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
packages/react/react-forms --package-name @suaveplan/react-forms`.
|
||||
- Two files exist (one `.ts`, one `.d.ts`) so consumers' bundlers do
|
||||
not strip the runtime side-effect even when declaration-only files
|
||||
are filtered out by aggressive tree-shaking.
|
||||
- The unique-symbol brand on `ContributedTranslationKeys` ensures the
|
||||
augmentation surface stays mergeable across packages — never declare
|
||||
a `type ContributedTranslationKeys = { ... }`; it MUST stay an
|
||||
`interface`.
|
||||
105
docs/ui/forms/react-forms/src/locales/locales.md
Normal file
105
docs/ui/forms/react-forms/src/locales/locales.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Locales
|
||||
|
||||
Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The package owns a small set of user-facing strings used by the recipes
|
||||
(`Next`, `Back`, `Submit`, `Add`, `Remove`, `Loading…`, `Failed to
|
||||
load`). The strings ship as flat-key JSON bundles in five locales
|
||||
(`en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`) and are merged into the
|
||||
consumer's `I18nInstance.catalog` at provider mount.
|
||||
|
||||
Consumers MAY override any key via `<TranslationProvider>`'s catalog
|
||||
merging or via the recipe factory's per-recipe `labels` prop.
|
||||
|
||||
## Features
|
||||
|
||||
- Flat-key bundles addressable by `react-forms.<recipe>.<key>`
|
||||
- Lazy loaders (one dynamic import per locale) so only the active
|
||||
locale's JSON ships in the consumer's main bundle
|
||||
- Type-narrowed via the generated `augmentation.d.ts` so misuse fails
|
||||
at compile time
|
||||
- Sorted key list (`REACT_FORMS_BUNDLE_KEYS`) for parity tests
|
||||
- One source of truth (`en-GB.json`); other locales are kept parity-
|
||||
complete by `scripts/check-locale-completeness.ts`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_FORMS_BUNDLE_KEYS,
|
||||
reactFormsBundles,
|
||||
} from "@suaveplan/react-forms";
|
||||
|
||||
const en = await reactFormsBundles["en-GB"]();
|
||||
console.log(Object.keys(en).length); // 7
|
||||
console.log(REACT_FORMS_BUNDLE_KEYS[0]); // "react-forms.asyncSearch.error"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire the bundles into the active `I18nInstance` so recipe defaults
|
||||
resolve correctly. The pattern mirrors `react-ui`'s `ReactUiProvider`
|
||||
flat-to-nested merging step:
|
||||
|
||||
```ts
|
||||
import { reactFormsBundles } from "@suaveplan/react-forms";
|
||||
import type { I18nInstance } from "@suaveplan/i18n/plugin";
|
||||
|
||||
function nestFlat(bag: Record<string, string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(bag)) {
|
||||
const parts = k.split(".");
|
||||
let cur: Record<string, unknown> = out;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const seg = parts[i] as string;
|
||||
if (typeof cur[seg] !== "object" || cur[seg] === null) {
|
||||
cur[seg] = {};
|
||||
}
|
||||
cur = cur[seg] as Record<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1] as string] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function registerReactFormsLocales(
|
||||
i18n: I18nInstance
|
||||
): Promise<void> {
|
||||
for (const [locale, loader] of Object.entries(reactFormsBundles)) {
|
||||
const bundle = await loader();
|
||||
i18n.catalog.mergeMessages(
|
||||
locale,
|
||||
nestFlat(bundle as Record<string, string>)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SupportedFormsLocale` — `"en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU"`. The five locales the package ships translations for.
|
||||
- `reactFormsBundles` — `Readonly<Record<SupportedFormsLocale, () => Promise<FormsLocaleBundle>>>`. Frozen map of locale → async loader returning the flat key/value bag for that locale. Loaders are lazy so consumers only chunk the active locale.
|
||||
- `FormsLocaleBundle` — `Readonly<Record<string, string>>`. Shape of a single locale's bundle.
|
||||
- `REACT_FORMS_BUNDLE_KEYS` — `ReadonlyArray<string>`. Sorted list of every key in `en-GB.json` for parity tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bundles are imported via dynamic JSON imports so the bundler can
|
||||
tree-shake unused locales when consumers split per language route.
|
||||
- The augmentation lives in two files (`augmentation.ts` + the
|
||||
generated `augmentation.d.ts`) so a side-effect runtime import
|
||||
carries the augmentation through the package barrel even when
|
||||
declaration-only files are pruned by aggressive bundlers.
|
||||
- `Object.freeze` plus `Readonly<...>` annotations prevent accidental
|
||||
mutation by consumers — the bundles MUST be treated as immutable.
|
||||
- Adding a key requires updating ALL FIVE locale bundles + re-running
|
||||
the codegen (`bun packages/react/react-i18n/scripts/generate-translation-types.ts packages/react/react-forms --package-name @suaveplan/react-forms`)
|
||||
- duplicating the entry into `augmentation.ts`. The
|
||||
`check-locale-completeness.ts` CI gate fails the build if any locale
|
||||
drifts.
|
||||
76
docs/ui/forms/react-forms/src/locales/translation-keys.md
Normal file
76
docs/ui/forms/react-forms/src/locales/translation-keys.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# `translation-keys` — codegen union of contributed keys
|
||||
|
||||
## Purpose
|
||||
|
||||
`translation-keys.ts` is the codegen-emitted string-literal union of
|
||||
every translation key contributed by `@suaveplan/react-forms`. It is
|
||||
regenerated from `src/locales/en-GB.json` by `bun
|
||||
packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
packages/react/react-forms --package-name @suaveplan/react-forms` and
|
||||
committed verbatim so consumers and CI can statically reference the
|
||||
union without re-running the codegen.
|
||||
|
||||
The union is useful for non-React consumers (universal helpers,
|
||||
server-side validators) that want a typed reference to the
|
||||
forms-contributed keys without taking on the full
|
||||
`@suaveplan/react-i18n` `ContributedTranslationKeys` augmentation
|
||||
pathway. React consumers should prefer the branded `TranslationKey`
|
||||
type from `@suaveplan/react-i18n` instead — that type widens
|
||||
automatically as more packages augment the interface and is the type
|
||||
the recipe and factory props use.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-purpose: re-declares every key in `en-GB.json` as a string-
|
||||
literal union.
|
||||
- Sorted alphabetically via `localeCompare("en")` for diff stability.
|
||||
- Generated comment header documents the regeneration command so the
|
||||
file does not drift on hand-edit.
|
||||
- Side-effect-free — no runtime code, no imports from runtime packages.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { ReactFormsTranslationKey } from "@suaveplan/react-forms";
|
||||
|
||||
declare const key: ReactFormsTranslationKey;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { ReactFormsTranslationKey } from "@suaveplan/react-forms";
|
||||
|
||||
const RECIPE_LABELS: Record<string, ReactFormsTranslationKey> = {
|
||||
next: "react-forms.wizard.next",
|
||||
back: "react-forms.wizard.back",
|
||||
submit: "react-forms.wizard.submit",
|
||||
add: "react-forms.repeating.add",
|
||||
remove: "react-forms.repeating.remove",
|
||||
loading: "react-forms.asyncSearch.loading",
|
||||
error: "react-forms.asyncSearch.error",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ReactFormsTranslationKey`
|
||||
|
||||
String-literal union of every (flattened) key in
|
||||
`src/locales/en-GB.json`. Sorted alphabetically; deterministic across
|
||||
regeneration runs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The file is regenerated by codegen — never hand-edit. Re-run `bun
|
||||
packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
packages/react/react-forms --package-name @suaveplan/react-forms`
|
||||
after every change to `en-GB.json` and commit the regenerated file.
|
||||
- The sibling `augmentation.d.ts` (also generated by the same codegen)
|
||||
extends the runtime-typed `ContributedTranslationKeys` interface that
|
||||
`TranslationKey` reads. The two files always carry the same set of
|
||||
keys; the union here is a convenience export for non-React consumers,
|
||||
the augmentation is the default type integration.
|
||||
- The output ordering uses `localeCompare("en")` so the union is byte-
|
||||
identical for identical input regardless of `en-GB.json`'s in-file
|
||||
key insertion order.
|
||||
@@ -0,0 +1,115 @@
|
||||
# `react-forms-metrics.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Owns the OpenTelemetry instrument set for `@suaveplan/react-forms`. Six
|
||||
instruments — three counters and three histograms — cover every form
|
||||
lifecycle event the engine, the async-validator queue, and the submit
|
||||
pipeline emit. Built lazily so the package pays zero overhead when no
|
||||
`MeterProvider` is registered, but always emits through the OTel API
|
||||
when one is.
|
||||
|
||||
The module also keeps a small in-memory state ring so spec-conformance
|
||||
tests can assert exact attribute payloads without standing up a full
|
||||
SDK pipeline. The ring is process-local; consumers running multiple
|
||||
isolated forms in the same process should call
|
||||
`resetReactFormsMetricsState()` between cases.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy meter — `metrics.getMeter("suaveplan.react-forms")` is invoked
|
||||
on first use, then cached.
|
||||
- Counters: `react_forms.submit.total`,
|
||||
`react_forms.async_validator.total`,
|
||||
`react_forms.validation.error_total`.
|
||||
- Histograms: `react_forms.submit.duration_ms`,
|
||||
`react_forms.async_validator.duration_ms`,
|
||||
`react_forms.validation.duration_ms`.
|
||||
- In-memory ring per instrument so tests can assert attributes without
|
||||
an SDK exporter.
|
||||
- Stable contract — instrument names + attribute keys are part of the
|
||||
package's public surface.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordSubmitDuration,
|
||||
recordSubmitOutcome,
|
||||
} from "@suaveplan/react-forms/observability";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await submitForm(values);
|
||||
recordSubmitOutcome({ form: "register", outcome: "success" });
|
||||
} catch (cause) {
|
||||
recordSubmitOutcome({ form: "register", outcome: "error" });
|
||||
throw cause;
|
||||
} finally {
|
||||
recordSubmitDuration(performance.now() - start, { form: "register" });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getReactFormsMetricsState,
|
||||
recordAsyncValidatorDuration,
|
||||
recordAsyncValidatorOutcome,
|
||||
recordValidationError,
|
||||
resetReactFormsMetricsState,
|
||||
} from "@suaveplan/react-forms/observability";
|
||||
|
||||
resetReactFormsMetricsState();
|
||||
recordAsyncValidatorOutcome({
|
||||
validator: "asyncEmailUnique",
|
||||
outcome: "aborted",
|
||||
});
|
||||
recordAsyncValidatorDuration(42, { validator: "asyncEmailUnique" });
|
||||
recordValidationError({ kind: "async" });
|
||||
|
||||
const state = getReactFormsMetricsState();
|
||||
state.asyncValidatorTotal[0]?.attributes; // { validator, outcome }
|
||||
state.validationErrorTotal[0]?.attributes; // { kind: "async" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getReactFormsMetrics(): CachedInstruments` — return cached counters
|
||||
- histograms; build them on first call.
|
||||
- `recordSubmitOutcome({ form, outcome })` — increment the
|
||||
`react_forms.submit.total` counter and push a sample.
|
||||
- `recordSubmitDuration(durationMs, { form })` — record on
|
||||
`react_forms.submit.duration_ms`.
|
||||
- `recordAsyncValidatorOutcome({ validator, outcome })` — increment
|
||||
`react_forms.async_validator.total`.
|
||||
- `recordAsyncValidatorDuration(durationMs, { validator })` — record
|
||||
on `react_forms.async_validator.duration_ms`.
|
||||
- `recordValidationError({ kind })` — increment
|
||||
`react_forms.validation.error_total` with `kind` in
|
||||
`{schema, async, cross-field, server}`.
|
||||
- `recordValidationDuration(durationMs)` — record on
|
||||
`react_forms.validation.duration_ms`.
|
||||
- `getReactFormsMetricsState(): ReactFormsMetricsState` — read the
|
||||
in-memory ring.
|
||||
- `resetReactFormsMetricsState(): void` — clear the ring.
|
||||
- `resetReactFormsMetricsCache(): void` — drop the cached meter so a
|
||||
fresh one is built (test-only).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cache uses a closure-scoped `let cached` rather than a module
|
||||
constant so the test-only `resetReactFormsMetricsCache()` can invalidate
|
||||
it. Production code never calls the reset path.
|
||||
- Attribute keys are flat — `form`, `outcome`, `validator`, `kind` — to
|
||||
match dashboard-friendly conventions and avoid nested attributes that
|
||||
many OTel exporters serialise inconsistently.
|
||||
- Outcome unions (`SubmitOutcome`, `AsyncValidatorOutcome`) and
|
||||
`ValidationErrorKind` are exported so consumers wiring custom dashboards
|
||||
can typecheck against the same allowlist the package uses.
|
||||
- Renaming an instrument is a breaking change because consumer dashboards
|
||||
query by name. Per AGENTS.md §13a a migration note is required.
|
||||
121
docs/ui/forms/react-forms/src/primitives/field/field.md
Normal file
121
docs/ui/forms/react-forms/src/primitives/field/field.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `field` — single-source re-export of react-ui's Field family
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`primitives/field` re-exports the `<Field>`, `<FieldLabel>`,
|
||||
`<FieldDescription>`, `<FieldError>`, `<FieldGroup>`, `<FieldSet>`, and
|
||||
`<FieldLegend>` primitives from `@suaveplan/react-ui` so consumers
|
||||
building forms have a single, semantically-named import path. The
|
||||
underlying components are owned and tested inside `react-ui`; this
|
||||
module only forwards them so the forms package never duplicates the
|
||||
chassis surface.
|
||||
|
||||
The contract is identity-equality: `field.test.ts` asserts that every
|
||||
re-exported symbol is reference-equal to the upstream symbol. Adding a
|
||||
local wrapper would break the contract and silently fork the
|
||||
underlying behaviour — never wrap, always re-export.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-exports six React component references plus their TypeScript
|
||||
prop types, identity-equal to the `react-ui` originals.
|
||||
- Zero runtime cost — pure barrel export, no JS additions.
|
||||
- Maintains a single upgrade vector: bumping the workspace `react-ui`
|
||||
dep updates the field surface here automatically.
|
||||
- Sibling `.test.ts` enforces the identity-equality contract so an
|
||||
accidental wrapper rewrite fails the suite.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@suaveplan/react-forms/primitives/field";
|
||||
import { Input } from "@suaveplan/react-ui";
|
||||
|
||||
export function EmailField(): JSX.Element {
|
||||
return (
|
||||
<Field invalid={false}>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" type="email" />
|
||||
<FieldDescription>We never share your address.</FieldDescription>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire a fieldset of grouped fields using the `<FieldSet>` and
|
||||
`<FieldLegend>` primitives so the rendered tree carries the correct
|
||||
semantic structure for assistive technologies:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@suaveplan/react-forms/primitives/field";
|
||||
import { Input } from "@suaveplan/react-ui";
|
||||
|
||||
export function AddressFieldset(): JSX.Element {
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend>Postal address</FieldLegend>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="line1">Address line 1</FieldLabel>
|
||||
<Input id="line1" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="line2">Address line 2</FieldLabel>
|
||||
<Input id="line2" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="city">City</FieldLabel>
|
||||
<Input id="city" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Every export is a re-export of the same-named symbol from
|
||||
`@suaveplan/react-ui`. Refer to the upstream documentation for the
|
||||
canonical prop tables; this section enumerates only the available
|
||||
identifiers.
|
||||
|
||||
- `Field` — component. Container with `data-invalid` /
|
||||
`data-disabled` attributes.
|
||||
- `FieldLabel` — component. `htmlFor`-bound label primitive.
|
||||
- `FieldDescription` — component. Muted helper text rendered as `<p>`.
|
||||
- `FieldError` — component. Destructive helper with `role="alert"`.
|
||||
- `FieldGroup` — component. Vertical stack container.
|
||||
- `FieldSet` — component. Semantic `<fieldset>` wrapper.
|
||||
- `FieldLegend` — component. Semantic `<legend>` wrapper.
|
||||
- `FieldProps`, `FieldLabelProps`, `FieldDescriptionProps`,
|
||||
`FieldErrorProps`, `FieldGroupProps`, `FieldSetProps`,
|
||||
`FieldLegendProps` — TypeScript types matching each component.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The module never adds local wrappers. If a future feature requires
|
||||
a forms-only `Field` variant, ship it under a new feature directory
|
||||
(`primitives/<variant>/`) rather than modifying this re-export.
|
||||
- Identity equality is asserted in the sibling `.test.ts` so any drift
|
||||
(e.g. accidentally wrapping `Field` with a memoised proxy) fails CI
|
||||
before publish.
|
||||
- The barrel `index.ts` exports the same symbols so consumers can pick
|
||||
either subpath (`@suaveplan/react-forms/primitives/field`) or the
|
||||
package barrel (`@suaveplan/react-forms`).
|
||||
@@ -0,0 +1,128 @@
|
||||
# `MoneyInput`
|
||||
|
||||
Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Render a numeric input that displays its value as locale-formatted
|
||||
currency (`£12,345.67`, `12 345,67 €`, `¥12,345`) while keeping the
|
||||
public API a plain `number | null`. The component reads the active
|
||||
i18n locale from `useLocale()` so locale switches re-render the input
|
||||
in the new locale automatically. Parsing on blur (or per-keystroke,
|
||||
configurable) converts the user's typed text back into a JavaScript
|
||||
number for the consumer's `onChange` callback.
|
||||
|
||||
The component is fully controlled — the consumer owns the value. No
|
||||
hidden state is held across renders other than a transient editing
|
||||
buffer used between focus and blur so the user can type freely
|
||||
without the formatter rewriting their caret position.
|
||||
|
||||
## Features
|
||||
|
||||
- Locale-aware formatting via `@suaveplan/i18n`'s `formatCurrency`.
|
||||
- ISO 4217 currency code drives both display and the precision of the
|
||||
value reported back to `onChange`.
|
||||
- Two commit modes: `keystroke` (default, fires on every change) and
|
||||
`blur` (fires only when the input loses focus).
|
||||
- Subscribed to `useLocale()` so a `setLocale()` in any ancestor
|
||||
re-renders the displayed text without remounting.
|
||||
- Fully type-safe — `placeholder` is typed as `TranslationKey` so a
|
||||
hardcoded English literal is a compile error.
|
||||
- Accessible — `inputMode="decimal"` triggers the numeric keyboard on
|
||||
mobile; the underlying `<Input>` carries the chassis a11y wiring.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { MoneyInput } from "@suaveplan/react-forms/primitives/money-input";
|
||||
|
||||
function PriceInput(): JSX.Element {
|
||||
const [price, setPrice] = useState<number | null>(null);
|
||||
return <MoneyInput value={price} currency="GBP" onChange={setPrice} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Defer commits until blur, pin the locale to French, and surface the
|
||||
parsed value back to a react-hook-form `Controller`:
|
||||
|
||||
```tsx
|
||||
import { Controller, type Control } from "react-hook-form";
|
||||
import { MoneyInput } from "@suaveplan/react-forms/primitives/money-input";
|
||||
|
||||
interface OrderForm {
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
export function OrderTotalField(props: {
|
||||
readonly control: Control<OrderForm>;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Controller
|
||||
control={props.control}
|
||||
name="total"
|
||||
render={({ field }) => (
|
||||
<MoneyInput
|
||||
value={field.value ?? null}
|
||||
currency="EUR"
|
||||
locale="fr-FR"
|
||||
commitMode="blur"
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<MoneyInput>` (component)
|
||||
|
||||
Props (extends `InputProps` minus `value`, `defaultValue`, `onChange`,
|
||||
`type`, `inputMode`, `placeholder`):
|
||||
|
||||
- `value: number | null` — current amount (`null` is empty).
|
||||
- `currency: string` — ISO 4217 code (`"GBP"`, `"USD"`, ...).
|
||||
- `locale?: string` — BCP-47 override; defaults to the active i18n
|
||||
locale resolved via `useLocale()`.
|
||||
- `placeholder?: TranslationKey` — empty-state placeholder, typed.
|
||||
- `onChange: (value: number | null) => void` — committed value
|
||||
callback. `null` when the user clears the field.
|
||||
- `commitMode?: "keystroke" | "blur"` — when `onChange` fires.
|
||||
Defaults to `"keystroke"`.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- Non-finite values (`NaN`, `Infinity`) supplied via `value` are
|
||||
coerced to `null` at render time.
|
||||
- The component never overwrites the user's typing while focused —
|
||||
prop changes during edit are deferred until blur.
|
||||
- Parsing tolerates locale-specific decimal separators (`.`, `,`, `٫`)
|
||||
and grouping separators (space, NBSP, apostrophe, `.`, `,`).
|
||||
- Sign markers `-`, `+`, and the Unicode minus `−` are all parsed
|
||||
correctly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The editing buffer is rendered as a plain `<input type="text"
|
||||
inputMode="decimal">` rather than `<input type="number">` because
|
||||
the latter rejects locale separators in many browsers.
|
||||
- `data-currency={currency}` is written onto the rendered element so
|
||||
consumers can style by currency without re-passing the prop.
|
||||
- The decimal-separator detector caches `Intl.NumberFormat` lookups
|
||||
per-locale so a re-rendering form does not re-instantiate the
|
||||
formatter on every keystroke.
|
||||
- The component is `forwardRef`-bound so consumers can focus / select
|
||||
it from the parent (e.g. `useEffect(() => ref.current?.focus(), [])`
|
||||
on mount).
|
||||
- Currency display formatting always uses the i18n `Intl.NumberFormat`
|
||||
defaults — to override fraction digits, wrap the component or use
|
||||
the underlying `formatCurrency` utility directly inside a custom
|
||||
primitive.
|
||||
@@ -0,0 +1,150 @@
|
||||
# `createAsyncSearchForm`
|
||||
|
||||
Async-prefilled form recipe. Resolves an entity by `id` via the
|
||||
supplied `load(id, signal)` callback, then mounts the underlying
|
||||
`createDomainForm` with the resolved values as `defaultValues`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The canonical "edit by id" pattern: given a stable identifier (URL
|
||||
slug, list-row click), fetch the underlying entity, then render a
|
||||
form prefilled with the entity's current state. The recipe handles
|
||||
the three-phase lifecycle (`loading`, `error`, `ready`) so consumers
|
||||
do not have to wire `useEffect` + `AbortController` + `useState`
|
||||
themselves on every page.
|
||||
|
||||
The `signal` passed to `load` is aborted on unmount AND when the
|
||||
`id` prop changes mid-load, so a fast list of entity-edit pages
|
||||
does not race stale fetches against the active form. The error
|
||||
phase renders a translated `role="alert"` banner so screen readers
|
||||
announce the failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-phase lifecycle (`loading` → `ready` OR `loading` → `error`).
|
||||
- AbortSignal threaded through `load(id, signal)`; auto-aborted on
|
||||
unmount + id change.
|
||||
- Default loading shell uses `react-ui`'s `<Skeleton>` so the
|
||||
placeholder looks the same as every other loading region in the
|
||||
app.
|
||||
- Error region renders as `role="alert"` with translated text so
|
||||
assistive tech announces the failure.
|
||||
- Loading + error labels are TranslationKey-typed and overridable.
|
||||
- Built on `createDomainForm` so the inner form inherits every
|
||||
factory feature (escape hatches, layout, OTel, server-error
|
||||
mapping).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { createAsyncSearchForm } from "@suaveplan/react-forms/recipes/async-search";
|
||||
import z from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
const EditUserForm = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: { kind: "email", label: "user.email.label" as never },
|
||||
name: { kind: "text", label: "user.name.label" as never },
|
||||
},
|
||||
load: async (id, signal) => {
|
||||
const res = await fetch(`/api/users/${id}`, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as { email: string; name: string };
|
||||
},
|
||||
submit: {
|
||||
label: "user.save" as never,
|
||||
onSubmit: async (values) => {
|
||||
await fetch(`/api/users/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function UserEditPage(props: { readonly id: string }): JSX.Element {
|
||||
return <EditUserForm id={props.id} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Provide a custom loading skeleton sized to the form's layout:
|
||||
|
||||
```tsx
|
||||
import { Skeleton } from "@suaveplan/react-ui";
|
||||
|
||||
export function UserEditPageWithCustomLoader(props: {
|
||||
readonly id: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<EditUserForm
|
||||
id={props.id}
|
||||
loadingFallback={
|
||||
<div style={{ display: "grid", gap: "1rem" }}>
|
||||
<Skeleton style={{ height: "3rem" }} />
|
||||
<Skeleton style={{ height: "3rem" }} />
|
||||
<Skeleton style={{ height: "2rem", width: "30%" }} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createAsyncSearchForm(config)`
|
||||
|
||||
- `formId: string` — stable id for OTel attributes.
|
||||
- `schema: ZodType<TValues>` — Zod schema spine for the inner form.
|
||||
- `fields: FieldConfigMap<TValues>` — per-field config map.
|
||||
- `load: (id: string, signal: AbortSignal) => Promise<TValues>` —
|
||||
async loader; receives an AbortSignal that is aborted on unmount
|
||||
- id change.
|
||||
- `submit: { label: TranslationKey; cancelLabel?: TranslationKey; onSubmit: (values: TValues) => Promise<unknown> }` — submit
|
||||
configuration mirroring `createDomainForm`'s shape.
|
||||
- `labels?: { loading?: TranslationKey; error?: TranslationKey }` —
|
||||
override the bundled defaults.
|
||||
|
||||
### `<AsyncSearchForm>` props
|
||||
|
||||
- `id: string` — entity identifier passed to `load`.
|
||||
- `loadingFallback?: ReactNode` — replaces the default Skeleton shell.
|
||||
- `className?: string` — class on the rendered container.
|
||||
|
||||
### `AsyncSearchPhase`
|
||||
|
||||
`"loading" | "error" | "ready"` — exported for tooling that wants
|
||||
to observe the lifecycle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The recipe constructs the inner `createDomainForm` lazily inside
|
||||
`useMemo`, gated by `phase === "ready"`. This means the inner
|
||||
form's hooks only mount once the data is available — so RHF's
|
||||
`defaultValues` is the source of truth (it does not flicker
|
||||
between empty and populated).
|
||||
- An `AbortController` is created per id-prop change and stored in
|
||||
the effect's cleanup so unmount + id change both abort the
|
||||
signal cleanly. The `requestRef` guards against late-resolving
|
||||
fetches that should not update state for a superseded id.
|
||||
- The error region intentionally does not surface the underlying
|
||||
error message — only a translated label. Internal failures
|
||||
(`cause`) flow through error-tracking middleware (OTel + the
|
||||
package's `recordSubmitError` helpers) so consumers debug via
|
||||
observability sinks, not via user-facing strings.
|
||||
- The recipe re-creates the inner form factory on every successful
|
||||
load. This is OK because `createDomainForm` is cheap (it returns
|
||||
a function component); the alternative — a single factory with
|
||||
controlled defaultValues — fights RHF's `defaultValues` semantics.
|
||||
122
docs/ui/forms/react-forms/src/recipes/conditional/conditional.md
Normal file
122
docs/ui/forms/react-forms/src/recipes/conditional/conditional.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# `ConditionalField`
|
||||
|
||||
Show / hide form fields based on a predicate over the parent form's
|
||||
values. When the gate flips closed the recipe unregisters the named
|
||||
fields from RHF so their values disappear from the submit payload
|
||||
and any prior errors are cleared.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Conditional fields are the canonical pattern for "depending on the
|
||||
selected payment method, show the bank account section" — fields
|
||||
whose presence is a function of other field values. The recipe
|
||||
subscribes to the parent form via RHF's `useWatch` and renders the
|
||||
children only when the supplied `when(values)` predicate returns
|
||||
true. When the predicate flips from `true` to `false` the named
|
||||
fields are explicitly `unregister`ed so they do not pollute the
|
||||
submitted payload AND any errors set on them are cleared so they do
|
||||
not block submission.
|
||||
|
||||
The recipe MUST be rendered inside a `<FormProvider>` ancestor.
|
||||
Missing-provider use cases throw `REACT_FORMS.CONTEXT_NOT_PROVIDED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Predicate-driven conditional rendering with `useWatch`-based
|
||||
subscription.
|
||||
- Explicit `unregister` + `clearErrors` on close so the form state
|
||||
matches the visible UI exactly.
|
||||
- Optional `watch` array as a performance hint — the recipe only
|
||||
re-evaluates the predicate when one of the listed fields changes.
|
||||
- Children may be a static node OR a render-prop receiving the
|
||||
current form values (for cheap derived UI).
|
||||
- Throws on missing provider so misuse surfaces immediately.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { ConditionalField } from "@suaveplan/react-forms/recipes/conditional";
|
||||
|
||||
interface CheckoutValues {
|
||||
paymentMethod: "card" | "transfer";
|
||||
bankAccount?: string;
|
||||
cardNumber?: string;
|
||||
}
|
||||
|
||||
function ConditionalSection(): JSX.Element {
|
||||
return (
|
||||
<ConditionalField<CheckoutValues>
|
||||
when={(v) => v.paymentMethod === "transfer"}
|
||||
names={["bankAccount"]}
|
||||
watch={["paymentMethod"]}
|
||||
>
|
||||
<input name="bankAccount" />
|
||||
</ConditionalField>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the render-prop form to show derived UI based on the current
|
||||
form values:
|
||||
|
||||
```tsx
|
||||
function PaymentSummary(): JSX.Element {
|
||||
return (
|
||||
<ConditionalField<CheckoutValues>
|
||||
when={(v) => v.paymentMethod === "card"}
|
||||
names={["cardNumber"]}
|
||||
>
|
||||
{(values) => (
|
||||
<div data-card-summary>
|
||||
Card ending in
|
||||
{values.cardNumber?.slice(-4) ?? "----"}
|
||||
</div>
|
||||
)}
|
||||
</ConditionalField>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<ConditionalField<TValues>>`
|
||||
|
||||
Props:
|
||||
|
||||
- `when: (values: TValues) => boolean` — predicate. Children render
|
||||
only when this returns truthy.
|
||||
- `names: ReadonlyArray<string>` — the names of the conditional
|
||||
fields. Unregistered + cleared on close.
|
||||
- `watch?: ReadonlyArray<string>` — fields to subscribe to. Performance
|
||||
hint — when omitted, the recipe subscribes to the entire form.
|
||||
- `children: ReactNode | ((values: TValues) => ReactNode)` — node or
|
||||
render-prop callback.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The recipe uses `useWatch` to subscribe and `getValues()` to read
|
||||
the current snapshot. `useWatch`'s subscription is what triggers
|
||||
re-renders when a watched field changes; `getValues()` returns the
|
||||
full current values bag so the predicate sees the up-to-date
|
||||
picture.
|
||||
- The recipe tracks the previous visibility via a `useRef` so the
|
||||
unregister-on-close branch only fires on the actual transition,
|
||||
not on every re-render while hidden.
|
||||
- `unregister` is called per name to avoid the case where RHF batches
|
||||
unregisters and one name's error survives. `clearErrors` is called
|
||||
separately for the same reason.
|
||||
- The recipe never registers fields itself — that is the consumer's
|
||||
job (typically via `form.register(name)` inside the child node or
|
||||
via a sibling `RenderField`). The recipe is purely an
|
||||
unregister-on-close lifecycle wrapper.
|
||||
- Children are wrapped in a Fragment in both branches (static and
|
||||
render-prop) so the recipe contributes zero DOM nodes.
|
||||
- The throw on missing `<FormProvider>` is intentional: silently
|
||||
no-oping would let consumers ship forms whose conditional fields
|
||||
never re-render. Surfacing the error at first mount is safer.
|
||||
@@ -0,0 +1,173 @@
|
||||
# `createRepeatingFieldset`
|
||||
|
||||
Dynamic-array fieldset recipe backed by react-hook-form's
|
||||
`useFieldArray`. Renders one row per array entry; add / remove
|
||||
buttons throw `REACT_FORMS.REPEATING_ROW_LIMIT` when the operation
|
||||
would breach the configured `minRows` / `maxRows` bounds.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Repeating fieldsets are the canonical pattern for any form whose
|
||||
shape is "0..N entries of the same record" — contacts, line items,
|
||||
addresses, attendees. The recipe wires `useFieldArray` against a
|
||||
named array property on the parent form (the form created via
|
||||
`createDomainForm`, `createWizardForm`, or a hand-rolled
|
||||
`<FormProvider>`) and renders one labelled row per entry. Each row
|
||||
is a small `FieldConfigMap` so all the supported field kinds are
|
||||
available inside a row.
|
||||
|
||||
The recipe MUST be rendered inside a `<FormProvider>`. The factory's
|
||||
`<DomainForm>` already wraps one; consumers using bespoke shells
|
||||
must do the same. When the provider is missing the recipe throws
|
||||
`REACT_FORMS.CONTEXT_NOT_PROVIDED` so the bug surfaces immediately
|
||||
rather than producing a confusing null-context error inside RHF.
|
||||
|
||||
## Features
|
||||
|
||||
- Bound to RHF's `useFieldArray` for stable row keys, ordered
|
||||
insertion / deletion, and integration with the parent form's
|
||||
validation pass.
|
||||
- `minRows` / `maxRows` bounds enforced at the call site —
|
||||
disabled buttons are the soft gate, the throw is the hard gate.
|
||||
- Semantic `<ul>` / `<li>` elements (with implicit `list` /
|
||||
`listitem` roles) so screen readers announce the rows as a list.
|
||||
- Children render-prop yields the rows array (with per-row context
|
||||
including a `remove` callback) so consumers can build custom row
|
||||
chrome (drag handles, summary panels, ...).
|
||||
- Translated `Add` / `Remove` button labels with overrides via the
|
||||
`labels` config.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { createRepeatingFieldset } from "@suaveplan/react-forms/recipes/repeating-fieldset";
|
||||
|
||||
interface ContactRow {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
const Contacts = createRepeatingFieldset<ContactRow>({
|
||||
formId: "checkout",
|
||||
name: "contacts",
|
||||
minRows: 1,
|
||||
maxRows: 5,
|
||||
defaultRow: { name: "", email: "" },
|
||||
fieldset: {
|
||||
name: { kind: "text", label: "checkout.contact.name" as never },
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "checkout.contact.email" as never,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function ContactsSection(): JSX.Element {
|
||||
// Must be rendered inside a <FormProvider> ancestor.
|
||||
return <Contacts />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Render rows inside a custom drag-and-drop shell with a per-row
|
||||
summary panel:
|
||||
|
||||
```tsx
|
||||
export function CustomContacts(): JSX.Element {
|
||||
return (
|
||||
<Contacts>
|
||||
{({ rows, add, canAdd }) => (
|
||||
<div>
|
||||
{rows.map((row) => (
|
||||
<article
|
||||
key={row.id}
|
||||
data-draggable
|
||||
data-row-index={row.index}
|
||||
>
|
||||
<header>Contact #{row.index + 1}</header>
|
||||
{Object.values(row.fields)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={row.remove}
|
||||
disabled={!row.canRemove}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
disabled={!canAdd}
|
||||
>
|
||||
Add contact
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Contacts>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createRepeatingFieldset(config)`
|
||||
|
||||
- `formId: string` — stable id for OTel attributes / DOM inspection.
|
||||
- `name: string` — array field name on the parent form.
|
||||
- `minRows?: number` — minimum row count enforced by `remove()`.
|
||||
Defaults to `0`.
|
||||
- `maxRows?: number` — maximum row count enforced by `add()`.
|
||||
Defaults to `Infinity`.
|
||||
- `defaultRow: TRow` — default values for a newly-added row.
|
||||
- `fieldset: FieldConfigMap<TRow>` — per-row field config map.
|
||||
- `labels?: { add?: TranslationKey; remove?: TranslationKey }` —
|
||||
override the bundled defaults.
|
||||
|
||||
### `<RepeatingFieldset>` props
|
||||
|
||||
- `children?: (ctx) => ReactNode` — render-prop receiving
|
||||
`{ rows, add, canAdd, fieldArray }`. When supplied, the default
|
||||
shell is NOT rendered.
|
||||
- `className?: string` — class on the rendered `<div>`.
|
||||
|
||||
### `RepeatingChildrenContext<TRow>` (passed to children)
|
||||
|
||||
- `rows: ReadonlyArray<RepeatingRowContext<TRow>>` — one entry per
|
||||
row, each containing:
|
||||
- `index: number`
|
||||
- `id: string` — RHF's stable row id (use as React key).
|
||||
- `fields: Readonly<Record<string, ReactElement>>` — pre-rendered
|
||||
field elements for the row.
|
||||
- `remove: () => void` — removes this row (throws on min-row
|
||||
breach).
|
||||
- `canRemove: boolean` — whether the soft gate would allow remove.
|
||||
- `add: () => void` — appends a new `defaultRow` (throws on
|
||||
max-row breach).
|
||||
- `canAdd: boolean` — whether the soft gate would allow add.
|
||||
- `fieldArray: UseFieldArrayReturn<...>` — raw RHF field-array
|
||||
context for advanced operations (`move`, `swap`, `insert`,
|
||||
`replace`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The recipe uses RHF's `useFieldArray` with a stringly-typed name —
|
||||
the upstream API is intentionally `string`-typed, so the recipe
|
||||
passes through. TypeScript narrows the row shape at the consumer
|
||||
level via the `<TRow>` generic.
|
||||
- Throwing on `add()` past `maxRows` AND disabling the button is
|
||||
intentional belt-and-braces: the disabled button is the affordance
|
||||
for the typical user; the throw catches consumers who bypass the
|
||||
affordance via the children render-prop.
|
||||
- The default shell renders the add button inside the same `<ul>` as
|
||||
the rows so the button shares the list semantics. Custom children
|
||||
can render the button anywhere.
|
||||
- Field configs are validated at module-eval time so a misconfigured
|
||||
row never reaches a render.
|
||||
- `data-row-index` and `data-row-action` attributes are stable DOM
|
||||
contracts useful for E2E tests and CSS targeting.
|
||||
176
docs/ui/forms/react-forms/src/recipes/wizard/wizard.md
Normal file
176
docs/ui/forms/react-forms/src/recipes/wizard/wizard.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# `createWizardForm`
|
||||
|
||||
Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed
|
||||
field-config map; navigating forward validates the current step
|
||||
before advancing; navigating backward preserves entered data;
|
||||
`onComplete` runs after the final step's validation succeeds.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wizards are the canonical pattern for forms that exceed a single
|
||||
viewport — registration, checkout, multi-page surveys — where the
|
||||
user benefits from chunked progress. The recipe wraps `useForm` from
|
||||
react-hook-form with a small step-state machine; field validation
|
||||
runs against the current step's sub-schema, and only the last step's
|
||||
button is wired to `onComplete`. The merged values across all steps
|
||||
flow into the `onComplete` callback as a single object so the
|
||||
consumer never has to stitch step state together.
|
||||
|
||||
The recipe ships with a default chrome (Back / Next / Submit
|
||||
buttons, vertical field stack), all wired to the bundled translation
|
||||
keys (`react-forms.wizard.next` / `back` / `submit`). Consumers
|
||||
override individual labels via the `labels` config option or replace
|
||||
the entire chrome via the children render-prop.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-step Zod schema enforced before forward navigation.
|
||||
- Backward navigation preserves entered data — `useForm` state
|
||||
spans every step.
|
||||
- Default chrome with translated Back / Next / Submit buttons; final
|
||||
step's button auto-flips from `Next` to `Submit`.
|
||||
- `WizardState` exposed via the children render-prop for custom
|
||||
progress UIs (`currentIndex`, `totalSteps`, `canGoBack`,
|
||||
`canGoNext`, `isComplete`).
|
||||
- `data-wizard-step` attribute on the rendered `<form>` so DOM
|
||||
inspectors can identify the active step without subscribing to
|
||||
React state.
|
||||
- Per-step field-config validation at module-eval time — a
|
||||
misconfigured step throws `REACT_FORMS.FIELD_KIND_INVALID` before
|
||||
any render.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { createWizardForm } from "@suaveplan/react-forms/recipes/wizard";
|
||||
import z from "zod";
|
||||
|
||||
const SignUpWizard = createWizardForm({
|
||||
formId: "signup",
|
||||
steps: [
|
||||
{
|
||||
id: "credentials",
|
||||
title: "auth.steps.credentials" as never,
|
||||
schema: z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
}),
|
||||
fields: {
|
||||
email: { kind: "email", label: "auth.email.label" as never },
|
||||
password: { kind: "password", label: "auth.password.label" as never },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
title: "auth.steps.profile" as never,
|
||||
schema: z.object({ name: z.string().min(1) }),
|
||||
fields: {
|
||||
name: { kind: "text", label: "auth.name.label" as never },
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultValues: { email: "", password: "", name: "" },
|
||||
onComplete: async (values) => {
|
||||
await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export function SignUpPage(): JSX.Element {
|
||||
return <SignUpWizard />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Replace the default chrome with a custom progress UI showing the
|
||||
total step count and a localised label per step:
|
||||
|
||||
```tsx
|
||||
export function SignUpPageWithProgress(): JSX.Element {
|
||||
return (
|
||||
<SignUpWizard>
|
||||
{({ state, fields, goNext, goPrev, isSubmitting }) => (
|
||||
<div>
|
||||
<ol data-progress>
|
||||
<li>
|
||||
Step {state.currentIndex + 1} of {state.totalSteps}
|
||||
</li>
|
||||
</ol>
|
||||
{Object.values(fields)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={!state.canGoBack || isSubmitting}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void goNext()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{state.canGoNext ? "Next" : "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SignUpWizard>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createWizardForm(config)`
|
||||
|
||||
- `formId: string` — stable id for OTel attributes.
|
||||
- `steps: WizardStep<TValues>[]` — at least one. Each:
|
||||
- `id: string` — stable step identifier.
|
||||
- `title: TranslationKey` — heading translation key.
|
||||
- `schema: ZodType<Partial<TValues>>` — sub-schema for the step.
|
||||
- `fields: FieldConfigMap<TValues>` — per-step field configs.
|
||||
- `defaultValues?: Partial<TValues>` — initial values across steps.
|
||||
- `labels?: { next?: TranslationKey; back?: TranslationKey; submit?: TranslationKey }` —
|
||||
override the bundled defaults.
|
||||
- `onComplete: (values: TValues) => Promise<unknown>` — invoked when
|
||||
the final step validates successfully.
|
||||
|
||||
### `<WizardForm>` props
|
||||
|
||||
- `children?: (ctx) => ReactNode` — render-prop receiving
|
||||
`{ form, fields, state, goNext, goPrev, isSubmitting }`.
|
||||
- `className?: string` — class on the rendered `<form>`.
|
||||
|
||||
### `WizardState` (from the render-prop ctx)
|
||||
|
||||
- `currentIndex: number`
|
||||
- `currentStep: { id: string }`
|
||||
- `totalSteps: number`
|
||||
- `canGoBack: boolean`
|
||||
- `canGoNext: boolean`
|
||||
- `isComplete: boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The wizard validates ONLY the current step's slice of values — the
|
||||
later steps are skipped. This means `onComplete` may be called
|
||||
with values that fail a downstream sub-schema if a later step is
|
||||
edited, then the user navigates back. Consumers should prefer
|
||||
schemas where every step's sub-schema is a strict subset of the
|
||||
full Zod schema.
|
||||
- The merged values passed to `onComplete` are read via
|
||||
`form.getValues()` — the RHF state is the single source of truth.
|
||||
- The default chrome renders both buttons inside a
|
||||
`<div data-wizard-nav>` so styling layers can target the navigation
|
||||
block independently of the field stack.
|
||||
- Throwing on zero steps is a hard contract — there is no defaultable
|
||||
"empty wizard" UI worth shipping.
|
||||
- Field-config validation runs once per step at module-eval time;
|
||||
consumers cannot accidentally smuggle a deferred kind (`select`,
|
||||
`date`, ...) into a step's config.
|
||||
@@ -0,0 +1,106 @@
|
||||
# `async-email-unique.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Async validator factory that resolves to `{ valid: true }` when a
|
||||
caller-supplied `check(email, signal)` predicate returns `true`
|
||||
("the email is unique"), and to `{ valid: false, messageKey }` when
|
||||
the predicate returns `false` ("the email is already registered").
|
||||
The validator is the canonical pattern for "async field validation
|
||||
that hits the network" without baking a transport into the package.
|
||||
|
||||
The factory wraps the supplied `check` in a `@suaveplan/timing.debounce`
|
||||
window so a user typing characters fires at most one network call per
|
||||
window (default 300 ms), and observes its `AbortSignal` argument so
|
||||
in-flight checks for stale values do not overwrite the response for
|
||||
the latest value.
|
||||
|
||||
## Features
|
||||
|
||||
- Caller-injected `check(email, signal): Promise<boolean>` — the
|
||||
validator owns no transport, no fetch, no network primitive. Tests
|
||||
pass a real function (no `vi.mock`).
|
||||
- `AbortSignal` observation — when the signal aborts, the validator
|
||||
resolves to a structured failure with
|
||||
`messageKey === "react_forms.validation.async_aborted"`.
|
||||
- Debounce window via `@suaveplan/timing.debounce` (`leading: false,
|
||||
trailing: true`).
|
||||
- In-flight dedup — when the same value is checked twice in quick
|
||||
succession, the second call returns the in-flight promise rather
|
||||
than starting a fresh debounce.
|
||||
- OTel telemetry — every invocation emits
|
||||
`react_forms.async_validator.duration_ms` and
|
||||
`react_forms.async_validator.total{validator,outcome}`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { asyncEmailUnique } from "@suaveplan/react-forms/validation/async";
|
||||
|
||||
const validateUnique = asyncEmailUnique({
|
||||
check: async (email, signal) => {
|
||||
const res = await fetch(`/api/users?email=${encodeURIComponent(email)}`, {
|
||||
signal,
|
||||
});
|
||||
return res.status === 404; // 404 = not found = unique
|
||||
},
|
||||
});
|
||||
|
||||
await validateUnique("alice@example.com", new AbortController().signal);
|
||||
// → { valid: true } if the email is unique
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { asyncEmailUnique } from "@suaveplan/react-forms/validation/async";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const cache = new Map<string, boolean>();
|
||||
|
||||
const validateUnique = asyncEmailUnique({
|
||||
debounceMs: 500,
|
||||
messageKey: "auth.email.taken" as TranslationKey,
|
||||
check: async (email, signal) => {
|
||||
const cached = cache.get(email);
|
||||
if (cached !== undefined) return cached;
|
||||
const res = await fetch(`/api/email/check?email=${email}`, {
|
||||
signal,
|
||||
});
|
||||
const fresh = res.status === 404;
|
||||
cache.set(email, fresh);
|
||||
return fresh;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `asyncEmailUnique(opts: AsyncEmailUniqueOptions):
|
||||
AsyncValidator<string>` — factory.
|
||||
- `interface AsyncEmailUniqueOptions {`
|
||||
- `check: (email: string, signal: AbortSignal) => Promise<boolean>`
|
||||
- `debounceMs?: number` — default `300`.
|
||||
- `messageKey?: TranslationKey` — default
|
||||
`react_forms.validation.email_taken`.
|
||||
- `}`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The debounce uses `@suaveplan/timing.debounce` rather than `setTimeout`
|
||||
directly because the convention is that all timing utilities
|
||||
flow through `@suaveplan/timing` for dependency-injected `Clock`
|
||||
testability.
|
||||
- The `check` function receives the abort signal — implementations
|
||||
that hit `fetch` should pass it through as `fetch(url, { signal })`
|
||||
so the underlying request is cancelled on abort.
|
||||
- The validator surfaces `AbortError` (the `DOMException` thrown by
|
||||
`fetch` on abort) as a structured "aborted" failure rather than
|
||||
re-throwing. Re-throwing would force the engine to try/catch every
|
||||
validator call, which obscures normal-failure code paths.
|
||||
- The `check` predicate can be sync-throwing or async-rejecting;
|
||||
both paths converge on `{ valid: false, messageKey }` so the
|
||||
validator never crashes the form.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `async-username-available.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Async validator factory that resolves to `{ valid: true }` when a
|
||||
caller-supplied `check(username, signal)` predicate returns `true`
|
||||
("the username is available"), and to
|
||||
`{ valid: false, messageKey }` when it returns `false` ("the username
|
||||
is already taken"). Mirrors `asyncEmailUnique` in shape — the split
|
||||
exists so the failure key, OTel `validator` attribute, and dedup
|
||||
namespace stay distinct between the two validators.
|
||||
|
||||
## Features
|
||||
|
||||
- Caller-injected `check(username, signal): Promise<boolean>` — no
|
||||
baked-in transport.
|
||||
- `AbortSignal` observation — aborts surface as
|
||||
`react_forms.validation.async_aborted`.
|
||||
- Debounce window via `@suaveplan/timing.debounce`
|
||||
(`leading: false, trailing: true`, default 300 ms).
|
||||
- In-flight dedup keyed on the username value.
|
||||
- OTel telemetry —
|
||||
`react_forms.async_validator.duration_ms{validator="asyncUsernameAvailable"}`
|
||||
histogram and
|
||||
`react_forms.async_validator.total{validator,outcome}` counter.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { asyncUsernameAvailable } from "@suaveplan/react-forms/validation/async";
|
||||
|
||||
const validate = asyncUsernameAvailable({
|
||||
check: async (username, signal) => {
|
||||
const res = await fetch(
|
||||
`/api/usernames/${encodeURIComponent(username)}`,
|
||||
{ signal }
|
||||
);
|
||||
return res.status === 404; // 404 = available
|
||||
},
|
||||
});
|
||||
|
||||
await validate("alice", new AbortController().signal);
|
||||
// → { valid: true }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { asyncUsernameAvailable } from "@suaveplan/react-forms/validation/async";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const validate = asyncUsernameAvailable({
|
||||
debounceMs: 500,
|
||||
messageKey: "auth.username.taken" as TranslationKey,
|
||||
check: async (username, signal) => {
|
||||
if (username.length < 3) return false;
|
||||
return await fetch(`/api/usernames/${username}`, {
|
||||
signal,
|
||||
}).then((r) => r.status === 404);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `asyncUsernameAvailable(opts: AsyncUsernameAvailableOptions):
|
||||
AsyncValidator<string>` — factory.
|
||||
- `interface AsyncUsernameAvailableOptions {`
|
||||
- `check: (username: string, signal: AbortSignal) => Promise<boolean>`
|
||||
- `debounceMs?: number` — default `300`.
|
||||
- `messageKey?: TranslationKey` — default
|
||||
`react_forms.validation.username_taken`.
|
||||
- `}`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The validator deliberately namespaces its dedup key (`username::<v>`)
|
||||
so it can co-exist with `asyncEmailUnique` (which uses `email::<v>`)
|
||||
in the same form's validation engine without one stomping on the
|
||||
other.
|
||||
- The debounce uses `@suaveplan/timing.debounce` for the same reasons
|
||||
as `asyncEmailUnique` — clock injectability via the timing
|
||||
primitive.
|
||||
- The factory does NOT enforce a minimum username length; layer
|
||||
`minLength` via `sequence` if you want the check to skip until the
|
||||
user has typed enough characters.
|
||||
117
docs/ui/forms/react-forms/src/validation/compose/compose.md
Normal file
117
docs/ui/forms/react-forms/src/validation/compose/compose.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# `compose.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
|
||||
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
- or — OR-semantics composer for the validator suite.…
|
||||
|
||||
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
|
||||
- or — OR-semantics composer for the validator suite.…
|
||||
|
||||
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
|
||||
- or — OR-semantics composer for the validator suite.…
|
||||
|
||||
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
|
||||
## Purpose
|
||||
|
||||
`compose` (AND semantics) — combine multiple validators into a single
|
||||
validator that passes only when every input validator passes. Failures
|
||||
short-circuit on the first failing validator and the composed
|
||||
validator returns that validator's `messageKey` so the user sees the
|
||||
single most specific error rather than a fan-out of all violations.
|
||||
|
||||
The same module exports `runChain`, the chain-runner used by both
|
||||
`compose` and the `or` composer in the sibling module. Centralising
|
||||
the chain logic here keeps the AND/OR/SEQUENCE composers thin and
|
||||
guarantees consistent sync-vs-async dispatch semantics across all
|
||||
three.
|
||||
|
||||
## Features
|
||||
|
||||
- AND semantics: short-circuits on first failure.
|
||||
- Sync if every input validator is sync; async if any input is async.
|
||||
- Generic over the value type `T`.
|
||||
- `runChain` exported as the shared engine for related composers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { compose } from "@suaveplan/react-forms/validation/compose";
|
||||
import {
|
||||
minLength,
|
||||
pattern,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateSlug = compose(
|
||||
minLength(3),
|
||||
pattern(/^[a-z0-9-]+$/)
|
||||
);
|
||||
|
||||
validateSlug("abc"); // → { valid: true }
|
||||
validateSlug("ab"); // → { valid: false, messageKey: "react_forms.validation.min_length" }
|
||||
validateSlug("ABC"); // → { valid: false, messageKey: "react_forms.validation.pattern" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { compose } from "@suaveplan/react-forms/validation/compose";
|
||||
import {
|
||||
custom,
|
||||
email,
|
||||
required,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateEmail = compose(
|
||||
required("auth.email.required" as TranslationKey),
|
||||
email("auth.email.invalid" as TranslationKey),
|
||||
custom(
|
||||
async (v: string) => Promise.resolve(!v.endsWith("@temp.com")),
|
||||
"auth.email.disposable" as TranslationKey
|
||||
)
|
||||
);
|
||||
|
||||
await validateEmail("alice@temp.com", new AbortController().signal);
|
||||
// → { valid: false, messageKey: "auth.email.disposable" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `compose<T>(...validators: Validator<T>[]): Validator<T>` — sync
|
||||
overload.
|
||||
- `compose<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
|
||||
AsyncValidator<T>` — mixed overload returning an async validator.
|
||||
- `runChain<T>(validators, value, signal, mode)` — internal chain-
|
||||
runner exported for use by sibling composers (`or`, `sequence`).
|
||||
- `mode: "and" | "or"` — combinator semantics.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The chain-runner uses an iterative `step()` function rather than a
|
||||
reducer so the sync path stays sync — `Array.prototype.reduce`
|
||||
cannot represent a partial early-return cleanly while preserving
|
||||
sync-vs-async narrowing.
|
||||
- The runner uses an injected `AbortSignal` (or a fresh one when none
|
||||
is supplied) for every async validator invocation. This guarantees
|
||||
every async validator sees a signal, so a validator that ignores the
|
||||
argument on the assumption "the caller will handle abort" never
|
||||
silently leaks a hanging promise.
|
||||
- `runChain` is exported only as an internal helper for the sibling
|
||||
composers — it is NOT re-exported from the package barrel because
|
||||
consumers should always go through the named composer (`compose`,
|
||||
`or`, `sequence`).
|
||||
74
docs/ui/forms/react-forms/src/validation/compose/or.md
Normal file
74
docs/ui/forms/react-forms/src/validation/compose/or.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `or.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
OR-semantics composer for the validator suite. Returns a validator
|
||||
that passes when any of the input validators passes. Successes short-
|
||||
circuit on the first match (so cheap validators should come first); if
|
||||
every validator fails the LAST failure is returned, which gives the
|
||||
caller the most specific error message rather than the most generic.
|
||||
|
||||
## Features
|
||||
|
||||
- OR semantics: short-circuits on first success.
|
||||
- Sync if every input is sync; async if any input is async.
|
||||
- Generic over the value type `T`.
|
||||
- Empty chain returns `{ valid: true }` (vacuous truth — OR-of-zero
|
||||
is identity-true; treat as a no-op).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { or } from "@suaveplan/react-forms/validation/compose";
|
||||
import {
|
||||
email,
|
||||
url,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateContact = or(email(), url({ protocols: ["https"] }));
|
||||
|
||||
validateContact("alice@example.com"); // → { valid: true }
|
||||
validateContact("https://alice.example"); // → { valid: true }
|
||||
validateContact("nope"); // → { valid: false, messageKey: "react_forms.validation.url" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { or } from "@suaveplan/react-forms/validation/compose";
|
||||
import {
|
||||
custom,
|
||||
email,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const looksLikeUsername = custom<string>(
|
||||
(s) => /^[a-z0-9_-]{3,30}$/i.test(s),
|
||||
"form.username.invalid" as TranslationKey
|
||||
);
|
||||
|
||||
const emailOrUsername = or(email(), looksLikeUsername);
|
||||
|
||||
emailOrUsername("alice"); // → { valid: true }
|
||||
emailOrUsername("alice@example.com"); // → { valid: true }
|
||||
emailOrUsername("!!!"); // → { valid: false, messageKey: "form.username.invalid" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `or<T>(...validators: Validator<T>[]): Validator<T>` — sync
|
||||
overload.
|
||||
- `or<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
|
||||
AsyncValidator<T>` — mixed overload returning an async validator.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The runner returns the LAST failure (rather than the first or all of
|
||||
them) because the convention in industry-standard form libraries
|
||||
(Yup, Formik) is "show the most specific error, which is usually
|
||||
the most recently checked".
|
||||
- Cheap validators come first — `or(email(), expensiveAsyncCheck())`
|
||||
short-circuits on the cheap email match in the common case and only
|
||||
invokes the async check when the cheap one rejects.
|
||||
- The chain runner is shared with `compose` (AND); only the `mode`
|
||||
argument differs.
|
||||
85
docs/ui/forms/react-forms/src/validation/compose/sequence.md
Normal file
85
docs/ui/forms/react-forms/src/validation/compose/sequence.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# `sequence.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Short-circuit AND composer with explicit ordering semantics. Identical
|
||||
to `compose` in result — every validator must pass for the composed
|
||||
validator to pass, and the first failure short-circuits — but the
|
||||
order in which validators are passed is deliberately meaningful: cheap
|
||||
synchronous validators come first, expensive async validators come
|
||||
last. Use `sequence` whenever the order matters; use `compose` when
|
||||
order is incidental.
|
||||
|
||||
The split (rather than just an alias) exists because:
|
||||
|
||||
- Naming the intent at the call site (`sequence(required, email,
|
||||
asyncCheck)`) communicates "run these in order" to the next
|
||||
developer reading the code, where `compose` reads as "combine
|
||||
these by AND".
|
||||
- Future runtime hooks (e.g. tracing-per-step latency, ordered failure
|
||||
metrics) will land on `sequence` because it carries an explicit
|
||||
ordering contract; `compose` is order-agnostic so the same hook on
|
||||
`compose` would be misleading.
|
||||
|
||||
## Features
|
||||
|
||||
- AND semantics with documented ordering contract.
|
||||
- Sync if every input is sync; async if any input is async.
|
||||
- Generic over the value type `T`.
|
||||
- Empty chain returns `{ valid: true }`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sequence } from "@suaveplan/react-forms/validation/compose";
|
||||
import {
|
||||
email,
|
||||
required,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
// `required` runs first (cheap), `email` runs only when required passed.
|
||||
const validateEmail = sequence(
|
||||
required("auth.email.required" as TranslationKey),
|
||||
email("auth.email.invalid" as TranslationKey)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { sequence } from "@suaveplan/react-forms/validation/compose";
|
||||
import {
|
||||
custom,
|
||||
email,
|
||||
required,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateEmail = sequence(
|
||||
required("auth.email.required" as TranslationKey),
|
||||
email("auth.email.invalid" as TranslationKey),
|
||||
custom(
|
||||
async (v: string) => Promise.resolve(!v.endsWith("@temp.com")),
|
||||
"auth.email.disposable" as TranslationKey
|
||||
)
|
||||
);
|
||||
|
||||
await validateEmail("alice@temp.com", new AbortController().signal);
|
||||
// → { valid: false, messageKey: "auth.email.disposable" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `sequence<T>(...validators: Validator<T>[]): Validator<T>` — sync
|
||||
overload.
|
||||
- `sequence<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
|
||||
AsyncValidator<T>` — mixed overload returning an async validator.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The implementation delegates to `runChain` from `compose.ts` with
|
||||
`mode: "and"`. The split is API-level only — there is one
|
||||
short-circuit AND engine.
|
||||
- Putting cheap validators first matters because async validators
|
||||
invariably hit the network. A `sequence` that re-orders them at
|
||||
runtime would silently fire network I/O the caller did not intend;
|
||||
the convention is "preserve insertion order, full stop".
|
||||
88
docs/ui/forms/react-forms/src/validation/compose/shared.md
Normal file
88
docs/ui/forms/react-forms/src/validation/compose/shared.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# `shared.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Tiny internal helper module shared by every composer in
|
||||
`validation/compose`. Centralises the `Promise` discrimination check
|
||||
so a future change to "what counts as async" only happens in one place.
|
||||
Keeping the helper here (rather than in `compose.ts`) lets `or.ts` and
|
||||
`sequence.ts` import it without pulling in the AND-combinator code or
|
||||
risking circular imports.
|
||||
|
||||
## Features
|
||||
|
||||
- `isAsyncResult(out)` — type-narrowing predicate that tells
|
||||
TypeScript whether a `ValidatorResult | Promise<ValidatorResult>` is
|
||||
the async branch.
|
||||
- Cross-realm safe — uses `instanceof Promise` rather than duck-typing
|
||||
on `.then` so monkey-patched `Promise.resolve` shims do not produce
|
||||
false positives.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { isAsyncResult } from "./shared.js";
|
||||
|
||||
const out: ValidatorResult | Promise<ValidatorResult> = validator(value);
|
||||
if (isAsyncResult(out)) {
|
||||
out.then((res) => handle(res));
|
||||
} else {
|
||||
handle(out);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { isAsyncResult } from "./shared.js";
|
||||
|
||||
function reduceChain<T>(
|
||||
chain: ReadonlyArray<Validator<T> | AsyncValidator<T>>,
|
||||
value: T,
|
||||
signal: AbortSignal
|
||||
): ValidatorResult | Promise<ValidatorResult> {
|
||||
let acc: ValidatorResult | Promise<ValidatorResult> = { valid: true };
|
||||
for (const v of chain) {
|
||||
if (isAsyncResult(acc)) {
|
||||
acc = acc.then((prev) =>
|
||||
prev.valid ? v(value, signal) : prev
|
||||
);
|
||||
} else if (!acc.valid) {
|
||||
return acc;
|
||||
} else {
|
||||
acc = v(value, signal);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `isAsyncResult(out: ValidatorResult | Promise<ValidatorResult>): out
|
||||
is Promise<ValidatorResult>` — narrows the union when the value is a
|
||||
`Promise`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `instanceof Promise` check is the only safe cross-realm
|
||||
discriminator JavaScript gives us. Duck-typing on `.then` is unsafe
|
||||
because plain objects with a `.then` member ("thenables") would also
|
||||
match — that's how callers regularly leak coroutine-style state into
|
||||
validators that the chain runner would mistake for async results.
|
||||
- The helper is not part of the package's public surface; it is
|
||||
internal-only and not re-exported from any barrel — the composers
|
||||
(`compose`, `or`, `sequence`) are the only call sites and they all
|
||||
go through `runChain` from `compose.ts`.
|
||||
- The two-line predicate is intentionally tiny so it inlines into the
|
||||
caller after the JIT warms up. There is no real-world performance
|
||||
cost to wrapping every result through this guard, and the cost of
|
||||
a wrong discrimination (an async result mistakenly handled as sync)
|
||||
is a missed Promise resolution, which is far worse than the cost
|
||||
of the function call.
|
||||
- For micro-tasks where every cycle matters, callers writing tight
|
||||
loops over many validators could short-circuit by checking
|
||||
`isAsyncResult` once on the first result and assuming subsequent
|
||||
results follow the same shape — but the chain runner deliberately
|
||||
does NOT make this assumption because mixing sync + async in a
|
||||
composition pipeline is the common case, not an edge case.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `bic.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied string matches the
|
||||
ISO 9362 Business Identifier Code (BIC, also known as SWIFT code)
|
||||
format. The structure is fixed: 4-letter institution code +
|
||||
2-letter ISO 3166-1 country code + 2-character location code +
|
||||
optional 3-character branch code, totalling 8 or 11 alphanumeric
|
||||
characters. There is no checksum.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.bic`.
|
||||
|
||||
## Features
|
||||
|
||||
- Whitespace-tolerant — `"DEUT DEFF"` and `"DEUTDEFF"` both pass.
|
||||
- Case-insensitive — uppercased before matching.
|
||||
- Length corridor 8/11 — values of any other length fail before regex
|
||||
evaluation.
|
||||
- Pure-JS regex — no runtime dependency.
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { bic } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validate = bic();
|
||||
validate("DEUTDEFF"); // { valid: true }
|
||||
validate("DEUTDEFF500"); // { valid: true } — branch-coded
|
||||
validate("12345678"); // { valid: false } — first 4 chars must be letters
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { bic } from "@suaveplan/react-forms/validation/validators";
|
||||
import { sequence } from "@suaveplan/react-forms/validation/compose";
|
||||
import { required } from "@suaveplan/react-forms/validation/validators";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const validateBic = sequence(
|
||||
required("payment.bic.required" as TranslationKey),
|
||||
bic("payment.bic.invalid" as TranslationKey)
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `bic(messageKey?: TranslationKey): Validator` — factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The regex enforces the ISO 9362 partitioning explicitly so a
|
||||
superficially-correct string like `"12345678"` fails (the institution
|
||||
code MUST be alphabetic).
|
||||
- The country-code segment accepts any 2-letter combination —
|
||||
enforcing it against the ISO 3166-1 list would require shipping the
|
||||
list (changes ~1×/year) inside the package; consumers needing strict
|
||||
country validation should layer a separate check on the slice
|
||||
`value.slice(4, 6)` against their preferred country registry.
|
||||
- The location-code and branch-code segments accept alphanumerics per
|
||||
the spec (`[A-Z0-9]`) — letters and digits are equally legal.
|
||||
- Compared with the IBAN validator's mod-97 checksum, BIC has no
|
||||
cryptographic-style integrity check; a malformed-but-syntax-correct
|
||||
BIC will still pass and only fail at the receiving bank. Consumers
|
||||
routing payments should layer a directory lookup on top of the BIC
|
||||
validator for high-value transactions.
|
||||
- Whitespace stripping is applied before length checking so a value
|
||||
formatted with the spec-recommended 4/2/2/3 spacing (e.g.
|
||||
`"DEUT DE FF 500"`) matches the same regex as the contiguous form.
|
||||
- The validator is dependency-free: a flat regex plus length corridor
|
||||
is the entire implementation, which keeps bundle size negligible
|
||||
and makes the rule auditable from a single 30-line source file.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `credit-card.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied string is a
|
||||
syntactically valid credit-card number per the Luhn checksum algorithm
|
||||
(ISO/IEC 7812-1). The validator strips whitespace and hyphens before
|
||||
running Luhn so user-friendly formats like `4111-1111-1111-1111` and
|
||||
`4111 1111 1111 1111` are accepted alongside the contiguous
|
||||
`4111111111111111` form.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.credit_card`.
|
||||
|
||||
## Features
|
||||
|
||||
- ISO/IEC 7812-1 length corridor (12–19 digits) checked before the
|
||||
checksum.
|
||||
- Whitespace and hyphens stripped from the input.
|
||||
- Pure-JS Luhn implementation — no runtime dependency, no PCI scope
|
||||
expansion.
|
||||
- The Luhn helper (`luhnValid(digits: string)`) is exported so callers
|
||||
can use it standalone — e.g. for a related ID format that also uses
|
||||
the algorithm (IMEI, ICCID, NPI for US healthcare).
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { creditCard } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validate = creditCard();
|
||||
validate("4111 1111 1111 1111"); // { valid: true } — Luhn-valid Visa test card
|
||||
validate("4111-1111-1111-1112"); // { valid: false } — Luhn fails
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
creditCard,
|
||||
luhnValid,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const validateCard = creditCard(
|
||||
"checkout.card.invalid" as TranslationKey
|
||||
);
|
||||
|
||||
// Reuse the helper for IMEI validation
|
||||
const isValidImei = (imei: string) =>
|
||||
/^\d{15}$/.test(imei) && luhnValid(imei);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `creditCard(messageKey?: TranslationKey): Validator` — factory.
|
||||
- Returns a validator that passes on Luhn-valid 12–19 digit strings,
|
||||
fails otherwise.
|
||||
- `luhnValid(digits: string): boolean` — pure Luhn helper. Accepts a
|
||||
digits-only string and returns `true` when the checksum is satisfied.
|
||||
Does NOT validate length or strip non-digits — call sites should
|
||||
pre-process.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Luhn implementation iterates from the rightmost digit, doubles
|
||||
every second digit, and subtracts 9 when the doubled value exceeds
|
||||
9. The character-code arithmetic (`charCode - 48`) avoids
|
||||
`parseInt` allocation per digit and is roughly 4× faster on hot
|
||||
paths.
|
||||
- Brand-prefix rules (Visa starts with 4, MasterCard 51-55, etc.) are
|
||||
intentionally NOT enforced because card networks add new brands
|
||||
routinely and BIN ranges are owned by ISO/IEC 7812-1 issuers, not
|
||||
the form library. Consumers wanting brand detection should layer a
|
||||
separate library on top after Luhn passes.
|
||||
- The validator does not log, store, or transmit the card number;
|
||||
values stay on the caller's stack frame and are eligible for
|
||||
garbage collection immediately after the validator returns.
|
||||
@@ -0,0 +1,92 @@
|
||||
# `custom.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Adapter that wraps a caller-supplied boolean predicate into a
|
||||
`Validator` (or `AsyncValidator`) returning the canonical
|
||||
`{ valid, messageKey }` discriminated union. The caller writes a
|
||||
plain predicate that returns `true` for "valid" and `false` for
|
||||
"invalid"; `custom` translates that into the validator-suite return
|
||||
shape so the predicate composes uniformly with every other validator
|
||||
in the package.
|
||||
|
||||
The wrapper detects synchronous-vs-asynchronous at call time: if the
|
||||
predicate returns a `Promise`, the wrapper returns a `Promise<ValidatorResult>`;
|
||||
otherwise it returns the result synchronously. The TypeScript overloads
|
||||
declare the two return shapes so callers get the right type back.
|
||||
|
||||
## Features
|
||||
|
||||
- Two overloads — pure-sync (`(value) => boolean`) → `Validator`, and
|
||||
the union form (`(value) => boolean | Promise<boolean>`) →
|
||||
`AsyncValidator`.
|
||||
- Generic over the value type `T` so the predicate body has its
|
||||
argument fully typed.
|
||||
- `messageKey` is required and typed as `TranslationKey` — there is no
|
||||
default key since custom predicates are opaque to the package.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { custom } from "@suaveplan/react-forms/validation/validators";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const isEven = custom<number>(
|
||||
(n) => n % 2 === 0,
|
||||
"form.must_be_even" as TranslationKey
|
||||
);
|
||||
|
||||
isEven(4); // → { valid: true }
|
||||
isEven(7); // → { valid: false, messageKey: "form.must_be_even" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { custom } from "@suaveplan/react-forms/validation/validators";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
// Async predicate against a shared cache
|
||||
const cache = new Map<string, boolean>();
|
||||
|
||||
const inAllowlist = custom<string>(
|
||||
async (handle) => {
|
||||
const cached = cache.get(handle);
|
||||
if (cached !== undefined) return cached;
|
||||
const fresh = await fetch(`/api/allowlist/${handle}`).then((r) =>
|
||||
r.ok
|
||||
);
|
||||
cache.set(handle, fresh);
|
||||
return fresh;
|
||||
},
|
||||
"auth.handle.not_allowed" as TranslationKey
|
||||
);
|
||||
|
||||
await inAllowlist("alice"); // → { valid: true }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `custom<T>(predicate: (value: T) => boolean, messageKey:
|
||||
TranslationKey): Validator<T>` — synchronous overload.
|
||||
- `custom<T>(predicate: (value: T) => boolean | Promise<boolean>,
|
||||
messageKey: TranslationKey): AsyncValidator<T>` — async overload.
|
||||
- `type CustomPredicate<T> = (value: T) => boolean | Promise<boolean>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The function-body `if (out instanceof Promise)` discriminator is the
|
||||
same shape every "sometimes-sync" wrapper in JS uses (see RxJS,
|
||||
effect-ts). It avoids forcing every caller into `async`-await even
|
||||
for cheap predicates.
|
||||
- The `as Validator<T> | AsyncValidator<T>` cast in the implementation
|
||||
body is a deliberate widening — the public overloads pin the
|
||||
precise return type per call site, but the implementation must
|
||||
satisfy both signatures simultaneously.
|
||||
- `messageKey` has no default because the predicate's failure
|
||||
semantics are opaque to the package — there is no useful generic key
|
||||
the package could supply that the consumer's catalog would also
|
||||
carry.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `email.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied value is a
|
||||
syntactically valid email address per the practical RFC 5322 subset
|
||||
documented in the source. The pattern accepts the 99.9% of email
|
||||
addresses real users actually type — local parts of unreserved
|
||||
characters separated by dots, a domain of dot-separated alphanumeric
|
||||
labels, and a TLD of at least two ASCII letters — while rejecting
|
||||
exotic forms (quoted local parts, bracket-IP literals,
|
||||
internationalised domain names in their punycoded form, comments) that
|
||||
no real-world signup form should accept.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.email`.
|
||||
|
||||
## Features
|
||||
|
||||
- Length-bounded — rejects inputs above the 254-byte RFC-defined limit
|
||||
before regex evaluation, both for correctness and for ReDoS
|
||||
resistance.
|
||||
- Local-part length capped at 64 chars per the RFC.
|
||||
- Domain labels enforced to start and end with an alphanumeric and to
|
||||
be ≤ 63 chars each.
|
||||
- TLD enforced to ≥ 2 ASCII letters so common typos like `foo@bar.x`
|
||||
fail.
|
||||
- Pure factory — the returned validator carries no state.
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { email } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validate = email();
|
||||
validate("alice@example.com"); // { valid: true }
|
||||
validate("not-an-email"); // { valid: false, messageKey: "react_forms.validation.email" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { email } from "@suaveplan/react-forms/validation/validators";
|
||||
import { sequence } from "@suaveplan/react-forms/validation/compose";
|
||||
import { required } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateEmail = sequence(
|
||||
required("auth.email.required" as TranslationKey),
|
||||
email("auth.email.invalid" as TranslationKey)
|
||||
);
|
||||
|
||||
validateEmail(""); // → { valid: false, messageKey: "auth.email.required" }
|
||||
validateEmail("nope"); // → { valid: false, messageKey: "auth.email.invalid" }
|
||||
validateEmail("alice@example.com"); // → { valid: true }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `email(messageKey?: TranslationKey): Validator` — factory. Returns a
|
||||
validator that passes on valid email syntax, fails otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The pattern is split into three string fragments (`EMAIL_LOCAL`,
|
||||
`EMAIL_LABEL`, `EMAIL_DOMAIN`) and assembled with `new RegExp` so the
|
||||
RFC-derived structure is auditable from the source diff.
|
||||
- ReDoS exposure is bounded by the 254-byte length cap and the
|
||||
non-backtracking structure of the alternation (no nested quantifiers
|
||||
on overlapping character classes).
|
||||
- Non-string inputs ALWAYS fail — pair with `required` if you need the
|
||||
presence check to come first with its own message.
|
||||
- Internationalised email addresses (IDN domains) must be punycoded
|
||||
before validation. The factory does NOT perform automatic conversion
|
||||
to keep the validator deterministic and dependency-free.
|
||||
@@ -0,0 +1,84 @@
|
||||
# `iban.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied string is a
|
||||
syntactically valid International Bank Account Number per ISO 13616.
|
||||
Implements the full mod-97 checksum: country code + check digits move
|
||||
to the end, every letter expands to its two-digit numeric equivalent
|
||||
(A=10..Z=35), and the resulting integer mod 97 must equal 1.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.iban`.
|
||||
|
||||
## Features
|
||||
|
||||
- Whitespace-tolerant — `"GB29 NWBK 6016 1331 9268 19"` and
|
||||
`"GB29NWBK60161331926819"` both pass.
|
||||
- Case-insensitive — uppercased before mod-97.
|
||||
- Length corridor 15–34 (the per-country IBAN length range; we don't
|
||||
enforce per-country length since the country registry changes too
|
||||
often to bake in).
|
||||
- Streaming mod-97 implementation that avoids BigInt allocation:
|
||||
process 9 digits at a time so the running integer stays under
|
||||
`Number.MAX_SAFE_INTEGER`.
|
||||
- Helpers `expandLettersToDigits` and `mod97` exported so callers can
|
||||
reuse the primitives in related ID validators (e.g. national
|
||||
fiscal-ID checksums that share the mod-97 structure).
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { iban } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validate = iban();
|
||||
validate("GB29 NWBK 6016 1331 9268 19"); // { valid: true }
|
||||
validate("GB29 NWBK 6016 1331 9268 18"); // { valid: false } — checksum fails
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
iban,
|
||||
mod97,
|
||||
expandLettersToDigits,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateIban = iban("payment.iban.invalid" as TranslationKey);
|
||||
|
||||
// Reuse the helpers for related checksums
|
||||
function isValidIsin(isin: string) {
|
||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return false;
|
||||
const digits = expandLettersToDigits(isin);
|
||||
return mod97(digits) === 1;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `iban(messageKey?: TranslationKey): Validator` — factory.
|
||||
- `expandLettersToDigits(input: string): string` — replace each letter
|
||||
with its two-digit numeric equivalent (A=10..Z=35) and pass digits
|
||||
through unchanged.
|
||||
- `mod97(digits: string): number` — streaming mod-97 over a digit
|
||||
string.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The streaming mod-97 algorithm is the standard ISO/IEC 7064 trick
|
||||
for modular reduction over arbitrary-length integers without BigInt.
|
||||
Concatenating the running remainder with the next 9-digit chunk and
|
||||
parsing the combined integer keeps the value under 2^53 at every
|
||||
step.
|
||||
- Per-country length tables (BBAN length per ISO 13616) are
|
||||
intentionally omitted because the SWIFT registry adds and changes
|
||||
countries multiple times per year. Callers requiring strict per-
|
||||
country length checks can layer `pattern` on top.
|
||||
- The validator does not log, store, or transmit the IBAN; values
|
||||
stay on the caller's stack frame and are eligible for garbage
|
||||
collection immediately after the validator returns.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `max-length.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied value's length is
|
||||
less than or equal to `n`. Operates on strings (UTF-16 code-unit
|
||||
length, same metric as `String.prototype.length`) and arrays. Other
|
||||
shapes — `null`, `undefined`, plain objects, numbers — pass because
|
||||
length is meaningless for them; pair this validator with `required` to
|
||||
enforce presence.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.max_length` and carries
|
||||
`messageArgs: { max, actual }` so consumer catalogs can interpolate the
|
||||
limits into a localised error string.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts `string` and `Array<unknown>`; passes silently for shapes
|
||||
with no measurable length so `maxLength` is composable with
|
||||
type-permissive Zod schemas.
|
||||
- Throws `RangeError` from the factory when `n` is not a non-negative
|
||||
integer — invalid configuration is loud, not silent.
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { maxLength } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validate = maxLength(10);
|
||||
validate("short"); // { valid: true }
|
||||
validate("this is too long"); // { valid: false, messageKey, messageArgs: { max: 10, actual: 16 } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { maxLength } from "@suaveplan/react-forms/validation/validators";
|
||||
import { compose } from "@suaveplan/react-forms/validation/compose";
|
||||
import { minLength } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateBio = compose(
|
||||
minLength(20, "bio.too_short" as TranslationKey),
|
||||
maxLength(280, "bio.too_long" as TranslationKey)
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `maxLength(n: number, messageKey?: TranslationKey): Validator` —
|
||||
factory.
|
||||
- Throws `RangeError` if `n` is not a non-negative integer.
|
||||
- Returns a validator that yields `{ valid: true }` on pass and
|
||||
`{ valid: false, messageKey, messageArgs: { max, actual } }` on
|
||||
failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory eagerly validates `n` so misconfiguration surfaces at
|
||||
module evaluation rather than on first form interaction.
|
||||
- Unsupported shapes pass instead of failing (the inverse of
|
||||
`minLength`'s policy) because a max-length constraint on a value
|
||||
without a length is semantically vacuous — `[1,2,3]` violates "max
|
||||
length 2" but `42` does not.
|
||||
- For grapheme-aware counting, callers compose
|
||||
`custom((v) => Array.from(new Intl.Segmenter().segment(v)).length <= n)`.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `min-length.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied value's length is
|
||||
greater than or equal to `n`. Operates on strings (UTF-16 code-unit
|
||||
length, same as `String.prototype.length`) and arrays. Other shapes —
|
||||
`null`, `undefined`, plain objects, `Map`, `Set`, numbers — fail
|
||||
because length has no meaning for them; pair this validator with
|
||||
`required` when nullable inputs are legal.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.min_length` and carries
|
||||
`messageArgs: { min, actual }` so consumer catalogs can interpolate the
|
||||
limits into a localised error string.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts `string` and `Array<unknown>` inputs; rejects every other
|
||||
shape with a deterministic failure that still carries the requested
|
||||
`min` so the UI can render a consistent message.
|
||||
- Throws `RangeError` from the factory when `n` is not a non-negative
|
||||
integer — invalid configuration is loud, not silent.
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { minLength } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validate = minLength(8);
|
||||
validate("password"); // { valid: true }
|
||||
validate("short"); // { valid: false, messageKey, messageArgs: { min: 8, actual: 5 } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { minLength } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateTags = minLength(2, "tags.too_few" as TranslationKey);
|
||||
validateTags(["one"]);
|
||||
// → {
|
||||
// valid: false,
|
||||
// messageKey: "tags.too_few",
|
||||
// messageArgs: { min: 2, actual: 1 },
|
||||
// }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `minLength(n: number, messageKey?: TranslationKey): Validator` —
|
||||
factory.
|
||||
- Throws `RangeError` if `n` is not a non-negative integer.
|
||||
- Returns a validator that yields `{ valid: true }` on pass and
|
||||
`{ valid: false, messageKey, messageArgs: { min, actual } }` on
|
||||
failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory eagerly validates `n` so misconfiguration surfaces at
|
||||
module evaluation rather than on first form interaction.
|
||||
- `lengthOf` returns `undefined` for unsupported shapes, which the
|
||||
caller maps to `actual: 0`. This preserves a stable failure shape
|
||||
while signalling that the input had no measurable length.
|
||||
- For grapheme-aware counting, callers compose
|
||||
`custom((v) => Array.from(new Intl.Segmenter().segment(v)).length >= n)`.
|
||||
@@ -0,0 +1,94 @@
|
||||
# `password-strength.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied password scores at
|
||||
or above a configured threshold on the zxcvbn 0–4 scale, as computed
|
||||
by `@zxcvbn-ts/core`. zxcvbn is the de-facto standard for password
|
||||
strength estimation — it accounts for keyboard patterns, dictionary
|
||||
matches, leet-speak substitutions, l33t-shifts, repeated characters,
|
||||
and date-pattern guesses, producing a score that maps directly to
|
||||
"how many guesses to crack offline".
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.password_strength` and carries `messageArgs:
|
||||
{ score, minScore }` so the consumer's catalog can render strength-
|
||||
specific copy ("too short", "too common", "almost there", ...).
|
||||
|
||||
## Features
|
||||
|
||||
- Forwards to `@zxcvbn-ts/core`'s `zxcvbn` function — full strength
|
||||
estimation with no extra dependencies.
|
||||
- Configurable `minScore` (`0|1|2|3|4`); default `3` (safely
|
||||
unguessable, the modern consumer-app baseline).
|
||||
- Failure exposes both `score` and `minScore` so dashboard / UI copy
|
||||
can show a strength bar without re-running the estimator.
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
- Dictionary-free at the package level — consumer apps register
|
||||
English/language wordlists at app boot via
|
||||
`zxcvbnOptions.setOptions(...)`. This keeps the package lean and
|
||||
avoids forcing every consumer to ship the same ~50 KB of wordlists.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { passwordStrength } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validate = passwordStrength();
|
||||
validate("password"); // { valid: false, score: 0, minScore: 3 }
|
||||
validate("9!ApricotZebra-2024"); // { valid: true }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
// In your app's bootstrap module — register dictionaries once.
|
||||
import { zxcvbnOptions } from "@zxcvbn-ts/core";
|
||||
import { dictionary, adjacencyGraphs } from "@zxcvbn-ts/language-en";
|
||||
|
||||
zxcvbnOptions.setOptions({
|
||||
dictionary,
|
||||
graphs: adjacencyGraphs,
|
||||
translations: zxcvbnOptions.translations,
|
||||
});
|
||||
|
||||
// In your form definition — use the validator with a strict threshold.
|
||||
import { passwordStrength } from "@suaveplan/react-forms/validation/validators";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const validate = passwordStrength({
|
||||
minScore: 4,
|
||||
messageKey: "auth.password.weak" as TranslationKey,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `passwordStrength(opts?: PasswordStrengthOptions): Validator` —
|
||||
factory.
|
||||
- `opts.minScore?: 0|1|2|3|4` — minimum acceptable score. Default 3.
|
||||
- `opts.messageKey?: TranslationKey` — message key on failure.
|
||||
- `interface PasswordStrengthOptions { minScore?: PasswordStrengthScore;
|
||||
messageKey?: TranslationKey }`.
|
||||
- `type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4` — the zxcvbn score
|
||||
scale. Re-exported so consumers building strength-bar UIs can pin
|
||||
their own discriminated unions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The validator never calls `zxcvbnOptions.setOptions` — that is the
|
||||
consumer's responsibility at app bootstrap so dictionaries (which are
|
||||
large) are registered once globally.
|
||||
- Empty strings always fail with `score: 0` before invoking the
|
||||
estimator. Non-string inputs do the same.
|
||||
- `@zxcvbn-ts/core` is intentionally a `dependencies` rather than a
|
||||
`peerDependencies` entry: forms cannot enforce the consumer registers
|
||||
language packs, but the core estimator is required for the default
|
||||
validator to work at all (it returns score 0 with no dictionary,
|
||||
which still rejects most weak passwords).
|
||||
- For asynchronous strength estimation (e.g. against a server-side
|
||||
HIBP-style breach corpus), wrap `zxcvbnAsync` from `@zxcvbn-ts/core`
|
||||
in a `custom` async validator.
|
||||
@@ -0,0 +1,71 @@
|
||||
# `pattern.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied string matches a
|
||||
caller-supplied `RegExp`. The factory clones the regex without `g`/`y`
|
||||
flags so `RegExp.prototype.test` cannot leak `lastIndex` state between
|
||||
invocations — a footgun that breaks roughly half of all user-written
|
||||
form validators when the same validator is called twice.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.pattern`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strips `g` and `y` flags so `test()` is stateless across calls.
|
||||
- Throws `TypeError` from the factory when the supplied argument is
|
||||
not a `RegExp` — invalid configuration is loud, not silent.
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
- Non-string inputs always fail; pair with `required` for presence.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { pattern } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateSlug = pattern(/^[a-z0-9-]+$/);
|
||||
validateSlug("hello-world"); // { valid: true }
|
||||
validateSlug("Hello World"); // { valid: false, messageKey: "react_forms.validation.pattern" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { pattern } from "@suaveplan/react-forms/validation/validators";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const validateZip = pattern(
|
||||
/^\d{5}(?:-\d{4})?$/,
|
||||
"address.zip.invalid" as TranslationKey
|
||||
);
|
||||
|
||||
validateZip("90210"); // → { valid: true }
|
||||
validateZip("90210-1234"); // → { valid: true }
|
||||
validateZip("9021"); // → { valid: false, messageKey: "address.zip.invalid" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `pattern(re: RegExp, messageKey?: TranslationKey): Validator` —
|
||||
factory.
|
||||
- Throws `TypeError` if `re` is not a `RegExp`.
|
||||
- Returns a validator that yields `{ valid: true }` on match,
|
||||
`{ valid: false, messageKey }` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The flag-stripping is mandatory; without it,
|
||||
`pattern(/abc/g)("abc")` would return true on first call and false
|
||||
on second because `lastIndex` advances through `abc` and never
|
||||
resets.
|
||||
- The factory clones the regex via `new RegExp(re.source, safeFlags)`
|
||||
to insulate the validator from any subsequent mutation of the
|
||||
original instance (callers occasionally hand a regex out of a shared
|
||||
config object that other code later edits).
|
||||
- For more complex constraints (cross-field references, async checks),
|
||||
combine `pattern` with `compose` / `sequence` from
|
||||
`@suaveplan/react-forms/validation/compose`.
|
||||
@@ -0,0 +1,76 @@
|
||||
# `phone.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied string parses as a
|
||||
valid telephone number per Google's `libphonenumber-js`. Covers every
|
||||
ITU-T E.164 country code and every per-country numbering plan the
|
||||
upstream metadata corpus carries — the same corpus Android, the iOS
|
||||
Phone app, and Google Voice rely on for validation.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.phone`.
|
||||
|
||||
## Features
|
||||
|
||||
- Two acceptance modes: international (E.164 with leading `+`) and
|
||||
national (when a `region` is supplied).
|
||||
- Region argument typed as ISO 3166-1 alpha-2 (`"GB"`, `"US"`,
|
||||
`"FR"`, ...).
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { phone } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateGB = phone({ region: "GB" });
|
||||
validateGB("020 7946 0958"); // { valid: true }
|
||||
validateGB("not a phone"); // { valid: false }
|
||||
|
||||
const validateIntl = phone();
|
||||
validateIntl("+44 20 7946 0958"); // { valid: true }
|
||||
validateIntl("020 7946 0958"); // { valid: false } — needs leading +
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { phone } from "@suaveplan/react-forms/validation/validators";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const validateUsPhone = phone({
|
||||
region: "US",
|
||||
messageKey: "checkout.phone.invalid" as TranslationKey,
|
||||
});
|
||||
|
||||
validateUsPhone("(415) 555-2671"); // → { valid: true }
|
||||
validateUsPhone("nope"); // → { valid: false, messageKey: "checkout.phone.invalid" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `phone(opts?: PhoneValidatorOptions): Validator` — factory.
|
||||
- `opts.region?: string` — ISO 3166-1 alpha-2 country code; treated
|
||||
as the default country when the input is in national format.
|
||||
- `opts.messageKey?: TranslationKey` — message key on failure.
|
||||
- `interface PhoneValidatorOptions { region?: string; messageKey?:
|
||||
TranslationKey }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The validator is intentionally permissive on output format —
|
||||
libphonenumber-js accepts spaces, hyphens, dots, and parentheses as
|
||||
separators. Callers wanting a strict format requirement should layer
|
||||
`pattern` on top via `compose`.
|
||||
- The `libphonenumber-js` package ships ~120 KB of metadata even after
|
||||
tree-shaking. Consumers concerned about bundle size can swap to
|
||||
`libphonenumber-js/min` (the lighter metadata bundle) at the
|
||||
application level via Vite/Webpack alias — the validator's API
|
||||
surface is unchanged.
|
||||
- Empty strings always fail before the underlying parser is invoked.
|
||||
This is a small optimisation but matters for forms that re-validate
|
||||
on every keystroke.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `postcode.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied string matches the
|
||||
configured country's postcode format. v1 ships curated regexes for the
|
||||
five most common deployment countries — `GB`, `US`, `CA`, `DE`, `FR`.
|
||||
Additional countries are added per consumer request: the alternative
|
||||
(generating from a third-party data source) would introduce a hard
|
||||
runtime dependency without meaningfully improving coverage for the core
|
||||
five.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.postcode`.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-country regex registry — explicit, auditable, no runtime data
|
||||
source.
|
||||
- Throws `RangeError` from the factory when `country` is not in the
|
||||
supported set so misconfiguration surfaces at module evaluation
|
||||
rather than at form-submit time.
|
||||
- Trims whitespace before matching so `" SW1A 1AA "` validates the
|
||||
same as `"SW1A 1AA"`.
|
||||
- Exposes `SUPPORTED_POSTCODE_COUNTRIES` so consumer apps can render
|
||||
a country-aware postcode field UI without re-keeping the registry
|
||||
in sync.
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { postcode } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateGB = postcode({ country: "GB" });
|
||||
validateGB("SW1A 1AA"); // { valid: true }
|
||||
validateGB("12345"); // { valid: false }
|
||||
|
||||
const validateUS = postcode({ country: "US" });
|
||||
validateUS("90210"); // { valid: true }
|
||||
validateUS("90210-1234"); // { valid: true }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
postcode,
|
||||
SUPPORTED_POSTCODE_COUNTRIES,
|
||||
} from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
function makeValidator(country: string) {
|
||||
if (!SUPPORTED_POSTCODE_COUNTRIES.includes(country)) {
|
||||
return (() => ({ valid: true })) as Validator;
|
||||
}
|
||||
return postcode({
|
||||
country,
|
||||
messageKey: "address.postcode.invalid" as TranslationKey,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `postcode(opts: PostcodeValidatorOptions): Validator` — factory.
|
||||
- `opts.country: string` — ISO 3166-1 alpha-2 country code; case-
|
||||
insensitive.
|
||||
- `opts.messageKey?: TranslationKey` — message key on failure.
|
||||
- Throws `RangeError` when the country is not in the supported set.
|
||||
- `interface PostcodeValidatorOptions { country: string; messageKey?:
|
||||
TranslationKey }`.
|
||||
- `SUPPORTED_POSTCODE_COUNTRIES: readonly string[]` — the supported
|
||||
country codes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory uppercases the country code internally so callers can
|
||||
pass either case.
|
||||
- The five country regexes are commonly used in upstream libraries
|
||||
(`postal-codes-js`, `i18n-postal-address`); we ship them inline so
|
||||
the package stays leaf-clean and so the regexes are auditable from
|
||||
the diff.
|
||||
- For new country requests, file an OpenSpec change adding the regex +
|
||||
unit tests against the country's official format reference (Royal
|
||||
Mail PAF, USPS Pub 28, Canada Post Specs Manual, etc.).
|
||||
@@ -0,0 +1,81 @@
|
||||
# `required.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous presence validator for form fields. Returns success when the
|
||||
supplied value carries semantic content, failure otherwise. Unlike the
|
||||
typical "is it truthy?" check, this validator treats whitespace-only
|
||||
strings, empty arrays, and empty `Map` / `Set` instances as absent so a
|
||||
field full of spaces or an empty tag input fails just as a blank field
|
||||
would.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.required`. Callers may override the key per
|
||||
field — a registration form might want
|
||||
`auth.email.required` rather than the generic key — so the same
|
||||
validator factory can render any message the consumer's catalog
|
||||
declares.
|
||||
|
||||
## Features
|
||||
|
||||
- Presence semantics that catch real-world "blank" inputs: `null`,
|
||||
`undefined`, `""`, `" "`, `[]`, empty `Map`, empty `Set`.
|
||||
- Falsy primitives (`false`, `0`, `0n`) count as PRESENT so checkbox
|
||||
and numeric-zero inputs are not silently rejected.
|
||||
- Optional `messageKey` argument typed as `TranslationKey` so callers
|
||||
cannot pass a raw literal that would slip past the translation
|
||||
pipeline.
|
||||
- Pure factory — the validator returned has no closure over time, RNG,
|
||||
or network and is safe to call from any pipeline step.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { required } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateName = required();
|
||||
validateName("Jane"); // { valid: true }
|
||||
validateName(" "); // { valid: false, messageKey: "react_forms.validation.required" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { required } from "@suaveplan/react-forms/validation/validators";
|
||||
import { sequence } from "@suaveplan/react-forms/validation/compose";
|
||||
import { minLength } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateUsername = sequence(
|
||||
required("auth.username.required"),
|
||||
minLength(3, "auth.username.too_short")
|
||||
);
|
||||
|
||||
validateUsername(""); // → { valid: false, messageKey: "auth.username.required" }
|
||||
validateUsername("ab"); // → { valid: false, messageKey: "auth.username.too_short" }
|
||||
validateUsername("alice"); // → { valid: true }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `required(messageKey?: TranslationKey): Validator` — returns a
|
||||
`Validator<unknown>` that returns `{ valid: true }` when the value is
|
||||
present, `{ valid: false, messageKey }` otherwise. The default key is
|
||||
`react_forms.validation.required`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The presence check is intentionally narrower than `Boolean(value)` so
|
||||
legitimate falsy primitives are not counted as missing. This matches
|
||||
the behaviour every mature form library converges on (Yup, Zod,
|
||||
react-hook-form's `validate.required`).
|
||||
- Whitespace-only strings are stripped via `value.trim().length > 0`.
|
||||
Locale-aware whitespace (e.g. CJK ideographic space U+3000) is
|
||||
included via JavaScript's spec-compliant `String.prototype.trim`.
|
||||
- `Array.isArray` is used in preference to `instanceof Array` to handle
|
||||
cross-realm arrays, which appear when forms are rendered inside
|
||||
iframes.
|
||||
- `Map` and `Set` checks come AFTER the array check so `Array.isArray`
|
||||
short-circuits on the common case.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `url.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Synchronous validator that passes when the supplied value parses
|
||||
successfully via the WHATWG `URL` constructor and (optionally) matches
|
||||
a configured protocol allowlist. Using the native parser is both more
|
||||
correct than any hand-rolled regex (it handles IDN, percent-encoding,
|
||||
authority components, port ranges, and edge-case input gracefully) and
|
||||
ReDoS-safe by construction.
|
||||
|
||||
The default failure message resolves the `TranslationKey`
|
||||
`react_forms.validation.url`.
|
||||
|
||||
## Features
|
||||
|
||||
- WHATWG `URL`-backed parsing — every spec edge case handled by the
|
||||
runtime.
|
||||
- Optional `protocols` allowlist (e.g. `["http", "https"]`) so http(s)
|
||||
inputs can be required and `javascript:` URIs rejected.
|
||||
- Optional `messageKey` typed as `TranslationKey`.
|
||||
- Backwards-compatible single-argument form: `url("custom.key")` is
|
||||
equivalent to `url({ messageKey: "custom.key" })` so the validator
|
||||
composes uniformly with the other one-arg validators.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { url } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validate = url();
|
||||
validate("https://example.com"); // { valid: true }
|
||||
validate("not a url"); // { valid: false, messageKey: "react_forms.validation.url" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { url } from "@suaveplan/react-forms/validation/validators";
|
||||
|
||||
const validateWebUrl = url({
|
||||
protocols: ["http", "https"],
|
||||
messageKey: "profile.website.invalid" as TranslationKey,
|
||||
});
|
||||
|
||||
validateWebUrl("ftp://example.com"); // → { valid: false, messageKey: "profile.website.invalid" }
|
||||
validateWebUrl("javascript:alert(1)"); // → { valid: false, messageKey: "profile.website.invalid" }
|
||||
validateWebUrl("https://example.com/about"); // → { valid: true }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `url(opts?: UrlValidatorOptions | TranslationKey): Validator` —
|
||||
factory.
|
||||
- `opts.protocols?: readonly string[]` — protocols (without the
|
||||
trailing colon) accepted. When omitted, every protocol the runtime
|
||||
accepts is allowed.
|
||||
- `opts.messageKey?: TranslationKey` — message key on failure.
|
||||
- `interface UrlValidatorOptions { protocols?: readonly string[];
|
||||
messageKey?: TranslationKey }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The constructor's `try/catch` is the cheapest deterministic test for
|
||||
validity. The catch suppresses the `TypeError` thrown by `URL` on
|
||||
malformed input and converts it to a structured failure.
|
||||
- Protocol matching is case-insensitive because `URL.protocol` is
|
||||
always returned lowercase per the WHATWG URL spec.
|
||||
- The string-shorthand overload exists so callers writing
|
||||
`url("auth.url.invalid")` get the same surface as the other
|
||||
validators (`required`, `email`, `pattern`, ...). The function body
|
||||
unwraps the shorthand into the canonical options bag.
|
||||
21
docs/ui/react/README.md
Normal file
21
docs/ui/react/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# `react`
|
||||
|
||||
React hooks and components for audio, auth, and UI surfaces.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `react`
|
||||
|
||||
9 packages.
|
||||
|
||||
| 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 -->
|
||||
1
docs/ui/react/_intro.md
Normal file
1
docs/ui/react/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
React hooks and components for audio, auth, and UI surfaces.
|
||||
91
docs/ui/react/react-audio-fingerprint/README.md
Normal file
91
docs/ui/react/react-audio-fingerprint/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# @suaveplan/react-audio-fingerprint - React 19 surface for client-side audio fingerprinting (Shazam-style constellation algorithm) and identity matching. Consumes FFT/spectrogram primitives from @suaveplan/audio-analysis-spectrum so the audio family has a single FFT implementation. Universal fingerprint + matcher cores; React hook + accessible component on top.
|
||||
|
||||
React 19 surface for client-side audio fingerprinting and identity matching.
|
||||
The package implements the classic Shazam-style constellation algorithm
|
||||
(2D-peak detection + pair hashing + time-offset histogram matching) on top of
|
||||
the FFT/spectrogram primitives from
|
||||
[`@suaveplan/audio-analysis-spectrum`](../../../media/audio/audio-analysis-spectrum)
|
||||
— there is exactly **one** FFT implementation across the audio family.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
|
||||
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
|
||||
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
|
||||
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
|
||||
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
|
||||
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
|
||||
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
|
||||
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
|
||||
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
|
||||
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
|
||||
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
|
||||
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
|
||||
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
|
||||
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
|
||||
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
|
||||
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
|
||||
- [test-setup](./src/test-setup.md) — This file is loaded by `vitest.config.ts` via `setupFiles` so every test in the package runs against a consistent jest-…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Status
|
||||
|
||||
bootstrap landing — initial slice focused on:
|
||||
|
||||
- Six error codes via `defineCodes("REACT_AUDIO_FINGERPRINT", …)`.
|
||||
- `peak-detector`, `constellation`, `audio-fingerprinter`.
|
||||
- `fingerprint-matcher` (time-offset histogramming).
|
||||
- `memory-corpus` (`Map`-backed, async-shaped, subscribable).
|
||||
- `local-backend` (default `FingerprintBackend` running in-process).
|
||||
- `useAudioFingerprint` hook (driver with abort + progress).
|
||||
- `<FingerprintMatchDisplay>` component + stories.
|
||||
|
||||
Deferred to follow-up: `IndexedDbCorpus`, `<AudioFingerprintUploader>`,
|
||||
`<FingerprintVisualizer>`, `python-backend`, `__benchmarks__`, integration +
|
||||
purity + property + bundle-size tests.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import { decode } from "@suaveplan/audio-engine-browser/decode";
|
||||
import {
|
||||
LocalBackend,
|
||||
MemoryCorpus,
|
||||
FingerprintMatchDisplay,
|
||||
useAudioFingerprint,
|
||||
} from "@suaveplan/react-audio-fingerprint";
|
||||
|
||||
function MatchSurface({ file }: { file: File }) {
|
||||
const [doc, setDoc] = useState<AudioDocument | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
decode(file).then(setDoc);
|
||||
}, [file]);
|
||||
const { state, fingerprint } = useAudioFingerprint(doc);
|
||||
return state === "done"
|
||||
? <FingerprintMatchDisplay results={[]} />
|
||||
: <progress />;
|
||||
}
|
||||
```
|
||||
|
||||
## Algorithm
|
||||
|
||||
`AudioFingerprinter` composes three deterministic stages:
|
||||
|
||||
1. **Spectrogram** (delegated to `@suaveplan/audio-analysis-spectrum`).
|
||||
2. **2D peak detection** (`peak-detector`) — local-max sweep above the
|
||||
`noiseFloor`, capped at `peaksPerFrame` per frame.
|
||||
3. **Constellation pair hashing** (`constellation`) — 32-bit packed
|
||||
`binA[12] | binB[12] | dt[8]` over each anchor's `fanout` next peaks
|
||||
inside a `targetZoneSec` window.
|
||||
|
||||
Matching is identical to the original Wang (2003) paper: build a 10 ms-wide
|
||||
histogram of `(candidateOffset − queryOffset)` per shared hash, return the
|
||||
heaviest bin's count as the score and its midpoint as the alignment offset.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see `LICENSE` at the repo root.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# `local-backend` — Default in-process `FingerprintBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LocalBackend` is the default implementation of the `FingerprintBackend`
|
||||
contract that hooks and components depend on. It composes `AudioFingerprinter`
|
||||
and `FingerprintMatcher` so callers get a single facade with the same shape
|
||||
the optional `PythonBackend` exposes from its gated subpath. The class owns
|
||||
no state apart from its two collaborator instances.
|
||||
|
||||
## Features
|
||||
|
||||
- Single facade for `fingerprint(doc, opts?)` and `match(query, corpus)`.
|
||||
- `AbortSignal` and progress passthrough to the underlying fingerprinter.
|
||||
- Pure in-process: no I/O, no fetch, no server dependency.
|
||||
- Matches the `FingerprintBackend` interface byte-for-byte so it can be
|
||||
swapped at runtime via a hook prop.
|
||||
- Constructor takes no arguments — defaults rule.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
|
||||
|
||||
const backend = new LocalBackend();
|
||||
const fp = await backend.fingerprint(doc);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject the backend into the hook explicitly to keep the default exporting tree
|
||||
shaken when a custom backend is in use:
|
||||
|
||||
```ts
|
||||
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
|
||||
|
||||
const backend = new LocalBackend();
|
||||
const controller = new AbortController();
|
||||
const fp = await backend.fingerprint(doc, {
|
||||
signal: controller.signal,
|
||||
onProgress: ({ fraction }) => console.log(fraction),
|
||||
});
|
||||
const results = await backend.match(fp, corpus);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class LocalBackend implements FingerprintBackend`.
|
||||
- `fingerprint(doc, opts?): Promise<Fingerprint>`.
|
||||
- `match(query, corpus): Promise<MatchResult[]>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class deliberately avoids exposing the underlying `AudioFingerprinter` and
|
||||
`FingerprintMatcher` instances so consumers cannot mutate them after
|
||||
construction — this preserves determinism and keeps the public surface stable
|
||||
across versions. There is no caching layer here; consumers wishing to cache
|
||||
fingerprint results between renders should do so at the hook layer, where the
|
||||
relationship to the React commit phase is explicit. Errors from the
|
||||
underlying components propagate unchanged; the backend deliberately does NOT
|
||||
re-wrap them so the original `ReactAudioFingerprintError` code surfaces to the
|
||||
caller.
|
||||
@@ -0,0 +1,67 @@
|
||||
# `FingerprintMatchDisplay` — Ranked results list
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match`
|
||||
as an accessible ranked list. Each row shows the result identifier, a
|
||||
confidence bar whose width is proportional to the top score, and the recovered
|
||||
time offset in seconds. The component is intentionally lightweight — it has
|
||||
no fetching logic, no internal state, and no animation — so consumers can
|
||||
compose it inside any layout without conflicts.
|
||||
|
||||
## Features
|
||||
|
||||
- Empty-state message via `<p role="status">`.
|
||||
- Confidence bar with proper `role="progressbar"` and `aria-valuemin/max/now`.
|
||||
- Optional `onSelect` callback renders the row as a `<button>` for keyboard
|
||||
activation.
|
||||
- Optional `renderTitle` to override the default `id` label.
|
||||
- Accessible `aria-label` on the wrapping list (`"Match results"` default).
|
||||
- Zero side-effects; pure rendering function.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
|
||||
|
||||
<FingerprintMatchDisplay results={ranked} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Map IDs to friendlier titles and listen for selection:
|
||||
|
||||
```tsx
|
||||
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
|
||||
|
||||
<FingerprintMatchDisplay
|
||||
results={ranked}
|
||||
label="Top matches in your library"
|
||||
onSelect={(r) => navigateToTrack(r.id)}
|
||||
renderTitle={(r) => library.titles[r.id] ?? r.id}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FingerprintMatchDisplayProps = { results, label?, onSelect?, renderTitle? }`.
|
||||
- `results: ReadonlyArray<MatchResult>`.
|
||||
- `label?: string` (default `"Match results"`).
|
||||
- `onSelect?: (result: MatchResult) => void`.
|
||||
- `renderTitle?: (result: MatchResult) => string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component never mutates `props.results`. The confidence bar's width
|
||||
percentage is computed against the top score (`results[0].score`) so the best
|
||||
match always reads 100% and the rest scale proportionally — this provides an
|
||||
intuitive visual ranking regardless of the absolute hash counts. When
|
||||
`onSelect` is supplied each row renders as a `<button type="button">` so the
|
||||
default keyboard behaviour (Enter / Space) just works; without `onSelect` the
|
||||
rows are plain `<li>` to avoid spurious focus stops. The empty state uses
|
||||
`role="status"` (rather than rendering nothing) so assistive tech announces
|
||||
"no matches" rather than silently leaving the user in the dark.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `memory-corpus` — In-memory `Map`-backed fingerprint corpus
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MemoryCorpus` is the default `Corpus` implementation: a `Map<string,
|
||||
Fingerprint>` wrapped with a `Promise`-returning surface and a subscription
|
||||
hook. It satisfies the matcher's `CorpusLike` contract and the React hook's
|
||||
`subscribe` requirement, making it the simplest store you can plug into the
|
||||
fingerprint pipeline. The asynchronous API matches the `IndexedDbCorpus`
|
||||
follow-up so caller code does not need to branch on backing store.
|
||||
|
||||
## Features
|
||||
|
||||
- `add`, `remove`, `get`, `size`, `values`, `subscribe`.
|
||||
- Sync work behind an async surface — no I/O cost in tests.
|
||||
- Notifies subscribers on every successful mutation.
|
||||
- Throws `REACT_AUDIO_FINGERPRINT.CORPUS_FAILED` on invalid IDs.
|
||||
- `subscribe(listener)` returns an unsubscribe function (React-friendly).
|
||||
- Iteration order matches insertion order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
|
||||
|
||||
const corpus = new MemoryCorpus();
|
||||
await corpus.add("song-1", fingerprint);
|
||||
console.log(corpus.size()); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Subscribe to mutations from a `useSyncExternalStore` consumer and clean up on
|
||||
unmount:
|
||||
|
||||
```ts
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
|
||||
|
||||
const corpus = new MemoryCorpus();
|
||||
function useCorpusSize(): number {
|
||||
return useSyncExternalStore(
|
||||
(cb) => corpus.subscribe(cb),
|
||||
() => corpus.size(),
|
||||
() => 0
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MemoryCorpus implements Corpus`.
|
||||
- `add(id, fingerprint): Promise<void>`.
|
||||
- `remove(id): Promise<void>`.
|
||||
- `get(id): Promise<FingerprintCorpusEntry | undefined>`.
|
||||
- `size(): number`.
|
||||
- `values(): IterableIterator<FingerprintCorpusEntry>`.
|
||||
- `subscribe(listener): () => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The listener set is a `Set` so subscribe/unsubscribe is O(1) and duplicate
|
||||
registrations are de-duped. `remove` only notifies when the key actually
|
||||
existed; this matches the React convention that no-op mutations should not
|
||||
trigger a render. Empty-string IDs are rejected so consumers cannot create a
|
||||
ghost entry that `get("")` would later return — the matcher relies on `id`
|
||||
being a stable label. Iteration uses a generator so callers can `break` early
|
||||
without paying for a full copy of the store.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `error-codes` — Stable error registry for `@suaveplan/react-audio-fingerprint`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares every error code that `@suaveplan/react-audio-fingerprint`
|
||||
can throw. Each code is registered through `defineCodes` from
|
||||
`@suaveplan/error`, producing namespaced strings (e.g.
|
||||
`REACT_AUDIO_FINGERPRINT.DECODE_FAILED`) that downstream code, telemetry, and
|
||||
i18n catalogues can branch on without depending on free-form messages. The
|
||||
companion `ReactAudioFingerprintError` class extends `SuaveplanError`, carries
|
||||
the code as a structural field, and round-trips through the error wire format
|
||||
defined by the error package.
|
||||
|
||||
## Features
|
||||
|
||||
- Six explicit codes covering the full failure surface: decode, fingerprint,
|
||||
match, corpus, abort, and option validation.
|
||||
- Frozen metadata table keyed by code with `description` and `severity`.
|
||||
- `ReactAudioFingerprintError` class with stable `category = "react-audio-fingerprint"`.
|
||||
- Idempotent `registerReactAudioFingerprintErrorCodes()` hook for parity with
|
||||
sibling packages.
|
||||
- Strict TypeScript typing — codes are inferred from the literal tuple.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
} from "@suaveplan/react-audio-fingerprint/error-codes";
|
||||
|
||||
throw new ReactAudioFingerprintError(
|
||||
REACT_AUDIO_FINGERPRINT_CODES.DECODE_FAILED,
|
||||
"react-audio-fingerprint.decode.failed",
|
||||
{ context: { mime: "audio/mp3" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Discriminate caught errors by code and surface a structured toast:
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_FINGERPRINT_CODES,
|
||||
ReactAudioFingerprintError,
|
||||
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
|
||||
} from "@suaveplan/react-audio-fingerprint/error-codes";
|
||||
|
||||
try {
|
||||
await fingerprint(source);
|
||||
} catch (e) {
|
||||
if (e instanceof ReactAudioFingerprintError) {
|
||||
const meta = REACT_AUDIO_FINGERPRINT_ERROR_METADATA[e.code];
|
||||
if (e.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED) {
|
||||
return; // user-driven cancel, no toast
|
||||
}
|
||||
toast.error(`${meta.severity}: ${meta.description}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_FINGERPRINT_CODES` — frozen object whose keys/values are the
|
||||
six dotted code strings.
|
||||
- `ReactAudioFingerprintErrorCode` — union of valid code strings.
|
||||
- `REACT_AUDIO_FINGERPRINT_ERROR_METADATA` — frozen lookup keyed by code.
|
||||
- `ReactAudioFingerprintError extends SuaveplanError` — `code`, `messageKey`,
|
||||
optional `cause` / `context`, `category: "react-audio-fingerprint"`.
|
||||
- `registerReactAudioFingerprintErrorCodes()` — no-op registration hook for
|
||||
symmetry with packages that defer metadata registration to runtime.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The metadata table is `Object.freeze`d so consumers cannot mutate it. Every
|
||||
code in `REACT_AUDIO_FINGERPRINT_CODES` MUST have a metadata entry; the test
|
||||
suite asserts the two sets line up via `Object.keys`. The class sets
|
||||
`this.name` to include the severity for friendlier debugger output without
|
||||
mutating the underlying code, leaving structural equality intact. Registration
|
||||
runs at module load via the side-effectless hook so importing the barrel is
|
||||
sufficient to make codes discoverable from the central registry. No biome
|
||||
suppression comments are present; the file passes biome's strict ruleset
|
||||
unaided.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `audio-fingerprinter` — Spectrogram → peaks → constellation orchestrator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AudioFingerprinter` is the entry point that turns an `AudioDocument` into a
|
||||
deterministic constellation fingerprint. It composes three pieces: the
|
||||
spectrogram from `@suaveplan/audio-analysis-spectrum` (the ONLY FFT used by
|
||||
this package), the local-peak detector defined alongside this file, and the
|
||||
constellation pair-hasher. The class owns no DSP — every numerical step is
|
||||
delegated, which keeps the fingerprint algorithm in one auditable file.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options (`frameSize`, `hopSize`, `window`, `noiseFloor`,
|
||||
`peaksPerFrame`, `neighbourhood`, `fanout`, `targetZoneSec`).
|
||||
- Cooperative cancellation via `AbortSignal` checked at every pipeline stage.
|
||||
- Monotonic progress callback (`{ fraction }`) reported at 0, 0.5, 0.8, 1.
|
||||
- Wraps spectrogram failures in `REACT_AUDIO_FINGERPRINT.FINGERPRINT_FAILED`
|
||||
while preserving the underlying `cause`.
|
||||
- Stable output: identical input bytes produce a byte-equal fingerprint.
|
||||
- Returns the sample rate, hop, and frame size alongside the fingerprint so
|
||||
the matcher / visualizer can reconstruct frame ↔ time arithmetic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
|
||||
|
||||
const fp = await new AudioFingerprinter().fingerprint(doc);
|
||||
console.log(fp.fingerprint.length, "pairs");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Pass an `AbortSignal` and a progress callback while running a non-default
|
||||
window:
|
||||
|
||||
```ts
|
||||
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
|
||||
|
||||
const controller = new AbortController();
|
||||
const fp = await new AudioFingerprinter().fingerprint(doc, {
|
||||
window: "blackman",
|
||||
frameSize: 2048,
|
||||
hopSize: 512,
|
||||
signal: controller.signal,
|
||||
onProgress: ({ fraction }) => console.log(fraction),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class AudioFingerprinter`.
|
||||
- `fingerprint(doc, opts?): Promise<FingerprintResult>`.
|
||||
- `FingerprintResult = { fingerprint, sampleRate, hopSize, frameSize }`.
|
||||
- `FingerprintOptionsSchema` — Zod schema; defaults `frameSize=4096`,
|
||||
`hopSize=1024`, `window="hann"`, `targetZoneSec=5`.
|
||||
- `signal?: AbortSignal`, `onProgress?: ({ fraction }) => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`targetZoneFrames` is derived from `targetZoneSec` using the document's actual
|
||||
`sampleRate` and the configured `hopSize`. This keeps the target zone musically
|
||||
consistent across rates (e.g. 16 kHz speech vs 48 kHz music). The class only
|
||||
re-throws a `ReactAudioFingerprintError` when it can identify the original
|
||||
failure as an abort; everything else becomes `FINGERPRINT_FAILED` with the
|
||||
original error as `cause`. There is no caching — every call re-runs the
|
||||
spectrogram. Callers wanting to fingerprint the same document twice should
|
||||
memoise the result themselves.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `constellation` — Pair-hash construction over detected peaks
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The constellation module converts a list of spectrogram peaks into a stable,
|
||||
hashable fingerprint by pairing each anchor peak with several subsequent peaks
|
||||
inside a fixed target zone. Each pair `(binA, binB, Δframes)` is packed into a
|
||||
32-bit integer; the resulting `(hash, timeOffsetSec)` records form the
|
||||
fingerprint that the matcher consumes. This is the classic Shazam construction
|
||||
described by Wang (2003) without any modifications.
|
||||
|
||||
## Features
|
||||
|
||||
- 32-bit packed hash with explicit `binA[12] | binB[12] | dt[8]` layout.
|
||||
- `packHash` / `unpackHash` round-trip helpers for diagnostics and tests.
|
||||
- Configurable `fanout`, `targetZoneFrames`, `minDeltaFrames` via Zod schema.
|
||||
- Deterministic output sorted by anchor `timeOffsetSec`.
|
||||
- Empty-input safe (no peaks → empty fingerprint, no throw).
|
||||
- Validates `hopSamples` / `sampleRate` and rejects non-positive grids with
|
||||
`ReactAudioFingerprintError(INVALID_OPTIONS)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
|
||||
|
||||
const fp = buildConstellation(peaks, 512, 48_000);
|
||||
console.log(fp.length, fp[0]?.hash);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Tighten the target zone for short percussive clips:
|
||||
|
||||
```ts
|
||||
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
|
||||
|
||||
const fp = buildConstellation(peaks, 256, 48_000, {
|
||||
fanout: 8,
|
||||
targetZoneFrames: 80,
|
||||
minDeltaFrames: 2,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildConstellation(peaks, hopSamples, sampleRate, opts?): Fingerprint`.
|
||||
- `Fingerprint = ReadonlyArray<{ hash: number; timeOffsetSec: number }>`.
|
||||
- `packHash(binA, binB, dt): number` — 32-bit pack.
|
||||
- `unpackHash(h): { binA; binB; dt }` — inverse.
|
||||
- `ConstellationOptionsSchema` — Zod schema with defaults `fanout=5`,
|
||||
`targetZoneFrames=200`, `minDeltaFrames=1`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Δt field is clamped to 8 bits, which limits the effective target zone to
|
||||
255 hop-frames regardless of the schema value. At the default 512-sample hop
|
||||
and 48 kHz sample rate this is ~2.72 seconds — well inside the classic
|
||||
constellation target-zone width. Hashes are unsigned 32-bit integers; the
|
||||
`>>> 0` at the end of `packHash` reinterprets the bitwise result, which is
|
||||
otherwise a signed 32-bit integer in JavaScript's number type. Output sorting
|
||||
makes equal-fingerprint comparison trivial (`JSON.stringify` round-trip is
|
||||
enough for byte equality). Pair construction is O(`peaks × fanout`) because
|
||||
each anchor stops emitting once the fanout cap is hit or the target zone is
|
||||
exhausted.
|
||||
@@ -0,0 +1,67 @@
|
||||
# `fingerprint-matcher` — Time-offset histogram match scoring
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FingerprintMatcher` scores a query fingerprint against a corpus of candidate
|
||||
fingerprints using the classic Shazam time-offset histogramming technique. For
|
||||
every hash that appears in both query and candidate, the matcher accumulates
|
||||
`(candidateOffset − queryOffset)` into a 10 ms-wide histogram bucket; the
|
||||
heaviest bucket count is the match score and its midpoint is the alignment
|
||||
offset. Results sort by descending score, ready for ranked display.
|
||||
|
||||
## Features
|
||||
|
||||
- `match(query, corpus)` returns ranked `MatchResult[]`.
|
||||
- `matchOne(query, candidate)` returns the best bin only — useful for unit
|
||||
tests and direct pairwise comparison.
|
||||
- Configurable `binWidthSec` (default 0.01 s).
|
||||
- Hash-index pre-step keeps the cost at `O(|query| + |candidate|)` per pair.
|
||||
- Wraps iteration-time corpus failures in
|
||||
`REACT_AUDIO_FINGERPRINT.MATCH_FAILED`.
|
||||
- Empty inputs return zero-scored results without throwing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
|
||||
|
||||
const ranked = new FingerprintMatcher().match(queryFp, corpus);
|
||||
console.log(ranked[0]?.id, ranked[0]?.score);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive a tighter alignment window for short clips:
|
||||
|
||||
```ts
|
||||
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
|
||||
|
||||
const m = new FingerprintMatcher();
|
||||
const one = m.matchOne(queryFp, candidateFp, { binWidthSec: 0.005 });
|
||||
console.log(one.score, one.offsetSec, one.matchedHashCount);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FingerprintMatcher`.
|
||||
- `match(query, corpus, opts?): MatchResult[]`.
|
||||
- `matchOne(query, candidate, opts?): MatchOneResult`.
|
||||
- `MatchResult = { id, score, offsetSec, matchedHashCount }`.
|
||||
- `CorpusLike = { values(): Iterable<{ id; fingerprint }> }`.
|
||||
- `MatcherOptions = { binWidthSec?: number }` (default `0.01`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
A candidate's hash table is built lazily on each call — caching is left to
|
||||
callers because corpora are typically streamed. Hash indices use `Map<number,
|
||||
number[]>` since duplicate hashes inside one fingerprint are common (the same
|
||||
constellation pair can recur at different times). The histogram is also a
|
||||
`Map<number, number>` because the bucket range is unbounded in principle.
|
||||
Bucket midpoints (rather than left edges) keep the reported offset symmetric
|
||||
around the true alignment. The matcher is pure — given the same inputs it
|
||||
produces the same outputs — so callers can safely memoise results keyed by
|
||||
`(queryFp, corpusVersion)`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `peak-detector` — 2D spectrogram local-peak detection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The peak detector consumes a spectrogram (a sequence of FFT magnitude frames
|
||||
produced by `@suaveplan/audio-analysis-spectrum`) and returns the time-frequency
|
||||
coordinates of cells that dominate their local neighbourhood. The output drives
|
||||
constellation hashing, which is the core of every Shazam-style fingerprint
|
||||
algorithm. The detector is deterministic — identical input produces a
|
||||
byte-equal peak list — and is the only place in the package where raw
|
||||
spectrogram cells are inspected.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable square `neighbourhood` radius (default 3).
|
||||
- `noiseFloor` threshold (linear magnitude, default 1e-3 ≈ −60 dBFS).
|
||||
- `peaksPerFrame` cap (default 5) — bounds memory and downstream hash count.
|
||||
- Deterministic ordering: ascending frame, then descending magnitude.
|
||||
- Zod-validated options with a `ReactAudioFingerprintError(INVALID_OPTIONS)` on
|
||||
invalid input.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
|
||||
|
||||
const peaks = detectPeaks(spectrogram);
|
||||
console.log(peaks.length, peaks[0]);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Tighten the neighbourhood and lower the floor to catch quiet harmonics in a
|
||||
chirp:
|
||||
|
||||
```ts
|
||||
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
|
||||
|
||||
const peaks = detectPeaks(spectrogram, {
|
||||
neighbourhood: 2,
|
||||
noiseFloor: 1e-5,
|
||||
peaksPerFrame: 8,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `detectPeaks(spectrogram, opts?): Peak[]` — runs the 2D local-max sweep.
|
||||
- `Peak = { frame: number; bin: number; magnitude: number }`.
|
||||
- `PeakDetectorOptionsSchema` — Zod schema; defaults shown above.
|
||||
- `PeakDetectorOptionsInput` — pre-parse input shape (everything optional).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation uses a straightforward `O(F · B · N²)` sweep where `F` is
|
||||
the frame count, `B` is the bin count, and `N` is the neighbourhood radius. The
|
||||
constants in the spec (default `N=3`, `peaksPerFrame=5`, `bins≈1025` for a
|
||||
2048-point FFT) keep the cost firmly in `Float32Array` index arithmetic — no
|
||||
heap allocations inside the inner loop other than the per-frame `frameHits`
|
||||
array, which is bounded by the bin count. Sorting by descending magnitude and
|
||||
truncating to `peaksPerFrame` ensures the loudest local maxima win when more
|
||||
candidates qualify than the per-frame cap allows; ties resolve to the lower
|
||||
bin via the stable native sort. Empty spectrograms produce an empty array
|
||||
without throwing; downstream constellation hashing handles that case by
|
||||
emitting no pairs.
|
||||
@@ -0,0 +1,71 @@
|
||||
# `useAudioFingerprint` — React hook for the fingerprint pipeline
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a
|
||||
React-managed `AudioDocument` and returns the live state, the resulting
|
||||
fingerprint when done, a `0..1` progress fraction, and any error encountered.
|
||||
The hook centralises the abort-on-unmount and progress wiring so component
|
||||
authors do not have to repeat that work across uploaders, visualisers, and
|
||||
match displays.
|
||||
|
||||
## Features
|
||||
|
||||
- `source` can be `undefined`/`null` to mean "do nothing" — useful while
|
||||
waiting for an upload to decode.
|
||||
- `state` cycles through `'idle'` → `'fingerprinting'` → `'done'` (or
|
||||
`'error'`); a fresh source resets the cycle.
|
||||
- Abort-on-unmount and abort-on-source-change via internal `AbortController`.
|
||||
- Backend swap via `options.backend`; defaults to `LocalBackend`.
|
||||
- Wraps non-`ReactAudioFingerprintError` rejections in `FINGERPRINT_FAILED`.
|
||||
- Suppresses `ABORTED` errors — they settle back to `'idle'`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
|
||||
|
||||
function FingerprintPreview({ doc }: { doc: AudioDocument }) {
|
||||
const { state, fingerprint, progress } = useAudioFingerprint(doc);
|
||||
if (state === "fingerprinting") return <progress value={progress} />;
|
||||
if (state === "done") return <span>{fingerprint?.length} pairs</span>;
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject a custom backend (e.g. the optional Python backend) and surface errors:
|
||||
|
||||
```tsx
|
||||
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
|
||||
|
||||
function Surface({ doc, backend }: { doc: AudioDocument; backend: FingerprintBackend }) {
|
||||
const { state, error, fingerprint } = useAudioFingerprint(doc, { backend });
|
||||
if (state === "error") return <Banner code={error?.code}>{error?.message}</Banner>;
|
||||
if (state === "done") return <Result fp={fingerprint!} />;
|
||||
return <Spinner />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAudioFingerprint(source, options?): UseAudioFingerprintResult`.
|
||||
- `source: AudioDocument | undefined | null`.
|
||||
- `options?: { backend?: FingerprintBackend }`.
|
||||
- `UseAudioFingerprintResult = { state, fingerprint, progress, error }`.
|
||||
- `state: 'idle' | 'fingerprinting' | 'done' | 'error'`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook uses `useMemo` to stabilise the default `LocalBackend` so changing
|
||||
unrelated props does not retrigger the effect. The `alive` flag guarding all
|
||||
post-await `setState` calls is the canonical React pattern for protecting
|
||||
against the lifecycle race between an async fetch completing and a fast
|
||||
unmount. Aborts settle to `'idle'` (not `'error'`) so user-driven cancels do
|
||||
not produce a banner. Non-Suaveplan errors get wrapped so consumers can always
|
||||
discriminate via `error.code` without an `instanceof` chain.
|
||||
58
docs/ui/react/react-audio-fingerprint/src/test-setup.md
Normal file
58
docs/ui/react/react-audio-fingerprint/src/test-setup.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# `test-setup` — Vitest global setup
|
||||
|
||||
## Purpose
|
||||
|
||||
This file is loaded by `vitest.config.ts` via `setupFiles` so every test in
|
||||
the package runs against a consistent jest-dom matcher set and tears down its
|
||||
React tree after each case. It is intentionally short — heavy work happens in
|
||||
the runner-agnostic polyfills shipped by `@suaveplan/testing/polyfills`, which
|
||||
this file complements with DOM-specific affordances.
|
||||
|
||||
## Features
|
||||
|
||||
- Imports `@testing-library/jest-dom/vitest` to register matchers such as
|
||||
`.toBeInTheDocument()` and `.toHaveAccessibleName()`.
|
||||
- Registers an `afterEach` that calls `cleanup()` from
|
||||
`@testing-library/react`, unmounting trees between tests and avoiding
|
||||
cross-test DOM pollution.
|
||||
- No mocks, no spies, no global stubs — keeps the suite faithful to the
|
||||
CLAUDE.md "no mocks" rule.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
`vitest.config.ts` references this file:
|
||||
|
||||
```ts
|
||||
setupFiles: [
|
||||
"@suaveplan/testing/polyfills",
|
||||
"./src/test-setup.ts",
|
||||
],
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
If a future component needs an additional matcher (for example to assert
|
||||
custom accessibility properties), add the relevant import here so every test
|
||||
in the package benefits without per-file boilerplate:
|
||||
|
||||
```ts
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "@suaveplan/testing/runner";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
// (Add additional matchers or global afterEach hooks here.)
|
||||
afterEach(() => cleanup());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- No exports; runs purely for side-effects at vitest setup time.
|
||||
- Depends on `@suaveplan/testing/runner` for `afterEach` (rule 11 compliance).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The setup file runs once per worker, so the `cleanup` afterEach registration
|
||||
is shared across every test file in that worker. This is the canonical
|
||||
pattern documented by Testing Library and matches every sibling Suaveplan
|
||||
React package's setup. The package owns no provider configuration so there is
|
||||
no provider-tree wrapper to install — tests render components directly via
|
||||
`@testing-library/react`'s `render` / `renderHook`.
|
||||
136
docs/ui/react/react-audio-player/README.md
Normal file
136
docs/ui/react/react-audio-player/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# @suaveplan/react-audio-player - Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.
|
||||
|
||||
Track-style media-player surface for the Suaveplan audio family.
|
||||
The package ships two kinds of exports:
|
||||
|
||||
1. **Karaoke** — a self-contained LRC parser, `useLyrics` hook, and
|
||||
`<LyricsDisplay>` component implemented in this package.
|
||||
2. **Re-export façade** — namespace and flat aliases for the player
|
||||
UI that already lives in
|
||||
[`@suaveplan/react-audio`](../react-audio/README.md):
|
||||
`<AudioProvider>`, `<AudioPlayer>`, `<BasicPlayer>`,
|
||||
`<PremiumPlayer>`, `<TransportControls>`, `<Mixer>`,
|
||||
`<PluginChainView>`, `<PresetManager>`, plus hooks
|
||||
(`useTransport`, `useTransportTime`, `useAnalyser`, `usePlaylist`,
|
||||
…).
|
||||
|
||||
Consumers can either import directly from `@suaveplan/react-audio`
|
||||
(the original home of those components) or from
|
||||
`@suaveplan/react-audio-player`, whichever reads better in their
|
||||
codebase. The named entry points resolve to the same singletons via
|
||||
ESM re-export — no duplication, no extra bundle weight.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
|
||||
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
|
||||
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
|
||||
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
|
||||
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
|
||||
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
|
||||
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Status
|
||||
|
||||
This package targets the *additive* scope of the
|
||||
`enterprise-react-audio-player` change. The pre-existing player
|
||||
components (`<BasicPlayer>`, `<AudioPlayer>`, `<PremiumPlayer>`,
|
||||
visualisers, transport controls, mixer, preset manager) already live
|
||||
in `@suaveplan/react-audio`; duplicating them here would violate the
|
||||
"do not duplicate enterprise components" guidance. Instead this
|
||||
package adds:
|
||||
|
||||
- The karaoke surface — LRC parser, `useLyrics`, `<LyricsDisplay>`.
|
||||
- A namespaced re-export façade (`ReactAudio.*`) plus flat aliases
|
||||
for the most common entry points.
|
||||
- Stable `REACT_AUDIO_PLAYER.*` error codes.
|
||||
|
||||
### Deferred scope
|
||||
|
||||
- **Multi-peer playback sync** (`useMultiPeerSync`) is deferred until
|
||||
`@suaveplan/react-audio-stream` exists . The spec
|
||||
requirement is retained in the canonical capability spec with a
|
||||
`> Deferred (multi-peer)` marker; once the dependency lands, the
|
||||
hook adds in a follow-up change without breaking this package's
|
||||
public surface.
|
||||
- R3F screensavers (`<GLBViewer>`, `<ShaderBackground>`) — the spec
|
||||
requirement is retained; consumers needing these today can
|
||||
compose any R3F surface as a sibling of `<PremiumPlayer>`. The
|
||||
optional `./screensavers/*` subpaths will be filled by a focused
|
||||
follow-up change.
|
||||
|
||||
The two hooks consumed by visualisers (`useAnalyser`) and queues
|
||||
(`usePlaylist`) **are** shipped — they were added to
|
||||
`@suaveplan/react-audio` alongside this package and are re-exported
|
||||
here for ergonomic reach.
|
||||
|
||||
## Quick start — karaoke
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AudioProvider,
|
||||
AudioPlayer,
|
||||
LyricsDisplay,
|
||||
} from "@suaveplan/react-audio-player";
|
||||
|
||||
function App({ track, lrc }: { track: string; lrc: string }) {
|
||||
return (
|
||||
<AudioProvider options={{ /* … */ }}>
|
||||
<AudioPlayer source={track} />
|
||||
<KaraokeLine lrc={lrc} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
function KaraokeLine({ lrc }: { lrc: string }) {
|
||||
const seconds = useTransportTime();
|
||||
return <LyricsDisplay lrc={lrc} currentSec={seconds} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Quick start — standalone preview (no provider)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
LyricsDisplay,
|
||||
} from "@suaveplan/react-audio-player";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function Preview({ lrc }: { lrc: string }) {
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
const [t, setT] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={0.1}
|
||||
value={t}
|
||||
onChange={(e) => setT(Number(e.currentTarget.value))}
|
||||
aria-label="time"
|
||||
/>
|
||||
<LyricsDisplay lyrics={lyrics} currentSec={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
| Subpath | Surface |
|
||||
| --- | --- |
|
||||
| `.` | Root barrel — karaoke + flat aliases + `ReactAudio` namespace + error codes. |
|
||||
| `./karaoke` | All karaoke exports. |
|
||||
| `./karaoke/lrc-parser` | `parseLrc`, `activeLyricIndex`, types. |
|
||||
| `./karaoke/use-lyrics` | `useLyrics` and types. |
|
||||
| `./karaoke/lyrics-display` | `<LyricsDisplay>` and types. |
|
||||
| `./error-codes` | `REACT_AUDIO_PLAYER.*` registry. |
|
||||
|
||||
See the per-feature `.md` files for full API references.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/react-audio-player`.
|
||||
Every error thrown by a public surface in this package carries a
|
||||
code from `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n
|
||||
resolution, and an optional `context` / `cause` chain. Codes are
|
||||
namespaced under the `REACT_AUDIO_PLAYER.` prefix so the wire format
|
||||
`{ code: "REACT_AUDIO_PLAYER.LRC_PARSE_FAILED" }` is greppable and
|
||||
stable across versions.
|
||||
|
||||
The registry is the single source of truth for the package's error
|
||||
contract — the test suite walks `REACT_AUDIO_PLAYER_ERROR_METADATA`
|
||||
and confirms every code is reachable from a real call site, so a
|
||||
code that gets added without a thrower (or a thrower whose code
|
||||
gets removed) is a build break.
|
||||
|
||||
## Features
|
||||
|
||||
- Five public codes: `NO_PROVIDER`, `PLAYLIST_EMPTY`,
|
||||
`PERSISTENCE_FAILED`, `LRC_PARSE_FAILED`, `INVALID_OPTIONS`.
|
||||
- Frozen metadata map keyed by code; every entry has a `description`
|
||||
and `severity` (`"error"` | `"warning"`).
|
||||
- `ReactAudioPlayerError` class extends `SuaveplanError`; carries a
|
||||
stable `category = "react-audio-player"` field.
|
||||
- Idempotent `registerReactAudioPlayerErrorCodes()` for parity with
|
||||
sibling audio packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
ReactAudioPlayerError,
|
||||
} from "@suaveplan/react-audio-player/error-codes";
|
||||
|
||||
if (!provider) {
|
||||
throw new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.NO_PROVIDER,
|
||||
"react-audio-player.no-provider"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
} from "@suaveplan/react-audio-player/error-codes";
|
||||
|
||||
try {
|
||||
parseLrc(input);
|
||||
} catch (cause) {
|
||||
const code = REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED;
|
||||
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
|
||||
throw new ReactAudioPlayerError(code, "lrc.parse.failed", {
|
||||
cause,
|
||||
context: { severity: meta.severity, bytes: input.length },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_PLAYER_CODES` — `Readonly<Record<…, string>>` of the
|
||||
five public codes (frozen).
|
||||
- `ReactAudioPlayerErrorCode` — union of the code string-literals.
|
||||
- `REACT_AUDIO_PLAYER_ERROR_METADATA` — frozen metadata map.
|
||||
- `ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode>`.
|
||||
- `registerReactAudioPlayerErrorCodes(): void` — idempotent no-op.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is the workspace's standard helper for producing
|
||||
namespaced code maps; it freezes the result and asserts that every
|
||||
key is a valid identifier at module load. Metadata is declared with
|
||||
`Object.freeze` so consumers (and the test suite) cannot mutate it
|
||||
at runtime.
|
||||
|
||||
`SuaveplanError` is the universal base error from
|
||||
`@suaveplan/error`; it carries `code`, `messageKey`, `context`, and
|
||||
`cause` plus a stable wire-format serialisation. The
|
||||
`category = "react-audio-player"` literal is the discriminator used
|
||||
by the cross-package error router when an error crosses a package
|
||||
boundary.
|
||||
|
||||
`name` is set to `ReactAudioPlayerError(<severity>)` so stack-trace
|
||||
output includes the severity at a glance. This is a string-only
|
||||
augmentation; it does not change the prototype chain so
|
||||
`instanceof ReactAudioPlayerError` still narrows correctly.
|
||||
@@ -0,0 +1,106 @@
|
||||
# `karaoke/lrc-parser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseLrc(source, opts?)` parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
|
||||
karaoke source into a timestamp-sorted array of `Lyric` entries
|
||||
suitable for the package's `<LyricsDisplay>` and any consumer-built
|
||||
karaoke surface. The parser is universal — it has no React, DOM, or
|
||||
audio-context dependency — so it is safe to call in workers, on the
|
||||
server during SSR, or inside other Suaveplan tools that need to
|
||||
preview lyric timing.
|
||||
|
||||
The companion `activeLyricIndex(lyrics, currentSec)` performs a
|
||||
binary search for the active line and is the bridge between the
|
||||
transport's `seconds` and the `<LyricsDisplay>` highlight state.
|
||||
Both functions are pure; the parser throws structured
|
||||
`ReactAudioPlayerError` for the two invalid-input cases.
|
||||
|
||||
## Features
|
||||
|
||||
- Recognises `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, and
|
||||
`[h:mm:ss(.xxx)]` timestamp forms.
|
||||
- Expands multi-timestamp lines (`[00:10][00:20]hello`) by default;
|
||||
togglable via `expandMultiTimestamp`.
|
||||
- Drops the LRC metadata tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]`,
|
||||
`[by:]`, `[offset:]`, `[re:]`, `[ve:]` — no extraction in v1.
|
||||
- Skips empty lines, malformed lines, and lines with no leading
|
||||
timestamp.
|
||||
- Output is sorted ascending by `timestampSec` and frozen.
|
||||
- Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
|
||||
or input exceeding `maxLength` (default 1 MiB).
|
||||
- `activeLyricIndex` is O(log n) and allocation-free.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
|
||||
const lyrics = parseLrc(`
|
||||
[ar:Test Artist]
|
||||
[00:10.50]First line
|
||||
[00:25.00]Second line
|
||||
`);
|
||||
// → [{ timestampSec: 10.5, text: "First line" },
|
||||
// { timestampSec: 25.0, text: "Second line" }]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
activeLyricIndex,
|
||||
} from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function LyricLine({ source }: { source: string }) {
|
||||
const lyrics = useMemo(
|
||||
() => parseLrc(source, { expandMultiTimestamp: false }),
|
||||
[source]
|
||||
);
|
||||
const seconds = useTransportTime();
|
||||
const idx = activeLyricIndex(lyrics, seconds);
|
||||
return (
|
||||
<span aria-live="polite">
|
||||
{idx >= 0 ? lyrics[idx].text : "…"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseLrc(source: unknown, opts?: ParseLrcOptions): readonly Lyric[]`
|
||||
- `activeLyricIndex(lyrics: readonly Lyric[], currentSec: number): number`
|
||||
- `Lyric` — `{ timestampSec: number; text: string }`.
|
||||
- `ParseLrcOptions` — `maxLength?: number` (default 1 MiB),
|
||||
`expandMultiTimestamp?: boolean` (default `true`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The timestamp regex `/\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g`
|
||||
captures optional hours, minutes, seconds, and an optional
|
||||
fractional component. The fractional value is padded right with
|
||||
zeros to three digits then divided by 1000, so `.5` parses to 0.500
|
||||
seconds, `.50` to 0.500, and `.500` to 0.500 — all equivalent. Only `.` is accepted as the
|
||||
fractional separator; the `:` form would conflict with the
|
||||
`[h:mm:ss]` extended timestamp grammar.
|
||||
|
||||
Multi-timestamp expansion walks the regex's global state, but only
|
||||
honours timestamps that form a contiguous prefix of the line. A
|
||||
bracketed sequence later in the line is treated as text — many
|
||||
karaoke services emit `[00:10]hello [00:11]world` to mean two
|
||||
separate lyric lines and this parser handles both layouts
|
||||
correctly.
|
||||
|
||||
`activeLyricIndex` uses the upper-bound binary search idiom: pick
|
||||
the greatest index whose timestamp is ≤ current. When `current` is
|
||||
between two entries, the earlier entry wins; when `current`
|
||||
precedes the first entry, `-1` is returned (so the consumer can
|
||||
render an empty active line until the first cue).
|
||||
@@ -0,0 +1,109 @@
|
||||
# `karaoke/lyrics-display`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<LyricsDisplay>` is the karaoke surface that renders a stack of
|
||||
lyric lines with the active line highlighted. It is the visible
|
||||
end-product of the `parseLrc` → `useLyrics` pipeline and the
|
||||
component the player variants compose when their `lrc` prop is
|
||||
populated.
|
||||
|
||||
The component is clock-agnostic by design: the caller passes
|
||||
`currentSec` (a plain number) and the component renders the
|
||||
highlighted line. The typical wiring is `useTransportTime()` from
|
||||
`@suaveplan/react-audio` when the display sits inside an
|
||||
`<AudioProvider>`, but standalone preview and test surfaces can
|
||||
hand in any number — Playwright spec runs use a deterministic
|
||||
clock; documentation pages drive a `<range>` input. Keeping the time
|
||||
source explicit removes a hidden provider dependency.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders one `<span>` per lyric line with `data-active="true|false"`
|
||||
for CSS-driven styling; consumers needing structural control can
|
||||
pass `renderLine` for full custom rendering.
|
||||
- Active line carries `aria-live="polite"` by default so screen
|
||||
readers announce lyric changes; togglable via `announceLyrics`.
|
||||
- Wrapping `<div>` exposes `role="region"` + `aria-label="Lyrics"`.
|
||||
- Re-binds the active index every 100 ms (configurable) regardless
|
||||
of the consumer's render cadence.
|
||||
- Both time-source modes share the same internal render path; the
|
||||
outer dispatch is a one-time component-type pick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function Player({ lrcSource }: { lrcSource: string }) {
|
||||
const seconds = useTransportTime();
|
||||
return <LyricsDisplay lrc={lrcSource} currentSec={seconds} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
|
||||
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function StyledLyrics({ lrc, time }: { lrc: string; time: number }) {
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
return (
|
||||
<LyricsDisplay
|
||||
lyrics={lyrics}
|
||||
currentSec={time}
|
||||
intervalMs={50}
|
||||
announceLyrics={false}
|
||||
renderLine={({ lyric, isActive }) => (
|
||||
<p
|
||||
style={{
|
||||
opacity: isActive ? 1 : 0.4,
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{lyric.text || <span aria-hidden="true">·</span>}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<LyricsDisplay>` accepts `LyricsDisplayProps`:
|
||||
- `lyrics?: readonly Lyric[]` — preferred over `lrc`.
|
||||
- `lrc?: string` — parsed once per identity change.
|
||||
- `currentSec: number` — required.
|
||||
- `intervalMs?: number` (default 100).
|
||||
- `announceLyrics?: boolean` (default `true`).
|
||||
- `className?: string`.
|
||||
- `renderLine?: ({ lyric, isActive, index }) => ReactNode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component is a single-branch render — no dual-component
|
||||
dispatch — because `currentSec` is always a plain prop. This keeps
|
||||
the Rules of Hooks trivially satisfied (one render path, one hook
|
||||
sequence) and removes a hidden coupling to the surrounding
|
||||
`<AudioProvider>`. Consumers that want the transport-driven path
|
||||
call `useTransportTime()` themselves and pass the result down.
|
||||
|
||||
The `data-active="true|false"` attribute is the recommended CSS
|
||||
selector hook. Toggling `aria-live` only on the active line ensures
|
||||
exactly one element narrates per change cycle; without that targeted
|
||||
gating, every line's render would re-announce on every re-mount,
|
||||
which is acoustically unusable.
|
||||
|
||||
When `renderLine` is supplied, the component delegates to it
|
||||
verbatim and skips the default `<span>` + `data-active` path. The
|
||||
delegated node is wrapped in a keyed `<Fragment>` so consumers can
|
||||
return any element they like — `<li>`, `<p>`, `<div>` — without
|
||||
fighting an enclosing wrapper.
|
||||
@@ -0,0 +1,121 @@
|
||||
# `karaoke/use-lyrics`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the
|
||||
active-line state, samples it at a fixed interval (100 ms by
|
||||
default), and returns `{ lyrics, activeIndex, activeLyric }`. The
|
||||
consumer hands in either pre-parsed `Lyric[]` or raw LRC source,
|
||||
plus the current playback time in seconds. The hook deliberately
|
||||
does not call `useTransportTime` itself so it can be driven from any
|
||||
clock — `<LyricsDisplay>` wires the transport in for you, but a
|
||||
preview panel running under a deterministic test clock can call this
|
||||
hook directly.
|
||||
|
||||
The 100 ms re-bind ceiling matches the spec requirement that the
|
||||
active lyric advance within 100 ms of the transport. Sampling at
|
||||
that cadence (rather than per-frame) saves a substantial amount of
|
||||
React commit work on songs with hundreds of lyric lines.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure: no audio context, no global side-effect.
|
||||
- Pre-parsed `lyrics` or raw `lrc` source — both accepted; if both
|
||||
provided, `lyrics` wins (no double work).
|
||||
- Configurable `intervalMs` (default 100). The hook re-evaluates
|
||||
the active line on each tick and only commits when the index
|
||||
actually changes — silent renders cost nothing.
|
||||
- Returns `activeLyric: undefined` before the first cue or when
|
||||
`lyrics` is empty.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useLyrics } from "@suaveplan/react-audio-player/karaoke/use-lyrics";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function Lyrics({ lrc }: { lrc: string }) {
|
||||
const seconds = useTransportTime();
|
||||
const { activeLyric } = useLyrics({ lrc, currentSec: seconds });
|
||||
return <span aria-live="polite">{activeLyric?.text ?? "…"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
useLyrics,
|
||||
} from "@suaveplan/react-audio-player/karaoke";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
function StandaloneLyricPreview({ lrc }: { lrc: string }) {
|
||||
// Parse once at the call site rather than on every mount.
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
// Deterministic clock — no <AudioProvider> required.
|
||||
const [tick, setTick] = useState(0);
|
||||
const { activeIndex, activeLyric, lyrics: ls } = useLyrics({
|
||||
lyrics,
|
||||
currentSec: tick * 0.5,
|
||||
intervalMs: 50,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setTick((t) => t + 1)}>
|
||||
+0.5 s
|
||||
</button>
|
||||
<ol>
|
||||
{ls.map((l, i) => (
|
||||
<li
|
||||
key={`${l.timestampSec}-${i}`}
|
||||
data-active={i === activeIndex}
|
||||
>
|
||||
{l.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p>Active: {activeLyric?.text ?? "—"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useLyrics(opts: UseLyricsOptions): UseLyricsResult`
|
||||
- `UseLyricsOptions`:
|
||||
- `lyrics?: readonly Lyric[]` — preferred.
|
||||
- `lrc?: string` — parsed once per identity change.
|
||||
- `intervalMs?: number` (default 100).
|
||||
- `currentSec: number` — required.
|
||||
- `UseLyricsResult`:
|
||||
- `lyrics: readonly Lyric[]`,
|
||||
- `activeIndex: number` (`-1` for "before first cue / empty"),
|
||||
- `activeLyric: Lyric | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook owns one piece of React state — the active index — and one
|
||||
`setInterval`. Both `lyrics` and `currentSec` are mirrored through
|
||||
refs so the interval callback always sees the latest values without
|
||||
re-binding the timer on every render. The interval only fires
|
||||
`setActiveIndex` when the recomputed index actually differs, which
|
||||
is the standard React micro-optimisation for high-frequency external
|
||||
clocks.
|
||||
|
||||
`activeLyricIndex` (from the sibling `lrc-parser` module) is a
|
||||
binary search; combined with the 100 ms sampling cadence, the hook's
|
||||
steady-state work per frame is one comparison plus a "same index?"
|
||||
branch. Long-form lyrics (10k+ lines) are not measurably more
|
||||
expensive than short ones.
|
||||
|
||||
The hook lazily initialises its `activeIndex` state from
|
||||
`activeLyricIndex(lyrics, currentSec)` so the first render — before
|
||||
the effect mounts — already reflects the correct active line. This
|
||||
matters for SSR-rendered previews and snapshot tests that read the
|
||||
hook's output without ever ticking the interval.
|
||||
132
docs/ui/react/react-audio-stream/README.md
Normal file
132
docs/ui/react/react-audio-stream/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# @suaveplan/react-audio-stream - Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.
|
||||
|
||||
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-stream` split.
|
||||
|
||||
> Chunks A (session-core) and B (transports) deliver the headless engine. Chunk D
|
||||
> delivers `useTransportShare` and its banner. This chunk delivers the React surface
|
||||
> consumers actually touch.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [internal](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-context](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [stream-context](./src/provider/stream-context.md) — `StreamReactContext` is the single React context that publishes the live `StreamSession` to every chunk-C hook and comp…
|
||||
- [stream-provider](./src/provider/stream-provider.md) — `<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for a tree. It calls the caller-su…
|
||||
- [session](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [reconnect](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [control-channel](./src/transport-share/control-channel.md) — The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow be…
|
||||
- [share-state](./src/transport-share/share-state.md) — The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'`…
|
||||
- [transports](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [trystero](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [trystero-transport](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [webrtc](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [webrtc-transport](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [websocket](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [websocket-transport](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [transport](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [test-setup](./src/test-setup.md) — `test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s `setupFiles`. It registers `@testin…
|
||||
- [test-utils](./src/test-utils.md) — `test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used by every chunk-C hook + compo…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Provider
|
||||
|
||||
- `<StreamProvider sessionFactory={…}>` — StrictMode-safe singleton; injects a
|
||||
`StreamSession` lazily; fans seven session event channels into a single render-bump
|
||||
counter; disconnects on unmount.
|
||||
- `StreamReactContext` — exposed for advanced consumers that need raw context access.
|
||||
- Type-only mirrors (`SessionState`, `Peer`, `PeerMixerState`, `MediaPermissionState`,
|
||||
`LevelSample`, `RecordingResult`, `StreamSessionFactory`, `StreamSessionLike`,
|
||||
`SessionEventMap`, `ReactAudioStreamErrorLike`) for downstream typing.
|
||||
|
||||
## Hooks
|
||||
|
||||
- `useStreamSession()` — session, FSM state, and `connect` / `join` / `leave` /
|
||||
`disconnect`.
|
||||
- `useStreamRoom()` — `roomId`, `isJoined`, `joinedAt`, `participants`.
|
||||
- `useStreamPeers()` — live `readonly Peer[]`.
|
||||
- `useStreamMixer()` — per-peer `setVolume` / `setMute` / `setSolo` with DAW
|
||||
solo semantics (when any peer is soloed, every non-soloed peer's track-volume → 0).
|
||||
Every mutation routes through the session's `setTrackVolume` / `setTrackMute` /
|
||||
`setTrackSolo`.
|
||||
- `useStreamLevels(peerId?)` — `{ peak, rms }` for the local mic or named peer.
|
||||
- `useStreamRecording()` — `start(peerIds?)` and `stop()` returning a
|
||||
`RecordingResult` blob.
|
||||
- `useStreamPermissions()` — typed `MediaPermissionState` with `request()` callback.
|
||||
|
||||
## Components
|
||||
|
||||
- `<StreamRoom>` — headless render-prop wrapper exposing the room snapshot plus join /
|
||||
leave callbacks.
|
||||
- `<StreamMixer>` — per-peer mixer strip with range slider, `aria-pressed`
|
||||
mute/solo toggles, FX-slot read-out; supports `renderStrip` render-prop.
|
||||
- `<StreamRecorder>` — record/stop toggle with `mm:ss` duration display and ARIA-live
|
||||
read-out.
|
||||
- `<StreamPermissionsBanner>` — `role="status"` `aria-live="polite"` banner; renders
|
||||
nothing when permission is granted.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { StreamProvider, useStreamSession } from "@suaveplan/react-audio-stream";
|
||||
|
||||
function Room() {
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") return <button onClick={() => connect("lobby").then(join)}>Join</button>;
|
||||
return <p>Joined!</p>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createMyStreamSession()}>
|
||||
<Room />
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints (from the canonical spec)
|
||||
|
||||
- Zero browser audio APIs: no `AudioContext` / `AudioNode` / `AudioWorklet` imports.
|
||||
- DAW solo semantics live in the mixer hook, not in each component.
|
||||
- Every hook throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# SharedTransportIndicator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'receiving'` mode. The component is intentionally presentational — it consumes a `ShareState` snapshot from the caller (typically `useTransportShare().state`) and renders nothing when no share is active.
|
||||
|
||||
Decoupling the visual from the hook lets consumers compose the indicator anywhere in their layout: in a status bar, inside a mixer panel, as part of a notifications drawer, or even pinned next to a specific peer's avatar. Because the component reads its data from props, it is also straightforward to test without wiring up a session or a coordinator.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders only when the local share role matches the configured `visibleRoles` (default `['receiving']`).
|
||||
- Carries `aria-live="polite"` and `role="status"` so screen readers announce share-state transitions politely.
|
||||
- `data-share-role` and `data-driver-id` attributes for selector-based styling.
|
||||
- Optional `label` override (string or any `ReactNode`).
|
||||
- Optional `className` for layout integration.
|
||||
- Spec-aligned default label: `"Shared transport active"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
import { SharedTransportIndicator } from "@suaveplan/react-audio-stream/components/shared-transport-indicator";
|
||||
|
||||
function StatusBar(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return <SharedTransportIndicator state={share.state} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function MixerHeader(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return (
|
||||
<header className="mixer-header">
|
||||
<SharedTransportIndicator
|
||||
state={share.state}
|
||||
className="mixer-header__share-badge"
|
||||
visibleRoles={["driving", "receiving"]}
|
||||
label={
|
||||
share.state.role === "driving"
|
||||
? `Driving ${share.state.receivers.length} peer(s)`
|
||||
: `Receiving from ${share.state.driverId}`
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SharedTransportIndicator(props): ReactNode`
|
||||
- `SharedTransportIndicatorProps`
|
||||
- `state: ShareState` — required.
|
||||
- `className?: string` — optional root class.
|
||||
- `label?: ReactNode` — optional label override.
|
||||
- `visibleRoles?: ReadonlyArray<ShareRole>` — defaults to `['receiving']`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component does not memoise its rendered output — it is a thin function-of-props with no internal state, so React's default reconciliation already short-circuits when the parent passes referentially-stable props. The `data-driver-id` attribute defaults to `""` when the driver identity is unknown so consumers can rely on the attribute always being present. The default label is exposed as a private constant rather than a JSX literal so the component remains testable with a single equality check, and the `visibleRoles` default is a frozen `as const` tuple to avoid accidental mutation by consumers.
|
||||
@@ -0,0 +1,63 @@
|
||||
# stream-mixer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a
|
||||
mute toggle, a solo toggle, and a read-only FX-slot-count indicator. The component is
|
||||
the visible counterpart to `useStreamMixer()` — both surfaces are required by
|
||||
Requirement 6 ("Per-peer stream mixer") and the component's solo button proves the
|
||||
DAW-semantics test case from the spec.
|
||||
|
||||
Markup is intentionally minimal and unstyled — every interactive control carries an
|
||||
`aria-label` derived from the peer id so screen readers announce the right peer, and
|
||||
the toggles use `aria-pressed` for accessibility. Consumers who want richer chrome pass
|
||||
a `renderStrip` render-prop and own the layout entirely.
|
||||
|
||||
## Features
|
||||
|
||||
- Default markup with `range` slider, `aria-pressed` mute/solo toggles, FX-slot count.
|
||||
- `renderStrip` render-prop for custom layouts (e.g. shadcn cards, drag handles).
|
||||
- All mutations route through `useStreamMixer()` — DAW solo semantics inherited
|
||||
unchanged.
|
||||
- Group `role="group"` with consumer-supplied `aria-label` for screen readers.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamMixer label="Live room mix" />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamMixer
|
||||
label="Studio mix"
|
||||
renderStrip={(peer, { setVolume, setMute, setSolo }) => (
|
||||
<PeerStrip
|
||||
peer={peer}
|
||||
onVolume={setVolume}
|
||||
onMute={() => setMute(!peer.muted)}
|
||||
onSolo={() => setSolo(!peer.soloed)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamMixerProps` — `{ label?: string; renderStrip?: (peer, controls) => ReactNode }`
|
||||
- `StreamMixer(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `aria-pressed` attribute is set on both the mute and solo buttons because they are
|
||||
two-state toggles — this matches WAI-ARIA's authoring practice for toggle buttons and
|
||||
allows assistive tech to announce the current state without parsing the button label.
|
||||
The range slider's `value` is bound to the live `peer.volume` so it stays in sync with
|
||||
ducking caused by other peers' solo state (per Requirement 6: when any peer is soloed,
|
||||
non-soloed peers' track volumes go to 0, and the slider reflects that).
|
||||
@@ -0,0 +1,62 @@
|
||||
# stream-permissions-banner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It
|
||||
renders a `role="status"` ARIA-live region with copy reflecting the current microphone
|
||||
permission state and an explicit request button when the state is actionable
|
||||
(`"prompt"` or `"unknown"`). When the user has already granted access, the banner
|
||||
renders nothing so the room UI is unobstructed.
|
||||
|
||||
The banner is intentionally opinionated about accessibility — `aria-live="polite"`
|
||||
ensures screen readers announce state transitions without interrupting other speech —
|
||||
but stays unopinionated about styling. The default copy is overridable on a
|
||||
per-state basis via the `messages` prop.
|
||||
|
||||
## Features
|
||||
|
||||
- ARIA-live `role="status"` with `aria-live="polite"`.
|
||||
- Per-state copy with sensible defaults (`granted` / `denied` / `prompt` / `unknown`).
|
||||
- Request button rendered only when the state is `"prompt"` or `"unknown"`.
|
||||
- `onResolved` callback invoked with the next state after `request()` settles.
|
||||
- Renders `null` when permission is already granted.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamPermissionsBanner />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamPermissionsBanner
|
||||
requestLabel="Allow microphone"
|
||||
messages={{
|
||||
denied: "Microphone blocked. Open site settings to allow access.",
|
||||
prompt: "We use your mic to stream live audio to other participants.",
|
||||
}}
|
||||
onResolved={(state) => {
|
||||
if (state === "granted") void session.connect("lobby").then(() => session.join());
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamPermissionsBannerProps` — `{ messages?, requestLabel?, onResolved? }`
|
||||
- `StreamPermissionsBanner(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `data-state` attribute on the root element mirrors the `MediaPermissionState`
|
||||
string so consumers can target it via CSS attribute selectors (e.g.
|
||||
`[data-state="denied"] { color: red; }`) without re-deriving the state in JS. The
|
||||
request button renders only on `"prompt"` and `"unknown"` because `"denied"` cannot be
|
||||
escalated back to `"prompt"` through `getUserMedia` alone — the user must change
|
||||
browser-level settings, which the copy explicitly tells them.
|
||||
@@ -0,0 +1,60 @@
|
||||
# stream-recorder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamRecorder>` is a record/stop control with a live duration display. It delegates
|
||||
recording lifecycle to `useStreamRecording()` and adds two pieces of presentation logic:
|
||||
an interval-driven duration counter that ticks at 4 Hz while recording, and an
|
||||
ARIA-live duration read-out so screen-reader users hear updates. When recording stops,
|
||||
the component invokes the optional `onComplete` callback with the full `RecordingResult`
|
||||
(blob + timing metadata) so consumers can upload, download, or transcribe the capture.
|
||||
|
||||
The component supports the optional `peerIds` filter — passing it forwards the list to
|
||||
`start()` so only the named peers (plus the local mic) are mixed into the resulting
|
||||
blob. This makes the same component usable for "record the whole room" and
|
||||
"record only the guest" flows without branching the consumer's code.
|
||||
|
||||
## Features
|
||||
|
||||
- Toggle button with `aria-pressed` for the current state.
|
||||
- ARIA-live duration string formatted as `mm:ss`.
|
||||
- 4 Hz interval, cleaned up on unmount and on `isRecording=false`.
|
||||
- `onComplete` callback receives the full `RecordingResult` after `stop()` resolves.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamRecorder onComplete={(r) => download(r.blob)} />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const guests = useStreamPeers().filter((p) => p.id !== "host").map((p) => p.id);
|
||||
<StreamRecorder
|
||||
peerIds={guests}
|
||||
onComplete={async ({ blob, durationMs }) => {
|
||||
await fetch("/api/interviews", { method: "POST", body: blob });
|
||||
analytics.track("interview_recorded", { durationMs });
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamRecorderProps` — `{ onComplete?, peerIds?, label? }`
|
||||
- `StreamRecorder(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The duration counter uses `Date.now()` rather than accumulating `setInterval` ticks
|
||||
because intervals drift; sampling wall-clock time keeps the display in sync with the
|
||||
underlying recording timeline. On stop the component snaps the displayed duration to
|
||||
the precise `durationMs` returned by the session, which is the authoritative value
|
||||
(the session's offline render runs at a fixed sample rate). The interval is cleared in
|
||||
the effect cleanup, so re-mount under StrictMode does not leak a timer.
|
||||
@@ -0,0 +1,72 @@
|
||||
# stream-room
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup
|
||||
of its own — instead it exposes the live room snapshot (`roomId`, `isJoined`,
|
||||
`participants`, `state`) and the `join` / `leave` callbacks via a render-prop. This
|
||||
keeps presentation decisions (layout, theming, copy, animations) entirely in the
|
||||
consumer's hands while the audio-stream package owns the data flow.
|
||||
|
||||
The render-prop pattern was chosen over compound components because the room scope is
|
||||
trivially small (six fields, two callbacks) and a single function child is the smallest
|
||||
possible surface that does not lock consumers into a particular DOM structure.
|
||||
|
||||
## Features
|
||||
|
||||
- Headless: no DOM, no classes, no inline styles.
|
||||
- Render-prop returns the room snapshot and lifecycle callbacks.
|
||||
- Re-renders on every relevant session event via the underlying hooks.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when rendered outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamRoom>
|
||||
{({ roomId, isJoined, join, leave }) =>
|
||||
isJoined ? (
|
||||
<button onClick={leave}>Leave {roomId}</button>
|
||||
) : (
|
||||
<button onClick={join}>Join room</button>
|
||||
)
|
||||
}
|
||||
</StreamRoom>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamRoom>
|
||||
{({ roomId, participants, state, join, leave }) => (
|
||||
<section aria-busy={state === "joining" || state === "leaving"}>
|
||||
<h2>{roomId ?? "no room"}</h2>
|
||||
<ul>
|
||||
{participants.map((p) => (
|
||||
<li key={p.id}>{p.displayName}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div role="group">
|
||||
<button disabled={state !== "connected"} onClick={join}>Join</button>
|
||||
<button disabled={state !== "joined"} onClick={leave}>Leave</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</StreamRoom>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamRoomRenderProps` — `{ roomId, isJoined, participants, join, leave, state }`
|
||||
- `StreamRoomProps` — `{ children: (rp: StreamRoomRenderProps) => ReactNode }`
|
||||
- `StreamRoom(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`state` is forwarded from `useStreamSession()` so consumers can disable buttons during
|
||||
`"joining"` / `"leaving"` without re-deriving the FSM state themselves. The component
|
||||
delegates to two narrow hooks rather than reading the context directly, which keeps the
|
||||
render-prop forwarding cost at exactly two memoised hook calls per render.
|
||||
104
docs/ui/react/react-audio-stream/src/error-codes/error-codes.md
Normal file
104
docs/ui/react/react-audio-stream/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module is the canonical source of truth for every
|
||||
failure mode published by `@suaveplan/react-audio-stream`. It declares
|
||||
the seven dotted codes under the `REACT_AUDIO_STREAM.` namespace via
|
||||
`defineCodes`, the frozen metadata table, and the `ReactAudioStreamError`
|
||||
class — every error thrown by the package extends this class and carries
|
||||
one of these codes. Consumers discriminate failures with `instanceof
|
||||
ReactAudioStreamError` or by matching on the stable `error.code` string
|
||||
without needing to know which sub-feature originated the throw.
|
||||
|
||||
The codes line up one-to-one with the Requirement 9 surface of the
|
||||
parent OpenSpec change so the human-authored spec can be cross-checked
|
||||
against runtime reality: a test asserts every entry in
|
||||
`REACT_AUDIO_STREAM_ERROR_METADATA` is reachable from at least one
|
||||
production call site.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable, lexically sorted codes: `NO_PROVIDER`, `INVALID_STATE`,
|
||||
`SIGNALLING_FAILED`, `PEER_NOT_FOUND`, `MEDIA_PERMISSION_DENIED`,
|
||||
`RECONNECT_EXHAUSTED`, `INVALID_OPTIONS`.
|
||||
- `ReactAudioStreamError extends SuaveplanError` — inherits structured
|
||||
context, cause-chain support, and serialization to the shared wire
|
||||
format.
|
||||
- Idempotent `registerReactAudioStreamErrorCodes()` registration hook
|
||||
for symmetry with sibling Suaveplan packages.
|
||||
- `Object.freeze`-d metadata table — mutation attempts throw in strict
|
||||
mode and a test guards the invariant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
throw new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.NO_PROVIDER,
|
||||
"react-audio-stream.no-provider",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
try {
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
if (err instanceof ReactAudioStreamError) {
|
||||
const meta = REACT_AUDIO_STREAM_ERROR_METADATA[err.code];
|
||||
logger.warn({
|
||||
code: err.code,
|
||||
severity: meta.severity,
|
||||
description: meta.description,
|
||||
context: err.context,
|
||||
}, "stream session failure");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_STREAM_CODES` — frozen object literal keyed by short
|
||||
code, value is the dotted string (`"REACT_AUDIO_STREAM.NO_PROVIDER"`).
|
||||
- `ReactAudioStreamErrorCode` — derived union of every value in the
|
||||
code registry.
|
||||
- `ReactAudioStreamErrorMetadata` — `{ description, severity }` pair
|
||||
per code.
|
||||
- `REACT_AUDIO_STREAM_ERROR_METADATA` — `Record<ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata>`, `Object.freeze`-d at module load.
|
||||
- `ReactAudioStreamError` — `SuaveplanError<ReactAudioStreamErrorCode>`
|
||||
subclass; `category` is the literal `"react-audio-stream"`.
|
||||
- `registerReactAudioStreamErrorCodes(): void` — idempotent no-op
|
||||
retained for parity with sibling registries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` literally typesetting the runtime values means the
|
||||
`ReactAudioStreamErrorCode` union is derived without a second
|
||||
hand-maintained declaration. Adding a code requires only an entry in
|
||||
both `REACT_AUDIO_STREAM_CODES` and `REACT_AUDIO_STREAM_ERROR_METADATA`
|
||||
— the metadata test fails otherwise. Severity is currently `error` on
|
||||
every entry; the field exists so future warning-class diagnostics
|
||||
(deprecated transport probes, lossy fallbacks) slot in without a
|
||||
schema change. The class extends the foundation `SuaveplanError`
|
||||
directly — the parent OpenSpec change references an `AudioError` from
|
||||
`@suaveplan/audio-core` but the chunk-A dep allowlist excludes that
|
||||
package, so we follow the same pattern as sibling `@suaveplan/react-audio`
|
||||
which also extends the foundation base.
|
||||
@@ -0,0 +1,59 @@
|
||||
# use-stream-context (internal)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helper consumed by every public chunk-C hook. It reads the
|
||||
`StreamReactContext`, returns the non-null `{ session, version }` container, and throws a
|
||||
structured `REACT_AUDIO_STREAM.NO_PROVIDER`-coded error when the hook ran outside any
|
||||
`<StreamProvider>`. Centralising the null check in one place guarantees every hook in
|
||||
the package surfaces the same code string and the same exact message wording, which
|
||||
keeps consumer-facing error handling consistent.
|
||||
|
||||
The function is intentionally not exported from the package root — public hooks
|
||||
re-export their own narrow surface area and consumers never see this helper directly.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for the `NO_PROVIDER` error.
|
||||
- Attaches a stable `code` property to the thrown `Error` so tests can switch on the
|
||||
code without parsing the message.
|
||||
- Returns the typed `StreamContextValue` (no `| null`) so downstream hooks need no
|
||||
defensive guards.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export function useStreamSession() {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
return session;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
try {
|
||||
useStreamContextOrThrow();
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === "REACT_AUDIO_STREAM.NO_PROVIDER") {
|
||||
// Render a fallback UI explaining the missing provider.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamContextOrThrow(): StreamContextValue` — throws `Error & { code: "REACT_AUDIO_STREAM.NO_PROVIDER" }` when no provider is present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The error is constructed as a plain `Error` (rather than the canonical
|
||||
`ReactAudioStreamError` from chunk A) so this module stays dependency-free relative to
|
||||
the session-core package. Chunk A's full `ReactAudioStreamError` class will subsume this
|
||||
shape on integration; the `code` property name is identical so the swap is mechanical.
|
||||
@@ -0,0 +1,54 @@
|
||||
# use-stream-levels
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamLevels` projects realtime peak/RMS samples out of the session. Called without
|
||||
an argument it returns the local-microphone level; called with a peer id it returns the
|
||||
named peer's level. The level numbers come from chunks A/B's analyzer slot (which lives
|
||||
on each peer's plugin chain alongside the `StreamSourcePlugin`), and this hook is a
|
||||
zero-state projection — it does not compute anything itself.
|
||||
|
||||
The reading is intentionally synchronous: the provider's render-bump version is what
|
||||
schedules re-renders, and the session is responsible for batching analyzer ticks into
|
||||
the `levels-changed` event channel at a sane cadence (typically 60 Hz).
|
||||
|
||||
## Features
|
||||
|
||||
- Same hook for local and per-peer levels — the `peerId?` argument selects.
|
||||
- Re-renders on every `levels-changed` event via the provider's version counter.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { peak, rms } = useStreamLevels();
|
||||
return <LevelMeter peak={peak} rms={rms} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
return peers.map((p) => {
|
||||
const { peak } = useStreamLevels(p.id);
|
||||
return <PeerLevelBar key={p.id} peerId={p.id} peak={peak} />;
|
||||
});
|
||||
```
|
||||
|
||||
(Real apps should hoist the per-peer call into a child component to keep the hook count
|
||||
stable across renders.)
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamLevels(peerId?: string): LevelSample` — `LevelSample` is `{ peak: number; rms: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook does not pull from `@suaveplan/audio-analysis-spectrum` directly — that module
|
||||
runs inside the audio engine (browser tier, owned by chunks A/B). The React boundary
|
||||
only ever sees the peak/RMS scalars produced by the engine's analyzer. Keeping the
|
||||
hook this thin guarantees zero DSP work happens during React rendering.
|
||||
@@ -0,0 +1,72 @@
|
||||
# use-stream-mixer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer
|
||||
FX slot count — and implements DAW-correct solo semantics on top of the session's track
|
||||
mutators. It is the only hook in chunk C that owns cross-peer behaviour: when any peer
|
||||
is soloed, every non-soloed peer's track-volume is set to 0; when no peer is soloed,
|
||||
every track keeps its last user-set volume (the session is the source of truth for
|
||||
"last set" because the mutations go through `setTrackVolume`).
|
||||
|
||||
Every mutation routes through `session.setTrackVolume`, `session.setTrackMute`, and
|
||||
`session.setTrackSolo` rather than being re-implemented inside the hook — this is the
|
||||
explicit Requirement-6 constraint and the reason the test asserts on
|
||||
`session.calls.setTrackSolo` / `setTrackVolume`.
|
||||
|
||||
## Features
|
||||
|
||||
- DAW solo semantics: any solo activates the per-track ducking pass; clearing the last
|
||||
solo lets non-soloed tracks resume their stored volume.
|
||||
- Pure delegation: zero local volume state — the session owns it.
|
||||
- Stable callbacks via `useCallback`.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { peers, setVolume, setMute, setSolo } = useStreamMixer();
|
||||
return peers.map((p) => (
|
||||
<PeerStrip
|
||||
key={p.id}
|
||||
peer={p}
|
||||
onVolume={(v) => setVolume(p.id, v)}
|
||||
onMute={(m) => setMute(p.id, m)}
|
||||
onSolo={(s) => setSolo(p.id, s)}
|
||||
/>
|
||||
));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { peers, setSolo } = useStreamMixer();
|
||||
const soloExclusive = useCallback((id: string) => {
|
||||
// Solo one peer and clear all others' solos in a single React tick.
|
||||
peers.forEach((p) => p.soloed && p.id !== id && setSolo(p.id, false));
|
||||
setSolo(id, true);
|
||||
}, [peers, setSolo]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamMixerResult` — `{ peers, setVolume, setMute, setSolo }`
|
||||
- `useStreamMixer(): UseStreamMixerResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`setSolo` reads the current mixer snapshot from `session.getPeerMixerStates()`,
|
||||
applies the requested solo change in a local copy, computes `anySoloed`, and — when
|
||||
true — issues `setTrackVolume(otherId, 0)` for every non-soloed peer whose current
|
||||
volume is not already 0. The non-zero check avoids redundant mutations that would
|
||||
otherwise show up as extra `calls.setTrackVolume` entries in tests.
|
||||
|
||||
The "restore previous volume on un-solo" arm is delegated to the session: chunk A's
|
||||
session implementation stores the user-set volume independently from the live mixer
|
||||
volume, so when the last solo clears, the session emits a `mixer-changed` event that
|
||||
restores the stored values. The hook does not have to keep its own restore-map, which
|
||||
keeps the React surface stateless.
|
||||
@@ -0,0 +1,61 @@
|
||||
# use-stream-peers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each
|
||||
peer entry carries the participant id, display name, transport connection state, and
|
||||
the latest measured round-trip time in milliseconds. The hook is the canonical source
|
||||
of peer data for any UI that renders a participant list, a per-peer mixer strip, or a
|
||||
per-peer level meter, and it re-evaluates whenever the provider's render-bump version
|
||||
ticks.
|
||||
|
||||
The hook is intentionally a thin projection — it does not own selection, sorting, or
|
||||
filtering. Consumers compose their own derived state on top with `useMemo`.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable array identity across renders when peers have not changed.
|
||||
- Re-renders on `peers-changed`, `state-changed`, and every other session event channel
|
||||
via the provider's render-bump counter.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
return (
|
||||
<ul>
|
||||
{peers.map((p) => (
|
||||
<li key={p.id}>{p.displayName} ({p.rttMs} ms)</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
const sorted = useMemo(
|
||||
() => [...peers].sort((a, b) => a.displayName.localeCompare(b.displayName)),
|
||||
[peers]
|
||||
);
|
||||
const slow = sorted.filter((p) => p.rttMs > 150);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamPeers(): readonly Peer[]` — see `Peer` in `session/session-shape/session-shape.types.ts`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation memoises against the provider's `version` counter, which is the
|
||||
only legitimate invalidation signal — components must not depend on the peer array's
|
||||
internal identity for invalidation because the session is free to reuse the same array
|
||||
across mutations (for example when only RTT changed). The version-counter approach is
|
||||
intentionally over-eager: re-deriving an array reference is O(1) and matches the
|
||||
package-wide pattern.
|
||||
@@ -0,0 +1,61 @@
|
||||
# use-stream-permissions
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamPermissions` exposes the microphone permission state with the typed
|
||||
`MediaPermissionState` enum (`"granted" | "denied" | "prompt" | "unknown"`) and a
|
||||
`request()` callback that triggers `navigator.mediaDevices.getUserMedia` via the
|
||||
session. The hook decouples React components from the browser's permission API surface
|
||||
so consumers can render a permissions banner, a "click to enable mic" button, or a
|
||||
hard-blocked error UI without touching `navigator` themselves.
|
||||
|
||||
The hook is provider-bound — it does not poll `navigator.permissions.query` itself.
|
||||
Chunk A's session is responsible for keeping the permission state fresh (either by
|
||||
listening to the `PermissionStatus` `change` event or by re-querying on
|
||||
`getUserMedia` errors) and emitting `'permission-changed'` so the provider's
|
||||
render-bump version increments.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `request` callback (`useCallback`-wrapped) for safe use in event handlers.
|
||||
- Re-renders on every `'permission-changed'` event channel.
|
||||
- Returns the typed enum rather than the browser's `string` so consumers can do
|
||||
exhaustive switches.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { state, request } = useStreamPermissions();
|
||||
if (state === "denied") return <PermissionsBlockedBanner />;
|
||||
return <button onClick={request}>Enable microphone</button>;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { state, request } = useStreamPermissions();
|
||||
useEffect(() => {
|
||||
if (state === "prompt") {
|
||||
// Auto-request as soon as the user navigates into the room view.
|
||||
void request();
|
||||
}
|
||||
}, [state, request]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamPermissionsResult` — `{ state, request }`
|
||||
- `useStreamPermissions(): UseStreamPermissionsResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`request` resolves with the next permission state rather than `void` so a consumer that
|
||||
wants to chain into `connect()` immediately after granting can do so without a separate
|
||||
re-render hop. The session implementation is responsible for emitting
|
||||
`'permission-changed'` synchronously after the resolve, which keeps the React render
|
||||
state aligned with the promise result.
|
||||
@@ -0,0 +1,67 @@
|
||||
# use-stream-recording
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamRecording` exposes the start/stop controls for capturing a session as a single
|
||||
audio file. The hook delegates to chunks A/B's session, which performs an offline render
|
||||
through `audio-engine-browser`: every peer's plugin chain (including the
|
||||
`StreamSourcePlugin` and any user-added FX such as the per-peer mute/solo) is mixed into
|
||||
a single output `Blob` with timing metadata.
|
||||
|
||||
Two recording scopes are supported. Calling `start()` with no argument records the
|
||||
entire session output. Calling `start(["alice", "bob"])` records only those peers plus
|
||||
the local microphone — useful for "record this guest" interview flows.
|
||||
|
||||
## Features
|
||||
|
||||
- `isRecording` boolean reflects the session's true recording state, not a local React
|
||||
flag, so external triggers (e.g. a keyboard shortcut handled outside the React tree)
|
||||
still drive the UI correctly.
|
||||
- `start` accepts an optional `peerIds` filter for partial recordings.
|
||||
- `stop` returns a `RecordingResult` with `{ blob, durationMs, startedAt, endedAt }`.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { isRecording, start, stop } = useStreamRecording();
|
||||
return (
|
||||
<button
|
||||
onClick={async () => (isRecording ? stop() : start())}
|
||||
aria-pressed={isRecording}
|
||||
>
|
||||
{isRecording ? "Stop" : "Record"}
|
||||
</button>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { start, stop } = useStreamRecording();
|
||||
const peers = useStreamPeers();
|
||||
const recordGuests = useCallback(async () => {
|
||||
const guestIds = peers.filter((p) => p.id !== "host").map((p) => p.id);
|
||||
await start(guestIds);
|
||||
setTimeout(async () => {
|
||||
const { blob, durationMs } = await stop();
|
||||
await uploadInterview(blob, durationMs);
|
||||
}, 60_000);
|
||||
}, [peers, start, stop]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamRecordingResult` — `{ isRecording, start, stop }`
|
||||
- `useStreamRecording(): UseStreamRecordingResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook reads `session.isRecording()` synchronously on every render rather than caching
|
||||
it in local state. This is safe because the provider's render-bump version increments on
|
||||
every `recording-changed` event, so the read is always fresh and the React render path
|
||||
stays the single source of truth for `aria-pressed` / `aria-busy` indicators.
|
||||
@@ -0,0 +1,60 @@
|
||||
# use-stream-room
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamRoom` projects the current room scope out of the session: the room id (or
|
||||
`null` when the session is not connected), an `isJoined` boolean derived from the FSM
|
||||
state, the `joinedAt` epoch-milliseconds timestamp, and the live participant list
|
||||
(remote peers only — the local user is represented via the session itself, not the
|
||||
participant array).
|
||||
|
||||
The hook is intentionally cheap and read-only; components that render the room header,
|
||||
the participant chip-list, or any "you have not joined yet" empty state can depend on
|
||||
it without pulling in the heavier mixer/level/recording surfaces.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure projection — no callbacks, no side effects.
|
||||
- `participants` is a stable reference between renders when peers have not changed,
|
||||
thanks to the memoisation barrier and the session's own array stability contract.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { roomId, isJoined, participants } = useStreamRoom();
|
||||
if (!isJoined) return <JoinButton />;
|
||||
return (
|
||||
<header>
|
||||
<h2>{roomId}</h2>
|
||||
<p>{participants.length} present</p>
|
||||
</header>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { participants, joinedAt } = useStreamRoom();
|
||||
const elapsedMs = joinedAt === null ? 0 : Date.now() - joinedAt;
|
||||
return (
|
||||
<Stats elapsedMs={elapsedMs} peerIds={participants.map((p) => p.id)} />
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamRoomResult` — `{ roomId, isJoined, joinedAt, participants }`
|
||||
- `useStreamRoom(): UseStreamRoomResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isJoined` is derived from `session.state === "joined"` rather than from a separate
|
||||
`isJoined` field on the session, because the FSM state is the single source of truth
|
||||
(see Requirement "StreamSession FSM lifecycle"). The participant array is read via
|
||||
`session.getPeers()` on every render; the provider's render-bump version keeps that read
|
||||
fresh.
|
||||
@@ -0,0 +1,59 @@
|
||||
# use-stream-session
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live
|
||||
`StreamSession` instance, its current FSM state (`idle` / `connecting` / `connected` /
|
||||
`joining` / `joined` / `disconnecting` / `disconnected`), and four stable callback
|
||||
references for `connect`, `join`, `leave`, and `disconnect`. The callbacks delegate to
|
||||
the underlying session, which is the only legal source of FSM mutations.
|
||||
|
||||
The hook re-runs whenever the provider's render-bump version increments, which means a
|
||||
component using `useStreamSession()` re-renders on every session event — including
|
||||
`'state-changed'`, `'interruption'`, peer changes, mixer changes, and so on. Components
|
||||
that need finer granularity should compose narrower hooks (`useStreamPeers`,
|
||||
`useStreamMixer`, …).
|
||||
|
||||
## Features
|
||||
|
||||
- Direct access to the live `StreamSessionLike` for cases that need methods beyond the
|
||||
big four (e.g. `requestTransportShare` from chunk D).
|
||||
- Stable `useCallback`-wrapped lifecycle methods so consumer components can pass them
|
||||
to memoised children without churning.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") {
|
||||
return <button onClick={() => connect("lobby").then(join)}>Join lobby</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { session, state, disconnect } = useStreamSession();
|
||||
useEffect(() => {
|
||||
return session.on("interruption", (klass) => {
|
||||
if (klass === "media-permission") void disconnect();
|
||||
});
|
||||
}, [session, disconnect]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamSessionResult` — `{ session, state, connect, join, leave, disconnect }`
|
||||
- `useStreamSession(): UseStreamSessionResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The returned object is memoised against `session.state` so a render-bump caused by a
|
||||
non-state event (e.g. a peer-level tick) does not produce a new object identity. The
|
||||
callback references depend only on the session, so they remain stable for the lifetime
|
||||
of the provider — even across state transitions.
|
||||
@@ -0,0 +1,97 @@
|
||||
# useTransportShare
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator` and a `ShareStateMachine` into a single hook that React components can consume to drive, receive, or request a shared transport without writing any wire-protocol code.
|
||||
|
||||
The hook is deliberately injection-based — the local `transport`, the peer `channel`, and the local `selfId` are passed in by the caller (usually a session-aware wrapper inside the package's `<StreamProvider>`). This keeps the hook testable in isolation and free from the network surface, mirroring the rest of `@suaveplan/react-audio-stream`'s purity boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Single React-friendly handle for the whole share workflow.
|
||||
- Stable `useRef`-backed coordinator and state machine — re-renders never tear down the share.
|
||||
- `state`, `role`, and `isReceiver` for UI gating.
|
||||
- `requestTransportShare(participantId)` — record a pending invitation.
|
||||
- `grantTransportShare(receiverId?)` — flip to `'driving'`; receiver optional for the broadcast-first case.
|
||||
- `releaseTransportShare()` — return to `'local'` cleanly.
|
||||
- `applyDriverGrant(driverId)` — flip to `'receiving'` (session-driven).
|
||||
- `notifyDriverDisconnect()` — receiver-side fall-back to `'local'`.
|
||||
- `notifySeek(seconds)` — propagate a programmatic seek when driving.
|
||||
- Auto-disposes the coordinator on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
|
||||
function ShareButton(props: {
|
||||
transport: SharedTransportTarget;
|
||||
channel: ControlChannel;
|
||||
selfId: string;
|
||||
receiverId: string;
|
||||
}) {
|
||||
const share = useTransportShare(props);
|
||||
if (share.role === "driving") {
|
||||
return <button onClick={share.releaseTransportShare}>Stop sharing</button>;
|
||||
}
|
||||
return (
|
||||
<button onClick={() => share.grantTransportShare(props.receiverId)}>
|
||||
Share transport with {props.receiverId}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function HostPanel(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = props.session.on("peer-left", (peer) => {
|
||||
if (share.role === "receiving" && peer.id === share.state.driverId) {
|
||||
share.notifyDriverDisconnect();
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [props.session, share]);
|
||||
|
||||
return (
|
||||
<section aria-live="polite">
|
||||
{share.isReceiver ? (
|
||||
<p>Receiving from {share.state.driverId}</p>
|
||||
) : null}
|
||||
<button onClick={() => share.requestTransportShare(props.peerId)}>
|
||||
Ask {props.peerId} to drive
|
||||
</button>
|
||||
<button onClick={() => share.notifySeek(props.cursor)}>
|
||||
Sync seek
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTransportShare(options): UseTransportShareResult`
|
||||
- `UseTransportShareOptions` — `{ transport, channel, selfId }`.
|
||||
- `UseTransportShareResult`
|
||||
- `state: ShareState`
|
||||
- `role: ShareRole`
|
||||
- `isReceiver: boolean`
|
||||
- `requestTransportShare(participantId)`
|
||||
- `grantTransportShare(receiverId?)`
|
||||
- `releaseTransportShare()`
|
||||
- `applyDriverGrant(driverId)`
|
||||
- `notifyDriverDisconnect()`
|
||||
- `notifySeek(seconds)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The coordinator and state machine are created lazily inside `useRef` ignition blocks to avoid the React-18 strict-mode double-invocation pitfall where a constructor with side effects would run twice. The hook subscribes to the state machine in an effect so the React state reflects every transition, and it disposes the coordinator on unmount — disposal is idempotent so this is safe even if the parent already called `releaseTransportShare`. All returned callbacks are wrapped in `useCallback` with the precise dependency list so consumers can pass them into memoized children without breaking referential equality.
|
||||
@@ -0,0 +1,61 @@
|
||||
# stream-context
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamReactContext` is the single React context that publishes the live `StreamSession`
|
||||
to every chunk-C hook and component. It is intentionally a tiny container — the session
|
||||
itself owns all mutable state, and the context exposes only the session reference plus a
|
||||
monotonic `version` counter that increments on every session event. Hooks subscribe to
|
||||
the context and re-render whenever `version` ticks, which keeps the React render path
|
||||
decoupled from the audio engine's event firehose.
|
||||
|
||||
The `null` sentinel value is the deliberate signal that a hook ran outside any
|
||||
`<StreamProvider>`; every public hook in chunk C throws `REACT_AUDIO_STREAM.NO_PROVIDER`
|
||||
when it observes that sentinel.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure React context — zero side effects at import time.
|
||||
- Render-bump counter pattern: O(1) re-render fan-out regardless of session event volume.
|
||||
- `null` sentinel forces explicit provider placement, surfaced as a structured error.
|
||||
- `displayName` set for legible React DevTools output.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
|
||||
import { useContext } from "react";
|
||||
|
||||
const ctx = useContext(StreamReactContext);
|
||||
if (ctx === null) {
|
||||
throw new Error("StreamProvider missing");
|
||||
}
|
||||
const { session, version } = ctx;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
|
||||
|
||||
function StreamConsumer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StreamReactContext.Consumer>
|
||||
{(ctx) => (ctx === null ? <p>no provider</p> : children)}
|
||||
</StreamReactContext.Consumer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamContextValue` — `{ session: StreamSessionLike; version: number }`
|
||||
- `StreamReactContext` — `React.Context<StreamContextValue | null>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`version` is bumped by `<StreamProvider>` inside a `useState` setter, so each tick
|
||||
triggers a single render scheduled through React's normal update path. The context value
|
||||
itself is memoised by the provider, so children that read only `session` (not `version`)
|
||||
do not have to re-render — but every chunk-C hook intentionally reads `version` to keep
|
||||
their derived state fresh.
|
||||
@@ -0,0 +1,69 @@
|
||||
# stream-provider
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for
|
||||
a tree. It calls the caller-supplied `sessionFactory` exactly once per provider instance,
|
||||
subscribes to the seven session event channels (`state-changed`, `interruption`,
|
||||
`peers-changed`, `mixer-changed`, `levels-changed`, `recording-changed`,
|
||||
`permission-changed`), and increments a render-bump counter on every tick so descendant
|
||||
hooks observe a fresh snapshot of session state.
|
||||
|
||||
The factory pattern keeps `@suaveplan/react-audio-stream` transport-agnostic: the
|
||||
consumer decides whether the underlying transport is WebRTC, WebSocket, Trystero, or a
|
||||
test fake, and the provider never imports any transport directly. Cleanup on unmount
|
||||
calls `session.disconnect()` so the audio engine + socket are torn down deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- StrictMode-safe singleton via `useRef`-guarded lazy construction.
|
||||
- Caller-injected `sessionFactory` — zero transport coupling at provider tier.
|
||||
- Render-bump counter fans seven event channels into one re-render path.
|
||||
- Cleanup-on-unmount disconnects the session and detaches every listener.
|
||||
- Memoised context value — only the `version` field changes per tick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
|
||||
import { createStreamSession } from "./my-session-factory";
|
||||
|
||||
export function App({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createStreamSession({ transport: "webrtc" })}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function ScopedRoom({ roomId, children }: { roomId: string; children: React.ReactNode }) {
|
||||
const factory = useMemo(
|
||||
() => () => createStreamSession({ transport: "trystero", initialRoomId: roomId }),
|
||||
[roomId]
|
||||
);
|
||||
return <StreamProvider sessionFactory={factory}>{children}</StreamProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
Keying the provider on `roomId` (`<StreamProvider key={roomId} …>`) forces a fresh
|
||||
session when the room changes, because the singleton is per-instance.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamProviderProps` — `{ sessionFactory: StreamSessionFactory; children?: ReactNode }`
|
||||
- `StreamProvider(props)` — renders a `StreamReactContext.Provider` with `{ session, version }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The seven event subscriptions all funnel into a single `setVersion((v) => v + 1)` call,
|
||||
which collapses bursts into a single React render pass thanks to automatic batching. The
|
||||
disconnect call on unmount is wrapped in `Promise.resolve` so the same code path handles
|
||||
both sync and async session implementations, and the rejection branch swallows errors
|
||||
(any failure already surfaced as an `interruption` event before the listener detach).
|
||||
@@ -0,0 +1,104 @@
|
||||
# `interruption-classifier`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InterruptionClassifier` maps a structural disconnect cause to exactly
|
||||
one of `network | media-permission | signalling | peer | unknown`.
|
||||
Classification is deterministic — every rule is a typed equality or
|
||||
membership check, no probabilistic heuristics — so the same cause
|
||||
record always produces the same verdict regardless of timing or
|
||||
ordering. This is essential for the auto-reconnect policy that lands in
|
||||
chunk D: the policy decides whether to retry by inspecting the verdict
|
||||
label, and a flaky classifier would translate into flaky reconnect
|
||||
behaviour for users.
|
||||
|
||||
The classifier is intentionally state-free. Every consumer (the
|
||||
`StreamSession`, future React hooks, integration tests) constructs one
|
||||
instance and calls `classify` on every disconnect — there is no
|
||||
internal counter, ring buffer, or memo, so a single instance can be
|
||||
shared across sessions without contention.
|
||||
|
||||
## Features
|
||||
|
||||
- Five disjoint labels covering every observed interruption shape.
|
||||
- Priority-ordered rules with explicit precedence: `peer` beats
|
||||
`media-permission` beats `network` beats `signalling`.
|
||||
- WebRTC-friendly: keys directly off `iceConnectionState` and
|
||||
`connectionState`.
|
||||
- WebSocket-friendly: keys off RFC 6455 close codes, skipping the
|
||||
clean-close codes (1000, 1001) so a graceful server-side close does
|
||||
not trigger reconnect.
|
||||
- `shouldAutoReconnect(class)` helper exposes the chunk-D reconnect
|
||||
policy in a single place.
|
||||
- Zero allocations on the hot path beyond the verdict object.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InterruptionClassifier } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const classifier = new InterruptionClassifier();
|
||||
const verdict = classifier.classify({ iceConnectionState: "failed" });
|
||||
// verdict.class === "network"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InterruptionClassifier,
|
||||
type InterruptionCause,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const classifier = new InterruptionClassifier();
|
||||
|
||||
function onDisconnect(rawCause: InterruptionCause) {
|
||||
const verdict = classifier.classify(rawCause);
|
||||
if (classifier.shouldAutoReconnect(verdict.class)) {
|
||||
scheduleReconnect(verdict);
|
||||
} else {
|
||||
surfaceToUi(verdict);
|
||||
}
|
||||
}
|
||||
|
||||
// Hard browser-permission revoke
|
||||
onDisconnect({
|
||||
mediaErrorName: "NotAllowedError",
|
||||
reason: "user revoked microphone permission",
|
||||
});
|
||||
// → verdict.class === "media-permission"; no reconnect.
|
||||
|
||||
// Server-initiated abnormal close
|
||||
onDisconnect({ signallingCloseCode: 1006, reason: "abrupt server close" });
|
||||
// → verdict.class === "signalling"; reconnect scheduled.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class InterruptionClassifier` — stateless classifier.
|
||||
- `classify(cause: InterruptionCause): InterruptionVerdict` — apply the
|
||||
rule mapping and return `{ class, cause }`.
|
||||
- `shouldAutoReconnect(class: InterruptionClass): boolean` — true iff
|
||||
the class is `network` or `signalling`.
|
||||
- `InterruptionClass`, `InterruptionCause`, `InterruptionVerdict` —
|
||||
re-exported types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rule ordering is the only piece of business logic with subtle
|
||||
consequences. `peer` is evaluated first because an intentional remote
|
||||
leave should never be treated as a network glitch — even if the ICE
|
||||
state also happens to read `disconnected` during the closing handshake.
|
||||
Permission errors take precedence over ICE because a user-revoked
|
||||
microphone often surfaces as both a `getUserMedia` rejection and an
|
||||
ICE failure milliseconds later; the permission failure is the
|
||||
actionable cause. Close codes 1000 and 1001 are explicitly skipped so a
|
||||
clean server shutdown (`going away`) does not show up as a signalling
|
||||
interruption — those map to `unknown` instead, which the reconnect
|
||||
policy treats as non-retryable. The classifier deliberately holds no
|
||||
state so it composes cleanly into the FSM-driven session flow without
|
||||
spreading hidden coupling.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user