mega-plant/components/Sidebar.tsx

219 lines
8.5 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type NavLeaf = { href: string; label: string };
export type NavItem =
| { kind: "link"; href: string; label: string; icon: React.ReactNode }
| { kind: "group"; label: string; icon: React.ReactNode; children: NavLeaf[] };
// A group is active when the current path is one of its children, or sits under one
// of them. A child of "/" only matches exactly — otherwise every route would count as
// "under" the dashboard.
function isGroupActive(item: Extract<NavItem, { kind: "group" }>, pathname: string): boolean {
return item.children.some((c) =>
c.href === "/" ? pathname === "/" : pathname === c.href || pathname.startsWith(`${c.href}/`),
);
}
// The desktop rail and the mobile drawer render the same list from this one
// component, so the two navs can never drift apart.
function NavList({ items, onNavigate }: { items: NavItem[]; onNavigate?: () => void }) {
const pathname = usePathname();
return (
<nav className="p-3 space-y-0.5 text-sm">
{items.map((item, i) =>
item.kind === "link" ? (
<LeafLink
key={i}
href={item.href}
label={item.label}
icon={item.icon}
active={pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))}
onClick={onNavigate}
/>
) : (
<Group key={i} item={item} active={isGroupActive(item, pathname)} />
)
)}
</nav>
);
}
/**
* Desktop rail only. It is `hidden lg:block`, so it contributes ZERO width below
* `lg` and must stay a pure sibling of `<main>` — anything visible on mobile
* rendered in this flex row steals width from the page. The mobile trigger lives
* in the header instead; see `MobileNav`.
*/
export function Sidebar({ items }: { items: NavItem[] }) {
return (
<aside className="hidden lg:block w-64 shrink-0 bg-white dark:bg-surface-muted border-r border-gray-200 dark:border-border min-h-[calc(100vh-4rem)]">
<NavList items={items} />
</aside>
);
}
/**
* Mobile nav: the hamburger plus the drawer it opens. Rendered INSIDE the header,
* which is the whole point — the old version put the button in the content flex
* row, where its 36px was subtracted from every page below `lg` and indented the
* entire site on phones and tablets.
*
* The header is full-bleed, so a 44px trigger there costs no content width.
* `-ml-3` pulls the hit area into the header's own `px-4`, which lands the icon's
* left edge exactly on the logo's (16px) without changing the logo's position.
*/
export function MobileNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// Close drawer on navigation
useEffect(() => {
setOpen(false);
}, [pathname]);
// Scroll lock, plus the SSR gate for the portal below — `document` does not
// exist while this component is server-rendered.
useEffect(() => {
setMounted(true);
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [open]);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open navigation"
aria-expanded={open}
className="lg:hidden -ml-3 w-11 h-11 shrink-0 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<Menu className="w-5 h-5" />
</button>
{/* ⚠️ THE PORTAL IS LOAD-BEARING — do not "simplify" it back to inline JSX.
The header carries `backdrop-blur`, and a filter/backdrop-filter makes an
element the containing block for its `position: fixed` descendants. Inline,
this drawer would size and position itself against the 64px header box
instead of the viewport — a 64px-tall sliver pinned under the header.
Portalling to <body> returns it to the root stacking context.
This is the same trap class as MachineCombobox: a portal is NOT a DOM
descendant of the component that made it, so any hit-testing that reaches
across this boundary (click-away, focus traps) will read as "outside". */}
{mounted &&
createPortal(
<>
{open && (
<div
className="lg:hidden fixed inset-0 z-[1200] bg-black/40"
onClick={() => setOpen(false)}
/>
)}
<aside
className={cn(
"lg:hidden fixed top-0 left-0 bottom-0 z-[1300] w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform",
open ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-200 dark:border-border">
<div className="flex items-center gap-2">
<img
src="/brand/logo.png"
alt="Mega Plant"
className="w-8 h-8 rounded-xl object-contain bg-white dark:bg-bg shadow-sm"
/>
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</span>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close navigation"
className="w-11 h-11 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<X className="w-5 h-5" />
</button>
</div>
<NavList items={items} onNavigate={() => setOpen(false)} />
</aside>
</>,
document.body,
)}
</>
);
}
function LeafLink({
href, label, icon, active, onClick,
}: {
href: string; label: string; icon: React.ReactNode; active: boolean; onClick?: () => void;
}) {
return (
<Link
href={href}
onClick={onClick}
className={cn(
"min-h-11 flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
active
? "bg-gradient-to-r from-[#30638E]/10 to-[#00798C]/5 text-[#003D5B] dark:from-[#30638E]/20 dark:to-[#00798C]/10 dark:text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{icon}</span>
<span>{label}</span>
</Link>
);
}
function Group({ item, active }: { item: Extract<NavItem, { kind: "group" }>; active: boolean }) {
const [open, setOpen] = useState(active);
const pathname = usePathname();
return (
<div>
<button
onClick={() => setOpen((o) => !o)}
className={cn(
"w-full min-h-11 flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
active
? "text-[#003D5B] dark:text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{item.icon}</span>
<span className="flex-1 text-left">{item.label}</span>
<ChevronDown className={cn("w-4 h-4 text-gray-400 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="ml-7 mt-0.5 space-y-0.5 border-l-2 border-gray-100 dark:border-border pl-2">
{item.children.map((child) => {
const childActive = pathname === child.href;
return (
<Link
key={child.href}
href={child.href}
className={cn(
"flex items-center min-h-11 px-3 py-1.5 rounded-lg text-xs font-medium transition",
childActive
? "bg-[#30638E]/10 text-[#30638E] dark:bg-[#00798C]/15 dark:text-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
{child.label}
</Link>
);
})}
</div>
)}
</div>
);
}