'use client'; /** * SidePanel — a non-modal side drawer. * * Use for inspector panels, playgrounds, filters — anywhere you want a * slide-in surface that does NOT lock out the rest of the page. The * surrounding UI stays clickable and focusable; only Esc (optional) and * the close button dismiss the panel. * * Differences from the existing ``Drawer``: * - ``modal={false}`` — no focus trap, no scroll-lock on . * - No backdrop overlay. * - No ``shouldScaleBackground`` (vaul's iOS-style fancy scale looks * wrong for side panels — reserved for bottom sheets). * - Opinionated for ``right``/``left`` directions; vaul still drives * the transform + swipe-to-close gesture underneath. * * Layout is intentionally similar to our Sheet/Drawer components so * callers feel at home. */ import * as React from 'react'; import { Drawer as DrawerPrimitive } from 'vaul'; import { X } from 'lucide-react'; import { cn } from '../../../lib/utils'; // ─── Root ───────────────────────────────────────────────────────────────────── export interface SidePanelProps { open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode; /** ``'right'`` (default) slides in from the right edge; ``'left'`` from the left. */ side?: 'right' | 'left'; /** Close when the user presses Escape. Default ``true``. Disable when * the parent wants custom handling or Esc is bound to something else. */ closeOnEsc?: boolean; } const SidePanel: React.FC & { Content: typeof SidePanelContent; Header: typeof SidePanelHeader; Title: typeof SidePanelTitle; Description: typeof SidePanelDescription; Body: typeof SidePanelBody; Footer: typeof SidePanelFooter; Close: typeof SidePanelClose; } = ({ open, onOpenChange, children, side = 'right', closeOnEsc = true }) => { // Esc handling: vaul's built-in closes on Esc only when modal=true. // We're non-modal, so we install our own listener — gated on ``open`` // to avoid swallowing Esc globally while the panel is closed. React.useEffect(() => { if (!open || !closeOnEsc) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [open, closeOnEsc, onOpenChange]); return ( `` styles (position:fixed, // overflow:hidden, pointer-events:none) to support swipe-to- // close. For a non-modal side panel that behaviour is wrong — // it disables every interaction outside the panel including // page scroll. ``noBodyStyles`` tells vaul to keep its hands // off the document. noBodyStyles disablePreventScroll dismissible > {children} ); }; SidePanel.displayName = 'SidePanel'; // Carries the side down to Content so it can place border + transform correctly. const SidePanelSideContext = React.createContext<'right' | 'left'>('right'); // ─── Content ────────────────────────────────────────────────────────────────── export interface SidePanelContentProps extends React.ComponentPropsWithoutRef { /** CSS width (any valid value: ``'440px'``, ``'32rem'``, ``'min(440px, 90vw)'``). * Default ``'440px'``. Override via ``className`` with ``w-*`` if you * prefer Tailwind. */ width?: string; } const SidePanelContent = React.forwardRef< React.ElementRef, SidePanelContentProps >(({ className, children, width = '440px', style, ...props }, ref) => { const side = React.useContext(SidePanelSideContext); const positioning = side === 'right' ? 'inset-y-0 right-0 border-l' : 'inset-y-0 left-0 border-r'; return ( {children} ); }); SidePanelContent.displayName = 'SidePanelContent'; // ─── Header / Title / Description ───────────────────────────────────────────── const SidePanelHeader = React.forwardRef>( ({ className, ...props }, ref) => (
), ); SidePanelHeader.displayName = 'SidePanelHeader'; const SidePanelTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); SidePanelTitle.displayName = 'SidePanelTitle'; const SidePanelDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); SidePanelDescription.displayName = 'SidePanelDescription'; // ─── Body / Footer ──────────────────────────────────────────────────────────── const SidePanelBody = React.forwardRef>( ({ className, ...props }, ref) => (
), ); SidePanelBody.displayName = 'SidePanelBody'; const SidePanelFooter = React.forwardRef>( ({ className, ...props }, ref) => (
), ); SidePanelFooter.displayName = 'SidePanelFooter'; // ─── Close ──────────────────────────────────────────────────────────────────── export interface SidePanelCloseProps extends Omit, 'type' | 'children'> { /** Accessible label. Default ``"Close panel"``. */ label?: string; /** Replace the default ``X`` icon with custom content. */ children?: React.ReactNode; } const SidePanelClose = React.forwardRef( ({ className, label = 'Close panel', children, ...props }, ref) => ( ), ); SidePanelClose.displayName = 'SidePanelClose'; // Attach subcomponents to the root for ergonomic dot-access. SidePanel.Content = SidePanelContent; SidePanel.Header = SidePanelHeader; SidePanel.Title = SidePanelTitle; SidePanel.Description = SidePanelDescription; SidePanel.Body = SidePanelBody; SidePanel.Footer = SidePanelFooter; SidePanel.Close = SidePanelClose; export { SidePanel, SidePanelContent, SidePanelHeader, SidePanelTitle, SidePanelDescription, SidePanelBody, SidePanelFooter, SidePanelClose, };