SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
6.5 KB · 160 lines tsx
Raw Blame History
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