{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"form-field-clean","type":"registry:ui","title":"Form Field (Clean)","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-clean/form-field-clean.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, clean style.\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-fldc\", 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-clean\" precedence=\"default\">\n        {css}\n      </style>\n      {label != null && (\n        <div className=\"ui-fldc__head\">\n          <label className=\"ui-fldc__label\" id={labelId} htmlFor={controlId}>\n            {label}\n            {required && (\n              <span className=\"ui-fldc__required\" aria-hidden=\"true\">\n                *\n              </span>\n            )}\n          </label>\n          {optional && !required && <span className=\"ui-fldc__optional\">Optional</span>}\n        </div>\n      )}\n      {body}\n      {(message || hasCounter) && (\n        <div className=\"ui-fldc__foot\">\n          {/* The error swaps in where the hint was, so the form below doesn't move */}\n          <p className=\"ui-fldc__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-fldc__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-fldc (see Theming in the docs) */\n.ui-fldc {\n  --ui-fldc-title: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-fldc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-fldc-muted: var(--aui-muted, rgba(0, 0, 0, 0.61));\n  --ui-fldc-subtle: var(--aui-muted, rgba(0, 0, 0, 0.45));\n  --ui-fldc-faint: var(--aui-placeholder, rgba(0, 0, 0, 0.3));\n  --ui-fldc-danger: var(--aui-danger, #c4320a);\n  --ui-fldc-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  min-width: 0;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13px;\n  line-height: 20px;\n  color: var(--ui-fldc-ink);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-fldc[data-disabled] { opacity: 0.55; }\n\n.ui-fldc__head { display: flex; align-items: baseline; gap: 6px; min-width: 0; }\n.ui-fldc__label { min-width: 0; font-weight: 500; color: var(--ui-fldc-title); overflow-wrap: anywhere; cursor: default; }\n/* The required mark: quiet, right after the last word, never alone on a line */\n.ui-fldc__required { margin-inline-start: 2px; color: var(--ui-fldc-subtle); font-weight: 400; white-space: nowrap; }\n.ui-fldc__optional { flex: none; font-size: 12px; line-height: 16px; color: var(--ui-fldc-subtle); }\n\n/* Under the control: the hint, or the error in its place, and the count */\n.ui-fldc__foot { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }\n.ui-fldc__message {\n  flex: 1;\n  min-width: 0;\n  margin: 0;\n  font-size: 12px;\n  line-height: 16px;\n  color: var(--ui-fldc-subtle);\n  overflow-wrap: anywhere;\n  animation: ui-fldc-message 260ms var(--ui-fldc-ease);\n}\n.ui-fldc[data-invalid] .ui-fldc__message { color: var(--ui-fldc-danger); }\n.ui-fldc__count { flex: none; margin-inline-start: auto; font-size: 12px; line-height: 16px; font-variant-numeric: tabular-nums; color: var(--ui-fldc-faint); white-space: nowrap; }\n\n@keyframes ui-fldc-message {\n  from { opacity: 0; transform: translateY(-3px); filter: blur(2px); }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ui-fldc__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-fldc {\n  --ui-fldc-title: var(--aui-ink, rgba(255, 255, 255, 0.92));\n  --ui-fldc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-fldc-muted: var(--aui-muted, rgba(255, 255, 255, 0.62));\n  --ui-fldc-subtle: var(--aui-muted, rgba(255, 255, 255, 0.46));\n  --ui-fldc-faint: var(--aui-placeholder, rgba(255, 255, 255, 0.3));\n  --ui-fldc-danger: var(--aui-danger, #ff8a7a);\n}\n`;\n"}]}