Wip stage M4
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user