{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sheet",
  "type": "registry:ui",
  "title": "Sheet",
  "description": "Side panel on the drawer curve that swipes away and resizes with give.",
  "categories": [
    "dialogs"
  ],
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/sheet.tsx",
      "content": "\"use client\";\nimport { Drawer } from \"@base-ui/react/drawer\";\nimport { createContext, useContext, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { X } from \"@/lib/icons\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type SheetSide = \"right\" | \"left\";\n\nconst SideContext = createContext<SheetSide>(\"right\");\n\nexport type SheetProps = Omit<Drawer.Root.Props, \"swipeDirection\" | \"snapPoints\" | \"snapPoint\" | \"defaultSnapPoint\" | \"onSnapPointChange\"> & {\n  /** The edge it slides from. Swiping back toward that edge dismisses it. */\n  side?: SheetSide;\n};\n\n/** A side panel. On touch it follows the finger and can be swiped away. */\nexport function Sheet({ side = \"right\", ...rest }: SheetProps) {\n  return (\n    <SideContext.Provider value={side}>\n      <Drawer.Root swipeDirection={side} {...rest} />\n    </SideContext.Provider>\n  );\n}\n\nexport type SheetTriggerProps = Drawer.Trigger.Props;\n\nexport function SheetTrigger(props: SheetTriggerProps) {\n  return <Drawer.Trigger {...props} />;\n}\n\nexport type SheetContentProps = Omit<Drawer.Popup.Props, \"className\" | \"style\"> & {\n  className?: string;\n  style?: React.CSSProperties;\n  /** Render into this element instead of document.body. Backdrop and panel become absolute to it. */\n  container?: Drawer.Portal.Props[\"container\"];\n  /** Adds a drag handle on the inner edge. Arrow keys resize it too; double-click resets. */\n  resizable?: boolean;\n  /** Width in px. Controlled; pair with onWidthChange. */\n  width?: number;\n  defaultWidth?: number;\n  onWidthChange?: (width: number) => void;\n  minWidth?: number;\n  maxWidth?: number;\n  /** Hide the backdrop, e.g. for an inspector used with the page (set modal={false} on Sheet). */\n  backdrop?: boolean;\n};\n\nexport function SheetContent({\n  container,\n  resizable = false,\n  width: widthProp,\n  defaultWidth = 400,\n  onWidthChange,\n  minWidth = 320,\n  maxWidth = 720,\n  backdrop = true,\n  className,\n  style,\n  children,\n  ...rest\n}: SheetContentProps) {\n  const side = useContext(SideContext);\n  const contained = container != null;\n  const [width, setWidth] = useControllableState({ value: widthProp, defaultValue: defaultWidth, onChange: onWidthChange });\n  const [resizing, setResizing] = useState(false);\n\n  return (\n    <Drawer.Portal container={container}>\n      {backdrop && (\n        <Drawer.Backdrop\n          className={cn(\n            contained ? \"absolute\" : \"fixed\",\n            \"inset-0 z-(--z-overlay) bg-overlay\",\n            // Tracks the swipe: the page comes back as the panel leaves.\n            \"opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-drawer\",\n            \"data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-200 data-swiping:duration-0\",\n          )}\n        />\n      )}\n      <Drawer.Viewport\n        className={cn(\n          contained ? \"absolute\" : \"fixed\",\n          \"inset-0 z-(--z-dialog) flex p-2\",\n          side === \"right\" ? \"justify-end\" : \"justify-start\",\n          !backdrop && \"pointer-events-none\",\n        )}\n      >\n        <Drawer.Popup\n          data-side={side}\n          data-resizing={resizing ? \"\" : undefined}\n          style={{ ...style, \"--sheet-width\": `${width}px` } as React.CSSProperties}\n          className={cn(\n            \"group/sheet pointer-events-auto relative flex h-full w-(--sheet-width) max-w-full flex-col rounded-2xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n            // Slides from its edge on the drawer curve; follows the finger 1:1 while swiped;\n            // a fast flick shortens the exit.\n            \"transition-[transform,width] duration-[320ms] ease-drawer data-swiping:duration-0 data-resizing:transition-none\",\n            side === \"right\"\n              ? \"[transform:translateX(var(--drawer-swipe-movement-x,0px))] data-starting-style:[transform:translateX(calc(100%+8px))] data-ending-style:[transform:translateX(calc(100%+8px))]\"\n              : \"[transform:translateX(var(--drawer-swipe-movement-x,0px))] data-starting-style:[transform:translateX(calc(-100%-8px))] data-ending-style:[transform:translateX(calc(-100%-8px))]\",\n            \"data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*240ms)]\",\n            \"data-swiping:select-none\",\n            className,\n          )}\n          {...rest}\n        >\n          {children}\n          {/* Last in the DOM so it never takes initial focus; it sits on the inner edge visually. */}\n          {resizable && (\n            <ResizeHandle\n              side={side}\n              width={width}\n              min={minWidth}\n              max={maxWidth}\n              reset={defaultWidth}\n              onResize={setWidth}\n              onResizingChange={setResizing}\n            />\n          )}\n        </Drawer.Popup>\n      </Drawer.Viewport>\n    </Drawer.Portal>\n  );\n}\n\n/**\n * The inner-edge grip. Pointer drag resizes with a little give past the limits\n * (30% of the overshoot) and springs back on release; arrows step by 16px\n * (64px with Shift); Home/End jump to the limits; double-click or Enter resets.\n */\nfunction ResizeHandle({\n  side,\n  width,\n  min,\n  max,\n  reset,\n  onResize,\n  onResizingChange,\n}: {\n  side: SheetSide;\n  width: number;\n  min: number;\n  max: number;\n  reset: number;\n  onResize: (w: number) => void;\n  onResizingChange: (r: boolean) => void;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const drag = useRef<{ x: number; w: number; max: number } | null>(null);\n\n  // The available room depends on where the sheet is rendered.\n  const limit = () => {\n    const room = ref.current?.closest(\"[data-side]\")?.parentElement?.clientWidth;\n    return room ? Math.max(min, Math.min(max, room - 16)) : max;\n  };\n  const clamp = (w: number, hi = limit()) => Math.round(Math.min(hi, Math.max(min, w)));\n\n  return (\n    <div\n      ref={ref}\n      role=\"separator\"\n      aria-orientation=\"vertical\"\n      aria-label=\"Resize panel\"\n      aria-valuenow={width}\n      aria-valuemin={min}\n      aria-valuemax={max}\n      tabIndex={0}\n      data-base-ui-swipe-ignore=\"\"\n      onPointerDown={(e) => {\n        if (e.button !== 0) return;\n        e.preventDefault();\n        e.currentTarget.setPointerCapture(e.pointerId);\n        drag.current = { x: e.clientX, w: width, max: limit() };\n        onResizingChange(true);\n      }}\n      onPointerMove={(e) => {\n        const d = drag.current;\n        if (!d) return;\n        const delta = side === \"right\" ? d.x - e.clientX : e.clientX - d.x;\n        const raw = d.w + delta;\n        // Rubber-band past either limit instead of a hard stop.\n        const over = raw > d.max ? (raw - d.max) * 0.3 : raw < min ? (raw - min) * 0.3 : 0;\n        onResize(Math.round(Math.min(d.max, Math.max(min, raw)) + over));\n      }}\n      onPointerUp={(e) => {\n        const d = drag.current;\n        if (!d) return;\n        drag.current = null;\n        e.currentTarget.releasePointerCapture(e.pointerId);\n        onResizingChange(false);\n        onResize(clamp(width, d.max));\n      }}\n      onPointerCancel={() => {\n        drag.current = null;\n        onResizingChange(false);\n        onResize(clamp(width));\n      }}\n      onDoubleClick={() => onResize(clamp(reset))}\n      onKeyDown={(e) => {\n        const step = e.shiftKey ? 64 : 16;\n        const grow = side === \"right\" ? \"ArrowLeft\" : \"ArrowRight\";\n        const shrink = side === \"right\" ? \"ArrowRight\" : \"ArrowLeft\";\n        let next: number | null = null;\n        if (e.key === grow) next = width + step;\n        else if (e.key === shrink) next = width - step;\n        else if (e.key === \"Home\") next = min;\n        else if (e.key === \"End\") next = limit();\n        else if (e.key === \"Enter\") next = reset;\n        if (next === null) return;\n        e.preventDefault();\n        onResize(clamp(next));\n      }}\n      className={cn(\n        \"group/handle absolute inset-y-0 z-2 flex w-4 cursor-ew-resize touch-none items-center justify-center outline-none pointer-coarse:hidden\",\n        side === \"right\" ? \"-left-2\" : \"-right-2\",\n      )}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"h-10 w-1 rounded-full bg-fg-4 opacity-0 transition-[opacity,background-color,scale] duration-150 ease-out-quart\",\n          \"group-hover/handle:opacity-100 group-focus-visible/handle:opacity-100 group-focus-visible/handle:bg-fg-2\",\n          \"group-data-resizing/sheet:scale-y-125 group-data-resizing/sheet:bg-fg-2 group-data-resizing/sheet:opacity-100\",\n        )}\n      />\n    </div>\n  );\n}\n\nexport type SheetHeaderProps = React.ComponentProps<\"div\"> & {\n  /** The × at the end of the header. */\n  showCloseButton?: boolean;\n  closeLabel?: string;\n};\n\nexport function SheetHeader({ showCloseButton = true, closeLabel = \"Close\", className, children, ...rest }: SheetHeaderProps) {\n  return (\n    <div className={cn(\"flex shrink-0 items-start gap-3 px-5 pb-3 pt-4\", className)} {...rest}>\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1 pt-1\">{children}</div>\n      {showCloseButton && (\n        <Drawer.Close\n          aria-label={closeLabel}\n          className={cn(\n            \"relative -mr-1.5 grid size-7 shrink-0 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        </Drawer.Close>\n      )}\n    </div>\n  );\n}\n\nexport type SheetTitleProps = Omit<Drawer.Title.Props, \"className\"> & { className?: string };\n\nexport function SheetTitle({ className, ...rest }: SheetTitleProps) {\n  return <Drawer.Title className={cn(\"text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg text-balance\", className)} {...rest} />;\n}\n\nexport type SheetDescriptionProps = Omit<Drawer.Description.Props, \"className\"> & { className?: string };\n\nexport function SheetDescription({ className, ...rest }: SheetDescriptionProps) {\n  return <Drawer.Description className={cn(\"text-[12.5px] leading-[1.5] text-fg-2 text-pretty\", className)} {...rest} />;\n}\n\nexport type SheetBodyProps = React.ComponentProps<\"div\">;\n\n/** Scrolls on its own; hairlines show at the edges only while content is hidden past them. Text in here selects instead of swiping. */\nexport function SheetBody({ className, children, ...rest }: SheetBodyProps) {\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      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} className=\"group/body relative flex min-h-0 flex-1 flex-col\">\n      <span aria-hidden 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      <Drawer.Content\n        ref={scroller}\n        className={cn(\n          \"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-3 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      </Drawer.Content>\n      <span aria-hidden 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    </div>\n  );\n}\n\nexport type SheetFooterProps = React.ComponentProps<\"div\">;\n\nexport function SheetFooter({ className, ...rest }: SheetFooterProps) {\n  return (\n    <div\n      className={cn(\"flex shrink-0 items-center justify-end gap-2 px-5 pb-4 pt-3 max-sm:grid max-sm:auto-cols-fr max-sm:grid-flow-col max-sm:*:h-10\", className)}\n      {...rest}\n    />\n  );\n}\n\nexport type SheetCloseProps = Omit<Drawer.Close.Props, \"className\"> & {\n  className?: string;\n  variant?: \"primary\" | \"secondary\" | \"ghost\";\n};\n\nexport function SheetClose({ variant = \"secondary\", className, ...rest }: SheetCloseProps) {\n  return (\n    <Drawer.Close\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/sheet"
}
