/** @jsxImportSource react */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { HostMenuItem, HostMenuProps } from "../../../../ui/host"; import { CheckboxBox } from "./controls"; import { WebIcon } from "./icons"; const MENU_KEYS = new Set(["ArrowDown", "ArrowUp", "Home", "End", "PageUp", "PageDown", "Enter", " ", "j", "k"]); function selectable(item: HostMenuItem): boolean { return item.kind !== "divider" && item.kind !== "heading" && !item.disabled; } /** Desktop Menu: the one list every popover menu in the app renders. */ export function WebMenu({ items, onSelect, selection = "none", title, label, onClose, highlightedId, onHighlight }: HostMenuProps) { const enabled = useMemo(() => items.filter(selectable), [items]); const controlled = highlightedId !== undefined; const [ownHighlighted, setOwnHighlighted] = useState( () => items.find((item) => item.selected && selectable(item))?.id ?? enabled[0]?.id ?? null, ); const highlighted = controlled ? highlightedId : ownHighlighted; const setHighlighted = useCallback((id: string | null) => { if (controlled) { if (id) onHighlight?.(id); } else { setOwnHighlighted(id); } }, [controlled, onHighlight]); const listRef = useRef(null); const choose = useCallback((id: string) => { onSelect(id); }, [onSelect]); // The popover takes focus when it opens, so its keys arrive at the document. // Stop them there or the pane behind the menu moves its cursor too. useEffect(() => { if (controlled) return; const handleKeyDown = (event: KeyboardEvent) => { if (!MENU_KEYS.has(event.key)) return; event.preventDefault(); event.stopPropagation(); const index = enabled.findIndex((item) => item.id === highlighted); if (event.key === "ArrowDown" || event.key === "j") setHighlighted(enabled[Math.min(enabled.length - 1, index + 1)]?.id ?? null); else if (event.key === "ArrowUp" || event.key === "k") setHighlighted(enabled[Math.max(0, index - 1)]?.id ?? null); else if (event.key === "Home" || event.key === "PageUp") setHighlighted(enabled[0]?.id ?? null); else if (event.key === "End" || event.key === "PageDown") setHighlighted(enabled[enabled.length - 1]?.id ?? null); else if (highlighted) choose(highlighted); }; document.addEventListener("keydown", handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true); }, [choose, controlled, enabled, highlighted, setHighlighted]); useEffect(() => { listRef.current?.querySelector("[data-highlighted=true]")?.scrollIntoView({ block: "nearest" }); }, [highlighted]); return (
{ if (event.key === "Escape") onClose?.(); }} > {title ?
{title}
: null} {items.map((item) => { if (item.kind === "divider") return
; if (item.kind === "heading") return
{item.label}
; // A plain menu item with a `checked` state is a toggle, like a native menu's. const toggle = selection === "none" && item.checked !== undefined; const role = selection === "none" ? (toggle ? "menuitemcheckbox" : "menuitem") : "option"; return (
{ if (!item.disabled) setHighlighted(item.id); }} onMouseDown={(event) => { event.preventDefault(); event.stopPropagation(); }} onClick={(event) => { event.stopPropagation(); if (!item.disabled) choose(item.id); }} > {(selection === "multi" || toggle) && ( )} {item.label} {item.description ? {item.description} : null} {item.hint ? {item.hint} : null} {selection === "single" && item.selected ? : null}
); })}
); }