{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "unsaved-changes-guard",
  "title": "Unsaved Changes Guard",
  "description": "A leave guard for a form with unsaved changes: it holds the departure, asks whether the work should be discarded, and lets it through only when the answer is yes. Reach for it wherever a screen holds work that only exists in the browser: a long or multi-step form, a profile or settings page, a post, comment or article editor, an invoice, order or booking being filled in, a canvas, diagram or query builder, an upload that has been configured but not started, and any modal a user can wander out of. Common asks it answers: \"unsaved changes warning react\", \"are you sure you want to leave this page\", \"beforeunload react hook\", \"useBeforeUnload\", \"prompt before leaving page react\", \"block navigation react router\", \"useBlocker alternative\", \"next.js unsaved changes warning\", \"next.js prevent navigation\", \"confirm before leaving form\", \"dirty form guard\", \"warn user before closing tab\", \"onbeforeunload not working\", \"react router prompt replacement\", \"shadcn unsaved changes dialog\", \"discard changes dialog\", \"route change confirmation react\". The reason to install one rather than write four lines is that the four lines guard the wrong door. beforeunload fires when the browser unloads the document — closing the tab, reloading, typing a new URL, following a link off the site. Clicking a next/link or a React Router Link is none of those: the document stays exactly where it is, the router swaps what is rendered, and the half-filled form is gone without the browser ever being consulted. So the guard everyone ships protects the tab button and lets every single in-app route change walk straight past it, which is the way people actually lose the work. beforeunload is not straightforward on its own terms either. The spec settled on preventDefault() while older engines only look at returnValue having been set, so a handler that does one of the two is silently dead in some browsers. Custom wording is discarded by every current browser, so the message you actually control is the in-app one. And nothing fires at all on a page the user has never clicked or typed into, because browsers require sticky activation before they will interrupt a departure. This component does the browser half correctly and then covers the two departures it cannot see, each with the mechanism that is right for it. Link clicks are caught by a capture-phase listener on document, which runs before React's root listener and therefore before any router's handler; both preventDefault() and stopPropagation() are called, because routers differ on whether they check defaultPrevented and stopping the event short of the React root is true of all of them. Answering \"discard\" replays the original click on the same anchor, so the router handles it exactly as it would have — no full page reload, no reimplementation of routing. Back and forward are caught with the Navigation API's navigate event, the only thing in a browser that can actually refuse a traversal: popstate is announced after the history entry has already changed, and the usual workaround of pushing a sentinel entry to have something to pop leaves a duplicate entry and a back press that does nothing for the rest of the session. That trade is refused here rather than hidden, so the reach is stated plainly: where window.navigation is missing, back is not intercepted, and the way to cover it — along with navigation your own code starts — is the guard prop, which takes a blocker from your router (React Router's useBlocker drops straight in) and stands the built-in interception down while keeping the browser-level warning. Just as deliberate is what is never held: a router.push the app itself makes, because the redirect after a successful save is exactly that and blocking it traps someone on a form they have already submitted; cross-origin links, which are a real unload and which beforeunload already covers; hash links, downloads, target=\"_blank\", and cmd, ctrl, shift or middle clicks, every one of which leaves this page where it is. Official shadcn/ui has nothing in this area — beforeunload, unsaved, dirty and blocker appear in none of its sixty-three components, and it ships no navigation blocking of any kind. Within pulld it is distinct from confirm-button, which guards a destructive action somebody deliberately clicked; this one interrupts a departure nobody thought of as destructive. It pairs with save-status and form-error-summary on the same screen. The dialog is an alertdialog because it interrupts rather than being asked for, and focus lands on \"keep editing\" rather than on discard, so an Enter press already on its way to the page cannot answer with the destructive choice; Escape does the same as keep editing, and there is no third way out that would leave the navigation in limbo. Once a departure has been answered the browser stops asking the same question, and starts again the moment the user touches the page still holding unsaved work. useUnsavedChanges() is exported for a dialog of your own, useBeforeUnload() for the browser half alone, every colour is a shadcn token so it follows light and dark, and the whole thing is one file with no dependency beyond the icon.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/unsaved-changes-guard.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { TriangleAlert } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Warns before the browser itself takes the page away — a closed tab, a reload, a typed URL, a\n * link to another site.\n *\n * Three things about `beforeunload` are not obvious, and a handler that misses any of them is\n * silently useless:\n *\n *   - **Both `preventDefault()` and `returnValue` are needed.** The spec settled on\n *     `preventDefault()`; older engines only look at `returnValue` being set to something. Neither\n *     alone covers every browser in use, and there is no cost to doing both.\n *   - **Custom wording is discarded.** Every current browser shows its own sentence and throws the\n *     string away, so there is nothing to pass in. That is also why this hook is separate from the\n *     dialog below: the message you actually control is the in-app one.\n *   - **It does not fire on a page the user has never touched.** Browsers require sticky activation\n *     — a click, a key, a tap — before they will interrupt a departure. A form that was filled in\n *     has that by definition; a page that was only scrolled does not.\n *\n * The listener is attached only while `when` is true rather than being left on with an early\n * return. Some browsers refuse the back/forward cache to a page that has a `beforeunload` listener\n * registered at all, so an always-on listener slows every back button on the site to protect the\n * one screen that needed it.\n *\n * `bypassRef` is an escape hatch for a caller that is itself about to navigate on purpose: while\n * its `current` is true the warning stays quiet. `useUnsavedChanges` uses it so that the browser's\n * own prompt does not stack on top of the dialog the user has already answered.\n */\nexport function useBeforeUnload(when: boolean, bypassRef?: React.RefObject<boolean>): void {\n  React.useEffect(() => {\n    if (!when) return\n    const handler = (event: BeforeUnloadEvent) => {\n      if (bypassRef?.current) return\n      event.preventDefault()\n      // Assigned for engines that predate `preventDefault()` being enough. The value is never shown.\n      event.returnValue = \"\"\n    }\n    window.addEventListener(\"beforeunload\", handler)\n    return () => window.removeEventListener(\"beforeunload\", handler)\n  }, [when, bypassRef])\n}\n\n/** How a blocked departure was started. */\nexport type PendingNavigationKind = \"link\" | \"back\"\n\nexport interface PendingNavigation {\n  /** Whether the user followed a link or used the browser's back/forward control. */\n  kind: PendingNavigationKind\n  /** Where it was heading, when that is known. */\n  href: string | null\n}\n\nexport interface NavigationGuard {\n  /** The departure waiting on an answer, or null when nothing is pending. */\n  pending: PendingNavigation | null\n  /** Let it happen. */\n  proceed: () => void\n  /** Stay on the page. */\n  cancel: () => void\n}\n\nexport interface UnsavedChangesOptions {\n  /** Whether there is anything to lose. Wire this to your form's dirty state. */\n  when: boolean\n  /** Intercept clicks on same-origin links. */\n  interceptLinks?: boolean\n  /** Intercept the back and forward buttons, where the browser allows it (see below). */\n  interceptBack?: boolean\n}\n\n/** The slice of the Navigation API this component uses, declared locally so no lib update is needed. */\ninterface NavigateEventLike extends Event {\n  readonly navigationType: \"push\" | \"replace\" | \"reload\" | \"traverse\"\n  readonly hashChange: boolean\n  readonly downloadRequest: string | null\n  readonly destination: { readonly url: string; readonly key: string }\n}\n\ninterface NavigationLike extends EventTarget {\n  traverseTo(key: string): unknown\n}\n\nfunction getNavigation(): NavigationLike | null {\n  if (typeof window === \"undefined\") return null\n  const nav = (window as unknown as { navigation?: NavigationLike }).navigation\n  return nav && typeof nav.traverseTo === \"function\" ? nav : null\n}\n\nfunction findAnchor(target: EventTarget | null): HTMLAnchorElement | null {\n  if (!(target instanceof Element)) return null\n  const found = target.closest(\"a[href]\")\n  return found instanceof HTMLAnchorElement ? found : null\n}\n\n/**\n * Holds a departure until the user has answered for it.\n *\n * The reason this is not four lines of `beforeunload` is that `beforeunload` never fires for the\n * way people actually leave a form. Clicking a `next/link` or a React Router `Link` is not an\n * unload: the document stays, the router swaps what is rendered, and the half-filled form is gone\n * without the browser having been involved at all. A guard built on `beforeunload` alone protects\n * the tab button and nothing else — every route change inside the app walks straight past it.\n *\n * So two more mechanisms are needed, and each is used where it is the better one:\n *\n *   - **Links** are caught by a `click` listener on `document` in the capture phase, which runs\n *     before React's own root listener and therefore before any router's handler. Both\n *     `preventDefault()` and `stopPropagation()` are called: routers differ on whether they check\n *     `defaultPrevented`, and stopping the event before it reaches the React root is the part that\n *     is true of all of them. Answering \"discard\" replays the original click on the same anchor,\n *     so the router handles it exactly as it would have — which is why this path is used for links\n *     in preference to the Navigation API, whose `navigate()` would leave the router behind and\n *     load the document afresh.\n *   - **Back and forward** are caught with the Navigation API's `navigate` event, which is the only\n *     thing in a browser that can refuse a traversal. `popstate` cannot: it is announced after the\n *     history entry has already changed, and the usual workaround — pushing a sentinel entry to\n *     have something to pop — leaves a duplicate entry behind and a back press that appears to do\n *     nothing for the rest of the session. Refusing to ship that is why `interceptBack` is honest\n *     about its reach: where `window.navigation` is missing, back is simply not intercepted, and\n *     the way to cover it is your router's own blocker (see `guard` on `UnsavedChangesGuard`).\n *\n * What is deliberately *not* intercepted: navigation the app starts itself (`router.push` in a\n * submit handler), because the redirect after a successful save is exactly that and blocking it\n * would trap the user on a form they have already submitted; cross-origin links, which\n * `beforeunload` covers and which cannot be replayed without reimplementing `target` and `rel`;\n * hash-only links, which change nothing about the document; and downloads and modified clicks\n * (middle-click, cmd/ctrl-click), which open elsewhere and leave the page where it is.\n */\nexport function useUnsavedChanges({\n  when,\n  interceptLinks = true,\n  interceptBack = true,\n}: UnsavedChangesOptions): NavigationGuard {\n  const [pending, setPending] = React.useState<PendingNavigation | null>(null)\n\n  // Read through refs so that the listeners are installed once and not torn down and rebuilt every\n  // time the form's dirty flag flips — which, on a form, is every keystroke that matters.\n  const whenRef = React.useRef(when)\n  whenRef.current = when\n\n  /**\n   * What it would take to resume the held departure: the anchor whose click was cancelled, so it\n   * can be replayed verbatim, or the history key the traversal was heading for. Kept beside\n   * `pending` rather than in it, because neither is anything the dialog renders.\n   */\n  const targetRef = React.useRef<\n    { kind: \"link\"; anchor: HTMLAnchorElement } | { kind: \"back\"; key: string } | undefined\n  >(undefined)\n  /** True for the single replayed click, so the interceptor lets its own replay through. */\n  const replayingRef = React.useRef(false)\n  /** The traversal that has been allowed, matched by key because `traverseTo` resolves later. */\n  const allowedKeyRef = React.useRef<string | undefined>(undefined)\n  /** Set once the user has answered \"discard\", so the browser does not ask the same question again. */\n  const bypassRef = React.useRef(false)\n\n  useBeforeUnload(when, bypassRef)\n\n  const clearPending = React.useCallback(() => {\n    targetRef.current = undefined\n    setPending(null)\n  }, [])\n\n  React.useEffect(() => {\n    if (!interceptLinks) return\n\n    function onClick(event: MouseEvent) {\n      if (replayingRef.current) return\n      if (!whenRef.current) return\n      if (event.defaultPrevented) return\n      // Anything but a plain primary click opens somewhere else and leaves this page alone.\n      if (event.button !== 0) return\n      if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return\n\n      const anchor = findAnchor(event.target)\n      if (!anchor) return\n      if (anchor.hasAttribute(\"download\")) return\n      if (anchor.target && anchor.target !== \"_self\") return\n\n      // `anchor.href` is already resolved, but a `javascript:` or `mailto:` href is not a\n      // navigation this guard has any business holding, and `new URL` is how they are told apart.\n      let url: URL\n      try {\n        url = new URL(anchor.href, window.location.href)\n      } catch {\n        return\n      }\n      if (url.protocol !== \"http:\" && url.protocol !== \"https:\") return\n      // Leaving the site entirely is the browser's own departure, and `beforeunload` has it.\n      if (url.origin !== window.location.origin) return\n      // A link to where we already are, or to a fragment of it, keeps the document and the form.\n      if (url.pathname === window.location.pathname && url.search === window.location.search) return\n\n      event.preventDefault()\n      event.stopPropagation()\n      targetRef.current = { kind: \"link\", anchor }\n      setPending({ kind: \"link\", href: url.href })\n    }\n\n    document.addEventListener(\"click\", onClick, true)\n    return () => document.removeEventListener(\"click\", onClick, true)\n  }, [interceptLinks])\n\n  React.useEffect(() => {\n    if (!interceptBack) return\n    const navigation = getNavigation()\n    if (!navigation) return\n\n    function onNavigate(event: Event) {\n      const navigate = event as NavigateEventLike\n      if (navigate.navigationType !== \"traverse\") return\n      if (!whenRef.current) return\n      if (navigate.hashChange) return\n      if (navigate.downloadRequest !== null) return\n      const key = navigate.destination.key\n      if (!key) return\n      // The traversal this hook let through arrives here as a fresh event; matching on the key is\n      // what tells it apart from the user pressing back a second time.\n      if (allowedKeyRef.current === key) {\n        allowedKeyRef.current = undefined\n        return\n      }\n      // Not every traversal can be refused, and which ones is a browser's decision, so it is asked\n      // rather than assumed. An uncancellable one falls through untouched.\n      if (!navigate.cancelable) return\n      navigate.preventDefault()\n      targetRef.current = { kind: \"back\", key }\n      setPending({ kind: \"back\", href: navigate.destination.url })\n    }\n\n    navigation.addEventListener(\"navigate\", onNavigate)\n    return () => navigation.removeEventListener(\"navigate\", onNavigate)\n  }, [interceptBack])\n\n  // Warnings come back the next time the user does anything on the page. If the departure they\n  // agreed to did happen, this listener leaves with the page; if something else cancelled it and\n  // they are still here typing, the unsaved work is at risk again and the browser should say so.\n  React.useEffect(() => {\n    function rearm() {\n      bypassRef.current = false\n    }\n    document.addEventListener(\"pointerdown\", rearm, true)\n    document.addEventListener(\"keydown\", rearm, true)\n    return () => {\n      document.removeEventListener(\"pointerdown\", rearm, true)\n      document.removeEventListener(\"keydown\", rearm, true)\n    }\n  }, [])\n\n  const proceed = React.useCallback(() => {\n    const target = targetRef.current\n    clearPending()\n    bypassRef.current = true\n    if (!target) return\n\n    if (target.kind === \"link\") {\n      const { anchor } = target\n      if (anchor.isConnected) {\n        replayingRef.current = true\n        try {\n          anchor.click()\n        } finally {\n          replayingRef.current = false\n        }\n      } else {\n        // The anchor was unmounted while the dialog was open — rare, but a route that re-renders\n        // under it can do it. The destination is still known, at the cost of a full page load.\n        window.location.assign(anchor.href)\n      }\n      return\n    }\n\n    allowedKeyRef.current = target.key\n    getNavigation()?.traverseTo(target.key)\n  }, [clearPending])\n\n  return { pending, proceed, cancel: clearPending }\n}\n\nexport interface UnsavedChangesGuardProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"title\" | \"children\">,\n    UnsavedChangesOptions {\n  /**\n   * Replaces the built-in link and back interception with a blocker of your own, which is how a\n   * router that has one covers the cases a page cannot see — programmatic navigation, and back in\n   * a browser without the Navigation API. React Router:\n   *\n   * ```tsx\n   * const blocker = useBlocker(dirty)\n   * <UnsavedChangesGuard\n   *   when={dirty}\n   *   guard={{\n   *     pending: blocker.state === \"blocked\" ? { kind: \"link\", href: null } : null,\n   *     proceed: () => blocker.proceed?.(),\n   *     cancel: () => blocker.reset?.(),\n   *   }}\n   * />\n   * ```\n   *\n   * `when` still drives the browser-level warning, so the tab button stays covered either way.\n   */\n  guard?: NavigationGuard\n  title?: React.ReactNode\n  description?: React.ReactNode\n  /** The destructive choice. */\n  discardLabel?: string\n  /** The safe choice, which is the one that starts focused. */\n  keepLabel?: string\n}\n\n/**\n * The confirmation a user sees when they are about to walk away from unsaved work.\n *\n * It renders nothing at all until something has been held, so it costs a pair of listeners to\n * leave mounted for the lifetime of a form.\n *\n * The dialog is an `alertdialog` because it interrupts rather than being asked for, and focus lands\n * on **keep editing**, not on discard. A confirmation whose destructive button is pre-focused is\n * answered by the same Enter press that was already on its way to the page, which is the failure\n * the dialog exists to prevent. Escape does the same as keep editing; there is no close button,\n * because a dismissal that is neither answer would leave the navigation in limbo.\n *\n * Colours are shadcn tokens throughout, so it follows light and dark with the rest of the app.\n */\nexport function UnsavedChangesGuard({\n  when,\n  guard,\n  interceptLinks,\n  interceptBack,\n  title = \"Leave without saving?\",\n  description = \"Your changes have not been saved yet. If you leave now they will be lost.\",\n  discardLabel = \"Discard changes\",\n  keepLabel = \"Keep editing\",\n  className,\n  onKeyDown: onKeyDownProp,\n  ...props\n}: UnsavedChangesGuardProps) {\n  // A supplied guard owns the in-app half; the built-in interception stands down so a single click\n  // is not held twice. The hook is still called, because `when` and the unload warning are its job.\n  const internal = useUnsavedChanges({\n    when,\n    interceptLinks: guard ? false : interceptLinks,\n    interceptBack: guard ? false : interceptBack,\n  })\n  const { pending, proceed, cancel } = guard ?? internal\n\n  const dialogRef = React.useRef<HTMLDivElement>(null)\n  const keepRef = React.useRef<HTMLButtonElement>(null)\n  const open = pending !== null\n\n  React.useEffect(() => {\n    if (!open) return\n    const restore = document.activeElement\n    keepRef.current?.focus()\n    return () => {\n      // The element that was focused when the dialog opened is the link or the form field the user\n      // was on, and a keyboard user who cancels needs to be put back there rather than at the top.\n      if (restore instanceof HTMLElement && restore.isConnected) restore.focus()\n    }\n  }, [open])\n\n  const titleId = React.useId()\n  const descriptionId = React.useId()\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDownProp?.(event)\n    if (event.defaultPrevented) return\n    if (event.key === \"Escape\") {\n      event.preventDefault()\n      cancel()\n      return\n    }\n    if (event.key !== \"Tab\") return\n    // Modal: Tab stays inside the dialog.\n    const focusables = Array.from(\n      dialogRef.current?.querySelectorAll<HTMLElement>(\"button:not([disabled])\") ?? []\n    )\n    if (focusables.length === 0) {\n      event.preventDefault()\n      return\n    }\n    const first = focusables[0]\n    const last = focusables[focusables.length - 1]\n    const active = document.activeElement\n    if (event.shiftKey && (active === first || active === dialogRef.current)) {\n      event.preventDefault()\n      last.focus()\n    } else if (!event.shiftKey && active === last) {\n      event.preventDefault()\n      first.focus()\n    }\n  }\n\n  if (!open) return null\n\n  return (\n    <div\n      role=\"presentation\"\n      className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4\"\n    >\n      {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}\n      <div\n        {...props}\n        ref={dialogRef}\n        role=\"alertdialog\"\n        aria-modal=\"true\"\n        aria-labelledby={titleId}\n        aria-describedby={descriptionId}\n        tabIndex={-1}\n        onKeyDown={onKeyDown}\n        className={cn(\n          \"w-full max-w-md rounded-xl border bg-background text-foreground shadow-2xl outline-none\",\n          className\n        )}\n      >\n        <div className=\"flex gap-3 p-5\">\n          <TriangleAlert\n            className=\"mt-0.5 h-5 w-5 shrink-0 text-destructive\"\n            aria-hidden=\"true\"\n          />\n          <div className=\"space-y-1.5\">\n            <h2 id={titleId} className=\"text-sm font-medium\">\n              {title}\n            </h2>\n            <p id={descriptionId} className=\"text-sm text-muted-foreground\">\n              {description}\n            </p>\n          </div>\n        </div>\n        <div className=\"flex justify-end gap-2 border-t px-5 py-3\">\n          <button\n            ref={keepRef}\n            type=\"button\"\n            onClick={cancel}\n            className=\"inline-flex h-9 items-center rounded-md border px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          >\n            {keepLabel}\n          </button>\n          <button\n            type=\"button\"\n            onClick={proceed}\n            className=\"inline-flex h-9 items-center rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          >\n            {discardLabel}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
