133 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|