"use client"; import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { useIsMobile, usePrefersReducedMotion } from "@/lib/client/hooks"; /** * BottomSheet — native-feeling mobile sheet built on Radix Dialog (focus trap, a11y, Esc). * * - drag handle + swipe-to-dismiss (pointer events; direction-locked so inner scrolling still works) * - snap points: `snap="content" | "half" | "full"` (max height); `expandable` lets the user drag up to full * - background blur, safe-area padding, `100dvh`-based sizing, keyboard-aware (`--kb`) * * Use `ResponsiveDialog` when the same content must be a centered dialog on desktop. */ export type SheetSnap = "content" | "half" | "full"; export interface BottomSheetProps { open: boolean; onOpenChange: (open: boolean) => void; title?: React.ReactNode; description?: React.ReactNode; children: React.ReactNode; /** Sticky footer (e.g. primary action). Rendered above the safe area. */ footer?: React.ReactNode; snap?: SheetSnap; /** Allow dragging up from `content`/`half` to `full`. */ expandable?: boolean; /** Hide the visual title but keep it for screen readers. */ hideTitle?: boolean; showClose?: boolean; className?: string; bodyClassName?: string; /** Prevent closing by tapping the overlay / swiping. */ modal?: boolean; /** Remove body padding (lists that manage their own). */ flush?: boolean; /** Let a fixed header (e.g. search input) sit above the scrolling body. */ header?: React.ReactNode; } const SNAP_MAX: Record = { content: "max-h-[min(88dvh,calc(100dvh-var(--kb)-16px))]", half: "h-[min(56dvh,calc(100dvh-var(--kb)-16px))]", full: "h-[calc(100dvh-var(--kb)-var(--sat)-8px)]", }; export function BottomSheet({ open, onOpenChange, title, description, children, footer, snap = "content", expandable = true, hideTitle, showClose = false, className, bodyClassName, modal = false, flush, header }: BottomSheetProps) { const reduced = usePrefersReducedMotion(); const [current, setCurrent] = React.useState(snap); const [dragY, setDragY] = React.useState(0); const [dragging, setDragging] = React.useState(false); const bodyRef = React.useRef(null); const startY = React.useRef(null); const startScrollTop = React.useRef(0); const axis = React.useRef<"y" | "none" | null>(null); React.useEffect(() => { if (open) { // eslint-disable-next-line react-hooks/set-state-in-effect setCurrent(snap); setDragY(0); } }, [open, snap]); const onPointerDown = (e: React.PointerEvent, fromHandle: boolean) => { if (e.pointerType === "mouse" && !fromHandle) return; startY.current = e.clientY; startScrollTop.current = bodyRef.current?.scrollTop ?? 0; axis.current = fromHandle ? "y" : null; }; const onPointerMove = (e: React.PointerEvent) => { if (startY.current === null) return; const dy = e.clientY - startY.current; if (axis.current === null) { // Only take over when the body is scrolled to the top and the user pulls down. if (Math.abs(dy) < 8) return; axis.current = dy > 0 && startScrollTop.current <= 0 ? "y" : "none"; } if (axis.current !== "y") return; if (dy > 0) { setDragging(true); setDragY(dy); } else if (expandable && current !== "full") { setDragging(true); setDragY(Math.max(dy, -80)); } }; const onPointerUp = () => { if (startY.current === null) return; const dy = dragY; startY.current = null; axis.current = null; setDragging(false); setDragY(0); if (dy > 110 && !modal) onOpenChange(false); else if (dy < -40 && expandable && current !== "full") setCurrent("full"); else if (dy > 60 && current === "full" && snap !== "full") setCurrent(snap); }; return ( {} : onOpenChange}> e.preventDefault()} className={cn( "fixed inset-x-0 bottom-0 z-50 flex flex-col bg-bg-elevated text-fg shadow-sheet focus:outline-none", "rounded-t-[22px] border-t border-border/70", SNAP_MAX[current], !dragging && !reduced && "data-[state=open]:animate-sheet-in data-[state=closed]:animate-sheet-out transition-[height,max-height] duration-300 ease-[var(--ease-out-soft)]", className, )} style={dragging ? { transform: `translateY(${Math.max(0, dragY)}px)` } : undefined} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} > {/* Handle: always draggable */}
onPointerDown(e, true)} aria-hidden>
{title ?? "Sheet"} {description ? {description} : null} {showClose ? ( ) : null}
{header ?
{header}
: null}
onPointerDown(e, false)} className={cn("min-h-0 flex-1 overflow-y-auto contain-scroll scrollbar-thin", flush ? "" : "px-4 pb-4", !footer && "pb-[max(16px,var(--sab))]", bodyClassName)}> {children}
{footer ?
{footer}
: null} ); } /* ------------------------------------------------------------------------------------------------ */ export interface ResponsiveDialogProps extends Omit { /** Desktop width. */ size?: "sm" | "md" | "lg" | "xl" | "full"; /** Mobile snap point (default content). */ snap?: SheetSnap; expandable?: boolean; /** Desktop variant: centered dialog (default) or right side panel. */ desktop?: "dialog" | "panel"; /** Desktop: remove the default body padding. */ desktopFlush?: boolean; } const SIZES = { sm: "sm:max-w-sm", md: "sm:max-w-lg", lg: "sm:max-w-2xl", xl: "sm:max-w-4xl", full: "sm:max-w-[min(96vw,1200px)]" }; /** * Bottom sheet on phones, centered dialog (or side panel) on ≥ md. One API, two native-feeling shapes. */ export function ResponsiveDialog({ size = "md", desktop = "dialog", desktopFlush, ...props }: ResponsiveDialogProps) { const isMobile = useIsMobile(); if (isMobile) return ; const { open, onOpenChange, title, description, children, footer, hideTitle, showClose = true, className, bodyClassName, modal, flush, header } = props; const isPanel = desktop === "panel"; return ( {} : onOpenChange}>
{title ?? "Dialog"} {description ? {description} : null}
{showClose ? ( ) : null} {header ?
{header}
: null}
{children}
{footer ?
{footer}
: null}
); } /* ------------------------------------------------------------------------------------------------ */ export interface ActionSheetItem { key: string; label: React.ReactNode; icon?: React.ReactNode; hint?: React.ReactNode; onSelect: () => void; destructive?: boolean; disabled?: boolean; /** Renders a check mark (selection lists). */ selected?: boolean; } export interface ActionSheetProps { open: boolean; onOpenChange: (open: boolean) => void; title?: React.ReactNode; description?: React.ReactNode; items: (ActionSheetItem | "separator")[]; /** Optional content above the list (e.g. a preview of the message). */ children?: React.ReactNode; cancelLabel?: string; } /** * iOS-style action list inside a bottom sheet. Use for long-press / "more" menus on phones. * On desktop callers should keep using DropdownMenu; `useIsMobile()` decides. */ export function ActionSheet({ open, onOpenChange, title, description, items, children, cancelLabel = "Cancel" }: ActionSheetProps) { return ( {children ?
{children}
: null}
{items.map((it, i) => it === "separator" ? (
) : ( ), )}
); }