167 lines
4.9 KiB
TypeScript
167 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export interface NavItem {
|
|
id: string;
|
|
label: string;
|
|
href?: string;
|
|
icon?: React.ReactNode;
|
|
badge?: string | number;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface NavGroup {
|
|
id: string;
|
|
title?: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
export interface SideNavProps extends React.HTMLAttributes<HTMLElement> {
|
|
/** 品牌名 */
|
|
brandName?: string;
|
|
/** 品牌图标 */
|
|
brandIcon?: React.ReactNode;
|
|
/** 导航分组 */
|
|
groups: NavGroup[];
|
|
/** 当前激活的导航项 id */
|
|
activeId?: string;
|
|
/** 导航项点击回调 */
|
|
onNavClick?: (item: NavItem) => void;
|
|
}
|
|
|
|
// ─── NavItemRow ───────────────────────────────────────────────────────────────
|
|
|
|
const NavItemRow = React.memo(
|
|
({
|
|
item,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
item: NavItem;
|
|
active: boolean;
|
|
onClick?: () => void;
|
|
}) => {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={item.disabled}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"group relative flex w-full items-center gap-3 rounded-lg py-2.5 px-4 text-sm font-medium transition-all duration-150",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1",
|
|
"disabled:pointer-events-none disabled:opacity-40",
|
|
active
|
|
? "bg-emerald-50 text-emerald-600 font-semibold border-l-[3px] border-emerald-500"
|
|
: "text-gray-600 hover:bg-gray-50 border-l-[3px] border-transparent"
|
|
)}
|
|
>
|
|
{/* icon */}
|
|
{item.icon && (
|
|
<span
|
|
className={cn(
|
|
"flex h-5 w-5 shrink-0 items-center justify-center transition-colors duration-150",
|
|
active ? "text-emerald-500" : "text-gray-400 group-hover:text-gray-600"
|
|
)}
|
|
>
|
|
{item.icon}
|
|
</span>
|
|
)}
|
|
|
|
{/* label */}
|
|
<span className="flex-1 truncate text-left">{item.label}</span>
|
|
|
|
{/* badge */}
|
|
{item.badge !== undefined && (
|
|
<span
|
|
className={cn(
|
|
"ml-auto flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] font-bold",
|
|
active
|
|
? "bg-emerald-500 text-white"
|
|
: "bg-gray-200 text-gray-600"
|
|
)}
|
|
>
|
|
{item.badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
);
|
|
NavItemRow.displayName = "NavItemRow";
|
|
|
|
// ─── Main SideNav ─────────────────────────────────────────────────────────────
|
|
|
|
const SideNav = React.forwardRef<HTMLElement, SideNavProps>(
|
|
(
|
|
{
|
|
className,
|
|
brandName = "GEO Platform",
|
|
brandIcon,
|
|
groups,
|
|
activeId,
|
|
onNavClick,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
return (
|
|
<nav
|
|
ref={ref}
|
|
className={cn(
|
|
"flex flex-col h-full bg-white border-r border-gray-200 overflow-y-auto",
|
|
"w-60",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{/* ── Brand header ── */}
|
|
<div className="flex items-center gap-3 px-5 py-5 border-b border-gray-200 pb-6 mb-4 shrink-0">
|
|
{brandIcon && (
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-white">
|
|
{brandIcon}
|
|
</div>
|
|
)}
|
|
{!brandIcon && (
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-sm font-bold text-white">
|
|
G
|
|
</div>
|
|
)}
|
|
<span className="text-base font-bold text-gray-900 tracking-tight truncate">
|
|
{brandName}
|
|
</span>
|
|
</div>
|
|
|
|
{/* ── Nav groups ── */}
|
|
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-6">
|
|
{groups.map((group) => (
|
|
<div key={group.id}>
|
|
{group.title && (
|
|
<p className="mb-2 px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
{group.title}
|
|
</p>
|
|
)}
|
|
<div className="space-y-0.5">
|
|
{group.items.map((item) => (
|
|
<NavItemRow
|
|
key={item.id}
|
|
item={item}
|
|
active={item.id === activeId}
|
|
onClick={() => onNavClick?.(item)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
);
|
|
SideNav.displayName = "SideNav";
|
|
|
|
export { SideNav };
|