51 lines
1.6 KiB
TypeScript
51 lines
1.6 KiB
TypeScript
import * as React from "react";
|
|
import { cn } from "../lib/cn";
|
|
import { Label } from "./label";
|
|
|
|
export interface FormFieldProps {
|
|
label: React.ReactNode;
|
|
children: React.ReactElement<{
|
|
id?: string;
|
|
"aria-describedby"?: string;
|
|
"aria-invalid"?: boolean;
|
|
}>;
|
|
id?: string;
|
|
description?: React.ReactNode;
|
|
error?: React.ReactNode;
|
|
required?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
/** Associates a control with its label, supporting text, and validation error. */
|
|
export function FormField({
|
|
label,
|
|
children,
|
|
id,
|
|
description,
|
|
error,
|
|
required,
|
|
className,
|
|
}: FormFieldProps) {
|
|
const generatedId = React.useId();
|
|
const controlId = id ?? children.props.id ?? `field-${generatedId}`;
|
|
const descriptionId = description ? `${controlId}-description` : undefined;
|
|
const errorId = error ? `${controlId}-error` : undefined;
|
|
const describedBy = [children.props["aria-describedby"], descriptionId, errorId].filter(Boolean).join(" ") || undefined;
|
|
|
|
return (
|
|
<div className={cn("space-y-1.5", className)}>
|
|
<Label htmlFor={controlId} className="mb-0 text-sm text-text">
|
|
{label}
|
|
{required && <span className="ml-1 text-danger" aria-hidden>*</span>}
|
|
</Label>
|
|
{React.cloneElement(children, {
|
|
id: controlId,
|
|
"aria-describedby": describedBy,
|
|
"aria-invalid": Boolean(error) || children.props["aria-invalid"],
|
|
})}
|
|
{description && !error && <p id={descriptionId} className="text-xs text-muted">{description}</p>}
|
|
{error && <p id={errorId} className="text-xs font-medium text-danger">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|