{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bottom-sheet",
  "type": "registry:ui",
  "title": "Bottom Sheet",
  "description": "Drag between peek, half and full; the list scrolls only once it's open.",
  "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/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/bottom-sheet.tsx",
      "content": "\"use client\";\nimport { Drawer } from \"@base-ui/react/drawer\";\nimport { createContext, useContext, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type SnapPoint = number | string;\n\ntype Ctx = {\n  points: SnapPoint[];\n  snap: SnapPoint | null;\n  setSnap: (s: SnapPoint) => void;\n};\nconst SheetCtx = createContext<Ctx | null>(null);\nconst useSheet = () => {\n  const ctx = useContext(SheetCtx);\n  if (!ctx) throw new Error(\"BottomSheet parts must be inside <BottomSheet>\");\n  return ctx;\n};\n\nexport type BottomSheetProps = Omit<\n  Drawer.Root.Props,\n  \"swipeDirection\" | \"snapPoints\" | \"snapPoint\" | \"defaultSnapPoint\" | \"onSnapPointChange\"\n> & {\n  /**\n   * Resting heights, smallest first. 0–1 is a fraction of the container (or\n   * viewport), larger numbers are px, strings take \"px\" or \"rem\".\n   */\n  snapPoints?: SnapPoint[];\n  snapPoint?: SnapPoint | null;\n  defaultSnapPoint?: SnapPoint;\n  onSnapPointChange?: (snapPoint: SnapPoint | null) => void;\n};\n\n/** Peek, half, full: a sheet that rests where the content has natural stops. */\nexport function BottomSheet({\n  snapPoints = [\"148px\", 0.55, 1],\n  snapPoint,\n  defaultSnapPoint,\n  onSnapPointChange,\n  onOpenChange,\n  ...rest\n}: BottomSheetProps) {\n  const initial = defaultSnapPoint ?? snapPoints[Math.min(1, snapPoints.length - 1)] ?? null;\n  const [snap, setSnap] = useControllableState<SnapPoint | null>({\n    value: snapPoint,\n    defaultValue: initial,\n    onChange: onSnapPointChange,\n  });\n  return (\n    <SheetCtx.Provider value={{ points: snapPoints, snap, setSnap }}>\n      <Drawer.Root\n        swipeDirection=\"down\"\n        snapPoints={snapPoints}\n        snapPoint={snap}\n        onSnapPointChange={(next) => setSnap(next)}\n        onOpenChange={(open, details) => {\n          // Every opening starts from the default rest, not wherever it was dismissed from.\n          if (open) setSnap(initial);\n          onOpenChange?.(open, details);\n        }}\n        {...rest}\n      />\n    </SheetCtx.Provider>\n  );\n}\n\nexport type BottomSheetTriggerProps = Drawer.Trigger.Props;\n\nexport function BottomSheetTrigger(props: BottomSheetTriggerProps) {\n  return <Drawer.Trigger {...props} />;\n}\n\nexport type BottomSheetContentProps = Omit<Drawer.Popup.Props, \"className\"> & {\n  className?: string;\n  /** Render into this element instead of document.body; snap fractions then measure the container. */\n  container?: Drawer.Portal.Props[\"container\"];\n  /** Space left above the sheet at its full height, so the page behind still shows. */\n  topGap?: number;\n};\n\nexport function BottomSheetContent({ container, topGap = 24, className, style, children, ...rest }: BottomSheetContentProps) {\n  const contained = container != null;\n  const { points, snap, setSnap } = useSheet();\n  const lastWheel = useRef(0);\n\n  return (\n    <Drawer.Portal container={container}>\n      <Drawer.Backdrop\n        className={cn(\n          contained ? \"absolute\" : \"fixed\",\n          \"inset-0 z-(--z-overlay) bg-overlay\",\n          // Dims fully only at the top snap point and lightens as the sheet comes down.\n          \"opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-[400ms] ease-drawer\",\n          \"data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*300ms)] data-swiping:duration-0\",\n        )}\n      />\n      <Drawer.Viewport\n        className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-dialog) flex touch-none items-end justify-center overflow-hidden\")}\n      >\n        <Drawer.Popup\n          style={{ ...(style as React.CSSProperties), \"--top-gap\": `${topGap}px` } as React.CSSProperties}\n          // Wheel down on a sheet that isn't fully open opens it a step, like a drag would.\n          onWheel={(e) => {\n            const i = points.findIndex((p) => Object.is(p, snap));\n            if (e.deltaY <= 4 || i < 0 || i >= points.length - 1) return;\n            const now = performance.now();\n            if (now - lastWheel.current < 450) return;\n            lastWheel.current = now;\n            setSnap(points[i + 1]);\n          }}\n          className={cn(\n            \"group/sheet relative flex max-h-[calc(100%-var(--top-gap))] w-full max-w-[640px] flex-col rounded-t-2xl border border-b-0 border-line-2 bg-raised text-fg shadow-pop outline-none\",\n            // Snap offset plus live drag; transitions only between rests, 1:1 while dragging.\n            \"[transform:translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))]\",\n            \"transition-transform duration-[400ms] ease-drawer data-swiping:duration-0 data-swiping:select-none\",\n            \"data-starting-style:[transform:translateY(100%)] data-ending-style:[transform:translateY(100%)]\",\n            \"data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*300ms)]\",\n            // Fills the gap underneath when the sheet is pulled past the top and rubber-bands.\n            \"after:pointer-events-none after:absolute after:inset-x-[-1px] after:top-full after:h-16 after:border-x after:border-line-2 after:bg-inherit after:content-['']\",\n            className,\n          )}\n          {...rest}\n        >\n          {children}\n        </Drawer.Popup>\n      </Drawer.Viewport>\n    </Drawer.Portal>\n  );\n}\n\nexport type BottomSheetHandleProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** How each snap point is announced, smallest first. */\n  snapLabels?: string[];\n};\n\n/**\n * The grabber. Drag it like the rest of the sheet; tap or press Enter to step up\n * through the snap points (wrapping to the smallest); arrows, Home and End move\n * between them. It is a slider to assistive tech: “Sheet height, Half”.\n */\nexport function BottomSheetHandle({ snapLabels, className, onClick, onKeyDown, onPointerDown, ...rest }: BottomSheetHandleProps) {\n  const { points, snap, setSnap } = useSheet();\n  const i = Math.max(0, points.findIndex((p) => Object.is(p, snap)));\n  const last = points.length - 1;\n  const labels = snapLabels ?? (points.length === 3 ? [\"Collapsed\", \"Half\", \"Expanded\"] : points.map((_, n) => `${n + 1} of ${points.length}`));\n  const go = (n: number) => points.length && setSnap(points[Math.min(last, Math.max(0, n))]);\n\n  return (\n    <div\n      role=\"slider\"\n      tabIndex={0}\n      aria-label=\"Sheet height\"\n      aria-orientation=\"vertical\"\n      aria-valuemin={0}\n      aria-valuemax={last}\n      aria-valuenow={i}\n      aria-valuetext={labels[i]}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        if (e.button !== 0) return;\n        const from = { x: e.clientX, y: e.clientY };\n        // The sheet captures the pointer for the drag, so listen for the release on the window.\n        // Only a tap steps; a drag already moved the sheet where it should be.\n        const up = (u: PointerEvent) => {\n          if (Math.hypot(u.clientX - from.x, u.clientY - from.y) <= 4) go(i === last ? 0 : i + 1);\n        };\n        window.addEventListener(\"pointerup\", up, { once: true });\n      }}\n      onClick={(e) => {\n        onClick?.(e);\n        // Clicks synthesised by assistive tech (detail 0) step too.\n        if (!e.defaultPrevented && e.detail === 0) go(i === last ? 0 : i + 1);\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented) return;\n        const next =\n          e.key === \"ArrowUp\" || e.key === \"ArrowRight\" ? i + 1\n          : e.key === \"ArrowDown\" || e.key === \"ArrowLeft\" ? i - 1\n          : e.key === \"Home\" ? 0\n          : e.key === \"End\" ? last\n          : e.key === \"Enter\" || e.key === \" \" ? (i === last ? 0 : i + 1)\n          : null;\n        if (next === null) return;\n        e.preventDefault();\n        go(next);\n      }}\n      className={cn(\n        \"group/handle relative flex h-5 w-full shrink-0 cursor-grab touch-none select-none items-start justify-center pt-2 outline-none active:cursor-grabbing\",\n        className,\n      )}\n      {...rest}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"h-[5px] w-9 rounded-full bg-fg-4 transition-[background-color,scale] duration-150 ease-out-quart\",\n          \"group-hover/handle:bg-fg-3 group-focus-visible/handle:bg-fg-2 group-focus-visible/handle:outline-solid group-focus-visible/handle:outline-1 group-focus-visible/handle:outline-offset-3 group-focus-visible/handle:outline-fg-3\",\n          // The grabber widens a little while the sheet is held.\n          \"group-active/handle:scale-x-110 group-data-swiping/sheet:scale-x-125 group-data-swiping/sheet:bg-fg-3\",\n        )}\n      />\n    </div>\n  );\n}\n\nexport type BottomSheetHeaderProps = React.ComponentProps<\"div\">;\n\n/** Sits under the handle and is the part you drag by; visible at every snap point. */\nexport function BottomSheetHeader({ className, ...rest }: BottomSheetHeaderProps) {\n  return <div className={cn(\"flex shrink-0 flex-col gap-0.5 px-5 pb-3 pt-2 select-none\", className)} {...rest} />;\n}\n\nexport type BottomSheetTitleProps = Omit<Drawer.Title.Props, \"className\"> & { className?: string };\n\nexport function BottomSheetTitle({ className, ...rest }: BottomSheetTitleProps) {\n  return <Drawer.Title className={cn(\"text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg\", className)} {...rest} />;\n}\n\nexport type BottomSheetDescriptionProps = Omit<Drawer.Description.Props, \"className\"> & { className?: string };\n\nexport function BottomSheetDescription({ className, ...rest }: BottomSheetDescriptionProps) {\n  return <Drawer.Description className={cn(\"text-[12.5px] leading-[1.5] text-fg-3\", className)} {...rest} />;\n}\n\nexport type BottomSheetBodyProps = Omit<Drawer.Content.Props, \"className\"> & { className?: string };\n\n/**\n * Scrolls only once the sheet is fully open. Below that a drag moves the sheet;\n * at the top a drag scrolls the list, and pulling down from the top of the\n * list hands the gesture back to the sheet. Keyboard focus inside opens it fully.\n */\nexport function BottomSheetBody({ className, onFocus, ...rest }: BottomSheetBodyProps) {\n  const { points, setSnap } = useSheet();\n  return (\n    <Drawer.Content\n      onFocus={(e) => {\n        onFocus?.(e);\n        if ((e.target as HTMLElement).matches?.(\":focus-visible\") && points.length) setSnap(points[points.length - 1]);\n      }}\n      className={cn(\n        \"min-h-0 flex-1 overflow-y-hidden overscroll-contain border-t border-line px-5 pb-[max(16px,env(safe-area-inset-bottom))] pt-3\",\n        \"group-data-expanded/sheet:touch-pan-y group-data-expanded/sheet:overflow-y-auto\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nexport type BottomSheetCloseProps = Omit<Drawer.Close.Props, \"className\"> & { className?: string };\n\nexport function BottomSheetClose({ className, ...rest }: BottomSheetCloseProps) {\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] text-fg-2\",\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.97] active:duration-75\",\n        \"before:absolute before:-inset-x-1 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/bottom-sheet"
}
