{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"form-field","type":"registry:ui","title":"Form Field","description":"The frame around any control – label with a required mark or Optional, a hint, an error in its place and a count, all wired up with ids and aria","dependencies":[],"files":[{"path":"registry/form-field/form-field.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { cloneElement, isValidElement, useId, type ComponentPropsWithRef, type ReactElement, type ReactNode } from \"react\";\n\n/*\n * Form Field – a single self-contained file.\n * The frame around any control: a label, a required mark or \"Optional\", a quiet hint, an error that takes the hint's\n * place and a slot for a count. It hands the control its id, aria-describedby, aria-invalid and aria-required, so a\n * plain <input>, a <select>, a textarea or your own control is wired up without extra code.\n * The styles are injected below, so no extra files are needed.\n */\n\n/** What the field hands its control – spread it onto the element that takes focus */\nexport type FormFieldControlProps = {\n  id: string;\n  \"aria-describedby\"?: string;\n  \"aria-invalid\"?: true;\n  \"aria-required\"?: true;\n  /** The label's id – for controls a <label> can't name, like a radio group: pass it as aria-labelledby */\n  labelId: string;\n  disabled?: boolean;\n};\n\nexport type FormFieldProps = Omit<ComponentPropsWithRef<\"div\">, \"children\"> & {\n  /** Shown above the control and tied to it */\n  label?: ReactNode;\n  /** A quiet line under the control – the error takes its place while there is one */\n  hint?: ReactNode;\n  /** Marks the control invalid and shows this under it, in place of the hint; true marks it without a message */\n  error?: ReactNode;\n  /** A small mark after the label, and aria-required on the control */\n  required?: boolean;\n  /** \"Optional\" next to the label – for forms where most fields are required */\n  optional?: boolean;\n  /** Under the control at the end, e.g. \"12/40\" – read out with the hint */\n  counter?: ReactNode;\n  /** Dims the field and disables the control */\n  disabled?: boolean;\n  /** The control's id – it keeps its own id if it has one, otherwise one is made up */\n  htmlFor?: string;\n  /** One control element – it gets the ids and aria attributes – or a function that spreads them where they belong */\n  children: ReactElement | ((control: FormFieldControlProps) => ReactNode);\n};\n\nexport function FormField({\n  label,\n  hint,\n  error,\n  required = false,\n  optional = false,\n  counter,\n  disabled = false,\n  htmlFor,\n  className,\n  children,\n  ...rest\n}: FormFieldProps) {\n  const autoId = useId();\n  const child = typeof children === \"function\" ? null : isValidElement<Record<string, unknown>>(children) ? children : null;\n  const childProps = (child?.props ?? {}) as Record<string, unknown>;\n\n  const controlId = (typeof childProps.id === \"string\" && childProps.id) || htmlFor || `${autoId}-control`;\n  const labelId = `${autoId}-label`;\n  const messageId = `${autoId}-message`;\n  const counterId = `${autoId}-count`;\n\n  const invalid = Boolean(error);\n  // Only text or elements count – error={true} marks the control invalid without leaving an empty line\n  const message = shows(error) ? error : shows(hint) ? hint : null;\n  const hasCounter = shows(counter);\n  const describedBy =\n    [typeof childProps[\"aria-describedby\"] === \"string\" ? childProps[\"aria-describedby\"] : null, message ? messageId : null, hasCounter ? counterId : null]\n      .filter(Boolean)\n      .join(\" \") || undefined;\n\n  const control: FormFieldControlProps = {\n    id: controlId,\n    \"aria-describedby\": describedBy,\n    \"aria-invalid\": invalid || undefined,\n    \"aria-required\": required || undefined,\n    labelId,\n    disabled: disabled || undefined,\n  };\n\n  let body: ReactNode = null;\n  if (typeof children === \"function\") body = children(control);\n  else if (child) {\n    const tag = typeof child.type === \"string\" ? child.type : null;\n    const native = tag !== null && NATIVE.includes(tag);\n    const named = childProps[\"aria-label\"] != null || childProps[\"aria-labelledby\"] != null;\n    body = cloneElement(child, {\n      id: controlId,\n      \"aria-describedby\": describedBy,\n      \"aria-invalid\": invalid || childProps[\"aria-invalid\"] || undefined,\n      \"aria-required\": required || childProps[\"aria-required\"] || undefined,\n      // A native control also gets required, so the browser checks it on submit\n      ...(native && required ? { required: true } : null),\n      // A <div role=\"radiogroup\"> and the like can't be named by a <label> – point at it instead\n      ...(tag !== null && !native && !named && label != null ? { \"aria-labelledby\": labelId } : null),\n      ...(disabled ? { disabled: true } : null),\n    });\n  }\n\n  return (\n    <div\n      {...rest}\n      className={[\"ui-fld\", className].filter(Boolean).join(\" \")}\n      data-invalid={invalid || undefined}\n      data-disabled={disabled || undefined}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many fields render */}\n      <style href=\"ui-form-field\" precedence=\"default\">\n        {css}\n      </style>\n      {label != null && (\n        <div className=\"ui-fld__head\">\n          <label className=\"ui-fld__label\" id={labelId} htmlFor={controlId}>\n            {label}\n            {required && (\n              <span className=\"ui-fld__required\" aria-hidden=\"true\">\n                *\n              </span>\n            )}\n          </label>\n          {optional && !required && <span className=\"ui-fld__optional\">Optional</span>}\n        </div>\n      )}\n      {body}\n      {(message || hasCounter) && (\n        <div className=\"ui-fld__foot\">\n          {/* The error swaps in where the hint was, so the form below doesn't move */}\n          <p className=\"ui-fld__message\" id={messageId} key={shows(error) ? \"error\" : \"hint\"} dir=\"auto\" aria-live={shows(error) ? \"polite\" : undefined}>\n            {message}\n          </p>\n          {hasCounter && (\n            <span className=\"ui-fld__count\" id={counterId}>\n              {counter}\n            </span>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** Elements a <label for> names and that take required */\nconst NATIVE = [\"input\", \"select\", \"textarea\"];\n/** Text or an element – not true, false, null or an empty string */\nconst shows = (v: ReactNode) => v != null && typeof v !== \"boolean\" && v !== \"\";\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-fld (see Theming in the docs) */\n.ui-fld {\n  --ui-fld-ink: var(--aui-ink, #171717);\n  --ui-fld-muted: var(--aui-muted, #8f8f8f);\n  --ui-fld-faint: var(--aui-placeholder, #b5b5b5);\n  --ui-fld-danger: var(--aui-danger, #c4320a);\n  --ui-fld-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n  display: flex;\n  flex-direction: column;\n  gap: 7px;\n  min-width: 0;\n  font-family: var(--aui-font, \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13.5px;\n  line-height: 20px;\n  color: var(--ui-fld-ink);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-fld[data-disabled] { opacity: 0.55; }\n\n.ui-fld__head { display: flex; align-items: baseline; gap: 6px; min-width: 0; padding: 0 2px; }\n.ui-fld__label { min-width: 0; font-size: 13px; font-weight: 500; color: var(--ui-fld-ink); overflow-wrap: anywhere; cursor: default; }\n/* The required mark: quiet, right after the last word */\n.ui-fld__required { margin-inline-start: 2px; color: var(--ui-fld-muted); font-weight: 400; white-space: nowrap; }\n.ui-fld__optional { flex: none; font-size: 12px; line-height: 16px; color: var(--ui-fld-muted); }\n\n/* Under the control: the hint, or the error in its place, and the count */\n.ui-fld__foot { display: flex; align-items: flex-start; gap: 12px; min-width: 0; padding: 0 2px; }\n.ui-fld__message {\n  flex: 1;\n  min-width: 0;\n  margin: 0;\n  font-size: 12.5px;\n  line-height: 17px;\n  color: var(--ui-fld-muted);\n  overflow-wrap: anywhere;\n  animation: ui-fld-message 260ms var(--ui-fld-ease);\n}\n.ui-fld[data-invalid] .ui-fld__message { color: var(--ui-fld-danger); }\n.ui-fld__count { flex: none; margin-inline-start: auto; font-size: 12px; line-height: 17px; font-variant-numeric: tabular-nums; color: var(--ui-fld-faint); white-space: nowrap; }\n\n@keyframes ui-fld-message {\n  from { opacity: 0; transform: translateY(-3px); filter: blur(2px); }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ui-fld__message { animation-duration: 1ms; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-fld {\n  --ui-fld-ink: var(--aui-ink, #ededed);\n  --ui-fld-muted: var(--aui-muted, #8b8b92);\n  --ui-fld-faint: var(--aui-placeholder, #5c5c63);\n  --ui-fld-danger: var(--aui-danger, #ff8a7a);\n}\n`;\n"}]}