Form
A comprehensive form system with schema-based validation, error handling, and field management. Built with Radix UI primitives and AJV validation, supporting text inputs, textareas, selects, checkboxes, switches, and checkbox groups with automatic error propagation and touch state management.
Preview
import { useRef } from "react";
import * as React from "react";
import {
Form,
FormField,
FormSubmit,
FormSwitch,
FormCheckbox,
FormCheckboxGroup,
FormAlert,
} from "@/components/ui/Form";
import { Input } from "@/components/ui/Input";
import { FormFieldCombobox } from "@/components/FormFieldCombobox";
import { FormFieldSelect } from "@/components/FormFieldSelect";
import { FormFieldSignaturePad } from "@/components/FormFieldSignaturePad";
import { FormFieldSortableList } from "@/components/FormFieldSortableList";
import { FormFieldEditor } from "@/components/FormFieldEditor";
import { useFormDynamic } from "@/hooks/use-dynamic-form";
import { validateFormData } from "@/utils/web-validation";
export function FormDemo() {
return {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
name: "text",
email: "text",
});
const { name, email } = formData.getFields();
const { isValid, errors } = validateFormData(
defaultSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please complete all required fields");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-default"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormField field={name} label="Name *">
<Input type="text" placeholder="Enter your name" />
</FormField>
<FormField field={email} label="Email *">
<Input type="email" placeholder="Enter your email" />
</FormField>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-default"
/>
</Form>
);
};Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/formManual
Install the following dependencies:
pnpm install @radix-ui/react-icons ajv radix-uiCopy and paste the alert status component into your components/AlertStatus.tsx file.
import React from "react";
import {
InfoCircledIcon,
CheckCircledIcon,
ExclamationTriangleIcon,
CrossCircledIcon,
QuoteIcon,
} from "@radix-ui/react-icons";
import {
Alert,
AlertContent,
AlertTitle,
AlertDescription,
AlertIcon,
} from "@/components/ui/Alert";
export type AlertProps = {
status?: "info" | "success" | "warning" | "danger" | "other";
title?: React.ReactNode;
description?: React.ReactNode;
icon?: React.ReactNode;
showIcon?: boolean;
};
const AlertStatus = ({
status = "other",
title,
description,
icon,
showIcon = true,
...props
}: AlertProps) => {
// Map status to Alert variant
const variantMap: Record<
"info" | "success" | "warning" | "danger" | "other",
"info" | "success" | "warning" | "danger" | "default"
> = {
info: "info",
success: "success",
warning: "warning",
danger: "danger",
other: "default",
};
// Map status to default icons
const iconMap = {
info: InfoCircledIcon,
success: CheckCircledIcon,
warning: ExclamationTriangleIcon,
danger: CrossCircledIcon,
other: QuoteIcon,
};
const variant = variantMap[status];
const DefaultIcon = iconMap[status];
return (
<Alert variant={variant} {...props}>
{showIcon && (
<AlertIcon>
{icon ? icon : <DefaultIcon />}
</AlertIcon>
)}
<AlertContent>
{title && <AlertTitle>{title}</AlertTitle>}
{description && <AlertDescription>{description}</AlertDescription>}
</AlertContent>
</Alert>
);
};
export default AlertStatus;Copy and paste the checkbox component into your components/ui/Checkbox.tsx file.
"use client";
/**
* @description A control that allows the user to toggle between checked and not checked.
*/
import * as React from "react";
import { Checkbox as CheckboxRadix } from "radix-ui";
import { CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@/utils/class-names";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxRadix.Root>) {
return (
<CheckboxRadix.Root
data-slot="checkbox"
className={cn(
"border border-gray-a6 flex size-1.5 items-center justify-center rounded outline-none hover:bg-accent-2 cursor-pointer",
className
)}
{...props}
>
<CheckboxIndicator />
</CheckboxRadix.Root>
);
}
function CheckboxIndicator({
className,
...props
}: React.ComponentProps<typeof CheckboxRadix.Indicator>) {
return (
<CheckboxRadix.Indicator {...props}>
<CheckIcon className={cn(className, "text-accent-9")} />
</CheckboxRadix.Indicator>
);
}
interface CheckboxGroupProps extends React.HTMLAttributes<HTMLDivElement> {
value?: string[];
onValueChange?: (value: string[]) => void;
defaultValue?: string[];
}
function CheckboxGroup({
className,
value,
onValueChange,
defaultValue,
children,
...props
}: CheckboxGroupProps) {
const [internalValue, setInternalValue] = React.useState<string[]>(
defaultValue ?? []
);
const controlledValue = value ?? internalValue;
const handleValueChange = React.useCallback(
(itemValue: string, checked: boolean) => {
const newValue = checked
? [...controlledValue, itemValue]
: controlledValue.filter((v) => v !== itemValue);
if (onValueChange) {
onValueChange(newValue);
} else {
setInternalValue(newValue);
}
},
[controlledValue, onValueChange]
);
return (
<div
data-slot="checkbox-group"
className={cn("flex flex-col gap-1", className)}
{...props}
>
{React.Children.map(children, (child) => {
if (
React.isValidElement<CheckboxGroupItemProps>(child) &&
child.type === CheckboxGroupItem
) {
const childProps = child.props;
return React.cloneElement(child, {
...childProps,
checked: controlledValue.includes(childProps.value),
onCheckedChange: (checked: boolean) =>
handleValueChange(childProps.value, checked),
});
}
return child;
})}
</div>
);
}
interface CheckboxGroupItemProps
extends Omit<React.ComponentProps<typeof CheckboxRadix.Root>, "onCheckedChange"> {
value: string;
label?: React.ReactNode;
description?: React.ReactNode;
onCheckedChange?: (checked: boolean) => void;
}
function CheckboxGroupItem({
className,
value,
label,
description,
id,
...props
}: CheckboxGroupItemProps) {
const checkboxId = id || `checkbox-${value}`;
return (
<div
data-slot="checkbox-group-item"
className={cn("flex items-start gap-1", className)}
>
<Checkbox id={checkboxId} {...props} />
{(label || description) && (
<div className="flex flex-col gap-1">
{label && (
<label
htmlFor={checkboxId}
className="text-sm font-medium leading-none cursor-pointer"
>
{label}
</label>
)}
{description && (
<p className="text-sm text-gray-a11 m-0">{description}</p>
)}
</div>
)}
</div>
);
}
export { Checkbox, CheckboxIndicator, CheckboxGroup, CheckboxGroupItem };Copy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the radio group component into your components/ui/RadioGroup.tsx file.
"use client";
import * as React from "react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { cn } from "@/utils/class-names";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn(className, "cursor-pointer")}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
className,
"size-1.5 cursor-pointer rounded-full border border-gray-a6 hover:bg-accent-2"
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex size-full items-center justify-center after:block after:size-0.5 after:rounded-full after:bg-accent-9"
/>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };Copy and paste the switch component into your components/ui/Switch.tsx file.
"use client";
/**
* @description A control that allows the user to toggle between on and off states.
*/
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";
import { cn } from "@/utils/class-names";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"relative h-1.5 w-2.5 cursor-pointer rounded-full outline-none bg-accent-4 data-[state=checked]:bg-accent-10 border border-gray-a6 hover:bg-accent-5",
className
)}
{...props}
>
<SwitchThumb />
</SwitchPrimitive.Root>
);
}
function SwitchThumb({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Thumb>) {
return (
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"block size-1 translate-x-[3.5px] rounded-full bg-gray-1 shadow-md transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[15.5px]",
className
)}
{...props}
/>
);
}
export { Switch, SwitchThumb };Copy and paste the use dynamic form hook into your hooks/use-dynamic-form.ts file.
import { useState } from "react";
// Define the possible field types for the form.
type FieldType = "text" | "textarea" | "select" | "checkbox";
// Interface to define the structure of the initial values object.
// It maps field names (string) to their respective field types.
interface useFormDynamicProps {
[key: string]: FieldType;
}
// Object to resolve the initial value based on the field type.
const initialValueResolver = {
text: "",
textarea: "",
select: "",
checkbox: false,
};
/**
* Custom hook to manage the state of input fields in a form.
* @param initialValues - An object where keys are field names and values are field types.
* @returns An object containing the current values, errors, touch states, and functions to manage them.
*/
const useInputValueFields = (initialValues: useFormDynamicProps = {}) => {
// Resolve initial values based on the provided types.
const resolvedInitialValues = Object.entries(initialValues).reduce(
(acc, [key, type]) => {
acc[key] = initialValueResolver[type];
return acc;
},
{} as Record<string, string | boolean>
);
// State to hold the current values of the form fields.
const [value, setValue] = useState<Record<string, string | boolean>>(
resolvedInitialValues
);
// State to hold any errors related to the form fields.
const [error, setError] = useState<string | null>(null);
// State to track if a field has been touched (focused and blurred).
const [touch, setTouch] = useState<Record<string, boolean>>({});
return {
types: initialValues, // The types of the fields.
value, // The current values of the fields.
error, // Any errors associated with the fields.
touch, // The touch state of the fields.
setTouch, // Function to update the touch state.
setValue, // Function to update the field values.
setError, // Function to update the error state.
};
};
// Type definition for the props of a single field.
export type FieldProps = {
name: string; // The name of the field.
type: FieldType; // The type of the field.
value: string | boolean; // The current value of the field.
setValue: (value: string | boolean) => void; // Function to set the value of the field.
onChange: (
event: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => void; // Function to handle changes in the field.
onCheckedChange: React.ChangeEventHandler<HTMLInputElement> &
((checked: boolean) => void); // Function to handle changes in checkbox fields.
touch: boolean; // Whether the field has been touched.
setTouch: (value: boolean) => void; // Function to set the touch state of the field.
reset: () => void; // Function to reset the field to its default value.
};
/**
* Custom hook to manage a dynamic form with multiple fields.
* @param initialValues - An object defining the initial field types.
* @returns An object containing form state, field management functions, and form-level actions.
*/
export const useFormDynamic = (
initialValues: useFormDynamicProps,
formGeneralRef?: React.RefObject<HTMLFormElement | null>
) => {
// State to track the status of a fetch operation (e.g., submitting the form).
const [fetchStatus, setFetchStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
// Use the useInputValueFields hook to manage the fields.
const fields = useInputValueFields(initialValues);
/**
* Updates a specific field's value and touch state.
* @param name - The name of the field to update.
* @param updates - An object containing the new value and/or touch state.
*/
const updateField = (
name: string,
updates: { value?: string | boolean; touch?: boolean }
) => {
// Update the field's value.
fields.setValue((prev) => {
const newValues = { ...prev, [name]: updates.value };
return newValues;
});
// Update the field's touch state.
fields.setTouch((prev) => ({ ...prev, [name]: updates.touch }));
};
/**
* Returns the props for a specific field.
* @param name - The name of the field.
* @returns An object containing the field's props.
*/
const field = (name: string): FieldProps => {
const fieldType = fields.types[name];
const defaultValue = initialValueResolver[fieldType];
// Handles changes in the field's value.
const handleChange = (
event: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => {
const value = event.target.value;
updateField(name, {
value,
touch: true,
});
};
// Handles changes in checkbox fields.
const handleOnCheckedChange = (event: boolean): void => {
updateField(name, {
value: event,
touch: true,
});
};
// Sets the field's value in the DOM and updates the state.
const setFormFieldValue = (
value: string | boolean,
formRef = formGeneralRef
): void => {
if (formRef) {
const input = formRef?.current?.querySelector(`[name="${name}"]`) as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| null;
if (input) {
if (input instanceof HTMLInputElement && input.type === "checkbox") {
(input as HTMLInputElement).checked = value as boolean;
} else {
input.value = String(value);
}
}
}
updateField(name, {
value: value,
});
};
return {
name,
type: fields.types[name],
value: fields.value[name] ?? defaultValue,
setValue: setFormFieldValue,
onChange: handleChange,
onCheckedChange:
handleOnCheckedChange as React.ChangeEventHandler<HTMLInputElement> &
((checked: boolean) => void),
touch: fields.touch[name] ?? false,
setTouch: (value: boolean) => updateField(name, { touch: value }),
reset: () =>
updateField(name, {
value: defaultValue,
touch: false,
}),
};
};
/**
* Returns an object containing the props for all fields.
* @returns An object where keys are field names and values are their props.
*/
const getFields = () => {
return Object.keys(fields.value).reduce(
(acc, key) => {
acc[key] = field(key);
return acc;
},
{} as Record<string, ReturnType<typeof field>>
);
};
/**
* Resets the form to its initial state.
* @param formRef - A ref to the form element.
* @param initialValues - The initial values for the form fields.
*/
const resetForm = (
formRef: React.RefObject<HTMLFormElement>,
initialValues?: Record<string, string | boolean>
): void => {
const fields = getFields();
for (const field in fields) {
fields[field].reset();
fields[field].setFormFieldValue(
formRef,
initialValues?.[field] ?? initialValueResolver[fields[field].type]
);
}
};
/**
* Touches all fields in the form.
*/
const touchForm = () => {
const fields = getFields();
for (const field in fields) {
updateField(field, { value: fields[field].value, touch: true });
}
};
/**
* Returns an object containing the current values of all fields.
* @returns An object where keys are field names and values are their current values.
*/
const getValues = () => {
return Object.keys(fields.value).reduce(
(acc, key) => {
acc[key] = fields.value[key];
return acc;
},
{} as Record<string, string | boolean>
);
};
return {
...fields, // Spread the fields object to include its properties.
resetForm, // Function to reset the form.
touchForm, // Function to touch all fields.
field, // Function to get the props for a specific field.
getFields, // Function to get the props for all fields.
getValues, // Function to get the current values of all fields.
fetchStatus, // The current fetch status.
setFetchStatus, // Function to set the fetch status.
};
};Copy and paste the Form component into your components/ui/Form.tsx file.
import * as React from "react";
import { ErrorObject } from "ajv";
import { Form as FormRadix } from "radix-ui";
import { ChevronDownIcon, SymbolIcon } from "@radix-ui/react-icons";
import { FieldProps } from "@/hooks/use-dynamic-form";
import AlertStatus from "@/components/AlertStatus";
import Button, { ButtonProps } from "@/components/ui/Button";
import { cn } from "@/utils/class-names";
import {
Checkbox,
CheckboxGroup,
CheckboxGroupItem,
} from "@/components/ui/Checkbox";
import { Switch } from "@/components/ui/Switch";
import { RadioGroup, RadioGroupItem } from "@/components/ui/RadioGroup";
interface FormProps extends FormRadix.FormProps {
errors?: ErrorObject<string, Record<string, any>, unknown>[];
}
export const Form = React.forwardRef<HTMLFormElement, FormProps>(
({ children, errors, ...props }, forwardedRef) => {
const childrenArray = React.Children.toArray(children);
const processChildren = (
children: React.ReactNode[]
): React.ReactNode[] => {
return children.map((child) => {
if (!React.isValidElement(child)) {
return child;
}
if (child.props?.field) {
const fieldErrors = errors?.filter(
(error) => error.instancePath.slice(1) === child.props?.field?.name
);
return React.cloneElement(child as React.ReactElement, {
...child.props,
errors: fieldErrors,
});
}
if (child.props?.children) {
const processedChildren = processChildren(
React.Children.toArray(child.props.children)
);
return React.cloneElement(child as React.ReactElement, {
...child.props,
children: processedChildren,
});
}
return child;
});
};
return (
<FormRadix.Root {...props} ref={forwardedRef}>
{processChildren(childrenArray)}
</FormRadix.Root>
);
}
);
interface FormSubmitProps extends FormRadix.FormSubmitProps {
buttonProps?: ButtonProps;
fetchStatus?: "idle" | "loading" | "success" | "error";
}
export const FormSubmit = React.forwardRef<HTMLButtonElement, FormSubmitProps>(
({ buttonProps, fetchStatus, ...props }, forwardedRef) => {
return (
<FormRadix.Submit {...props} ref={forwardedRef} asChild>
<Button
{...buttonProps}
isLoading={fetchStatus === "loading"}
isLoadingText={
<>
<SymbolIcon className="icon animate-spin" />
</>
}
className="min-w-10"
/>
</FormRadix.Submit>
);
}
);
interface FormFieldProps extends Partial<FormRadix.FormFieldProps> {
label: React.ReactNode;
labelProps?: FormRadix.FormLabelProps;
controlProps?: FormRadix.FormControlProps;
field?: FieldProps;
errors?: ErrorObject<string, Record<string, any>, unknown>[];
}
export const FormField = React.forwardRef<HTMLDivElement, FormFieldProps>(
(
{ labelProps, label, controlProps, children, field, errors, ...props },
forwardedRef
) => {
const classNameConnect: string[] = ["flex flex-col gap-0.5"];
const hasError = field.touch && errors && errors.length > 0;
const hasSelect = React.Children.toArray(children).some(
(child: any) => child?.type === "select"
);
if (props.className) {
classNameConnect.push(props.className);
}
const name = props.name || field?.name;
return (
<FormRadix.Field
className={classNameConnect.join(" ")}
name={name}
{...props}
serverInvalid={hasError}
ref={forwardedRef}
>
{label && <FormRadix.Label {...labelProps}>{label}</FormRadix.Label>}
<div className="relative ">
<FormRadix.Control
{...controlProps}
onChange={field?.onChange}
asChild={Boolean(children)}
>
{children}
</FormRadix.Control>
{hasSelect && (
<div className="absolute top-1/2 -translate-y-1/2 right-2 pointer-events-none">
<ChevronDownIcon className="icon" />
</div>
)}
</div>
{hasError &&
errors?.map((error, index) => (
<FormRadix.Message className="text-warning-contrast" key={index}>
{error.message}
</FormRadix.Message>
))}
</FormRadix.Field>
);
}
);
interface FormSwitchProps extends Partial<FormRadix.FormFieldProps> {
id?: string;
label: React.ReactNode;
labelProps?: FormRadix.FormLabelProps;
controlProps?: FormRadix.FormControlProps;
field?: FieldProps;
errors?: ErrorObject<string, Record<string, any>, unknown>[];
}
export const FormSwitch = React.forwardRef<HTMLDivElement, FormSwitchProps>(
(
{ labelProps, label, controlProps, field, children, id, errors, ...props },
forwardedRef
) => {
const hasError = field.touch && errors && errors.length > 0;
const idConnect = id ? id : React.useId();
const name = props.name || field?.name;
return (
<FormRadix.Field
{...props}
ref={forwardedRef}
name={name}
serverInvalid={hasError}
>
<div className="flex items-center gap-1">
{label && (
<FormRadix.Label htmlFor={idConnect}>{label}</FormRadix.Label>
)}
<Switch
id={idConnect}
checked={Boolean(field?.value)}
onCheckedChange={field?.onCheckedChange}
/>
</div>
<FormRadix.Control
value="on"
type="checkbox"
checked={Boolean(field?.value)}
onChange={field?.onCheckedChange}
className="border-0 absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap break-normal clip-rect hidden"
/>
{hasError &&
errors?.map((error, index) => (
<FormRadix.Message className="text-warning-contrast" key={index}>
{error.message}
</FormRadix.Message>
))}
</FormRadix.Field>
);
}
);
interface FormCheckboxProps extends Partial<FormRadix.FormFieldProps> {
id?: string;
label: React.ReactNode;
labelProps?: FormRadix.FormLabelProps;
controlProps?: FormRadix.FormControlProps;
field?: FieldProps;
errors?: ErrorObject<string, Record<string, any>, unknown>[];
}
export const FormCheckbox = React.forwardRef<HTMLDivElement, FormCheckboxProps>(
(
{ labelProps, label, controlProps, field, children, id, errors, ...props },
forwardedRef
) => {
const hasError = field.touch && errors && errors.length > 0;
const idConnect = id ? id : React.useId();
const name = props.name || field?.name;
return (
<FormRadix.Field
{...props}
ref={forwardedRef}
name={name}
serverInvalid={hasError}
>
<div className="flex items-center gap-1">
<div>
<Checkbox
id={idConnect}
className="border border-gray-a6 flex size-1.5 items-center justify-center rounded outline-none hover:bg-accent-2"
checked={Boolean(field?.value)}
onCheckedChange={field?.onCheckedChange}
/>
</div>
{label && (
<FormRadix.Label htmlFor={idConnect}>{label}</FormRadix.Label>
)}
</div>
<FormRadix.Control
value="on"
type="checkbox"
checked={Boolean(field?.value)}
onChange={field?.onCheckedChange}
className="border-0 absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap break-normal clip-rect hidden"
/>
{hasError &&
errors?.map((error, index) => (
<FormRadix.Message className="text-warning-contrast" key={index}>
{error.message}
</FormRadix.Message>
))}
</FormRadix.Field>
);
}
);
export interface CheckboxGroupOption {
value: string;
label: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
}
export interface RadioGroupOption {
value: string;
label: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
}
interface FormCheckboxGroupProps extends Partial<FormRadix.FormFieldProps> {
label?: React.ReactNode;
labelProps?: FormRadix.FormLabelProps;
controlProps?: FormRadix.FormControlProps;
field?: FieldProps;
errors?: ErrorObject<string, Record<string, any>, unknown>[];
options: CheckboxGroupOption[];
className?: string;
}
export const FormCheckboxGroup = React.forwardRef<
HTMLDivElement,
FormCheckboxGroupProps
>(
(
{
label,
labelProps,
controlProps,
field,
errors,
options,
className,
...props
},
forwardedRef
) => {
const hasError = field?.touch && errors && errors.length > 0;
const name = props.name || field?.name || "";
const fieldValue = Array.isArray(field?.value)
? (field?.value as string[])
: [];
const handleValueChange = React.useCallback(
(value: string[]) => {
if (field?.setValue) {
// Checkbox groups use arrays, but field.setValue expects string | boolean
// We'll cast it since checkbox groups specifically need array values
field.setValue(value as any);
}
},
[field]
);
return (
<FormRadix.Field
{...props}
ref={forwardedRef}
name={name}
serverInvalid={hasError}
className={className}
>
{label && (
<div className="mb-1">
<FormRadix.Label {...labelProps}>{label}</FormRadix.Label>
</div>
)}
<FormRadix.Control {...controlProps} asChild>
<CheckboxGroup
value={fieldValue}
onValueChange={handleValueChange}
className="flex flex-col gap-1"
>
{options.map((option) => (
<CheckboxGroupItem
key={option.value}
value={option.value}
label={option.label}
description={option.description}
disabled={option.disabled}
/>
))}
</CheckboxGroup>
</FormRadix.Control>
{hasError &&
errors?.map((error, index) => (
<FormRadix.Message className="text-warning-contrast" key={index}>
{error.message}
</FormRadix.Message>
))}
</FormRadix.Field>
);
}
);
interface FormRadioGroupProps extends Partial<FormRadix.FormFieldProps> {
label?: React.ReactNode;
labelProps?: FormRadix.FormLabelProps;
controlProps?: FormRadix.FormControlProps;
field?: FieldProps;
errors?: ErrorObject<string, Record<string, any>, unknown>[];
options: RadioGroupOption[];
className?: string;
}
export const FormRadioGroup = React.forwardRef<
HTMLDivElement,
FormRadioGroupProps
>(
(
{
label,
labelProps,
controlProps,
field,
errors,
options,
className,
...props
},
forwardedRef
) => {
const hasError = field?.touch && errors && errors.length > 0;
const name = props.name || field?.name || "";
const fieldValue = field?.value ? String(field.value) : "";
const handleValueChange = React.useCallback(
(value: string) => {
if (field?.setValue) {
field.setValue(value);
}
},
[field]
);
return (
<FormRadix.Field
{...props}
ref={forwardedRef}
name={name}
serverInvalid={hasError}
className={className}
>
{label && (
<div className="mb-1">
<FormRadix.Label {...labelProps}>{label}</FormRadix.Label>
</div>
)}
<FormRadix.Control {...controlProps} asChild>
<RadioGroup
value={fieldValue}
onValueChange={handleValueChange}
className="flex flex-col gap-0.5"
>
{options.map((option) => {
const optionId = `${name}-${option.value}`;
return (
<div
key={option.value}
className={cn(
"flex items-start gap-1",
option.disabled && "opacity-50 cursor-not-allowed"
)}
>
<RadioGroupItem
value={option.value}
id={optionId}
disabled={option.disabled}
/>
<div className="flex flex-col gap-0.5">
<label
htmlFor={optionId}
className="text-sm font-medium leading-none cursor-pointer"
>
{option.label}
</label>
{option.description && (
<p className="text-sm text-gray-11 m-0">
{option.description}
</p>
)}
</div>
</div>
);
})}
</RadioGroup>
</FormRadix.Control>
{hasError &&
errors?.map((error, index) => (
<FormRadix.Message className="text-warning-contrast" key={index}>
{error.message}
</FormRadix.Message>
))}
</FormRadix.Field>
);
}
);
interface FormAlertProps {
formData: any;
}
export const FormAlert = ({ formData, ...props }: FormAlertProps) => {
if (formData.fetchStatus !== "error") {
return null;
}
if (!formData.error) {
return null;
}
return (
<AlertStatus status="danger" {...props} description={formData.error} />
);
};Usage
MultipleFields
export const MultipleFields = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
name: "text",
email: "text",
message: "textarea",
country: "select",
});
const { name, email, message, country } = formData.getFields();
const { isValid, errors } = validateFormData(
multipleFieldsSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please complete all required fields");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-multiple"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormField field={name} label="Name *">
<Input type="text" placeholder="Enter your name" />
</FormField>
<FormField field={email} label="Email *">
<Input type="email" placeholder="Enter your email" />
</FormField>
<FormField field={message} label="Message">
<textarea placeholder="Enter your message" rows={4} />
</FormField>
<FormField field={country} label="Country">
<select>
<option value="">Select a country</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
</select>
</FormField>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-multiple"
/>
</Form>
);
};WithErrors
export const WithErrors = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
name: "text",
email: "text",
});
const { name, email } = formData.getFields();
const { isValid, errors } = validateFormData(
defaultSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please fix the validation errors");
return;
}
formData.setFetchStatus("success");
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-errors"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormField field={name} label="Name *">
<Input type="text" placeholder="Enter your name" />
</FormField>
<FormField field={email} label="Email *">
<Input type="email" placeholder="Enter your email" />
</FormField>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-errors"
/>
</Form>
);
};WithSwitch
export const WithSwitch = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
notifications: "checkbox",
marketing: "checkbox",
});
const { notifications, marketing } = formData.getFields();
const { isValid, errors } = validateFormData(
switchSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-switch"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormSwitch field={notifications} label="Enable notifications" />
<FormSwitch field={marketing} label="Receive marketing emails" />
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-switch"
/>
</Form>
);
};WithCheckbox
export const WithCheckbox = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
terms: "checkbox",
privacy: "checkbox",
});
const { terms, privacy } = formData.getFields();
const { isValid, errors } = validateFormData(
checkboxSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please accept all required agreements");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-checkbox"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormCheckbox
field={terms}
label="I agree to the terms and conditions *"
/>
<FormCheckbox field={privacy} label="I agree to the privacy policy *" />
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-checkbox"
/>
</Form>
);
};WithCheckboxGroup
export const WithCheckboxGroup = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
interests: "checkbox",
});
const { interests } = formData.getFields();
const { isValid, errors } = validateFormData(
checkboxGroupSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const interestsOptions = [
{
value: "react",
label: "React",
description: "A JavaScript library for building user interfaces",
},
{
value: "typescript",
label: "TypeScript",
description: "JavaScript with syntax for types",
},
{
value: "tailwind",
label: "Tailwind CSS",
description: "A utility-first CSS framework",
},
{
value: "nextjs",
label: "Next.js",
description: "The React Framework for the Web",
},
];
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please select at least one interest");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-checkbox-group"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormCheckboxGroup
field={interests}
label="Select your interests *"
options={interestsOptions}
/>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-checkbox-group"
/>
</Form>
);
};WithSelect
export const WithSelect = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
country: "text",
city: "text",
});
const { country, city } = formData.getFields();
const { isValid, errors } = validateFormData(
selectSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const countryOptions = [
{ label: "United States", value: "us" },
{ label: "United Kingdom", value: "uk" },
{ label: "Canada", value: "ca" },
{ label: "Australia", value: "au" },
];
const cityOptions = [
{ label: "New York", value: "ny" },
{ label: "Los Angeles", value: "la" },
{ label: "Chicago", value: "ch" },
{ label: "San Francisco", value: "sf" },
];
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please select both country and city");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-select"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormFieldSelect
field={country}
label="Country *"
options={countryOptions}
placeholder="Select a country"
/>
<FormFieldSelect
field={city}
label="City *"
options={cityOptions}
placeholder="Select a city"
/>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-select"
/>
</Form>
);
};WithLoading
export const WithLoading = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
name: "text",
email: "text",
});
const { name, email } = formData.getFields();
const { isValid, errors } = validateFormData(
defaultSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please complete all required fields");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 2000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-loading"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormField field={name} label="Name *">
<Input type="text" placeholder="Enter your name" />
</FormField>
<FormField field={email} label="Email *">
<Input type="email" placeholder="Enter your email" />
</FormField>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-loading"
/>
</Form>
);
};CompleteForm
export const CompleteForm = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
name: "text",
email: "text",
phone: "text",
message: "textarea",
country: "select",
notifications: "checkbox",
terms: "checkbox",
interests: "checkbox",
});
const {
name,
email,
phone,
message,
country,
notifications,
terms,
interests,
} = formData.getFields();
const { isValid, errors } = validateFormData(
completeFormSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const interestsOptions = [
{
value: "web",
label: "Web Development",
description: "Building websites and web applications",
},
{
value: "mobile",
label: "Mobile Development",
description: "Creating mobile apps",
},
{
value: "design",
label: "UI/UX Design",
description: "Designing user interfaces",
},
];
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please complete all required fields");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-complete"
className="flex flex-col gap-1 smash"
>
<FormAlert formData={formDataForAlert} />
<FormField field={name} label="Full Name *">
<Input type="text" placeholder="Enter your full name" />
</FormField>
<FormField field={email} label="Email Address *">
<Input type="email" placeholder="Enter your email" />
</FormField>
<FormField field={phone} label="Phone Number">
<Input type="tel" placeholder="Enter your phone number" />
</FormField>
<FormField field={country} label="Country">
<select>
<option value="">Select a country</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
</select>
</FormField>
<FormField field={message} label="Message">
<textarea placeholder="Enter your message" rows={4} />
</FormField>
<FormSwitch field={notifications} label="Enable email notifications" />
<FormCheckboxGroup
field={interests}
label="Select your interests *"
options={interestsOptions}
className="bg-gray-1 p-2 border border-gray-a6 rounded-sm my-1"
/>
<FormCheckbox
field={terms}
label="I agree to the terms and conditions *"
/>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit Form" }}
form="form-complete"
/>
</Form>
);
};WithComboboxSingle
export const WithComboboxSingle = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
country: "text",
});
const { country } = formData.getFields();
const { isValid, errors } = validateFormData(
comboboxSingleSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please select a country");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-combobox-single"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormFieldCombobox
field={country}
label="Country *"
options={comboboxOptions}
placeholder="Select a country"
/>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-combobox-single"
/>
</Form>
);
};WithComboboxMultiple
export const WithComboboxMultiple = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
interests: "text",
}, formRef);
const { interests } = formData.getFields();
const { isValid, errors } = validateFormData(
comboboxMultipleSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please select at least one interest");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-combobox-multiple"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormFieldCombobox
field={interests}
label="Select your interests *"
options={comboboxInterestsOptions}
multiple
placeholder="Select one or more interests"
/>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-combobox-multiple"
/>
</Form>
);
};WithSignaturePad
export const WithSignaturePad = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
signature: "text",
});
const { signature } = formData.getFields();
const { isValid, errors } = validateFormData(
signaturePadSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please provide your signature");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-signature-pad"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormFieldSignaturePad
field={signature}
label="Signature *"
variant="default"
size="md"
/>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-signature-pad"
/>
</Form>
);
};WithSortableList
export const WithSortableList = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
priorities: "text",
});
const { priorities } = formData.getFields();
// Initialize with array value for sortable list
React.useEffect(() => {
if (!Array.isArray(priorities.value)) {
priorities.setValue(["High Priority", "Medium Priority", "Low Priority"] as any);
}
}, []);
const { isValid, errors } = validateFormData(
sortableListSchema,
formData.getValues()
);
const formErrors = errors || undefined;
// Filter errors for the priorities field
const prioritiesErrors = React.useMemo(() => {
if (!formErrors) return undefined;
return formErrors.filter(
(error) => error.instancePath === `/${priorities.name}`
);
}, [formErrors, priorities.name]);
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please ensure at least one priority is added");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-sortable-list"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormFieldSortableList
field={priorities}
label="Priorities *"
errors={prioritiesErrors}
renderItem={(item) => (
<span className="text-gray-12 flex-1">{String(item)}</span>
)}
/>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-sortable-list"
/>
</Form>
);
};WithEditor
export const WithEditor = () => {
const formRef = useRef<HTMLFormElement>(null);
const formData = useFormDynamic({
content: "text",
});
const { content } = formData.getFields();
console.log(content.value);
const { isValid, errors } = validateFormData(
editorSchema,
formData.getValues()
);
const formErrors = errors || undefined;
const handleOnSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
formData.setFetchStatus("loading");
if (!isValid) {
formData.setFetchStatus("error");
formData.touchForm();
formData.setError("Please enter some content");
return;
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
formData.setFetchStatus("success");
console.log("Form submitted:", formData.getValues());
};
const formDataForAlert = {
fetchStatus: formData.fetchStatus,
error: formData.error,
};
return (
<Form
ref={formRef}
onSubmit={handleOnSubmit}
errors={formErrors}
id="form-editor"
className="flex flex-col gap-1"
>
<FormAlert formData={formDataForAlert} />
<FormFieldEditor
field={content}
label="Content *"
/>
<FormSubmit
fetchStatus={formData.fetchStatus}
buttonProps={{ children: "Submit" }}
form="form-editor"
/>
</Form>
);
};