SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
3.8 KB · 106 lines tsx
Raw Blame History
1'use client';2import { X } from 'lucide-react';3import { type ReactNode, useEffect, useRef } from 'react';4import { cn } from '@/lib/cn';56/**7 * Sheet / drawer primitive shared by the event drawer, filter sheets and the mobile "More" menu.8 * `side="auto"` (default): right-side panel ≥ lg, bottom sheet below. Focus trap, Esc, backdrop click, body scroll lock.9 */10export function Sheet({11  open,12  onClose,13  title,14  eyebrow,15  children,16  side = 'auto',17  width = 'lg:w-[30rem]',18  className,19  footer,20  id,21  headerExtra,22}: {23  open: boolean;24  onClose: () => void;25  title?: ReactNode;26  eyebrow?: ReactNode;27  children: ReactNode;28  side?: 'auto' | 'right' | 'bottom';29  width?: string;30  className?: string;31  footer?: ReactNode;32  id?: string;33  headerExtra?: ReactNode;34}) {35  const panel = useRef<HTMLDivElement>(null);36  const closeBtn = useRef<HTMLButtonElement>(null);37  const restore = useRef<HTMLElement | null>(null);3839  useEffect(() => {40    if (!open) return;41    restore.current = document.activeElement as HTMLElement | null;42    const prevOverflow = document.body.style.overflow;43    document.body.style.overflow = 'hidden';44    const t = setTimeout(() => closeBtn.current?.focus(), 20);45    const onKey = (e: KeyboardEvent) => {46      if (e.key === 'Escape') {47        e.stopPropagation();48        onClose();49      } else if (e.key === 'Tab' && panel.current) {50        const nodes = panel.current.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])');51        if (!nodes.length) return;52        const first = nodes[0] as HTMLElement;53        const last = nodes[nodes.length - 1] as HTMLElement;54        if (e.shiftKey && document.activeElement === first) {55          e.preventDefault();56          last.focus();57        } else if (!e.shiftKey && document.activeElement === last) {58          e.preventDefault();59          first.focus();60        }61      }62    };63    document.addEventListener('keydown', onKey);64    return () => {65      clearTimeout(t);66      document.removeEventListener('keydown', onKey);67      document.body.style.overflow = prevOverflow;68      restore.current?.focus?.();69    };70  }, [open, onClose]);7172  if (!open) return null;73  const right = side === 'right';74  return (75    <div className="fixed inset-0 z-[90] flex bg-black/45 backdrop-blur-[1px]" onClick={onClose} role="presentation">76      <div77        ref={panel}78        id={id}79        role="dialog"80        aria-modal="true"81        onClick={(e) => e.stopPropagation()}82        className={cn(83          'sheet-enter panel flex flex-col overflow-hidden shadow-2xl',84          !right && 'absolute inset-x-0 bottom-0 max-h-[88dvh] rounded-b-none rounded-t-lg',85          side === 'auto' && `lg:inset-y-0 lg:right-0 lg:left-auto lg:h-full lg:max-h-none lg:rounded-none lg:border-y-0 lg:border-r-0 ${width} lg:max-w-[92vw]`,86          right && `absolute inset-y-0 right-0 h-full max-w-[92vw] rounded-none border-y-0 border-r-0 ${width}`,87          className,88        )}89      >90        <div className="flex items-start gap-3 border-b border-rule px-4 py-3">91          <div className="min-w-0 flex-1">92            {eyebrow && <p className="eyebrow">{eyebrow}</p>}93            {title && <h2 className="mt-0.5 text-[15px] font-semibold leading-snug text-ink">{title}</h2>}94          </div>95          {headerExtra}96          <button ref={closeBtn} type="button" onClick={onClose} className="-mr-1 flex size-11 shrink-0 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close">97            <X className="size-5" aria-hidden />98          </button>99        </div>100        <div className="scrollbar-thin flex-1 overflow-y-auto px-4 py-3">{children}</div>101        {footer && <div className="safe-bottom border-t border-rule px-4 py-3">{footer}</div>}102      </div>103    </div>104  );105}106