{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "unsaved-changes-bar",
  "title": "Unsaved Changes Bar",
  "description": "Persistent save and discard actions with busy, error, and page-exit warnings.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/components/unsaved-changes-bar.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { Check } from \"lucide-react\";\nimport { type ComponentProps, createContext, useContext, useEffect, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/uai-utils\";\n\nexport const UNSAVED_CHANGES_BAR_VARIANTS = [\"bar\", \"floating\", \"compact\"] as const;\nexport type UnsavedChangesBarVariant = (typeof UNSAVED_CHANGES_BAR_VARIANTS)[number];\nexport type UnsavedChangesBarProps = ComponentProps<\"section\"> & {\n  variant?: UnsavedChangesBarVariant;\n  dirty: boolean;\n  status?: \"idle\" | \"saving\" | \"error\";\n  warnBeforeUnload?: boolean;\n  onSave?: () => void;\n  onDiscard?: () => void;\n};\n/** How long the bar confirms a successful save before it leaves. */\nconst SAVED_MS = 1200;\nconst EXIT_MS = 150;\ntype Phase = \"open\" | \"saved\" | \"closing\" | \"closed\";\ntype ChangesContext = {\n  /** `saved` is internal: the bar confirms a save after `dirty` turns false. */\n  status: \"idle\" | \"saving\" | \"error\" | \"saved\";\n  onSave?: () => void;\n  onDiscard?: () => void;\n};\nconst Context = createContext<ChangesContext | null>(null);\nconst unsavedChangesBarVariants = cva(\n  \"sticky z-10 flex flex-wrap items-center border-solid border-border bg-card text-card-foreground\",\n  {\n    variants: {\n      variant: {\n        bar: \"bottom-0 gap-3 rounded-none border-x-0 border-y px-4 py-2.5 text-[13px]/[18px]\",\n        floating:\n          \"bottom-4 gap-3 rounded-2xl border-0 py-2 pr-2 pl-4 text-[13px]/[18px] shadow-[0_0_0_1px_var(--border-strong),0_12px_32px_-12px_oklch(0_0_0/0.45)]\",\n        compact: \"bottom-0 gap-2 rounded-xl border py-1.5 pr-1.5 pl-3 text-[12.5px]/[18px]\",\n      },\n    },\n  },\n);\nconst actionClass =\n  \"h-7.5 cursor-pointer whitespace-nowrap rounded-full border-0 px-3.5 py-0 text-[12.5px] font-medium transition-[background-color,filter,scale] duration-[120ms,120ms,140ms] ease-[ease-out,ease-out,cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid enabled:active:scale-[0.97] disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-60 has-[>svg]:px-3.5 motion-reduce:transition-none motion-reduce:enabled:active:scale-100\";\nfunction useChanges() {\n  const context = useContext(Context);\n  if (!context) throw new Error(\"UnsavedChangesBar children must be used within UnsavedChangesBar\");\n  return context;\n}\nexport function UnsavedChangesBar({\n  variant = \"bar\",\n  dirty,\n  status = \"idle\",\n  warnBeforeUnload = true,\n  onSave,\n  onDiscard,\n  className,\n  children,\n  ...props\n}: UnsavedChangesBarProps) {\n  useEffect(() => {\n    if (!dirty || !warnBeforeUnload) return;\n    const warn = (event: BeforeUnloadEvent) => {\n      event.preventDefault();\n      event.returnValue = \"\";\n    };\n    window.addEventListener(\"beforeunload\", warn);\n    return () => window.removeEventListener(\"beforeunload\", warn);\n  }, [dirty, warnBeforeUnload]);\n\n  // When `dirty` turns false after a `saving` status, confirm the save, then play the exit.\n  // Any other clean transition (discard, synchronous save) unmounts at once, as before.\n  const [phase, setPhase] = useState<Phase>(dirty ? \"open\" : \"closed\");\n  const [saved, setSaved] = useState(false);\n  const [wasDirty, setWasDirty] = useState(dirty);\n  const [lastStatus, setLastStatus] = useState(status);\n  if (dirty && status !== lastStatus) setLastStatus(status);\n  if (dirty !== wasDirty) {\n    setWasDirty(dirty);\n    const confirmed = !dirty && (lastStatus === \"saving\" || status === \"saving\");\n    setSaved(confirmed);\n    setPhase(dirty ? \"open\" : confirmed ? \"saved\" : \"closed\");\n  }\n  useEffect(() => {\n    if (phase !== \"saved\" && phase !== \"closing\") return;\n    const timer = window.setTimeout(\n      () => setPhase(phase === \"saved\" ? \"closing\" : \"closed\"),\n      phase === \"saved\" ? SAVED_MS : EXIT_MS,\n    );\n    return () => window.clearTimeout(timer);\n  }, [phase]);\n\n  if (phase === \"closed\") return null;\n  return (\n    <Context.Provider value={{ status: saved ? \"saved\" : status, onSave, onDiscard }}>\n      <section\n        aria-label=\"Unsaved changes\"\n        data-slot=\"unsaved-changes-bar\"\n        data-variant={variant}\n        data-state={phase}\n        className={cn(\n          unsavedChangesBarVariants({ variant }),\n          phase === \"closing\"\n            ? \"animate-out fade-out-0 slide-out-to-bottom-1 fill-mode-forwards duration-150 ease-out motion-reduce:hidden\"\n            : \"animate-in fade-in-0 slide-in-from-bottom-1 zoom-in-98 duration-180 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:animate-none\",\n          className,\n        )}\n        {...props}\n        aria-busy={status === \"saving\"}\n      >\n        {children}\n      </section>\n    </Context.Provider>\n  );\n}\nexport function UnsavedChangesBarMessage({ children, className, ...props }: ComponentProps<\"p\">) {\n  const context = useChanges();\n  const error = context.status === \"error\";\n  const saved = context.status === \"saved\";\n  return (\n    <p\n      data-slot=\"unsaved-changes-bar-message\"\n      className={cn(\n        \"m-0 flex min-w-0 flex-[1_1_140px] items-center gap-2 font-medium\",\n        error ? \"text-destructive-ink\" : \"text-foreground\",\n        className,\n      )}\n      {...props}\n      role={error ? \"alert\" : \"status\"}\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"size-1.5 shrink-0 rounded-full ring-3\",\n          \"transition-colors duration-200 ease-out motion-reduce:transition-none\",\n          error\n            ? \"bg-destructive ring-destructive/22\"\n            : saved\n              ? \"bg-success ring-success/22\"\n              : \"bg-warning ring-warning/22\",\n        )}\n      />\n      <span\n        className={\n          context.status === \"saving\"\n            ? \"shimmer-text motion-reduce:text-muted-foreground\"\n            : undefined\n        }\n      >\n        {children ??\n          (context.status === \"saving\"\n            ? \"Saving changes…\"\n            : saved\n              ? \"Changes saved.\"\n              : error\n                ? \"Changes could not be saved. Try again.\"\n                : \"You have unsaved changes.\")}\n      </span>\n    </p>\n  );\n}\nexport function UnsavedChangesBarActions({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"unsaved-changes-bar-actions\"\n      className={cn(\"flex flex-wrap gap-1.5\", className)}\n      {...props}\n    />\n  );\n}\nexport function UnsavedChangesBarSave({\n  children = \"Save changes\",\n  savedLabel = \"Saved\",\n  onClick,\n  className,\n  ...props\n}: ComponentProps<\"button\"> & { savedLabel?: string }) {\n  const context = useChanges();\n  const saved = context.status === \"saved\";\n  return (\n    <Button\n      data-slot=\"unsaved-changes-bar-save\"\n      variant=\"default\"\n      className={cn(\n        actionClass,\n        saved\n          ? \"gap-1.5 bg-success/14 text-success-ink hover:bg-success/14 disabled:cursor-default disabled:opacity-100\"\n          : \"bg-primary text-primary-foreground hover:bg-primary enabled:hover:brightness-108\",\n        className,\n      )}\n      {...props}\n      type=\"button\"\n      disabled={context.status === \"saving\" || saved || props.disabled}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.onSave?.();\n      }}\n    >\n      {saved ? (\n        <>\n          <Check\n            className=\"size-3.5 animate-in fade-in-0 zoom-in-50 duration-200 ease-out-quint motion-reduce:animate-none\"\n            strokeWidth={2.2}\n            aria-hidden=\"true\"\n          />\n          {savedLabel}\n        </>\n      ) : context.status === \"saving\" ? (\n        \"Saving…\"\n      ) : (\n        children\n      )}\n    </Button>\n  );\n}\nexport function UnsavedChangesBarDiscard({\n  children = \"Discard\",\n  onClick,\n  className,\n  ...props\n}: ComponentProps<\"button\">) {\n  const context = useChanges();\n  return (\n    <Button\n      data-slot=\"unsaved-changes-bar-discard\"\n      variant=\"secondary\"\n      className={cn(\n        actionClass,\n        \"bg-secondary text-secondary-foreground hover:bg-secondary enabled:hover:bg-[color-mix(in_oklab,var(--secondary)_85%,var(--foreground))]\",\n        className,\n      )}\n      {...props}\n      type=\"button\"\n      disabled={context.status === \"saving\" || context.status === \"saved\" || props.disabled}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.onDiscard?.();\n      }}\n    >\n      {children}\n    </Button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/uai/unsaved-changes-bar.tsx"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}