{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"drawer","type":"registry:component","title":"Drawer","description":"Sheet that slides up from the bottom edge on the native dialog element. Drag the handle down, or flick it, to dismiss.","author":"Ryan","dependencies":["clsx","lucide-react","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/drawer.tsx","type":"registry:component","target":"@components/motion/drawer.tsx","content":"\"use client\";\n// easeui.dev/components/motion/drawer\n\nimport { X } from \"lucide-react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** Matches --ease-drawer in globals.css. The iOS sheet curve: steep start, gentle settle. */\nconst EASE_DRAWER = \"cubic-bezier(0.32, 0.72, 0, 1)\";\nconst ENTER_MS = 300;\n/** Matches the exit transition below. Closing is quicker than opening. */\nconst EXIT_MS = 200;\n/** Drag distance, as a fraction of the panel's height, that dismisses it. */\nconst SWIPE_FRACTION = 0.4;\n/** Drag speed, in px per ms, that dismisses it even on a short drag. */\nconst SWIPE_VELOCITY = 0.5;\n\nexport interface DrawerProps {\n  /** Whether the drawer is open. */\n  open: boolean;\n  /** Called when the drawer asks to close, from Escape, the backdrop, or a drag past the threshold. */\n  onOpenChange: (open: boolean) => void;\n  /** Heading that also names the dialog for screen readers. */\n  title: ReactNode;\n  /** Optional line under the title. */\n  description?: ReactNode;\n  /** Buttons along the bottom edge. */\n  footer?: ReactNode;\n  children?: ReactNode;\n  className?: string;\n}\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const update = () => setReduced(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, []);\n  return reduced;\n}\n\n/**\n * A sheet that slides up from the bottom edge, built on the native dialog\n * element like Modal. Drag the handle down, or flick it, to dismiss.\n */\nexport function Drawer({\n  open,\n  onOpenChange,\n  title,\n  description,\n  footer,\n  children,\n  className,\n}: DrawerProps) {\n  const dialogRef = useRef<HTMLDialogElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const titleId = useId();\n  const descriptionId = useId();\n  const reduceMotion = useReducedMotion();\n\n  // Drives the transition. It lags one frame behind opening so the entrance can animate.\n  const [shown, setShown] = useState(false);\n  const [drag, setDrag] = useState<{ startY: number; startedAt: number; y: number } | null>(null);\n\n  useEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n\n    if (open) {\n      if (!dialog.open) dialog.showModal();\n      const frame = requestAnimationFrame(() => setShown(true));\n      return () => cancelAnimationFrame(frame);\n    }\n\n    setShown(false);\n    if (!dialog.open) return;\n    // Let the exit transition finish before the dialog leaves the top layer.\n    const timeout = setTimeout(() => dialog.close(), EXIT_MS);\n    return () => clearTimeout(timeout);\n  }, [open]);\n\n  // The native dialog does not stop the page behind it from scrolling.\n  useEffect(() => {\n    if (!open) return;\n    const root = document.documentElement;\n    const previous = root.style.overflow;\n    root.style.overflow = \"hidden\";\n    return () => {\n      root.style.overflow = previous;\n    };\n  }, [open]);\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0) return;\n    if ((event.target as HTMLElement).closest(\"button\")) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setDrag({ startY: event.clientY, startedAt: Date.now(), y: 0 });\n  };\n\n  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!drag) return;\n    const raw = event.clientY - drag.startY;\n    // Down follows the finger. Up resists, since there is nowhere to go.\n    setDrag({ ...drag, y: raw > 0 ? raw : -Math.sqrt(-raw) });\n  };\n\n  const onPointerUp = () => {\n    if (!drag) return;\n    const height = panelRef.current?.offsetHeight ?? 1;\n    const velocity = drag.y / Math.max(1, Date.now() - drag.startedAt);\n    if (drag.y > height * SWIPE_FRACTION || (drag.y > 8 && velocity > SWIPE_VELOCITY)) {\n      onOpenChange(false);\n    }\n    setDrag(null);\n  };\n\n  const dragging = drag !== null;\n  const dragY = Math.max(0, drag?.y ?? 0);\n  const height = panelRef.current?.offsetHeight ?? 1;\n  const backdropOpacity = shown ? Math.min(1, Math.max(0, 1 - dragY / height)) : 0;\n  const settleMs = shown ? ENTER_MS : EXIT_MS;\n  const transition = dragging || reduceMotion ? \"none\" : `transform ${settleMs}ms ${EASE_DRAWER}`;\n\n  const panelStyle: CSSProperties = {\n    transform: shown ? `translateY(${dragY}px)` : \"translateY(100%)\",\n    transition,\n  };\n  const backdropStyle: CSSProperties = {\n    opacity: backdropOpacity,\n    transition:\n      dragging || reduceMotion ? \"none\" : `opacity ${settleMs}ms ${EASE_DRAWER}`,\n  };\n\n  return (\n    <dialog\n      ref={dialogRef}\n      aria-labelledby={titleId}\n      aria-describedby={description ? descriptionId : undefined}\n      // Escape fires cancel. Close through state instead so the exit animates.\n      onCancel={(event) => {\n        event.preventDefault();\n        onOpenChange(false);\n      }}\n      onClose={() => {\n        if (open) onOpenChange(false);\n      }}\n      className=\"fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none items-end justify-center overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-transparent open:flex\"\n    >\n      <button\n        type=\"button\"\n        aria-label=\"Close\"\n        tabIndex={-1}\n        onClick={() => onOpenChange(false)}\n        style={backdropStyle}\n        className=\"absolute inset-0 cursor-default bg-black/40\"\n      />\n      <div\n        ref={panelRef}\n        style={panelStyle}\n        className={cn(\n          \"relative flex w-full max-w-lg flex-col overflow-y-auto rounded-t-3xl bg-background pb-[env(safe-area-inset-bottom)] shadow-[0_0_0_1px_var(--border-strong),0_-24px_60px_-20px_rgb(0_0_0/0.45)]\",\n          \"max-h-[85dvh]\",\n          className,\n        )}\n      >\n        {/* Handle plus header are draggable; the close button is excluded. */}\n        <div\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={() => setDrag(null)}\n          className={cn(\n            \"flex shrink-0 touch-none flex-col gap-1.5 px-6 pt-2\",\n            !open && \"pointer-events-none\",\n          )}\n        >\n          <span\n            aria-hidden=\"true\"\n            className=\"mx-auto h-1.5 w-10 shrink-0 cursor-grab rounded-full bg-foreground/20 active:cursor-grabbing\"\n          />\n          <div className=\"flex items-start justify-between gap-4 pb-4 pt-3\">\n            <div className=\"flex flex-col gap-1.5\">\n              <h2 id={titleId} className=\"text-lg font-semibold tracking-tight\">\n                {title}\n              </h2>\n              {description ? (\n                <p id={descriptionId} className=\"text-pretty text-sm text-muted-foreground\">\n                  {description}\n                </p>\n              ) : null}\n            </div>\n            <button\n              type=\"button\"\n              aria-label=\"Close\"\n              onClick={() => onOpenChange(false)}\n              className=\"relative -mr-2 -mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 after:absolute after:-inset-1.5 hover:bg-muted hover:text-foreground\"\n            >\n              <X className=\"h-4 w-4\" />\n            </button>\n          </div>\n        </div>\n        {children ? <div className=\"px-6\">{children}</div> : null}\n        {footer ? (\n          <div className=\"flex flex-wrap justify-end gap-2 px-6 pb-6 pt-6\">{footer}</div>\n        ) : null}\n      </div>\n    </dialog>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}