HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
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