TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import * as DialogPrimitive from "@radix-ui/react-dialog";4import { X } from "lucide-react";5import { cn } from "@/lib/utils";6import { useIsMobile, usePrefersReducedMotion } from "@/lib/client/hooks";78/**9 * BottomSheet — native-feeling mobile sheet built on Radix Dialog (focus trap, a11y, Esc).10 *11 * - drag handle + swipe-to-dismiss (pointer events; direction-locked so inner scrolling still works)12 * - snap points: `snap="content" | "half" | "full"` (max height); `expandable` lets the user drag up to full13 * - background blur, safe-area padding, `100dvh`-based sizing, keyboard-aware (`--kb`)14 *15 * Use `ResponsiveDialog` when the same content must be a centered dialog on desktop.16 */17export type SheetSnap = "content" | "half" | "full";1819export interface BottomSheetProps {20 open: boolean;21 onOpenChange: (open: boolean) => void;22 title?: React.ReactNode;23 description?: React.ReactNode;24 children: React.ReactNode;25 /** Sticky footer (e.g. primary action). Rendered above the safe area. */26 footer?: React.ReactNode;27 snap?: SheetSnap;28 /** Allow dragging up from `content`/`half` to `full`. */29 expandable?: boolean;30 /** Hide the visual title but keep it for screen readers. */31 hideTitle?: boolean;32 showClose?: boolean;33 className?: string;34 bodyClassName?: string;35 /** Prevent closing by tapping the overlay / swiping. */36 modal?: boolean;37 /** Remove body padding (lists that manage their own). */38 flush?: boolean;39 /** Let a fixed header (e.g. search input) sit above the scrolling body. */40 header?: React.ReactNode;41}4243const SNAP_MAX: Record<SheetSnap, string> = {44 content: "max-h-[min(88dvh,calc(100dvh-var(--kb)-16px))]",45 half: "h-[min(56dvh,calc(100dvh-var(--kb)-16px))]",46 full: "h-[calc(100dvh-var(--kb)-var(--sat)-8px)]",47};4849export function BottomSheet({ open, onOpenChange, title, description, children, footer, snap = "content", expandable = true, hideTitle, showClose = false, className, bodyClassName, modal = false, flush, header }: BottomSheetProps) {50 const reduced = usePrefersReducedMotion();51 const [current, setCurrent] = React.useState<SheetSnap>(snap);52 const [dragY, setDragY] = React.useState(0);53 const [dragging, setDragging] = React.useState(false);54 const bodyRef = React.useRef<HTMLDivElement>(null);55 const startY = React.useRef<number | null>(null);56 const startScrollTop = React.useRef(0);57 const axis = React.useRef<"y" | "none" | null>(null);5859 React.useEffect(() => {60 if (open) {61 // eslint-disable-next-line react-hooks/set-state-in-effect62 setCurrent(snap);63 setDragY(0);64 }65 }, [open, snap]);6667 const onPointerDown = (e: React.PointerEvent, fromHandle: boolean) => {68 if (e.pointerType === "mouse" && !fromHandle) return;69 startY.current = e.clientY;70 startScrollTop.current = bodyRef.current?.scrollTop ?? 0;71 axis.current = fromHandle ? "y" : null;72 };73 const onPointerMove = (e: React.PointerEvent) => {74 if (startY.current === null) return;75 const dy = e.clientY - startY.current;76 if (axis.current === null) {77 // Only take over when the body is scrolled to the top and the user pulls down.78 if (Math.abs(dy) < 8) return;79 axis.current = dy > 0 && startScrollTop.current <= 0 ? "y" : "none";80 }81 if (axis.current !== "y") return;82 if (dy > 0) {83 setDragging(true);84 setDragY(dy);85 } else if (expandable && current !== "full") {86 setDragging(true);87 setDragY(Math.max(dy, -80));88 }89 };90 const onPointerUp = () => {91 if (startY.current === null) return;92 const dy = dragY;93 startY.current = null;94 axis.current = null;95 setDragging(false);96 setDragY(0);97 if (dy > 110 && !modal) onOpenChange(false);98 else if (dy < -40 && expandable && current !== "full") setCurrent("full");99 else if (dy > 60 && current === "full" && snap !== "full") setCurrent(snap);100 };101102 return (103 <DialogPrimitive.Root open={open} onOpenChange={modal ? () => {} : onOpenChange}>104 <DialogPrimitive.Portal>105 <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[3px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />106 <DialogPrimitive.Content107 onOpenAutoFocus={(e) => e.preventDefault()}108 className={cn(109 "fixed inset-x-0 bottom-0 z-50 flex flex-col bg-bg-elevated text-fg shadow-sheet focus:outline-none",110 "rounded-t-[22px] border-t border-border/70",111 SNAP_MAX[current],112 !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)]",113 className,114 )}115 style={dragging ? { transform: `translateY(${Math.max(0, dragY)}px)` } : undefined}116 onPointerMove={onPointerMove}117 onPointerUp={onPointerUp}118 onPointerCancel={onPointerUp}119 >120 {/* Handle: always draggable */}121 <div className="shrink-0 cursor-grab touch-none select-none active:cursor-grabbing" onPointerDown={(e) => onPointerDown(e, true)} aria-hidden>122 <div className="sheet-handle" />123 </div>124 <div className={cn("shrink-0 px-5", title && !hideTitle ? "pb-2 pt-1" : "")}>125 <DialogPrimitive.Title className={cn("text-[17px] font-semibold leading-6 tracking-tight", hideTitle && "sr-only")}>{title ?? "Sheet"}</DialogPrimitive.Title>126 {description ? <DialogPrimitive.Description className={cn("mt-0.5 text-[13px] text-fg-muted", hideTitle && "sr-only")}>{description}</DialogPrimitive.Description> : null}127 {showClose ? (128 <DialogPrimitive.Close className="tap absolute right-3 top-3 rounded-full bg-bg-muted p-1.5 text-fg-muted hover:text-fg" aria-label="Close">129 <X className="size-4" />130 </DialogPrimitive.Close>131 ) : null}132 </div>133 {header ? <div className="shrink-0">{header}</div> : null}134 <div ref={bodyRef} onPointerDown={(e) => 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)}>135 {children}136 </div>137 {footer ? <div className="shrink-0 border-t border-border bg-bg-elevated px-4 pt-3 pb-[max(12px,var(--sab))]">{footer}</div> : null}138 </DialogPrimitive.Content>139 </DialogPrimitive.Portal>140 </DialogPrimitive.Root>141 );142}143144/* ------------------------------------------------------------------------------------------------ */145146export interface ResponsiveDialogProps extends Omit<BottomSheetProps, "snap" | "expandable"> {147 /** Desktop width. */148 size?: "sm" | "md" | "lg" | "xl" | "full";149 /** Mobile snap point (default content). */150 snap?: SheetSnap;151 expandable?: boolean;152 /** Desktop variant: centered dialog (default) or right side panel. */153 desktop?: "dialog" | "panel";154 /** Desktop: remove the default body padding. */155 desktopFlush?: boolean;156}157158const 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)]" };159160/**161 * Bottom sheet on phones, centered dialog (or side panel) on ≥ md. One API, two native-feeling shapes.162 */163export function ResponsiveDialog({ size = "md", desktop = "dialog", desktopFlush, ...props }: ResponsiveDialogProps) {164 const isMobile = useIsMobile();165 if (isMobile) return <BottomSheet {...props} />;166 const { open, onOpenChange, title, description, children, footer, hideTitle, showClose = true, className, bodyClassName, modal, flush, header } = props;167 const isPanel = desktop === "panel";168 return (169 <DialogPrimitive.Root open={open} onOpenChange={modal ? () => {} : onOpenChange}>170 <DialogPrimitive.Portal>171 <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />172 <DialogPrimitive.Content173 className={cn(174 "fixed z-50 flex flex-col border border-border bg-bg-elevated text-fg shadow-lg focus:outline-none",175 isPanel176 ? "inset-y-2 right-2 w-[min(440px,calc(100vw-16px))] rounded-2xl data-[state=open]:animate-fade-up data-[state=closed]:animate-fade-out"177 : cn("left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 rounded-2xl max-h-[min(86vh,900px)] data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out", SIZES[size]),178 className,179 )}180 >181 <div className={cn("shrink-0", title && !hideTitle ? "px-6 pt-5 pb-3" : "")}>182 <DialogPrimitive.Title className={cn("text-base font-semibold leading-6 tracking-tight", hideTitle && "sr-only")}>{title ?? "Dialog"}</DialogPrimitive.Title>183 {description ? <DialogPrimitive.Description className={cn("mt-0.5 text-sm text-fg-muted", hideTitle && "sr-only")}>{description}</DialogPrimitive.Description> : null}184 </div>185 {showClose ? (186 <DialogPrimitive.Close className="absolute right-3 top-3 rounded-md p-1.5 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg" aria-label="Close">187 <X className="size-4" />188 </DialogPrimitive.Close>189 ) : null}190 {header ? <div className="shrink-0">{header}</div> : null}191 <div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", flush || desktopFlush ? "" : "px-6 pb-6", bodyClassName)}>{children}</div>192 {footer ? <div className="shrink-0 border-t border-border px-6 py-3">{footer}</div> : null}193 </DialogPrimitive.Content>194 </DialogPrimitive.Portal>195 </DialogPrimitive.Root>196 );197}198199/* ------------------------------------------------------------------------------------------------ */200201export interface ActionSheetItem {202 key: string;203 label: React.ReactNode;204 icon?: React.ReactNode;205 hint?: React.ReactNode;206 onSelect: () => void;207 destructive?: boolean;208 disabled?: boolean;209 /** Renders a check mark (selection lists). */210 selected?: boolean;211}212213export interface ActionSheetProps {214 open: boolean;215 onOpenChange: (open: boolean) => void;216 title?: React.ReactNode;217 description?: React.ReactNode;218 items: (ActionSheetItem | "separator")[];219 /** Optional content above the list (e.g. a preview of the message). */220 children?: React.ReactNode;221 cancelLabel?: string;222}223224/**225 * iOS-style action list inside a bottom sheet. Use for long-press / "more" menus on phones.226 * On desktop callers should keep using DropdownMenu; `useIsMobile()` decides.227 */228export function ActionSheet({ open, onOpenChange, title, description, items, children, cancelLabel = "Cancel" }: ActionSheetProps) {229 return (230 <BottomSheet open={open} onOpenChange={onOpenChange} title={title} description={description} hideTitle={!title} expandable={false} flush bodyClassName="px-3 pb-[max(12px,var(--sab))]">231 {children ? <div className="px-2 pb-3">{children}</div> : null}232 <div className="overflow-hidden rounded-2xl bg-bg-subtle">233 {items.map((it, i) =>234 it === "separator" ? (235 <div key={`sep-${i}`} className="h-2 bg-bg" />236 ) : (237 <button238 key={it.key}239 type="button"240 disabled={it.disabled}241 onClick={() => {242 onOpenChange(false);243 // Let the sheet start closing before running navigation-heavy handlers.244 setTimeout(it.onSelect, 10);245 }}246 className={cn(247 "flex min-h-[52px] w-full items-center gap-3 px-4 text-left text-[16px] transition-colors active:bg-bg-muted disabled:opacity-40 [&_svg]:size-[18px] [&_svg]:shrink-0",248 i > 0 && items[i - 1] !== "separator" && "border-t border-hairline",249 it.destructive ? "text-danger" : "text-fg",250 )}251 >252 <span className={cn("text-fg-muted", it.destructive && "text-danger")}>{it.icon}</span>253 <span className="min-w-0 flex-1 truncate">{it.label}</span>254 {it.hint ? <span className="text-[13px] text-fg-subtle">{it.hint}</span> : null}255 {it.selected ? <span className="text-accent">✓</span> : null}256 </button>257 ),258 )}259 </div>260 <button type="button" onClick={() => onOpenChange(false)} className="mt-2 flex min-h-[52px] w-full items-center justify-center rounded-2xl bg-bg-subtle text-[16px] font-semibold text-fg active:bg-bg-muted">261 {cancelLabel}262 </button>263 </BottomSheet>264 );265}266