Files
edr-platform/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx
2026-07-09 07:04:31 +00:00

267 lines
7.7 KiB
TypeScript

import {
AppShell,
Box,
Group,
NavLink,
ScrollArea,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { ChevronDown, X } from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import type { SidebarItem, SidebarSection } from "./types";
import { Link } from "react-router-dom";
export interface FreightSidebarProps {
sections: SidebarSection[];
activeHref?: string;
onNavigate?: (href: string) => void;
/** Close handler for the mobile drawer (X button, hidden on desktop). */
onClose?: () => void;
}
const BRAND_LOGO = "/assets/logo.svg";
// Active / inactive NavLink styling, expressed through the shared edr-* theme
// tokens (bridged into Tailwind in index.css). Items are pills floating on the
// page background — the navbar itself has no surface of its own.
const navClassNames = (active: boolean) =>
active
? {
root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
label: "text-edr-primary-dark! font-medium! text-sm!",
section: "text-edr-primary-dark!",
}
: {
root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
section: "text-edr-text!",
};
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
`${parentKey}/${item.href ?? item.label}/${index}`;
const collectHrefs = (items: SidebarItem[]): string[] =>
items.flatMap((item) => [
...(item.href ? [item.href.toLowerCase()] : []),
...(item.children?.length ? collectHrefs(item.children) : []),
]);
const FreightSidebar = ({
sections,
activeHref,
onNavigate,
onClose,
}: FreightSidebarProps) => {
const activePath = activeHref?.toLowerCase() ?? "";
const isHrefActive = useCallback(
(href: string) => {
const normalized = href.toLowerCase();
return (
activePath === normalized || activePath.startsWith(`${normalized}/`)
);
},
[activePath],
);
const branchActive = useCallback(
(items: SidebarItem[]) => collectHrefs(items).some(isHrefActive),
[isHrefActive],
);
// Branches containing the active route start expanded; manual toggles win
// afterwards (merge keeps user intent while still opening newly-active paths).
const defaultOpen = useMemo(() => {
const acc: Record<string, boolean> = {};
const walk = (items: SidebarItem[], parentKey: string) => {
items.forEach((item, i) => {
if (!item.children?.length) return;
const key = itemKey(parentKey, item, i);
acc[key] =
(item.href ? isHrefActive(item.href) : false) ||
branchActive(item.children);
walk(item.children, key);
});
};
sections.forEach((section) => walk(section.items, section.title));
return acc;
}, [sections, isHrefActive, branchActive]);
const [openMap, setOpenMap] = useState(defaultOpen);
useEffect(() => {
setOpenMap((current) => ({ ...defaultOpen, ...current }));
}, [defaultOpen]);
const toggle = useCallback(
(key: string) => setOpenMap((m) => ({ ...m, [key]: !m[key] })),
[],
);
const renderItem = useCallback(
(item: SidebarItem, key: string): ReactNode => {
const hasChildren = !!item.children?.length;
if (hasChildren) {
const isLink = !!item.href;
const active = isLink ? isHrefActive(item.href!) : false;
const isOpen = openMap[key] ?? false;
return (
// `opened` is controlled so a link-parent navigates on row click
// without collapsing; the chevron is the only toggle affordance.
<NavLink
key={key}
label={item.label}
leftSection={item.icon}
active={active}
opened={isOpen}
classNames={navClassNames(active)}
onClick={() => toggle(key)}
rightSection={
<Box
component="span"
role="button"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(key);
}}
className="flex cursor-pointer items-center"
>
<ChevronDown
size={16}
className="text-edr-muted transition-transform duration-200"
style={{
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
}}
/>
</Box>
}
>
{item.children!.map((child, i) =>
renderItem(child, itemKey(key, child, i)),
)}
</NavLink>
);
}
if (!item.href) return null;
const active = isHrefActive(item.href);
return (
<NavLink
key={key}
label={item.label}
leftSection={item.icon}
active={active}
component={Link}
classNames={navClassNames(active)}
to={item.href!}
/>
);
},
[branchActive, isHrefActive, onNavigate, openMap, toggle],
);
const renderedSections = useMemo(
() =>
sections.map((section) => (
<Box key={section.title}>
<Text
size="xs"
tt="uppercase"
px="sm"
mb={6}
className={"text-edr-muted!"}
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
{section.title}
</Text>
<Stack gap={2}>
{section.items.map((item, i) =>
renderItem(item, itemKey(section.title, item, i)),
)}
</Stack>
</Box>
)),
[renderItem, sections],
);
return (
<AppShell.Navbar
withBorder={false}
// White surface + hairline right border, matching the portal AppLayout.
// Inline styles beat Mantine's cascade layer (where a Tailwind bg-* would
// lose to the navbar's default --mantine-color-body).
style={{
backgroundColor: "var(--mantine-color-edr-card-6)",
borderRight: "1px solid var(--mantine-color-edr-border-6)",
}}
>
{/* Brand — aligns with the 64px header for a continuous top edge */}
<Box className="flex h-16 shrink-0 items-center justify-between px-5">
<Group gap={10} wrap="nowrap">
<img
src={BRAND_LOGO}
alt="EDR Freight"
className="size-8 shrink-0 object-contain"
/>
<Box>
<Text
className="text-edr-primary! leading-tight"
fw={700}
fz={15}
style={{ letterSpacing: "-0.01em" }}
>
EDR Freight
</Text>
<Text
className="text-edr-muted!"
fz={10}
fw={500}
style={{ letterSpacing: "0.02em" }}
>
Backoffice Console
</Text>
</Box>
</Group>
{onClose && (
<UnstyledButton
onClick={onClose}
hiddenFrom="sm"
aria-label="Close sidebar"
>
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
)}
</Box>
{/* Nav */}
<AppShell.Section
grow
component={ScrollArea}
type="hover"
scrollbars="y"
scrollbarSize={6}
scrollHideDelay={500}
px="sm"
pb="md"
>
<Stack gap="lg">{renderedSections}</Stack>
</AppShell.Section>
</AppShell.Navbar>
);
};
export default FreightSidebar;