{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-validation",
  "type": "registry:ui",
  "title": "Inline Validation",
  "description": "Async checks that spin, then draw a tick or strike a cross, without flicker.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/field.json"
  ],
  "files": [
    {
      "path": "components/ui/inline-validation.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Field, FieldControl, FieldDescription, FieldLabel, type FieldControlProps } from \"@/components/ui/field\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ValidationStatus = \"idle\" | \"checking\" | \"valid\" | \"invalid\" | \"error\";\n\n/** Return nothing when the value is fine, or the message to show when it isn't. Throw when the check itself failed. */\nexport type AsyncValidator = (value: string, ctx: { signal: AbortSignal }) => Promise<React.ReactNode | null | undefined> | React.ReactNode | null | undefined;\n\ntype Options = {\n  validate: AsyncValidator;\n  /** Milliseconds of quiet typing before a check starts. */\n  debounce?: number;\n  /** Once the spinner shows, it stays at least this long so a fast answer never flickers. */\n  minPending?: number;\n  onStatusChange?: (status: ValidationStatus) => void;\n};\n\n/**\n * The check on its own. Call `change` on every edit and `flush` on blur.\n * Stale answers are dropped (each check aborts the last), empty values are idle,\n * and the same value is never checked twice in a row: after a failed check, `retry` asks again.\n */\nexport function useInlineValidation({ validate, debounce = 450, minPending = 500, onStatusChange }: Options) {\n  const [status, setStatus] = useState<ValidationStatus>(\"idle\");\n  const [message, setMessage] = useState<React.ReactNode>(null);\n  // The value the current answer is about, which is not always the one on screen.\n  const [subject, setSubject] = useState(\"\");\n  // True while the value on screen differs from the one the current answer is about.\n  const [stale, setStale] = useState(false);\n  const timer = useRef<number>(undefined);\n  const hold = useRef<number>(undefined);\n  const abort = useRef<AbortController | null>(null);\n  const runId = useRef(0);\n  const checked = useRef<{ value: string } | null>(null);\n  const latest = useRef({ validate, onStatusChange });\n  useEffect(() => {\n    latest.current = { validate, onStatusChange };\n  });\n\n  const report = useCallback((next: ValidationStatus) => {\n    setStatus(next);\n    latest.current.onStatusChange?.(next);\n  }, []);\n\n  const cancel = useCallback(() => {\n    window.clearTimeout(timer.current);\n    window.clearTimeout(hold.current);\n    abort.current?.abort();\n    runId.current += 1;\n  }, []);\n\n  useEffect(() => cancel, [cancel]);\n\n  const flush = useCallback(\n    async (raw: string) => {\n      const value = raw.trim();\n      if (!value) {\n        cancel();\n        checked.current = null;\n        setStale(false);\n        setMessage(null);\n        report(\"idle\");\n        return;\n      }\n      if (checked.current?.value === value) {\n        // Nothing new to ask (a failed check waits for Try again); drop the debounce and un-dim the answer we have.\n        window.clearTimeout(timer.current);\n        setStale(false);\n        return;\n      }\n      cancel();\n      const id = runId.current;\n      const controller = new AbortController();\n      abort.current = controller;\n      checked.current = { value };\n      report(\"checking\");\n      const started = performance.now();\n\n      let result: React.ReactNode | null | undefined;\n      let failed = false;\n      try {\n        result = await latest.current.validate(value, { signal: controller.signal });\n      } catch {\n        failed = true;\n      }\n      if (id !== runId.current) return;\n      const rest = minPending - (performance.now() - started);\n      if (rest > 0) await new Promise((r) => (hold.current = window.setTimeout(r, rest)));\n      if (id !== runId.current) return;\n\n      setStale(false);\n      setSubject(value);\n      if (failed) {\n        setMessage(null);\n        report(\"error\");\n      } else if (result != null && result !== false && result !== \"\") {\n        setMessage(result);\n        report(\"invalid\");\n      } else {\n        setMessage(null);\n        report(\"valid\");\n      }\n    },\n    [cancel, minPending, report],\n  );\n\n  const change = useCallback(\n    (value: string, schedule = true) => {\n      cancel();\n      if (!value.trim()) {\n        flush(\"\");\n        return;\n      }\n      // The previous answer is about a different value now: dim it, and ask again once typing pauses.\n      setStale(true);\n      setStatus((s) => (s === \"checking\" ? \"idle\" : s));\n      if (schedule) timer.current = window.setTimeout(() => flush(value), debounce);\n    },\n    [cancel, debounce, flush],\n  );\n\n  const retry = useCallback((value: string) => {\n    checked.current = null;\n    flush(value);\n  }, [flush]);\n\n  return { status, message, subject, stale, change, flush, retry, cancel };\n}\n\nexport type InlineValidationProps = Omit<FieldControlProps, \"value\" | \"defaultValue\" | \"end\" | \"confirmFix\"> &\n  Options & {\n    label: React.ReactNode;\n    /** The resting hint. Validation messages take its place in the same slot. */\n    description?: React.ReactNode;\n    value?: string;\n    defaultValue?: string;\n    onValueChange?: (value: string) => void;\n    /** Check as the user types (debounced), or only when they leave the field. */\n    trigger?: \"change\" | \"blur\";\n    /** Shown under the field while a check runs. */\n    checkingMessage?: React.ReactNode;\n    /** Shown when the value passes. Receives the value, e.g. `(v) => \\`${v} is available\\``. */\n    validMessage?: React.ReactNode | ((value: string) => React.ReactNode);\n    /** Shown when the check itself failed (network, timeout). A Try again button follows it. */\n    errorMessage?: React.ReactNode;\n    required?: boolean;\n    optional?: boolean;\n    fieldClassName?: string;\n  };\n\nexport function InlineValidation({\n  label,\n  description,\n  validate,\n  debounce,\n  minPending,\n  onStatusChange,\n  trigger = \"change\",\n  checkingMessage = \"Checking…\",\n  validMessage,\n  errorMessage = \"Couldn’t check right now.\",\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  required,\n  optional,\n  name,\n  disabled,\n  size,\n  fieldClassName,\n  onBlur,\n  onKeyDown,\n  ...rest\n}: InlineValidationProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const v = useInlineValidation({ validate, debounce, minPending, onStatusChange });\n  const input = useRef<HTMLInputElement | null>(null);\n\n  const answer = v.status;\n  let slot: { key: string; tone: \"hint\" | \"muted\" | \"success\" | \"danger\" | \"warning\"; body: React.ReactNode } | null = null;\n  if (answer === \"checking\") slot = { key: \"checking\", tone: \"muted\", body: checkingMessage };\n  else if (answer === \"invalid\") slot = { key: \"invalid\", tone: \"danger\", body: v.message };\n  else if (answer === \"valid\" && validMessage) slot = { key: \"valid\", tone: \"success\", body: typeof validMessage === \"function\" ? validMessage(v.subject) : validMessage };\n  else if (answer === \"error\")\n    slot = {\n      key: \"error\",\n      tone: \"warning\",\n      body: (\n        <>\n          {errorMessage}{\" \"}\n          <button\n            type=\"button\"\n            onClick={() => {\n              v.retry(value);\n              input.current?.focus();\n            }}\n            className=\"relative font-medium text-fg underline decoration-fg-4 underline-offset-[3px] outline-none transition-[text-decoration-color] duration-150 hover:decoration-fg-2 focus-visible:rounded-[3px] focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid before:absolute before:-inset-x-1 before:-inset-y-2 before:content-['']\"\n          >\n            Try again\n          </button>\n        </>\n      ),\n    };\n  else if (description) slot = { key: \"hint\", tone: \"hint\", body: description };\n\n  return (\n    <Field\n      name={name}\n      required={required}\n      disabled={disabled}\n      size={size}\n      invalid={v.status === \"invalid\" && !v.stale}\n      data-status={v.status}\n      className={fieldClassName}\n    >\n      <FieldLabel optional={optional}>{label}</FieldLabel>\n      <FieldControl\n        {...rest}\n        ref={input}\n        value={value}\n        onValueChange={(next) => {\n          setValue(next);\n          // On blur mode an edit only marks the old answer as out of date.\n          v.change(next, trigger === \"change\");\n        }}\n        onBlur={(e) => {\n          onBlur?.(e);\n          v.flush(e.currentTarget.value);\n        }}\n        onKeyDown={(e) => {\n          onKeyDown?.(e);\n          // Enter checks now instead of waiting for the pause.\n          if (e.key === \"Enter\") v.flush(e.currentTarget.value);\n        }}\n        aria-busy={v.status === \"checking\" || undefined}\n        confirmFix={false}\n        end={<StatusGlyph status={v.status} stale={v.stale} />}\n      />\n      <MessageSlot slot={slot} stale={v.stale} />\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {v.stale || v.status === \"checking\" || v.status === \"idle\" ? \"\" : slot && slot.key !== \"hint\" ? slot.body : v.status === \"valid\" ? \"Looks good\" : \"\"}\n      </span>\n    </Field>\n  );\n}\n\nconst svg = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.5, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\n/** The end-slot status: a spinner, then a tick that draws or a cross that strikes. Always 16px, so the value never shifts. */\nexport function StatusGlyph({ status, stale = false, className }: { status: ValidationStatus; stale?: boolean; className?: string }) {\n  const reduce = useReducedMotion();\n  const draw = (delay = 0.05) =>\n    reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: 0.28, ease: ease.out, delay } };\n  const glyph =\n    status === \"checking\" ? (\n      <svg {...svg} strokeWidth={1.4} className=\"animate-spin text-fg-3 [animation-duration:0.75s]\">\n        <circle cx=\"8\" cy=\"8\" r=\"5.75\" opacity=\"0.22\" />\n        <path d=\"M8 2.25a5.75 5.75 0 0 1 5.75 5.75\" />\n      </svg>\n    ) : status === \"valid\" ? (\n      <svg {...svg} className=\"text-success\">\n        <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw()} />\n      </svg>\n    ) : status === \"invalid\" ? (\n      <svg {...svg} className=\"text-danger\">\n        <motion.path d=\"m4.75 4.75 6.5 6.5\" {...draw()} />\n        <motion.path d=\"m11.25 4.75-6.5 6.5\" {...draw(0.14)} />\n      </svg>\n    ) : status === \"error\" ? (\n      <svg {...svg} strokeWidth={1.4} className=\"text-warning\">\n        <path d=\"M7.1 2.9a1 1 0 0 1 1.8 0l5 9.1a1 1 0 0 1-.9 1.5H3a1 1 0 0 1-.9-1.5z\" />\n        <path d=\"M8 6.25v3\" />\n        <circle cx=\"8\" cy=\"11.1\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n      </svg>\n    ) : null;\n\n  return (\n    <span data-status={status} className={cn(\"relative grid size-4 place-items-center\", className)}>\n      <AnimatePresence initial={false}>\n        {glyph && (\n          <motion.span\n            key={status}\n            className=\"absolute inset-0 grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n            animate={{ opacity: stale ? 0.35 : 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            {glyph}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n// Marked important so the tone always beats the description's resting color.\nconst tones = {\n  hint: \"text-fg-3!\",\n  muted: \"text-fg-3!\",\n  success: \"text-success!\",\n  danger: \"text-danger!\",\n  warning: \"text-fg-2!\",\n};\n\n// One slot under the field. The hint, \"Checking…\", the verdict and the failure all\n// take turns in the same grid cell, so a message change never moves the form below.\n// With nothing to say, the slot closes, and takes the field's 6px gap with it.\nfunction MessageSlot({ slot, stale }: { slot: { key: string; tone: keyof typeof tones; body: React.ReactNode } | null; stale: boolean }) {\n  const reduce = useReducedMotion();\n  // Clip only while the height moves, so a focus ring on Try again is never cut off at rest.\n  const [moving, setMoving] = useState(false);\n  return (\n    <motion.div\n      initial={false}\n      animate={slot ? { height: \"auto\", marginTop: 0, opacity: 1 } : { height: 0, marginTop: -6, opacity: 0 }}\n      transition={{ duration: reduce ? 0 : 0.24, ease: ease.out }}\n      onAnimationStart={() => setMoving(true)}\n      onAnimationComplete={() => setMoving(false)}\n      className={cn((moving || !slot) && \"overflow-hidden\")}\n    >\n      <div className=\"grid\">\n        <AnimatePresence initial={false}>\n          {slot && (\n            <motion.div\n              key={slot.key}\n              className=\"col-start-1 row-start-1\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n              animate={{ opacity: stale && slot.key !== \"hint\" ? 0.5 : 1, y: 0 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, transition: { duration: 0.12, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n            >\n              <FieldDescription className={cn(\"text-[12px] transition-colors duration-150\", tones[slot.tone])}>{slot.body}</FieldDescription>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/inline-validation"
}
