{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"context-menu","type":"registry:ui","title":"Context Menu","description":"Opens from the pointer, not the corner.","dependencies":["motion"],"categories":["overlay"],"docs":"https://www.interior.dev/docs/context-menu","files":[{"path":"registry/interior/context-menu.tsx","type":"registry:ui","target":"components/interior/context-menu.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n\nconst EXIT = [0.4, 0, 1, 1] as const;\n\nconst ITEM_H = 32;\nconst SEP_H = 9;\nconst PAD = 5;\nconst BORDER = 1;\n\nexport type ContextMenuItem =\n  | { id: string; type: \"separator\" }\n  | {\n      id: string;\n      type?: \"item\";\n      label: string;\n      shortcut?: string;\n      icon?: ReactNode;\n      disabled?: boolean;\n      onSelect?: (id: string) => void;\n    };\n\nexport type ContextMenuPlacement = {\n  left: number;\n  top: number;\n  width: number;\n  maxHeight: number;\n  transformOrigin: string;\n};\n\nexport type UseContextMenuOptions = {\n  items: ContextMenuItem[];\n  onSelect?: (id: string) => void;\n  width?: number;\n  margin?: number;\n  holdDuration?: number;\n  moveTolerance?: number;\n  disabled?: boolean;\n};\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), Math.max(min, max));\n}\n\nfunction measure(items: ContextMenuItem[]) {\n  let height = PAD * 2 + BORDER * 2;\n  for (const item of items) height += item.type === \"separator\" ? SEP_H : ITEM_H;\n  return height;\n}\n\nexport function useContextMenu({\n  items,\n  onSelect,\n  width = 224,\n  margin = 8,\n  holdDuration = 460,\n  moveTolerance = 8,\n  disabled = false,\n}: UseContextMenuOptions) {\n  const [placement, setPlacement] = useState<ContextMenuPlacement | null>(null);\n  const [active, setActive] = useState(-1);\n\n  const triggerRef = useRef<HTMLDivElement | null>(null);\n  const menuRef = useRef<HTMLDivElement | null>(null);\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const opened = useRef(false);\n  const activeRef = useRef(-1);\n  const hold = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const holdFrom = useRef<{ x: number; y: number } | null>(null);\n  const swallowClick = useRef(false);\n  const pressed = useRef(-1);\n  const query = useRef(\"\");\n  const queryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  activeRef.current = active;\n\n  const list = useRef(items);\n  list.current = items;\n  const emit = useRef(onSelect);\n  emit.current = onSelect;\n\n  const height = useMemo(() => measure(items), [items]);\n\n  const steps = useMemo(\n    () =>\n      items.reduce<number[]>((acc, item, index) => {\n        if (item.type !== \"separator\" && !item.disabled) acc.push(index);\n        return acc;\n      }, []),\n    [items],\n  );\n  const stepsRef = useRef(steps);\n  stepsRef.current = steps;\n\n  const clearHold = useCallback(() => {\n    if (hold.current !== null) clearTimeout(hold.current);\n    hold.current = null;\n    holdFrom.current = null;\n  }, []);\n\n  const close = useCallback(\n    (restoreFocus = false) => {\n      clearHold();\n      if (!opened.current) return;\n      opened.current = false;\n      setPlacement(null);\n      setActive(-1);\n      if (restoreFocus) triggerRef.current?.focus({ preventScroll: true });\n    },\n    [clearHold],\n  );\n\n  const openAt = useCallback(\n    (x: number, y: number, source: \"pointer\" | \"keyboard\" = \"pointer\") => {\n      if (disabled || list.current.length === 0) return;\n\n      const vw = document.documentElement.clientWidth;\n      const vh = document.documentElement.clientHeight;\n      const w = Math.min(width, Math.max(160, vw - margin * 2));\n      const cap = Math.max(ITEM_H + PAD * 2, vh - margin * 2);\n      const h = Math.min(height, cap);\n\n      const left = clamp(x + w + margin <= vw ? x : x - w, margin, vw - w - margin);\n      const top = clamp(y + h + margin <= vh ? y : y - h, margin, vh - h - margin);\n\n      opened.current = true;\n      pressed.current = -1;\n      setPlacement({\n        left,\n        top,\n        width: w,\n        maxHeight: cap,\n        transformOrigin: `${clamp(x - left, 0, w)}px ${clamp(y - top, 0, h)}px`,\n      });\n      setActive(source === \"keyboard\" ? (stepsRef.current[0] ?? -1) : -1);\n    },\n    [disabled, height, margin, width],\n  );\n\n  const choose = useCallback(\n    (index: number) => {\n      const item = list.current[index];\n      if (!item || item.type === \"separator\" || item.disabled) return;\n      close(true);\n      item.onSelect?.(item.id);\n      emit.current?.(item.id);\n    },\n    [close],\n  );\n\n  const step = useCallback((dir: 1 | -1) => {\n    const order = stepsRef.current;\n    if (order.length === 0) return;\n    const at = order.indexOf(activeRef.current);\n    setActive(\n      at === -1\n        ? (dir === 1 ? order[0] : order[order.length - 1])\n        : order[(at + dir + order.length) % order.length],\n    );\n  }, []);\n\n  const edge = useCallback((which: \"first\" | \"last\") => {\n    const order = stepsRef.current;\n    if (order.length === 0) return;\n    setActive(which === \"first\" ? order[0] : order[order.length - 1]);\n  }, []);\n\n  const typeahead = useCallback((char: string) => {\n    query.current += char.toLowerCase();\n    if (queryTimer.current !== null) clearTimeout(queryTimer.current);\n    queryTimer.current = setTimeout(() => {\n      query.current = \"\";\n    }, 600);\n\n    const order = stepsRef.current;\n    const from = order.indexOf(activeRef.current) + 1;\n    for (let k = 0; k < order.length; k += 1) {\n      const index = order[(from + k) % order.length];\n      const item = list.current[index];\n      if (item.type !== \"separator\" && item.label.toLowerCase().startsWith(query.current)) {\n        setActive(index);\n        return;\n      }\n    }\n  }, []);\n\n  const isOpen = placement !== null;\n\n  useEffect(() => {\n    if (!isOpen) return;\n    const node = activeRef.current >= 0 ? itemRefs.current[activeRef.current] : menuRef.current;\n    node?.focus({ preventScroll: true });\n    if (activeRef.current >= 0) node?.scrollIntoView({ block: \"nearest\" });\n  }, [isOpen, active]);\n\n  useEffect(() => {\n    if (!isOpen) return;\n\n    const inside = (target: EventTarget | null) =>\n      menuRef.current?.contains(target as Node) ?? false;\n\n    const onDown = (event: PointerEvent) => {\n      if (inside(event.target)) return;\n      if (event.button === 2 && triggerRef.current?.contains(event.target as Node)) return;\n      close(false);\n    };\n    const onScroll = (event: Event) => {\n      if (inside(event.target)) return;\n      close(false);\n    };\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.preventDefault();\n      event.stopPropagation();\n      close(true);\n    };\n    const bail = () => close(false);\n\n    document.addEventListener(\"pointerdown\", onDown, true);\n    document.addEventListener(\"scroll\", onScroll, { capture: true, passive: true });\n    document.addEventListener(\"keydown\", onKey, true);\n    window.addEventListener(\"resize\", bail);\n    window.addEventListener(\"blur\", bail);\n\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown, true);\n      document.removeEventListener(\"scroll\", onScroll, { capture: true });\n      document.removeEventListener(\"keydown\", onKey, true);\n      window.removeEventListener(\"resize\", bail);\n      window.removeEventListener(\"blur\", bail);\n    };\n  }, [isOpen, close]);\n\n  useEffect(\n    () => () => {\n      if (hold.current !== null) clearTimeout(hold.current);\n      if (queryTimer.current !== null) clearTimeout(queryTimer.current);\n    },\n    [],\n  );\n\n  const triggerProps = {\n    tabIndex: disabled ? -1 : 0,\n    \"aria-haspopup\": \"menu\" as const,\n    \"aria-expanded\": isOpen,\n    style: { touchAction: \"manipulation\", WebkitTouchCallout: \"none\" } as CSSProperties,\n    onContextMenu: (event: ReactMouseEvent<HTMLElement>) => {\n      if (disabled) return;\n      event.preventDefault();\n      event.stopPropagation();\n      clearHold();\n      triggerRef.current = event.currentTarget as HTMLDivElement;\n      openAt(event.clientX, event.clientY, \"pointer\");\n    },\n    onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => {\n      if (disabled || opened.current) return;\n      const wants =\n        event.key === \"ContextMenu\" ||\n        (event.shiftKey && event.key === \"F10\") ||\n        (event.key === \"Enter\" && event.target === event.currentTarget);\n      if (!wants) return;\n      event.preventDefault();\n      const rect = event.currentTarget.getBoundingClientRect();\n      openAt(Math.round(rect.left + 14), Math.round(rect.top + 14), \"keyboard\");\n    },\n    onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {\n      if (disabled || event.pointerType === \"mouse\" || opened.current) return;\n      const x = event.clientX;\n      const y = event.clientY;\n      triggerRef.current = event.currentTarget as HTMLDivElement;\n      holdFrom.current = { x, y };\n      hold.current = setTimeout(() => {\n        hold.current = null;\n        swallowClick.current = true;\n        navigator.vibrate?.(10);\n        openAt(x, y, \"pointer\");\n      }, holdDuration);\n    },\n    onPointerMove: (event: ReactPointerEvent<HTMLElement>) => {\n      const from = holdFrom.current;\n      if (hold.current === null || !from) return;\n      if (Math.hypot(event.clientX - from.x, event.clientY - from.y) > moveTolerance) clearHold();\n    },\n    onPointerUp: clearHold,\n    onPointerCancel: clearHold,\n    onPointerLeave: clearHold,\n    onClick: (event: ReactMouseEvent<HTMLElement>) => {\n      if (!swallowClick.current) return;\n      swallowClick.current = false;\n      event.preventDefault();\n      event.stopPropagation();\n    },\n  };\n\n  const menuProps = {\n    role: \"menu\" as const,\n    tabIndex: -1,\n    \"aria-orientation\": \"vertical\" as const,\n    onContextMenu: (event: ReactMouseEvent) => event.preventDefault(),\n    onKeyDown: (event: ReactKeyboardEvent) => {\n      if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n        event.preventDefault();\n        step(event.key === \"ArrowDown\" ? 1 : -1);\n        return;\n      }\n      if (event.key === \"Home\" || event.key === \"End\") {\n        event.preventDefault();\n        edge(event.key === \"Home\" ? \"first\" : \"last\");\n        return;\n      }\n      if (event.key === \"Tab\") {\n        event.preventDefault();\n        close(true);\n        return;\n      }\n      if (\n        event.key.length === 1 &&\n        event.key !== \" \" &&\n        !event.metaKey &&\n        !event.ctrlKey &&\n        !event.altKey\n      ) {\n        typeahead(event.key);\n      }\n    },\n  };\n\n  const getItemProps = (index: number) => ({\n    ref: (node: HTMLButtonElement | null) => {\n      itemRefs.current[index] = node;\n    },\n    role: \"menuitem\" as const,\n    tabIndex: -1,\n    onPointerMove: () => {\n      const item = list.current[index];\n      if (activeRef.current === index || item.type === \"separator\" || item.disabled) return;\n      setActive(index);\n    },\n    onPointerDown: () => {\n      pressed.current = index;\n    },\n    onClick: (event: ReactMouseEvent) => {\n      if (event.detail !== 0 && pressed.current !== index) return;\n      pressed.current = -1;\n      choose(index);\n    },\n  });\n\n  return {\n    isOpen,\n    active,\n    placement,\n    openAt,\n    close,\n    triggerRef,\n    triggerProps,\n    menuRef,\n    menuProps,\n    getItemProps,\n  };\n}\n\nexport type ContextMenuProps = {\n  items: ContextMenuItem[];\n  children: ReactNode;\n  onSelect?: (id: string) => void;\n  label?: string;\n  width?: number;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport function ContextMenu({\n  items,\n  children,\n  onSelect,\n  label = \"Context menu\",\n  width = 224,\n  disabled = false,\n  className = \"\",\n}: ContextMenuProps) {\n  const uid = useId();\n  const reduced = useReducedMotion();\n\n  const [host, setHost] = useState<HTMLElement | null>(null);\n  useEffect(() => setHost(document.body), []);\n\n  const {\n    isOpen,\n    active,\n    placement,\n    triggerRef,\n    triggerProps,\n    menuRef,\n    menuProps,\n    getItemProps,\n  } = useContextMenu({ items, onSelect, width, disabled });\n\n  const hasIcons = items.some((item) => item.type !== \"separator\" && item.icon);\n\n  const menuId = `${uid}-menu`;\n\n  return (\n    <>\n      <div\n        ref={triggerRef}\n        {...triggerProps}\n        aria-controls={isOpen ? menuId : undefined}\n        aria-describedby={`${uid}-hint`}\n        className={`outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.08] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${className}`}\n      >\n        {children}\n        <span id={`${uid}-hint`} className=\"sr-only\">\n          Right-click, or press Shift plus F10, for options\n        </span>\n      </div>\n      <Portal host={host}>\n      <AnimatePresence>\n        {placement ? (\n          <motion.div\n            key={menuId}\n            ref={menuRef}\n            id={menuId}\n            {...menuProps}\n            aria-label={label}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={\n              reduced\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, scale: 0.98, transition: { duration: 0.14, ease: EXIT } }\n            }\n            transition={reduced ? { duration: 0 } : { duration: 0.2, ease: EASE }}\n            style={{\n              position: \"fixed\",\n              left: placement.left,\n              top: placement.top,\n              width: placement.width,\n              maxHeight: placement.maxHeight,\n              transformOrigin: placement.transformOrigin,\n              zIndex: 60,\n            }}\n            className=\"overflow-y-auto overscroll-contain rounded-[14px] border border-stone-200 bg-white p-[5px] shadow-[0_1px_2px_rgba(28,25,23,0.06),0_16px_36px_-18px_rgba(28,25,23,0.5)] outline-none dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_2px_12px_rgba(0,0,0,0.6)]\"\n          >\n            {items.map((item, index) =>\n              item.type === \"separator\" ? (\n                <div key={item.id} className=\"px-1 py-1\">\n                  <hr className=\"h-px border-0 bg-stone-200 dark:bg-white/10\" />\n                </div>\n              ) : (\n                <button\n                  key={item.id}\n                  type=\"button\"\n                  aria-disabled={item.disabled || undefined}\n                  {...getItemProps(index)}\n                  className={`flex h-[32px] w-full cursor-default select-none items-center gap-2 rounded-[7px] px-2.5 text-left text-[13px] outline-none ${\n                    item.disabled\n                      ? \"text-stone-400 dark:text-stone-500\"\n                      : \"text-stone-700 dark:text-stone-200\"\n                  } ${active === index ? \"bg-stone-100 dark:bg-white/10\" : \"\"}`}\n                >\n                  {hasIcons ? (\n                    <span\n                      aria-hidden\n                      className=\"grid size-4 shrink-0 place-items-center text-stone-500 dark:text-stone-400\"\n                    >\n                      {item.icon}\n                    </span>\n                  ) : null}\n\n                  <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n                  {item.shortcut ? (\n                    <span\n                      aria-hidden\n                      className=\"shrink-0 font-mono text-[10.5px] tabular-nums text-stone-500 dark:text-stone-400\"\n                    >\n                      {item.shortcut}\n                    </span>\n                  ) : null}\n                </button>\n              ),\n            )}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n      </Portal>\n    </>\n  );\n}\n\nfunction Portal({\n  host,\n  children,\n}: {\n  host: HTMLElement | null;\n  children: ReactNode;\n}) {\n  if (!host) return null;\n  return createPortal(children, host);\n}\n"}]}