first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user