import { ArrowLeft, MoreVertical, X } from 'lucide-react'; import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react'; import { cn } from '../../lib/utils'; import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; import { ScrollArea } from './scroll-area'; export const DEFAULT_ASIDE_INLINE_WIDTH_PX = 380; const MIN_ASIDE_INLINE_WIDTH_PX = 320; const MAX_ASIDE_INLINE_WIDTH_PX = 720; export function clampAsideInlineWidth(width: number): number { return Math.max(MIN_ASIDE_INLINE_WIDTH_PX, Math.min(MAX_ASIDE_INLINE_WIDTH_PX, width)); } export interface AsidePanelResizeProps { resizable?: boolean; persistWidthStorageKey?: string; resizeAriaLabel?: string; } function parseInlineWidthPx(width: string): number { const arbitraryWidthMatch = width.match(/w-\[(.+)\]/); if (arbitraryWidthMatch) { const raw = arbitraryWidthMatch[1].trim(); const parsed = parseInt(raw, 10); if (!Number.isNaN(parsed)) return clampAsideInlineWidth(parsed); } switch (width) { case 'w-full': case 'w-screen': return DEFAULT_ASIDE_INLINE_WIDTH_PX; default: return DEFAULT_ASIDE_INLINE_WIDTH_PX; } } function readPersistedAsideWidth(storageKey: string | undefined, fallback: number): number { if (!storageKey) return fallback; const stored = localStorageAdapter.readNumber(storageKey); if (stored === null) return fallback; return clampAsideInlineWidth(stored); } // Types interface AsideContentItem { id: string; title: string; subtitle?: string; actions?: ReactNode; content: ReactNode; } interface AsidePanelContextType { push: (item: AsideContentItem) => void; pop: () => void; replace: (item: AsideContentItem) => void; clear: () => void; canGoBack: boolean; currentItem: AsideContentItem | null; } const AsidePanelContext = createContext(null); const AsideActionMenuContext = createContext<(() => void) | null>(null); export const useAsidePanel = () => { const context = useContext(AsidePanelContext); if (!context) { throw new Error('useAsidePanel must be used within an AsidePanel'); } return context; }; // Props interface AsidePanelProps extends AsidePanelResizeProps { open: boolean; onClose: () => void; title?: string; subtitle?: string; actions?: ReactNode; showBackButton?: boolean; onBack?: () => void; children: ReactNode; className?: string; width?: string; layout?: AsidePanelLayout; /** * Optional stable identifier emitted as `data-section` on the panel * root. Used as a targeting hook for Custom CSS (Settings → Appearance). */ dataSection?: string; } interface AsidePanelHeaderProps { title: string; subtitle?: string; actions?: ReactNode; onBack?: () => void; onClose: () => void; showBackButton?: boolean; } // Header Component export const AsidePanelHeader: React.FC = ({ title, subtitle, actions, onBack, onClose, showBackButton = false, }) => { return (
{showBackButton && onBack && ( )}

{title}

{subtitle && (

{subtitle}

)}
{actions}
); }; // Content Component (wraps children with scroll) export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({ children, className, }) => { return ( [data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
{children}
); }; // Footer Component export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: string }> = ({ children, className, }) => { return (
{children}
); }; // Action Menu Component (for the ... button) interface AsideActionMenuProps { children: ReactNode; } export const AsideActionMenu: React.FC = ({ children }) => { const [open, setOpen] = useState(false); const close = useCallback(() => setOpen(false), []); return ( {children} ); }; export const invokeAsideActionMenuItemClick = ( closeMenu: (() => void) | null, onClick?: () => void, ) => { closeMenu?.(); onClick?.(); }; // Action Menu Item export const AsideActionMenuItem: React.FC<{ icon?: ReactNode; children: ReactNode; onClick?: () => void; variant?: 'default' | 'destructive'; }> = ({ icon, children, onClick, variant = 'default' }) => { const closeMenu = useContext(AsideActionMenuContext); return ( ); }; // Main Panel Component with Stack Support interface AsidePanelStackProps { open: boolean; onClose: () => void; initialItem: AsideContentItem; className?: string; width?: string; layout?: AsidePanelLayout; /** * Optional stable identifier emitted as `data-section` on the panel * root. Used as a targeting hook for Custom CSS. */ dataSection?: string; } export type AsidePanelLayout = 'overlay' | 'inline'; const resolveInlineWidth = (width: string) => { const arbitraryWidthMatch = width.match(/w-\[(.+)\]/); if (arbitraryWidthMatch) { return arbitraryWidthMatch[1]; } switch (width) { case 'w-full': return '100%'; case 'w-screen': return '100vw'; default: return '380px'; } }; export const AsidePanelStack: React.FC = ({ open, onClose, initialItem, className, width = 'w-[380px]', layout = 'overlay', dataSection, }) => { const [stack, setStack] = useState([initialItem]); const push = useCallback((item: AsideContentItem) => { setStack(prev => [...prev, item]); }, []); const pop = useCallback(() => { setStack(prev => { if (prev.length > 1) { return prev.slice(0, -1); } return prev; }); }, []); const replace = useCallback((item: AsideContentItem) => { setStack([item]); }, []); const clear = useCallback(() => { setStack([initialItem]); }, [initialItem]); const currentItem = stack[stack.length - 1]; const canGoBack = stack.length > 1; const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]); const inlineStyle = layout === 'inline' ? ({ width: inlineWidth, ['--aside-inline-width' as string]: inlineWidth, } as React.CSSProperties) : undefined; // Reset stack when panel closes/opens React.useEffect(() => { if (open) { setStack([initialItem]); } }, [open, initialItem]); if (!open) return null; return (
{currentItem.content}
); }; // Simple Panel Component (no stack) export const AsidePanel: React.FC = ({ open, onClose, title, subtitle, actions, showBackButton, onBack, children, className, width = 'w-[380px]', layout = 'overlay', resizable = false, persistWidthStorageKey, resizeAriaLabel, dataSection, }) => { const fallbackWidthPx = parseInlineWidthPx(width); const [panelWidthPx, setPanelWidthPx] = useState(() => resizable ? readPersistedAsideWidth(persistWidthStorageKey, fallbackWidthPx) : fallbackWidthPx, ); const [isResizing, setIsResizing] = useState(false); const effectivePanelWidthPx = resizable ? panelWidthPx : fallbackWidthPx; // Resizable panels always use pixel width so overlay and inline share the same drag path. const usesPixelWidth = resizable || layout === 'inline'; const panelStyle = usesPixelWidth ? ({ width: `${effectivePanelWidthPx}px`, ['--aside-inline-width' as string]: `${effectivePanelWidthPx}px`, } as React.CSSProperties) : undefined; const handleResizeStart = useCallback((event: React.PointerEvent) => { if (!resizable) return; event.preventDefault(); event.stopPropagation(); const startX = event.clientX; const startWidth = panelWidthPx; const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; setIsResizing(true); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const handlePointerMove = (moveEvent: PointerEvent) => { setPanelWidthPx(clampAsideInlineWidth(startWidth + startX - moveEvent.clientX)); }; const handlePointerUp = (upEvent: PointerEvent) => { const nextWidth = clampAsideInlineWidth(startWidth + startX - upEvent.clientX); setPanelWidthPx(nextWidth); if (persistWidthStorageKey) { localStorageAdapter.writeNumber(persistWidthStorageKey, nextWidth); } setIsResizing(false); document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); }, [panelWidthPx, persistWidthStorageKey, resizable]); if (!open) return null; return (
{resizable ? (
) : null} {title && ( )} {children}
); }; export default AsidePanel;