329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
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-white rounded-default shadow-lg border border-neutral-200 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-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 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-neutral-200";
|
|
const SECTION_CLS = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 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-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900";
|
|
|
|
interface MenuContextValue {
|
|
openOnHover: boolean;
|
|
hoverCloseDelay: number;
|
|
closeMenu: () => void;
|
|
cancelParentClose?: () => void;
|
|
}
|
|
|
|
const MenuContext = createContext<MenuContextValue | null>(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 (
|
|
<FloatingRoot open={props.open} onOpenChange={(v: boolean) => props.onOpenChange?.(v)} placement={props.placement ?? "bottom-start"} offset={props.offset ?? 4}>
|
|
<MenuContextProvider openOnHover={props.openOnHover ?? false} hoverCloseDelay={props.hoverCloseDelay ?? 150}>
|
|
{props.children}
|
|
</MenuContextProvider>
|
|
</FloatingRoot>
|
|
);
|
|
}
|
|
|
|
interface MenuContextProviderProps {
|
|
openOnHover: boolean;
|
|
hoverCloseDelay: number;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
function MenuContextProvider(props: MenuContextProviderProps) {
|
|
const { setIsOpen, cancelHoverClose } = useFloatingContext();
|
|
|
|
const closeMenu = () => setIsOpen(false);
|
|
|
|
return (
|
|
<MenuContext.Provider value={{
|
|
get openOnHover() { return props.openOnHover; },
|
|
get hoverCloseDelay() { return props.hoverCloseDelay; },
|
|
closeMenu,
|
|
cancelParentClose: cancelHoverClose,
|
|
}}>
|
|
{props.children}
|
|
</MenuContext.Provider>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<FloatingTrigger class={props.class || ""} openOnHover={menuCtx.openOnHover} hoverCloseDelay={menuCtx.hoverCloseDelay}>
|
|
{resolvedChildren()}
|
|
</FloatingTrigger>
|
|
);
|
|
}
|
|
|
|
interface MenuContentProps {
|
|
class?: string;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function MenuContent(props: MenuContentProps) {
|
|
const menuCtx = useMenuContext();
|
|
const hoverProps = useFloatingHover(menuCtx.openOnHover, menuCtx.hoverCloseDelay);
|
|
|
|
return (
|
|
<FloatingContent class={MENU_CLS + " " + (props.class || "")} onMouseEnter={hoverProps.onMouseEnter} onMouseLeave={hoverProps.onMouseLeave}>
|
|
{props.children}
|
|
</FloatingContent>
|
|
);
|
|
}
|
|
|
|
interface MenuItemProps {
|
|
icon?: string;
|
|
disabled?: boolean;
|
|
onclick?: ((_e: MouseEvent) => Promise<void>) | ((_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 (
|
|
<button type="button" role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={handleClick} onKeyDown={handleKeyDown} disabled={props.disabled}>
|
|
<Show when={props.icon}>
|
|
<Icon icon={props.icon!} size={16} class="shrink-0"/>
|
|
</Show>
|
|
{props.children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface MenuLinkProps {
|
|
href: string;
|
|
icon?: string;
|
|
class?: string;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function MenuLink(props: MenuLinkProps) {
|
|
const { closeMenu } = useMenuContext();
|
|
|
|
return (
|
|
<a href={props.href} role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={closeMenu}>
|
|
<Show when={props.icon}>
|
|
<Icon icon={props.icon!} size={16} class="shrink-0"/>
|
|
</Show>
|
|
{props.children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
interface MenuAnchorProps extends MenuLinkProps {
|
|
target?: string;
|
|
rel?: string;
|
|
}
|
|
|
|
export function MenuAnchor(props: MenuAnchorProps) {
|
|
const { closeMenu } = useMenuContext();
|
|
|
|
return (
|
|
<a href={props.href} target={props.target ?? "_blank"} rel={props.rel ?? "noopener noreferrer"} role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={closeMenu}>
|
|
<Show when={props.icon}>
|
|
<Icon icon={props.icon!} size={16} class="shrink-0"/>
|
|
</Show>
|
|
{props.children}
|
|
<Show when={props.target === "_blank"}>
|
|
<Icon icon="arrow-right" size={12} class="shrink-0 ml-auto text-neutral-400"/>
|
|
</Show>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
export function MenuDivider(props: { class?: string }) {
|
|
return <hr class={DIVIDER_CLS + " " + (props.class || "")} role="separator"/>;
|
|
}
|
|
|
|
interface MenuSectionProps {
|
|
class?: string;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function MenuSection(props: MenuSectionProps) {
|
|
return (
|
|
<div class={SECTION_CLS + " " + (props.class || "")} role="presentation">
|
|
{props.children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<SubmenuPosition | null>(null);
|
|
let closeTimeoutRef: ReturnType<typeof setTimeout> | 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 (
|
|
<MenuContext.Provider value={{
|
|
openOnHover: parentMenu.openOnHover,
|
|
hoverCloseDelay: parentMenu.hoverCloseDelay,
|
|
closeMenu: parentMenu.closeMenu,
|
|
cancelParentClose: clearCloseTimeout,
|
|
}}>
|
|
<div ref={(el: HTMLDivElement) => triggerRef = el} role="menuitem" aria-haspopup="menu" aria-expanded={isOpen()} class={SUBMENU_TRIGGER_CLS + " " + (props.class || "")} onMouseEnter={handleTriggerMouseEnter} onMouseLeave={handleMouseLeave} onclick={handleClick}>
|
|
<span class="flex items-center gap-2">
|
|
<Show when={props.icon}>
|
|
<Icon icon={props.icon!} size={16} class="shrink-0"/>
|
|
</Show>
|
|
{props.trigger}
|
|
</span>
|
|
<Icon icon="chevron-right" size={16} class="shrink-0 ml-auto text-neutral-400"/>
|
|
</div>
|
|
|
|
<div ref={(el: HTMLDivElement) => 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}
|
|
</div>
|
|
</MenuContext.Provider>
|
|
);
|
|
}
|
|
|
|
interface MenuGroupProps {
|
|
class?: string;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function MenuGroup(props: MenuGroupProps) {
|
|
return <div role="group" class={props.class || ""}>{props.children}</div>;
|
|
}
|