{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drawer",
  "type": "registry:ui",
  "title": "Drawer",
  "description": "A panel that comes in from any edge and covers most of the screen, dragged anywhere on its face to send it back out.",
  "dependencies": [
    "@radix-ui/react-dialog"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/drawer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type DrawerSide = \"top\" | \"right\" | \"bottom\" | \"left\"\n\nexport interface DrawerContentProps extends React.ComponentProps<\n  typeof DialogPrimitive.Content\n> {\n  /** Edge the drawer comes in from. */\n  side?: DrawerSide\n  /** How much of the screen it covers when open. Any CSS length. */\n  size?: string\n  /** Draw the notch you take hold of. */\n  showHandle?: boolean\n  /** Allow the swipe, the overlay and `Escape` to close the drawer. */\n  dismissible?: boolean\n}\n\n/** Off screen, as a share of the panel. */\nconst CLOSED: Record<DrawerSide, string> = {\n  top: \"0 -100%\",\n  bottom: \"0 100%\",\n  left: \"-100% 0\",\n  right: \"100% 0\",\n}\n\nconst PANEL: Record<DrawerSide, string> = {\n  top: \"inset-x-0 top-0 h-[var(--drawer-size)] w-full rounded-b-2xl border-b\",\n  bottom:\n    \"inset-x-0 bottom-0 h-[var(--drawer-size)] w-full rounded-t-2xl border-t\",\n  left: \"inset-y-0 left-0 h-full w-[var(--drawer-size)] rounded-r-2xl border-r\",\n  right:\n    \"inset-y-0 right-0 h-full w-[var(--drawer-size)] rounded-l-2xl border-l\",\n}\n\n/** Where the contents start from as they settle into the landed panel. */\nconst RISE: Record<DrawerSide, string> = {\n  top: \"0 -14px\",\n  bottom: \"0 14px\",\n  left: \"-14px 0\",\n  right: \"14px 0\",\n}\n\nconst HANDLE_POSITION: Record<DrawerSide, string> = {\n  top: \"inset-x-0 bottom-0 justify-center\",\n  bottom: \"inset-x-0 top-0 justify-center\",\n  left: \"inset-y-0 right-0 flex-col justify-center\",\n  right: \"inset-y-0 left-0 flex-col justify-center\",\n}\n\n/** Milliseconds. Arriving and settling back. */\nconst OPEN_MS = 260\n/** Leaving. An exit can be quicker than an entrance without feeling cut off. */\nconst CLOSE_MS = 200\n/** Pixels per millisecond past which a flick closes on its own. */\nconst VELOCITY_THRESHOLD = 0.2\n/** Share of the panel dragged away before it closes. */\nconst CLOSE_THRESHOLD = 0.25\n/** After the content is scrolled, this long before a drag can start. */\nconst SCROLL_LOCK_MS = 100\n/** The drag waits this long after opening, so the arrival can be scrolled. */\nconst OPEN_GUARD_MS = OPEN_MS\n/** Open: the whole panel on screen, against its edge. */\nconst AT_REST = \"0 0\"\n\nconst isVertical = (side: DrawerSide) => side === \"top\" || side === \"bottom\"\n/** Which way is out: down and right are positive, up and left are not. */\nconst outwardSign = (side: DrawerSide) =>\n  side === \"bottom\" || side === \"right\" ? 1 : -1\n\n/** Resistance past fully open: gives at first, then firms up. */\nconst dampen = (distance: number) =>\n  Math.max(8 * (Math.log(distance + 1) - 2), 0)\n\nexport const Drawer = DialogPrimitive.Root\nexport const DrawerTrigger = DialogPrimitive.Trigger\nexport const DrawerClose = DialogPrimitive.Close\nexport const DrawerPortal = DialogPrimitive.Portal\n\nexport function DrawerOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"drawer-overlay\"\n      className={cn(\n        \"fixed inset-0 z-50 bg-black/45\",\n        \"data-[state=open]:animate-drawer-overlay-in data-[state=closed]:animate-drawer-overlay-out\",\n        // A drag writes opacity inline, and a keyframe would outrank it.\n        \"data-dragging:animate-none motion-reduce:animate-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/**\n * A panel from one edge, standing in for a modal. The whole face is the grip.\n *\n * Keyframes rather than transitions, since the panel is only in the DOM while\n * open. The way in does not fill forwards, or the held last frame would outrank\n * the drag's inline styles. The way out does, or it snaps back before unmount.\n */\nexport function DrawerContent({\n  side = \"bottom\",\n  size,\n  showHandle = true,\n  dismissible = true,\n  className,\n  children,\n  onOpenAutoFocus,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  ...props\n}: DrawerContentProps) {\n  const vertical = isVertical(side)\n  const outward = outwardSign(side)\n\n  const panelRef = React.useRef<HTMLDivElement>(null)\n  const overlayRef = React.useRef<HTMLDivElement>(null)\n  const closeRef = React.useRef<HTMLButtonElement>(null)\n  const origin = React.useRef(0)\n  const offset = React.useRef(0)\n  const startedAt = React.useRef(0)\n  const openedAt = React.useRef(0)\n  const scrolledAt = React.useRef(0)\n  /** Granted once per gesture. Deciding again mid drag hands it back. */\n  const allowed = React.useRef(false)\n  // Refs, not state: state lands a render later, and a fast click inside that\n  // gap used to leave the drag switched on.\n  const held = React.useRef<number | null>(null)\n  const timer = React.useRef(0)\n  const [dragging, setDragging] = React.useState(false)\n\n  React.useEffect(() => () => window.clearTimeout(timer.current), [])\n\n  /** How far the panel travels before it is gone, in pixels. */\n  const measure = () => {\n    const panel = panelRef.current\n    if (!panel) return 0\n    return vertical ? panel.offsetHeight : panel.offsetWidth\n  }\n\n  const move = (distance: number) => {\n    const node = panelRef.current\n    const overlay = overlayRef.current\n    if (!node) return\n\n    node.style.translate = vertical\n      ? `0 ${distance * outward}px`\n      : `${distance * outward}px 0`\n\n    // The page brightening as the panel pulls away is what joins the two.\n    if (overlay) {\n      const size = measure()\n      const gone = size > 0 ? Math.min(Math.max(distance / size, 0), 1) : 0\n      overlay.style.transition = \"none\"\n      overlay.style.opacity = `${1 - gone}`\n    }\n  }\n\n  const glide = (translate: string, opacity: number, duration: number) => {\n    const node = panelRef.current\n    const overlay = overlayRef.current\n    // The panel and the overlay move as one unit, so they share the curve and\n    // the duration exactly.\n    const ease = `${duration}ms var(--ease-drawer)`\n\n    if (node) {\n      node.style.transition = `translate ${ease}`\n      node.style.translate = translate\n    }\n    if (overlay) {\n      overlay.style.transition = `opacity ${ease}`\n      overlay.style.opacity = `${opacity}`\n    }\n  }\n\n  /**\n   * Whether this press is the drag or something inside the panel. Anything the\n   * content can still scroll wins.\n   */\n  const shouldDrag = (target: EventTarget | null, outwards: boolean) => {\n    let node = target as HTMLElement | null\n    if (!node) return false\n\n    if (node.closest(\"[data-no-drag]\")) return false\n    if (node.tagName === \"SELECT\") return false\n    if (!vertical) return true\n    // Already pulled away, so the gesture is plainly the drag.\n    if (offset.current > 0) return true\n\n    const now = Date.now()\n    if (now - openedAt.current < OPEN_GUARD_MS) return false\n    if (window.getSelection()?.toString()) return false\n    if (now - scrolledAt.current < SCROLL_LOCK_MS) return false\n    // Dragging further in belongs to the content. Overdrag is only reachable\n    // once the drag already owns the panel.\n    if (!outwards) {\n      scrolledAt.current = now\n      return false\n    }\n\n    while (node) {\n      if (node.scrollHeight > node.clientHeight) {\n        if (node.scrollTop !== 0) {\n          scrolledAt.current = now\n          return false\n        }\n        if (node.getAttribute(\"role\") === \"dialog\") return true\n      }\n      node = node.parentElement\n    }\n\n    return true\n  }\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerDown?.(event)\n    if (!dismissible) return\n    if (held.current !== null) return\n\n    // Captured on the pressed element, not the panel, so a button under the\n    // finger still gets its click when the press is not a drag.\n    const target = event.target as HTMLElement\n    if (target.setPointerCapture) target.setPointerCapture(event.pointerId)\n    held.current = event.pointerId\n    allowed.current = false\n\n    origin.current = vertical ? event.clientY : event.clientX\n    offset.current = 0\n    startedAt.current = event.timeStamp\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerMove?.(event)\n    if (held.current !== event.pointerId) return\n\n    // A mouse reporting no button let go somewhere this never heard about.\n    if (event.pointerType === \"mouse\" && event.buttons === 0) {\n      handlePointerUp(event)\n      return\n    }\n\n    const point = vertical ? event.clientY : event.clientX\n    const travelled = (point - origin.current) * outward\n\n    if (!allowed.current) {\n      if (!shouldDrag(event.target, travelled > 0)) return\n      allowed.current = true\n      setDragging(true)\n\n      const node = panelRef.current\n      if (node) node.style.transition = \"\"\n      // Re-origin here, or the panel jumps to catch up with the finger.\n      origin.current = point\n      return\n    }\n\n    let next = (point - origin.current) * outward\n    if (next < 0) next = -dampen(-next)\n\n    offset.current = Math.min(next, measure())\n    move(offset.current)\n  }\n\n  const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerUp?.(event)\n    if (held.current !== event.pointerId) return\n    held.current = null\n\n    const target = event.target as HTMLElement\n    if (target.hasPointerCapture?.(event.pointerId)) {\n      target.releasePointerCapture(event.pointerId)\n    }\n\n    if (!allowed.current) return\n    allowed.current = false\n\n    const travelled = offset.current\n    const elapsed = Math.max(event.timeStamp - startedAt.current, 1)\n    const flicked = travelled / elapsed > VELOCITY_THRESHOLD\n    const far = travelled >= measure() * CLOSE_THRESHOLD\n\n    if (dismissible && travelled > 0 && (flicked || far)) {\n      // The swipe carries on out and closes on arrival, rather than snapping\n      // back to play an exit it has already been given. `dragging` stays on\n      // until then, which is what keeps the exit keyframes off it.\n      glide(CLOSED[side], 0, CLOSE_MS)\n      timer.current = window.setTimeout(\n        () => closeRef.current?.click(),\n        CLOSE_MS - 20\n      )\n      return\n    }\n\n    setDragging(false)\n    glide(AT_REST, 1, OPEN_MS)\n  }\n\n  /**\n   * Every open starts against the edge. Written out rather than cleared: React\n   * only rewrites an inline property when its own value changes.\n   */\n  const park = () => {\n    setDragging(false)\n    held.current = null\n    allowed.current = false\n    offset.current = 0\n    openedAt.current = Date.now()\n\n    const node = panelRef.current\n    if (node) {\n      node.style.transition = \"\"\n      node.style.translate = AT_REST\n    }\n\n    const overlay = overlayRef.current\n    if (overlay) {\n      overlay.style.transition = \"\"\n      overlay.style.opacity = \"\"\n    }\n  }\n\n  return (\n    <DrawerPortal>\n      <DrawerOverlay\n        ref={overlayRef}\n        data-dragging={dragging ? \"\" : undefined}\n        onClick={dismissible ? undefined : (event) => event.preventDefault()}\n      />\n      <DialogPrimitive.Content\n        ref={panelRef}\n        data-slot=\"drawer-content\"\n        data-side={side}\n        data-dragging={dragging ? \"\" : undefined}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        // Radix fires this on every open, mounted fresh or not.\n        onOpenAutoFocus={(event) => {\n          park()\n          onOpenAutoFocus?.(event)\n        }}\n        onEscapeKeyDown={\n          dismissible ? undefined : (event) => event.preventDefault()\n        }\n        onInteractOutside={\n          dismissible ? undefined : (event) => event.preventDefault()\n        }\n        className={cn(\n          \"group bg-background text-foreground fixed z-50 flex flex-col border shadow-2xl outline-none\",\n          \"[touch-action:none] [will-change:translate]\",\n          PANEL[side],\n          \"data-[state=open]:animate-drawer-in data-[state=closed]:animate-drawer-out\",\n          \"data-dragging:animate-none data-dragging:select-none motion-reduce:animate-none\",\n          className\n        )}\n        style={\n          {\n            // Short of the whole screen on purpose: the sliver of page left\n            // showing keeps the rounded edge on screen.\n            \"--drawer-size\": size ?? (vertical ? \"90svh\" : \"26rem\"),\n            \"--drawer-closed\": CLOSED[side],\n            \"--drawer-rise\": RISE[side],\n            translate: AT_REST,\n          } as React.CSSProperties\n        }\n        {...props}\n      >\n        <div\n          data-slot=\"drawer-strip\"\n          onScroll={() => {\n            scrolledAt.current = Date.now()\n          }}\n          className={cn(\n            \"relative flex h-full w-full flex-col gap-4 overflow-y-auto overscroll-contain p-6\",\n            // The content keeps the scroll on its own axis. The rest of the\n            // panel belongs to the drag.\n            vertical ? \"[touch-action:pan-y]\" : \"[touch-action:pan-x]\",\n            // Only mounted while open, so this plays once on arrival.\n            \"animate-drawer-rise motion-reduce:animate-none\",\n            showHandle && (vertical ? \"pt-7\" : \"pl-7\"),\n            showHandle && side === \"top\" && \"pt-6 pb-7\",\n            showHandle && side === \"right\" && \"pr-6 pl-7\",\n            showHandle && side === \"left\" && \"pr-7 pl-6\"\n          )}\n        >\n          {showHandle ? (\n            <div\n              aria-hidden=\"true\"\n              data-slot=\"drawer-handle\"\n              className={cn(\n                // 44px of target, whatever the notch itself measures.\n                \"absolute z-10 flex items-center justify-center p-3\",\n                vertical ? \"min-h-11\" : \"min-w-11\",\n                HANDLE_POSITION[side],\n                dismissible\n                  ? \"cursor-grab active:cursor-grabbing\"\n                  : \"cursor-auto\"\n              )}\n            >\n              <span\n                className={cn(\n                  // Scale, not width: transform and opacity are the only two\n                  // properties that skip layout and paint.\n                  \"bg-muted-foreground/40 ease rounded-full transition-[background-color,scale] duration-200 motion-reduce:transition-none\",\n                  \"group-data-dragging:bg-muted-foreground/70\",\n                  vertical\n                    ? \"h-1.5 w-12 group-data-dragging:scale-x-125\"\n                    : \"h-12 w-1.5 group-data-dragging:scale-y-125\"\n                )}\n              />\n            </div>\n          ) : null}\n\n          {children}\n        </div>\n\n        {/* The swipe closes by pressing this, so the panel works whether the\n            open state is Radix's or the caller's. */}\n        <DialogPrimitive.Close ref={closeRef} className=\"sr-only\" tabIndex={-1}>\n          Close\n        </DialogPrimitive.Close>\n      </DialogPrimitive.Content>\n    </DrawerPortal>\n  )\n}\n\nexport function DrawerHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"drawer-header\"\n      className={cn(\"flex flex-col gap-1.5\", className)}\n      {...props}\n    />\n  )\n}\n\nexport function DrawerFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"drawer-footer\"\n      className={cn(\"mt-auto flex flex-col gap-2\", className)}\n      {...props}\n    />\n  )\n}\n\nexport function DrawerTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"drawer-title\"\n      className={cn(\"text-lg font-semibold tracking-tight\", className)}\n      {...props}\n    />\n  )\n}\n\nexport function DrawerDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"drawer-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "ease-drawer": "cubic-bezier(0.32, 0.72, 0, 1)",
      "animate-drawer-in": "drawer-in 260ms var(--ease-drawer)",
      "animate-drawer-out": "drawer-out 200ms var(--ease-drawer) forwards",
      "animate-drawer-rise": "drawer-content-rise 200ms var(--ease-drawer) 60ms both",
      "animate-drawer-overlay-in": "drawer-overlay-in 260ms var(--ease-drawer)",
      "animate-drawer-overlay-out": "drawer-overlay-out 200ms var(--ease-drawer) forwards"
    }
  },
  "css": {
    "@keyframes drawer-in": {
      "from": {
        "translate": "var(--drawer-closed)"
      }
    },
    "@keyframes drawer-out": {
      "to": {
        "translate": "var(--drawer-closed)"
      }
    },
    "@keyframes drawer-overlay-in": {
      "from": {
        "opacity": "0"
      },
      "to": {
        "opacity": "1"
      }
    },
    "@keyframes drawer-overlay-out": {
      "to": {
        "opacity": "0"
      }
    },
    "@keyframes drawer-content-rise": {
      "from": {
        "opacity": "0",
        "translate": "var(--drawer-rise)"
      },
      "60%": {
        "opacity": "1"
      },
      "to": {
        "opacity": "1",
        "translate": "0 0"
      }
    },
    "[data-slot=\"drawer-content\"]::after": {
      "content": "\"\"",
      "position": "absolute",
      "background": "inherit"
    },
    "[data-slot=\"drawer-content\"][data-side=\"bottom\"]::after": {
      "top": "100%",
      "right": "0",
      "left": "0",
      "height": "200%"
    },
    "[data-slot=\"drawer-content\"][data-side=\"top\"]::after": {
      "bottom": "100%",
      "right": "0",
      "left": "0",
      "height": "200%"
    },
    "[data-slot=\"drawer-content\"][data-side=\"left\"]::after": {
      "top": "0",
      "right": "100%",
      "bottom": "0",
      "width": "200%"
    },
    "[data-slot=\"drawer-content\"][data-side=\"right\"]::after": {
      "top": "0",
      "bottom": "0",
      "left": "100%",
      "width": "200%"
    }
  }
}