@alphinex/forms
v1.4.1
Published
Form Engine: FormProvider, Zod/RHF integration, field components, dynamic form builder.
Readme
@alphinex/forms
The platform's form engine: a thin wrapper over React Hook Form
with Zod as the single source of validation truth, a matched set of typed field
components, and buildFormFromSchema for rendering forms from a JSON-serializable definition
(e.g. per-tenant custom fields configured outside the codebase). Every field component reads its
value/error state from RHF's context — nothing is passed to them explicitly beyond a name.
useAppForm + Form
useAppForm is the one way to create a form in this platform: it's RHF's useForm pre-wired to a
Zod schema via zodResolver, so validation logic never has to be duplicated between a schema and ad
hoc RHF rules. Form wraps RHF's own FormProvider with a real <form> element bound to
handleSubmit:
import { Form, useAppForm, TextField, NumberField } from "@alphinex/forms";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "Name is required"),
age: z.coerce.number().min(0),
});
function ProfileForm() {
const form = useAppForm({
schema,
defaultValues: { name: "", age: 0 },
});
return (
<Form form={form} onSubmit={(values) => console.log(values)}>
<TextField name="name" label="Name" required />
<NumberField name="age" label="Age" description="In years" />
<button type="submit">Save</button>
</Form>
);
}useAppForm's options are RHF's UseFormProps (defaultValues, mode, ...) plus schema — every
other RHF option (resolver aside, which is set for you) passes through unchanged.
Field components
TextField, NumberField, TextareaField, CheckboxField, SwitchField, SelectField, and
DateField all take a name (matching a key in your Zod schema/defaultValues), plus optional
label, description, and required. They must render inside a <Form> (or anything wrapping RHF's
FormProvider) since they call useController({ name }) internally. Everything else spreads onto
the underlying @alphinex/ui control, so e.g. TextField also accepts placeholder, Select's
props flow through SelectField, and so on.
import {
Form,
useAppForm,
TextField,
TextareaField,
CheckboxField,
SwitchField,
SelectField,
DateField,
} from "@alphinex/forms";
import { z } from "zod";
const schema = z.object({
title: z.string().min(1),
notes: z.string().optional(),
isUrgent: z.boolean(),
notifyByEmail: z.boolean(),
priority: z.string(),
dueDate: z.string(),
});
function TaskForm() {
const form = useAppForm({
schema,
defaultValues: {
title: "",
notes: "",
isUrgent: false,
notifyByEmail: true,
priority: "normal",
dueDate: "",
},
});
return (
<Form form={form} onSubmit={(values) => console.log(values)}>
<TextField name="title" label="Title" required />
<TextareaField name="notes" label="Notes" />
<CheckboxField name="isUrgent" label="Urgent" />
<SwitchField name="notifyByEmail" label="Email me updates" />
<SelectField
name="priority"
label="Priority"
options={[
{ value: "low", label: "Low" },
{ value: "normal", label: "Normal" },
{ value: "high", label: "High" },
]}
/>
<DateField name="dueDate" label="Due date" />
<button type="submit">Create task</button>
</Form>
);
}NumberField renders <input type="number"> bound to RHF as a string like any other text input —
pair it with z.coerce.number() in the field's schema so validation (and the typed form values)
resolve to an actual number. DateField similarly renders a native <input type="date">, bound as
an ISO "YYYY-MM-DD" string.
FieldWrapper and FieldMessage are the shared label/control/help-or-error layout every field above
renders through — reach for them directly only when building a new field type that doesn't exist yet.
getFieldError(errors, name) resolves a possibly-nested error (e.g. "address.street") out of RHF's
errors object, and useFieldMeta(name, description) derives the id/aria wiring (id,
descriptionId, errorId, error, describedBy) that every field component above uses internally.
Data-entry fields
RadioGroupField, NumberInputField, SliderField, RangeSliderField, TagInputField,
OtpInputField, RatingField, and ColorPickerField bind the matching @alphinex/ui controls
to RHF, taking the same name/label/description/required props as the fields above and
spreading everything else onto the underlying control:
import {
Form,
useAppForm,
RadioGroupField,
NumberInputField,
SliderField,
RangeSliderField,
TagInputField,
OtpInputField,
RatingField,
ColorPickerField,
} from "@alphinex/forms";
import { z } from "zod";
const schema = z.object({
plan: z.string().min(1, "Pick a plan"),
seats: z.number().min(1),
volume: z.number(),
budget: z.tuple([z.number(), z.number()]),
tags: z.array(z.string()).min(1, "Add at least one tag"),
code: z.string().length(6, "Enter all 6 digits"),
score: z.number().min(1, "Rate it"),
brand: z.string(),
});
<Form form={form} onSubmit={save}>
<RadioGroupField
name="plan"
label="Plan"
options={[
{ value: "basic", label: "Basic" },
{ value: "pro", label: "Pro" },
]}
/>
<NumberInputField name="seats" label="Seats" min={1} max={50} />
<SliderField name="volume" label="Volume" />
<RangeSliderField name="budget" label="Budget" />
<TagInputField name="tags" label="Tags" />
<OtpInputField name="code" label="Verification code" length={6} />
<RatingField name="score" label="Score" allowHalf />
<ColorPickerField name="brand" label="Brand color" />
</Form>;Note NumberInputField is distinct from NumberField: the latter wraps a plain Input, while
this one wraps NumberInput and brings increment/decrement affordances plus min/max clamping.
Controls with no single focusable element (a radiogroup, a slider, a rating) are labelled via
aria-labelledby rather than htmlFor, which FieldWrapper's labelId prop handles.
Selection fields
ComboboxField, MultiSelectField, AutocompleteField, CascaderField, TreeSelectField, and
TransferField bind the searchable-selection controls to RHF:
import {
ComboboxField,
MultiSelectField,
AutocompleteField,
CascaderField,
TreeSelectField,
TransferField,
} from "@alphinex/forms";
<Form form={form} onSubmit={save}>
{/* value: the selected option's `value` */}
<ComboboxField name="country" label="Country" options={countries} clearable />
{/* value: string[] of option values */}
<MultiSelectField name="visited" label="Visited" options={countries} max={5} />
{/* value: whatever text is in the field */}
<AutocompleteField name="city" label="City" options={cities} />
{/* value: the path array, root-first */}
<CascaderField name="region" label="Region" options={regions} />
{/* value: the selected node's id */}
<TreeSelectField name="category" label="Category" items={categories} />
{/* value: string[] of chosen values */}
<TransferField name="grants" label="Grants" options={permissions} searchable />
</Form>;Match the Zod type to what each field stores — z.string() for ComboboxField/TreeSelectField/
AutocompleteField, z.array(z.string()) for MultiSelectField/CascaderField/TransferField.
Date and time fields
DatePickerField, DateRangePickerField, TimePickerField, and DateTimePickerField bind
@alphinex/dates' pickers to RHF. They need a <ThemeProvider> above them, like SliderField.
import {
DatePickerField,
DateRangePickerField,
DateTimePickerField,
TimePickerField,
} from "@alphinex/forms";
<Form form={form} onSubmit={save}>
{/* value: Date */}
<DatePickerField name="dueAt" label="Due date" min={new Date()} disabledDaysOfWeek={[0, 6]} />
{/* value: Date — the calendar day of any existing value is preserved */}
<TimePickerField name="reminderAt" label="Reminder" minuteStep={15} minTime="09:00" />
{/* value: Date */}
<DateTimePickerField name="startsAt" label="Starts at" defaultTime="09:00" />
{/* value: { start: Date | null; end: Date | null } */}
<DateRangePickerField name="stay" label="Stay" startLabel="Check in" endLabel="Check out" />
</Form>;Use z.date() for the three single-value fields, and
z.object({ start: z.date().nullable(), end: z.date().nullable() }) for the range.
defaultValues may hold ISO strings rather than Date objects — a form hydrated straight from
a JSON API needs no conversion. They are read as local dates, so "2024-03-20" is the 20th
everywhere, not the 19th west of Greenwich.
buildFormFromSchema
Renders a form from a JSON-serializable array of FieldDefinition objects instead of hand-written
JSX — the mechanism behind admin-configurable forms (e.g. per-tenant custom fields stored in a
database), built entirely on top of the same field components you'd use directly. Wrap the result in
<Form>:
import { Form, useAppForm, buildFormFromSchema, type FieldDefinition } from "@alphinex/forms";
import { z } from "zod";
const fieldDefs: FieldDefinition[] = [
{ type: "text", name: "companyName", label: "Company name", required: true },
{
type: "select",
name: "industry",
label: "Industry",
options: [
{ value: "tech", label: "Technology" },
{ value: "retail", label: "Retail" },
],
},
{ type: "checkbox", name: "acceptsTerms", label: "I accept the terms" },
];
const schema = z.object({
companyName: z.string().min(1),
industry: z.string(),
acceptsTerms: z.boolean(),
});
function DynamicOnboardingForm() {
const form = useAppForm({
schema,
defaultValues: { companyName: "", industry: "", acceptsTerms: false },
});
return (
<Form form={form} onSubmit={(values) => console.log(values)}>
{buildFormFromSchema(fieldDefs)}
<button type="submit">Continue</button>
</Form>
);
}Each FieldDefinition is a discriminated union on type ("text" | "number" | "textarea" |
"checkbox" | "switch" | "select" | "date"), so TypeScript narrows the extra options per type —
select requires options: SelectOption[], text/number/textarea accept placeholder, and so
on. The Zod schema is still yours to write and keep in sync with the field names.
See documentation/ARCHITECTURE.md for the full package contract, dependency rules, and roadmap placement.
FileUploadField
import { FileUploadField } from "@alphinex/forms";
<FileUploadField
name="attachments"
label="Attachments"
multiple
accept=".pdf,.png"
maxSize={5_000_000}
/>;The stored value is always a File[], even in single-file mode — one shape means a schema and a
submit handler never have to branch on multiple:
const schema = z.object({
attachments: z.array(z.instanceof(File)).min(1, "Attach at least one file"),
avatar: z.array(z.instanceof(File)).max(1),
});Files failing the field's own accept/maxSize checks never reach the form value, and the
reason is shown under the drop zone.
