import type { JSX, ValidComponent } from "@solidjs/web";
/**
* `
` — the button that opens the menu. Solid 2 port of Base
* UI React's `MenuTrigger` (`packages/react/src/menu/trigger/MenuTrigger.tsx`).
*
* Renders a `` with `aria-haspopup="menu"`, `aria-expanded`, and
* `aria-controls` pointing at the popup.
*
* ### Wiring
*
* - Self-registers in the `MenuStore`'s `PopupTriggerMap` so
* `MenuHandle.open(id)` can look the trigger up.
* - Spreads `openInteractionTypeProps` from the root so
* `useOpenInteractionType` captures `openMethod` at click/pointerdown
* time.
* - When `isOpenedByThisTrigger`, wraps its element in two `FocusGuard`
* sentinels that catch Tab / Shift+Tab out of the trigger
* while the menu is open and redirect through `useTriggerFocusGuards`.
*
* ### Port deviations
*
* - Hover-to-open is wired via `useHoverReferenceInteraction` when
* `openOnHover` is set.
* - The 200ms mouseup-on-item window (which lets users drag from the
* trigger to an item and release to commit) is not ported — not
* testable in happy-dom.
*/
import { dynamic } from "@solidjs/web";
import { createMemo, createSignal, omit, onCleanup, untrack } from "solid-js";
import { useBaseUiId } from "../../internals/base-ui-id";
import { composeRefs, userRef } from "../../internals/compose-refs";
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 { FocusGuard } from "../../internals/popups/focus-guard";
import {
type TriggerFocusGuardStore,
useTriggerFocusGuards,
} from "../../internals/popups/use-trigger-focus-guards";
import { resolveClassStyle } from "../../internals/resolve-class-style";
import type {
BaseUIComponentProps,
NativeButtonProps,
} from "../../internals/types";
import { useButton } from "../../internals/use-button/use-button";
import { useMenubarContext } from "../../menubar/menubar-context";
import { useMenuRootContext } from "../root/menu-root-context";
import type { MenuHandle } from "../store/menu-store";
export interface MenuTriggerState {
/** Whether the menu is currently open. */
open: boolean;
/** Whether the trigger is disabled. */
disabled: boolean;
}
export interface MenuTriggerProps
extends NativeButtonProps,
BaseUIComponentProps<"button", MenuTriggerState> {
/**
* Tag/component to render. Defaults to `"button"`. Replaces the
* pre-spike `render` prop (axis G).
*/
as?: ValidComponent;
/** Whether the component should ignore user interaction. @default false */
disabled?: boolean | undefined;
/**
* Optional handle this trigger should register with. Register against
* the handle's store instead of the enclosing root's store when
* supplied.
*/
handle?: MenuHandle | undefined;
/**
* Payload to associate with this trigger. When this trigger opens the
* menu, the payload is written into the root; render-function children
* receive it as `{ payload }`.
*/
payload?: Payload | undefined;
/**
* Whether the menu should also open when the trigger is hovered.
*/
openOnHover?: boolean | undefined;
/**
* How long to wait before hover-opens the menu (ms).
* @default 100
*/
delay?: number | undefined;
/** @default 0 */
closeDelay?: number | undefined;
}
export function MenuTrigger(
props: MenuTriggerProps,
): JSX.Element {
const root = useMenuRootContext();
const fallbackId = useBaseUiId();
const resolvedId = (): string | undefined =>
typeof props.id === "string" ? props.id : fallbackId;
const disabled = (): boolean => Boolean(props.disabled) || root.disabled();
const nativeButton = (): boolean => props.nativeButton ?? true;
const { props: buttonProps, ref: buttonRef } = useButton({
disabled,
focusableWhenDisabled: () => false,
native: nativeButton,
});
// Determine which store to register into — handle's if supplied, else
// the root's. Handle takes precedence so consumers opt into a shared
// registry by passing the same handle on trigger + root.
const store = () => props.handle?.store ?? root.store;
// Trigger DOM element ref — used by hover + focus-guard hooks.
const triggerElementRef: MutableRef = { current: null };
const [triggerElement, setTriggerElement] = createSignal(
null,
{ ownedWrite: true },
);
// Active-trigger flag — `true` when this trigger is the one that opened
// the menu. Drives `data-open` + focus-guard render.
const isActive = createMemo(() => {
const id = resolvedId();
if (id === undefined) return true;
return root.activeTriggerId() === id;
});
const isOpenedByThisTrigger = createMemo(
() => root.open() && isActive(),
);
// Register in the trigger map on mount; unregister on cleanup.
let registeredId: string | null = null;
let registeredElement: HTMLElement | null = null;
const setTriggerRef = (el: HTMLElement | null): void =>
untrack(() => {
const id = resolvedId();
const s = store();
triggerElementRef.current = el;
setTriggerElement(el);
if (registeredId !== null && registeredElement !== null) {
const current = s.context.triggerElements.getById(registeredId);
if (current === registeredElement) {
s.context.triggerElements.delete(registeredId);
}
registeredId = null;
registeredElement = null;
}
if (el && id !== undefined) {
s.context.triggerElements.add(id, el);
registeredId = id;
registeredElement = el;
}
if (el) {
// Keep the root's active-trigger slot in sync for single-trigger
// setups and for positioner anchoring.
if (id === undefined || isActive()) {
root.setActiveTriggerElement(el);
}
if (id !== undefined && root.activeTriggerId() == null) {
root.setActiveTriggerId(id);
}
}
// Forward the payload when this trigger is (or becomes) the active one.
if ("payload" in props && (id === undefined || isActive())) {
root.setPayload(props.payload as Payload | undefined);
}
});
onCleanup(() => {
if (registeredId !== null && registeredElement !== null) {
const s = store();
const current = s.context.triggerElements.getById(registeredId);
if (current === registeredElement) {
s.context.triggerElements.delete(registeredId);
}
}
});
// Inside a menubar, once any of its menus is open, hovering another
// trigger hands off to that trigger's menu (Base UI parity).
const menubar = useMenubarContext(true);
const inMenubar = (): boolean =>
menubar != null && root.parentType() === "menubar";
const menubarHasMenuOpen = (): boolean =>
inMenubar() && (menubar?.hasSubmenuOpen() ?? false);
const hoverProps = useHoverReferenceInteraction(root.floatingRootContext, {
enabled: () =>
(props.openOnHover ?? menubarHasMenuOpen()) &&
!disabled() &&
root.parentType() !== "context-menu" &&
(!inMenubar() || (menubarHasMenuOpen() && !isOpenedByThisTrigger())),
mouseOnly: true,
move: false,
handleClose: safePolygon(),
delay: (): Delay => ({
open: props.delay ?? (inMenubar() ? 0 : 100),
close: props.closeDelay ?? 0,
}),
triggerElementRef,
triggerElement,
});
// Click hook — latched enabled (papercut #2). Disable-reactivity is
// covered by the fact that the button itself refuses clicks when
// `disabled()` returns true via `useButton`.
const click = useClick(root.floatingRootContext, {
enabled: true,
event: "mousedown",
toggle: true,
// In a menubar, a click on a trigger whose menu opened on hover closes
// it (Base UI parity); elsewhere a hover-opened menu sticks.
stickIfOpen: !untrack(inMenubar),
});
const localInteractions = useInteractions([
hoverProps ? { reference: hoverProps } : undefined,
click,
]);
const state = createMemo(() => ({
open: isOpenedByThisTrigger(),
disabled: disabled(),
}));
// onClick capture — write the active-trigger id + payload when this
// trigger originates the open. `useClick` toggles open via its own
// merged handler; we run alongside to capture the identity.
const markActive = (): void => {
const id = resolvedId();
if (id !== undefined) {
root.setActiveTriggerId(id);
}
if ("payload" in props) {
root.setPayload(props.payload as Payload | undefined);
}
if (triggerElementRef.current) {
root.setActiveTriggerElement(triggerElementRef.current);
}
};
const handleTriggerClick = (event: MouseEvent): void => {
markActive();
void event;
};
// Focus-guard integration. We build a `TriggerFocusGuardStore`
// on the fly that satisfies the structural shape the hook expects —
// the real `MenuStore` in this port doesn't yet carry state selectors,
// so we project through the root context instead.
const focusGuardStore: TriggerFocusGuardStore = {
setOpen: (openNext, details) => {
root.setOpen(openNext, details);
},
select: (key) => {
if (key === "positionerElement") return root.positionerRef.current;
return null;
},
context: {
beforeContentFocusGuardRef: root.beforeContentFocusGuardRef,
triggerFocusTargetRef: root.triggerFocusTargetRef,
},
};
const { preFocusGuardRef, handlePreFocusGuardFocus, handleFocusTargetFocus } =
useTriggerFocusGuards(focusGuardStore, triggerElementRef);
const setPreFocusGuardRef = (el: HTMLSpanElement | null): void => {
preFocusGuardRef.current = el;
};
const setPostFocusGuardRef = (el: HTMLSpanElement | null): void => {
root.triggerFocusTargetRef.current = el;
};
const internal = {
onClick: handleTriggerClick,
onPointerEnter: markActive,
onFocus: markActive,
get id() {
return resolvedId();
},
"aria-haspopup": "menu",
get role() {
// Menubar-nested triggers announce as `menuitem`; top-level
// triggers stay on the native `button` role.
return root.parentType() === "menubar" ? "menuitem" : undefined;
},
get "aria-expanded"() {
return root.open() ? "true" : "false";
},
get "aria-controls"() {
// Seed "" so Solid's spread subscribes. Returns
// the popup id once the popup mounts.
if (!root.open()) return "";
return root.popupRef.current?.id ?? "";
},
get "data-popup-open"() {
return state().open ? "" : undefined;
},
get "data-pressed"() {
return state().open ? "" : undefined;
},
get "data-disabled"() {
return state().disabled ? "" : undefined;
},
};
const userClassStyle = resolveClassStyle(props, () => state());
const userProps = omit(
props,
"as",
"class",
"style",
"attrs",
"disabled",
"nativeButton",
"openOnHover",
"delay",
"closeDelay",
"handle",
"payload",
);
let merged: Record = mergeRootProps(
root.interactions.getReferenceProps(),
localInteractions.getReferenceProps(),
);
merged = mergeRootProps(merged, root.openInteractionTypeProps);
merged = mergeRootProps(merged, buttonProps);
merged = mergeRootProps(merged, internal);
merged = mergeRootProps(merged, userClassStyle);
merged = mergeRootProps(merged, userProps);
const Tag = dynamic(() => props.as ?? "button");
const element = (
);
return (
<>
{isOpenedByThisTrigger() ? (
) : null}
{element}
{isOpenedByThisTrigger() ? (
) : null}
>
);
}