'use client'; import React, { forwardRef, useCallback, useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from 'react'; import { Surface, cn, surfaceClasses, useEffectiveSurface, } from '../common'; export interface PixelNavigationMenuItem { label: string; href?: string; onSelect?: () => void; content?: ReactNode; icon?: ReactNode; description?: string; } export interface PixelNavigationMenuProps extends Omit, 'children'> { items: PixelNavigationMenuItem[]; orientation?: 'horizontal' | 'vertical'; viewport?: boolean; surface?: Surface; /** * Accessible name for the nav landmark. Required when more than one nav * lands on the same page (WCAG 2.4.6). Defaults to "Main navigation". */ ariaLabel?: string; } export const PixelNavigationMenu = forwardRef< HTMLElement, PixelNavigationMenuProps >(function PixelNavigationMenu( { items, orientation = 'horizontal', viewport = true, surface: surfaceProp, ariaLabel = 'Main navigation', className, onMouseLeave, ...rest }, forwardedRef, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const [activeIndex, setActiveIndex] = useState(null); const itemRefs = useRef>([]); const baseId = useId(); // Reset refs length to match items. itemRefs.current.length = items.length; const closeAll = useCallback(() => setActiveIndex(null), []); const openIndex = useCallback( (idx: number) => { const item = items[idx]; if (!item) return; if (item.content) setActiveIndex(idx); else setActiveIndex(null); }, [items], ); const focusItem = useCallback((idx: number) => { const el = itemRefs.current[idx]; if (el) el.focus(); }, []); const handleItemKeyDown = ( e: ReactKeyboardEvent, idx: number, ) => { const item = items[idx]; const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'; const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'; if (e.key === nextKey) { e.preventDefault(); const next = (idx + 1) % items.length; focusItem(next); } else if (e.key === prevKey) { e.preventDefault(); const prev = (idx - 1 + items.length) % items.length; focusItem(prev); } else if (e.key === 'Home') { e.preventDefault(); focusItem(0); } else if (e.key === 'End') { e.preventDefault(); focusItem(items.length - 1); } else if (e.key === 'Escape') { e.preventDefault(); closeAll(); } else if (e.key === 'Enter' || e.key === ' ') { if (!item) return; // Anchors handle Enter natively — let the browser navigate. if (item.href) return; e.preventDefault(); if (item.content) { setActiveIndex((cur) => (cur === idx ? null : idx)); } item.onSelect?.(); } }; const handleRootMouseLeave = (e: React.MouseEvent) => { onMouseLeave?.(e); if (!e.defaultPrevented) closeAll(); }; const listOrientationClass = orientation === 'horizontal' ? 'flex flex-row items-stretch gap-1' : 'flex flex-col items-stretch gap-1'; const setItemRef = (idx: number) => (node: HTMLElement | null) => { itemRefs.current[idx] = node; }; const activeItem = activeIndex !== null ? items[activeIndex] : null; const activeContent = activeItem?.content; return ( ); }); PixelNavigationMenu.displayName = 'PixelNavigationMenu';