import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, For, env } from "@solidrt/core" import type { LayoutProps, PointerEvent } from "@solidrt/core" import { createPress } from "./press" import { theme } from "./theme" import { policy } from "./policy" import { space } from "./spacing" import { typeStyle } from "./typography" export interface ContextMenuItem { label: string onSelect?: () => void disabled?: boolean } export interface ContextMenuProps { items: ContextMenuItem[] // The content the menu attaches to. children?: any layout?: LayoutProps } const LONG_PRESS_MS = 500 // Finger travel (window px) that cancels a pending long-press. const MOVE_SLOP = 8 // Minimum distance kept between the menu and the window edges. const MARGIN = 4 const MIN_WIDTH = 120 /** * Secondary actions on the wrapped content. The opening gesture follows the * physical pointer: right-click for a mouse, long-press for touch. The * presentation forks on the interaction policy: touch gets a bottom sheet over * a scrim, desktop/hybrid an anchored menu at the pointer. Pressing outside * closes without selecting. */ export function ContextMenu(props: ContextMenuProps) { let [open, setOpen] = createSignal(false) let [point, setPoint] = createSignal({ x: 0, y: 0 }) let timer: ReturnType | undefined let downAt: { x: number; y: number } | undefined let openAt = (x: number, y: number) => { setPoint({ x, y }) setOpen(true) } let cancelHold = () => { clearTimeout(timer) downAt = undefined } onCleanup(cancelHold) let handleDown = (e: PointerEvent) => { if (e.button === 2) { cancelHold() openAt(e.clientX, e.clientY) } else if (e.pointerType === "touch") { downAt = { x: e.clientX, y: e.clientY } clearTimeout(timer) timer = setTimeout(() => { if (downAt) openAt(downAt.x, downAt.y) downAt = undefined }, LONG_PRESS_MS) } } let handleMove = (e: PointerEvent) => { if (!downAt) return if (Math.abs(e.clientX - downAt.x) > MOVE_SLOP || Math.abs(e.clientY - downAt.y) > MOVE_SLOP) cancelHold() } let choose = (item: ContextMenuItem) => { setOpen(false) item.onSelect?.() } let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 }) let ItemRow = (p: { item: ContextMenuItem; padY: number }) => { let press = createPress({ onPress: () => choose(p.item) }) return ( {p.item.label} ) } // Anchored at the opening pointer position, flipping up when it would run // off the bottom. Same reflow-free placement as Tooltip/Select: portal at // the window root, measured in onLayout, moved with x/y paint transforms. let Menu = () => { let menu: { id: number } | undefined let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null) onLayout(() => { let b = menu && getBoundingBox(menu) if (!b) return let p = point() let x = Math.round(Math.min(Math.max(p.x, MARGIN), env.windowSize.width - b.width - MARGIN)) let y = Math.round( Math.max(p.y + b.height > env.windowSize.height - MARGIN ? p.y - b.height : p.y, MARGIN), ) let cur = pos() if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y }) }) return createPortal( setOpen(false)} /> (menu = n)} position="absolute" top={0} left={0} x={pos()?.x ?? -10000} y={pos()?.y ?? 0} minWidth={MIN_WIDTH} flexDirection="column" paddingTop={theme.spacing.sm} paddingBottom={theme.spacing.sm} > {(item: ContextMenuItem) => } , ) } // Bottom sheet over a scrim; content is a sibling of the scrim (Modal's // trick) so a row press never has the scrim on its hit path. let Sheet = () => createPortal( setOpen(false)}> {(item: ContextMenuItem) => } , ) return ( {props.children} }> ) }