{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"context-menu","type":"registry:ui","title":"Context Menu","description":"The menu a right-click opens, right at the pointer – a long press on touch, Shift F10 from the keyboard, items built for what was clicked, with icons, shortcuts, checks, red items and submenus","dependencies":[],"files":[{"path":"registry/context-menu/context-menu.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  cloneElement,\n  createElement,\n  Fragment,\n  isValidElement,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type FocusEvent as ReactFocusEvent,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type CSSProperties,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  type SyntheticEvent,\n} from \"react\";\n\n/*\n * Context Menu – a single self-contained file.\n * The menu a right-click opens, right at the pointer: on a file, a row, a canvas or a whole list. A long press opens\n * it on touch screens, Shift F10 and the menu key from the focused element. Icons, a quiet second line, shortcuts,\n * dividers, red items, checks and submenus, one soft highlight that glides between the rows and type-ahead – the same\n * menu as the Dropdown Menu. It sits in the top layer, so no overflow cuts it, and opens to whichever side has room.\n * The styles are injected below, so no extra files are needed.\n */\n\n/** An icon from Hugeicons (e.g. `import { Copy01Icon } from \"@hugeicons/core-free-icons\"`) – drawn inline */\nexport type HugeIcon = readonly (readonly [string, { readonly [key: string]: string | number }])[];\n\nexport type ContextMenuItem = {\n  label: string;\n  /** A second line in a lighter color */\n  description?: string;\n  /** A 16px glyph before the label: an inline SVG or a Hugeicons icon object */\n  icon?: ReactNode | HugeIcon;\n  /** A brand mark (Gmail, Slack …) in place of the icon, in its own colors */\n  logo?: ReactNode;\n  /** Shows a check – the item becomes a choice (true or false; leave it out for a plain action) */\n  checked?: boolean;\n  /** How a choice is announced: one of a set (radio, the default) or on/off on its own (checkbox) */\n  type?: \"radio\" | \"checkbox\";\n  /** Red text and icon, e.g. Delete */\n  danger?: boolean;\n  /** A keyboard shortcut on the right, e.g. \"⌘C\" – shown only, wire the keys up yourself */\n  shortcut?: string;\n  /** A line above this item, to group the ones after it */\n  divider?: boolean;\n  /** Shown, but can't be picked; the arrow keys skip it */\n  disabled?: boolean;\n  /** The menu stays open after a pick – for checkboxes you tick several of */\n  keepOpen?: boolean;\n  /** More items that open to the side on hover, →, Enter or Space */\n  submenu?: ContextMenuItem[];\n  onSelect?: () => void;\n};\n\nexport type ContextMenuProps = {\n  /** The area it opens on – exactly one element that takes a ref: a row, a card, a list, a canvas */\n  children: ReactElement;\n  /**\n   * The items – or a function that builds them for the element under the pointer (or the focused one), so one menu\n   * can cover a whole list: `(target) => target.closest(\"[data-file]\") ? fileItems : listItems`. No items: the\n   * browser's own menu shows\n   */\n  items: ContextMenuItem[] | ((target: HTMLElement) => ContextMenuItem[]);\n  /** A small heading above the items – it also names the menu */\n  title?: string;\n  /** Names the menu when there is no title */\n  \"aria-label\"?: string;\n  /** Never opens – the browser's own menu shows */\n  disabled?: boolean;\n  /** Called when it opens (with the element it opened on) and when it closes – e.g. to mark the row it is about */\n  onOpenChange?: (open: boolean, target: HTMLElement | null) => void;\n  /** The menu's width – by default it fits its items (at least 168px) */\n  width?: number | string;\n  /** Classes on the menu panel */\n  menuClassName?: string;\n};\n\ntype At = { x: number; y: number; target: HTMLElement; back: HTMLElement | null; keyboard: boolean; n: number };\n\n/** How long a finger rests before the menu opens, and how far it may drift meanwhile */\nconst PRESS = 500;\nconst SLOP = 10;\n\n/** Shift F10 and the menu key open it from the keyboard */\nconst isMenuKey = (e: { key: string; shiftKey: boolean }) => e.key === \"ContextMenu\" || (e.shiftKey && e.key === \"F10\");\n\nconst FOCUSABLE = 'button, [href], input, select, textarea, summary, [tabindex]:not([tabindex=\"-1\"])';\n\n/** Text fields keep the browser's menu – paste, spelling, autofill */\nconst editable = (el: Element | null) => !!el?.closest('input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"]');\n\nexport function ContextMenu({\n  children,\n  items,\n  title,\n  \"aria-label\": ariaLabel = \"Actions\",\n  disabled,\n  onOpenChange,\n  width,\n  menuClassName,\n}: ContextMenuProps) {\n  const uid = useId();\n  const area = useRef<HTMLElement | null>(null);\n  const [at, setAt] = useState<At | null>(null);\n  const count = useRef(0);\n  // The keyboard and a long press open it themselves – the browser's contextmenu event that may follow is swallowed\n  const handled = useRef(0);\n  const press = useRef<{ timer: number; x: number; y: number } | null>(null);\n  const report = useRef(onOpenChange);\n  report.current = onOpenChange;\n\n  const build = (target: HTMLElement) => {\n    const list = typeof items === \"function\" ? items(target) : items;\n    return Array.isArray(list) ? list.filter(Boolean) : [];\n  };\n\n  const openAt = (x: number, y: number, target: HTMLElement, keyboard: boolean) => {\n    if (disabled || !build(target).length) return false;\n    const now = document.activeElement as HTMLElement | null;\n    const root = area.current;\n    // The focus comes back here when it closes: what had it inside the area, or the control that was clicked, or the area\n    const hit = target.closest<HTMLElement>(FOCUSABLE);\n    const back = now && root?.contains(now) ? now : hit && root?.contains(hit) ? hit : root && root.tabIndex >= 0 ? root : null;\n    setAt({ x, y, target, back, keyboard, n: ++count.current });\n    report.current?.(true, target);\n    return true;\n  };\n\n  const close = (focusBack: boolean | \"now\") => {\n    const back = at?.back ?? null;\n    if (focusBack === \"now\") back?.focus({ preventScroll: true });\n    setAt(null);\n    report.current?.(false, null);\n    if (focusBack === true) refocus(back);\n  };\n\n  useEffect(() => () => window.clearTimeout(press.current?.timer), []);\n  const endPress = () => {\n    window.clearTimeout(press.current?.timer);\n    press.current = null;\n  };\n\n  if (!isValidElement(children)) return <>{children}</>;\n  const own = children.props as Record<string, unknown> & { ref?: Ref<HTMLElement>; style?: CSSProperties };\n  const call = (name: string, e: SyntheticEvent) => (own[name] as ((e: SyntheticEvent) => void) | undefined)?.(e);\n  // The child's handler runs first; when it calls preventDefault, the menu leaves the event alone\n  const wrap =\n    <E extends SyntheticEvent>(name: string, mine: (e: E) => void) =>\n    (e: E) => {\n      call(name, e);\n      if (!e.defaultPrevented) mine(e);\n    };\n\n  const area$ = cloneElement(children as ReactElement<Record<string, unknown>>, {\n    ref: (el: HTMLElement | null) => {\n      area.current = el;\n      const r = own.ref;\n      if (typeof r === \"function\") r(el);\n      else if (r) (r as { current: HTMLElement | null }).current = el;\n    },\n    // No callout or magnifier on a long press (iOS) – the menu takes its place\n    style: disabled ? own.style : { WebkitTouchCallout: \"none\", ...own.style },\n    onContextMenu: wrap(\"onContextMenu\", (e: ReactMouseEvent) => {\n      if (Date.now() - handled.current < 600) return e.preventDefault();\n      const target = e.target as HTMLElement;\n      if (disabled || editable(target)) return;\n      // Android sends its own contextmenu on a long press – the press timer then stands down\n      const touch = !!press.current;\n      endPress();\n      if (openAt(e.clientX, e.clientY, target, false)) {\n        e.preventDefault();\n        if (touch) handled.current = Date.now();\n      }\n    }),\n    onKeyDown: wrap(\"onKeyDown\", (e: ReactKeyboardEvent) => {\n      if (!isMenuKey(e) || disabled) return;\n      const target = e.target as HTMLElement;\n      if (editable(target)) return;\n      // Opens at the focused element, a little in from its corner\n      const r = target.getBoundingClientRect();\n      const rtl = getComputedStyle(target).direction === \"rtl\";\n      const x = rtl ? r.right - Math.min(12, r.width / 2) : r.left + Math.min(12, r.width / 2);\n      if (openAt(x, r.top + Math.min(r.height, 26), target, true)) {\n        e.preventDefault();\n        handled.current = Date.now();\n      }\n    }),\n    // A long press on a touch screen\n    onPointerDown: wrap(\"onPointerDown\", (e: ReactPointerEvent) => {\n      endPress();\n      if (e.pointerType !== \"touch\" || disabled || editable(e.target as Element)) return;\n      const target = e.target as HTMLElement;\n      const { clientX: x, clientY: y } = e;\n      press.current = {\n        x,\n        y,\n        timer: window.setTimeout(() => {\n          press.current = null;\n          if (openAt(x, y, target, false)) handled.current = Date.now();\n        }, PRESS),\n      };\n    }),\n    onPointerMove: wrap(\"onPointerMove\", (e: ReactPointerEvent) => {\n      const p = press.current;\n      if (p && Math.hypot(e.clientX - p.x, e.clientY - p.y) > SLOP) endPress();\n    }),\n    onPointerUp: wrap(\"onPointerUp\", endPress),\n    onPointerCancel: wrap(\"onPointerCancel\", endPress),\n    // The tap that ends a long press doesn't also press what is under the finger\n    onClickCapture: (e: ReactMouseEvent) => {\n      if (Date.now() - handled.current < 600) {\n        e.preventDefault();\n        e.stopPropagation();\n      }\n      call(\"onClickCapture\", e);\n    },\n  });\n\n  const list = at ? build(at.target) : [];\n\n  return (\n    <>\n      {/* React 19 hoists this into <head> once, no matter how many menus render */}\n      <style href=\"ui-context-menu\" precedence=\"default\">\n        {css}\n      </style>\n      {area$}\n      {at && list.length > 0 && (\n        <MenuPanel\n          key={at.n}\n          id={`${uid}-menu`}\n          at={at}\n          items={list}\n          title={title}\n          label={ariaLabel}\n          width={width}\n          className={menuClassName}\n          onClose={close}\n        />\n      )}\n    </>\n  );\n}\n\n/* ---------- The menu at the pointer ---------- */\n\nfunction MenuPanel({\n  id,\n  at,\n  items,\n  title,\n  label,\n  width,\n  className,\n  onClose,\n}: {\n  id: string;\n  at: At;\n  items: ContextMenuItem[];\n  title?: string;\n  label: string;\n  width?: number | string;\n  className?: string;\n  onClose: (focusBack: boolean | \"now\") => void;\n}) {\n  const panel = useRef<HTMLDivElement>(null);\n  const close = useRef(onClose);\n  close.current = onClose;\n  const titleId = `${id}-title`;\n\n  // Into the top layer at the pointer, before paint: its corner on the point, or turned to whichever side has room\n  useLayoutEffect(() => {\n    const el = panel.current;\n    if (!el) return;\n    showLayer(el);\n    const rtl = getComputedStyle(at.target).direction === \"rtl\";\n    const update = () => place(el, new DOMRect(at.x, at.y, 0, 0), \"bottom\", rtl ? \"end\" : \"start\", 0);\n    update();\n    const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(update);\n    ro?.observe(el);\n    return () => {\n      ro?.disconnect();\n      hideLayer(el);\n    };\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // The focus moves in: onto the menu after a pointer (nothing looks picked yet), onto the first item from the keyboard\n  useEffect(() => {\n    const menu = panel.current?.querySelector<HTMLElement>(\":scope > .ui-ctx__list\");\n    if (!menu) return;\n    if (!at.keyboard) return menu.focus({ preventScroll: true });\n    focusRow(itemsOf(menu)[0] ?? menu);\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // A press elsewhere, a scroll that moves the area, resizing or leaving the window closes it\n  useEffect(() => {\n    const inside = (t: EventTarget | null) => t instanceof Node && !!panel.current?.contains(t);\n    const down = (e: PointerEvent) => !inside(e.target) && close.current(false);\n    const scroll = (e: Event) => {\n      const t = e.target;\n      if (inside(t)) return;\n      if (t === document || (t instanceof Node && t.contains(at.target))) close.current(false);\n    };\n    const away = () => close.current(false);\n    document.addEventListener(\"pointerdown\", down, true);\n    window.addEventListener(\"scroll\", scroll, true);\n    window.addEventListener(\"resize\", away);\n    window.addEventListener(\"blur\", away);\n    return () => {\n      document.removeEventListener(\"pointerdown\", down, true);\n      window.removeEventListener(\"scroll\", scroll, true);\n      window.removeEventListener(\"resize\", away);\n      window.removeEventListener(\"blur\", away);\n    };\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // It sits beside the area in the tree – its clicks and right-clicks stop here instead of reaching the area's parents\n  const stop = (e: SyntheticEvent) => e.stopPropagation();\n\n  return (\n    <div\n      ref={panel}\n      className={[\"ui-ctx\", \"ui-ctx__panel\", className].filter(Boolean).join(\" \")}\n      popover=\"manual\"\n      style={width != null ? { width, maxWidth: \"calc(100vw - 16px)\" } : undefined}\n      onClick={stop}\n      onDoubleClick={stop}\n      onPointerDown={stop}\n      onMouseDown={stop}\n      onContextMenu={(e) => {\n        e.preventDefault();\n        e.stopPropagation();\n      }}\n    >\n      {title && (\n        <span className=\"ui-ctx__title\" id={titleId}>\n          {title}\n        </span>\n      )}\n      <MenuLevel id={id} items={items} labelledBy={title ? titleId : undefined} label={title ? undefined : label} onPick={() => onClose(true)} onClose={onClose} />\n    </div>\n  );\n}\n\n/* ---------- One level of the menu: the root, or a submenu ---------- */\n\ntype LevelProps = {\n  id: string;\n  items: ContextMenuItem[];\n  empty?: ReactNode;\n  labelledBy?: string;\n  label?: string;\n  /** A pick that closes the whole menu */\n  onPick: () => void;\n  /** Escape at the root, Tab anywhere – the focus goes back to where it was */\n  onClose: (focusBack: boolean | \"now\") => void;\n  /** ← or Escape in a submenu: back to the item that opened it */\n  onBack?: () => void;\n};\n\nfunction MenuLevel({ id, items, empty, labelledBy, label, onPick, onClose, onBack }: LevelProps) {\n  const uid = useId();\n  const list = useRef<HTMLDivElement>(null);\n  const [glow, setGlow] = useState<{ top: number; height: number; danger: boolean } | null>(null);\n  const [sub, setSub] = useState<{ index: number; focus: boolean } | null>(null);\n  const timer = useRef(0);\n  /** What the timer will do: open the submenu of this index, or -1 to close the open one */\n  const pending = useRef<number | null>(null);\n  const typed = useRef({ text: \"\", at: 0 });\n\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n  const stopTimer = () => {\n    window.clearTimeout(timer.current);\n    pending.current = null;\n  };\n  const later = (fn: () => void, ms: number, what: number) => {\n    window.clearTimeout(timer.current);\n    pending.current = what;\n    timer.current = window.setTimeout(() => {\n      pending.current = null;\n      fn();\n    }, ms);\n  };\n\n  const rowAt = (i: number) => list.current?.querySelector<HTMLElement>(`:scope > [data-index=\"${i}\"]`) ?? null;\n  const openSub = (index: number, focus: boolean) => {\n    stopTimer();\n    setSub((s) => (s?.index === index ? (focus ? { index, focus } : s) : { index, focus }));\n    // Already open from a hover: the keyboard steps into it right away\n    if (focus && sub?.index === index) focusRow(itemsOf(document.getElementById(`${uid}-sub`)?.querySelector(\".ui-ctx__list\") ?? null)[0]);\n  };\n  const back = () => {\n    const at = sub?.index;\n    setSub(null);\n    if (at != null) focusRow(rowAt(at));\n  };\n\n  const activate = (i: number, keyboard: boolean) => {\n    const item = items[i];\n    if (!item || item.disabled) return;\n    if (item.submenu) {\n      if (!keyboard && sub?.index === i && !sub.focus) setSub(null);\n      else openSub(i, keyboard);\n      return;\n    }\n    item.onSelect?.();\n    if (!item.keepOpen) onPick();\n  };\n\n  // Typing letters jumps to the next item that starts with them\n  const typeahead = (key: string) => {\n    const now = Date.now();\n    typed.current.text = now - typed.current.at > 700 ? key : typed.current.text + key;\n    typed.current.at = now;\n    const q = typed.current.text.toLowerCase();\n    const rows = itemsOf(list.current);\n    const at = rows.indexOf(document.activeElement as HTMLElement);\n    const from = q.length === 1 ? at + 1 : Math.max(at, 0);\n    for (let k = 0; k < rows.length; k++) {\n      const row = rows[(from + k) % rows.length];\n      if ((items[Number(row.dataset.index)]?.label ?? \"\").toLowerCase().startsWith(q)) return focusRow(row);\n    }\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    const rows = itemsOf(list.current);\n    const at = rows.indexOf(document.activeElement as HTMLElement);\n    const index = at >= 0 ? Number(rows[at].dataset.index) : -1;\n    const item = index >= 0 ? items[index] : undefined;\n    const stop = () => {\n      e.preventDefault();\n      e.stopPropagation();\n    };\n    const go = (i: number) => {\n      stop();\n      // Moving on from an item closes a submenu the pointer had opened\n      if (sub) setSub(null);\n      if (rows.length) focusRow(rows[(i + rows.length) % rows.length]);\n    };\n    switch (e.key) {\n      case \"ArrowDown\":\n        return go(at + 1);\n      case \"ArrowUp\":\n        return go(at < 0 ? -1 : at - 1);\n      case \"Home\":\n      case \"PageUp\":\n        return go(0);\n      case \"End\":\n      case \"PageDown\":\n        return go(-1);\n      case \"ArrowRight\":\n        if (item?.submenu) {\n          stop();\n          openSub(index, true);\n        }\n        return;\n      case \"ArrowLeft\":\n        if (onBack) {\n          stop();\n          onBack();\n        }\n        return;\n      case \"Escape\":\n        stop();\n        return onBack ? onBack() : onClose(\"now\");\n      case \"Enter\":\n      case \" \":\n        stop();\n        if (e.key === \" \" && Date.now() - typed.current.at < 700) return typeahead(\" \");\n        return activate(index, true);\n      case \"Tab\":\n        // The focus goes back first, so Tab moves on from there\n        return onClose(\"now\");\n      default:\n        if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {\n          stop();\n          typeahead(e.key);\n        }\n    }\n  };\n\n  // The highlight follows the focused row – the pointer focuses the row it is on\n  const onFocus = (e: ReactFocusEvent<HTMLDivElement>) => {\n    const row = e.target as HTMLElement;\n    if (row === list.current || row.parentElement !== list.current) return setGlow(null);\n    setGlow({ top: row.offsetTop, height: row.offsetHeight, danger: row.hasAttribute(\"data-danger\") });\n  };\n  const onBlur = (e: ReactFocusEvent<HTMLDivElement>) => {\n    const to = e.relatedTarget as HTMLElement | null;\n    if (!to || to.parentElement !== list.current) setGlow(null);\n  };\n\n  const subItems = sub ? items[sub.index]?.submenu : undefined;\n\n  return (\n    <>\n      <div\n        ref={list}\n        id={id}\n        className=\"ui-ctx__list\"\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        aria-labelledby={labelledBy}\n        aria-label={label}\n        tabIndex={-1}\n        onKeyDown={onKeyDown}\n        onFocus={onFocus}\n        onBlur={onBlur}\n        onPointerLeave={() => {\n          stopTimer();\n          // The pointer left: the highlight goes, the keys start again from the menu\n          if (list.current?.contains(document.activeElement) && document.activeElement !== list.current) list.current?.focus({ preventScroll: true });\n        }}\n      >\n        {glow && <span className=\"ui-ctx__glow\" aria-hidden=\"true\" data-danger={glow.danger || undefined} style={{ transform: `translateY(${glow.top}px)`, height: glow.height }} />}\n        {items.length === 0 && <span className=\"ui-ctx__empty\">{empty}</span>}\n        {items.map((item, i) => {\n          const choice = item.checked !== undefined && !item.submenu;\n          const role = !choice ? \"menuitem\" : item.type === \"checkbox\" ? \"menuitemcheckbox\" : \"menuitemradio\";\n          const isOpen = sub?.index === i;\n          return (\n            <Fragment key={`${i}-${item.label}`}>\n              {item.divider && i > 0 && <span className=\"ui-ctx__sep\" role=\"separator\" />}\n              <div\n                className=\"ui-ctx__item\"\n                role={role}\n                tabIndex={-1}\n                data-index={i}\n                aria-checked={choice ? Boolean(item.checked) : undefined}\n                aria-disabled={item.disabled || undefined}\n                aria-haspopup={item.submenu ? \"menu\" : undefined}\n                aria-expanded={item.submenu ? isOpen : undefined}\n                aria-controls={item.submenu && isOpen ? `${uid}-sub-list` : undefined}\n                data-danger={item.danger || undefined}\n                data-rich={item.description ? \"\" : undefined}\n                data-open={isOpen || undefined}\n                onPointerDown={(e) => e.preventDefault()}\n                onPointerMove={(e) => {\n                  const row = e.currentTarget;\n                  if (item.disabled) {\n                    if (document.activeElement !== list.current) list.current?.focus({ preventScroll: true });\n                    return;\n                  }\n                  if (document.activeElement !== row) row.focus({ preventScroll: true });\n                  // Rest on an item with a submenu to open it; on another item the open one closes after a beat\n                  if (item.submenu) {\n                    if (isOpen) stopTimer();\n                    else if (pending.current !== i) later(() => openSub(i, false), 90, i);\n                  } else if (!sub) stopTimer();\n                  else if (pending.current !== -1) later(() => setSub(null), 220, -1);\n                }}\n                onClick={(e) => activate(i, e.detail === 0)}\n              >\n                {(item.logo || item.icon) && (\n                  <span className=\"ui-ctx__icon\" data-logo={item.logo ? \"\" : undefined} aria-hidden=\"true\">\n                    {item.logo ?? <Glyph icon={item.icon} />}\n                  </span>\n                )}\n                <span className=\"ui-ctx__label\">\n                  <span className=\"ui-ctx__text\" title={item.label}>\n                    {item.label}\n                  </span>\n                  {item.description && <span className=\"ui-ctx__desc\" title={item.description}>{item.description}</span>}\n                </span>\n                {item.shortcut && <kbd className=\"ui-ctx__kbd\">{item.shortcut}</kbd>}\n                {item.submenu && (\n                  <span className=\"ui-ctx__more\" aria-hidden=\"true\">\n                    <ChevronIcon />\n                  </span>\n                )}\n                {choice && (\n                  <span className=\"ui-ctx__check\" aria-hidden=\"true\">\n                    {item.checked && <CheckIcon />}\n                  </span>\n                )}\n              </div>\n            </Fragment>\n          );\n        })}\n      </div>\n      {sub && subItems && (\n        <SubMenu\n          id={`${uid}-sub`}\n          listId={`${uid}-sub-list`}\n          row={() => rowAt(sub.index)}\n          items={subItems}\n          focus={sub.focus}\n          label={items[sub.index]?.label}\n          onEnter={() => stopTimer()}\n          onBack={back}\n          onPick={onPick}\n          onClose={onClose}\n        />\n      )}\n    </>\n  );\n}\n\n/** A submenu: its own small panel in the top layer, beside the item that opened it (or on the other side) */\nfunction SubMenu({\n  id,\n  listId,\n  row,\n  items,\n  focus,\n  label,\n  onEnter,\n  onBack,\n  onPick,\n  onClose,\n}: {\n  id: string;\n  listId: string;\n  row: () => HTMLElement | null;\n  items: ContextMenuItem[];\n  focus: boolean;\n  label?: string;\n  onEnter: () => void;\n  onBack: () => void;\n  onPick: () => void;\n  onClose: (focusBack: boolean | \"now\") => void;\n}) {\n  const panel = useRef<HTMLDivElement>(null);\n\n  useLayoutEffect(() => {\n    const el = panel.current;\n    if (!el) return;\n    showLayer(el);\n    const update = () => {\n      const r = row()?.getBoundingClientRect();\n      // Its first item lines up with the item that opened it: the panel's 4px padding sits above it\n      if (r) place(el, new DOMRect(r.left - 4, r.top - 4, r.width + 8, r.height + 8), \"right\", \"start\", 4);\n    };\n    update();\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    return () => {\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n      hideLayer(el);\n    };\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    if (focus) focusRow(itemsOf(panel.current?.querySelector(\".ui-ctx__list\") ?? null)[0]);\n  }, [focus]);\n\n  return (\n    <div ref={panel} id={id} className=\"ui-ctx__panel\" data-sub=\"\" popover=\"manual\" onPointerEnter={onEnter}>\n      <MenuLevel id={listId} items={items} label={label} onPick={onPick} onClose={onClose} onBack={onBack} />\n    </div>\n  );\n}\n\n/* ---------- Helpers ---------- */\n\n/** The rows of one level that can be focused – not separators, not disabled ones, not a submenu's */\nconst itemsOf = (menu: Element | null) =>\n  menu ? [...menu.querySelectorAll<HTMLElement>(':scope > [role^=\"menuitem\"]:not([aria-disabled=\"true\"])')] : [];\n\nfunction focusRow(row: HTMLElement | null | undefined) {\n  if (!row) return;\n  row.focus({ preventScroll: true });\n  row.scrollIntoView?.({ block: \"nearest\" });\n}\n\n/** After a pick the menu is gone – the focus goes back to where it was, unless the pick moved it somewhere (a rename field) */\nconst refocus = (back: HTMLElement | null) =>\n  window.setTimeout(() => {\n    const at = document.activeElement;\n    if (back?.isConnected && (!at || at === document.body)) back.focus({ preventScroll: true });\n  });\n\n/** The top layer, so no overflow cuts it and no z-index fights it */\nfunction showLayer(el: HTMLElement) {\n  if (\"showPopover\" in el && !el.matches(\":popover-open\")) el.showPopover();\n}\nfunction hideLayer(el: HTMLElement) {\n  if (\"hidePopover\" in el && el.isConnected && el.matches(\":popover-open\")) el.hidePopover();\n}\n\n/**\n * Puts a fixed panel next to a rectangle: on the preferred side, or the other one when that has more room; the\n * edge it lines up with switches when it would stick out, and it is pushed inside the window as a last resort.\n * Too tall for the room it gets: it scrolls.\n */\nfunction place(el: HTMLElement, r: DOMRect, side: \"top\" | \"bottom\" | \"left\" | \"right\", align: \"start\" | \"end\", gap: number) {\n  const pad = 8;\n  const vw = document.documentElement.clientWidth || window.innerWidth;\n  const vh = window.innerHeight;\n  el.style.maxHeight = \"\";\n  const w = el.offsetWidth;\n  const h = el.offsetHeight;\n  const room = { bottom: vh - r.bottom - gap - pad, top: r.top - gap - pad, right: vw - r.right - gap - pad, left: r.left - gap - pad };\n  const flip = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" } as const;\n  let s: keyof typeof room = side;\n  let a = align;\n  let top: number;\n  let left: number;\n  const vertical = s === \"top\" || s === \"bottom\";\n  if (room[s] < (vertical ? h : w) && room[flip[s]] > room[s]) s = flip[s];\n  if (!vertical && room[s] < w) {\n    // No room beside it on a narrow screen: under the row, a little indented\n    s = \"bottom\";\n    top = r.bottom - 2;\n    left = Math.min(Math.max(pad, r.left + 16), vw - pad - w);\n    el.style.maxHeight = `${Math.max(120, vh - top - pad)}px`;\n  } else if (s === \"top\" || s === \"bottom\") {\n    const fit = Math.min(h, Math.max(120, room[s]));\n    top = s === \"bottom\" ? r.bottom + gap : r.top - gap - fit;\n    left = a === \"start\" ? r.left : r.right - w;\n    if (a === \"start\" && left + w > vw - pad && r.right - w >= pad) (a = \"end\"), (left = r.right - w);\n    else if (a === \"end\" && left < pad && r.left + w <= vw - pad) (a = \"start\"), (left = r.left);\n    left = Math.min(Math.max(pad, left), vw - pad - w);\n    if (h > fit) el.style.maxHeight = `${fit}px`;\n  } else {\n    left = s === \"right\" ? r.right + gap : r.left - gap - w;\n    top = a === \"start\" ? r.top : r.bottom - h;\n    if (top + h > vh - pad) top = Math.max(pad, vh - pad - h);\n    if (top < pad) top = pad;\n    if (h > vh - 2 * pad) el.style.maxHeight = `${vh - 2 * pad}px`;\n  }\n  el.style.top = `${Math.round(top)}px`;\n  el.style.left = `${Math.round(left)}px`;\n  el.dataset.side = s;\n  el.dataset.align = a;\n}\n\nconst isHugeIcon = (icon: unknown): icon is HugeIcon =>\n  Array.isArray(icon) && icon.length > 0 && icon.every((part) => Array.isArray(part) && typeof part[0] === \"string\");\n\n/** Draws a Hugeicons icon object as an inline SVG; anything else is shown as it is */\nfunction Glyph({ icon }: { icon: ReactNode | HugeIcon }) {\n  if (!isHugeIcon(icon)) return <>{icon}</>;\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" focusable=\"false\">\n      {icon.map(([tag, { key, ...attrs }], i) => createElement(tag, { ...attrs, key: String(key ?? i) }))}\n    </svg>\n  );\n}\n\n/* Icons: Hugeicons Free (MIT, © Hugeicons, hugeicons.com) */\nfunction ChevronIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"M9 6s6 4.42 6 6-6 6-6 6\" />\n    </svg>\n  );\n}\nfunction CheckIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <path className=\"ui-ctx__tick\" d=\"M5 14 8.5 17.5 19 6.5\" pathLength={1} />\n    </svg>\n  );\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-ctx (see Theming in the docs) */\n.ui-ctx {\n  --ui-ctx-surface: var(--aui-overlay, var(--aui-surface, #ffffff));\n  --ui-ctx-ink: var(--aui-ink, #171717);\n  --ui-ctx-text: var(--aui-ink, #262626);\n  --ui-ctx-icon: var(--aui-muted, #8f8f8f);\n  --ui-ctx-faint: var(--aui-placeholder, #a3a3a3);\n  --ui-ctx-line: var(--aui-border, #ececec);\n  --ui-ctx-glow: #f4f4f5;\n  --ui-ctx-danger: var(--aui-danger, #d92d20);\n  --ui-ctx-danger-glow: color-mix(in srgb, var(--aui-red, #f04438) 8%, transparent);\n  --ui-ctx-shadow: var(--aui-shadow-popover, 0 0 0 1px rgba(0, 0, 0, 0.06), 0 18px 40px -12px rgba(0, 0, 0, 0.18), 0 4px 12px -4px rgba(0, 0, 0, 0.06));\n  --ui-ctx-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-ctx-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  --ui-ctx-radius: calc(var(--aui-radius-lg, 10px) + 4px);\n  --ui-ctx-row-radius: calc(var(--aui-radius-sm, 5px) + 4px);\n}\n\n/* The menu: a white card in the top layer, a 1px edge and a deeper drop */\n.ui-ctx__panel {\n  position: fixed;\n  inset: auto;\n  z-index: 1000;\n  display: flex;\n  flex-direction: column;\n  box-sizing: border-box;\n  min-width: 180px;\n  /* Fits its content up to 320px – long items shorten with … (full text on hover) instead of stretching it; the width prop lifts the cap */\n  max-width: min(320px, calc(100vw - 16px));\n  max-height: calc(100vh - 16px);\n  margin: 0;\n  padding: 5px;\n  border: 0;\n  border-radius: var(--ui-ctx-radius);\n  background: var(--ui-ctx-surface);\n  box-shadow: var(--ui-ctx-shadow);\n  color: var(--ui-ctx-ink);\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13.5px;\n  line-height: 20px;\n  font-weight: 400;\n  text-align: start;\n  overflow: visible;\n  -webkit-font-smoothing: antialiased;\n  transform-origin: top left;\n  animation: ui-ctx-in 220ms var(--ui-ctx-ease) both;\n}\n.ui-ctx__panel::backdrop { display: none; }\n.ui-ctx__panel[data-align=\"end\"] { transform-origin: top right; }\n.ui-ctx__panel[data-side=\"top\"] { transform-origin: bottom left; animation-name: ui-ctx-in-up; }\n.ui-ctx__panel[data-side=\"top\"][data-align=\"end\"] { transform-origin: bottom right; }\n.ui-ctx__panel[data-side=\"right\"] { transform-origin: top left; animation-name: ui-ctx-in-side; animation-duration: 160ms; }\n.ui-ctx__panel[data-side=\"left\"] { transform-origin: top right; animation-name: ui-ctx-in-side; animation-duration: 160ms; }\n\n.ui-ctx__title { display: block; flex: none; padding: 7px 10px 5px; font-size: 12px; line-height: 16px; font-weight: 500; color: var(--ui-ctx-icon); }\n\n/* The rows; a long menu scrolls inside the card */\n.ui-ctx__list {\n  position: relative;\n  display: grid;\n  grid-template-columns: minmax(0, 1fr);\n  align-content: start;\n  gap: 1px;\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  scrollbar-width: thin;\n}\n.ui-ctx__list:focus { outline: none; }\n\n/* One soft highlight that glides between the rows */\n.ui-ctx__glow {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  border-radius: var(--ui-ctx-row-radius);\n  background: var(--ui-ctx-glow);\n  pointer-events: none;\n  transition: transform 180ms var(--ui-ctx-ease), height 180ms var(--ui-ctx-ease), background-color 120ms ease;\n}\n.ui-ctx__glow[data-danger] { background: var(--ui-ctx-danger-glow); }\n\n.ui-ctx__item {\n  position: relative;\n  display: flex;\n  align-items: center;\n  gap: 9px;\n  box-sizing: border-box;\n  min-width: 0;\n  min-height: 34px;\n  padding: 0 10px;\n  border-radius: var(--ui-ctx-row-radius);\n  color: var(--ui-ctx-text);\n  white-space: nowrap;\n  cursor: pointer;\n  user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  transition: color 120ms ease;\n}\n.ui-ctx__item[data-rich] { min-height: 46px; padding: 7px 10px; }\n.ui-ctx__item:focus { outline: none; color: var(--ui-ctx-ink); }\n.ui-ctx__item:focus-visible { outline: 2px solid var(--ui-ctx-focus); outline-offset: -2px; }\n/* The item whose submenu is open keeps a highlight while the focus is in the submenu */\n.ui-ctx__item[data-open] { color: var(--ui-ctx-ink); }\n.ui-ctx__item[data-open]:not(:focus) { background: var(--ui-ctx-glow); }\n.ui-ctx__item[aria-checked=\"true\"] { color: var(--ui-ctx-ink); font-weight: 500; }\n.ui-ctx__item[data-danger], .ui-ctx__item[data-danger]:focus { color: var(--ui-ctx-danger); }\n.ui-ctx__item[aria-disabled=\"true\"] { color: var(--ui-ctx-faint); cursor: default; }\n\n.ui-ctx__icon { display: grid; flex: none; place-items: center; width: 16px; height: 16px; color: var(--ui-ctx-icon); transition: color 120ms ease; }\n.ui-ctx__icon > svg { display: block; width: 16px; height: 16px; }\n.ui-ctx__icon[data-logo] > svg, .ui-ctx__icon[data-logo] > img { width: 14px; height: 14px; }\n.ui-ctx__item:focus .ui-ctx__icon:not([data-logo]), .ui-ctx__item[data-open] .ui-ctx__icon:not([data-logo]) { color: var(--ui-ctx-ink); }\n.ui-ctx__item[data-danger] .ui-ctx__icon { color: var(--ui-ctx-danger); }\n.ui-ctx__item[aria-disabled=\"true\"] .ui-ctx__icon { opacity: 0.5; color: var(--ui-ctx-icon); }\n\n/* Long names stay on one line – the full name on hover */\n.ui-ctx__label { display: grid; flex: 1; gap: 1px; min-width: 0; }\n.ui-ctx__text, .ui-ctx__desc { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n.ui-ctx__desc { font-size: 12.5px; line-height: 17px; font-weight: 400; color: var(--ui-ctx-icon); }\n\n.ui-ctx__kbd { flex: none; padding-left: 16px; font: inherit; font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: var(--ui-ctx-faint); }\n.ui-ctx__more, .ui-ctx__check { display: grid; flex: none; place-items: center; width: 14px; height: 14px; }\n.ui-ctx__more { color: var(--ui-ctx-faint); }\n.ui-ctx__check { color: var(--ui-ctx-ink); }\n.ui-ctx__more > svg, .ui-ctx__check > svg { display: block; width: 14px; height: 14px; }\n.ui-ctx__tick { stroke-dasharray: 1; animation: ui-ctx-draw 280ms 40ms var(--ui-ctx-ease) both; }\n\n.ui-ctx__sep { display: block; height: 1px; margin: 5px 4px; background: var(--ui-ctx-line); }\n.ui-ctx__empty { display: block; padding: 6px 8px; color: var(--ui-ctx-faint); }\n\n@keyframes ui-ctx-in { from { opacity: 0; transform: scale(0.96) translateY(-4px); } }\n@keyframes ui-ctx-in-up { from { opacity: 0; transform: scale(0.96) translateY(4px); } }\n@keyframes ui-ctx-in-side { from { opacity: 0; transform: scale(0.98); } }\n@keyframes ui-ctx-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }\n\n/* Touch screens: rows a finger can hit */\n@media (pointer: coarse) {\n  .ui-ctx__item { min-height: 40px; }\n  .ui-ctx__item[data-rich] { min-height: 48px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-ctx__panel, .ui-ctx__tick { animation-duration: 1ms; }\n  .ui-ctx__glow, .ui-ctx__item, .ui-ctx__icon { transition-duration: 1ms; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-ctx {\n  --ui-ctx-surface: var(--aui-overlay, var(--aui-surface, #19191b));\n  --ui-ctx-ink: var(--aui-ink, #ededed);\n  --ui-ctx-text: var(--aui-ink, #e4e4e7);\n  --ui-ctx-icon: var(--aui-muted, #8b8b92);\n  --ui-ctx-faint: var(--aui-placeholder, #5c5c63);\n  --ui-ctx-line: var(--aui-border, #2a2a2e);\n  --ui-ctx-glow: #26262a;\n  --ui-ctx-danger: var(--aui-danger, #ff8a7a);\n  --ui-ctx-danger-glow: color-mix(in srgb, var(--aui-red, #ff6b5f) 14%, transparent);\n  --ui-ctx-shadow: var(--aui-shadow-popover, 0 0 0 1px rgba(255, 255, 255, 0.06), 0 18px 40px -12px rgba(0, 0, 0, 0.6));\n  --ui-ctx-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  color-scheme: dark;\n}\n`;\n"}]}