mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
import { type MouseEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
|
import clsx from "clsx";
|
|
import { ChevronDown } from "lucide-react";
|
|
|
|
export interface SidebarItem {
|
|
label: string;
|
|
href: string;
|
|
icon?: ReactNode;
|
|
children?: SidebarItem[];
|
|
}
|
|
|
|
export interface SidebarProps {
|
|
title?: string;
|
|
items: SidebarItem[];
|
|
activeHref?: string;
|
|
onNavigate?: (href: string) => void;
|
|
headerExtra?: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Brand palette
|
|
* #10B981 — dominant dark green → brand mark, active state, hover text
|
|
* #10B981 — secondary green → hover background tint
|
|
* #10B981 — muted green tone → sidebar canvas
|
|
*/
|
|
const Sidebar = ({
|
|
title,
|
|
items,
|
|
activeHref,
|
|
onNavigate,
|
|
headerExtra,
|
|
}: SidebarProps) => {
|
|
const activePath = activeHref?.toLowerCase() ?? "";
|
|
const defaultExpanded = useMemo(
|
|
() =>
|
|
items.reduce<Record<string, boolean>>((acc, item) => {
|
|
if (item.children?.length) {
|
|
acc[item.href] =
|
|
activePath === item.href.toLowerCase() ||
|
|
activePath.startsWith(`${item.href.toLowerCase()}/`) ||
|
|
item.children.some((child) =>
|
|
activePath.startsWith(child.href.toLowerCase()),
|
|
);
|
|
}
|
|
return acc;
|
|
}, {}),
|
|
[activePath, items],
|
|
);
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded);
|
|
|
|
useEffect(() => {
|
|
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
|
}, [defaultExpanded]);
|
|
|
|
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
|
if (onNavigate) {
|
|
event.preventDefault();
|
|
onNavigate(href);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<aside className="flex w-64 flex-col gap-1 border-r border-sidebar-border bg-sidebar px-3 py-5 ">
|
|
{title ? (
|
|
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sidebar-primary text-sm font-bold text-white">
|
|
{title.charAt(0)}
|
|
</div>
|
|
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
|
{title}
|
|
</div>
|
|
</div>
|
|
{headerExtra}
|
|
</div>
|
|
) : null}
|
|
|
|
<nav className="flex flex-col gap-1">
|
|
{items.map((item) => {
|
|
const hasChildren = Boolean(item.children?.length);
|
|
const itemHref = item.href.toLowerCase();
|
|
const childActive =
|
|
item.children?.some((child) =>
|
|
activePath.startsWith(child.href.toLowerCase()),
|
|
) ?? false;
|
|
const isCurrentItem = hasChildren
|
|
? activePath === itemHref
|
|
: activePath === itemHref || activePath.startsWith(`${itemHref}/`);
|
|
const isSectionActive = childActive && !isCurrentItem;
|
|
|
|
return (
|
|
<div key={item.href} className="flex flex-col gap-1">
|
|
<div
|
|
className={clsx(
|
|
"group flex items-center gap-2 rounded-md transition",
|
|
isCurrentItem
|
|
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
|
: isSectionActive
|
|
? "bg-sidebar-accent/70 text-sidebar-accent-foreground"
|
|
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
|
)}
|
|
>
|
|
<a
|
|
href={item.href}
|
|
onClick={(event) => navigateTo(event, item.href)}
|
|
aria-current={isCurrentItem ? "page" : undefined}
|
|
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
|
|
>
|
|
{item.icon ? (
|
|
<span
|
|
className={clsx(
|
|
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
|
isCurrentItem
|
|
? "text-white"
|
|
: isSectionActive
|
|
? "text-[#10B981] dark:text-emerald-300"
|
|
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
|
)}
|
|
>
|
|
{item.icon}
|
|
</span>
|
|
) : null}
|
|
<span className="truncate">{item.label}</span>
|
|
</a>
|
|
{hasChildren ? (
|
|
<button
|
|
type="button"
|
|
aria-label={`Toggle ${item.label}`}
|
|
aria-expanded={expanded[item.href] ?? false}
|
|
onClick={() =>
|
|
setExpanded((current) => ({
|
|
...current,
|
|
[item.href]: !current[item.href],
|
|
}))
|
|
}
|
|
className={clsx(
|
|
"mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md transition",
|
|
isCurrentItem
|
|
? "text-white/90 hover:bg-white/10"
|
|
: isSectionActive
|
|
? "text-[#10B981] hover:bg-sidebar-accent/80 dark:text-emerald-300"
|
|
: "text-slate-500 hover:bg-sidebar-accent/60 dark:text-slate-400",
|
|
)}
|
|
>
|
|
<ChevronDown
|
|
className={clsx(
|
|
"h-4 w-4 transition-transform",
|
|
expanded[item.href] ? "rotate-180" : "rotate-0",
|
|
)}
|
|
/>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{hasChildren && expanded[item.href] ? (
|
|
<div className="ml-4 flex flex-col gap-1 border-l border-sidebar-border/60 pl-3">
|
|
{item.children!.map((child) => {
|
|
const childActiveHref = activePath === child.href.toLowerCase();
|
|
|
|
return (
|
|
<a
|
|
key={child.href}
|
|
href={child.href}
|
|
onClick={(event) => navigateTo(event, child.href)}
|
|
aria-current={childActiveHref ? "page" : undefined}
|
|
className={clsx(
|
|
"rounded-md px-3 py-2 text-sm transition",
|
|
childActiveHref
|
|
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
|
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
|
)}
|
|
>
|
|
{child.label}
|
|
</a>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export default Sidebar;
|