{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "context-menu",
  "type": "registry:ui",
  "title": "Context Menu",
  "description": "A right-click and long-press menu with checkbox and radio items, nested submenus, shortcuts, typeahead and full menu ARIA.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/context-menu.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\ninterface EntryBase {\n  id: string;\n  label: string;\n  /** Decorative leading icon. */\n  icon?: React.ReactNode;\n  /** Shortcut hint shown on the right, e.g. \"⌘C\". Display only — bind the keys yourself. */\n  shortcut?: string;\n  disabled?: boolean;\n}\n\nexport interface ContextMenuAction extends EntryBase {\n  type?: \"item\";\n  /** Red styling for destructive actions. */\n  destructive?: boolean;\n  onSelect?: () => void;\n}\n\nexport interface ContextMenuCheckbox extends EntryBase {\n  type: \"checkbox\";\n  checked: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n}\n\nexport interface ContextMenuRadioGroup {\n  type: \"radio\";\n  id: string;\n  /** Accessible name of the group (also shown as a small heading). */\n  label?: string;\n  value: string;\n  onValueChange?: (value: string) => void;\n  options: { value: string; label: string; shortcut?: string; disabled?: boolean }[];\n}\n\nexport interface ContextMenuSeparator {\n  type: \"separator\";\n  id?: string;\n}\n\nexport interface ContextMenuGroup {\n  type: \"group\";\n  id: string;\n  /** Group heading and accessible name. */\n  label: string;\n  items: ContextMenuEntry[];\n}\n\nexport interface ContextMenuSubmenu extends Omit<EntryBase, \"shortcut\"> {\n  type: \"submenu\";\n  items: ContextMenuEntry[];\n}\n\nexport type ContextMenuEntry = ContextMenuAction | ContextMenuCheckbox | ContextMenuRadioGroup | ContextMenuSeparator | ContextMenuGroup | ContextMenuSubmenu;\n\nexport interface ContextMenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  /** Menu structure: actions, checkboxes, radio groups, separators, labelled groups and submenus. */\n  items: ContextMenuEntry[];\n  /** The target area. Right-click, long-press, Shift+F10 or the ContextMenu key opens the menu. */\n  children: React.ReactNode;\n  /** Called with the id of any activated action, checkbox or radio option (`groupId:value`). */\n  onSelect?: (id: string) => void;\n  /** Minimum menu width in px. */\n  width?: number;\n  /** Touch long-press duration in ms. */\n  longPressDelay?: number;\n  /** Show shortcut hints. */\n  showShortcuts?: boolean;\n  /** Spring for the scale-in from the pointer. */\n  spring?: Spring;\n  /** Accent for the highlighted item tint and focus rings. */\n  accent?: string;\n  /** Accessible name of the menu. */\n  menuLabel?: string;\n  /** Disable the custom menu (the browser menu shows instead). */\n  disabled?: boolean;\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst EDGE = 8; // px kept from the viewport edge\nconst PANEL_PAD = 4; // panel padding, so submenus line up with their trigger\nconst HOVER_DELAY = 110; // ms before hover opens / closes submenus\nconst ITEM_SELECTOR = \"[data-cm-item]\";\n\ninterface MenuContext {\n  uid: string;\n  openPath: string[];\n  setOpenPath: (path: string[]) => void;\n  /** Ask the panel at `depth` to focus its first item when it mounts. */\n  requestFocus: (depth: number) => void;\n  /** True (once) when the panel at `depth` should focus its first item. */\n  takeFocusRequest: (depth: number) => boolean;\n  /** Runs `fn` after the hover delay, replacing any pending hover action. */\n  scheduleHover: (fn: () => void) => void;\n  cancelHover: () => void;\n  activate: (entry: ContextMenuAction | ContextMenuCheckbox) => void;\n  activateRadio: (group: ContextMenuRadioGroup, value: string) => void;\n  close: (returnFocus: boolean) => void;\n  showShortcuts: boolean;\n  reduceMotion: boolean;\n  spring: Spring;\n  width: number;\n}\n\n/** Enabled items that belong directly to this panel (not to a nested submenu). */\nfunction panelItems(panel: HTMLElement) {\n  return Array.from(panel.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter(\n    (el) => el.closest('[role=\"menu\"]') === panel && el.getAttribute(\"aria-disabled\") !== \"true\",\n  );\n}\n\n/**\n * A context menu for a target area: right-click, touch long-press, Shift+F10 or the ContextMenu\n * key opens it at the pointer (or the focused element). Supports actions, checkbox and radio items,\n * labelled groups, separators, nested submenus (hover or ArrowRight), shortcut hints and typeahead,\n * with full menu ARIA. It renders in the top layer, flips to stay in the viewport and scales in\n * from the pointer on a spring. Escape, Tab or an outside click closes it and returns focus.\n */\nexport function ContextMenu({\n  items,\n  children,\n  onSelect,\n  width = 224,\n  longPressDelay = 500,\n  showShortcuts = true,\n  spring = { stiffness: 520, damping: 32, mass: 0.7 },\n  accent = \"#FF7448\",\n  menuLabel = \"Context menu\",\n  disabled = false,\n  className = \"\",\n  style,\n  onContextMenu,\n  onKeyDown,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onPointerCancel,\n  ...props\n}: ContextMenuProps) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const targetRef = React.useRef<HTMLDivElement>(null);\n  const rootPanelRef = React.useRef<HTMLDivElement | null>(null);\n  const pendingFocus = React.useRef<number | null>(null);\n  const hoverTimer = React.useRef(0);\n  const longPress = React.useRef({ timer: 0, x: 0, y: 0 });\n  const keyboardOpenedAt = React.useRef(-Infinity);\n  const [anchor, setAnchor] = React.useState<{ x: number; y: number } | null>(null);\n  const [openPath, setOpenPath] = React.useState<string[]>([]);\n\n  const open = (x: number, y: number, focusFirst: boolean) => {\n    window.clearTimeout(hoverTimer.current);\n    pendingFocus.current = focusFirst ? 0 : null;\n    setOpenPath([]);\n    setAnchor({ x, y });\n  };\n\n  const close = React.useCallback((returnFocus: boolean) => {\n    window.clearTimeout(hoverTimer.current);\n    setAnchor(null);\n    setOpenPath([]);\n    if (returnFocus) targetRef.current?.focus({ preventScroll: true });\n  }, []);\n\n  const activate = (entry: ContextMenuAction | ContextMenuCheckbox) => {\n    if (entry.disabled) return;\n    if (entry.type === \"checkbox\") {\n      // Toggles keep the menu open so several can be changed in a row.\n      entry.onCheckedChange?.(!entry.checked);\n      onSelect?.(entry.id);\n      return;\n    }\n    entry.onSelect?.();\n    onSelect?.(entry.id);\n    close(true);\n  };\n\n  const activateRadio = (group: ContextMenuRadioGroup, value: string) => {\n    group.onValueChange?.(value);\n    onSelect?.(`${group.id}:${value}`);\n  };\n\n  // While open: outside pointer, scroll, resize and window blur close the menu.\n  React.useEffect(() => {\n    if (!anchor) return;\n    const onPointer = (event: PointerEvent) => {\n      if (rootPanelRef.current?.contains(event.target as Node)) return;\n      close(false);\n      // Return focus to the target unless the click put it somewhere else (a link, an input…).\n      requestAnimationFrame(() => {\n        const active = document.activeElement;\n        if (!active || active === document.body) targetRef.current?.focus({ preventScroll: true });\n      });\n    };\n    const onScroll = (event: Event) => {\n      if (rootPanelRef.current?.contains(event.target as Node)) return;\n      close(false);\n    };\n    const onDismiss = () => close(false);\n    document.addEventListener(\"pointerdown\", onPointer, true);\n    window.addEventListener(\"scroll\", onScroll, { capture: true, passive: true });\n    window.addEventListener(\"resize\", onDismiss);\n    window.addEventListener(\"blur\", onDismiss);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointer, true);\n      window.removeEventListener(\"scroll\", onScroll, { capture: true });\n      window.removeEventListener(\"resize\", onDismiss);\n      window.removeEventListener(\"blur\", onDismiss);\n    };\n  }, [anchor, close]);\n\n  React.useEffect(() => {\n    const press = longPress.current;\n    const hover = hoverTimer;\n    return () => {\n      window.clearTimeout(press.timer);\n      window.clearTimeout(hover.current);\n    };\n  }, []);\n\n  /** Places the root panel at the anchor, flipping left / up when it would leave the viewport. */\n  const mountRoot = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      rootPanelRef.current = node;\n      if (!node || !anchor) return;\n      if (typeof node.showPopover === \"function\" && !node.matches(\":popover-open\")) {\n        try {\n          node.showPopover();\n        } catch {\n          // Unsupported or already open: position: fixed still applies.\n        }\n      }\n      const vw = document.documentElement.clientWidth;\n      const vh = window.innerHeight;\n      const w = node.offsetWidth;\n      const h = node.offsetHeight;\n      const flipX = anchor.x + w > vw - EDGE && anchor.x - w >= EDGE;\n      const flipY = anchor.y + h > vh - EDGE && anchor.y - h >= EDGE;\n      const left = Math.max(EDGE, Math.min(flipX ? anchor.x - w : anchor.x, vw - w - EDGE));\n      const top = Math.max(EDGE, Math.min(flipY ? anchor.y - h : anchor.y, vh - h - EDGE));\n      node.style.left = `${Math.round(left)}px`;\n      node.style.top = `${Math.round(top)}px`;\n      // Scale in from the pointer.\n      node.style.transformOrigin = `${Math.round(anchor.x - left)}px ${Math.round(anchor.y - top)}px`;\n      if (pendingFocus.current === 0) {\n        pendingFocus.current = null;\n        panelItems(node)[0]?.focus({ preventScroll: true });\n      } else {\n        node.focus({ preventScroll: true });\n      }\n    },\n    [anchor],\n  );\n\n  /* ------------------------------ target events ------------------------------ */\n\n  const cancelLongPress = () => window.clearTimeout(longPress.current.timer);\n\n  const ctx: MenuContext = {\n    uid,\n    openPath,\n    setOpenPath,\n    requestFocus: (depth) => {\n      pendingFocus.current = depth;\n    },\n    takeFocusRequest: (depth) => {\n      if (pendingFocus.current !== depth) return false;\n      pendingFocus.current = null;\n      return true;\n    },\n    scheduleHover: (fn) => {\n      window.clearTimeout(hoverTimer.current);\n      hoverTimer.current = window.setTimeout(fn, HOVER_DELAY);\n    },\n    cancelHover: () => window.clearTimeout(hoverTimer.current),\n    activate,\n    activateRadio,\n    close,\n    showShortcuts,\n    reduceMotion,\n    spring,\n    width,\n  };\n\n  return (\n    <>\n      <div\n        ref={targetRef}\n        tabIndex={0}\n        aria-haspopup={disabled ? undefined : \"menu\"}\n        aria-expanded={disabled ? undefined : anchor !== null}\n        aria-describedby={disabled ? undefined : `${uid}-hint`}\n        className={`relative select-none outline-hidden [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cm-accent)] ${className}`}\n        style={{ [\"--cm-accent\" as string]: accent, ...style }}\n        onContextMenu={(event) => {\n          onContextMenu?.(event);\n          if (disabled || event.defaultPrevented) return;\n          event.preventDefault();\n          cancelLongPress();\n          // The keyboard path already opened the menu; some browsers follow up with a contextmenu event.\n          if (performance.now() - keyboardOpenedAt.current < 400) return;\n          open(event.clientX, event.clientY, false);\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n          if (disabled || event.defaultPrevented) return;\n          if (event.key === \"ContextMenu\" || (event.key === \"F10\" && event.shiftKey)) {\n            event.preventDefault();\n            keyboardOpenedAt.current = performance.now();\n            const focused = document.activeElement instanceof HTMLElement && event.currentTarget.contains(document.activeElement) ? document.activeElement : event.currentTarget;\n            const rect = focused.getBoundingClientRect();\n            open(rect.left + Math.min(24, rect.width / 2), rect.top + Math.min(24, rect.height / 2), true);\n          }\n        }}\n        onPointerDown={(event) => {\n          onPointerDown?.(event);\n          if (disabled || event.pointerType === \"mouse\") return;\n          cancelLongPress();\n          const { clientX, clientY } = event;\n          longPress.current = {\n            x: clientX,\n            y: clientY,\n            timer: window.setTimeout(() => open(clientX, clientY, false), longPressDelay),\n          };\n        }}\n        onPointerMove={(event) => {\n          onPointerMove?.(event);\n          if (event.pointerType === \"mouse\") return;\n          const { x, y } = longPress.current;\n          if (Math.hypot(event.clientX - x, event.clientY - y) > 10) cancelLongPress();\n        }}\n        onPointerUp={(event) => {\n          onPointerUp?.(event);\n          cancelLongPress();\n        }}\n        onPointerCancel={(event) => {\n          onPointerCancel?.(event);\n          cancelLongPress();\n        }}\n        {...props}\n      >\n        {children}\n      </div>\n      <span id={`${uid}-hint`} hidden>\n        Right-click, long-press, or press Shift+F10 for options.\n      </span>\n\n      <AnimatePresence>\n        {anchor ? (\n          <MenuPanel\n            key={`${anchor.x}:${anchor.y}`}\n            ref={mountRoot}\n            ctx={ctx}\n            entries={items}\n            depth={0}\n            label={menuLabel}\n            accent={accent}\n          />\n        ) : null}\n      </AnimatePresence>\n    </>\n  );\n}\n\n/* --------------------------------------------------------------------------- */\n/*  Panels and items                                                           */\n/* --------------------------------------------------------------------------- */\n\ninterface MenuPanelProps {\n  ctx: MenuContext;\n  entries: ContextMenuEntry[];\n  depth: number;\n  /** Root panel: aria-label. */\n  label?: string;\n  /** Submenus: id of the trigger that labels them. */\n  labelledBy?: string;\n  id?: string;\n  accent?: string;\n  ref?: React.Ref<HTMLDivElement>;\n}\n\nfunction MenuPanel({ ctx, entries, depth, label, labelledBy, id, accent, ref }: MenuPanelProps) {\n  const typeahead = React.useRef({ buffer: \"\", timer: 0 });\n  const root = depth === 0;\n\n  React.useEffect(() => {\n    const t = typeahead.current;\n    return () => window.clearTimeout(t.timer);\n  }, []);\n\n  /** Submenus: line up with the trigger, flip to the left / shift up when they would leave the viewport. */\n  const mountSub = (node: HTMLDivElement | null) => {\n    if (!node || root) return;\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    node.style.left = \"100%\";\n    node.style.right = \"auto\";\n    node.style.top = `${-PANEL_PAD - 1}px`;\n    let rect = node.getBoundingClientRect();\n    let originX = \"0%\";\n    if (rect.right > vw - EDGE) {\n      node.style.left = \"auto\";\n      node.style.right = \"100%\";\n      originX = \"100%\";\n      rect = node.getBoundingClientRect();\n    }\n    if (rect.bottom > vh - EDGE) node.style.top = `${Math.round(-PANEL_PAD - 1 - (rect.bottom - (vh - EDGE)))}px`;\n    node.style.transformOrigin = `${originX} 0%`;\n    if (ctx.takeFocusRequest(depth)) panelItems(node)[0]?.focus({ preventScroll: true });\n  };\n\n  // Motion re-invokes callback refs on re-render: position and focus only once per mounted node.\n  const nodeRef = React.useRef<HTMLDivElement | null>(null);\n  const setRefs = (node: HTMLDivElement | null) => {\n    if (!node || node === nodeRef.current) return;\n    nodeRef.current = node;\n    mountSub(node);\n    if (typeof ref === \"function\") ref(node);\n    else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = node;\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    // The innermost panel handles every key; parents never see it.\n    event.stopPropagation();\n    const panel = event.currentTarget;\n    const list = panelItems(panel);\n    const active = document.activeElement as HTMLElement | null;\n    const index = active ? list.indexOf(active) : -1;\n    const focusAt = (i: number) => list[(i + list.length) % list.length]?.focus({ preventScroll: true });\n\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusAt(index + 1);\n        return;\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusAt(index < 0 ? -1 : index - 1);\n        return;\n      case \"Home\":\n      case \"PageUp\":\n        event.preventDefault();\n        focusAt(0);\n        return;\n      case \"End\":\n      case \"PageDown\":\n        event.preventDefault();\n        focusAt(-1);\n        return;\n      case \"ArrowRight\":\n        if (active?.getAttribute(\"aria-haspopup\") === \"menu\") {\n          event.preventDefault();\n          ctx.requestFocus(depth + 1);\n          active.click();\n        }\n        return;\n      case \"ArrowLeft\":\n        if (!root) {\n          event.preventDefault();\n          ctx.setOpenPath(ctx.openPath.slice(0, depth - 1));\n          document.getElementById(labelledBy ?? \"\")?.focus({ preventScroll: true });\n        }\n        return;\n      case \"Escape\":\n        event.preventDefault();\n        ctx.close(true);\n        return;\n      case \"Tab\":\n        event.preventDefault();\n        ctx.close(true);\n        return;\n      case \"Enter\":\n      case \" \":\n        if (active && index >= 0) {\n          event.preventDefault();\n          if (active.getAttribute(\"aria-haspopup\") === \"menu\") ctx.requestFocus(depth + 1);\n          active.click();\n        }\n        return;\n    }\n\n    // Typeahead: jump to the next item whose label starts with the typed characters.\n    if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n      const t = typeahead.current;\n      window.clearTimeout(t.timer);\n      t.buffer += event.key.toLowerCase();\n      t.timer = window.setTimeout(() => (t.buffer = \"\"), 600);\n      const ordered = [...list.slice(index + 1), ...list.slice(0, index + 1)];\n      const single = t.buffer.split(\"\").every((c) => c === t.buffer[0]);\n      const match =\n        ordered.find((el) => (el.dataset.label ?? \"\").startsWith(t.buffer)) ??\n        (single ? ordered.find((el) => (el.dataset.label ?? \"\").startsWith(t.buffer[0])) : undefined);\n      match?.focus({ preventScroll: true });\n    }\n  };\n\n  const motionProps = ctx.reduceMotion\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.08 } }\n    : {\n        initial: { opacity: 0, scale: root ? 0.88 : 0.95 },\n        animate: { opacity: 1, scale: 1 },\n        exit: { opacity: 0, scale: root ? 0.96 : 0.98, transition: { duration: 0.1 } },\n        transition: { type: \"spring\" as const, ...ctx.spring },\n      };\n\n  return (\n    <motion.div\n      ref={setRefs}\n      id={id}\n      role=\"menu\"\n      aria-label={label}\n      aria-labelledby={labelledBy}\n      aria-orientation=\"vertical\"\n      tabIndex={-1}\n      popover={root ? \"manual\" : undefined}\n      onKeyDown={onKeyDown}\n      onContextMenu={(event) => {\n        event.preventDefault();\n        event.stopPropagation();\n      }}\n      {...motionProps}\n      className={`${root ? \"fixed inset-auto m-0\" : \"absolute\"} z-50 overflow-visible rounded-xl border border-zinc-900/10 bg-white p-1 text-[13px] text-zinc-800 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.4),0_2px_6px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-200 dark:shadow-[0_24px_48px_-16px_rgb(0_0_0/0.75)]`}\n      style={{ minWidth: ctx.width, [\"--cm-accent\" as string]: accent }}\n    >\n      <Entries ctx={ctx} entries={entries} depth={depth} />\n    </motion.div>\n  );\n}\n\nfunction Entries({ ctx, entries, depth }: { ctx: MenuContext; entries: ContextMenuEntry[]; depth: number }) {\n  return (\n    <>\n      {entries.map((entry, i) => {\n        const key = entry.id ?? `sep-${i}`;\n        if (entry.type === \"separator\") {\n          return <div key={key} role=\"separator\" className=\"-mx-1 my-1 h-px bg-zinc-900/[0.08] dark:bg-white/[0.08]\" />;\n        }\n        if (entry.type === \"group\") {\n          const headingId = `${ctx.uid}-group-${entry.id}`;\n          return (\n            <div key={key} role=\"group\" aria-labelledby={headingId}>\n              <GroupHeading id={headingId}>{entry.label}</GroupHeading>\n              <Entries ctx={ctx} entries={entry.items} depth={depth} />\n            </div>\n          );\n        }\n        if (entry.type === \"radio\") {\n          const headingId = `${ctx.uid}-radio-${entry.id}`;\n          return (\n            <div key={key} role=\"group\" aria-label={entry.label ? undefined : entry.id} aria-labelledby={entry.label ? headingId : undefined}>\n              {entry.label ? <GroupHeading id={headingId}>{entry.label}</GroupHeading> : null}\n              {entry.options.map((option) => (\n                <Item\n                  key={option.value}\n                  role=\"menuitemradio\"\n                  ctx={ctx}\n                  depth={depth}\n                  label={option.label}\n                  shortcut={option.shortcut}\n                  disabled={option.disabled}\n                  checked={option.value === entry.value}\n                  indicator=\"radio\"\n                  onActivate={() => ctx.activateRadio(entry, option.value)}\n                />\n              ))}\n            </div>\n          );\n        }\n        if (entry.type === \"submenu\") return <Submenu key={key} ctx={ctx} entry={entry} depth={depth} />;\n        if (entry.type === \"checkbox\") {\n          return (\n            <Item\n              key={key}\n              role=\"menuitemcheckbox\"\n              ctx={ctx}\n              depth={depth}\n              label={entry.label}\n              icon={entry.icon}\n              shortcut={entry.shortcut}\n              disabled={entry.disabled}\n              checked={entry.checked}\n              indicator=\"check\"\n              onActivate={() => ctx.activate(entry)}\n            />\n          );\n        }\n        return (\n          <Item\n            key={key}\n            role=\"menuitem\"\n            ctx={ctx}\n            depth={depth}\n            label={entry.label}\n            icon={entry.icon}\n            shortcut={entry.shortcut}\n            disabled={entry.disabled}\n            destructive={entry.destructive}\n            onActivate={() => ctx.activate(entry)}\n          />\n        );\n      })}\n    </>\n  );\n}\n\nfunction GroupHeading({ id, children }: { id: string; children: React.ReactNode }) {\n  return (\n    <div id={id} aria-hidden=\"true\" className=\"px-2 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n      {children}\n    </div>\n  );\n}\n\nconst itemClass =\n  \"group/item relative flex h-8 w-full cursor-default items-center gap-2 rounded-md px-2 text-left outline-hidden transition-colors duration-75 aria-disabled:pointer-events-none aria-disabled:opacity-40 [&_svg]:size-4\";\n/** Highlight of the focused (keyboard or hovered) item. */\nconst tint = \"focus:bg-[color-mix(in_oklab,var(--cm-accent)_14%,transparent)] focus:text-zinc-950 dark:focus:text-white\";\nconst destructiveTint = \"text-red-600 focus:bg-red-500/10 focus:text-red-700 dark:text-red-400 dark:focus:text-red-300\";\n\ninterface ItemProps {\n  ctx: MenuContext;\n  depth: number;\n  role: \"menuitem\" | \"menuitemcheckbox\" | \"menuitemradio\";\n  label: string;\n  icon?: React.ReactNode;\n  shortcut?: string;\n  disabled?: boolean;\n  destructive?: boolean;\n  checked?: boolean;\n  indicator?: \"check\" | \"radio\";\n  onActivate: () => void;\n}\n\nfunction Item({ ctx, depth, role, label, icon, shortcut, disabled, destructive, checked, indicator, onActivate }: ItemProps) {\n  return (\n    <div\n      role={role}\n      tabIndex={-1}\n      data-cm-item=\"\"\n      data-label={label.toLowerCase()}\n      aria-disabled={disabled || undefined}\n      aria-checked={role === \"menuitem\" ? undefined : Boolean(checked)}\n      className={`${itemClass} ${destructive ? destructiveTint : tint}`}\n      onClick={() => {\n        if (!disabled) onActivate();\n      }}\n      onPointerMove={(event) => {\n        if (event.pointerType !== \"mouse\" || disabled) return;\n        if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });\n        // Hovering a plain item closes deeper submenus after a short delay.\n        if (ctx.openPath.length > depth) ctx.scheduleHover(() => ctx.setOpenPath(ctx.openPath.slice(0, depth)));\n      }}\n    >\n      <span aria-hidden=\"true\" className=\"flex w-4 shrink-0 items-center justify-center text-zinc-500 group-focus/item:text-current dark:text-zinc-400\">\n        {indicator === \"check\" ? (\n          checked ? (\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"text-[var(--cm-accent)]\">\n              <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          ) : null\n        ) : indicator === \"radio\" ? (\n          checked ? <span className=\"size-1.5 rounded-full bg-[var(--cm-accent)] ring-[3px] ring-[color-mix(in_oklab,var(--cm-accent)_25%,transparent)]\" /> : null\n        ) : (\n          icon\n        )}\n      </span>\n      <span className=\"min-w-0 flex-1 truncate\">{label}</span>\n      {ctx.showShortcuts && shortcut ? (\n        <kbd aria-hidden=\"true\" className=\"ml-4 shrink-0 font-mono text-[11px] tracking-tight text-zinc-400 dark:text-zinc-500\">\n          {shortcut}\n        </kbd>\n      ) : null}\n    </div>\n  );\n}\n\nfunction Submenu({ ctx, entry, depth }: { ctx: MenuContext; entry: ContextMenuSubmenu; depth: number }) {\n  const open = ctx.openPath[depth] === entry.id;\n  const triggerId = `${ctx.uid}-sub-${entry.id}`;\n  const panelId = `${triggerId}-menu`;\n  const openSub = () => ctx.setOpenPath([...ctx.openPath.slice(0, depth), entry.id]);\n\n  return (\n    <div role=\"none\" className=\"relative\">\n      <div\n        id={triggerId}\n        role=\"menuitem\"\n        tabIndex={-1}\n        data-cm-item=\"\"\n        data-label={entry.label.toLowerCase()}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-controls={open ? panelId : undefined}\n        aria-disabled={entry.disabled || undefined}\n        className={`${itemClass} ${tint} ${open ? \"bg-zinc-900/[0.05] dark:bg-white/[0.06]\" : \"\"}`}\n        onClick={() => {\n          if (!entry.disabled) openSub();\n        }}\n        onPointerMove={(event) => {\n          if (event.pointerType !== \"mouse\" || entry.disabled) return;\n          if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });\n          if (open) ctx.cancelHover();\n          else ctx.scheduleHover(openSub);\n        }}\n      >\n        <span aria-hidden=\"true\" className=\"flex w-4 shrink-0 items-center justify-center text-zinc-500 group-focus/item:text-current dark:text-zinc-400\">\n          {entry.icon}\n        </span>\n        <span className=\"min-w-0 flex-1 truncate\">{entry.label}</span>\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"ml-4 shrink-0 text-zinc-400 dark:text-zinc-500\">\n          <path d=\"M6 3.5 10.5 8 6 12.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        </svg>\n      </div>\n      <AnimatePresence>\n        {open ? <MenuPanel key={entry.id} ctx={ctx} entries={entry.items} depth={depth + 1} id={panelId} labelledBy={triggerId} /> : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/context-menu"
}
