{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dialog",
  "type": "registry:ui",
  "title": "Dialog",
  "description": "Rises from .97 on the expo curve, scrolls inside with edge hairlines, nudges when refused.",
  "categories": [
    "dialogs"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json"
  ],
  "files": [
    {
      "path": "components/ui/dialog.tsx",
      "content": "\"use client\";\nimport { Dialog as BaseDialog } from \"@base-ui/react/dialog\";\nimport { useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { X } from \"@/lib/icons\";\n\ntype DialogContextValue = { setPopup: (node: HTMLDivElement | null) => void; nudge: () => void };\nconst DialogContext = createContext<DialogContextValue | null>(null);\n\n/**\n * A small \"I'm still here\" push: the popup swells 1.5% and settles. Used when a\n * click outside is refused, so the refusal reads as intentional, not as lag.\n */\nfunction nudgeElement(el: HTMLElement | null, reduce: boolean) {\n  if (!el || reduce || typeof el.animate !== \"function\") return;\n  el.animate([{ transform: \"scale(1)\" }, { transform: \"scale(1.015)\" }, { transform: \"scale(1)\" }], {\n    duration: 280,\n    easing: \"cubic-bezier(0.16, 1, 0.3, 1)\",\n  });\n}\n\nexport type DialogProps = BaseDialog.Root.Props & {\n  /**\n   * Whether a click on the backdrop closes the dialog. When false the dialog\n   * nudges instead, which is what you want once a form has unsaved input.\n   * Escape and the close button still work.\n   */\n  dismissible?: boolean;\n};\n\nexport function Dialog({ dismissible = true, onOpenChange, ...rest }: DialogProps) {\n  const popupRef = useRef<HTMLDivElement>(null);\n  const reduce = !!useReducedMotion();\n  const nudge = () => nudgeElement(popupRef.current, reduce);\n  const setPopup = (node: HTMLDivElement | null) => {\n    popupRef.current = node;\n  };\n  return (\n    <DialogContext.Provider value={{ setPopup, nudge }}>\n      <BaseDialog.Root\n        onOpenChange={(open, details) => {\n          if (!open && !dismissible && details.reason === \"outside-press\") {\n            details.cancel();\n            nudge();\n            return;\n          }\n          onOpenChange?.(open, details);\n        }}\n        {...rest}\n      />\n    </DialogContext.Provider>\n  );\n}\n\n/** Nudge the open dialog from your own code, e.g. when you cancel a close in onOpenChange. */\nexport function useDialogNudge() {\n  return useContext(DialogContext)?.nudge ?? (() => {});\n}\n\nexport type DialogTriggerProps = BaseDialog.Trigger.Props;\n\n/** Opens the dialog. Pass your own button with `render` to keep its look. */\nexport function DialogTrigger(props: DialogTriggerProps) {\n  return <BaseDialog.Trigger {...props} />;\n}\n\nexport type DialogSize = \"sm\" | \"md\" | \"lg\";\n\nconst widths: Record<DialogSize, string> = {\n  sm: \"max-w-[400px]\",\n  md: \"max-w-[520px]\",\n  lg: \"max-w-[720px]\",\n};\n\nexport type DialogContentProps = Omit<BaseDialog.Popup.Props, \"className\"> & {\n  className?: string;\n  /** 400px for a confirm, 520px for a form, 720px for content. */\n  size?: DialogSize;\n  /** The × in the top-right corner. */\n  showCloseButton?: boolean;\n  /** Accessible name of the close button. */\n  closeLabel?: string;\n  /** Render into this element instead of document.body. The backdrop then covers only the container. */\n  container?: BaseDialog.Portal.Props[\"container\"];\n  backdropClassName?: string;\n};\n\nexport function DialogContent({\n  size = \"md\",\n  showCloseButton = true,\n  closeLabel = \"Close\",\n  container,\n  backdropClassName,\n  className,\n  children,\n  ref,\n  ...rest\n}: DialogContentProps) {\n  const ctx = useContext(DialogContext);\n  const contained = container != null;\n  return (\n    <BaseDialog.Portal container={container}>\n      <BaseDialog.Backdrop\n        className={cn(\n          contained ? \"absolute\" : \"fixed\",\n          \"inset-0 z-(--z-overlay) bg-overlay\",\n          // In fast, out faster. Opacity only: a backdrop has nothing to say about direction.\n          \"transition-opacity duration-200 ease-out-quart data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-150\",\n          backdropClassName,\n        )}\n      />\n      <BaseDialog.Viewport\n        className={cn(\n          contained ? \"absolute\" : \"fixed\",\n          \"inset-0 z-(--z-dialog) flex items-center justify-center p-4\",\n          // On a phone the dialog sits at the bottom, under the thumb.\n          \"max-sm:items-end max-sm:p-2 max-sm:pb-[max(8px,env(safe-area-inset-bottom))]\",\n        )}\n      >\n        <BaseDialog.Popup\n          ref={(node: HTMLDivElement | null) => {\n            ctx?.setPopup(node);\n            if (typeof ref === \"function\") ref(node);\n            else if (ref) ref.current = node;\n          }}\n          data-size={size}\n          data-closable={showCloseButton ? \"\" : undefined}\n          className={cn(\n            \"group/dialog relative flex max-h-full w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n            widths[size],\n            // Enter: fade, grow from .97 and rise 6px on the expo curve. Exit: faster and simpler, no travel.\n            \"origin-center transition-[opacity,scale,translate] duration-[260ms] ease-out-expo\",\n            \"data-starting-style:translate-y-1.5 data-starting-style:scale-[0.97] data-starting-style:opacity-0\",\n            \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-[160ms] data-ending-style:ease-out-quart\",\n            // Phones: rise from the bottom edge instead of growing in the middle.\n            \"max-sm:data-starting-style:translate-y-4 max-sm:data-starting-style:scale-100\",\n            // A dialog opened on top of this one pushes it back and dims it.\n            \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-overlay after:opacity-0 after:transition-opacity after:duration-200\",\n            \"data-nested-dialog-open:scale-[0.96] data-nested-dialog-open:after:opacity-100\",\n            className,\n          )}\n          {...rest}\n        >\n          {children}\n          {showCloseButton && (\n            // Last in the DOM so initial focus lands on the content, first on screen.\n            <BaseDialog.Close\n              aria-label={closeLabel}\n              className={cn(\n                \"absolute right-3 top-3 grid size-7 place-items-center rounded-md text-fg-3\",\n                \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                \"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n                \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n              )}\n            >\n              <X size={16} />\n            </BaseDialog.Close>\n          )}\n        </BaseDialog.Popup>\n      </BaseDialog.Viewport>\n    </BaseDialog.Portal>\n  );\n}\n\nexport type DialogHeaderProps = React.ComponentProps<\"div\">;\n\nexport function DialogHeader({ className, ...rest }: DialogHeaderProps) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn(\"flex shrink-0 flex-col gap-1 px-5 pb-2 pt-5 group-data-closable/dialog:pr-12\", className)}\n      {...rest}\n    />\n  );\n}\n\nexport type DialogTitleProps = Omit<BaseDialog.Title.Props, \"className\"> & { className?: string };\n\nexport function DialogTitle({ className, ...rest }: DialogTitleProps) {\n  return (\n    <BaseDialog.Title\n      className={cn(\"text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg text-balance\", className)}\n      {...rest}\n    />\n  );\n}\n\nexport type DialogDescriptionProps = Omit<BaseDialog.Description.Props, \"className\"> & { className?: string };\n\nexport function DialogDescription({ className, ...rest }: DialogDescriptionProps) {\n  return <BaseDialog.Description className={cn(\"text-[13px] leading-[1.5] text-fg-2 text-pretty\", className)} {...rest} />;\n}\n\nexport type DialogBodyProps = React.ComponentProps<\"div\">;\n\n/**\n * The part that scrolls. The header and footer stay put; a hairline appears on\n * each edge only while there is more content hidden past it.\n */\nexport function DialogBody({ className, children, ...rest }: DialogBodyProps) {\n  const wrap = useRef<HTMLDivElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const root = wrap.current;\n    const el = scroller.current;\n    if (!root || !el) return;\n    const update = () => {\n      const max = el.scrollHeight - el.clientHeight;\n      root.toggleAttribute(\"data-overflow-top\", el.scrollTop > 1);\n      root.toggleAttribute(\"data-overflow-bottom\", max - el.scrollTop > 1);\n      // A region that scrolls must be reachable by keyboard when nothing inside it is.\n      if (max > 1) el.setAttribute(\"tabindex\", \"0\");\n      else el.removeAttribute(\"tabindex\");\n    };\n    update();\n    el.addEventListener(\"scroll\", update, { passive: true });\n    const ro = new ResizeObserver(update);\n    ro.observe(el);\n    if (content.current) ro.observe(content.current);\n    return () => {\n      el.removeEventListener(\"scroll\", update);\n      ro.disconnect();\n    };\n  }, []);\n\n  return (\n    <div ref={wrap} data-slot=\"dialog-body\" className=\"group/body relative flex min-h-0 flex-1 flex-col\">\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-0 top-0 z-1 h-px bg-line-2 opacity-0 transition-opacity duration-150 group-data-overflow-top/body:opacity-100\"\n      />\n      <div\n        ref={scroller}\n        className={cn(\n          \"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-2 outline-none\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4\",\n          className,\n        )}\n        {...rest}\n      >\n        <div ref={content}>{children}</div>\n      </div>\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 z-1 h-px bg-line-2 opacity-0 transition-opacity duration-150 group-data-overflow-bottom/body:opacity-100\"\n      />\n    </div>\n  );\n}\n\nexport type DialogFooterProps = React.ComponentProps<\"div\">;\n\nexport function DialogFooter({ className, ...rest }: DialogFooterProps) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\n        \"flex shrink-0 items-center justify-end gap-2 px-5 pb-5 pt-4\",\n        // Phones: buttons share the row equally and grow to a thumb-sized 40px.\n        \"max-sm:grid max-sm:auto-cols-fr max-sm:grid-flow-col max-sm:*:h-10\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nexport type DialogCloseProps = Omit<BaseDialog.Close.Props, \"className\"> & {\n  className?: string;\n  variant?: \"primary\" | \"secondary\" | \"ghost\";\n};\n\n/** A button that closes the dialog, styled as a button unless you `render` your own. */\nexport function DialogClose({ variant = \"secondary\", className, ...rest }: DialogCloseProps) {\n  return (\n    <BaseDialog.Close\n      data-variant={variant}\n      className={cn(\n        \"relative inline-flex h-8 shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg px-2.5 text-[12.5px] font-medium tracking-[-0.005em]\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,border-color,color,scale] duration-150 ease-out-quart active:scale-[0.97] active:duration-75\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n        variant === \"primary\" && \"bg-fg text-frame shadow-[var(--shadow)] hover:bg-fg/90\",\n        variant === \"secondary\" && \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\",\n        variant === \"ghost\" && \"text-fg-2 hover:bg-hover hover:text-fg\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/dialog"
}
