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 (
);
}