{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "save-bar",
  "title": "Save Bar",
  "description": "A bar that exists only while a form has unsaved changes. It says so, saves, confirms, and leaves.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/save-bar/save-bar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type SaveBarState = \"clean\" | \"dirty\" | \"saving\" | \"saved\" | \"error\"\n\nexport interface SaveBarProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> {\n  /** Whether the form is holding changes nobody has saved yet. */\n  dirty: boolean\n  /** A rejection is the failed state. Anything else counts as saved. */\n  onSave: () => void | Promise<void>\n  /** Omit it and no Reset button is drawn. */\n  onReset?: () => void\n  onSaveError?: (error: unknown) => void\n  message?: string\n  savingMessage?: string\n  savedMessage?: string\n  errorMessage?: string\n  saveLabel?: string\n  resetLabel?: string\n  retryLabel?: string\n  /** Saves on Cmd+S and Ctrl+S while there is something to save. */\n  shortcut?: boolean\n  /** Asks the browser to confirm a reload or a close while work is unsaved. */\n  warnOnLeave?: boolean\n  /** The accessible name of the bar. */\n  label?: string\n}\n\n/** Long enough to cover the check's path once, so it draws rather than jumps. */\nconst CHECK_LENGTH = 14\n\nexport const SaveBar = React.forwardRef<HTMLDivElement, SaveBarProps>(\n  function SaveBar(\n    {\n      className,\n      dirty,\n      errorMessage = \"Could not save\",\n      label = \"Save changes\",\n      message = \"Unsaved changes\",\n      onReset,\n      onSave,\n      onSaveError,\n      resetLabel = \"Reset\",\n      retryLabel = \"Try again\",\n      saveLabel = \"Save\",\n      savedMessage = \"Saved\",\n      savingMessage = \"Saving\",\n      shortcut = true,\n      warnOnLeave = true,\n      ...rootProps\n    },\n    forwardedRef\n  ) {\n    const [status, setStatus] = React.useState<\n      \"idle\" | \"saving\" | \"saved\" | \"error\"\n    >(\"idle\")\n    const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n    React.useEffect(\n      () => () => {\n        if (timer.current) clearTimeout(timer.current)\n      },\n      []\n    )\n\n    // A failure is worth keeping up only while there is still something to\n    // retry, so a form the host reset takes the message away with it.\n    const state: SaveBarState =\n      status === \"saving\"\n        ? \"saving\"\n        : status === \"saved\"\n          ? \"saved\"\n          : status === \"error\" && dirty\n            ? \"error\"\n            : dirty\n              ? \"dirty\"\n              : \"clean\"\n\n    const shown = state !== \"clean\"\n    const busy = state === \"saving\"\n\n    const save = React.useCallback(async () => {\n      if (timer.current) clearTimeout(timer.current)\n      setStatus(\"saving\")\n\n      try {\n        await onSave()\n        setStatus(\"saved\")\n        // The confirmation is the reason the bar is still here. Once it has\n        // been read, a clean form has nothing left to say.\n        timer.current = setTimeout(() => setStatus(\"idle\"), 1200)\n      } catch (error) {\n        setStatus(\"error\")\n        onSaveError?.(error)\n      }\n    }, [onSave, onSaveError])\n\n    React.useEffect(() => {\n      if (!shortcut || !dirty || busy) return\n\n      function handleKeyDown(event: KeyboardEvent) {\n        if (!event.metaKey && !event.ctrlKey) return\n        if (event.key.toLowerCase() !== \"s\") return\n\n        event.preventDefault()\n        void save()\n      }\n\n      window.addEventListener(\"keydown\", handleKeyDown)\n      return () => window.removeEventListener(\"keydown\", handleKeyDown)\n    }, [busy, dirty, save, shortcut])\n\n    React.useEffect(() => {\n      if (!warnOnLeave || !dirty) return\n\n      function warn(event: BeforeUnloadEvent) {\n        // preventDefault is the current spec; returnValue is what older\n        // browsers still read the intent from.\n        event.preventDefault()\n        event.returnValue = \"\"\n      }\n\n      window.addEventListener(\"beforeunload\", warn)\n      return () => window.removeEventListener(\"beforeunload\", warn)\n    }, [dirty, warnOnLeave])\n\n    const text =\n      state === \"saving\"\n        ? savingMessage\n        : state === \"saved\"\n          ? savedMessage\n          : state === \"error\"\n            ? errorMessage\n            : message\n\n    const action =\n      \"min-h-8 rounded-full px-3 text-sm transition-colors duration-150 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-60 motion-reduce:transition-none\"\n\n    return (\n      <>\n        <div\n          aria-hidden={!shown || undefined}\n          aria-label={label}\n          data-slot=\"save-bar\"\n          data-state={state}\n          ref={forwardedRef}\n          role=\"region\"\n          className={cn(\n            \"bg-background text-foreground border-border fixed inset-x-4 bottom-6 z-40 mx-auto flex max-w-md items-center gap-3 rounded-full border py-2 pr-2 pl-4 shadow-lg transition-[opacity,transform] duration-200 motion-reduce:transition-none\",\n            shown\n              ? \"translate-y-0 opacity-100\"\n              : \"pointer-events-none translate-y-4 opacity-0\",\n            className\n          )}\n          {...rootProps}\n        >\n          <span aria-hidden=\"true\" className=\"relative size-4 shrink-0\">\n            <span\n              className={cn(\n                \"absolute inset-[4px] rounded-full transition-[opacity,transform] duration-200 motion-reduce:transition-none\",\n                state === \"error\" ? \"bg-destructive\" : \"bg-muted-foreground\",\n                state === \"dirty\" || state === \"error\"\n                  ? \"scale-100 opacity-100\"\n                  : \"scale-50 opacity-0\"\n              )}\n            />\n            <svg\n              className={cn(\n                \"absolute inset-0 animate-spin transition-opacity duration-200 motion-reduce:animate-none motion-reduce:transition-none\",\n                busy ? \"opacity-100\" : \"opacity-0\"\n              )}\n              fill=\"none\"\n              viewBox=\"0 0 24 24\"\n            >\n              <circle\n                className=\"opacity-20\"\n                cx=\"12\"\n                cy=\"12\"\n                r=\"10\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2.5\"\n              />\n              <path\n                d=\"M12 2a10 10 0 0 1 10 10\"\n                stroke=\"currentColor\"\n                strokeLinecap=\"round\"\n                strokeWidth=\"2.5\"\n              />\n            </svg>\n            <svg\n              className={cn(\n                \"absolute inset-0 transition-opacity duration-200 motion-reduce:transition-none\",\n                state === \"saved\" ? \"opacity-100\" : \"opacity-0\"\n              )}\n              fill=\"none\"\n              viewBox=\"0 0 16 16\"\n            >\n              {/* Drawn rather than swapped in, so the confirmation has the\n                  same cause the bar's own arrival does. */}\n              <path\n                className=\"transition-[stroke-dashoffset] duration-300 ease-out motion-reduce:transition-none\"\n                d=\"M3.5 8.5 6.5 11.5 12.5 5\"\n                stroke=\"currentColor\"\n                strokeDasharray={CHECK_LENGTH}\n                strokeDashoffset={state === \"saved\" ? 0 : CHECK_LENGTH}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth=\"2\"\n              />\n            </svg>\n          </span>\n\n          <p className=\"min-w-0 flex-1 truncate text-sm\">{text}</p>\n\n          {onReset ? (\n            <button\n              className={cn(\n                action,\n                \"text-muted-foreground hover:text-foreground\"\n              )}\n              disabled={busy}\n              onClick={onReset}\n              tabIndex={shown ? undefined : -1}\n              type=\"button\"\n            >\n              {resetLabel}\n            </button>\n          ) : null}\n\n          <button\n            className={cn(\n              action,\n              \"bg-primary text-primary-foreground px-4 font-medium\"\n            )}\n            disabled={busy}\n            onClick={() => void save()}\n            tabIndex={shown ? undefined : -1}\n            type=\"button\"\n          >\n            {state === \"error\" ? retryLabel : saveLabel}\n          </button>\n        </div>\n\n        {/* Kept outside the bar, because a live region that was hidden a\n            moment ago is not reliably read when it reappears. */}\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {shown ? text : \"\"}\n        </span>\n      </>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "form",
    "button",
    "feedback",
    "floating"
  ],
  "type": "registry:ui"
}