{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "autosave-status",
  "type": "registry:ui",
  "title": "Autosave Status",
  "description": "Saving, saved, offline and failed states in one pill that springs to fit.",
  "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"
  ],
  "files": [
    {
      "path": "components/ui/autosave-status.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type AutosaveState = \"idle\" | \"saving\" | \"saved\" | \"offline\" | \"error\";\n\n/** Whether the browser thinks it is online, kept current. Renders as online on the server. */\nexport function useOnline() {\n  return useSyncExternalStore(\n    (cb) => {\n      window.addEventListener(\"online\", cb);\n      window.addEventListener(\"offline\", cb);\n      return () => {\n        window.removeEventListener(\"online\", cb);\n        window.removeEventListener(\"offline\", cb);\n      };\n    },\n    () => navigator.onLine,\n    () => true,\n  );\n}\n\nexport type AutosaveStatusProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  status: AutosaveState;\n  /** When the last save landed. Drives “Saved 2m ago”, refreshed every 30 seconds. */\n  savedAt?: Date | number | null;\n  /** Changes waiting while offline, e.g. “3 changes waiting”. */\n  pending?: number;\n  /** Shown after “Couldn’t save”. Omit to hide the button. */\n  onRetry?: () => void;\n  /** The spinner holds at least this long, so a quick save never flickers. */\n  minSaving?: number;\n  locale?: string;\n};\n\n// A save that finishes in 80ms still shows \"Saving…\" for a readable moment.\nfunction useHeldStatus(status: AutosaveState, minSaving: number) {\n  const [shown, setShown] = useState(status);\n  const since = useRef(0);\n  useEffect(() => {\n    if (status === shown) return;\n    if (status === \"saving\") since.current = performance.now();\n    const wait = shown === \"saving\" ? Math.max(0, minSaving - (performance.now() - since.current)) : 0;\n    const t = window.setTimeout(() => setShown(status), wait);\n    return () => window.clearTimeout(t);\n  }, [status, shown, minSaving]);\n  return shown;\n}\n\nfunction useNow(active: boolean) {\n  const [now, setNow] = useState<number | null>(null);\n  useEffect(() => {\n    if (!active) return;\n    const tick = () => setNow(Date.now());\n    const first = window.setTimeout(tick, 0);\n    const id = window.setInterval(tick, 30_000);\n    return () => {\n      window.clearTimeout(first);\n      window.clearInterval(id);\n    };\n  }, [active]);\n  return now;\n}\n\nfunction relative(then: number, now: number, locale?: string) {\n  const s = Math.round((then - now) / 1000);\n  const f = new Intl.RelativeTimeFormat(locale, { style: \"narrow\", numeric: \"auto\" });\n  if (s > -45) return \"just now\";\n  if (s > -3600) return f.format(Math.round(s / 60), \"minute\");\n  if (s > -86400) return f.format(Math.round(s / 3600), \"hour\");\n  return new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\" }).format(then);\n}\n\nexport function AutosaveStatus({ status, savedAt, pending, onRetry, minSaving = 600, locale, className, ...rest }: AutosaveStatusProps) {\n  const reduce = useReducedMotion();\n  const shown = useHeldStatus(status, minSaving);\n  const at = savedAt == null ? null : typeof savedAt === \"number\" ? savedAt : savedAt.getTime();\n  const now = useNow(shown === \"saved\" && at != null);\n  const when = at != null && now != null ? relative(at, now, locale) : null;\n\n  const text =\n    shown === \"saving\"\n      ? \"Saving…\"\n      : shown === \"saved\"\n        ? \"Saved\"\n        : shown === \"offline\"\n          ? pending\n            ? `Offline · ${pending} ${pending === 1 ? \"change\" : \"changes\"} waiting`\n            : \"Offline, will retry\"\n          : shown === \"error\"\n            ? \"Couldn’t save\"\n            : \"\";\n  const announce = shown === \"saving\" ? \"\" : shown === \"saved\" ? \"Saved\" : text;\n\n  // The pill's width springs to fit each state, measured from an invisible copy of the content.\n  const sizer = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue(-1);\n  const cssWidth = useTransform(width, (w) => (w < 0 ? \"auto\" : w));\n  useLayoutEffect(() => {\n    const w = sizer.current?.offsetWidth ?? 0;\n    if (width.get() < 0 || reduce) width.set(w);\n    else animate(width, w, spring.snappy);\n  }, [shown, when, pending, width, reduce]);\n\n  const content = (s: AutosaveState, live: boolean) => (\n    <>\n      {s !== \"idle\" && <Glyph state={s} reduce={!!reduce} live={live} />}\n      <span className={cn(\"whitespace-nowrap\", s === \"error\" ? \"text-danger\" : s === \"saved\" ? \"text-fg-2\" : \"text-fg-3\")}>{text}</span>\n      {s === \"saved\" && when && (\n        <time\n          suppressHydrationWarning\n          dateTime={at ? new Date(at).toISOString() : undefined}\n          title={at ? new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeStyle: \"short\" }).format(at) : undefined}\n          className=\"whitespace-nowrap text-fg-3 tabular\"\n        >\n          {when}\n        </time>\n      )}\n      {s === \"error\" && onRetry && <span className=\"w-[42px]\" aria-hidden />}\n    </>\n  );\n\n  return (\n    <div data-state={shown} className={cn(\"relative inline-flex h-7 items-center\", className)} {...rest}>\n      <motion.span\n        className=\"relative block h-7 overflow-hidden\"\n        style={{ width: cssWidth }}\n        animate={{ opacity: shown === \"idle\" ? 0 : 1 }}\n        transition={{ duration: 0.2 }}\n      >\n        <span ref={sizer} aria-hidden className=\"invisible absolute left-0 top-0 inline-flex h-7 items-center gap-1.5 px-0.5 text-[12px]\">\n          {content(shown, false)}\n        </span>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={shown}\n            className=\"absolute left-0 top-0 inline-flex h-7 items-center gap-1.5 px-0.5 text-[12px]\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n            transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n          >\n            {content(shown, true)}\n          </motion.span>\n        </AnimatePresence>\n      </motion.span>\n      {/* The retry sits outside the clipped pill so its focus ring and hit area are never cut. */}\n      {shown === \"error\" && onRetry && (\n        <motion.button\n          type=\"button\"\n          onClick={onRetry}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, x: -4 }}\n          animate={{ opacity: 1, x: 0 }}\n          transition={{ duration: 0.2, ease: ease.out, delay: reduce ? 0 : 0.08 }}\n          className={cn(\n            \"absolute right-0.5 rounded-[4px] text-[12px] font-medium text-fg underline decoration-fg-4 underline-offset-[3px] outline-none\",\n            \"transition-[text-decoration-color] duration-150 hover:decoration-fg-2\",\n            \"focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n            \"before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']\",\n          )}\n        >\n          Retry\n        </motion.button>\n      )}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\nconst svg = { width: 14, height: 14, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction Glyph({ state, reduce, live }: { state: AutosaveState; reduce: boolean; live: boolean }) {\n  const draw = live && !reduce;\n  if (state === \"saving\")\n    return (\n      <svg {...svg} strokeWidth={1.5} className=\"shrink-0 animate-spin text-fg-3 [animation-duration:0.8s]\">\n        <circle cx=\"8\" cy=\"8\" r=\"5.75\" opacity=\"0.25\" />\n        <path d=\"M8 2.25a5.75 5.75 0 0 1 5.75 5.75\" />\n      </svg>\n    );\n  if (state === \"saved\")\n    return (\n      <svg {...svg} strokeWidth={1.7} className=\"shrink-0 text-success\">\n        <motion.path\n          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n          initial={draw ? { pathLength: 0 } : false}\n          animate={{ pathLength: 1 }}\n          transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }}\n        />\n      </svg>\n    );\n  if (state === \"offline\")\n    return (\n      <span className=\"relative grid size-3.5 shrink-0 place-items-center\" aria-hidden>\n        <span className=\"size-1.5 rounded-full bg-warning\" />\n        {live && <span className=\"absolute size-1.5 animate-ping-soft rounded-full bg-warning [animation-duration:2.4s]\" />}\n      </span>\n    );\n  if (state === \"error\")\n    return (\n      <svg {...svg} strokeWidth={1.5} className=\"shrink-0 text-danger\">\n        <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n        <motion.path d=\"M8 5v3.5\" initial={draw ? { pathLength: 0 } : false} animate={{ pathLength: 1 }} transition={{ duration: 0.2, ease: ease.out }} />\n        <circle cx=\"8\" cy=\"10.9\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n      </svg>\n    );\n  return null;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/autosave-status"
}
