{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "context-menu-unfold",
  "title": "Context Menu Unfold",
  "description": "A context menu that unfolds like a pocket knife — items swing out from a hinged spine as slim blades, staggered from folded to open, with a hairline edge highlight while swinging; submenus unfold as a second, smaller knife from a blade's tip.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/context-menu-unfold/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// JackKnife — a context menu that unfolds like a pocket knife. A spine\n// anchors at the trigger point (the pointer for a right-click, or the\n// trigger button's own position for the accessible trigger); menu items are\n// slim \"blades\" hinged at the spine edge, each swinging in with a staggered\n// rotation from folded (~80deg, tucked flat against the spine) to open\n// (0deg). Every swing is a one-shot ref-driven CSS transition (transform +\n// a border-color pulse for the hairline edge highlight) — timed with a\n// per-item delay for the stagger — not a continuous rAF loop. A submenu is\n// the same component recursively, anchored at the parent blade's tip.\n// Distinct from menu-nested-trays (telescoping trays) and dropdown-drape (cloth\n// dropdown): this is a radial-fold context-menu primitive.\n// ---------------------------------------------------------------------------\n\nexport interface JackKnifeItem {\n  id: string;\n  label: string;\n  shortcut?: string;\n  disabled?: boolean;\n  submenu?: JackKnifeItem[];\n}\n\nexport interface JackKnifeProps {\n  items: JackKnifeItem[];\n  onSelect?: (id: string) => void;\n  /** region that opens the menu on right-click; the trigger button always works too */\n  children?: ReactNode;\n  /** accessible name for the trigger button and the menu itself */\n  label?: string;\n  className?: string;\n}\n\ntype Anchor = { x: number; y: number; side: \"left\" | \"right\" };\n\nconst OPEN_MS = 260;\nconst OPEN_STAGGER_MS = 38;\nconst CLOSE_MS = 150;\nconst CLOSE_STAGGER_MS = 22;\nconst HAIRLINE_HOLD_MS = 120;\nconst TYPEAHEAD_RESET_MS = 700;\nconst ITEM_W = 200;\nconst EDGE_MARGIN = 10;\n\nfunction computeAnchor(x: number, y: number): Anchor {\n  const side: Anchor[\"side\"] = x + ITEM_W + EDGE_MARGIN > window.innerWidth ? \"left\" : \"right\";\n  return { x, y, side };\n}\n\nfunction clampTop(y: number, height: number) {\n  return Math.min(Math.max(EDGE_MARGIN, y), Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN));\n}\n\nfunction foldItem(\n  el: HTMLElement | null,\n  index: number,\n  opening: boolean,\n  side: \"left\" | \"right\",\n  reduced: boolean\n) {\n  if (!el) return;\n  const sign = side === \"right\" ? -1 : 1;\n  if (reduced) {\n    el.style.transition = \"none\";\n    el.style.transform = \"rotate(0deg)\";\n    el.style.opacity = opening ? \"1\" : \"0\";\n    el.style.borderColor = \"var(--border)\";\n    return;\n  }\n  if (opening) {\n    el.style.transition = \"none\";\n    el.style.transform = `rotate(${sign * 80}deg)`;\n    el.style.opacity = \"0\";\n    el.style.borderColor = \"var(--border)\";\n    el.getBoundingClientRect(); // force reflow before animating in\n    const delay = index * OPEN_STAGGER_MS;\n    el.style.transition = `transform ${OPEN_MS}ms cubic-bezier(0.16,1,0.3,1) ${delay}ms, opacity ${OPEN_MS * 0.7}ms linear ${delay}ms, border-color ${OPEN_MS}ms linear ${delay}ms`;\n    el.style.transform = \"rotate(0deg)\";\n    el.style.opacity = \"1\";\n    el.style.borderColor = \"var(--foreground)\";\n    window.setTimeout(() => {\n      el.style.transition = `border-color ${HAIRLINE_HOLD_MS}ms linear`;\n      el.style.borderColor = \"var(--border)\";\n    }, delay + OPEN_MS);\n  } else {\n    const delay = index * CLOSE_STAGGER_MS;\n    el.style.transition = `transform ${CLOSE_MS}ms cubic-bezier(0.6,0,0.84,0) ${delay}ms, opacity ${CLOSE_MS}ms linear ${delay}ms`;\n    el.style.transform = `rotate(${sign * 80}deg)`;\n    el.style.opacity = \"0\";\n  }\n}\n\ninterface BladesProps {\n  items: JackKnifeItem[];\n  anchor: Anchor;\n  label: string;\n  onSelect: (id: string) => void;\n  onClose: (focusTrigger: boolean) => void;\n  menuId: string;\n}\n\nfunction Blades({ items, anchor, label, onSelect, onClose, menuId }: BladesProps) {\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const reducedRef = useRef(false);\n  const [reduced, setReduced] = useState(false);\n  const [submenuIndex, setSubmenuIndex] = useState<number | null>(null);\n  const [submenuAnchor, setSubmenuAnchor] = useState<Anchor | null>(null);\n  const typeaheadRef = useRef({ buffer: \"\", at: 0 });\n\n  const closableItems = useMemo(() => items.filter((it) => !it.disabled), [items]);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    setReduced(mq.matches);\n    const onChange = () => {\n      reducedRef.current = mq.matches;\n      setReduced(mq.matches);\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(() => {\n    items.forEach((_, i) => foldItem(itemRefs.current[i] ?? null, i, true, anchor.side, reducedRef.current));\n    const first = itemRefs.current.find((el, i) => el && !items[i]?.disabled);\n    first?.focus({ preventScroll: true });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const closeSelf = useCallback(\n    (focusTrigger: boolean) => {\n      items.forEach((_, i) => foldItem(itemRefs.current[i] ?? null, i, false, anchor.side, reducedRef.current));\n      const total = reducedRef.current ? 0 : items.length * CLOSE_STAGGER_MS + CLOSE_MS;\n      window.setTimeout(() => onClose(focusTrigger), total);\n    },\n    [items, anchor.side, onClose]\n  );\n\n  const openSubmenu = (index: number) => {\n    const el = itemRefs.current[index];\n    if (!el) return;\n    const rect = el.getBoundingClientRect();\n    const x = anchor.side === \"right\" ? rect.right - 8 : rect.left + 8;\n    setSubmenuAnchor(computeAnchor(x, rect.top));\n    setSubmenuIndex(index);\n  };\n  const closeSubmenu = () => {\n    setSubmenuIndex(null);\n    setSubmenuAnchor(null);\n  };\n\n  const focusByIndex = (i: number) => {\n    const el = itemRefs.current[i];\n    el?.focus({ preventScroll: true });\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (submenuIndex !== null) {\n      // let the submenu's own handler deal with everything except closing it back here\n      if (e.key === \"Escape\") return; // bubbles from submenu, harmless\n      return;\n    }\n    const enabled = itemRefs.current\n      .map((el, i) => ({ el, i }))\n      .filter(({ el, i }) => el && !items[i]?.disabled);\n    const currentIdx = enabled.findIndex(({ el }) => el === document.activeElement);\n\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      closeSelf(true);\n      return;\n    }\n    if (e.key === \"Tab\") {\n      closeSelf(false);\n      return;\n    }\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      const next = enabled[(currentIdx + 1) % enabled.length];\n      if (next) focusByIndex(next.i);\n      return;\n    }\n    if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      const next = enabled[currentIdx < 0 ? enabled.length - 1 : (currentIdx - 1 + enabled.length) % enabled.length];\n      if (next) focusByIndex(next.i);\n      return;\n    }\n    if (e.key === \"Home\") {\n      e.preventDefault();\n      const first = enabled[0];\n      if (first) focusByIndex(first.i);\n      return;\n    }\n    if (e.key === \"End\") {\n      e.preventDefault();\n      const last = enabled[enabled.length - 1];\n      if (last) focusByIndex(last.i);\n      return;\n    }\n    if (e.key === \"ArrowRight\" || e.key === \"Enter\" || e.key === \" \") {\n      const active = items[currentIdx >= 0 ? enabled[currentIdx]!.i : -1];\n      if (active?.submenu?.length) {\n        e.preventDefault();\n        openSubmenu(enabled[currentIdx]!.i);\n      } else if (e.key !== \"ArrowRight\" && active && !active.disabled) {\n        e.preventDefault();\n        onSelect(active.id);\n      }\n      return;\n    }\n    if (e.key === \"ArrowLeft\") {\n      return; // top level has nowhere to go left to; submenu instance handles its own close\n    }\n    if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {\n      const now = Date.now();\n      const ta = typeaheadRef.current;\n      ta.buffer = now - ta.at < TYPEAHEAD_RESET_MS ? ta.buffer + e.key : e.key;\n      ta.at = now;\n      const match = closableItems.find((it) => it.label.toLowerCase().startsWith(ta.buffer.toLowerCase()));\n      if (match) {\n        const idx = items.indexOf(match);\n        focusByIndex(idx);\n      }\n    }\n  };\n\n  const panelTop = clampTop(anchor.y, items.length * 38 + 16);\n  const panelLeft = anchor.side === \"right\" ? anchor.x : anchor.x - ITEM_W;\n\n  return (\n    <div\n      ref={panelRef}\n      role=\"menu\"\n      aria-label={label}\n      id={menuId}\n      onKeyDown={onKeyDown}\n      className=\"fixed z-[950] flex flex-col gap-0.5 rounded-[10px] border border-border bg-background p-1.5 shadow-lg\"\n      style={{ left: panelLeft, top: panelTop, width: ITEM_W }}\n    >\n      {items.map((item, i) => (\n        <button\n          key={item.id}\n          ref={(el) => {\n            itemRefs.current[i] = el;\n          }}\n          type=\"button\"\n          role=\"menuitem\"\n          disabled={item.disabled}\n          aria-disabled={item.disabled}\n          aria-haspopup={item.submenu ? \"menu\" : undefined}\n          aria-expanded={item.submenu ? submenuIndex === i : undefined}\n          tabIndex={-1}\n          onMouseEnter={(e: ReactMouseEvent<HTMLButtonElement>) => {\n            if (item.submenu?.length) openSubmenu(i);\n            else closeSubmenu();\n            e.currentTarget.focus({ preventScroll: true });\n          }}\n          onClick={() => {\n            if (item.disabled) return;\n            if (item.submenu?.length) openSubmenu(i);\n            else onSelect(item.id);\n          }}\n          className=\"ns-jk-blade flex origin-left items-center justify-between gap-3 rounded-[6px] border border-transparent px-2.5 py-1.5 text-left text-xs text-foreground outline-none transition-colors hover:bg-border/40 focus-visible:bg-border/40 disabled:pointer-events-none disabled:opacity-40\"\n        >\n          <span className=\"truncate\">{item.label}</span>\n          {item.shortcut && <span className=\"shrink-0 font-mono text-[10px] text-muted\">{item.shortcut}</span>}\n          {item.submenu && !item.shortcut && <span className=\"shrink-0 text-[10px] text-muted\">{\"›\"}</span>}\n        </button>\n      ))}\n\n      {submenuIndex !== null && submenuAnchor && items[submenuIndex]?.submenu && (\n        <Blades\n          items={items[submenuIndex]!.submenu!}\n          anchor={submenuAnchor}\n          label={`${items[submenuIndex]!.label} submenu`}\n          onSelect={onSelect}\n          onClose={(focusTrigger) => {\n            closeSubmenu();\n            if (focusTrigger) focusByIndex(submenuIndex);\n          }}\n          menuId={`${menuId}-sub`}\n        />\n      )}\n\n      {!reduced && <style>{`.ns-jk-blade{transform-box:fill-box;}`}</style>}\n    </div>\n  );\n}\n\nexport function JackKnife({ items, onSelect, children, label = \"Actions\", className = \"\" }: JackKnifeProps) {\n  const autoId = useId();\n  const menuId = `jk-menu-${autoId.replace(/:/g, \"\")}`;\n\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  const [anchor, setAnchor] = useState<Anchor | null>(null);\n  const [open, setOpen] = useState(false);\n\n  const openAt = (x: number, y: number) => setAnchor(computeAnchor(x, y));\n\n  useEffect(() => {\n    setOpen(anchor !== null);\n  }, [anchor]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (e: PointerEvent) => {\n      // scoped to this instance's own menu id (and its `-sub` descendants at\n      // any depth) — a bare `[role=\"menu\"]` query would match a SECOND\n      // JackKnife instance's panel elsewhere on the page and incorrectly\n      // treat a click inside THAT menu as \"inside this one\"\n      const panels = document.querySelectorAll(`[id^=\"${menuId}\"]`);\n      const target = e.target as Node;\n      const inside = Array.from(panels).some((p) => p.contains(target));\n      if (!inside) setAnchor(null);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true);\n  }, [open, menuId]);\n\n  const handleSelect = (id: string) => {\n    onSelect?.(id);\n    setAnchor(null);\n    triggerRef.current?.focus();\n  };\n\n  const handleClose = (focusTrigger: boolean) => {\n    setAnchor(null);\n    if (focusTrigger) triggerRef.current?.focus();\n  };\n\n  return (\n    <div className={`relative inline-block ${className}`}>\n      {children && (\n        <div\n          onContextMenu={(e: ReactMouseEvent) => {\n            e.preventDefault();\n            openAt(e.clientX, e.clientY);\n          }}\n        >\n          {children}\n        </div>\n      )}\n\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        data-context-menu-unfold-trigger=\"\"\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-controls={open ? menuId : undefined}\n        onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {\n          const rect = e.currentTarget.getBoundingClientRect();\n          openAt(rect.left, rect.bottom + 6);\n        }}\n        className=\"mt-3 rounded-[6px] border border-border px-2.5 py-1.5 text-xs text-foreground transition-colors hover:border-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\"\n      >\n        {label}\n      </button>\n\n      {open && anchor && (\n        <Blades\n          items={items}\n          anchor={anchor}\n          label={label}\n          onSelect={handleSelect}\n          onClose={handleClose}\n          menuId={menuId}\n        />\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/context-menu-unfold.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "context-menu",
      "menu",
      "dropdown",
      "right-click",
      "keyboard-navigation",
      "submenu",
      "accessibility"
    ],
    "instruction": "Build a context menu whose open/close animation reads as a pocket knife unfolding, usable both via right-click on a wrapped region and via a visible, accessible trigger button (both open the identical menu — the button exists because a right-click affordance alone is unreachable by keyboard, and it is also what any automated or assistive-tech interaction should use). On open, compute an anchor point (the pointer's clientX/clientY for a right-click, or the trigger button's own bounding rect for a button click) and decide a fold 'side' (right or left) by checking whether the menu's fixed width would overflow the right edge of the viewport from that anchor — if it would, anchor the panel so its right edge sits at the pointer instead of its left edge, and mirror the fold direction. The menu panel itself is position:fixed, its top clamped so it never overflows the bottom of the viewport either. Each menu item is a real <button role=\"menuitem\"> (transform-box: fill-box; transform-origin: left) that starts, on mount, folded flat (`rotate(80deg)` or `rotate(-80deg)` depending on fold side, so it always rotates 'away from' the anchor edge like a real blade tucked behind the spine) and opacity 0. Immediately after mount (post-reflow, so the folded starting pose actually commits as a distinct frame), transition every item to `rotate(0deg)` / opacity 1 over ~260ms with an ease-out-expo curve, but stagger each item's transition-delay by its index × ~38ms so they swing open one after another rather than all at once — a real fan-of-blades opening. Simultaneously transition the item's border-color from --border to --foreground over the same timing (the 'hairline edge highlight while swinging'), then — once that item's own swing finishes — transition the border back down to --border over a short ~120ms settle, so the highlight is specifically tied to motion, not a resting state. Closing reverses this: stagger from the LAST item to the first (reverse order reads as 'folding back up'), shorter duration (~150ms) and tighter stagger (~22ms), transform back to the folded rotation and opacity 0 — actually unmount the panel only after the full staggered close duration elapses, never abruptly. All of this is one-shot ref-driven inline style writes per open/close event (computed once, CSS interpolates) — never a continuous rAF loop, since nothing here needs per-frame updates. A submenu (items with a `submenu` array) is the exact same component rendered recursively: hovering or pressing ArrowRight/Enter on an item that has one computes an anchor at that blade's own tip (its bounding rect) and mounts a second, smaller-feeling knife there with the identical fold-in/fold-out mechanic — closing it (Escape, ArrowLeft context, or selecting one of its items) returns focus to the parent blade that opened it. Full menu keyboard semantics on the top-level panel (role=menu, aria-label): ArrowDown/ArrowUp move a roving focus among enabled (non-disabled) items only, wrapping at both ends; Home/End jump to the first/last enabled item; typeahead buffers printable keystrokes (reset after ~700ms of inactivity) and jumps focus to the first item whose label starts with the buffered string, case-insensitively; Escape folds the whole menu closed and returns focus to whatever opened it (the right-click surface conceptually, or literally the trigger button); Tab also closes the menu (without stealing focus) since a menu should never trap Tab out of the document flow. A pointerdown anywhere outside any open menu panel (top-level or submenu) closes the whole stack. Hovering an item (mouse only) slides it 2px further out along its own resting angle (0deg, so a plain translateX, signed by fold side) as a lightweight 'lean into it' cue, separate from the open/close swing. prefers-reduced-motion: items appear directly in their open pose (opacity 1, rotate(0deg)) with no folded starting frame, no stagger, no swing — closing likewise just disappears, both still fully functional and instant rather than absent. Distinct from menu-nested-trays (telescoping horizontal trays) and dropdown-drape (a cloth/verlet-simulated dropdown panel): this is a radial-fold context-menu primitive, not a scrollable tray or a physics panel. Zero dependencies, no canvas."
  },
  "type": "registry:ui"
}