slate-test-1770505673/packages/layout/src/Sidebar.tsx
jordan 3bc5efe56f
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-07 23:07:54 +00:00

170 lines
4.4 KiB
TypeScript

import * as React from 'react';
import { cn } from '@slate-test-1770505673/ui';
import { ChevronRight, type LucideIcon } from 'lucide-react';
export interface NavItem {
/** Display label */
label: string;
/** URL to navigate to */
href: string;
/** Icon component from lucide-react */
icon?: LucideIcon;
/** Whether this item is currently active */
active?: boolean;
/** Badge text to display */
badge?: string;
/** Nested items for collapsible sections */
children?: NavItem[];
}
export interface SidebarProps {
/** Logo or brand element to display at the top */
logo?: React.ReactNode;
/** Navigation items */
items: NavItem[];
/** Footer element (e.g., user menu, settings) */
footer?: React.ReactNode;
/** Additional class names */
className?: string;
/** Click handler for navigation items */
onNavigate?: (href: string) => void;
}
/**
* Sidebar provides navigation for dashboard applications.
* Supports icons, nested items, badges, and active state highlighting.
*
* @example
* <Sidebar
* logo={<Logo />}
* items={[
* { label: 'Dashboard', href: '/', icon: Home, active: true },
* { label: 'Users', href: '/users', icon: Users },
* { label: 'Settings', href: '/settings', icon: Settings },
* ]}
* footer={<UserMenu />}
* />
*/
export function Sidebar({
logo,
items,
footer,
className,
onNavigate,
}: SidebarProps) {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const toggleExpanded = (label: string) => {
setExpanded((prev) => ({ ...prev, [label]: !prev[label] }));
};
const handleClick = (item: NavItem, e: React.MouseEvent) => {
if (item.children) {
e.preventDefault();
toggleExpanded(item.label);
} else if (onNavigate) {
e.preventDefault();
onNavigate(item.href);
}
};
return (
<div className={cn('flex flex-col h-full', className)}>
{/* Logo area */}
{logo && (
<div className="flex h-16 items-center px-4 border-b border-[var(--border-muted)]">
{logo}
</div>
)}
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4">
<ul className="space-y-1 px-2">
{items.map((item) => (
<NavItemComponent
key={item.href}
item={item}
isExpanded={expanded[item.label]}
onClick={handleClick}
onNavigate={onNavigate}
/>
))}
</ul>
</nav>
{/* Footer */}
{footer && (
<div className="border-t border-[var(--border-muted)] p-4">
{footer}
</div>
)}
</div>
);
}
interface NavItemComponentProps {
item: NavItem;
isExpanded?: boolean;
depth?: number;
onClick: (item: NavItem, e: React.MouseEvent) => void;
onNavigate?: (href: string) => void;
}
function NavItemComponent({
item,
isExpanded = false,
depth = 0,
onClick,
onNavigate,
}: NavItemComponentProps) {
const Icon = item.icon;
const hasChildren = item.children && item.children.length > 0;
return (
<li>
<a
href={item.href}
onClick={(e) => onClick(item, e)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
item.active
? 'bg-[var(--surface-200)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-100)] hover:text-[var(--text-primary)]',
depth > 0 && 'ml-6'
)}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
<span className="flex-1">{item.label}</span>
{item.badge && (
<span className="rounded-full bg-[var(--accent)] px-2 py-0.5 text-xs font-medium text-[var(--accent-foreground)]">
{item.badge}
</span>
)}
{hasChildren && (
<ChevronRight
className={cn(
'h-4 w-4 transition-transform',
isExpanded && 'rotate-90'
)}
/>
)}
</a>
{/* Nested items */}
{hasChildren && isExpanded && (
<ul className="mt-1 space-y-1">
{item.children!.map((child) => (
<NavItemComponent
key={child.href}
item={child}
depth={depth + 1}
onClick={onClick}
onNavigate={onNavigate}
/>
))}
</ul>
)}
</li>
);
}