← back to Norma
components/email-analyzer/PanelShell.tsx
216 lines
'use client';
import { ReactNode, useState } from 'react';
import { ChevronDown, ChevronRight, GripVertical, Maximize2, X } from 'lucide-react';
interface PanelShellProps {
id: string;
title: string;
icon?: ReactNode;
badge?: ReactNode;
defaultCollapsed?: boolean;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
onRemove?: () => void;
onPopout?: () => void;
dragHandleClassName?: string;
children: ReactNode;
bodyPadding?: number;
}
/**
* Chrome wrapper for a dockable panel.
* - Title bar has a drag grip (left), title, optional badge, collapse chevron, optional pop-out, optional close.
* - Body is hidden when collapsed (just the header remains visible in the grid cell).
* - Uses Norma design tokens exclusively — no hardcoded colors.
*
* The drag handle class is configurable so react-grid-layout can target it precisely
* without the whole panel becoming a drag zone.
*/
export default function PanelShell({
title,
icon,
badge,
defaultCollapsed = false,
collapsed: controlledCollapsed,
onCollapsedChange,
onRemove,
onPopout,
dragHandleClassName = 'panel-drag-handle',
children,
bodyPadding = 8,
}: PanelShellProps) {
const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(defaultCollapsed);
const isControlled = controlledCollapsed !== undefined;
const collapsed = isControlled ? controlledCollapsed : uncontrolledCollapsed;
function toggleCollapsed() {
const next = !collapsed;
if (!isControlled) setUncontrolledCollapsed(next);
onCollapsedChange?.(next);
}
return (
<div
className="card"
style={{
padding: 0,
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
borderRadius: 'var(--radius-md)',
background: 'var(--color-surface)',
// Thin stroke that reads at-a-glance against the page background.
// Uses --color-border with a stronger inset ring layered on top.
border: '1px solid var(--color-border)',
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.04)',
transition: 'border-color var(--transition), box-shadow var(--transition)',
}}
>
{/* ── Header ────────────────────────────────────────────────────────── */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '5px 8px 5px 4px',
borderBottom: collapsed ? 'none' : '1px solid var(--color-border)',
background: 'var(--color-surface-el)',
flexShrink: 0,
minHeight: 30,
}}
>
{/* Drag grip — only this triggers grid drag */}
<button
type="button"
className={dragHandleClassName}
title="Drag to move"
aria-label="Drag panel"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 22,
height: 22,
borderRadius: 'var(--radius-sm)',
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'grab',
padding: 0,
flexShrink: 0,
}}
>
<GripVertical size={14} />
</button>
{/* Collapse chevron — doubles as the expand affordance */}
<button
type="button"
onClick={toggleCollapsed}
aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
title={collapsed ? 'Expand' : 'Collapse'}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 22,
height: 22,
borderRadius: 'var(--radius-sm)',
background: 'transparent',
border: 'none',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
transition: 'color var(--transition)',
}}
>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
{icon ? (
<span style={{ display: 'flex', alignItems: 'center', color: 'var(--color-text-secondary)', flexShrink: 0 }}>
{icon}
</span>
) : null}
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--color-text)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
onDoubleClick={toggleCollapsed}
>
{title}
</span>
{badge ? (
<span style={{ marginLeft: 4, display: 'flex', alignItems: 'center' }}>{badge}</span>
) : null}
<span style={{ flex: 1 }} />
{onPopout ? (
<button
type="button"
onClick={onPopout}
aria-label="Pop out"
title="Pop out"
style={iconBtnStyle}
>
<Maximize2 size={13} />
</button>
) : null}
{onRemove ? (
<button
type="button"
onClick={onRemove}
aria-label="Remove panel"
title="Remove from layout"
style={iconBtnStyle}
>
<X size={14} />
</button>
) : null}
</div>
{/* ── Body ─────────────────────────────────────────────────────────── */}
{!collapsed && (
<div
style={{
padding: bodyPadding,
flex: 1,
overflow: 'auto',
minHeight: 0,
}}
>
{children}
</div>
)}
</div>
);
}
const iconBtnStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 22,
height: 22,
borderRadius: 'var(--radius-sm)',
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
transition: 'background var(--transition), color var(--transition)',
};