{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "save-bar",
  "type": "registry:ui",
  "title": "Save Bar",
  "description": "Rises when a form is dirty, nudges before you leave, ticks once saved.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "@number-flow/react",
    "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/save-bar.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype Status = \"idle\" | \"saving\" | \"saved\" | \"error\";\n\n/** Warns before the tab closes or reloads while there are unsaved changes. */\nexport function useUnsavedChangesWarning(dirty: boolean) {\n  useEffect(() => {\n    if (!dirty) return;\n    const onBeforeUnload = (e: BeforeUnloadEvent) => {\n      e.preventDefault();\n      e.returnValue = \"\";\n    };\n    window.addEventListener(\"beforeunload\", onBeforeUnload);\n    return () => window.removeEventListener(\"beforeunload\", onBeforeUnload);\n  }, [dirty]);\n}\n\nexport type SaveBarProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Whether the form differs from what's saved. The bar shows while true, and through the saved moment after. */\n  dirty: boolean;\n  /** Save. Return a promise and the bar shows Saving…, then a tick, then leaves. Reject to keep it with Try again. */\n  onSave: () => unknown;\n  /** Throw the changes away. The bar leaves once dirty turns false. */\n  onDiscard: () => void;\n  /** How many fields changed, when you know it. The count rolls as it changes. */\n  changes?: number;\n  /** Increment when someone tries to leave with unsaved changes; the bar pulses and says so. */\n  nudgeKey?: number;\n  /** fixed to the viewport (default) or absolute inside a positioned container. */\n  position?: \"fixed\" | \"absolute\";\n  /** Save on ⌘S / Ctrl+S while the bar is showing. */\n  shortcut?: boolean;\n  labels?: { unsaved?: string; save?: string; discard?: string; saving?: string; saved?: string; error?: string; nudge?: string };\n};\n\nconst defaults = {\n  unsaved: \"Unsaved changes\",\n  save: \"Save changes\",\n  discard: \"Discard\",\n  saving: \"Saving…\",\n  saved: \"Saved\",\n  error: \"Couldn’t save\",\n  nudge: \"Save or discard first\",\n};\n\nexport function SaveBar({\n  dirty,\n  onSave,\n  onDiscard,\n  changes,\n  nudgeKey = 0,\n  position = \"fixed\",\n  shortcut = true,\n  labels: labelsProp,\n  className,\n  ...rest\n}: SaveBarProps) {\n  const labels = { ...defaults, ...labelsProp };\n  const reduce = useReducedMotion();\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [nudging, setNudging] = useState(false);\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const alive = useRef(true);\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  // Editing again after a save (or a failure) is a fresh set of changes.\n  const [prevDirty, setPrevDirty] = useState(dirty);\n  if (prevDirty !== dirty) {\n    setPrevDirty(dirty);\n    if (dirty && status !== \"saving\") setStatus(\"idle\");\n  }\n\n  const visible = dirty || status === \"saving\" || status === \"saved\";\n\n  const save = useCallback(async () => {\n    if (status === \"saving\") return;\n    let result: unknown;\n    try {\n      result = onSave();\n    } catch {\n      setStatus(\"error\");\n      return;\n    }\n    if (!result || typeof (result as Promise<unknown>).then !== \"function\") {\n      setStatus(\"saved\");\n      return;\n    }\n    setStatus(\"saving\");\n    const started = performance.now();\n    try {\n      await result;\n      // Hold the spinner long enough to read, so a fast save doesn't flash.\n      const rest = 450 - (performance.now() - started);\n      if (rest > 0) await new Promise((r) => setTimeout(r, rest));\n      if (alive.current) setStatus(\"saved\");\n    } catch {\n      if (alive.current) setStatus(\"error\");\n    }\n  }, [onSave, status]);\n\n  // The saved moment holds, then the bar leaves on its own.\n  useEffect(() => {\n    if (status !== \"saved\") return;\n    const t = window.setTimeout(() => setStatus(\"idle\"), 1400);\n    return () => window.clearTimeout(t);\n  }, [status]);\n\n  useEffect(() => {\n    if (!shortcut || !visible || status === \"saved\") return;\n    const onKey = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === \"s\") {\n        e.preventDefault();\n        void save();\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [shortcut, visible, status, save]);\n\n  // A gentle pulse when someone tries to leave: the bar lifts and settles, and says why.\n  const [prevNudge, setPrevNudge] = useState(nudgeKey);\n  if (prevNudge !== nudgeKey) {\n    setPrevNudge(nudgeKey);\n    if (dirty) setNudging(true);\n  }\n  useEffect(() => {\n    if (!nudging) return;\n    if (scope.current && !reduce) {\n      animate(scope.current, { y: [0, -6, 0, -2, 0], scale: [1, 1.025, 0.995, 1.005, 1] }, { duration: 0.6, ease: ease.out });\n    }\n    const t = window.setTimeout(() => setNudging(false), 2200);\n    return () => window.clearTimeout(t);\n  }, [nudging, nudgeKey, animate, scope, reduce]);\n\n  const message =\n    status === \"saving\" ? labels.saving : status === \"saved\" ? labels.saved : status === \"error\" ? labels.error : nudging ? labels.nudge : labels.unsaved;\n  // Saving answers the nudge; the warning steps aside for the progress.\n  const tone = status === \"saved\" ? \"success\" : status === \"error\" ? \"danger\" : nudging && status === \"idle\" ? \"warning\" : \"neutral\";\n  const locked = status === \"saving\" || status === \"saved\";\n  const mac = useIsMac();\n\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none inset-x-0 bottom-0 z-(--z-sticky) flex justify-center px-4 pb-[max(16px,env(safe-area-inset-bottom))]\",\n        position === \"fixed\" ? \"fixed\" : \"absolute pb-4\",\n        className,\n      )}\n      {...rest}\n    >\n      <AnimatePresence>\n        {visible && (\n          <motion.div\n            key=\"bar\"\n            role=\"region\"\n            aria-label=\"Unsaved changes\"\n            data-status={status}\n            data-tone={tone}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.97, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, y: 16, scale: 0.98, filter: \"blur(2px)\", transition: { duration: 0.2, ease: ease.in } }}\n            transition={reduce ? { duration: 0.15 } : { type: \"spring\", stiffness: 420, damping: 34, mass: 0.8 }}\n            className=\"@container/savebar pointer-events-auto w-full max-w-[440px]\"\n          >\n            <div\n              ref={scope}\n              className={cn(\n                \"flex min-h-12 items-center gap-3 rounded-xl border bg-raised py-2 pr-2 pl-3.5 shadow-pop\",\n                // Too narrow for one row (a phone): the message keeps its line and the buttons share the next.\n                \"@max-[27rem]/savebar:flex-wrap @max-[27rem]/savebar:gap-y-2.5 @max-[27rem]/savebar:pt-3 @max-[27rem]/savebar:pl-3\",\n                \"transition-[border-color,box-shadow] duration-300 ease-out\",\n                tone === \"warning\" ? \"border-warning/50 ring-3 ring-warning/15\" : \"border-line-2 ring-0 ring-transparent\",\n              )}\n            >\n              <span className=\"relative grid size-4 shrink-0 place-items-center\" aria-hidden>\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    key={tone === \"success\" ? \"tick\" : tone === \"danger\" ? \"x\" : \"dot\"}\n                    className=\"absolute inset-0 grid place-items-center\"\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, transition: { duration: 0.1 } }}\n                    transition={reduce ? { duration: 0.15 } : spring.pop}\n                  >\n                    {tone === \"success\" ? (\n                      <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"text-success\">\n                        <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3, ease: ease.out, delay: 0.05 }} />\n                      </svg>\n                    ) : tone === \"danger\" ? (\n                      <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" className=\"text-danger\">\n                        <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n                        <path d=\"M8 5v3.5\" />\n                        <circle cx=\"8\" cy=\"10.9\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n                      </svg>\n                    ) : (\n                      <span className={cn(\"relative size-2 rounded-full\", tone === \"warning\" ? \"bg-warning\" : \"bg-fg-2\")}>\n                        {tone === \"warning\" && <span className=\"absolute inset-0 animate-ping-soft rounded-full bg-warning\" />}\n                      </span>\n                    )}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n\n              {/* Every message sits in one grid cell, so the bar never changes width as it talks. */}\n              <p className=\"grid min-w-0 flex-1 text-[13px] font-medium tracking-[-0.005em] text-fg\">\n                {[labels.unsaved, labels.saving, labels.saved, labels.error, labels.nudge].map((m) => (\n                  <span key={m} aria-hidden className=\"invisible col-start-1 row-start-1 truncate\">\n                    {m}\n                    {changes != null && m === labels.unsaved ? \" · 00\" : \"\"}\n                  </span>\n                ))}\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    key={message}\n                    className=\"col-start-1 row-start-1 flex min-w-0 items-baseline gap-1.5\"\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.12, ease: ease.in } }}\n                    transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n                  >\n                    <span className=\"truncate\">{message}</span>\n                    {message === labels.unsaved && changes != null && changes > 0 && (\n                      <span className=\"shrink-0 font-normal text-fg-3\">\n                        · <NumberFlow value={changes} className=\"tabular\" />\n                      </span>\n                    )}\n                  </motion.span>\n                </AnimatePresence>\n              </p>\n\n              <div\n                className={cn(\n                  \"flex shrink-0 items-center gap-1.5 transition-[opacity,translate] duration-200 ease-out\",\n                  \"@max-[27rem]/savebar:w-full @max-[27rem]/savebar:*:flex-1 @max-[27rem]/savebar:*:justify-center\",\n                  status === \"saved\" && \"pointer-events-none translate-x-1 opacity-0 @max-[27rem]/savebar:hidden\",\n                )}\n                inert={status === \"saved\"}\n              >\n                <button\n                  type=\"button\"\n                  onClick={onDiscard}\n                  disabled={locked}\n                  className={cn(\n                    \"inline-flex h-8 items-center whitespace-nowrap rounded-lg px-2.5 text-[12.5px] font-medium text-fg-2 outline-none\",\n                    \"transition-[background-color,color,scale,opacity] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n                    \"focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid disabled:pointer-events-none disabled:opacity-50\",\n                  )}\n                >\n                  {labels.discard}\n                </button>\n                <button\n                  type=\"button\"\n                  onClick={() => void save()}\n                  aria-busy={status === \"saving\" || undefined}\n                  aria-keyshortcuts={shortcut ? \"Meta+S Control+S\" : undefined}\n                  className={cn(\n                    \"group/save relative inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-lg bg-fg px-3 text-[12.5px] font-medium text-frame shadow-[var(--shadow)] outline-none\",\n                    \"transition-[background-color,scale] duration-150 ease-out focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n                    status === \"saving\" ? \"bg-fg/85\" : \"hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n                  )}\n                >\n                  {/* Label and spinner share a cell: the button keeps its width while it works. */}\n                  <span className=\"grid place-items-center\">\n                    <span className={cn(\"col-start-1 row-start-1 transition-[opacity,scale] duration-150\", status === \"saving\" && \"scale-95 opacity-0\")}>\n                      {status === \"error\" ? \"Try again\" : labels.save}\n                    </span>\n                    <span aria-hidden className=\"invisible col-start-1 row-start-1\">{labels.save}</span>\n                    <span aria-hidden className=\"invisible col-start-1 row-start-1\">Try again</span>\n                    {status === \"saving\" && (\n                      <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" aria-hidden className=\"col-start-1 row-start-1 animate-spin [animation-duration:0.75s]\">\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                  </span>\n                  {shortcut && (\n                    <kbd\n                      suppressHydrationWarning\n                      className=\"hidden h-[18px] items-center rounded-[4px] bg-frame/15 px-1 font-mono text-[10.5px] font-normal text-frame/70 @min-[27rem]/savebar:pointer-fine:inline-flex\"\n                    >\n                      {mac ? \"⌘S\" : \"Ctrl S\"}\n                    </kbd>\n                  )}\n                </button>\n              </div>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {visible ? message : \"\"}\n      </span>\n    </div>\n  );\n}\n\n// The platform only matters for the shortcut hint; the server renders ⌘ and the client corrects it.\nfunction useIsMac() {\n  const [mac, setMac] = useState(true);\n  useEffect(() => {\n    const id = window.requestAnimationFrame(() => setMac(/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)));\n    return () => window.cancelAnimationFrame(id);\n  }, []);\n  return mac;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/save-bar"
}
