{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"context-menu","type":"registry:ui","title":"Animated Context Menu","description":"Right-click or long-press menu that opens at the pointer: it scales from the nearest corner, items stagger in, a highlight glides between rows and submenus slide in beside their item.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/context-menu.tsx","type":"registry:ui","target":"components/ui/context-menu.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\nimport { MenuPanel } from \"./menu-core\";\n\nexport type ContextMenuEntry =\n  | {\n      type?: \"item\";\n      label: string;\n      icon?: React.ReactNode;\n      /** Shown right-aligned, e.g. \"⌘D\". Display only — bind the keys yourself. */\n      shortcut?: string;\n      onSelect?: () => void;\n      disabled?: boolean;\n      /** Destructive styling. */\n      danger?: boolean;\n      /** Renders as a checkbox item with a check when true. */\n      checked?: boolean;\n      /** Nested entries open as a submenu that slides in beside the item. */\n      items?: ContextMenuEntry[];\n    }\n  | { type: \"separator\" }\n  | { type: \"label\"; label: string };\n\nexport interface ContextMenuProps {\n  items: ContextMenuEntry[];\n  /** The area that responds to right-click and long-press. */\n  children: React.ReactNode;\n  /** Touch long-press duration, ms. */\n  longPressDelay?: number;\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  /** Classes for the target wrapper. */\n  className?: string;\n  /** Classes for every menu surface. */\n  menuClassName?: string;\n}\n\nconst noopSubscribe = () => () => {};\n\n/**\n * Right-click (or long-press on touch) menu that opens at the pointer: the surface scales from the corner nearest\n * the cursor, items stagger in, a highlight glides between rows and submenus slide in beside their item.\n */\nexport function ContextMenu({ items, children, longPressDelay = 500, onOpenChange, disabled, className, menuClassName }: ContextMenuProps) {\n  const [menu, setMenu] = React.useState<{ x: number; y: number; keyboard: boolean; id: number } | null>(null);\n  const targetRef = React.useRef<HTMLDivElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const restoreRef = React.useRef<HTMLElement | null>(null);\n  const press = React.useRef<{ timer: ReturnType<typeof setTimeout>; x: number; y: number } | null>(null);\n  const counter = React.useRef(0);\n  /** Set when a long-press opened the menu, so the browser's follow-up contextmenu event is ignored. */\n  const pressOpened = React.useRef(false);\n  const mounted = React.useSyncExternalStore(\n    noopSubscribe,\n    () => true,\n    () => false,\n  );\n  const placement = React.useMemo(() => ({ kind: \"point\" as const, x: menu?.x ?? 0, y: menu?.y ?? 0 }), [menu]);\n  const onOpenChangeRef = React.useRef(onOpenChange);\n  React.useEffect(() => {\n    onOpenChangeRef.current = onOpenChange;\n  });\n\n  const openAt = React.useCallback((x: number, y: number, keyboard: boolean) => {\n    restoreRef.current = document.activeElement as HTMLElement | null;\n    counter.current += 1;\n    setMenu({ x, y, keyboard, id: counter.current });\n    onOpenChangeRef.current?.(true);\n  }, []);\n\n  const closeAll = React.useCallback((restore: boolean) => {\n    setMenu(null);\n    onOpenChangeRef.current?.(false);\n    if (restore) (restoreRef.current ?? targetRef.current)?.focus({ preventScroll: true });\n  }, []);\n\n  React.useEffect(() => {\n    if (!menu) return;\n    const onDown = (e: PointerEvent) => {\n      if (!panelRef.current?.contains(e.target as Node)) closeAll(false);\n    };\n    const onDismiss = () => closeAll(false);\n    document.addEventListener(\"pointerdown\", onDown, true);\n    window.addEventListener(\"resize\", onDismiss);\n    window.addEventListener(\"blur\", onDismiss);\n    window.addEventListener(\"scroll\", onDismiss, true);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown, true);\n      window.removeEventListener(\"resize\", onDismiss);\n      window.removeEventListener(\"blur\", onDismiss);\n      window.removeEventListener(\"scroll\", onDismiss, true);\n    };\n  }, [menu, closeAll]);\n\n  React.useEffect(\n    () => () => {\n      if (press.current) clearTimeout(press.current.timer);\n    },\n    [],\n  );\n\n  function cancelPress() {\n    if (press.current) clearTimeout(press.current.timer);\n    press.current = null;\n  }\n\n  function centre() {\n    const r = targetRef.current?.getBoundingClientRect();\n    return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : { x: 0, y: 0 };\n  }\n\n  return (\n    <>\n      <div\n        ref={targetRef}\n        tabIndex={0}\n        aria-haspopup=\"menu\"\n        aria-expanded={!!menu}\n        onContextMenu={(e) => {\n          if (disabled) return;\n          e.preventDefault();\n          cancelPress();\n          if (pressOpened.current) {\n            pressOpened.current = false;\n            return;\n          }\n          const kb = e.clientX === 0 && e.clientY === 0;\n          const p = kb ? centre() : { x: e.clientX, y: e.clientY };\n          openAt(p.x, p.y, kb);\n        }}\n        onKeyDown={(e) => {\n          if (disabled) return;\n          if (e.key === \"ContextMenu\" || (e.shiftKey && e.key === \"F10\")) {\n            e.preventDefault();\n            const p = centre();\n            openAt(p.x, p.y, true);\n          }\n        }}\n        onPointerDown={(e) => {\n          if (disabled || e.pointerType === \"mouse\") return;\n          const { clientX: x, clientY: y } = e;\n          cancelPress();\n          pressOpened.current = false;\n          press.current = {\n            x,\n            y,\n            timer: setTimeout(() => {\n              pressOpened.current = true;\n              openAt(x, y, false);\n            }, longPressDelay),\n          };\n        }}\n        onPointerMove={(e) => {\n          const p = press.current;\n          if (p && Math.hypot(e.clientX - p.x, e.clientY - p.y) > 8) cancelPress();\n        }}\n        onPointerUp={cancelPress}\n        onPointerCancel={cancelPress}\n        className={cn(\"rounded-md outline-none [-webkit-touch-callout:none] focus-visible:shadow-[var(--focus-ring)]\", className)}\n      >\n        {children}\n      </div>\n      {mounted &&\n        createPortal(\n          <AnimatePresence>\n            {menu && (\n              <MenuPanel\n                key={menu.id}\n                ref={panelRef}\n                entries={items}\n                level={0}\n                placement={placement}\n                focusOnOpen={menu.keyboard ? \"first\" : \"panel\"}\n                onCloseAll={closeAll}\n                menuClassName={menuClassName}\n              />\n            )}\n          </AnimatePresence>,\n          document.body,\n        )}\n    </>\n  );\n}\n"},{"path":"registry/geturui/ui/menu-core.tsx","type":"registry:ui","target":"components/ui/menu-core.tsx","content":"\"use client\";\n\n/**\n * Shared menu engine for ContextMenu and Dropdown: entries, APG menu keyboard support (arrows, Home/End,\n * typeahead, submenus), checkbox and radio rows, sections, the gliding highlight and viewport-aware placement.\n * Internal module — import ContextMenu or Dropdown instead.\n */\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, ChevronRight } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MenuItemEntry {\n  /** \"checkbox\" and \"radio\" render menuitemcheckbox / menuitemradio rows. A plain item with `checked` set is a checkbox too. */\n  type?: \"item\" | \"checkbox\" | \"radio\";\n  label: string;\n  icon?: React.ReactNode;\n  /** Second line under the label. */\n  description?: string;\n  /** Shown right-aligned, e.g. \"⌘D\". Display only — bind the keys yourself. */\n  shortcut?: string;\n  /** Trailing node such as a Badge or a count. */\n  badge?: React.ReactNode;\n  onSelect?: () => void;\n  disabled?: boolean;\n  /** Destructive styling. */\n  danger?: boolean;\n  checked?: boolean;\n  /** Nested entries open as a submenu beside the item. */\n  items?: MenuEntry[];\n  /** Extra words a search box matches against. */\n  keywords?: string[];\n  /** Override whether choosing this row closes the menu. */\n  closeOnSelect?: boolean;\n}\n\nexport type MenuEntry = MenuItemEntry | { type: \"separator\" } | { type: \"label\"; label: string };\n\nexport type MenuPlacement =\n  | { kind: \"point\"; x: number; y: number }\n  | { kind: \"anchor\"; anchor: HTMLElement; side: \"top\" | \"bottom\"; align: \"start\" | \"center\" | \"end\"; offset: number }\n  | { kind: \"sub\"; flip: boolean };\n\nexport const isMenuItem = (e: MenuEntry): e is MenuItemEntry =>\n  e.type === undefined || e.type === \"item\" || e.type === \"checkbox\" || e.type === \"radio\";\n\nconst SUB_WIDTH = 216;\nconst EDGE = 8;\n\nfunction roleOf(e: MenuItemEntry) {\n  if (e.type === \"radio\") return \"menuitemradio\" as const;\n  if (e.type === \"checkbox\" || e.checked !== undefined) return \"menuitemcheckbox\" as const;\n  return \"menuitem\" as const;\n}\n\n/** Filter entries by a query (label, description, keywords), keeping submenu parents with matching children and tidying separators and section labels. */\nexport function filterMenuEntries(entries: MenuEntry[], query: string): MenuEntry[] {\n  const q = query.trim().toLowerCase();\n  if (!q) return entries;\n  const kept: MenuEntry[] = [];\n  for (const e of entries) {\n    if (!isMenuItem(e)) {\n      kept.push(e);\n      continue;\n    }\n    const hay = [e.label, e.description ?? \"\", ...(e.keywords ?? [])].join(\" \").toLowerCase();\n    if (hay.includes(q)) kept.push(e);\n    else if (e.items?.length) {\n      const sub = filterMenuEntries(e.items, q);\n      if (sub.some(isMenuItem)) kept.push({ ...e, items: sub });\n    }\n  }\n  // Drop section labels with no items after them, and leading / trailing / doubled separators.\n  const out: MenuEntry[] = [];\n  kept.forEach((e, i) => {\n    if (e.type === \"label\") {\n      const next = kept[i + 1];\n      if (!next || !isMenuItem(next)) return;\n    }\n    if (e.type === \"separator\") {\n      const prev = out[out.length - 1];\n      const hasNext = kept.slice(i + 1).some(isMenuItem);\n      if (!prev || prev.type === \"separator\" || !hasNext) return;\n    }\n    out.push(e);\n  });\n  return out;\n}\n\nexport interface MenuPanelProps {\n  entries: MenuEntry[];\n  level?: number;\n  placement: MenuPlacement;\n  /** Where focus goes on mount: the first or last item (keyboard), the menu (pointer) or nowhere (hover-opened submenu). */\n  focusOnOpen: \"first\" | \"last\" | \"panel\" | \"none\";\n  /** Changing this re-applies focusOnOpen, e.g. ArrowRight on an item whose submenu is already open from hover. */\n  focusToken?: number;\n  onCloseAll: (restore: boolean) => void;\n  /** Submenu only: close this level and return focus to the parent item. */\n  onBack?: () => void;\n  /** ArrowUp on the first row calls this instead of wrapping (e.g. back to a search box). */\n  onExitTop?: () => void;\n  /** Replaces typeahead: printable keys and Backspace are handed here (e.g. to a search box). */\n  onPrintableKey?: (key: string) => void;\n  /** Whether checkbox / radio rows close the menu when chosen. */\n  closeOnCheck?: boolean;\n  /** Root only: content above the list (account card, search box). */\n  header?: React.ReactNode;\n  /** Root only: content below the list. */\n  footer?: React.ReactNode;\n  /** Shown when there are no entries (e.g. no search results). */\n  empty?: React.ReactNode;\n  /** Cap the list height and scroll it. Leave off when rows have submenus. */\n  scrollable?: boolean;\n  menuId?: string;\n  labelledBy?: string;\n  menuClassName?: string;\n  /** Extra classes for the root surface only. */\n  className?: string;\n  /** Root surface width in px (clamped to the viewport). */\n  width?: number;\n}\n\n/** The menu surface: renders entries, owns keyboard navigation, the highlight and nested submenus. */\nexport function MenuPanel({\n  entries,\n  level = 0,\n  placement,\n  focusOnOpen,\n  focusToken,\n  onCloseAll,\n  onBack,\n  onExitTop,\n  onPrintableKey,\n  closeOnCheck = true,\n  header,\n  footer,\n  empty,\n  scrollable,\n  menuId,\n  labelledBy,\n  menuClassName,\n  className,\n  width,\n  ref: forwarded,\n}: MenuPanelProps & { ref?: React.Ref<HTMLDivElement> }) {\n  const reduce = useReducedMotion();\n  const uid = React.useId();\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const [active, setActive] = React.useState(-1);\n  const [sub, setSub] = React.useState<{ index: number; keyboard: boolean; flip: boolean; token: number } | null>(null);\n  const hoverTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const typeahead = React.useRef<{ buffer: string; timer: ReturnType<typeof setTimeout> | null }>({ buffer: \"\", timer: null });\n  const sideRef = React.useRef<\"top\" | \"bottom\" | null>(null);\n  React.useImperativeHandle(forwarded, () => surfaceRef.current as HTMLDivElement);\n\n  const enabled = entries.map((e, i) => (isMenuItem(e) && !e.disabled ? i : -1)).filter((i) => i !== -1);\n\n  // Entries can change while open (search filter, toggled checkbox). Drop a highlight or submenu that no longer points at a live row.\n  const activeEntry = entries[active];\n  if (active !== -1 && !(activeEntry && isMenuItem(activeEntry) && !activeEntry.disabled)) setActive(-1);\n  const subEntry = sub ? entries[sub.index] : undefined;\n  if (sub && !(subEntry && isMenuItem(subEntry) && subEntry.items?.length)) setSub(null);\n\n  // Placement: keep the root inside the viewport (flip at the pointer, or flip / shift around an anchor) and nudge submenus up if they overflow.\n  React.useLayoutEffect(() => {\n    const el = surfaceRef.current;\n    if (!el) return;\n    if (placement.kind === \"point\") {\n      const r = el.getBoundingClientRect();\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      const left = placement.x + r.width > vw - EDGE ? Math.max(EDGE, placement.x - r.width) : placement.x;\n      const top = placement.y + r.height > vh - EDGE ? Math.max(EDGE, placement.y - r.height) : placement.y;\n      el.style.left = `${left}px`;\n      el.style.top = `${top}px`;\n      el.style.transformOrigin = `${left < placement.x ? \"100%\" : \"0%\"} ${top < placement.y ? \"100%\" : \"0%\"}`;\n      return;\n    }\n    if (placement.kind === \"sub\") {\n      const r = el.getBoundingClientRect();\n      const vh = window.innerHeight;\n      if (r.bottom > vh - EDGE) el.style.top = `${-4 - (r.bottom - (vh - EDGE))}px`;\n      return;\n    }\n    const { anchor, align, offset } = placement;\n    const place = () => {\n      const a = anchor.getBoundingClientRect();\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      // offsetWidth/Height ignore the entrance scale, so the first measurement is exact.\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      const below = vh - a.bottom - offset - EDGE;\n      const above = a.top - offset - EDGE;\n      if (!sideRef.current) {\n        sideRef.current =\n          placement.side === \"bottom\" ? (h > below && above > below ? \"top\" : \"bottom\") : h > above && below > above ? \"bottom\" : \"top\";\n      }\n      const side = sideRef.current;\n      const raw = align === \"start\" ? a.left : align === \"end\" ? a.right - w : a.left + a.width / 2 - w / 2;\n      const left = Math.round(Math.min(Math.max(EDGE, raw), Math.max(EDGE, vw - w - EDGE)));\n      el.style.left = `${left}px`;\n      if (side === \"bottom\") {\n        el.style.top = `${Math.round(a.bottom + offset)}px`;\n        el.style.bottom = \"auto\";\n      } else {\n        el.style.top = \"auto\";\n        el.style.bottom = `${Math.round(vh - a.top + offset)}px`;\n      }\n      // Room left for the scrolling list once the header / footer are accounted for.\n      const chrome = h - (listRef.current?.offsetHeight ?? 0);\n      el.style.setProperty(\"--menu-available-height\", `${Math.max(140, Math.round((side === \"bottom\" ? below : above) - chrome))}px`);\n      const ox = Math.round(Math.min(Math.max(a.left + a.width / 2 - left, 12), w - 12));\n      el.style.transformOrigin = `${ox}px ${side === \"bottom\" ? \"0%\" : \"100%\"}`;\n    };\n    place();\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    return () => {\n      window.removeEventListener(\"resize\", place);\n      window.removeEventListener(\"scroll\", place, true);\n    };\n  }, [placement]);\n\n  React.useEffect(() => {\n    const t = requestAnimationFrame(() => {\n      if (focusOnOpen === \"first\" && enabled.length) focusItem(enabled[0]);\n      else if (focusOnOpen === \"last\" && enabled.length) focusItem(enabled[enabled.length - 1]);\n      else if (focusOnOpen === \"panel\") listRef.current?.focus({ preventScroll: true });\n    });\n    return () => cancelAnimationFrame(t);\n    // On mount, and again when the parent asks (focusToken).\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [focusToken]);\n\n  React.useEffect(() => {\n    const ta = typeahead.current;\n    return () => {\n      if (hoverTimer.current) clearTimeout(hoverTimer.current);\n      if (ta.timer) clearTimeout(ta.timer);\n    };\n  }, []);\n\n  function focusItem(i: number) {\n    setActive(i);\n    itemRefs.current[i]?.focus({ preventScroll: true });\n  }\n\n  function openSub(i: number, viaKeyboard: boolean) {\n    const r = surfaceRef.current?.getBoundingClientRect();\n    const f = r ? r.right + SUB_WIDTH > window.innerWidth - EDGE : false;\n    setSub((prev) =>\n      prev?.index === i\n        ? { ...prev, keyboard: viaKeyboard || prev.keyboard, token: viaKeyboard ? prev.token + 1 : prev.token }\n        : { index: i, keyboard: viaKeyboard, flip: f, token: 0 },\n    );\n  }\n\n  function activate(i: number, viaKeyboard: boolean) {\n    const e = entries[i];\n    if (!e || !isMenuItem(e) || e.disabled) return;\n    if (e.items?.length) return openSub(i, viaKeyboard);\n    e.onSelect?.();\n    const checkable = roleOf(e) !== \"menuitem\";\n    if (e.closeOnSelect ?? (checkable ? closeOnCheck : true)) onCloseAll(true);\n  }\n\n  function runTypeahead(key: string) {\n    const ta = typeahead.current;\n    if (ta.timer) clearTimeout(ta.timer);\n    ta.buffer += key.toLowerCase();\n    ta.timer = setTimeout(() => {\n      ta.buffer = \"\";\n    }, 500);\n    const pos = enabled.indexOf(active);\n    // A single repeated letter cycles through matches; a longer buffer refines from the current row.\n    const repeat = ta.buffer.length > 1 && ta.buffer.split(\"\").every((c) => c === ta.buffer[0]);\n    const needle = repeat ? ta.buffer[0] : ta.buffer;\n    const start = needle.length === 1 ? pos + 1 : Math.max(pos, 0);\n    for (let k = 0; k < enabled.length; k++) {\n      const idx = enabled[(start + k) % enabled.length];\n      const e = entries[idx];\n      if (isMenuItem(e) && e.label.toLowerCase().startsWith(needle)) {\n        focusItem(idx);\n        return;\n      }\n    }\n  }\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    const pos = enabled.indexOf(active);\n    switch (e.key) {\n      case \"ArrowDown\":\n        if (!enabled.length) return;\n        focusItem(enabled[(pos + 1) % enabled.length]);\n        break;\n      case \"ArrowUp\":\n        if (pos <= 0 && onExitTop) {\n          setActive(-1);\n          onExitTop();\n          break;\n        }\n        if (!enabled.length) return;\n        focusItem(enabled[pos <= 0 ? enabled.length - 1 : pos - 1]);\n        break;\n      case \"Home\":\n        if (enabled.length) focusItem(enabled[0]);\n        break;\n      case \"End\":\n        if (enabled.length) focusItem(enabled[enabled.length - 1]);\n        break;\n      case \"ArrowRight\": {\n        const entry = entries[active];\n        if (entry && isMenuItem(entry) && entry.items?.length) openSub(active, true);\n        else return;\n        break;\n      }\n      case \"ArrowLeft\":\n        if (!onBack) return;\n        onBack();\n        break;\n      case \"Escape\":\n        if (onBack) onBack();\n        else onCloseAll(true);\n        break;\n      case \"Enter\":\n      case \" \":\n        if (active >= 0) activate(active, true);\n        break;\n      case \"Tab\":\n        onCloseAll(false);\n        break;\n      default: {\n        const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;\n        if (onPrintableKey && (printable || e.key === \"Backspace\")) onPrintableKey(e.key);\n        else if (printable && e.key !== \" \") runTypeahead(e.key);\n        else return;\n      }\n    }\n    e.preventDefault();\n    e.stopPropagation();\n  }\n\n  let itemCount = 0;\n\n  function renderItem(entry: MenuItemEntry, i: number) {\n    const n = itemCount++;\n    const on = active === i;\n    const hasSub = !!entry.items?.length;\n    const subOpen = sub?.index === i;\n    const role = roleOf(entry);\n    const checkable = role !== \"menuitem\";\n    const indicator =\n      role === \"menuitemradio\" ? (\n        <span className={cn(\"grid size-3.5 place-items-center rounded-full border\", entry.checked ? \"border-volt-ink\" : \"border-border-strong\")}>\n          {entry.checked && <span className=\"size-1.5 rounded-full bg-volt-ink\" />}\n        </span>\n      ) : entry.type === \"checkbox\" ? (\n        <span\n          className={cn(\n            \"grid size-3.5 place-items-center rounded-[4px] border\",\n            entry.checked ? \"border-volt bg-volt text-on-volt\" : \"border-border-strong\",\n          )}\n        >\n          {entry.checked && <Check className=\"!size-2.5\" strokeWidth={3.5} />}\n        </span>\n      ) : entry.checked ? (\n        <Check className=\"text-volt-ink\" strokeWidth={2.5} />\n      ) : null;\n    // Checkable rows with their own icon keep it and move the indicator to the end.\n    const leading = checkable && !entry.icon ? indicator : entry.icon;\n    const trailingIndicator = checkable && entry.icon ? indicator : null;\n    return (\n      <motion.div\n        key={`${entry.label}-${i}`}\n        ref={(el) => {\n          itemRefs.current[i] = el;\n        }}\n        role={role}\n        aria-checked={checkable ? !!entry.checked : undefined}\n        aria-disabled={entry.disabled || undefined}\n        aria-haspopup={hasSub ? \"menu\" : undefined}\n        aria-expanded={hasSub ? subOpen : undefined}\n        data-menu-item=\"\"\n        tabIndex={-1}\n        initial={reduce ? false : { opacity: 0, y: -4 }}\n        animate={{ opacity: entry.disabled ? 0.4 : 1, y: 0 }}\n        transition={{ delay: reduce ? 0 : 0.02 + n * 0.018, duration: 0.18 }}\n        onFocus={() => {\n          if (!entry.disabled && active !== i) setActive(i);\n        }}\n        onPointerMove={() => {\n          if (entry.disabled || active === i) return;\n          focusItem(i);\n          if (hoverTimer.current) clearTimeout(hoverTimer.current);\n          hoverTimer.current = setTimeout(() => {\n            if (hasSub) openSub(i, false);\n            else setSub(null);\n          }, 110);\n        }}\n        aria-label={entry.description ? undefined : entry.label}\n        onClick={(e) => {\n          e.stopPropagation();\n          activate(i, false);\n        }}\n        className={cn(\n          \"relative flex cursor-default select-none gap-2.5 rounded-sm px-2.5 outline-none\",\n          entry.description ? \"items-start py-2\" : \"h-8 items-center\",\n          entry.danger && !entry.disabled ? \"text-danger\" : \"text-ink\",\n        )}\n      >\n        {(on || subOpen) && (\n          <motion.span\n            layoutId={`${uid}-highlight`}\n            aria-hidden\n            transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 40 }}\n            className={cn(\"absolute inset-0 rounded-sm\", entry.danger ? \"bg-danger/12\" : \"bg-surface-hover\")}\n          />\n        )}\n        {(leading || !entry.description) && (\n          <span\n            aria-hidden\n            className={cn(\n              \"relative grid shrink-0 place-items-center text-ink-muted [&>svg]:size-4\",\n              entry.description ? \"mt-px min-h-4 min-w-4\" : \"size-4\",\n              entry.danger && \"text-danger\",\n            )}\n          >\n            {leading}\n          </span>\n        )}\n        {entry.description ? (\n          <span className=\"relative min-w-0 flex-1\">\n            <span className={cn(\"block truncate font-medium leading-5\", entry.danger && \"text-danger\")}>{entry.label}</span>\n            <span className=\"block text-[12px] leading-4 text-ink-subtle\">{entry.description}</span>\n          </span>\n        ) : (\n          <span className={cn(\"relative flex-1 truncate\", entry.danger && \"text-danger\")}>{entry.label}</span>\n        )}\n        {entry.badge && <span className={cn(\"relative flex shrink-0\", entry.description && \"mt-0.5\")}>{entry.badge}</span>}\n        {entry.shortcut && !hasSub && (\n          <kbd className={cn(\"relative font-sans text-[11.5px] tracking-[0.5px] text-ink-subtle\", entry.description && \"mt-0.5\")}>{entry.shortcut}</kbd>\n        )}\n        {trailingIndicator && (\n          <span aria-hidden className=\"relative grid size-4 shrink-0 place-items-center [&>svg]:size-4\">\n            {trailingIndicator}\n          </span>\n        )}\n        {hasSub && <ChevronRight aria-hidden className=\"relative size-3.5 text-ink-subtle\" />}\n        <AnimatePresence>\n          {subOpen && entry.items && (\n            <MenuPanel\n              key=\"sub\"\n              entries={entry.items}\n              level={level + 1}\n              placement={{ kind: \"sub\", flip: sub.flip }}\n              focusOnOpen={sub.keyboard ? \"first\" : \"none\"}\n              focusToken={sub.token}\n              onCloseAll={onCloseAll}\n              closeOnCheck={closeOnCheck}\n              onBack={() => {\n                setSub(null);\n                focusItem(i);\n              }}\n              menuClassName={menuClassName}\n            />\n          )}\n        </AnimatePresence>\n      </motion.div>\n    );\n  }\n\n  // Rows after a section label are wrapped in role=\"group\" labelled by it.\n  const nodes: React.ReactNode[] = [];\n  const open: { group: { key: string; labelId: string; children: React.ReactNode[] } | null } = { group: null };\n  const flush = () => {\n    const g = open.group;\n    if (!g) return;\n    nodes.push(\n      <div key={g.key} role=\"group\" aria-labelledby={g.labelId}>\n        {g.children}\n      </div>,\n    );\n    open.group = null;\n  };\n  for (let i = 0; i < entries.length; i++) {\n    const entry = entries[i];\n    if (entry.type === \"separator\") {\n      flush();\n      nodes.push(<div key={`sep-${i}`} role=\"separator\" className=\"mx-2 my-1 h-px bg-border\" />);\n    } else if (entry.type === \"label\") {\n      flush();\n      const labelId = `${uid}-l${i}`;\n      open.group = {\n        key: `group-${i}`,\n        labelId,\n        children: [\n          <div key=\"label\" id={labelId} className=\"px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-[0.5px] text-ink-subtle\">\n            {entry.label}\n          </div>,\n        ],\n      };\n    } else if (isMenuItem(entry)) {\n      const node = renderItem(entry, i);\n      if (open.group) open.group.children.push(node);\n      else nodes.push(node);\n    }\n  }\n  flush();\n\n  const isRoot = placement.kind !== \"sub\";\n  const flip = placement.kind === \"sub\" && placement.flip;\n\n  return (\n    <motion.div\n      ref={surfaceRef}\n      onPointerLeave={() => {\n        if (!sub) setActive(-1);\n      }}\n      initial={\n        reduce\n          ? { opacity: 0 }\n          : level === 0\n            ? { opacity: 0, scale: placement.kind === \"anchor\" ? 0.94 : 0.96, filter: \"blur(4px)\" }\n            : { opacity: 0, x: flip ? 8 : -8, filter: \"blur(4px)\" }\n      }\n      animate={{ opacity: 1, scale: 1, x: 0, filter: \"blur(0px)\" }}\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: 0.1 } }\n          : level === 0\n            ? { opacity: 0, scale: 0.97, filter: \"blur(2px)\", transition: { duration: 0.12 } }\n            : { opacity: 0, x: flip ? 6 : -6, transition: { duration: 0.12 } }\n      }\n      transition={{ type: \"spring\", stiffness: 520, damping: 34, opacity: { duration: 0.14 } }}\n      style={\n        placement.kind === \"point\"\n          ? { position: \"fixed\", left: placement.x, top: placement.y }\n          : placement.kind === \"anchor\"\n            ? { position: \"fixed\", left: 0, top: 0, ...(width ? { width, maxWidth: \"calc(100vw - 16px)\" } : null) }\n            : { position: \"absolute\", top: -5, ...(flip ? { right: \"calc(100% + 4px)\" } : { left: \"calc(100% + 4px)\" }) }\n      }\n      className={cn(\n        \"z-[100] min-w-[216px] rounded-md border border-border bg-glass p-1 text-[13px] text-ink shadow-lg outline-none backdrop-blur-xl backdrop-saturate-150\",\n        isRoot && className,\n        menuClassName,\n      )}\n    >\n      {isRoot && header}\n      <div\n        ref={listRef}\n        id={menuId}\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        aria-labelledby={labelledBy}\n        tabIndex={-1}\n        onKeyDown={onKeyDown}\n        className={cn(\"outline-none\", scrollable && \"max-h-[min(26rem,var(--menu-available-height,26rem))] overflow-y-auto overscroll-contain\")}\n      >\n        {nodes}\n        {!enabled.length && !entries.some(isMenuItem) && empty}\n      </div>\n      {isRoot && footer}\n    </motion.div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","context menu","right click","menu","long press","submenu","shortcuts","keyboard","dropdown"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/context-menu"}}