SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
13 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
4.2 KB · 112 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 evidence drawer, the terminal filter/inspector 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 * `aria-modal`. Renders nothing when closed (server/client markup match: mount it always, drive `open`).10 */11export function Sheet({12  open,13  onClose,14  title,15  eyebrow,16  children,17  side = 'auto',18  width = 'lg:w-[26rem]',19  className,20  footer,21  labelledBy,22  id,23}: {24  open: boolean;25  onClose: () => void;26  title?: ReactNode;27  eyebrow?: ReactNode;28  children: ReactNode;29  side?: 'auto' | 'right' | 'bottom';30  /** Tailwind width class for the right-side panel (`lg:w-[26rem]` for `auto`, `w-[…]` when `side="right"`). */31  width?: string;32  className?: string;33  footer?: ReactNode;34  labelledBy?: string;35  id?: string;36}) {37  const panel = useRef<HTMLDivElement>(null);38  const closeBtn = useRef<HTMLButtonElement>(null);39  const restore = useRef<HTMLElement | null>(null);4041  useEffect(() => {42    if (!open) return;43    restore.current = document.activeElement as HTMLElement | null;44    const prevOverflow = document.body.style.overflow;45    document.body.style.overflow = 'hidden';46    const t = setTimeout(() => closeBtn.current?.focus(), 20);47    const onKey = (e: KeyboardEvent) => {48      if (e.key === 'Escape') {49        e.stopPropagation();50        onClose();51      } else if (e.key === 'Tab' && panel.current) {52        const nodes = panel.current.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])');53        if (!nodes.length) return;54        const first = nodes[0] as HTMLElement;55        const last = nodes[nodes.length - 1] as HTMLElement;56        if (e.shiftKey && document.activeElement === first) {57          e.preventDefault();58          last.focus();59        } else if (!e.shiftKey && document.activeElement === last) {60          e.preventDefault();61          first.focus();62        }63      }64    };65    document.addEventListener('keydown', onKey);66    return () => {67      clearTimeout(t);68      document.removeEventListener('keydown', onKey);69      document.body.style.overflow = prevOverflow;70      restore.current?.focus?.();71    };72  }, [open, onClose]);7374  if (!open) return null;75  const right = side === 'right';76  const bottom = side === 'bottom';77  return (78    <div className="fixed inset-0 z-[90] flex bg-black/45 backdrop-blur-[1px]" onClick={onClose} role="presentation">79      <div80        ref={panel}81        id={id}82        role="dialog"83        aria-modal="true"84        aria-labelledby={labelledBy}85        onClick={(e) => e.stopPropagation()}86        className={cn(87          'sheet-enter panel flex flex-col overflow-hidden shadow-2xl',88          // bottom sheet (mobile / forced)89          !right && 'absolute inset-x-0 bottom-0 max-h-[86dvh] rounded-b-none rounded-t-lg',90          // right drawer ≥ lg (auto) or forced91          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]`,92          right && `absolute inset-y-0 right-0 h-full max-w-[92vw] rounded-none border-y-0 border-r-0 ${width}`,93          bottom && 'lg:max-h-[86dvh]',94          className,95        )}96      >97        <div className="flex items-start gap-3 border-b border-rule px-4 py-3">98          <div className="min-w-0 flex-1">99            {eyebrow && <p className="eyebrow">{eyebrow}</p>}100            {title && <h2 className="mt-0.5 truncate text-[15px] font-semibold text-ink">{title}</h2>}101          </div>102          <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">103            <X className="size-5" aria-hidden />104          </button>105        </div>106        <div className="scrollbar-thin flex-1 overflow-y-auto px-4 py-3">{children}</div>107        {footer && <div className="safe-bottom border-t border-rule px-4 py-3">{footer}</div>}108      </div>109    </div>110  );111}112