import { createContext, useContext, createSignal, createEffect, onCleanup, Show, JSXElement } from "solid-js"; import { FloatingRoot, FloatingTrigger, FloatingContent, useFloatingContext, useFloatingHover, Placement } from "./Floating.tsx"; import { Icon } from "./Icons.tsx"; // Tailwind utility class groups for the menu UI — replaces the old // `.ui-menu` / `.item` / `.divider` / `.section` @scope CSS. These // are plain utility strings so they compose with any caller-supplied // classes via simple concatenation. // Padded container + inset, rounded items (the highlight is a rounded rectangle // that doesn't reach the menu edges) — matching the popover insert menus. const MENU_CLS = "bg-surface rounded-default shadow-lg border border-line p-1.5 min-w-48 max-h-96 overflow-y-auto"; const ITEM_CLS = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink bg-transparent border-0 cursor-pointer hover:bg-surface-raised hover:text-ink focus:bg-surface-raised focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"; // Divider runs edge-to-edge (negated container padding) for a clean separator. const DIVIDER_CLS = "my-1 -mx-1.5 border-0 border-t border-line"; const SECTION_CLS = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-ink-faint uppercase tracking-wide text-left"; const SUBMENU_TRIGGER_CLS = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink cursor-pointer hover:bg-surface-raised hover:text-ink"; interface MenuContextValue { openOnHover: boolean; hoverCloseDelay: number; closeMenu: () => void; cancelParentClose?: () => void; } const MenuContext = createContext(null); function useMenuContext(): MenuContextValue { const context = useContext(MenuContext); if (!context) { throw new Error("Menu components must be used within a Menu"); } return context; } interface MenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; placement?: Placement; offset?: number; openOnHover?: boolean; hoverCloseDelay?: number; children?: JSXElement; } export function Menu(props: MenuProps) { return ( props.onOpenChange?.(v)} placement={props.placement ?? "bottom-start"} offset={props.offset ?? 4}> {props.children} ); } interface MenuContextProviderProps { openOnHover: boolean; hoverCloseDelay: number; children?: JSXElement; } function MenuContextProvider(props: MenuContextProviderProps) { const { setIsOpen, cancelHoverClose } = useFloatingContext(); const closeMenu = () => setIsOpen(false); return ( {props.children} ); } interface MenuTriggerProps { asChild?: boolean; class?: string; children?: JSXElement | ((state: { isOpen: boolean }) => JSXElement); } export function MenuTrigger(props: MenuTriggerProps) { const menuCtx = useMenuContext(); const { isOpen } = useFloatingContext(); const resolvedChildren = () => typeof props.children === "function" ? (props.children as (state: { isOpen: boolean }) => JSXElement)({ isOpen: isOpen() }) : props.children; return ( {resolvedChildren()} ); } interface MenuContentProps { class?: string; children?: JSXElement; } export function MenuContent(props: MenuContentProps) { const menuCtx = useMenuContext(); const hoverProps = useFloatingHover(menuCtx.openOnHover, menuCtx.hoverCloseDelay); return ( {props.children} ); } interface MenuItemProps { icon?: string; disabled?: boolean; onclick?: ((_e: MouseEvent) => Promise) | ((_e: MouseEvent) => void); closeOnClick?: boolean; class?: string; children?: JSXElement; } export function MenuItem(props: MenuItemProps) { const { closeMenu } = useMenuContext(); const handleClick = (e?: MouseEvent) => { if (props.disabled) return; props.onclick?.(e); if (props.closeOnClick !== false) closeMenu(); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleClick(); } }; return ( ); } interface MenuLinkProps { href: string; icon?: string; class?: string; children?: JSXElement; } export function MenuLink(props: MenuLinkProps) { const { closeMenu } = useMenuContext(); return ( {props.children} ); } interface MenuAnchorProps extends MenuLinkProps { target?: string; rel?: string; } export function MenuAnchor(props: MenuAnchorProps) { const { closeMenu } = useMenuContext(); return ( {props.children} ); } export function MenuDivider(props: { class?: string }) { return ; } interface MenuSectionProps { class?: string; children?: JSXElement; } export function MenuSection(props: MenuSectionProps) { return ( ); } interface SubmenuProps { trigger: string; icon?: string; class?: string; children?: JSXElement; } interface SubmenuPosition { top: number; left: number; } export function Submenu(props: SubmenuProps) { const parentMenu = useMenuContext(); const [isOpen, setIsOpen] = createSignal(false); const [position, setPosition] = createSignal(null); let closeTimeoutRef: ReturnType | null = null; let triggerRef: HTMLDivElement | undefined; let contentRef: HTMLDivElement | undefined; const clearCloseTimeout = () => { if (closeTimeoutRef) { clearTimeout(closeTimeoutRef); closeTimeoutRef = null; } }; const scheduleClose = (delay: number) => { clearCloseTimeout(); closeTimeoutRef = setTimeout(() => setIsOpen(false), delay); }; const handleTriggerMouseEnter = () => { clearCloseTimeout(); parentMenu.cancelParentClose?.(); setIsOpen(true); }; const handleMouseLeave = () => scheduleClose(parentMenu.hoverCloseDelay); const handleContentMouseEnter = () => { clearCloseTimeout(); parentMenu.cancelParentClose?.(); }; const handleClick = () => setIsOpen((prev) => !prev); createEffect(() => { if (!isOpen() || !triggerRef) { setPosition(null); return; } const updatePosition = () => { if (!triggerRef) return; const triggerRect = triggerRef.getBoundingClientRect(); const contentEl = contentRef; let top = triggerRect.top; let left = triggerRect.right; if (contentEl) { const contentRect = contentEl.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; if (left + contentRect.width > vw - 8) left = triggerRect.left - contentRect.width; if (top + contentRect.height > vh - 8) top = vh - contentRect.height - 8; if (top < 8) top = 8; } setPosition({ top, left }); }; updatePosition(); window.addEventListener("scroll", updatePosition, true); window.addEventListener("resize", updatePosition); onCleanup(() => { window.removeEventListener("scroll", updatePosition, true); window.removeEventListener("resize", updatePosition); }); }); onCleanup(clearCloseTimeout); return (
triggerRef = el} role="menuitem" aria-haspopup="menu" aria-expanded={isOpen()} class={SUBMENU_TRIGGER_CLS + " " + (props.class || "")} onMouseEnter={handleTriggerMouseEnter} onMouseLeave={handleMouseLeave} onclick={handleClick}> {props.trigger}
contentRef = el} role="menu" class={MENU_CLS} style={{ position: "fixed", display: isOpen() ? "block" : "none", top: (position()?.top ?? 0) + "px", left: (position()?.left ?? 0) + "px", "z-index": 51, }} onMouseEnter={handleContentMouseEnter} onMouseLeave={handleMouseLeave}> {props.children}
); } interface MenuGroupProps { class?: string; children?: JSXElement; } export function MenuGroup(props: MenuGroupProps) { return
{props.children}
; }