spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import { X } from "lucide-react";4import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type PointerEvent as RPointerEvent, type ReactNode } from "react";5import { createPortal } from "react-dom";6import { cn } from "./cn";7import { IconButton } from "./button";89const noop = (): (() => void) => () => {};1011export interface SheetProps {12 open: boolean;13 onClose: () => void;14 title?: ReactNode;15 description?: ReactNode;16 children: ReactNode;17 footer?: ReactNode;18 /** desktop side (≥ md); mobile is always a bottom sheet */19 side?: "right" | "left";20 /** desktop width */21 width?: number;22 /** mobile: initial height as vh (default 70), drag handle allows expand/collapse */23 heightVh?: number;24 className?: string;25 /** render without backdrop (map quick-profile) so the map stays interactive */26 modal?: boolean;27 id?: string;28}2930const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';3132/**33 * Bottom sheet on mobile (drag handle: swipe down to close, up to expand), side drawer on ≥ md.34 * Focus trap, ESC to close, restores focus, locks body scroll when modal.35 */36export function Sheet({ open, onClose, title, description, children, footer, side = "right", width = 400, heightVh = 70, className, modal = true, id }: SheetProps) {37 const panelRef = useRef<HTMLDivElement>(null);38 const lastActive = useRef<Element | null>(null);39 const mounted = useSyncExternalStore(noop, () => true, () => false);40 const [drag, setDrag] = useState<{ startY: number; dy: number } | null>(null);41 const [expanded, setExpanded] = useState(false);42 const labelId = useId();43 const descId = useId();4445 useEffect(() => {46 if (!open) return;47 lastActive.current = document.activeElement;48 const prevOverflow = document.body.style.overflow;49 if (modal) document.body.style.overflow = "hidden";50 const t = setTimeout(() => {51 const first = panelRef.current?.querySelector<HTMLElement>(FOCUSABLE);52 (first ?? panelRef.current)?.focus();53 }, 20);54 const onKey = (e: KeyboardEvent) => {55 if (e.key === "Escape") {56 e.stopPropagation();57 onClose();58 }59 if (e.key === "Tab" && panelRef.current) {60 const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((n) => n.offsetParent !== null);61 if (!nodes.length) return;62 const first = nodes[0]!;63 const last = nodes[nodes.length - 1]!;64 if (e.shiftKey && document.activeElement === first) {65 e.preventDefault();66 last.focus();67 } else if (!e.shiftKey && document.activeElement === last) {68 e.preventDefault();69 first.focus();70 }71 }72 };73 document.addEventListener("keydown", onKey);74 return () => {75 clearTimeout(t);76 document.removeEventListener("keydown", onKey);77 if (modal) document.body.style.overflow = prevOverflow;78 setExpanded(false);79 (lastActive.current as HTMLElement | null)?.focus?.();80 };81 }, [open, onClose, modal]);8283 const onPointerDown = useCallback((e: RPointerEvent<HTMLDivElement>) => {84 setDrag({ startY: e.clientY, dy: 0 });85 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);86 }, []);87 const onPointerMove = useCallback((e: RPointerEvent<HTMLDivElement>) => {88 setDrag((d) => (d ? { ...d, dy: e.clientY - d.startY } : d));89 }, []);90 const onPointerUp = useCallback(() => {91 setDrag((d) => {92 if (d) {93 if (d.dy > 80) onClose();94 else if (d.dy < -60) setExpanded(true);95 else if (d.dy > 30 && expanded) setExpanded(false);96 }97 return null;98 });99 }, [onClose, expanded]);100101 if (!mounted || !open) return null;102103 const isDesktop = typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches;104 const translate = drag && drag.dy > 0 && !isDesktop ? `translateY(${drag.dy}px)` : undefined;105106 const node = (107 <div className={cn("fixed inset-0 z-[80] flex", !modal && "pointer-events-none")} role="presentation" data-sheet="">108 {modal && <button type="button" aria-label="Close" className="absolute inset-0 bg-ink/30 backdrop-blur-[1px] animate-fade-in dark:bg-black/50" onClick={onClose} />}109 <div110 ref={panelRef}111 id={id}112 role="dialog"113 aria-modal={modal}114 aria-labelledby={title ? labelId : undefined}115 aria-describedby={description ? descId : undefined}116 tabIndex={-1}117 style={{118 transform: translate,119 transition: drag ? "none" : undefined,120 ["--sheet-w" as string]: `${width}px`,121 ["--sheet-h" as string]: expanded ? "94dvh" : `${heightVh}dvh`,122 }}123 className={cn(124 "pointer-events-auto absolute flex flex-col border-hair bg-surface text-ink outline-none",125 // mobile: bottom sheet126 "inset-x-0 bottom-0 max-h-[94dvh] h-[var(--sheet-h)] rounded-t-md border-t animate-sheet-up",127 // desktop: side drawer128 "md:inset-y-0 md:bottom-auto md:h-auto md:max-h-none md:w-[var(--sheet-w)] md:max-w-[92vw] md:rounded-none md:border-t-0 md:animate-sheet-side",129 side === "right" ? "md:left-auto md:right-0 md:border-l" : "md:right-auto md:left-0 md:border-r",130 className,131 )}132 >133 <div className="flex touch-none justify-center pt-2 md:hidden" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} aria-hidden>134 <span className="block h-1 w-10 rounded-full bg-hair-2" />135 </div>136 <div className="flex items-start justify-between gap-3 px-4 pb-2 pt-2 md:border-b md:border-hair md:py-3">137 <div className="min-w-0">138 {title && (139 <h2 id={labelId} className="truncate text-[15px] font-semibold leading-tight">140 {title}141 </h2>142 )}143 {description && (144 <p id={descId} className="mt-0.5 text-[12px] text-ink-3">145 {description}146 </p>147 )}148 </div>149 <IconButton label="Close" size="md" onClick={onClose} className="-mr-2 -mt-1">150 <X className="size-4" />151 </IconButton>152 </div>153 <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4">{children}</div>154 {footer && <div className="border-t border-hair px-4 py-3 safe-b">{footer}</div>}155 </div>156 </div>157 );158 return createPortal(node, document.body);159}160