2026-05-01 00:24:28 +08:00

184 lines
5.3 KiB
JavaScript

import ExpandMore from "@mui/icons-material/ExpandMore";
import { useEffect, useRef, useState } from "react";
export function Sidebar({ activePath, isCollapsed, items, onNavigate }) {
const [expandedGroupId, setExpandedGroupId] = useState(() => findParentGroupId(activePath, items));
const [flyoutGroupId, setFlyoutGroupId] = useState(null);
const sidebarRef = useRef(null);
useEffect(() => {
setExpandedGroupId(findParentGroupId(activePath, items));
setFlyoutGroupId(null);
}, [activePath, items]);
useEffect(() => {
setFlyoutGroupId(null);
}, [isCollapsed]);
useEffect(() => {
if (!flyoutGroupId) {
return undefined;
}
const closeFlyout = (event) => {
if (sidebarRef.current?.contains(event.target)) {
return;
}
setFlyoutGroupId(null);
};
const closeOnEscape = (event) => {
if (event.key === "Escape") {
setFlyoutGroupId(null);
}
};
document.addEventListener("pointerdown", closeFlyout);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeFlyout);
document.removeEventListener("keydown", closeOnEscape);
};
}, [flyoutGroupId]);
const handleNavigate = (path) => {
if (path) {
onNavigate(path);
}
setFlyoutGroupId(null);
};
return (
<aside className={`sidebar ${isCollapsed ? "sidebar--collapsed" : ""}`} ref={sidebarRef}>
<nav className="nav-list" aria-label="主导航">
{items.map((item) => (
<NavGroup
activePath={activePath}
isExpanded={expandedGroupId === item.id}
isCollapsed={isCollapsed}
isFlyoutOpen={flyoutGroupId === item.id}
item={item}
key={item.id}
onNavigate={handleNavigate}
onToggleExpanded={() => setExpandedGroupId((current) => (current === item.id ? null : item.id))}
onToggleFlyout={() => setFlyoutGroupId((current) => (current === item.id ? null : item.id))}
/>
))}
</nav>
</aside>
);
}
function NavGroup({
activePath,
isCollapsed,
isExpanded,
isFlyoutOpen,
item,
onNavigate,
onToggleExpanded,
onToggleFlyout
}) {
const Icon = item.icon;
const children = item.children || [];
const hasChildren = children.length > 0;
const isActive = Boolean(item.path && activePath.startsWith(item.path));
const isChildActive = children.some((child) => child.path && activePath.startsWith(child.path));
const isOpen = isCollapsed ? isFlyoutOpen : isExpanded;
const handleParentClick = () => {
if (hasChildren && isCollapsed) {
onToggleFlyout();
return;
}
if (hasChildren) {
onToggleExpanded();
return;
}
onNavigate(item.path);
};
return (
<div className="nav-group">
<button
aria-expanded={hasChildren ? isOpen : undefined}
aria-haspopup={hasChildren && isCollapsed ? "menu" : undefined}
className={[
"nav-item",
hasChildren ? "nav-item--parent" : "",
isOpen ? "nav-item--expanded" : "",
isActive ? "nav-item--active" : "",
isChildActive || isOpen ? "nav-item--open" : "",
isFlyoutOpen ? "nav-item--flyout-open" : ""
]
.filter(Boolean)
.join(" ")}
type="button"
onClick={handleParentClick}
>
<Icon fontSize="small" />
<span className="nav-label">{item.label}</span>
{hasChildren ? <ExpandMore className="nav-expand" fontSize="small" /> : null}
</button>
{hasChildren && !isCollapsed ? (
<div className={`nav-children ${isOpen ? "nav-children--open" : ""}`}>
<div className="nav-children__inner">
{children.map((child) => {
const ChildIcon = child.icon;
return (
<button
className={`nav-item nav-item--child ${child.path && activePath.startsWith(child.path) ? "nav-item--active" : ""}`}
key={child.id}
type="button"
onClick={() => onNavigate(child.path)}
>
<ChildIcon fontSize="small" />
<span className="nav-label">{child.label}</span>
</button>
);
})}
</div>
</div>
) : null}
{hasChildren && isCollapsed && isFlyoutOpen ? (
<div className="nav-flyout" role="menu">
<div className="nav-flyout__title">{item.label}</div>
{children.map((child) => {
const ChildIcon = child.icon;
return (
<button
className={`nav-flyout__item ${child.path && activePath.startsWith(child.path) ? "nav-flyout__item--active" : ""}`}
key={child.id}
role="menuitem"
type="button"
onClick={() => onNavigate(child.path)}
>
<ChildIcon fontSize="small" />
<span>{child.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
function findParentGroupId(activePath, items) {
for (const item of items) {
if (item.children?.some((child) => child.path && activePath.startsWith(child.path))) {
return item.id;
}
}
return null;
}