{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-field",
  "title": "Field shell primitive",
  "description": "Label, description ⇄ error message slot and the bordered input group, on Base UI Field. Focus is the border alone; turning invalid shakes the group once, 6px, and the message swaps with y + blur while its height springs.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-auto-height.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/field/field.tsx",
      "content": "\"use client\";\n\nimport { Field as BaseField } from \"@base-ui/react/field\";\nimport { AnimatePresence, animate, motion, useIsPresent } from \"motion/react\";\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ComponentPropsWithoutRef,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { enter, exitFast, reducedFade, smooth, swap, swapIn, swapOut, swapShown, useSafeReducedMotion } from \"../../lib/motion\";\nimport { AutoHeight } from \"../auto-height/auto-height\";\n\n/* ---------- Sizes ------------------------------------------------------------- */\n\nexport type ControlSize = \"sm\" | \"md\" | \"lg\";\n\n/**\n * Height, radius, gap and type for a text control's shell, by size. Each sets\n * `--radius-outer`/`--pad` so anything nested (a clear button, a chip) can use `radius-nested`.\n */\nexport const controlSizeClass: Record<ControlSize, string> = {\n  sm: \"min-h-control-sm rounded-control-sm gap-1.5 text-ui-sm [--pad:6px] [--radius-outer:var(--eased-radius-control-sm)]\",\n  md: \"min-h-control-md rounded-control gap-2 text-ui [--pad:8px] [--radius-outer:var(--eased-radius-control)]\",\n  lg: \"min-h-control-lg rounded-control gap-2 text-ui [--pad:8px] [--radius-outer:var(--eased-radius-control)]\",\n};\n\n/** Horizontal padding of a one-line shell, by size (0.3–0.35 × height: text fields sit tighter than buttons). */\nexport const controlPadClass: Record<ControlSize, string> = { sm: \"px-2.5\", md: \"px-3\", lg: \"px-3.5\" };\n\n/**\n * Classes for the bare `<input>`/`<textarea>` inside an `InputGroup`: no box of its own (the\n * group draws border and focus), 16px on touch screens so iOS does not zoom on focus.\n */\nexport const controlClass =\n  \"min-w-0 flex-1 self-stretch bg-transparent text-text outline-none placeholder:text-text-muted disabled:cursor-not-allowed disabled:text-text-disabled disabled:placeholder:text-text-disabled pointer-coarse:text-ui-md\";\n\n/* ---------- Root, label, description ---------------------------------------- */\n\nexport type FieldRootProps = Omit<ComponentProps<typeof BaseField.Root>, \"className\"> & { className?: string };\n\n/**\n * Base UI's Field.Root: wires label, control, description and error together (ids,\n * `aria-describedby`, validation, `data-invalid` …). Stacks its parts with a 6px rhythm.\n */\nexport function FieldRoot({ className = \"\", ...props }: FieldRootProps) {\n  return (\n    <BaseField.Root\n      {...props}\n      className={`group/field flex min-w-0 flex-col gap-1.5 ${className}`}\n    />\n  );\n}\n\nexport type FieldLabelProps = Omit<ComponentProps<typeof BaseField.Label>, \"className\"> & { className?: string };\n\nexport function FieldLabel({ className = \"\", ...props }: FieldLabelProps) {\n  return (\n    <BaseField.Label\n      {...props}\n      className={`w-fit cursor-default text-ui font-medium text-text group-data-disabled/field:text-text-disabled ${className}`}\n    />\n  );\n}\n\n/* ---------- The message slot: description ⇄ error ----------------------------- */\n\nexport type FieldMessageProps = {\n  /** Helper text shown while the field is valid. An error takes its place, then gives it back. */\n  description?: ReactNode;\n  /**\n   * The error to show while the field is invalid. Leave it out to show Base UI's message: your\n   * `validate()` result, a `<Form errors>` entry, or the browser's validation message.\n   */\n  error?: ReactNode;\n  className?: string;\n};\n\n/**\n * One line under the control that holds either the description or the error. They swap in\n * place with y + blur (the outgoing line lifts, the incoming rises), and the slot's height\n * springs when the line count changes or the slot opens from nothing. Must sit inside a\n * `FieldRoot`.\n */\nexport function FieldMessage(props: FieldMessageProps) {\n  return <BaseField.Validity>{(v) => <MessageSlot {...props} validity={v} />}</BaseField.Validity>;\n}\n\nfunction MessageSlot({\n  description,\n  error,\n  className = \"\",\n  validity,\n}: FieldMessageProps & { validity: BaseField.Validity.State }) {\n  const reduced = useSafeReducedMotion();\n  const invalid = validity.validity.valid === false;\n  // Key the error by its text, so a new message swaps instead of sitting still.\n  const errorKey = typeof error === \"string\" ? error : validity.errors.join(\"\\n\") || validity.error || \"invalid\";\n  // An error with no description to replace opens the slot: let the control's shake land first.\n  const delay = invalid && !description && !reduced ? 0.08 : 0;\n\n  return (\n    // The slot owns the 6px gap above it (pt-1.5 against -mt-1.5), so an empty slot adds no space.\n    <AutoHeight fade={false} transition={{ ...smooth, delay }} className={`-mt-1.5 ${className}`} aria-live=\"polite\">\n      <div className=\"relative\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {invalid ? (\n            <MessageLine key={`error:${errorKey}`} reduced={reduced} delay={delay} tone=\"error\">\n              <ErrorIcon />\n              <ErrorText error={error} />\n            </MessageLine>\n          ) : description ? (\n            <MessageLine key=\"description\" reduced={reduced} delay={0} tone=\"description\">\n              <BaseField.Description className=\"min-w-0 text-pretty\">{description}</BaseField.Description>\n            </MessageLine>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </AutoHeight>\n  );\n}\n\n// `ref` is forwarded: AnimatePresence's popLayout needs it to lift the outgoing line out of flow.\nfunction MessageLine({\n  children,\n  reduced,\n  delay,\n  tone,\n  ref,\n}: {\n  children: ReactNode;\n  reduced: boolean;\n  delay: number;\n  tone: \"error\" | \"description\";\n  ref?: React.Ref<HTMLDivElement>;\n}) {\n  const present = useIsPresent();\n  return (\n    <motion.div\n      ref={ref}\n      aria-hidden={present ? undefined : true}\n      initial={reduced ? { opacity: 0 } : swapIn}\n      animate={reduced ? { opacity: 1 } : swapShown}\n      exit={\n        reduced\n          ? { opacity: 0, transition: reducedFade }\n          : { ...swapOut, transition: { ...swap, opacity: exitFast, filter: exitFast } }\n      }\n      transition={reduced ? reducedFade : { ...swap, opacity: { ...enter, delay }, filter: { ...enter, delay }, delay }}\n      className={`flex w-full items-start gap-1.5 pt-1.5 text-ui-sm ${\n        tone === \"error\" ? \"text-danger-text\" : \"text-text-secondary\"\n      }`}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\n/**\n * Base UI's Field.Error, which reads the live message. A line that is leaving keeps the\n * message it arrived with, so the outgoing copy never flashes the incoming text.\n */\nfunction ErrorText({ error }: { error?: ReactNode }) {\n  const present = useIsPresent();\n  const frozen = useRef<ReactNode>(null);\n  return (\n    <BaseField.Error\n      match\n      className=\"min-w-0 text-pretty\"\n      // No `children` key unless we have one: an explicit undefined would blank Base UI's message.\n      {...(error !== undefined ? { children: error } : null)}\n      render={(props) => {\n        if (present) frozen.current = props.children;\n        return <div {...props}>{present ? props.children : frozen.current}</div>;\n      }}\n    />\n  );\n}\n\nfunction ErrorIcon() {\n  // 16px to match 13px text (rule 54), centred on the first line (rule 58).\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"mt-px size-4 shrink-0 translate-y-[0.5px]\">\n      <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"M8 4.75v3.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n      <circle cx=\"8\" cy=\"10.9\" r=\"0.85\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\n/* ---------- Input group: the box a text control lives in -------------------- */\n\nexport type InputGroupProps = ComponentPropsWithoutRef<\"div\"> & {\n  size?: ControlSize;\n  /**\n   * Draw the invalid state and shake once when it turns on. Inside a `FieldRoot` this follows\n   * the field's validity on its own; pass it to drive a standalone control.\n   */\n  invalid?: boolean;\n  /** Stack content vertically (a textarea and its footer) instead of in one row. */\n  multiline?: boolean;\n};\n\n/**\n * The bordered shell for a text control and its addons (icons, prefixes, a clear button, a\n * shortcut hint). Hover steps the border up once; focus steps it up and grows a soft 3px glow\n * (`--eased-ring-soft`), the only ring the field draws; an invalid field keeps its danger border\n * and glows in danger while focused; a field turning invalid shakes the shell once, 6px, then\n * the message opens. A press anywhere\n * on the shell that is not itself interactive focuses the control, so addons never steal it.\n */\nexport function InputGroup({\n  size = \"md\",\n  invalid: invalidProp,\n  multiline = false,\n  className = \"\",\n  onPointerDown,\n  children,\n  ...props\n}: InputGroupProps) {\n  const reduced = useSafeReducedMotion();\n  const ref = useRef<HTMLDivElement>(null);\n  const controlInvalid = useControlInvalid(ref);\n  const invalid = invalidProp ?? controlInvalid;\n  useShakeOnInvalid(ref, invalid, reduced);\n\n  const focusControl = (e: PointerEvent<HTMLDivElement>) => {\n    onPointerDown?.(e);\n    if (e.defaultPrevented || e.button !== 0) return;\n    const target = e.target as HTMLElement;\n    if (target.closest(\"input, textarea, select, button, a, [role=button], [contenteditable=true]\")) return;\n    const control = ref.current?.querySelector<HTMLElement>(\"input, textarea\");\n    if (!control || control.matches(\":disabled\")) return;\n    e.preventDefault();\n    control.focus();\n  };\n\n  return (\n    <div\n      ref={ref}\n      {...props}\n      data-invalid={invalid || undefined}\n      onPointerDown={focusControl}\n      className={`relative flex w-full min-w-0 cursor-text border border-line-strong bg-surface text-text transition-[border-color,background-color,box-shadow] duration-(--eased-duration-standard) ease-standard reduced:transition-none ${\n        multiline ? \"flex-col items-stretch\" : `items-center ${controlPadClass[size]}`\n      } ${controlSizeClass[size]} hover-fine:not-data-invalid:border-(--eased-neutral-9) focus-within:not-data-invalid:border-(--eased-neutral-9) focus-within:shadow-[0_0_0_3px_var(--eased-ring-soft)] data-invalid:border-danger focus-within:data-invalid:shadow-[0_0_0_3px_var(--eased-ring-danger)] has-[:is(input,textarea,[aria-haspopup]):disabled]:cursor-not-allowed has-[:is(input,textarea,[aria-haspopup]):disabled]:border-line has-[:is(input,textarea,[aria-haspopup]):disabled]:bg-surface-sunken has-[:is(input,textarea,[aria-haspopup]):disabled]:text-text-disabled ${className}`}\n    >\n      {children}\n    </div>\n  );\n}\n\n/**\n * Whether the control inside reads as invalid. Base UI marks a field's control with\n * `data-invalid` and `aria-invalid`, so this works inside a Field and for a standalone control\n * given `aria-invalid`, with no context required.\n */\nfunction useControlInvalid(ref: React.RefObject<HTMLElement | null>) {\n  const [invalid, setInvalid] = useState(false);\n  useLayoutEffect(() => {\n    const control = ref.current?.querySelector(\"input, textarea\");\n    if (!control) return;\n    const read = () =>\n      setInvalid(control.hasAttribute(\"data-invalid\") || control.getAttribute(\"aria-invalid\") === \"true\");\n    read();\n    const mo = new MutationObserver(read);\n    mo.observe(control, { attributes: true, attributeFilter: [\"data-invalid\", \"aria-invalid\"] });\n    return () => mo.disconnect();\n  }, [ref]);\n  return invalid;\n}\n\n/** One horizontal shake, 6px, when `invalid` turns on. Reduced motion: none (the colour carries it). */\nexport function useShakeOnInvalid(ref: React.RefObject<HTMLElement | null>, invalid: boolean, reduced: boolean) {\n  const was = useRef(invalid);\n  useEffect(() => {\n    const turnedOn = invalid && !was.current;\n    was.current = invalid;\n    if (!turnedOn || reduced || !ref.current) return;\n    const shake = animate(ref.current, { x: [0, -6, 5, -3, 1.5, 0] }, { duration: 0.36, ease: \"easeOut\" });\n    return () => shake.stop();\n  }, [invalid, reduced, ref]);\n}\n\nexport type InputGroupAddonProps = ComponentPropsWithoutRef<\"span\">;\n\n/** A leading or trailing slot: icons at 16px in the muted colour, text prefixes, kbd hints. */\nexport function InputGroupAddon({ className = \"\", ...props }: InputGroupAddonProps) {\n  return (\n    <span\n      {...props}\n      className={`inline-flex shrink-0 items-center gap-1.5 text-text-muted select-none [&_svg]:size-4 [&_svg]:shrink-0 ${className}`}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/field/field.tsx"
    }
  ],
  "type": "registry:ui"
}