'use client' import * as React from 'react' import { Label } from './label' import { InfoHint } from './info-hint' /** * Accessibility props handed to the control by the `Field` render-prop. * Spread them onto the input/select/textarea so the label, required flag and * error message are all announced. */ export interface FieldRenderProps { id: string 'aria-required'?: boolean 'aria-invalid'?: boolean 'aria-describedby'?: string } export interface FieldProps { label: string /** Definition shown in a hover `InfoHint` next to the label. */ hint?: React.ReactNode required?: boolean children: (props: FieldRenderProps) => React.ReactNode error?: string | null /** * Inline content after the label text — AI badges, confidence chips. Lives in * the LABEL ROW so fields that carry badges keep the same geometry as fields * that don't; a hand-rolled label row next to a `Field` is what makes two * columns' controls land at different heights. */ labelExtras?: React.ReactNode /** * Right-aligned content at the end of the label row — character counters, * shortcuts. Same rationale: a counter rendered as its own row under the * control gives that column an extra line and misaligns every sibling. */ labelEnd?: React.ReactNode } /** * One form field: label, optional hint, control, optional error. * * Labels are a single line (hints live in the hover InfoHint icon, never in * flow), so cells top-align naturally and controls land on the same baseline. * Do NOT reintroduce `h-full` + `mt-auto` here: in a grid row it stretches the * cell to the tallest sibling and shoves this cell's control down to align with * the bottom of the sibling's ERROR text, detaching it from its own label. * * The label is tied to its control with a generated id, so clicking the label * focuses the input and assistive tech announces the field by name. `children` * is a REQUIRED render function — `{(f) => }` — spread what it * gives you onto the control. A plain-node escape hatch would leave `htmlFor` * pointing at an id that exists on no element, which is worse than no * association at all (the label looks wired up and announces nothing), so the * type doesn't offer one. */ export function Field({ label, hint, required, children, error, labelExtras, labelEnd }: FieldProps) { const controlId = React.useId() const errorId = `${controlId}-error` const renderProps: FieldRenderProps = { id: controlId, // A bare `*` draws a star and tells assistive tech nothing. ...(required ? { 'aria-required': true } : {}), ...(error ? { 'aria-invalid': true, 'aria-describedby': errorId } : {}), } return (
{hint && {hint}} {labelExtras} {labelEnd && {labelEnd}}
{children(renderProps)}
{/* `role="alert"` + the id the control points at: a validation message rendered as plain text is never announced. */} {error && ( )}
) }