Component
Form Field Optional
Optional fields: a muted (optional) indicator on the label — the clear alternative to marking every required field when most of the form is required.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/formfields/form-field-optional/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/FormFields/form-field-optional React/Components/FormFields/form-field-optional import {
Children,
cloneElement,
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useState,
} from "react";
import type {
FieldsetHTMLAttributes,
HTMLAttributes,
LabelHTMLAttributes,
ReactElement,
ReactNode,
} from "react";
/**
* DevSnips React Form Field — optional indicator.
*
* `optional` on `<FormFieldLabel>` renders a muted "(optional)" next to the
* label text — real text, so the state is never communicated by styling
* alone. The control is untouched (no attributes injected). Use it on the
* optional fields of a mostly-required form; use `required` on
* `<FormField>` for the inverse. Same compound core as the reference
* `form-field`.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ------------------------------------------------------------------------ */
/* Types + contexts */
/* ------------------------------------------------------------------------ */
export type FormFieldOrientation = "vertical" | "horizontal";
export type FormFieldMessageTone = "error" | "success";
type RegisteredTextKind = "description" | "helper" | "message";
interface RegisteredText {
id: string;
kind: RegisteredTextKind;
tone?: FormFieldMessageTone;
}
/**
* Nearest-registry contract: `FormField` and `FormFieldGroup` both provide
* one. Description / helper / message primitives register into the nearest
* registry, so texts placed inside a `<FormField>` describe that field's
* control, and texts placed directly inside a `<FormFieldGroup>` describe
* the whole `<fieldset>`.
*/
interface FieldTextRegistryValue {
registerText: (entry: RegisteredText) => () => void;
}
interface FormFieldContextValue {
controlId: string;
required: boolean;
disabled: boolean;
orientation: FormFieldOrientation;
describedBy: string | undefined;
hasError: boolean;
}
const FieldTextRegistryContext = createContext<FieldTextRegistryValue | null>(null);
const FormFieldContext = createContext<FormFieldContextValue | null>(null);
/**
* Access the wiring of the enclosing `<FormField>` (control id, described-by
* ids, error / required / disabled state). Use it to build custom controls
* that participate in the field wiring without `<FormFieldControl>`.
*/
export function useFormField(): FormFieldContextValue {
const field = useContext(FormFieldContext);
if (!field) {
throw new Error("useFormField must be used inside <FormField>");
}
return field;
}
function useRegisterFieldText(
id: string,
kind: RegisteredTextKind,
tone?: FormFieldMessageTone,
): void {
const registry = useContext(FieldTextRegistryContext);
useEffect(() => {
if (!registry) return;
return registry.registerText({ id, kind, tone });
}, [registry, id, kind, tone]);
}
function useFieldTexts(): [RegisteredText[], FieldTextRegistryValue["registerText"]] {
const [texts, setTexts] = useState<RegisteredText[]>([]);
const registerText = useCallback((entry: RegisteredText) => {
setTexts((prev) => (prev.some((t) => t.id === entry.id) ? prev : [...prev, entry]));
return () => {
setTexts((prev) => prev.filter((t) => t.id !== entry.id));
};
}, []);
return [texts, registerText];
}
/* ------------------------------------------------------------------------ */
/* Shared classes */
/* ------------------------------------------------------------------------ */
const LABEL_CLASSES = "block text-[13px] font-medium leading-5";
const DESCRIPTION_CLASSES = "text-xs leading-4 text-[var(--ds-color-muted-foreground)]";
const HELPER_CLASSES = "text-xs leading-4 text-[var(--ds-color-muted-foreground)]";
const MESSAGE_CLASSES = "flex items-start gap-1.5 text-xs leading-4";
const LEGEND_CLASSES = "p-0 text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]";
// In the horizontal layout the label sits in the left column; every other
// primitive (and the control) is placed in the right column via sm:col-start-2.
const LABEL_HORIZONTAL_CLASSES = "sm:col-start-1 sm:row-start-1 sm:pt-2";
const BODY_HORIZONTAL_CLASSES = "sm:col-start-2";
/* ------------------------------------------------------------------------ */
/* <FormField> — root provider */
/* ------------------------------------------------------------------------ */
export interface FormFieldProps extends HTMLAttributes<HTMLDivElement> {
/** Id given to the control (and the label's `htmlFor`). Generated when omitted. */
controlId?: string;
/** Marks the field required: the label gets a required indicator and the control a native `required`. */
required?: boolean;
/** Disables the field: the control gets a native `disabled` and the label is muted. */
disabled?: boolean;
/** `horizontal` puts the label in a left column from `sm` up; below `sm` the field stacks. */
orientation?: FormFieldOrientation;
children?: ReactNode;
}
export function FormField({
controlId,
required = false,
disabled = false,
orientation = "vertical",
className,
children,
...rest
}: FormFieldProps) {
const generatedId = useId();
const resolvedControlId = controlId ?? `field-${generatedId}`;
const [texts, registerText] = useFieldTexts();
const describedBy = texts.length ? texts.map((t) => t.id).join(" ") : undefined;
const hasError = texts.some((t) => t.kind === "message" && t.tone === "error");
// Context values are memoized: the primitives' registration effects depend
// on the registry, so a new object identity every render would re-run the
// effects (setState) in a loop.
const fieldValue = useMemo<FormFieldContextValue>(
() => ({
controlId: resolvedControlId,
required,
disabled,
orientation,
describedBy,
hasError,
}),
[resolvedControlId, required, disabled, orientation, describedBy, hasError],
);
const registryValue = useMemo<FieldTextRegistryValue>(
() => ({ registerText }),
[registerText],
);
return (
<FieldTextRegistryContext.Provider value={registryValue}>
<FormFieldContext.Provider value={fieldValue}>
<div
data-ds-form-field=""
data-orientation={orientation}
className={cx(
orientation === "horizontal"
? "grid w-full min-w-0 grid-cols-1 gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-x-4"
: "flex w-full min-w-0 flex-col gap-2",
className,
)}
{...rest}
>
{children}
</div>
</FormFieldContext.Provider>
</FieldTextRegistryContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* <FormFieldLabel> */
/* ------------------------------------------------------------------------ */
export interface FormFieldLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
/** Show a muted "(optional)" indicator. Use on the optional fields of a mostly-required form. */
optional?: boolean;
children?: ReactNode;
}
export function FormFieldLabel({
optional = false,
className,
children,
...rest
}: FormFieldLabelProps) {
const field = useFormField();
return (
<label
htmlFor={field.controlId}
className={cx(
LABEL_CLASSES,
field.disabled
? "text-[var(--ds-color-muted-foreground)]"
: "text-[var(--ds-color-foreground)]",
field.orientation === "horizontal" && LABEL_HORIZONTAL_CLASSES,
className,
)}
{...rest}
>
{children}
{field.required ? (
<>
<span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span>
<span className="sr-only"> (required)</span>
</>
) : optional ? (
<span className="ml-1 font-normal text-[var(--ds-color-muted-foreground)]">(optional)</span>
) : null}
</label>
);
}
/* ------------------------------------------------------------------------ */
/* <FormFieldControl> — injects the wiring into the wrapped control */
/* ------------------------------------------------------------------------ */
export interface FormFieldControlProps {
/**
* Exactly one control element: a native `<input>` / `<select>` /
* `<textarea>` / `<button>`-style control, or a DevSnips component that
* forwards these props to its underlying control.
*/
children: ReactElement;
}
export function FormFieldControl({ children }: FormFieldControlProps) {
const field = useFormField();
const child = Children.only(children);
const own = child.props as { className?: string; "aria-describedby"?: string };
const describedBy = cx(own["aria-describedby"], field.describedBy) || undefined;
return cloneElement(child, {
id: field.controlId,
"aria-describedby": describedBy,
...(field.hasError ? { "aria-invalid": true } : {}),
...(field.required ? { required: true } : {}),
...(field.disabled ? { disabled: true } : {}),
className: cx(
own.className,
field.orientation === "horizontal" && BODY_HORIZONTAL_CLASSES,
),
});
}
/* ------------------------------------------------------------------------ */
/* <FormFieldDescription> / <FormFieldHelper> / <FormFieldMessage> */
/* ------------------------------------------------------------------------ */
export interface FormFieldDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
children?: ReactNode;
}
/**
* Supporting text that frames the field before typing (purpose, impact).
* Renders between the label and the control and is linked to the control
* with `aria-describedby`. In the horizontal layout it may also be placed
* after the control.
*/
export function FormFieldDescription({
className,
children,
...rest
}: FormFieldDescriptionProps) {
const id = useId();
useRegisterFieldText(id, "description");
const field = useContext(FormFieldContext);
return (
<p
id={id}
className={cx(
DESCRIPTION_CLASSES,
field?.orientation === "horizontal" && BODY_HORIZONTAL_CLASSES,
className,
)}
{...rest}
>
{children}
</p>
);
}
export interface FormFieldHelperProps extends HTMLAttributes<HTMLParagraphElement> {
children?: ReactNode;
}
/**
* Persistent hint below the control (format, constraints — "how is this
* used"), linked with `aria-describedby`. For validation feedback use
* `<FormFieldMessage>` instead.
*/
export function FormFieldHelper({ className, children, ...rest }: FormFieldHelperProps) {
const id = useId();
useRegisterFieldText(id, "helper");
const field = useContext(FormFieldContext);
return (
<p
id={id}
className={cx(
HELPER_CLASSES,
field?.orientation === "horizontal" && BODY_HORIZONTAL_CLASSES,
className,
)}
{...rest}
>
{children}
</p>
);
}
export interface FormFieldMessageProps extends HTMLAttributes<HTMLParagraphElement> {
/** `error` announces with `role="alert"` and marks the control `aria-invalid`; `success` announces politely with `role="status"`. */
tone: FormFieldMessageTone;
children?: ReactNode;
}
/**
* Validation feedback below the control. An error message is destructive
* text with an alert icon, announced with `role="alert"`, and flips the
* control to `aria-invalid="true"` while it is rendered — remove the
* message to clear the error state. A success message uses the success
* token with a check icon and `role="status"`. An icon + text carry the
* state, never color alone.
*/
export function FormFieldMessage({ tone, className, children, ...rest }: FormFieldMessageProps) {
const id = useId();
useRegisterFieldText(id, "message", tone);
const field = useContext(FormFieldContext);
return (
<p
id={id}
data-tone={tone}
role={tone === "error" ? "alert" : "status"}
className={cx(
MESSAGE_CLASSES,
tone === "error"
? "text-[var(--ds-color-destructive)]"
: "text-[var(--ds-color-success)]",
field?.orientation === "horizontal" && BODY_HORIZONTAL_CLASSES,
className,
)}
{...rest}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className="mt-px size-3.5 shrink-0"
>
{tone === "error" ? (
<>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</>
) : (
<>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</>
)}
</svg>
<span>{children}</span>
</p>
);
}
/* ------------------------------------------------------------------------ */
/* <FormFieldGroup> — fieldset + legend for related fields */
/* ------------------------------------------------------------------------ */
export interface FormFieldGroupProps extends FieldsetHTMLAttributes<HTMLFieldSetElement> {
/** The group's accessible name, rendered as the `<legend>` (required). */
legend: ReactNode;
/** `horizontal` lays the children out in a wrapping row; `vertical` stacks them. */
orientation?: FormFieldOrientation;
children?: ReactNode;
}
/**
* A real `<fieldset>` + `<legend>` grouping related controls (radio groups,
* checkbox sets, address blocks). `disabled` disables every descendant
* control natively. `FormFieldDescription` / `FormFieldHelper` /
* `FormFieldMessage` placed directly inside register against the fieldset
* and are linked to it with `aria-describedby`; nested `<FormField>`
* children keep their own wiring.
*/
export function FormFieldGroup({
legend,
orientation = "vertical",
disabled,
className,
children,
...rest
}: FormFieldGroupProps) {
const [texts, registerText] = useFieldTexts();
const describedBy = texts.length ? texts.map((t) => t.id).join(" ") : undefined;
const registryValue = useMemo<FieldTextRegistryValue>(
() => ({ registerText }),
[registerText],
);
return (
<FieldTextRegistryContext.Provider value={registryValue}>
<fieldset
data-ds-form-field-group=""
data-orientation={orientation}
disabled={disabled}
aria-describedby={describedBy}
className={cx("m-0 w-full min-w-0 border-0 p-0", className)}
{...rest}
>
<legend className={LEGEND_CLASSES}>{legend}</legend>
<div
className={cx(
"mt-2",
orientation === "horizontal"
? "flex flex-wrap gap-x-4 gap-y-2"
: "flex flex-col gap-2",
)}
>
{children}
</div>
</fieldset>
</FieldTextRegistryContext.Provider>
);
}
export default FormField; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
Children,
cloneElement,
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const FieldTextRegistryContext = createContext(null);
const FormFieldContext = createContext(null);
function useFormField() {
const field = useContext(FormFieldContext);
if (!field) {
throw new Error("useFormField must be used inside <FormField>");
}
return field;
}
function useRegisterFieldText(id, kind, tone) {
const registry = useContext(FieldTextRegistryContext);
useEffect(() => {
if (!registry) return;
return registry.registerText({ id, kind, tone });
}, [registry, id, kind, tone]);
}
function useFieldTexts() {
const [texts, setTexts] = useState([]);
const registerText = useCallback((entry) => {
setTexts((prev) => prev.some((t) => t.id === entry.id) ? prev : [...prev, entry]);
return () => {
setTexts((prev) => prev.filter((t) => t.id !== entry.id));
};
}, []);
return [texts, registerText];
}
const LABEL_CLASSES = "block text-[13px] font-medium leading-5";
const DESCRIPTION_CLASSES = "text-xs leading-4 text-[var(--ds-color-muted-foreground)]";
const HELPER_CLASSES = "text-xs leading-4 text-[var(--ds-color-muted-foreground)]";
const MESSAGE_CLASSES = "flex items-start gap-1.5 text-xs leading-4";
const LEGEND_CLASSES = "p-0 text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]";
const LABEL_HORIZONTAL_CLASSES = "sm:col-start-1 sm:row-start-1 sm:pt-2";
const BODY_HORIZONTAL_CLASSES = "sm:col-start-2";
function FormField({
controlId,
required = false,
disabled = false,
orientation = "vertical",
className,
children,
...rest
}) {
const generatedId = useId();
const resolvedControlId = controlId ?? `field-${generatedId}`;
const [texts, registerText] = useFieldTexts();
const describedBy = texts.length ? texts.map((t) => t.id).join(" ") : undefined;
const hasError = texts.some((t) => t.kind === "message" && t.tone === "error");
const fieldValue = useMemo(
() => ({
controlId: resolvedControlId,
required,
disabled,
orientation,
describedBy,
hasError
}),
[resolvedControlId, required, disabled, orientation, describedBy, hasError]
);
const registryValue = useMemo(
() => ({ registerText }),
[registerText]
);
return <FieldTextRegistryContext.Provider value={registryValue}>
<FormFieldContext.Provider value={fieldValue}>
<div
data-ds-form-field=""
data-orientation={orientation}
className={cx(
orientation === "horizontal" ? "grid w-full min-w-0 grid-cols-1 gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-x-4" : "flex w-full min-w-0 flex-col gap-2",
className
)}
{...rest}
>
{children}
</div>
</FormFieldContext.Provider>
</FieldTextRegistryContext.Provider>;
}
function FormFieldLabel({
optional = false,
className,
children,
...rest
}) {
const field = useFormField();
return <label
htmlFor={field.controlId}
className={cx(
LABEL_CLASSES,
field.disabled ? "text-[var(--ds-color-muted-foreground)]" : "text-[var(--ds-color-foreground)]",
field.orientation === "horizontal" && LABEL_HORIZONTAL_CLASSES,
className
)}
{...rest}
>
{children}
{field.required ? <>
<span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span>
<span className="sr-only"> (required)</span>
</> : optional ? <span className="ml-1 font-normal text-[var(--ds-color-muted-foreground)]">(optional)</span> : null}
</label>;
}
function FormFieldControl({ children }) {
const field = useFormField();
const child = Children.only(children);
const own = child.props;
const describedBy = cx(own["aria-describedby"], field.describedBy) || undefined;
return cloneElement(child, {
id: field.controlId,
"aria-describedby": describedBy,
...field.hasError ? { "aria-invalid": true } : {},
...field.required ? { required: true } : {},
...field.disabled ? { disabled: true } : {},
className: cx(
own.className,
field.orientation === "horizontal" && BODY_HORIZONTAL_CLASSES
)
});
}
function FormFieldDescription({
className,
children,
...rest
}) {
const id = useId();
useRegisterFieldText(id, "description");
const field = useContext(FormFieldContext);
return <p
id={id}
className={cx(
DESCRIPTION_CLASSES,
field?.orientation === "horizontal" && BODY_HORIZONTAL_CLASSES,
className
)}
{...rest}
>
{children}
</p>;
}
function FormFieldHelper({ className, children, ...rest }) {
const id = useId();
useRegisterFieldText(id, "helper");
const field = useContext(FormFieldContext);
return <p
id={id}
className={cx(
HELPER_CLASSES,
field?.orientation === "horizontal" && BODY_HORIZONTAL_CLASSES,
className
)}
{...rest}
>
{children}
</p>;
}
function FormFieldMessage({ tone, className, children, ...rest }) {
const id = useId();
useRegisterFieldText(id, "message", tone);
const field = useContext(FormFieldContext);
return <p
id={id}
data-tone={tone}
role={tone === "error" ? "alert" : "status"}
className={cx(
MESSAGE_CLASSES,
tone === "error" ? "text-[var(--ds-color-destructive)]" : "text-[var(--ds-color-success)]",
field?.orientation === "horizontal" && BODY_HORIZONTAL_CLASSES,
className
)}
{...rest}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className="mt-px size-3.5 shrink-0"
>
{tone === "error" ? <>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</> : <>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</>}
</svg>
<span>{children}</span>
</p>;
}
function FormFieldGroup({
legend,
orientation = "vertical",
disabled,
className,
children,
...rest
}) {
const [texts, registerText] = useFieldTexts();
const describedBy = texts.length ? texts.map((t) => t.id).join(" ") : undefined;
const registryValue = useMemo(
() => ({ registerText }),
[registerText]
);
return <FieldTextRegistryContext.Provider value={registryValue}>
<fieldset
data-ds-form-field-group=""
data-orientation={orientation}
disabled={disabled}
aria-describedby={describedBy}
className={cx("m-0 w-full min-w-0 border-0 p-0", className)}
{...rest}
>
<legend className={LEGEND_CLASSES}>{legend}</legend>
<div
className={cx(
"mt-2",
orientation === "horizontal" ? "flex flex-wrap gap-x-4 gap-y-2" : "flex flex-col gap-2"
)}
>
{children}
</div>
</fieldset>
</FieldTextRegistryContext.Provider>;
}
export { useFormField, FormField, FormFieldLabel, FormFieldControl, FormFieldDescription, FormFieldHelper, FormFieldMessage, FormFieldGroup };
export default FormField; # Form Field Optional
Optional fields: a muted (optional) indicator on the label — the clear alternative to marking every required field when most of the form is required.
## Usage
```tsx
<FormField>
<FormFieldLabel optional>Company</FormFieldLabel>
<FormFieldControl>
<input name="company" />
</FormFieldControl>
<FormFieldHelper>We use this to tailor your invoice.</FormFieldHelper>
</FormField>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
<FormField>
<FormFieldLabel optional>Company</FormFieldLabel>
<FormFieldControl>
<input name="company" />
</FormFieldControl>
<FormFieldHelper>We use this to tailor your invoice.</FormFieldHelper>
</FormField>
```
## Props
### `<FormField>`
| Name | Type | Default | Description |
|---|---|---|---|
| `controlId` | `string` | generated | Id given to the control; the label's `htmlFor` points at it. |
| `required` | `boolean` | `false` | Required indicator on the label + native `required` on the control. |
| `disabled` | `boolean` | `false` | Muted label + native `disabled` on the control. |
| `orientation` | `"vertical" \| "horizontal"` | `"vertical"` | `horizontal` puts the label in a left column from `sm` up; stacks below `sm`. |
| `className` | `string` | — | Extra classes on the root element. |
| `children` | `ReactNode` | — | `FormFieldLabel`, `FormFieldControl`, descriptions, helpers, messages. |
### `<FormFieldLabel>`
| Name | Type | Default | Description |
|---|---|---|---|
| `optional` | `boolean` | `false` | Show a muted "(optional)" indicator (for the optional fields of a mostly-required form). |
| `className` | `string` | — | Extra classes on the `<label>`. |
| `children` | `ReactNode` | — | Label text. |
A real `<label htmlFor>` pointing at the field's control — clicking it focuses the control.
### `<FormFieldControl>`
| Name | Type | Default | Description |
|---|---|---|---|
| `children` | `ReactElement` (exactly one) | — | The control: a native `<input>` / `<select>` / `<textarea>`, or a DevSnips component that forwards props to its underlying control. |
Injects `id`, `aria-describedby` (registered description / helper / message ids, merged with any the control already carries), `aria-invalid="true"` (only while an error message is rendered), and `required` / `disabled` when the field sets them. Props already on the control win nowhere — the field owns `id`, `required`, and `disabled`; `aria-describedby` values are merged.
### `<FormFieldHelper>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<p>`. |
| `children` | `ReactNode` | — | Helper text. |
Muted persistent hint below the control (format, constraints), linked with `aria-describedby`. For validation feedback use `FormFieldMessage`.
## Composition
- `FormField` — the root provider. Owns the control id (generated or via `controlId`), the `required` / `disabled` / `orientation` state, and the registry that description, helper, and message texts register into.
- `FormFieldLabel` — a real `<label htmlFor>` pointing at the control. Renders the required indicator (`*` + sr-only text) when the field is required, or a muted "(optional)" when `optional` is set.
- `FormFieldControl` — wraps exactly one control element (native `<input>` / `<select>` / `<textarea>` or a DevSnips component that forwards props to its control) and injects the wiring: `id`, `aria-describedby`, `aria-invalid` (only while an error message is rendered), `required`, `disabled`.
- `FormFieldDescription` — muted supporting text framing the field; registered, then linked with `aria-describedby`.
- `FormFieldHelper` — muted persistent hint below the control; registered, then linked with `aria-describedby`.
- `FormFieldMessage` — validation feedback. `tone="error"` announces with `role="alert"` and flips the control to `aria-invalid="true"`; `tone="success"` announces politely with `role="status"`. An icon + text carry the state — never color alone.
- `FormFieldGroup` — a real `<fieldset>` + `<legend>` for related controls; texts placed directly inside describe the whole group. `disabled` disables every descendant control natively.
- `useFormField` — hook exposing the field wiring (control id, described-by ids, error / required / disabled) for building custom controls.
`optional` lives on `FormFieldLabel` — it is a label indicator, not a control state, so the control itself stays untouched.
## Field Wiring
`FormField` generates a control id (or takes `controlId`) and hands it to `FormFieldLabel` (`htmlFor`) and `FormFieldControl` (`id`), so the label/control association is automatic and can never dangle. `FormFieldDescription`, `FormFieldHelper`, and `FormFieldMessage` each generate their own id and **register** it with the nearest provider in an effect; only then does the control's `aria-describedby` reference those ids — the attribute is omitted entirely while no text is rendered, and removed ids are unregistered on unmount. `FormFieldControl` merges these ids with any `aria-describedby` the control already carries.
An error `FormFieldMessage` additionally flips the control to `aria-invalid="true"` for as long as it is rendered; removing the message clears both the described-by id and the invalid state. `required` / `disabled` on `FormField` are forwarded to the control as the **native** attributes, so constraint validation, form submission, and assistive-technology announcements behave natively.
The wiring is control-agnostic: `FormFieldControl` clones its single child and merges props, so native elements and DevSnips components (which forward these props to their underlying control) both work. Custom controls can read the same wiring through `useFormField()`.
## Accessibility
- Label ↔ control: `FormFieldLabel` is a real `<label>` whose `htmlFor` is the control's `id` — clicking the label focuses the control, and assistive technology announces the label as the control's accessible name.
- Descriptions, helpers, and messages are linked with `aria-describedby` **by registration**: ids are generated, registered, and only then referenced. There are no dangling ARIA references, and `aria-describedby` is omitted entirely when nothing describes the control.
- Error messages render `role="alert"` (announced immediately on appearance) and set `aria-invalid="true"` on the control; success messages render `role="status"` (politely announced). Both pair an icon with text, so state is never communicated by color alone.
- `required` is the native attribute (announced as required), plus a destructive `*` marked `aria-hidden` with an sr-only "(required)" fallback — the visual asterisk is never the only indicator.
- `disabled` is the native attribute: the control leaves the tab order and cannot be edited.
- `FormFieldGroup` is a real `<fieldset>` + `<legend>`, so grouped controls (radios, checkboxes) get a programmatic group name; group-level texts describe the whole fieldset via `aria-describedby`.
## States
- **Default** — foreground label, muted supporting text, control styled by its own component.
- **Required** — destructive `*` + sr-only "(required)" on the label; native `required` on the control.
- **Optional** — muted "(optional)" label indicator (a label choice, not a control state).
- **Disabled** — native `disabled` control (out of tab order, not editable) + muted label.
- **Error** — destructive message with alert icon, `role="alert"`, `aria-invalid="true"` on the control.
- **Success** — success-token message with check icon, `role="status"`.
- **Grouped** — `<fieldset>` + `<legend>`; group texts describe the whole group.
## Responsive Behavior
The default vertical layout stacks label, description, control, and helper/message in one column at every width — full-width, `min-w-0`, no overflow. `orientation="horizontal"` uses a `10rem` label column + `minmax(0,1fr)` control column from `sm` up and collapses to the single-column stack below `sm`, so labels are never clipped and controls never overflow on narrow screens. `FormFieldGroup orientation="horizontal"` lays children out in a wrapping row (`flex-wrap`), so choice rows reflow instead of overflowing. Verified at 375 / 768 / 1280px with zero horizontal overflow.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `text-[var(--ds-color-muted-foreground)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This form field variant uses the semantic color, typography, and spacing tokens — including `color.muted-foreground` for supporting text, `color.destructive` for the error state, `color.success` for the success state, and `color.focus-ring` on the wrapped control.
## Notes
Guideline: when most fields in a form are required, mark the optional ones (`optional`); when most are optional, mark the required ones (`required` on `FormField`). The demo mixes both conventions so the indicators can be compared side by side. 457 lines UTF-8 · LF · Spaces: 2
Continue browsing