'use client'; import React, { forwardRef, useCallback, useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { Surface, cn, surfaceClasses, useEffectiveSurface, } from '../common'; export interface PixelMenubarItem { value: string; label: string; icon?: React.ReactNode; shortcut?: string; onSelect?: () => void; submenu?: PixelMenubarItem[]; separator?: boolean; disabled?: boolean; } export interface PixelMenubarMenu { label: string; items: PixelMenubarItem[]; } export interface PixelMenubarProps extends React.HTMLAttributes { menus: PixelMenubarMenu[]; surface?: Surface; } /** Index of a real (non-separator, enabled) item among items in a menu. */ function firstFocusableIndex(items: PixelMenubarItem[]): number { for (let i = 0; i < items.length; i += 1) { const it = items[i]; if (!it.separator && !it.disabled) return i; } return -1; } function nextFocusableIndex(items: PixelMenubarItem[], from: number, dir: 1 | -1): number { const n = items.length; if (n === 0) return -1; let i = from; for (let step = 0; step < n; step += 1) { i = (i + dir + n) % n; const it = items[i]; if (!it.separator && !it.disabled) return i; } return from; } export const PixelMenubar = forwardRef( function PixelMenubar( { menus, surface: surfaceProp, className, onKeyDown, ...rest }, forwardedRef, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const baseId = useId(); const [openMenu, setOpenMenu] = useState(null); const [activeItem, setActiveItem] = useState(-1); const [openSubmenuItem, setOpenSubmenuItem] = useState(null); const rootRef = useRef(null); const triggerRefs = useRef>([]); const setRefs = useCallback( (node: HTMLDivElement | null) => { rootRef.current = node; if (typeof forwardedRef === 'function') forwardedRef(node); else if (forwardedRef && typeof forwardedRef === 'object') { (forwardedRef as React.MutableRefObject).current = node; } }, [forwardedRef], ); const closeAll = useCallback(() => { setOpenMenu(null); setActiveItem(-1); setOpenSubmenuItem(null); }, []); const openMenuAt = useCallback( (idx: number) => { const menu = menus[idx]; if (!menu) return; setOpenMenu(idx); setActiveItem(firstFocusableIndex(menu.items)); setOpenSubmenuItem(null); }, [menus], ); // Click outside closes all useEffect(() => { if (openMenu === null) return; const listener = (e: PointerEvent) => { const target = e.target as Node | null; if (!target) return; if (rootRef.current?.contains(target)) return; closeAll(); }; document.addEventListener('pointerdown', listener); return () => document.removeEventListener('pointerdown', listener); }, [openMenu, closeAll]); const handleTriggerClick = (idx: number) => { if (openMenu === idx) { closeAll(); } else { openMenuAt(idx); } }; const handleTriggerMouseEnter = (idx: number) => { if (openMenu !== null && openMenu !== idx) { openMenuAt(idx); } }; const activateItem = (item: PixelMenubarItem) => { if (item.disabled || item.separator) return; if (item.submenu && item.submenu.length > 0) return; item.onSelect?.(); closeAll(); }; const handleRootKeyDown = (e: ReactKeyboardEvent) => { onKeyDown?.(e); if (e.defaultPrevented) return; // Escape closes any open menu if (e.key === 'Escape') { if (openMenu !== null) { e.preventDefault(); closeAll(); } return; } // Horizontal navigation between top-level menus if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { // If a submenu parent is active and Right is pressed → open submenu if ( e.key === 'ArrowRight' && openMenu !== null && activeItem >= 0 && menus[openMenu]?.items[activeItem]?.submenu ) { e.preventDefault(); setOpenSubmenuItem(activeItem); return; } // If submenu is open and Left is pressed → close submenu if (e.key === 'ArrowLeft' && openSubmenuItem !== null) { e.preventDefault(); setOpenSubmenuItem(null); return; } e.preventDefault(); const dir = e.key === 'ArrowRight' ? 1 : -1; const current = openMenu ?? 0; const next = (current + dir + menus.length) % menus.length; openMenuAt(next); // Move focus to the new trigger triggerRefs.current[next]?.focus(); return; } // Vertical navigation within current menu if (openMenu !== null) { const items = menus[openMenu].items; if (e.key === 'ArrowDown') { e.preventDefault(); const from = activeItem < 0 ? -1 : activeItem; setActiveItem(nextFocusableIndex(items, from, 1)); setOpenSubmenuItem(null); return; } if (e.key === 'ArrowUp') { e.preventDefault(); const from = activeItem < 0 ? items.length : activeItem; setActiveItem(nextFocusableIndex(items, from, -1)); setOpenSubmenuItem(null); return; } if (e.key === 'Home') { e.preventDefault(); setActiveItem(firstFocusableIndex(items)); return; } if (e.key === 'End') { e.preventDefault(); // last focusable let last = -1; for (let i = items.length - 1; i >= 0; i -= 1) { if (!items[i].separator && !items[i].disabled) { last = i; break; } } setActiveItem(last); return; } if (e.key === 'Enter' || e.key === ' ') { if (activeItem >= 0) { const item = items[activeItem]; if (item.submenu && item.submenu.length > 0) { e.preventDefault(); setOpenSubmenuItem(activeItem); return; } e.preventDefault(); activateItem(item); } } } }; const triggerId = (idx: number) => `${baseId}-trigger-${idx}`; const menuId = (idx: number) => `${baseId}-menu-${idx}`; const itemId = (mIdx: number, iIdx: number) => `${baseId}-item-${mIdx}-${iIdx}`; return (
{menus.map((menu, mIdx) => { const isOpen = openMenu === mIdx; return (
{isOpen && ( ); }, ); PixelMenubar.displayName = 'PixelMenubar';