{"version":3,"file":"ContextMenu.cjs","names":[],"sources":["../../../src/components/ContextMenu/ContextMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines function-lines — the body owns one anchor point and\n * everything that reads from it: the three ways a menu opens (right click, long\n * press, click), the clamp that keeps it inside the viewport, and roving focus\n * for the keyboard path. Splitting them would hand the same geometry to three\n * files.\n */\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport { Portal } from \"@/components/Portal\";\nimport styles from \"./ContextMenu.module.css\";\n\nexport type ContextMenuItem =\n    | {\n          label: ReactNode;\n          onSelect?: () => void;\n          disabled?: boolean;\n          danger?: boolean;\n      }\n    | { separator: true };\n\n/** How the menu is opened by a pointer. Touch always gets the long press. */\nexport type ContextMenuTrigger = \"contextmenu\" | \"click\" | \"both\";\n\nexport interface ContextMenuProps {\n    /** Menu entries — selectable items and separators. */\n    items: ContextMenuItem[];\n    /** Trigger area. Right-clicking anywhere within opens the menu at the cursor. */\n    children: ReactNode;\n    /** Extra class names forwarded to the menu element. */\n    className?: string;\n    /**\n     * Which pointer gesture opens the menu. Default `\"contextmenu\"`.\n     *\n     * `\"click\"` is the `⋮` button case, where a right click is the wrong gesture\n     * on the desktop and impossible on touch. `\"both\"` keeps the right click and\n     * adds the left one.\n     */\n    trigger?: ContextMenuTrigger;\n    /**\n     * Hold, in ms, that opens the menu from a coarse pointer. Default `500`.\n     *\n     * Set `0` to turn the long press off. It is on by default because touch has\n     * no right click: measured in Chrome with touch emulation (Pixel 7 and\n     * iPhone 13 profiles), a 900 ms hold fires `pointerdown`, `touchstart`,\n     * `pointerup`, `touchend` and `click` — and no `contextmenu` at all. Every\n     * action behind this menu was unreachable on a phone.\n     */\n    longPressDelay?: number;\n    /**\n     * Gap, in px, kept between the menu and the edge of the viewport. Default `8`.\n     */\n    viewportMargin?: number;\n}\n\ninterface Position {\n    x: number;\n    y: number;\n}\n\n/** Movement, in px, that reads as a scroll rather than a hold. */\nconst LONG_PRESS_MOVE_TOLERANCE = 10;\n\nfunction isSeparator(item: ContextMenuItem): item is { separator: true } {\n    return \"separator\" in item && item.separator === true;\n}\n\n/**\n * Context menu — right click, long press, or click.\n *\n * - Opens at the pointer on `contextmenu`, and on a long press from touch or pen,\n *   which is the gesture those pointers have instead of a right click. `trigger`\n *   adds or replaces the mouse gesture; the long press is independent of it.\n * - Clamped inside the viewport after it mounts, so a menu opened near an edge\n *   moves in rather than overflowing — measured before the clamp: a 180 px menu\n *   opened at `x=235` in a 320 px window ran 95 px off screen, cutting the right\n *   edge off every label.\n * - Rendered through a {@link Portal} so it escapes parent overflow/stacking\n *   contexts.\n * - Closes on outside click, Escape, scroll, resize, or item selection.\n * - Arrow Up/Down move focus across selectable items and wrap; Home/End jump to\n *   the ends; Enter activates the focused item. The menu itself takes focus when\n *   it opens, so a screen reader announces it instead of staying on the trigger.\n *\n * @param props - The context menu props.\n * @returns The trigger wrapper plus the portalled menu when open.\n */\nexport function ContextMenu({\n    items,\n    children,\n    className,\n    trigger = \"contextmenu\",\n    longPressDelay = 500,\n    viewportMargin = 8,\n}: ContextMenuProps) {\n    const [open, setOpen] = useState(false);\n    const [position, setPosition] = useState<Position>({ x: 0, y: 0 });\n    const [activeIndex, setActiveIndex] = useState<number>(-1);\n    const id = useId();\n    const [menuNode, setMenuNode] = useState<HTMLUListElement | null>(null);\n    const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n    const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n    const longPressOrigin = useRef<Position | null>(null);\n\n    const selectableIndexes = items\n        .map((item, index) => (!isSeparator(item) && !item.disabled ? index : -1))\n        .filter((i) => i !== -1);\n\n    const close = useCallback((): void => {\n        setOpen(false);\n        setActiveIndex(-1);\n    }, []);\n\n    const openAt = useCallback((point: Position): void => {\n        setPosition(point);\n        setActiveIndex(-1);\n        setOpen(true);\n    }, []);\n\n    const cancelLongPress = useCallback((): void => {\n        if (longPressTimer.current !== null) {\n            clearTimeout(longPressTimer.current);\n            longPressTimer.current = null;\n        }\n        longPressOrigin.current = null;\n    }, []);\n\n    useEffect(() => cancelLongPress, [cancelLongPress]);\n\n    const handleContextMenu = (event: React.MouseEvent): void => {\n        if (trigger === \"click\") return;\n        event.preventDefault();\n        openAt({ x: event.clientX, y: event.clientY });\n    };\n\n    const handleClick = (event: React.MouseEvent): void => {\n        if (trigger === \"contextmenu\") return;\n        openAt({ x: event.clientX, y: event.clientY });\n    };\n\n    /**\n     * Start the hold that stands in for a right click on a coarse pointer.\n     *\n     * A mouse is excluded: it has `contextmenu`, and holding the left button is\n     * how a drag starts.\n     */\n    const handlePointerDown = (event: ReactPointerEvent): void => {\n        if (longPressDelay <= 0 || event.pointerType === \"mouse\") return;\n        const point = { x: event.clientX, y: event.clientY };\n        longPressOrigin.current = point;\n        cancelLongPress();\n        longPressOrigin.current = point;\n        longPressTimer.current = setTimeout(() => {\n            longPressTimer.current = null;\n            longPressOrigin.current = null;\n            suppressNextClick();\n            openAt(point);\n        }, longPressDelay);\n    };\n\n    /**\n     * Cancel the hold once the finger travels — otherwise every scroll that\n     * starts on the trigger opens a menu, which is the difference between a\n     * usable long press and an infuriating one.\n     */\n    const handlePointerMove = (event: ReactPointerEvent): void => {\n        const origin = longPressOrigin.current;\n        if (!origin || longPressTimer.current === null) return;\n        if (\n            Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >\n            LONG_PRESS_MOVE_TOLERANCE\n        ) {\n            cancelLongPress();\n        }\n    };\n\n    useEffect(() => {\n        if (!open) return;\n        const onKey = (event: KeyboardEvent): void => {\n            if (event.key === \"Escape\") {\n                close();\n                return;\n            }\n            const move = (next: number): void => {\n                event.preventDefault();\n                setActiveIndex(next);\n                itemRefs.current[next]?.focus();\n            };\n            if (selectableIndexes.length === 0) return;\n            const current = selectableIndexes.indexOf(activeIndex);\n            if (event.key === \"ArrowDown\") {\n                move(selectableIndexes[(current + 1) % selectableIndexes.length] ?? -1);\n            }\n            if (event.key === \"ArrowUp\") {\n                move(\n                    selectableIndexes[\n                        (current - 1 + selectableIndexes.length) % selectableIndexes.length\n                    ] ?? -1,\n                );\n            }\n            if (event.key === \"Home\") {\n                move(selectableIndexes[0] ?? -1);\n            }\n            if (event.key === \"End\") {\n                move(selectableIndexes[selectableIndexes.length - 1] ?? -1);\n            }\n        };\n        const onDown = (event: MouseEvent): void => {\n            if (menuNode && !menuNode.contains(event.target as Node)) close();\n        };\n        window.addEventListener(\"keydown\", onKey);\n        window.addEventListener(\"mousedown\", onDown);\n        window.addEventListener(\"resize\", close);\n        window.addEventListener(\"scroll\", close, true);\n        return () => {\n            window.removeEventListener(\"keydown\", onKey);\n            window.removeEventListener(\"mousedown\", onDown);\n            window.removeEventListener(\"resize\", close);\n            window.removeEventListener(\"scroll\", close, true);\n        };\n    }, [open, activeIndex, selectableIndexes, close, menuNode]);\n\n    /**\n     * Pull the menu inside the viewport, and take focus.\n     *\n     * Keyed off the node rather than off `open`, because {@link Portal} renders\n     * `null` on its first pass and mounts on an effect — an effect that reads a\n     * ref when `open` flips runs while the menu does not exist yet, measures\n     * nothing and focuses nothing. A callback ref in state is what makes this\n     * run on the render that has the element.\n     *\n     * `useLayoutEffect` runs before paint, so the menu is never painted at the\n     * overflowing position first.\n     *\n     * The size comes from `offsetWidth`/`offsetHeight` rather than from\n     * `getBoundingClientRect`, which reports the **transformed** box: the menu\n     * enters at `scale(0.96)`, so the rect is 4% small and the clamp lets the\n     * grown menu back over the edge. Measured in Chrome at 320 px wide, a 180 px\n     * menu came back at `right: 316` against a margin that asked for 312.\n     */\n    useLayoutEffect(() => {\n        if (!open || !menuNode) return;\n        menuNode.focus({ preventScroll: true });\n        const { offsetWidth, offsetHeight } = menuNode;\n        if (offsetWidth === 0 && offsetHeight === 0) return;\n        const maxLeft = window.innerWidth - offsetWidth - viewportMargin;\n        const maxTop = window.innerHeight - offsetHeight - viewportMargin;\n        const left = Math.max(viewportMargin, Math.min(position.x, maxLeft));\n        const top = Math.max(viewportMargin, Math.min(position.y, maxTop));\n        if (left !== position.x || top !== position.y) setPosition({ x: left, y: top });\n    }, [open, menuNode, position.x, position.y, viewportMargin]);\n\n    const handleSelect = (item: Extract<ContextMenuItem, { label: ReactNode }>): void => {\n        item.onSelect?.();\n        close();\n    };\n\n    return (\n        <>\n            <span\n                className={styles.root}\n                onContextMenu={handleContextMenu}\n                onClick={handleClick}\n                onPointerDown={handlePointerDown}\n                onPointerMove={handlePointerMove}\n                onPointerUp={cancelLongPress}\n                onPointerCancel={cancelLongPress}\n            >\n                {children}\n            </span>\n            {open && (\n                <Portal>\n                    <ul\n                        ref={setMenuNode}\n                        id={id}\n                        role=\"menu\"\n                        aria-orientation=\"vertical\"\n                        tabIndex={-1}\n                        className={cn(styles.menu, className)}\n                        style={{ top: position.y, left: position.x }}\n                    >\n                        {items.map((item, index) => {\n                            if (isSeparator(item)) {\n                                return (\n                                    <li\n                                        key={`separator-${index}`}\n                                        role=\"separator\"\n                                        className={styles.separator}\n                                        aria-hidden\n                                    />\n                                );\n                            }\n                            return (\n                                <li key={`item-${index}`} role=\"none\">\n                                    <button\n                                        ref={(el) => {\n                                            itemRefs.current[index] = el;\n                                        }}\n                                        type=\"button\"\n                                        role=\"menuitem\"\n                                        className={cn(\n                                            styles.item,\n                                            item.danger && styles.danger,\n                                            activeIndex === index && styles.active,\n                                        )}\n                                        disabled={item.disabled}\n                                        onClick={() => handleSelect(item)}\n                                        onMouseEnter={() => setActiveIndex(index)}\n                                    >\n                                        {item.label}\n                                    </button>\n                                </li>\n                            );\n                        })}\n                    </ul>\n                </Portal>\n            )}\n        </>\n    );\n}\n\n/**\n * Swallow the click a finger leaves behind after a long press.\n *\n * The hold fires while the finger is still down, so the browser goes on to\n * deliver `pointerup` and then `click` to whatever is under it — measured, that\n * is the same element the menu was just opened from. Without this, opening the\n * menu on a chat bubble also opens the bubble.\n *\n * Capture phase and `once`, so the listener never outlives the gesture that\n * installed it, and a later genuine click is untouched.\n */\nfunction suppressNextClick(): void {\n    if (typeof window === \"undefined\") return;\n    window.addEventListener(\n        \"click\",\n        (event: MouseEvent) => {\n            event.preventDefault();\n            event.stopPropagation();\n        },\n        { capture: true, once: true },\n    );\n}\n"],"mappings":"oKA8DA,IAAM,EAA4B,GAElC,SAAS,EAAY,EAAoD,CACrE,MAAO,cAAe,GAAQ,EAAK,YAAc,EACrD,CAsBA,SAAgB,EAAY,CACxB,QACA,WACA,YACA,UAAU,cACV,iBAAiB,IACjB,iBAAiB,GACA,CACjB,GAAM,CAAC,EAAM,IAAA,EAAW,EAAA,SAAA,CAAS,EAAK,EAChC,CAAC,EAAU,IAAA,EAAe,EAAA,SAAA,CAAmB,CAAE,EAAG,EAAG,EAAG,CAAE,CAAC,EAC3D,CAAC,EAAa,IAAA,EAAkB,EAAA,SAAA,CAAiB,EAAE,EACnD,GAAA,EAAK,EAAA,MAAA,CAAM,EACX,CAAC,EAAU,IAAA,EAAe,EAAA,SAAA,CAAkC,IAAI,EAChE,GAAA,EAAW,EAAA,OAAA,CAAwC,CAAC,CAAC,EACrD,GAAA,EAAiB,EAAA,OAAA,CAA6C,IAAI,EAClE,GAAA,EAAkB,EAAA,OAAA,CAAwB,IAAI,EAE9C,EAAoB,EACrB,KAAK,EAAM,IAAW,CAAC,EAAY,CAAI,GAAK,CAAC,EAAK,SAAW,EAAQ,EAAG,CAAC,CACzE,OAAQ,GAAM,IAAM,EAAE,EAErB,GAAA,EAAQ,EAAA,YAAA,KAAwB,CAClC,EAAQ,EAAK,EACb,EAAe,EAAE,CACrB,EAAG,CAAC,CAAC,EAEC,GAAA,EAAS,EAAA,YAAA,CAAa,GAA0B,CAClD,EAAY,CAAK,EACjB,EAAe,EAAE,EACjB,EAAQ,EAAI,CAChB,EAAG,CAAC,CAAC,EAEC,GAAA,EAAkB,EAAA,YAAA,KAAwB,CACxC,EAAe,UAAY,OAC3B,aAAa,EAAe,OAAO,EACnC,EAAe,QAAU,MAE7B,EAAgB,QAAU,IAC9B,EAAG,CAAC,CAAC,GAEL,EAAA,EAAA,UAAA,KAAgB,EAAiB,CAAC,CAAe,CAAC,EAElD,IAAM,EAAqB,GAAkC,CACrD,IAAY,UAChB,EAAM,eAAe,EACrB,EAAO,CAAE,EAAG,EAAM,QAAS,EAAG,EAAM,OAAQ,CAAC,EACjD,EAEM,EAAe,GAAkC,CAC/C,IAAY,eAChB,EAAO,CAAE,EAAG,EAAM,QAAS,EAAG,EAAM,OAAQ,CAAC,CACjD,EAQM,EAAqB,GAAmC,CAC1D,GAAI,GAAkB,GAAK,EAAM,cAAgB,QAAS,OAC1D,IAAM,EAAQ,CAAE,EAAG,EAAM,QAAS,EAAG,EAAM,OAAQ,EACnD,EAAgB,QAAU,EAC1B,EAAgB,EAChB,EAAgB,QAAU,EAC1B,EAAe,QAAU,eAAiB,CACtC,EAAe,QAAU,KACzB,EAAgB,QAAU,KAC1B,EAAkB,EAClB,EAAO,CAAK,CAChB,EAAG,CAAc,CACrB,EAOM,EAAqB,GAAmC,CAC1D,IAAM,EAAS,EAAgB,QAC1B,GAAU,EAAe,UAAY,MAEtC,KAAK,MAAM,EAAM,QAAU,EAAO,EAAG,EAAM,QAAU,EAAO,CAAC,EAC7D,GAEA,EAAgB,CAExB,GAEA,EAAA,EAAA,UAAA,KAAgB,CACZ,GAAI,CAAC,EAAM,OACX,IAAM,EAAS,GAA+B,CAC1C,GAAI,EAAM,MAAQ,SAAU,CACxB,EAAM,EACN,MACJ,CACA,IAAM,EAAQ,GAAuB,CACjC,EAAM,eAAe,EACrB,EAAe,CAAI,EACnB,EAAS,QAAQ,EAAK,EAAE,MAAM,CAClC,EACA,GAAI,EAAkB,SAAW,EAAG,OACpC,IAAM,EAAU,EAAkB,QAAQ,CAAW,EACjD,EAAM,MAAQ,aACd,EAAK,GAAmB,EAAU,GAAK,EAAkB,SAAW,EAAE,EAEtE,EAAM,MAAQ,WACd,EACI,GACK,EAAU,EAAI,EAAkB,QAAU,EAAkB,SAC5D,EACT,EAEA,EAAM,MAAQ,QACd,EAAK,EAAkB,IAAM,EAAE,EAE/B,EAAM,MAAQ,OACd,EAAK,EAAkB,EAAkB,OAAS,IAAM,EAAE,CAElE,EACM,EAAU,GAA4B,CACpC,GAAY,CAAC,EAAS,SAAS,EAAM,MAAc,GAAG,EAAM,CACpE,EAKA,OAJA,OAAO,iBAAiB,UAAW,CAAK,EACxC,OAAO,iBAAiB,YAAa,CAAM,EAC3C,OAAO,iBAAiB,SAAU,CAAK,EACvC,OAAO,iBAAiB,SAAU,EAAO,EAAI,MAChC,CACT,OAAO,oBAAoB,UAAW,CAAK,EAC3C,OAAO,oBAAoB,YAAa,CAAM,EAC9C,OAAO,oBAAoB,SAAU,CAAK,EAC1C,OAAO,oBAAoB,SAAU,EAAO,EAAI,CACpD,CACJ,EAAG,CAAC,EAAM,EAAa,EAAmB,EAAO,CAAQ,CAAC,GAoB1D,EAAA,EAAA,gBAAA,KAAsB,CAClB,GAAI,CAAC,GAAQ,CAAC,EAAU,OACxB,EAAS,MAAM,CAAE,cAAe,EAAK,CAAC,EACtC,GAAM,CAAE,cAAa,gBAAiB,EACtC,GAAI,IAAgB,GAAK,IAAiB,EAAG,OAC7C,IAAM,EAAU,OAAO,WAAa,EAAc,EAC5C,EAAS,OAAO,YAAc,EAAe,EAC7C,EAAO,KAAK,IAAI,EAAgB,KAAK,IAAI,EAAS,EAAG,CAAO,CAAC,EAC7D,EAAM,KAAK,IAAI,EAAgB,KAAK,IAAI,EAAS,EAAG,CAAM,CAAC,GAC7D,IAAS,EAAS,GAAK,IAAQ,EAAS,IAAG,EAAY,CAAE,EAAG,EAAM,EAAG,CAAI,CAAC,CAClF,EAAG,CAAC,EAAM,EAAU,EAAS,EAAG,EAAS,EAAG,CAAc,CAAC,EAE3D,IAAM,EAAgB,GAA+D,CACjF,EAAK,WAAW,EAChB,EAAM,CACV,EAEA,OACI,EAAA,EAAA,KAAA,CAAA,EAAA,SAAA,CAAA,SAAA,EACI,EAAA,EAAA,IAAA,CAAC,OAAD,CACI,UAAW,EAAA,QAAO,KAClB,cAAe,EACf,QAAS,EACT,cAAe,EACf,cAAe,EACf,YAAa,EACb,gBAAiB,EAEhB,UACC,CAAA,EACL,IACG,EAAA,EAAA,IAAA,CAAC,EAAA,OAAD,CAAA,UACI,EAAA,EAAA,IAAA,CAAC,KAAD,CACI,IAAK,EACD,KACJ,KAAK,OACL,mBAAiB,WACjB,SAAU,GACV,UAAW,EAAA,GAAG,EAAA,QAAO,KAAM,CAAS,EACpC,MAAO,CAAE,IAAK,EAAS,EAAG,KAAM,EAAS,CAAE,EAE1C,SAAA,EAAM,KAAK,EAAM,IACV,EAAY,CAAI,GAEZ,EAAA,EAAA,IAAA,CAAC,KAAD,CAEI,KAAK,YACL,UAAW,EAAA,QAAO,UAClB,cAAA,EACH,EAJQ,aAAa,GAIrB,GAIL,EAAA,EAAA,IAAA,CAAC,KAAD,CAA0B,KAAK,OAC3B,UAAA,EAAA,EAAA,IAAA,CAAC,SAAD,CACI,IAAM,GAAO,CACT,EAAS,QAAQ,GAAS,CAC9B,EACA,KAAK,SACL,KAAK,WACL,UAAW,EAAA,GACP,EAAA,QAAO,KACP,EAAK,QAAU,EAAA,QAAO,OACtB,IAAgB,GAAS,EAAA,QAAO,MACpC,EACA,SAAU,EAAK,SACf,YAAe,EAAa,CAAI,EAChC,iBAAoB,EAAe,CAAK,EAEvC,SAAA,EAAK,KACF,CAAA,CACR,EAlBK,QAAQ,GAkBb,CAEX,CACD,CAAA,CACA,CAAA,CAEd,CAAA,CAAA,CAEV,CAaA,SAAS,GAA0B,CAC3B,OAAO,OAAW,KACtB,OAAO,iBACH,QACC,GAAsB,CACnB,EAAM,eAAe,EACrB,EAAM,gBAAgB,CAC1B,EACA,CAAE,QAAS,GAAM,KAAM,EAAK,CAChC,CACJ"}