import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — an area that opens the menu on right-click * (desktop) or long-press (touch). Solid 2 port of Base UI React's * `packages/react/src/context-menu/trigger/ContextMenuTrigger.tsx`. * * Renders a `
` (not a button) — the interaction is delegated via * native `contextmenu` and `touchstart`/`touchmove`/`touchend`/`touchcancel` * events. On contextmenu (or after a 500ms long-press on touch), writes * a virtual anchor into the `` context and calls * `setOpen(true, …)` via the actionsRef supplied by ``. * * ### Port deviations * * - React wraps the mouseup late-dismissal listener via * `addEventListener` from `@base-ui/utils`. The Solid port uses * `document.addEventListener` + manual `removeEventListener`, which * is semantically identical (both pass options). The `{ once: true }` * option is preserved so the listener auto-removes after the first * mouseup. * - React calls `stopEvent(event)` from `floating-ui-react/utils` on * contextmenu. In Solid we inline the two calls (`preventDefault` + * `stopPropagation`) for parity. * - `findRootOwnerId` traversal on mouseup — * detects mouseup events that originated inside a nested menu owned * by this context-menu root so the outer open isn't cancelled. */ import { dynamic } from "@solidjs/web"; import { omit, onCleanup } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { contains } from "../../internals/dom/contains"; import { getTarget } from "../../internals/dom/get-target"; import { ownerDocument } from "../../internals/dom/owner-document"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { useTimeout } from "../../internals/use-timeout"; import { useMenuRootContext } from "../../menu/root/menu-root-context"; import { findRootOwnerId } from "../../menu/utils/find-root-owner-id"; import type { ContextMenuAnchor } from "../root/context-menu-root-context"; import { useContextMenuRootContext } from "../root/context-menu-root-context"; const LONG_PRESS_DELAY = 500; const LONG_PRESS_MOVE_THRESHOLD = 10; export interface ContextMenuTriggerState { /** Whether the context menu is currently open. */ open: boolean; } export interface ContextMenuTriggerProps extends BaseUIComponentProps<"div", ContextMenuTriggerState> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; } export function ContextMenuTrigger( props: ContextMenuTriggerProps, ): JSX.Element { const ctxMenu = useContextMenuRootContext(); const menu = useMenuRootContext(); let triggerElement: HTMLDivElement | null = null; let touchPosition: { x: number; y: number } | null = null; let allowMouseUp = false; const longPressTimeout = useTimeout(); const allowMouseUpTimeout = useTimeout(); function openAtPoint( x: number, y: number, event: MouseEvent | TouchEvent, ): void { const isTouchEvent = event.type.startsWith("touch"); ctxMenu.initialCursorPointRef.current = { x, y }; const anchor: ContextMenuAnchor = { getBoundingClientRect(): DOMRect { const width = isTouchEvent ? 10 : 0; const height = isTouchEvent ? 10 : 0; if (typeof DOMRect !== "undefined") { return DOMRect.fromRect({ width, height, x, y }); } return { width, height, x, y, top: y, right: x + width, bottom: y + height, left: x, toJSON: () => ({}), } as DOMRect; }, }; ctxMenu.setAnchor(anchor); allowMouseUp = false; ctxMenu.actionsRef.current?.setOpen( true, createChangeEventDetails(REASONS.triggerPress, event) as Parameters< NonNullable["setOpen"] >[1], ); allowMouseUpTimeout.start(LONG_PRESS_DELAY, () => { allowMouseUp = true; }); } function handleContextMenu(event: MouseEvent): void { if (menu.disabled()) { return; } ctxMenu.allowMouseUpTriggerRef.current = true; event.preventDefault(); event.stopPropagation(); openAtPoint(event.clientX, event.clientY, event); const doc = ownerDocument(triggerElement); const onMouseUp = (mouseEvent: MouseEvent): void => { // The `{ once: true }` listener auto-removes after firing; clear the // tracking refs so the component-level cleanup doesn't double-remove. activeMouseUpDoc = null; activeMouseUpHandler = null; ctxMenu.allowMouseUpTriggerRef.current = false; if (!allowMouseUp) { return; } allowMouseUpTimeout.clear(); allowMouseUp = false; const mouseUpTarget = getTarget(mouseEvent) as Element | null; if (contains(ctxMenu.positionerRef.current, mouseUpTarget)) { return; } // Nested-menu mouseup detection. If the mouseup // landed inside a nested menu popup whose `data-rootownerid` // chain traces back to this context-menu root, treat it as // "inside" — don't cancel the open. if ( ctxMenu.rootId && mouseUpTarget && findRootOwnerId(mouseUpTarget) === ctxMenu.rootId ) { return; } ctxMenu.actionsRef.current?.setOpen( false, createChangeEventDetails(REASONS.cancelOpen, mouseEvent) as Parameters< NonNullable["setOpen"] >[1], ); }; // Remove any prior pending listener before registering a new one (e.g. // a second right-click before the first mouseup fired). if (activeMouseUpDoc && activeMouseUpHandler) { activeMouseUpDoc.removeEventListener("mouseup", activeMouseUpHandler); } activeMouseUpDoc = doc; activeMouseUpHandler = onMouseUp; doc.addEventListener("mouseup", onMouseUp, { once: true }); } function handleTouchStart(event: TouchEvent): void { if (menu.disabled()) { return; } ctxMenu.allowMouseUpTriggerRef.current = false; if (event.touches.length === 1) { event.stopPropagation(); const touch = event.touches[0]; if (!touch) return; touchPosition = { x: touch.clientX, y: touch.clientY }; longPressTimeout.start(LONG_PRESS_DELAY, () => { if (touchPosition) { openAtPoint(touchPosition.x, touchPosition.y, event); } }); } } function handleTouchMove(event: TouchEvent): void { if ( longPressTimeout.isStarted() && touchPosition && event.touches.length === 1 ) { const touch = event.touches[0]; if (!touch) return; const deltaX = Math.abs(touch.clientX - touchPosition.x); const deltaY = Math.abs(touch.clientY - touchPosition.y); if ( deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD ) { longPressTimeout.clear(); } } } function handleTouchEnd(): void { longPressTimeout.clear(); touchPosition = null; } // Pending mouseup late-dismissal listener (registered in `handleContextMenu`). // Tracked at component scope so an unmount between right-click and mouseup // removes it — otherwise the `{ once: true }` listener leaks, pinning the // handler's closures. let activeMouseUpDoc: Document | null = null; let activeMouseUpHandler: ((event: MouseEvent) => void) | null = null; onCleanup(() => { if (activeMouseUpDoc && activeMouseUpHandler) { activeMouseUpDoc.removeEventListener("mouseup", activeMouseUpHandler); activeMouseUpDoc = null; activeMouseUpHandler = null; } }); const setRef = (el: HTMLDivElement | null): void => { triggerElement = el; }; const internalProps = { ref: setRef, onContextMenu: handleContextMenu, onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, onTouchCancel: handleTouchEnd, style: { "-webkit-touch-callout": "none", }, get "data-popup-open"(): "" | undefined { return menu.open() ? "" : undefined; }, get "data-pressed"(): "" | undefined { return menu.open() ? "" : undefined; }, }; const userStyling = resolveClassStyle(props, () => ({ open: menu.open() })); const userProps = omit(props, "class", "style", "attrs", "as") as Record< string, unknown >; const styledUserProps = mergeRootProps(userStyling, userProps); const merged = mergeRootProps(internalProps, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ; }