Navigation
CollapsibleSidebar
Collapsible node palette with search and categorised items you can drag out.
npx bezel-add add collapsible-sidebarpackages/ui/src/navigation/CollapsibleSidebar.tsx · 124 lines
import { useState, type DragEvent, type ReactNode } from 'react';
import { Search, PanelLeftClose, PanelLeft } from 'lucide-react';
export type SidebarItem = {
id: string;
label: string;
description?: string;
color: string;
icon: ReactNode;
dragData?: string;
dragKey?: string;
};
export type SidebarCategory = {
label: string;
items: SidebarItem[];
};
type CollapsibleSidebarProps = {
title?: string;
categories: SidebarCategory[];
searchPlaceholder?: string;
dragTransferKey?: string;
};
export function CollapsibleSidebar({
title = 'Items',
categories,
searchPlaceholder = 'Search...',
dragTransferKey = 'application/sidebar-item',
}: CollapsibleSidebarProps) {
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState(false);
const onDragStart = (e: DragEvent, item: SidebarItem) => {
e.dataTransfer.setData(item.dragKey ?? dragTransferKey, item.dragData ?? item.id);
e.dataTransfer.effectAllowed = 'move';
};
const filterItem = (item: SidebarItem) =>
item.label.toLowerCase().includes(search.toLowerCase());
if (collapsed) {
return (
<div className="w-12 bg-white border-r border-neutral-200 flex flex-col items-center pt-3">
<button
onClick={() => setCollapsed(false)}
aria-label="Expand sidebar"
className="p-2 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#912c22]"
>
<PanelLeft size={18} />
</button>
</div>
);
}
return (
<div className="w-60 bg-white border-r border-neutral-200 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h2 className="text-xs font-semibold text-[color:var(--bz-ink-subtle,#6b6b70)] uppercase tracking-wider">{title}</h2>
<button
onClick={() => setCollapsed(true)}
aria-label="Collapse sidebar"
className="p-1.5 rounded-lg hover:bg-neutral-100 text-[color:var(--bz-ink-subtle,#6b6b70)] transition-colors cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#912c22]"
>
<PanelLeftClose size={16} />
</button>
</div>
{/* Search */}
<div className="px-3 pb-3">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[color:var(--bz-ink-subtle,#6b6b70)]" />
<input
type="text"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-xs bg-neutral-50 border border-[color:var(--bz-line-control,#8a8a8e)] rounded-xl focus:outline-none focus:ring-2 focus:ring-[color:var(--bz-focus-ring,#912c22)] placeholder:text-[color:var(--bz-ink-subtle,#6b6b70)] transition"
/>
</div>
</div>
{/* Item list */}
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-4">
{categories.map((cat) => {
const filtered = cat.items.filter(filterItem);
if (filtered.length === 0) return null;
return (
<div key={cat.label}>
<h3 className="text-[10px] font-semibold text-[color:var(--bz-ink-subtle,#6b6b70)] uppercase tracking-widest mb-2 px-1">
{cat.label}
</h3>
<div className="space-y-1.5">
{filtered.map((item) => (
<div
key={item.id}
draggable
onDragStart={(e) => onDragStart(e, item)}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-neutral-50 border border-neutral-100 hover:border-neutral-300 hover:shadow-sm cursor-grab active:cursor-grabbing transition-[border-color,box-shadow] duration-150 group"
>
<div
className="flex items-center justify-center w-7 h-7 rounded-lg transition-transform group-hover:scale-110"
style={{ backgroundColor: `${item.color}18` }}
>
<span style={{ color: item.color, display: 'flex' }}>{item.icon}</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-700">{item.label}</div>
{item.description && (
<div className="text-[10px] text-[color:var(--bz-ink-subtle,#6b6b70)]">{item.description}</div>
)}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
}