Files
edr-platform/packages/ui-common/src/components/Layout/Sidebar.tsx
2026-05-25 15:50:47 +03:00

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;