import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — a menu item that opens a nested submenu. * Mirrors `packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx`. * * The submenu-trigger reads BOTH the parent menu root context (to register * into its composite-list) AND the inner `` / nested * `` context (to drive its own open state via `useClick`). * * ### Port deviations * * - Hover-to-open via `useHoverReferenceInteraction` when `openOnHover` * is set. * - `openOnArrowKeyDown` (Arrow-Right in the parent list opens the * submenu) is installed via a custom keydown handler. */ import { dynamic } from "@solidjs/web"; import { createMemo, createRenderEffect, createSignal, omit } from "solid-js"; import { useBaseUiId } from "../../internals/base-ui-id"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { useCompositeListItem } from "../../internals/composite/composite-list"; import { useClick } from "../../internals/floating-ui/hooks/use-click"; import { useHoverReferenceInteraction } from "../../internals/floating-ui/hooks/use-hover-reference-interaction"; import { useInteractions } from "../../internals/floating-ui/hooks/use-interactions"; import { safePolygon } from "../../internals/floating-ui/safe-polygon"; import type { Delay, MutableRef } from "../../internals/floating-ui/types"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps, NonNativeButtonProps, } from "../../internals/types"; import { useButton } from "../../internals/use-button/use-button"; import { useMenuRootContext } from "../root/menu-root-context"; import { useMenuSubmenuRootContext } from "../submenu-root/menu-submenu-root-context"; import { createItemHoverEmitter } from "../utils/item-hover"; export interface MenuSubmenuTriggerState { disabled: boolean; highlighted: boolean; open: boolean; } export interface MenuSubmenuTriggerProps extends NonNativeButtonProps, Omit, "id"> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; id?: string | undefined; label?: string | undefined; /** @default false */ disabled?: boolean | undefined; /** Whether the submenu should open when the trigger is hovered. */ openOnHover?: boolean | undefined; /** @default 100 */ delay?: number | undefined; /** @default 0 */ closeDelay?: number | undefined; } export function MenuSubmenuTrigger( props: MenuSubmenuTriggerProps, ): JSX.Element { const root = useMenuRootContext(); // this is the NESTED menu root const submenu = useMenuSubmenuRootContext(); if (submenu == null) { throw new Error( "Base UI: must be placed inside .", ); } const parentRoot = submenu.parentMenu; // This trigger is an item of the parent menu, whose popup node is this // submenu's floating parent. const emitItemHover = createItemHoverEmitter( parentRoot, root.floatingParentNodeId(), ); createRenderEffect( () => props.closeDelay ?? 0, (delay) => { root.closeDelayRef.current = delay; }, ); const fallbackId = useBaseUiId(); const id = (): string | undefined => typeof props.id === "string" ? props.id : fallbackId; const disabled = (): boolean => Boolean(props.disabled) || root.disabled() || parentRoot.disabled(); const nativeButton = (): boolean => props.nativeButton ?? false; // Register into the PARENT menu's composite-list. const listItem = useCompositeListItem({ label: props.label }); const highlighted = createMemo( () => parentRoot.activeIndex() === listItem.index(), ); const { props: buttonProps, ref: buttonRef } = useButton({ disabled, focusableWhenDisabled: () => true, native: nativeButton, composite: () => true, }); const triggerElementRef: MutableRef = { current: null }; const [triggerElement, setTriggerElement] = createSignal( null, { ownedWrite: true }, ); const hoverProps = useHoverReferenceInteraction(root.floatingRootContext, { enabled: () => (props.openOnHover ?? true) && !disabled(), mouseOnly: true, move: false, handleClose: safePolygon(), delay: (): Delay => ({ open: props.delay ?? 100, close: props.closeDelay ?? 0, }), triggerElementRef, triggerElement, }); // Click + local interactions for opening the submenu. const click = useClick(root.floatingRootContext, { enabled: true, event: "mousedown", toggle: true, }); const localInteractions = useInteractions([ hoverProps ? { reference: hoverProps } : undefined, click, ]); const state = createMemo(() => ({ disabled: disabled(), highlighted: highlighted(), open: root.open(), })); const markActive = (): void => { if (triggerElementRef.current) { root.setActiveTriggerElement(triggerElementRef.current); } }; const setTriggerRef = (el: HTMLElement | null): void => { triggerElementRef.current = el; setTriggerElement(el); if (el) { root.setActiveTriggerElement(el); } }; const internal = { get id() { return id(); }, onPointerEnter: markActive, onMouseMove: emitItemHover, onFocus: markActive, role: "menuitem", "aria-haspopup": "menu", get "aria-expanded"() { return root.open() ? "true" : "false"; }, get tabindex() { return highlighted() || root.open() ? 0 : -1; }, onKeyDown(event: KeyboardEvent) { // Right arrow opens the submenu (horizontal orientation // would use down-arrow; happy-dom can't test pointer // geometry so we stick to ArrowRight / ArrowLeft). if (event.key === "ArrowRight" && !root.open() && !disabled()) { event.preventDefault(); root.setOpen( true, createChangeEventDetails(REASONS.listNavigation, event), ); } else if (event.key === "ArrowLeft" && root.open()) { event.preventDefault(); root.setOpen( false, createChangeEventDetails(REASONS.listNavigation, event), ); } }, get "data-popup-open"() { return state().open ? "" : undefined; }, get "data-pressed"() { return state().open ? "" : undefined; }, get "data-disabled"() { return state().disabled ? "" : undefined; }, get "data-highlighted"() { return state().highlighted ? "" : undefined; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit( props, "as", "class", "style", "attrs", "id", "disabled", "label", "openOnHover", "delay", "closeDelay", "nativeButton", ); let merged: Record = mergeRootProps( root.interactions.getReferenceProps(), localInteractions.getReferenceProps(), ); merged = mergeRootProps(merged, parentRoot.interactions.getItemProps()); merged = mergeRootProps(merged, buttonProps); merged = mergeRootProps(merged, internal); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); return ( ); }