import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
import { Field as BaseField } from '@base-ui/react/field'
import { cn } from './cn'
/**
* Peek's Field (2026-08-28): a label over a control, 8px apart, with a red
* asterisk when required. The label is the `input-label` type token, merged
* with `cn()` like every other class list here. On Base UI's `Field` since
* stage 3 of the migration (2026-09-07).
*
* The label names its control. It did not once, and the control was a
* *sibling* of the label with no `htmlFor`, so there was neither an explicit
* nor an implicit association: a screen reader announced an unlabelled edit
* box and clicking the label focused nothing (SHA-17). That fix used to be
* ours — a context carrying a generated id, which every control had to opt
* into by calling `useFieldControlId`. **Base UI does it now**, for its own
* `Input`, `Checkbox`, `Select` and anything rendered through `Field.Control`,
* so a new control is labelled by construction rather than by remembering.
*
* `helper` and `error` are the line under the control, which Ship built by
* hand in two dialogs (`COMPONENTS-SHIP.md` F14) and Peek in four
* (`COMPONENTS-PEEK.md` F14), always the same two class lists. **An error
* replaces the helper rather than joining it** — that is what those callers
* did (`pairError ?? 'Leave empty and…'`), and a field that says both at once
* is asking the reader to work out which one is live.
*
* The line is announced: Base UI wires `aria-describedby` for the helper and
* `aria-invalid` + the error's id for the error, which the hand-built spans
* never did.
*
* `required` is announced too, since 2026-09-08. It drew the asterisk and
* nothing else — measured, the control carried neither `required` nor
* `aria-required`, so the one thing the mark means never reached anybody who
* could not see it. Base UI's `Field` has no `required` of its own, so this
* puts `aria-required` on the control itself.
*/
export interface FieldProps {
label: string
/**
* Draws the asterisk **and** marks the control `aria-required`, so the mark
* means something to a reader who cannot see it. It reaches a single control
* element; a `children` of several elements keeps the asterisk and owes its
* own `aria-required`.
*/
required?: boolean
/**
* A hint under the control — what the format is, what happens if it is left
* empty. `caption`, muted. Hidden while `error` is set.
*/
helper?: string
/**
* What is wrong, in the same place as the helper and in the error colour.
* Setting it also marks the control invalid, so the caller no longer passes
* `aria-invalid` itself.
*/
error?: string
children: ReactNode
}
/**
* The two looks the line under a control wears, in one place so `Field` and
* `FieldLine` cannot drift apart. `warning` is `FieldLine`'s alone — see there.
*/
const LINE_STYLES = {
helper: 'text-caption text-text-muted',
warning: 'text-caption text-warning-default',
error: 'text-caption text-error-default',
} as const
export function Field({ label, required = false, helper, error, children }: FieldProps) {
/*
The asterisk is a picture of `required`; this is the word for it. Base UI's
`Field` has no `required` to propagate, so the control is marked here —
`aria-required` rather than the native attribute, because the native one
also switches on the browser's own validation bubble, which no field in
either app uses. A control that already says so keeps what it says.
*/
const control =
required && isValidElement(children)
? cloneElement(children as ReactElement<{ 'aria-required'?: boolean | 'true' | 'false' }>, {
'aria-required': (children as ReactElement<{ 'aria-required'?: boolean | 'true' | 'false' }>).props['aria-required'] ?? true,
})
: children
return (
// `group/field`: the label, the box and the line under it are one thing under the pointer —
// anywhere in it strengthens the box's border, as hovering the box does (Katerina, 28 September).
// Before, the 8px between the label and the box was a gap where the border went out again.
{children}
) }