Form component with Zod validation

Compound Form component (Root, Field, Label, Control, Description,
ErrorMessage, Submit) with reactive error store, aria linking, and
render-prop FormControl. 10 tests across rendering and validation suites.
This commit is contained in:
Mats Bosson 2026-03-29 21:15:23 +07:00
parent 8a248958f5
commit 896819526e
12 changed files with 574 additions and 0 deletions

View File

@ -0,0 +1,79 @@
// packages/core/src/components/form/form-context.ts
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
// ─── Root Form Context ─────────────────────────────────────────────────────
export interface FormContextValue {
/** Reactive map of field name -> error messages */
errors: Accessor<Record<string, string[]>>;
/** Get error messages for a specific field */
getFieldErrors: (name: string) => string[];
/** Register a field value (used by FormField on blur/change) */
setFieldValue: (name: string, value: unknown) => void;
/** Get the current value of a field */
getFieldValue: (name: string) => unknown;
/** Validate a single field against the schema */
validateField: (name: string) => void;
/** Validate all fields; returns true if valid */
validateAll: () => boolean;
/** Whether the entire form is disabled */
disabled: Accessor<boolean>;
/** When validation is triggered */
validateOn: Accessor<"submit" | "blur" | "change">;
}
const FormContext = createContext<FormContextValue>();
/**
* Returns the internal Form context value.
* Throws if used outside of a Form root.
*/
export function useFormContext(): FormContextValue {
const ctx = useContext(FormContext);
if (!ctx) {
throw new Error(
"[PettyUI] Form parts must be used inside <Form>.\n" +
" Fix: Wrap your Form.Field, Form.Submit, etc. inside <Form>.\n" +
" Docs: https://pettyui.dev/components/form#composition",
);
}
return ctx;
}
export const FormContextProvider = FormContext.Provider;
// ─── Per-Field Context ─────────────────────────────────────────────────────
export interface FormFieldContextValue {
/** Field name, matches schema key */
name: string;
/** ID for the control element (used by label htmlFor) */
controlId: string;
/** ID for the description element */
descriptionId: string;
/** ID for the error message element */
errorId: string;
/** Reactive error messages for this field */
errors: Accessor<string[]>;
}
const FormFieldContext = createContext<FormFieldContextValue>();
/**
* Returns the per-field Form context value.
* Throws if used outside of a Form.Field.
*/
export function useFormFieldContext(): FormFieldContextValue {
const ctx = useContext(FormFieldContext);
if (!ctx) {
throw new Error(
"[PettyUI] Form sub-components must be used inside <Form.Field>.\n" +
" Fix: Wrap Form.Label, Form.Control, Form.ErrorMessage inside <Form.Field>.\n" +
" Docs: https://pettyui.dev/components/form#composition",
);
}
return ctx;
}
export const FormFieldContextProvider = FormFieldContext.Provider;

View File

@ -0,0 +1,35 @@
// packages/core/src/components/form/form-control.tsx
import type { JSX } from "solid-js";
import { useFormFieldContext } from "./form-context";
import type { FormControlProps } from "./form.props";
export type { FormControlProps };
/** Builds the aria-describedby string combining description and error IDs. */
function buildDescribedBy(descId: string, errorId: string, hasErrors: boolean): string | undefined {
if (hasErrors) return `${descId} ${errorId}`;
return descId;
}
/**
* Render-prop wrapper that injects accessible props (id, name, aria-describedby,
* aria-invalid) into the field's control element.
*/
export function FormControl(props: FormControlProps): JSX.Element {
const fieldCtx = useFormFieldContext();
const hasErrors = (): boolean => fieldCtx.errors().length > 0;
const describedBy = (): string | undefined =>
buildDescribedBy(fieldCtx.descriptionId, fieldCtx.errorId, hasErrors());
return (
<>
{props.children({
id: fieldCtx.controlId,
name: fieldCtx.name,
"aria-describedby": describedBy(),
"aria-invalid": hasErrors() ? true : undefined,
})}
</>
);
}

View File

@ -0,0 +1,22 @@
// packages/core/src/components/form/form-description.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useFormFieldContext } from "./form-context";
import type { FormDescriptionProps } from "./form.props";
export type { FormDescriptionProps };
/**
* Renders a <p> element with the field's description ID so controls can
* reference it via aria-describedby.
*/
export function FormDescription(props: FormDescriptionProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const fieldCtx = useFormFieldContext();
return (
<p id={fieldCtx.descriptionId} {...rest}>
{local.children}
</p>
);
}

View File

@ -0,0 +1,38 @@
// packages/core/src/components/form/form-error-message.tsx
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import { useFormFieldContext } from "./form-context";
import type { FormErrorMessageProps } from "./form.props";
export type { FormErrorMessageProps };
/** Resolves children — either static JSX or a render-prop function. */
function resolveChildren(
children: JSX.Element | ((errors: string[]) => JSX.Element),
errors: string[],
): JSX.Element {
if (typeof children === "function") {
return (children as (e: string[]) => JSX.Element)(errors);
}
return children;
}
/**
* Renders a <p role="alert"> with field error messages. Only mounts when
* the field has at least one error. Supports static children or a render prop
* receiving the errors array.
*/
export function FormErrorMessage(props: FormErrorMessageProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const fieldCtx = useFormFieldContext();
return (
<Show when={fieldCtx.errors().length > 0}>
<p id={fieldCtx.errorId} role="alert" {...rest}>
{local.children !== undefined
? resolveChildren(local.children, fieldCtx.errors())
: fieldCtx.errors().join(", ")}
</p>
</Show>
);
}

View File

@ -0,0 +1,34 @@
// packages/core/src/components/form/form-field.tsx
import type { JSX } from "solid-js";
import { createUniqueId } from "solid-js";
import { useFormContext, FormFieldContextProvider, type FormFieldContextValue } from "./form-context";
import type { FormFieldProps } from "./form.props";
export type { FormFieldProps };
/**
* Wraps a single form field. Generates unique IDs for the control, description,
* and error message elements to enable accessible ARIA linking. Triggers
* field-level validation on blur or change when validateOn is set accordingly.
*/
export function FormField(props: FormFieldProps): JSX.Element {
const formCtx = useFormContext();
const baseId = createUniqueId();
const controlId = `${baseId}-control`;
const descriptionId = `${baseId}-description`;
const errorId = `${baseId}-error`;
const fieldCtx: FormFieldContextValue = {
name: props.name,
controlId,
descriptionId,
errorId,
errors: () => formCtx.getFieldErrors(props.name),
};
return (
<FormFieldContextProvider value={fieldCtx}>
{props.children}
</FormFieldContextProvider>
);
}

View File

@ -0,0 +1,21 @@
// packages/core/src/components/form/form-label.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useFormFieldContext } from "./form-context";
import type { FormLabelProps } from "./form.props";
export type { FormLabelProps };
/**
* Renders a <label> element linked to the field's control via htmlFor.
*/
export function FormLabel(props: FormLabelProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const fieldCtx = useFormFieldContext();
return (
<label for={fieldCtx.controlId} {...rest}>
{local.children}
</label>
);
}

View File

@ -0,0 +1,116 @@
// packages/core/src/components/form/form-root.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { createStore } from "solid-js/store";
import { z } from "zod/v4";
import { FormContextProvider, type FormContextValue } from "./form-context";
import type { FormRootProps } from "./form.props";
export type { FormRootProps };
/**
* Root form element. Manages field values and errors via a reactive store.
* Validates on submit (or blur/change when validateOn is set) using the
* provided Zod v4 schema. Calls onSubmit on success, onValidationError on
* failure.
*/
export function FormRoot(props: FormRootProps): JSX.Element {
const [local, rest] = splitProps(props, [
"schema",
"onSubmit",
"onValidationError",
"disabled",
"validateOn",
"children",
]);
const [fieldValues, setFieldValues] = createStore<Record<string, unknown>>({});
const [errors, setErrors] = createStore<Record<string, string[]>>({});
const getFieldErrors = (name: string): string[] => errors[name] ?? [];
const setFieldValue = (name: string, value: unknown): void => {
setFieldValues(name, value);
};
const getFieldValue = (name: string): unknown => fieldValues[name];
const buildFieldErrors = (schema: z.ZodType, values: Record<string, unknown>): Record<string, string[]> => {
const result = schema.safeParse(values);
if (result.success) return {};
const fieldErrors: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const path = issue.path.join(".");
if (!fieldErrors[path]) fieldErrors[path] = [];
fieldErrors[path].push(issue.message);
}
return fieldErrors;
};
const validateField = (name: string): void => {
if (!local.schema) return;
const allErrors = buildFieldErrors(local.schema, fieldValues);
setErrors(name, allErrors[name] ?? []);
};
const validateAll = (): boolean => {
if (!local.schema) return true;
const fieldErrors = buildFieldErrors(local.schema, fieldValues);
const hasErrors = Object.keys(fieldErrors).length > 0;
if (hasErrors) {
for (const [key, msgs] of Object.entries(fieldErrors)) {
setErrors(key, msgs);
}
} else {
for (const key of Object.keys(errors)) {
setErrors(key, []);
}
}
return !hasErrors;
};
const handleSubmit: JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent> = (e) => {
e.preventDefault();
if (local.schema) {
const result = local.schema.safeParse(fieldValues);
if (!result.success) {
const fieldErrors: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const path = issue.path.join(".");
if (!fieldErrors[path]) fieldErrors[path] = [];
fieldErrors[path].push(issue.message);
}
for (const [key, msgs] of Object.entries(fieldErrors)) {
setErrors(key, msgs);
}
local.onValidationError?.(fieldErrors);
return;
}
for (const key of Object.keys(errors)) {
setErrors(key, []);
}
local.onSubmit?.(result.data as Record<string, unknown>, e);
} else {
local.onSubmit?.(fieldValues, e);
}
};
const ctx: FormContextValue = {
errors: () => errors,
getFieldErrors,
setFieldValue,
getFieldValue,
validateField,
validateAll,
disabled: () => local.disabled ?? false,
validateOn: () => local.validateOn ?? "submit",
};
return (
<FormContextProvider value={ctx}>
<form onSubmit={handleSubmit} {...rest}>
{local.children}
</form>
</FormContextProvider>
);
}

View File

@ -0,0 +1,26 @@
// packages/core/src/components/form/form-submit.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useFormContext } from "./form-context";
import type { FormSubmitProps } from "./form.props";
export type { FormSubmitProps };
/**
* Renders a <button type="submit"> that is automatically disabled when the
* form's disabled state is true.
*/
export function FormSubmit(props: FormSubmitProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "disabled"]);
const formCtx = useFormContext();
return (
<button
type="submit"
disabled={local.disabled ?? formCtx.disabled()}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,37 @@
// packages/core/src/components/form/form.props.ts
import { z } from "zod/v4";
import type { JSX } from "solid-js";
import type { ComponentMeta } from "../../meta";
export const FormRootPropsSchema = z.object({
disabled: z.boolean().optional().describe("Disable all fields in the form"),
validateOn: z.enum(["submit", "blur", "change"]).optional().describe("When to validate. Defaults to 'submit'"),
});
export interface FormRootProps extends z.infer<typeof FormRootPropsSchema>, Omit<JSX.FormHTMLAttributes<HTMLFormElement>, "onSubmit"> {
schema?: z.ZodType;
onSubmit?: (values: Record<string, unknown>, event: SubmitEvent) => void;
onValidationError?: (errors: Record<string, string[]>) => void;
children: JSX.Element;
}
export const FormFieldPropsSchema = z.object({
name: z.string().describe("Field name matching the schema key"),
});
export interface FormFieldProps extends z.infer<typeof FormFieldPropsSchema> {
children: JSX.Element;
}
export interface FormLabelProps extends JSX.LabelHTMLAttributes<HTMLLabelElement> { children?: JSX.Element; }
export interface FormControlProps { children: (props: { id: string; name: string; "aria-describedby"?: string; "aria-invalid"?: boolean }) => JSX.Element; }
export interface FormDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> { children?: JSX.Element; }
export interface FormErrorMessageProps extends JSX.HTMLAttributes<HTMLParagraphElement> { children?: JSX.Element | ((errors: string[]) => JSX.Element); }
export interface FormSubmitProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
export const FormMeta: ComponentMeta = {
name: "Form",
description: "Form with Zod v4 schema validation, field-level error display, and accessible aria linking",
parts: ["Root", "Field", "Label", "Control", "Description", "ErrorMessage", "Submit"] as const,
requiredParts: ["Root", "Field"] as const,
} as const;

View File

@ -0,0 +1,19 @@
import { FormRoot } from "./form-root";
import { FormField } from "./form-field";
import { FormLabel } from "./form-label";
import { FormControl } from "./form-control";
import { FormDescription } from "./form-description";
import { FormErrorMessage } from "./form-error-message";
import { FormSubmit } from "./form-submit";
export { FormRootPropsSchema, FormFieldPropsSchema, FormMeta } from "./form.props";
export type { FormRootProps } from "./form-root";
export type { FormFieldProps } from "./form-field";
export type { FormLabelProps } from "./form-label";
export type { FormControlProps } from "./form-control";
export type { FormDescriptionProps } from "./form-description";
export type { FormErrorMessageProps } from "./form-error-message";
export type { FormSubmitProps } from "./form-submit";
export type { FormContextValue, FormFieldContextValue } from "./form-context";
export const Form = Object.assign(FormRoot, { Field: FormField, Label: FormLabel, Control: FormControl, Description: FormDescription, ErrorMessage: FormErrorMessage, Submit: FormSubmit });

View File

@ -0,0 +1,60 @@
import { render, screen, fireEvent } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";
import { Form } from "../../../src/components/form/index";
const schema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
});
describe("Form — validation", () => {
it("shows Zod errors on submit", async () => {
render(() => (
<Form schema={schema}>
<Form.Field name="username">
<Form.Control>{(p) => <input {...p} />}</Form.Control>
<Form.ErrorMessage />
</Form.Field>
<Form.Submit>Submit</Form.Submit>
</Form>
));
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
await Promise.resolve();
expect(screen.getByRole("alert").textContent).toBeTruthy();
});
it("calls onValidationError with field errors", async () => {
const onValidationError = vi.fn();
render(() => (
<Form schema={schema} onValidationError={onValidationError}>
<Form.Field name="username">
<Form.Control>{(p) => <input {...p} />}</Form.Control>
</Form.Field>
<Form.Submit>Submit</Form.Submit>
</Form>
));
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
await Promise.resolve();
expect(onValidationError).toHaveBeenCalled();
const errArg = onValidationError.mock.calls[0][0] as Record<string, string[]>;
expect(Array.isArray(errArg["username"])).toBe(true);
});
it("error message render prop receives errors array", async () => {
render(() => (
<Form schema={schema}>
<Form.Field name="username">
<Form.Control>{(p) => <input {...p} />}</Form.Control>
<Form.ErrorMessage>
{(errors: string[]) => <span data-testid="errs">{errors[0]}</span>}
</Form.ErrorMessage>
</Form.Field>
<Form.Submit>Submit</Form.Submit>
</Form>
));
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
await Promise.resolve();
expect(screen.getByTestId("errs").textContent).toBeTruthy();
});
});

View File

@ -0,0 +1,87 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { Form, FormRootPropsSchema, FormMeta } from "../../../src/components/form/index";
const schema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
});
describe("Form — rendering", () => {
it("renders form with fields", () => {
render(() => (
<Form>
<Form.Field name="username">
<Form.Label>Username</Form.Label>
<Form.Control>{(p) => <input {...p} data-testid="u" />}</Form.Control>
</Form.Field>
<Form.Submit>Submit</Form.Submit>
</Form>
));
expect(screen.getByText("Username")).toBeTruthy();
expect(screen.getByTestId("u")).toBeTruthy();
expect(screen.getByRole("button", { name: "Submit" })).toBeTruthy();
});
it("label htmlFor links to control id", () => {
render(() => (
<Form>
<Form.Field name="username">
<Form.Label>Username</Form.Label>
<Form.Control>{(p) => <input {...p} />}</Form.Control>
</Form.Field>
</Form>
));
const label = screen.getByText("Username");
expect(label.getAttribute("for")).toBe(screen.getByRole("textbox").id);
});
it("description aria-describedby is linked to control", () => {
render(() => (
<Form>
<Form.Field name="username">
<Form.Control>{(p) => <input {...p} data-testid="ctrl" />}</Form.Control>
<Form.Description>Enter your username</Form.Description>
</Form.Field>
</Form>
));
const desc = screen.getByText("Enter your username");
expect(screen.getByTestId("ctrl").getAttribute("aria-describedby")).toContain(desc.id);
});
it("error message hidden when no errors", () => {
render(() => (
<Form schema={schema}>
<Form.Field name="username">
<Form.Control>{(p) => <input {...p} />}</Form.Control>
<Form.ErrorMessage>Required</Form.ErrorMessage>
</Form.Field>
</Form>
));
expect(screen.queryByRole("alert")).toBeNull();
});
it("submit button is disabled when form is disabled", () => {
render(() => <Form disabled><Form.Submit>Submit</Form.Submit></Form>);
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
});
});
describe("Form — schema and meta", () => {
it("schema validates validateOn enum", () => {
expect(FormRootPropsSchema.safeParse({ validateOn: "submit" }).success).toBe(true);
expect(FormRootPropsSchema.safeParse({ validateOn: "blur" }).success).toBe(true);
expect(FormRootPropsSchema.safeParse({ validateOn: "change" }).success).toBe(true);
expect(FormRootPropsSchema.safeParse({ validateOn: "invalid" }).success).toBe(false);
});
it("meta has fields", () => {
expect(FormMeta.name).toBe("Form");
expect(FormMeta.parts).toContain("Root");
expect(FormMeta.parts).toContain("Field");
expect(FormMeta.parts).toContain("Label");
expect(FormMeta.parts).toContain("Control");
expect(FormMeta.parts).toContain("ErrorMessage");
expect(FormMeta.parts).toContain("Submit");
});
});