{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "field",
  "type": "registry:ui",
  "title": "Field",
  "description": "Label, control, hint and error that speak up on blur, not on every keystroke.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/field.tsx",
      "content": "\"use client\";\nimport { Field as BaseField } from \"@base-ui/react/field\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype Size = \"sm\" | \"md\" | \"lg\";\nconst FieldContext = createContext<{ required: boolean; size: Size }>({ required: false, size: \"md\" });\n\n/** The field's size and required flag, for controls composed inside it. */\nexport const useFieldContext = () => useContext(FieldContext);\n\nexport type FieldProps = Omit<BaseField.Root.Props, \"className\"> & {\n  className?: string;\n  /** Marks the label and makes the control required, so the rule is written once. */\n  required?: boolean;\n  /** Height of the control inside: 28, 32 or 36px. */\n  size?: Size;\n};\n\n/**\n * Groups a label, a control, a description and an error. Validates when focus\n * leaves the control (not on every keystroke) and on submit inside a Base UI Form;\n * once an error shows, fixing the value clears it as you type.\n */\nexport function Field({ required = false, size = \"md\", validationMode = \"onBlur\", className, children, ...rest }: FieldProps) {\n  return (\n    <FieldContext value={{ required, size }}>\n      <BaseField.Root\n        data-size={size}\n        data-required={required || undefined}\n        validationMode={validationMode}\n        className={cn(\"group/field flex w-full min-w-0 flex-col gap-1.5\", className)}\n        {...rest}\n      >\n        {children}\n      </BaseField.Root>\n    </FieldContext>\n  );\n}\n\nexport type FieldLabelProps = Omit<BaseField.Label.Props, \"className\"> & {\n  className?: string;\n  /** Says \"Optional\" beside the label. Use it instead of marking every other field required. */\n  optional?: boolean;\n};\n\nexport function FieldLabel({ optional = false, className, children, ...rest }: FieldLabelProps) {\n  const { required } = useFieldContext();\n  return (\n    <BaseField.Label\n      className={cn(\n        \"flex w-fit select-none items-baseline gap-1 text-[12.5px] font-medium tracking-[-0.005em] text-fg-2\",\n        \"transition-colors duration-150 group-data-focused/field:text-fg group-data-disabled/field:text-fg-4\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n      {/* The input's own required state is what a screen reader hears; the mark is for the eye. */}\n      {required && (\n        <span aria-hidden className=\"text-fg-3 transition-colors duration-150 group-data-invalid/field:text-danger\">\n          *\n        </span>\n      )}\n      {optional && !required && <span className=\"text-[12px] font-normal text-fg-3\">Optional</span>}\n    </BaseField.Label>\n  );\n}\n\nexport type FieldControlProps = Omit<BaseField.Control.Props, \"className\" | \"size\"> & {\n  className?: string;\n  /** Classes for the inner input; `className` styles the box around it. */\n  inputClassName?: string;\n  /** Leading content: an icon or a fixed part like \"https://\". */\n  start?: React.ReactNode;\n  /** Trailing content: a unit, a status or a button. */\n  end?: React.ReactNode;\n  /** Draws a brief tick when a value that was invalid becomes valid. */\n  confirmFix?: boolean;\n  size?: Size;\n};\n\n// The box carries the border and the halo, so start, value and end read as one control.\n// Every state is read from the input's own attributes, so the box and Base UI never disagree.\nconst box = cn(\n  \"relative flex w-full min-w-0 items-center border border-line-2 bg-raised text-fg shadow-[var(--shadow)]\",\n  \"transition-[border-color,box-shadow,background-color] duration-150 ease-out\",\n  \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3\",\n  \"has-[input[data-invalid]]:border-danger/70 has-[input[data-invalid]]:hover:border-danger\",\n  \"has-[input[data-invalid]]:focus-within:border-danger has-[input[data-invalid]]:focus-within:ring-danger/15\",\n  \"has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 has-[input:disabled]:shadow-none has-[input:disabled]:hover:border-line-2\",\n  \"has-[input:read-only]:bg-frame has-[input:read-only]:shadow-none has-[input:read-only]:hover:border-line-2 has-[input:read-only]:focus-within:border-line-2 has-[input:read-only]:focus-within:ring-0\",\n);\n\nconst sizes: Record<Size, string> = {\n  sm: \"h-7 gap-1.5 rounded-md px-2 text-base sm:text-[12.5px] [&_[data-slot=affix]_svg]:size-3.5\",\n  md: \"h-8 gap-2 rounded-lg px-2.5 text-base sm:text-[13px]\",\n  lg: \"h-9 gap-2 rounded-lg px-3 text-base sm:text-[13px]\",\n};\n\nexport function FieldControl({\n  start,\n  end,\n  confirmFix = true,\n  size: sizeProp,\n  required: requiredProp,\n  className,\n  inputClassName,\n  ref,\n  ...rest\n}: FieldControlProps) {\n  const ctx = useFieldContext();\n  const size = sizeProp ?? ctx.size;\n  const inner = useRef<HTMLInputElement | null>(null);\n  const setRef = useCallback(\n    (node: HTMLInputElement | null) => {\n      inner.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  return (\n    <div\n      data-slot=\"field-control\"\n      data-size={size}\n      className={cn(box, sizes[size], className)}\n      // Pressing the padding or an affix puts the caret in the field, as a single native control would.\n      onMouseDown={(e) => {\n        if ((e.target as HTMLElement).closest(\"input, button, a, [role=button]\")) return;\n        e.preventDefault();\n        inner.current?.focus();\n      }}\n    >\n      {start != null && <Affix side=\"start\">{start}</Affix>}\n      <BaseField.Control\n        ref={setRef}\n        required={requiredProp ?? ctx.required}\n        className={cn(\n          \"h-full min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-fg-4\",\n          \"disabled:cursor-not-allowed read-only:cursor-default\",\n          \"autofill:shadow-[inset_0_0_0_1000px_var(--raised)] autofill:[-webkit-text-fill-color:var(--fg)]\",\n          inputClassName,\n        )}\n        {...rest}\n      />\n      {end != null && <Affix side=\"end\">{end}</Affix>}\n      {confirmFix && <BaseField.Validity>{(s) => <FixedTick valid={s.validity.valid} />}</BaseField.Validity>}\n    </div>\n  );\n}\n\nfunction Affix({ side, children }: { side: \"start\" | \"end\"; children: React.ReactNode }) {\n  // Text before the value sits tight against it, so \"stealth.pm/\" + \"northwind\" reads as one address.\n  const tight = side === \"start\" && (typeof children === \"string\" || typeof children === \"number\");\n  return (\n    <span\n      data-slot=\"affix\"\n      className={cn(\n        \"flex shrink-0 select-none items-center whitespace-nowrap text-fg-3 tabular transition-colors duration-150 group-data-focused/field:text-fg-2\",\n        tight && \"-mr-1.5 group-data-[size=sm]/field:-mr-1\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\n// Answers the moment an error is fixed: the tick draws, holds, and leaves on its own.\n// It only ever follows an error, so it never decorates a field that was simply filled in.\nfunction FixedTick({ valid }: { valid: boolean | null }) {\n  const reduce = useReducedMotion();\n  const [prev, setPrev] = useState(valid);\n  const [shown, setShown] = useState(false);\n  if (prev !== valid) {\n    setPrev(valid);\n    setShown(prev === false && valid === true);\n  }\n  useEffect(() => {\n    if (!shown) return;\n    const t = window.setTimeout(() => setShown(false), 1600);\n    return () => window.clearTimeout(t);\n  }, [shown]);\n\n  return (\n    <AnimatePresence initial={false}>\n      {shown && (\n        <motion.span\n          aria-hidden\n          className=\"grid shrink-0 place-items-center overflow-hidden text-success\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, width: 0, scale: 0.6 }}\n          animate={{ opacity: 1, width: 16, scale: 1 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, width: 0, scale: 0.8, transition: { duration: 0.16, ease: ease.in } }}\n          transition={reduce ? { duration: 0.15 } : spring.pop}\n        >\n          <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <motion.path\n              d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n              initial={reduce ? false : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.3, ease: ease.out, delay: 0.05 }}\n            />\n          </svg>\n        </motion.span>\n      )}\n    </AnimatePresence>\n  );\n}\n\nexport type FieldDescriptionProps = Omit<BaseField.Description.Props, \"className\"> & { className?: string };\n\nexport function FieldDescription({ className, ...rest }: FieldDescriptionProps) {\n  return (\n    <BaseField.Description\n      className={cn(\"text-[12px] leading-[1.45] text-fg-3 [text-wrap:pretty] group-data-disabled/field:text-fg-4\", className)}\n      {...rest}\n    />\n  );\n}\n\ntype ValidityKey = Exclude<keyof BaseField.Validity.State[\"validity\"], \"valid\">;\n\nexport type FieldErrorProps = Omit<BaseField.Error.Props, \"className\" | \"render\"> & {\n  className?: string;\n  /**\n   * Your wording for native failures, e.g. `{ valueMissing: \"Enter an email\", typeMismatch: \"Include an @\" }`.\n   * Anything not listed falls back to the validation message, and custom `validate` errors always show as returned.\n   */\n  messages?: Partial<Record<ValidityKey, React.ReactNode>>;\n};\n\n/**\n * The message under the control. Grows open from zero height (canceling the\n * field's gap as it goes, so nothing below jumps), fades and settles 4px down,\n * and collapses faster than it arrived, still showing the last message while it leaves.\n */\nexport function FieldError({ className, messages, ...rest }: FieldErrorProps) {\n  return (\n    <BaseField.Error\n      className={cn(\n        \"group/error grid grid-rows-[1fr] text-[12px] leading-[1.45] text-danger\",\n        \"transition-[grid-template-rows,opacity,margin-top] duration-[240ms] ease-out-expo\",\n        \"data-starting-style:-mt-1.5 data-starting-style:grid-rows-[0fr] data-starting-style:opacity-0\",\n        \"data-ending-style:-mt-1.5 data-ending-style:grid-rows-[0fr] data-ending-style:opacity-0 data-ending-style:duration-[160ms] data-ending-style:ease-in-out-quart\",\n        className,\n      )}\n      render={(props) => (\n        <div {...props}>\n          <div className=\"min-h-0 overflow-hidden\">\n            <div\n              className={cn(\n                \"flex items-start gap-1.5 transition-transform duration-[240ms] ease-out-expo\",\n                \"group-data-starting-style/error:-translate-y-1\",\n                \"[&_ul]:flex [&_ul]:flex-col [&_ul]:gap-0.5\",\n              )}\n            >\n              <Alert size={14} className=\"mt-[1.5px] shrink-0\" />\n              <div className=\"min-w-0 [text-wrap:pretty]\">\n                {messages ? (\n                  <BaseField.Validity>{(s) => <Worded messages={messages} validity={s.validity} fallback={props.children} />}</BaseField.Validity>\n                ) : (\n                  <Held>{props.children}</Held>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n      // Children are left out unless given, so Base UI can fill in the validation message.\n      {...rest}\n    />\n  );\n}\n\n// Keeps the last text message on screen while the error collapses, for callers who\n// pass the message as children and clear it the moment the field is valid.\nfunction Held({ children }: { children: React.ReactNode }) {\n  const text = typeof children === \"string\" && children ? children : null;\n  const [last, setLast] = useState<string | null>(text);\n  if (text && text !== last) setLast(text);\n  return <>{children || last}</>;\n}\n\n// Picks the wording for the failing constraint, and keeps the last one while the error collapses.\nfunction Worded({\n  messages,\n  validity,\n  fallback,\n}: {\n  messages: Partial<Record<ValidityKey, React.ReactNode>>;\n  validity: BaseField.Validity.State[\"validity\"];\n  fallback: React.ReactNode;\n}) {\n  // Remember which wording was showing (a key, not the node) so the collapse keeps it.\n  const failing = validity.valid === false;\n  const key = failing && !validity.customError ? (Object.keys(messages) as ValidityKey[]).find((k) => validity[k] && messages[k]) : undefined;\n  const [last, setLast] = useState<ValidityKey | undefined>(key);\n  if (failing && key !== last) setLast(key);\n  const shown = failing ? key : last;\n  return <>{shown ? messages[shown] : fallback}</>;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/field"
}
