Files
web_sport/apps/storefront/src/components/commerce/mega-menu.tsx
T
2026-08-13 23:20:22 +07:00

133 lines
4.8 KiB
TypeScript

'use client';
import { ChevronDown } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
export interface MegaMenuColumn {
readonly heading: string;
readonly links: readonly { readonly label: string; readonly href: string }[];
}
export interface MegaMenuEntry {
readonly id: string;
readonly label: string;
readonly href: string;
readonly columns: readonly MegaMenuColumn[];
}
/**
* Desktop navigation with a full-width drop panel.
*
* Hand-built rather than Radix NavigationMenu. The panel here is a plain hover
* region with no roving focus and no collision logic to get wrong, and it is
* the most brand-visible element in the chrome — the animation, the full-bleed
* black panel, the column rhythm. Radix would supply behaviour this does not
* need and constrain the markup that carries the identity.
*
* What it still has to get right, and does:
* - Opens on hover *and* on keyboard focus, so it is reachable without a mouse.
* - Closes on Escape and on focus leaving the group.
* - The top-level item stays a real link, so a sport is one click away rather
* than requiring the panel.
*/
export function MegaMenu({
entries,
className,
}: {
entries: readonly MegaMenuEntry[];
className?: string;
}) {
const [openId, setOpenId] = useState<string | null>(null);
// Escape is bound to the document rather than to a wrapper's `onKeyDown`:
// the panel is dismissible from anywhere while it is open, and hanging key
// handlers off a non-interactive container would be lying about what that
// container is.
useEffect(() => {
if (!openId) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpenId(null);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [openId]);
return (
<div className={cn('flex items-center gap-5', className)} onMouseLeave={() => setOpenId(null)}>
{entries.map((entry) => {
const open = openId === entry.id;
const hasPanel = entry.columns.length > 0;
return (
<div
key={entry.id}
className="static"
onMouseEnter={() => setOpenId(entry.id)}
onFocus={() => setOpenId(entry.id)}
onBlur={(event) => {
// Only close when focus leaves the whole group, otherwise tabbing
// from the trigger into the panel would shut it immediately.
if (!event.currentTarget.contains(event.relatedTarget)) setOpenId(null);
}}
>
<Link
href={entry.href}
aria-expanded={hasPanel ? open : undefined}
className={cn(
'hover:text-volt-600 focus-visible:ring-ring/50 flex items-center gap-1 py-5 text-xs font-semibold uppercase tracking-widest outline-none transition-colors focus-visible:ring-[3px]',
open && 'text-volt-600',
)}
>
{entry.label}
{hasPanel ? (
<ChevronDown
className={cn('size-3 transition-transform duration-200', open && 'rotate-180')}
/>
) : null}
</Link>
{/*
`border-y`, not `border-t`. On the homepage the panel is black
over a black hero, and without a bottom edge the two surfaces
merge — the menu reads as text floating on the hero rather than as
a panel in front of it.
*/}
{hasPanel && open ? (
<div className="bg-ink-950 absolute inset-x-0 top-full z-40 border-y border-white/15 text-white shadow-2xl">
<div className="max-w-page px-gutter mx-auto grid grid-cols-2 gap-10 py-10 md:grid-cols-4">
{entry.columns.map((column) => (
<div key={column.heading}>
<p className="text-volt-500 text-[0.625rem] font-semibold uppercase tracking-widest">
{column.heading}
</p>
<ul className="mt-4 space-y-2.5">
{column.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={() => setOpenId(null)}
className="text-ink-300 hover:text-volt-500 text-sm transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
) : null}
</div>
);
})}
</div>
);
}