150 lines
4.1 KiB
TypeScript
150 lines
4.1 KiB
TypeScript
|
|
/* MobileNav — hamburger menu for sub-md viewports.
|
||
|
|
*
|
||
|
|
* Reuses the same nav config that the desktop NavigationMenu consumes
|
||
|
|
* (passed in via prop) so the IA stays in sync. Renders as a Sheet
|
||
|
|
* sliding in from the left, with the same active-route treatment
|
||
|
|
* (2 px brand-blue underline). */
|
||
|
|
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState } from 'react';
|
||
|
|
import Link from 'next/link';
|
||
|
|
import { Menu } from 'lucide-react';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import {
|
||
|
|
Sheet,
|
||
|
|
SheetContent,
|
||
|
|
SheetHeader,
|
||
|
|
SheetTitle,
|
||
|
|
SheetTrigger,
|
||
|
|
} from '@/components/ui/sheet';
|
||
|
|
import { Separator } from '@/components/ui/separator';
|
||
|
|
|
||
|
|
interface NavItem {
|
||
|
|
title: string;
|
||
|
|
href?: string;
|
||
|
|
icon?: React.ElementType;
|
||
|
|
description?: string;
|
||
|
|
children?: NavItem[];
|
||
|
|
}
|
||
|
|
|
||
|
|
interface MobileNavProps {
|
||
|
|
items: NavItem[];
|
||
|
|
pathname: string;
|
||
|
|
isActive: (href?: string) => boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function MobileNav({ items, pathname, isActive }: MobileNavProps) {
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
|
||
|
|
// Close the sheet when the path changes — handles the link click before
|
||
|
|
// useEffect from a route subscription. Effect would also work but this
|
||
|
|
// is simpler and side-effect-free.
|
||
|
|
const handleNav = () => setOpen(false);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
||
|
|
<SheetTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="md:hidden h-9 w-9"
|
||
|
|
aria-label="Open navigation"
|
||
|
|
>
|
||
|
|
<Menu className="h-5 w-5" />
|
||
|
|
</Button>
|
||
|
|
</SheetTrigger>
|
||
|
|
<SheetContent side="left" className="w-72 p-0 flex flex-col">
|
||
|
|
<SheetHeader className="border-b">
|
||
|
|
<SheetTitle className="px-4 py-3 text-left">Pulse</SheetTitle>
|
||
|
|
</SheetHeader>
|
||
|
|
|
||
|
|
<nav className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||
|
|
{items.map((item) => (
|
||
|
|
<MobileNavGroup
|
||
|
|
key={item.title}
|
||
|
|
item={item}
|
||
|
|
isActive={isActive}
|
||
|
|
onNav={handleNav}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
<Separator />
|
||
|
|
<p className="tagline px-4 py-3">
|
||
|
|
Don't be afraid to cry · Wulf Consulting
|
||
|
|
</p>
|
||
|
|
</SheetContent>
|
||
|
|
</Sheet>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function MobileNavGroup({
|
||
|
|
item,
|
||
|
|
isActive,
|
||
|
|
onNav,
|
||
|
|
}: {
|
||
|
|
item: NavItem;
|
||
|
|
isActive: (href?: string) => boolean;
|
||
|
|
onNav: () => void;
|
||
|
|
}) {
|
||
|
|
if (!item.children) {
|
||
|
|
const active = isActive(item.href);
|
||
|
|
return (
|
||
|
|
<Link
|
||
|
|
href={item.href || '#'}
|
||
|
|
onClick={onNav}
|
||
|
|
className={cn(
|
||
|
|
'flex items-center gap-3 rounded-sm px-3 py-2 text-sm transition-colors',
|
||
|
|
'hover:bg-accent/50',
|
||
|
|
active
|
||
|
|
? 'text-primary font-medium border-l-2 border-primary pl-[10px]'
|
||
|
|
: 'text-foreground border-l-2 border-transparent pl-[10px]',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
|
||
|
|
{item.title}
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const groupActive = item.children.some((c) => isActive(c.href));
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-0.5">
|
||
|
|
<p
|
||
|
|
className={cn(
|
||
|
|
'flex items-center gap-3 px-3 pt-3 pb-1 metric-label',
|
||
|
|
groupActive && 'text-primary',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{item.icon && <item.icon className="h-3.5 w-3.5 shrink-0" />}
|
||
|
|
{item.title}
|
||
|
|
</p>
|
||
|
|
<div className="ml-2 space-y-0.5">
|
||
|
|
{item.children.map((child) => {
|
||
|
|
const active = isActive(child.href);
|
||
|
|
return (
|
||
|
|
<Link
|
||
|
|
key={child.title}
|
||
|
|
href={child.href || '#'}
|
||
|
|
onClick={onNav}
|
||
|
|
className={cn(
|
||
|
|
'flex items-center gap-3 rounded-sm px-3 py-1.5 text-sm transition-colors',
|
||
|
|
'hover:bg-accent/50',
|
||
|
|
active
|
||
|
|
? 'text-primary font-medium border-l-2 border-primary pl-[10px]'
|
||
|
|
: 'text-foreground border-l-2 border-transparent pl-[10px]',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{child.icon && <child.icon className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||
|
|
<span className="truncate">{child.title}</span>
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|