"use client"; import { cn } from "@prototype/lib/utils"; import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { IconButton } from "./icon-button"; export const DEFAULT_SIDEBAR_WIDTH = 400; export const MIN_SIDEBAR_WIDTH = 280; export const MAX_SIDEBAR_WIDTH = 720; export function clampSidebarWidth(width: number) { return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width)); } /** e.g. `data-comments-sidebar` + `"open"` → `data-comments-sidebar-open` */ export function getSidebarStateAttribute( dataAttribute: string, suffix: "open" | "inline" | "resizing", ): string { const base = dataAttribute.startsWith("data-") ? dataAttribute.slice(5) : dataAttribute; return `data-${base}-${suffix}`; } export const SIDEBAR_TITLE_CLASS = "font-display truncate text-sm font-semibold text-[var(--tool-chrome-text-heading)]"; export const SIDEBAR_ICON_BUTTON_CLASS = "cursor-pointer text-[var(--tool-chrome-icon)] hover:bg-[var(--tool-chrome-gray-highlight)] hover:text-[var(--tool-chrome-icon-hover)] focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"; type SidebarProps = { open: boolean; /** Plain title string, or custom header title content (e.g. a switcher). */ text: ReactNode; onClose: () => void; children: ReactNode; titleAddon?: ReactNode; headerActions?: ReactNode; ariaLabel?: string; dataAttribute?: string; widthCssVar?: string; className?: string; /** * When true, the panel floats over page content instead of reserving inline * layout width. It is taken out of flow (clipped, transform slide-in) so * opening/closing it never reflows or squishes the parent. Use this for * selector-style panels that should overlay, not push, the page. */ overlay?: boolean; }; export function Sidebar({ open, text, onClose, children, titleAddon, headerActions, ariaLabel = "Sidebar", dataAttribute = "data-platform-sidebar", widthCssVar = "--platform-sidebar-width", className, overlay = false, }: SidebarProps) { const openAttr = getSidebarStateAttribute(dataAttribute, "open"); const inlineAttr = getSidebarStateAttribute(dataAttribute, "inline"); const resizingAttr = getSidebarStateAttribute(dataAttribute, "resizing"); const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH); const [isResizing, setIsResizing] = useState(false); const resizeStartRef = useRef<{ startX: number; startWidth: number } | null>( null, ); const applySidebarWidth = useCallback( (width: number) => { const clampedWidth = clampSidebarWidth(width); setSidebarWidth((current) => current === clampedWidth ? current : clampedWidth, ); document.documentElement.style.setProperty( widthCssVar, `${clampedWidth}px`, ); }, [widthCssVar], ); useEffect(() => { if (!overlay) { document.documentElement.setAttribute(inlineAttr, ""); } document.documentElement.style.setProperty( widthCssVar, `${DEFAULT_SIDEBAR_WIDTH}px`, ); return () => { document.documentElement.style.removeProperty(widthCssVar); document.documentElement.removeAttribute(openAttr); document.documentElement.removeAttribute(resizingAttr); document.documentElement.removeAttribute(inlineAttr); }; }, [inlineAttr, openAttr, overlay, resizingAttr, widthCssVar]); useEffect(() => { document.documentElement.toggleAttribute(openAttr, open); }, [open, openAttr]); useEffect(() => { document.documentElement.toggleAttribute(resizingAttr, isResizing); }, [isResizing, resizingAttr]); const handleResizeStart = useCallback( (event: ReactPointerEvent) => { if (!open) return; event.preventDefault(); resizeStartRef.current = { startX: event.clientX, startWidth: sidebarWidth, }; setIsResizing(true); event.currentTarget.setPointerCapture(event.pointerId); }, [open, sidebarWidth], ); const handleResizeMove = useCallback( (event: ReactPointerEvent) => { if (!resizeStartRef.current) return; const { startX, startWidth } = resizeStartRef.current; applySidebarWidth(startWidth + (startX - event.clientX)); }, [applySidebarWidth], ); const handleResizeEnd = useCallback( (event: ReactPointerEvent) => { if (!resizeStartRef.current) return; resizeStartRef.current = null; setIsResizing(false); event.currentTarget.releasePointerCapture(event.pointerId); }, [], ); const aside = ( ); if (!overlay) return aside; // Clip layer: full-viewport, click-through, hides the panel off the right // edge while it is closed so it never produces horizontal overflow. Sits // below the floating review pill (z-index 1050) so its variant switcher // stays usable while the panel is open. return (
{aside}
); } type SidebarResizeHandleProps = { open: boolean; sidebarWidth: number; isResizing: boolean; onResizeStart: (event: ReactPointerEvent) => void; onResizeMove: (event: ReactPointerEvent) => void; onResizeEnd: (event: ReactPointerEvent) => void; }; export function SidebarResizeHandle({ open, sidebarWidth, isResizing, onResizeStart, onResizeMove, onResizeEnd, }: SidebarResizeHandleProps) { return (
); } export function SidebarHeader({ text, titleAddon, actions, }: { text: ReactNode; titleAddon?: ReactNode; actions?: ReactNode; }) { return (
{typeof text === "string" ? ( {text} ) : ( text )} {titleAddon}
{actions ? (
{actions}
) : null}
); } export function SidebarContent({ children }: { children: ReactNode }) { return (
{children}
); } function CloseIcon() { return ( ); }