'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,
};