From 896819526e59baf88ccf39b6d00c329e97ef3199 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 21:15:23 +0700 Subject: [PATCH] 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. --- .../core/src/components/form/form-context.ts | 79 ++++++++++++ .../core/src/components/form/form-control.tsx | 35 ++++++ .../src/components/form/form-description.tsx | 22 ++++ .../components/form/form-error-message.tsx | 38 ++++++ .../core/src/components/form/form-field.tsx | 34 +++++ .../core/src/components/form/form-label.tsx | 21 ++++ .../core/src/components/form/form-root.tsx | 116 ++++++++++++++++++ .../core/src/components/form/form-submit.tsx | 26 ++++ .../core/src/components/form/form.props.ts | 37 ++++++ packages/core/src/components/form/index.ts | 19 +++ .../components/form/form-validation.test.tsx | 60 +++++++++ .../core/tests/components/form/form.test.tsx | 87 +++++++++++++ 12 files changed, 574 insertions(+) create mode 100644 packages/core/src/components/form/form-context.ts create mode 100644 packages/core/src/components/form/form-control.tsx create mode 100644 packages/core/src/components/form/form-description.tsx create mode 100644 packages/core/src/components/form/form-error-message.tsx create mode 100644 packages/core/src/components/form/form-field.tsx create mode 100644 packages/core/src/components/form/form-label.tsx create mode 100644 packages/core/src/components/form/form-root.tsx create mode 100644 packages/core/src/components/form/form-submit.tsx create mode 100644 packages/core/src/components/form/form.props.ts create mode 100644 packages/core/src/components/form/index.ts create mode 100644 packages/core/tests/components/form/form-validation.test.tsx create mode 100644 packages/core/tests/components/form/form.test.tsx diff --git a/packages/core/src/components/form/form-context.ts b/packages/core/src/components/form/form-context.ts new file mode 100644 index 0000000..100eb0b --- /dev/null +++ b/packages/core/src/components/form/form-context.ts @@ -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>; + /** 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; + /** When validation is triggered */ + validateOn: Accessor<"submit" | "blur" | "change">; +} + +const FormContext = createContext(); + +/** + * 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
.\n" + + " Fix: Wrap your Form.Field, Form.Submit, etc. inside .\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; +} + +const FormFieldContext = createContext(); + +/** + * 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 .\n" + + " Fix: Wrap Form.Label, Form.Control, Form.ErrorMessage inside .\n" + + " Docs: https://pettyui.dev/components/form#composition", + ); + } + return ctx; +} + +export const FormFieldContextProvider = FormFieldContext.Provider; diff --git a/packages/core/src/components/form/form-control.tsx b/packages/core/src/components/form/form-control.tsx new file mode 100644 index 0000000..9b041d9 --- /dev/null +++ b/packages/core/src/components/form/form-control.tsx @@ -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, + })} + + ); +} diff --git a/packages/core/src/components/form/form-description.tsx b/packages/core/src/components/form/form-description.tsx new file mode 100644 index 0000000..15f347a --- /dev/null +++ b/packages/core/src/components/form/form-description.tsx @@ -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

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 ( +

+ {local.children} +

+ ); +} diff --git a/packages/core/src/components/form/form-error-message.tsx b/packages/core/src/components/form/form-error-message.tsx new file mode 100644 index 0000000..ef62a89 --- /dev/null +++ b/packages/core/src/components/form/form-error-message.tsx @@ -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

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 ( + 0}> +

+ + ); +} diff --git a/packages/core/src/components/form/form-field.tsx b/packages/core/src/components/form/form-field.tsx new file mode 100644 index 0000000..4e7b06f --- /dev/null +++ b/packages/core/src/components/form/form-field.tsx @@ -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 ( + + {props.children} + + ); +} diff --git a/packages/core/src/components/form/form-label.tsx b/packages/core/src/components/form/form-label.tsx new file mode 100644 index 0000000..8653dfa --- /dev/null +++ b/packages/core/src/components/form/form-label.tsx @@ -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