{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "context-menu",
  "title": "Context Menu",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/context-menu/context-menu.component.ts",
      "content": "import {\n  Component,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  Output,\n  TemplateRef,\n  ViewChild,\n  ViewContainerRef,\n  booleanAttribute,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport {\n  BodyPortal,\n  afterExitAnimation,\n  autoPlace,\n  computePlacement,\n  lockScroll,\n  pushDismissableLayer,\n  uniqueId,\n  type PlaceOptions,\n} from '@/ui/popper/popper'\nimport { contextMenuItemVariants, type ContextMenuItemVariants } from './context-menu-item.variants'\n\nexport type ContextMenuItemVariant = NonNullable<ContextMenuItemVariants['variant']>\nexport type ContextMenuCheckedState = boolean | 'indeterminate'\nexport type ContextMenuDir = 'ltr' | 'rtl'\n\n/**\n * autoPlace() positions on the next animation frame; place once synchronously too so the\n * panel never paints (or catches a fast click's pointerup) at the viewport origin.\n */\nfunction placeAndTrack(anchor: Element, panel: HTMLElement, options: () => PlaceOptions, prefix: string): () => void {\n  const stop = autoPlace(anchor, panel, options, prefix)\n  const p = computePlacement(\n    anchor.getBoundingClientRect(),\n    { width: panel.offsetWidth, height: panel.offsetHeight },\n    options(),\n  )\n  panel.style.left = `${Math.round(p.x)}px`\n  panel.style.top = `${Math.round(p.y)}px`\n  panel.setAttribute('data-side', p.side)\n  panel.setAttribute('data-align', p.align)\n  return stop\n}\n\n/*\n * Angular port of UIPKGE Context Menu with Radix ContextMenu behaviour: a right-click\n * (or a 700ms touch / pen long-press) on the trigger opens a body-portalled menu at the\n * pointer, flipped / shifted to stay on screen. Arrow keys / Home / End / typeahead\n * rove between items, Enter / Space select, Escape and outside pointer-downs dismiss,\n * selecting an item closes the menu unless its handler prevents it, and submenus open\n * on hover (with Radix's pointer grace area) or ArrowRight. Class strings are identical\n * to the React source.\n */\n\n// ---------------------------------------------------------------------------------------\n// Shared menu machinery (Radix Menu): input modality, roving focus, typeahead, grace area.\n// ---------------------------------------------------------------------------------------\n\n/** Radix `isUsingKeyboardRef`: keyboard opens focus the first item, pointer opens focus the menu. */\nlet usingKeyboard = false\nlet trackingModality = false\nfunction trackModality(): void {\n  if (trackingModality || typeof document === 'undefined') return\n  trackingModality = true\n  document.addEventListener('keydown', () => (usingKeyboard = true), true)\n  const pointer = () => (usingKeyboard = false)\n  document.addEventListener('pointerdown', pointer, true)\n  document.addEventListener('pointermove', pointer, true)\n}\n\ninterface Point {\n  x: number\n  y: number\n}\ninterface GraceIntent {\n  area: Point[]\n  side: 'left' | 'right'\n}\n\nfunction isPointInPolygon(point: Point, polygon: Point[]): boolean {\n  let inside = false\n  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n    const a = polygon[i]!\n    const b = polygon[j]!\n    if (a.y > point.y !== b.y > point.y && point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x)\n      inside = !inside\n  }\n  return inside\n}\n\nconst isMouse = (event: PointerEvent) => event.pointerType === 'mouse'\nconst FIRST_KEYS = ['ArrowDown', 'PageUp', 'Home']\nconst LAST_KEYS = ['ArrowUp', 'PageDown', 'End']\n\n/** Keyboard + pointer state of one menu panel (root content or a submenu). */\nclass MenuPanel {\n  el?: HTMLElement\n  openSub?: UiContextMenuSubComponent\n  /** Typeahead buffer; while non-empty a Space extends the search instead of selecting. */\n  search = ''\n  private searchTimer: ReturnType<typeof setTimeout> | undefined\n  private lastX = 0\n  private pointerDir: 'left' | 'right' = 'right'\n  private grace: GraceIntent | null = null\n  private graceTimer: ReturnType<typeof setTimeout> | undefined\n\n  constructor(private readonly loop: () => boolean) {}\n\n  items(): HTMLElement[] {\n    if (!this.el) return []\n    return [...this.el.querySelectorAll<HTMLElement>('[role^=\"menuitem\"]')].filter(\n      (el) => !el.hasAttribute('data-disabled'),\n    )\n  }\n\n  focusFirst(): void {\n    this.items()[0]?.focus({ preventScroll: true })\n  }\n\n  focusPanel(): void {\n    this.el?.focus({ preventScroll: true })\n  }\n\n  keydown(event: KeyboardEvent): void {\n    const panel = this.el\n    const target = event.target as HTMLElement\n    if (!panel) return\n    if (target.closest('[role=\"menu\"]') === panel) {\n      if (event.key === 'Tab') event.preventDefault()\n      if (!event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) this.typeahead(event.key)\n    }\n    const items = this.items()\n    if (target === panel) {\n      // Menu itself focused (pointer open): the first arrow press enters the list.\n      if (FIRST_KEYS.includes(event.key)) items[0]?.focus({ preventScroll: true })\n      else if (LAST_KEYS.includes(event.key)) items[items.length - 1]?.focus({ preventScroll: true })\n      else return\n      event.preventDefault()\n      return\n    }\n    if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return\n    const current = items.indexOf(target)\n    if (current < 0) return\n    let next: number\n    if (event.key === 'ArrowDown') next = current + 1\n    else if (event.key === 'ArrowUp') next = current - 1\n    else if (event.key === 'Home' || event.key === 'PageUp') next = 0\n    else if (event.key === 'End' || event.key === 'PageDown') next = items.length - 1\n    else return\n    event.preventDefault()\n    if (this.loop()) next = (next + items.length) % items.length\n    else next = Math.min(Math.max(next, 0), items.length - 1)\n    items[next]?.focus({ preventScroll: true })\n  }\n\n  /** Radix typeahead: accumulate for 1s, a repeated single character cycles matches. */\n  private typeahead(key: string): void {\n    const search = this.search + key\n    const items = this.items()\n    const text = (el: HTMLElement) => (el.getAttribute('data-text-value') ?? el.textContent ?? '').trim()\n    const current = items.find((el) => el === document.activeElement)\n    const repeated = search.length > 1 && [...search].every((c) => c === search[0])\n    const needle = (repeated ? search[0]! : search).toLowerCase()\n    const start = current ? items.indexOf(current) : 0\n    let candidates = [...items.slice(start), ...items.slice(0, start)]\n    if (needle.length === 1) candidates = candidates.filter((el) => el !== current)\n    const match = candidates.find((el) => text(el).toLowerCase().startsWith(needle))\n    this.search = search\n    clearTimeout(this.searchTimer)\n    this.searchTimer = setTimeout(() => (this.search = ''), 1000)\n    if (match && match !== current) setTimeout(() => match.focus({ preventScroll: true }))\n  }\n\n  pointerMove(event: PointerEvent): void {\n    if (!isMouse(event) || !this.el?.contains(event.target as Node) || event.clientX === this.lastX) return\n    this.pointerDir = event.clientX > this.lastX ? 'right' : 'left'\n    this.lastX = event.clientX\n  }\n\n  /** True while the pointer travels from a sub-trigger toward its open submenu. */\n  isMovingToSub(event: PointerEvent): boolean {\n    const g = this.grace\n    return !!g && this.pointerDir === g.side && isPointInPolygon({ x: event.clientX, y: event.clientY }, g.area)\n  }\n\n  setGrace(intent: GraceIntent | null): void {\n    this.grace = intent\n    clearTimeout(this.graceTimer)\n    if (intent) this.graceTimer = setTimeout(() => (this.grace = null), 300)\n  }\n\n  /** Radix `onItemLeave`: the pointer left an item, so the menu itself takes focus. */\n  itemLeave(event: PointerEvent): void {\n    if (this.isMovingToSub(event)) return\n    this.focusPanel()\n  }\n\n  /** Focus landing anywhere in this panel except the open sub's trigger closes that sub. */\n  focusMoved(target: EventTarget | null): void {\n    const sub = this.openSub\n    if (sub && target !== sub.trigger) sub.setOpen(false)\n  }\n\n  destroy(): void {\n    clearTimeout(this.searchTimer)\n    clearTimeout(this.graceTimer)\n    this.search = ''\n    this.grace = null\n  }\n}\n\n/** Zero-size anchor at the pointer (Radix's virtual element) that the popper can observe. */\nfunction pointAnchor(point: () => Point): Element {\n  const el = document.createElement('span')\n  el.getBoundingClientRect = () => {\n    const { x, y } = point()\n    return { x, y, top: y, left: x, right: x, bottom: y, width: 0, height: 0, toJSON: () => ({}) } as DOMRect\n  }\n  return el\n}\n\n// ---------------------------------------------------------------------------------------\n// Root, trigger, content\n// ---------------------------------------------------------------------------------------\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu, [ui-context-menu]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"context-menu\"',\n    '[attr.data-uipkge]': '\"\"',\n    // Radix Root renders no element: keep the wrapper out of layout.\n    class: 'contents',\n  },\n  template: `<ng-content />`,\n})\nexport class UiContextMenuComponent implements OnChanges {\n  /** Radix allows controlling `open`; the position is the last right-click / long-press point. */\n  @Input() open?: boolean\n  @Input() defaultOpen = false\n  @Input({ transform: booleanAttribute }) modal = true\n  @Input() dir: ContextMenuDir = 'ltr'\n  @Output() openChange = new EventEmitter<boolean>()\n\n  readonly contentId = uniqueId('context-menu-content')\n  private readonly _open = signal<boolean | null>(null)\n  point: Point = { x: 0, y: 0 }\n  content?: UiContextMenuContentComponent\n  openedWith: 'keyboard' | 'pointer' = 'pointer'\n\n  constructor() {\n    trackModality()\n  }\n\n  get isOpen(): boolean {\n    if (this.open !== undefined) return this.open\n    return this._open() ?? this.defaultOpen\n  }\n\n  ngOnChanges(): void {\n    this.content?.sync()\n  }\n\n  setOpen(value: boolean): void {\n    if (value === this.isOpen) return\n    if (value) this.openedWith = usingKeyboard ? 'keyboard' : 'pointer'\n    this._open.set(value)\n    this.openChange.emit(value)\n    this.content?.sync()\n  }\n\n  /** Open at a viewport point (what the trigger does on contextmenu / long-press). */\n  openAt(x: number, y: number): void {\n    this.point = { x, y }\n    this.setOpen(true)\n  }\n}\n\n/** The area that responds to right-click. A directive, so it sits on any element (Radix `asChild`). */\n@Directive({\n  selector: 'ui-context-menu-trigger, [ui-context-menu-trigger]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': 'dataSlot',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-state]': 'menu.isOpen ? \"open\" : \"closed\"',\n    '[attr.data-disabled]': 'disabled ? \"\" : null',\n    '[style.-webkit-touch-callout]': '\"none\"',\n    '(contextmenu)': 'onContextMenu($event)',\n    '(pointerdown)': 'onPointerDown($event)',\n    '(pointermove)': 'onPointerEnd($event)',\n    '(pointercancel)': 'onPointerEnd($event)',\n    '(pointerup)': 'onPointerEnd($event)',\n  },\n})\nexport class UiContextMenuTriggerComponent implements OnChanges, OnDestroy {\n  // Radix Slot: the trigger's data-slot overrides a wrapped component's own (ui-button's\n  // \"button\"), but a data-slot written on the element itself wins.\n  readonly dataSlot: string = inject(ElementRef).nativeElement.getAttribute('data-slot') ?? 'context-menu-trigger'\n  readonly menu = inject(UiContextMenuComponent)\n  @Input({ transform: booleanAttribute }) disabled = false\n  private longPress: ReturnType<typeof setTimeout> | undefined\n\n  ngOnChanges(): void {\n    if (this.disabled) clearTimeout(this.longPress)\n  }\n\n  onContextMenu(event: MouseEvent): void {\n    // Disabled: fall through to the browser's own menu, like Radix.\n    if (this.disabled) return\n    clearTimeout(this.longPress)\n    this.menu.openAt(event.clientX, event.clientY)\n    event.preventDefault()\n  }\n\n  /** Touch / pen: hold for 700ms to open (no contextmenu event on most touch browsers). */\n  onPointerDown(event: PointerEvent): void {\n    if (this.disabled || isMouse(event)) return\n    clearTimeout(this.longPress)\n    if (this.menu.isOpen) this.menu.setOpen(false)\n    const { clientX, clientY } = event\n    this.longPress = setTimeout(() => this.menu.openAt(clientX, clientY), 700)\n  }\n\n  onPointerEnd(event: PointerEvent): void {\n    if (!this.disabled && !isMouse(event)) clearTimeout(this.longPress)\n  }\n\n  ngOnDestroy(): void {\n    clearTimeout(this.longPress)\n  }\n}\n\nexport const CONTEXT_MENU_CONTENT_CLASS =\n  'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md'\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-content, [ui-context-menu-content]',\n  standalone: true,\n  // The host stays where it is declared (no box); the menu itself renders in a body portal.\n  host: { class: 'hidden' },\n  template: `\n    <ng-template #panelTpl>\n      <div\n        [id]=\"menu.contentId\"\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        data-orientation=\"vertical\"\n        tabindex=\"-1\"\n        data-slot=\"context-menu-content\"\n        data-uipkge=\"\"\n        [attr.dir]=\"menu.dir\"\n        [attr.data-state]=\"state()\"\n        [class]=\"panelClass\"\n        (keydown)=\"panel.keydown($event)\"\n        (pointermove)=\"panel.pointerMove($event)\"\n        (focus)=\"panel.focusMoved($event.target)\"\n      >\n        <ng-content />\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiContextMenuContentComponent implements OnDestroy {\n  readonly menu = inject(UiContextMenuComponent)\n  private readonly portal = new BodyPortal(inject(ViewContainerRef))\n\n  @Input('class') className?: string\n  @Input() alignOffset = 0\n  @Input({ transform: booleanAttribute }) avoidCollisions = true\n  @Input() collisionPadding = 0\n  @Input({ transform: booleanAttribute }) loop = false\n  /** Radix `onCloseAutoFocus`: `preventDefault()` keeps focus where it is after closing. */\n  @Output() closeAutoFocus = new EventEmitter<Event>()\n\n  @ViewChild('panelTpl', { static: true }) panelTpl!: TemplateRef<unknown>\n  readonly panel = new MenuPanel(() => this.loop)\n  readonly state = signal<'open' | 'closed'>('closed')\n  private cleanups: (() => void)[] = []\n  private returnFocusTo: HTMLElement | null = null\n\n  constructor() {\n    this.menu.content = this\n  }\n\n  get panelEl(): HTMLElement | undefined {\n    return this.panel.el\n  }\n\n  get panelClass(): string {\n    return cn(CONTEXT_MENU_CONTENT_CLASS, this.className)\n  }\n\n  /** Mirrors root open state into the portal (called by the root on every change). */\n  sync(): void {\n    if (this.menu.isOpen && (!this.portal.attached || this.state() === 'closed')) {\n      // Reopened mid exit-animation (right-click elsewhere): drop the closing menu, start fresh.\n      if (this.portal.attached) this.portal.detach()\n      this.show()\n    } else if (!this.menu.isOpen && this.portal.attached && this.state() === 'open') void this.hide()\n  }\n\n  private show(): void {\n    this.returnFocusTo = document.activeElement as HTMLElement | null\n    this.state.set('open')\n    const panel = this.portal.attach(this.panelTpl).firstElementChild as HTMLElement\n    this.panel.el = panel\n    const anchor = pointAnchor(() => this.menu.point)\n    this.cleanups.push(\n      placeAndTrack(\n        anchor,\n        panel,\n        () => ({\n          side: this.menu.dir === 'rtl' ? 'left' : 'right',\n          align: 'start',\n          sideOffset: 2,\n          alignOffset: this.alignOffset,\n          collisionPadding: this.collisionPadding,\n          avoidCollisions: this.avoidCollisions,\n        }),\n        'context-menu',\n      ),\n      pushDismissableLayer({\n        contains: (t) => panel.contains(t) || !!this.panel.openSub?.containsTarget(t),\n        onEscape: () => this.menu.setOpen(false),\n        onPointerDownOutside: () => this.menu.setOpen(false),\n      }),\n      () => this.panel.destroy(),\n    )\n    if (this.menu.modal) this.cleanups.push(lockScroll())\n    const keyboard = this.menu.openedWith === 'keyboard'\n    queueMicrotask(() => {\n      this.panel.focusPanel()\n      if (keyboard) this.panel.focusFirst()\n    })\n  }\n\n  private async hide(): Promise<void> {\n    this.panel.openSub?.setOpen(false)\n    this.state.set('closed')\n    const panel = this.panel.el\n    this.cleanups.splice(0).forEach((fn) => fn())\n    await afterExitAnimation(panel)\n    if (this.menu.isOpen) return\n    const active = document.activeElement\n    const focusWasInMenu = !active || active === document.body || !!panel?.contains(active)\n    this.portal.detach()\n    this.panel.el = undefined\n    const event = new Event('closeAutoFocus', { cancelable: true })\n    this.closeAutoFocus.emit(event)\n    // Radix FocusScope: hand focus back to whatever had it before the menu opened.\n    if (focusWasInMenu && !event.defaultPrevented) this.returnFocusTo?.focus?.({ preventScroll: true })\n    this.returnFocusTo = null\n  }\n\n  ngOnDestroy(): void {\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.portal.detach()\n  }\n}\n\n/** Radix `asChild`-free Portal part: content already renders in a body portal, so this only groups. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-portal, [ui-context-menu-portal]',\n  standalone: true,\n  host: { class: 'contents' },\n  template: `<ng-content />`,\n})\nexport class UiContextMenuPortalComponent {}\n\n// ---------------------------------------------------------------------------------------\n// Items\n// ---------------------------------------------------------------------------------------\n\ntype ParentContent = UiContextMenuContentComponent | UiContextMenuSubContentComponent\n\n/** Shared item behaviour (select, keyboard, pointer highlight). Host metadata lives on each concrete item. */\n@Directive()\nabstract class ContextMenuItemBase {\n  protected readonly menu = inject(UiContextMenuComponent)\n  protected readonly el = inject<ElementRef<HTMLElement>>(ElementRef)\n  protected readonly parent: ParentContent | null =\n    inject(UiContextMenuSubContentComponent, { optional: true }) ??\n    inject(UiContextMenuContentComponent, { optional: true })\n  @Input('class') className?: string\n  @Input({ transform: booleanAttribute }) disabled = false\n  /** Text used by typeahead when the item's text content is not what users type. */\n  @Input() textValue?: string\n  /** Radix `onSelect`: call `event.preventDefault()` to keep the menu open. */\n  @Output() select = new EventEmitter<Event>()\n  highlighted = false\n  private pointerWentDown = false\n\n  onClick(): void {\n    if (this.disabled) return\n    const event = new Event('select', { cancelable: true })\n    this.select.emit(event)\n    this.afterSelect()\n    if (!event.defaultPrevented) this.menu.setOpen(false)\n  }\n\n  /** Hook for checkbox / radio items to update their state before the menu closes. */\n  protected afterSelect(): void {}\n\n  onKeydown(event: KeyboardEvent): void {\n    if (this.disabled) return\n    if (event.key === ' ' && this.parent?.panel.search) return\n    if (event.key === 'Enter' || event.key === ' ') {\n      this.el.nativeElement.click()\n      event.preventDefault()\n    }\n  }\n\n  onPointerDown(): void {\n    this.pointerWentDown = true\n  }\n\n  /** Press on the trigger, release on an item (right-drag-release) selects it, like Radix. */\n  onPointerUp(): void {\n    if (!this.pointerWentDown) this.el.nativeElement.click()\n  }\n\n  onPointerMove(event: PointerEvent): void {\n    if (!isMouse(event) || !this.parent) return\n    if (this.disabled) return this.parent.panel.itemLeave(event)\n    if (this.parent.panel.isMovingToSub(event)) return\n    if (document.activeElement !== this.el.nativeElement) this.el.nativeElement.focus({ preventScroll: true })\n  }\n\n  onPointerLeave(event: PointerEvent): void {\n    if (isMouse(event)) this.parent?.panel.itemLeave(event)\n  }\n\n  onFocus(): void {\n    this.highlighted = true\n    this.parent?.panel.focusMoved(this.el.nativeElement)\n  }\n\n  onBlur(): void {\n    this.highlighted = false\n  }\n}\n\nconst ITEM_HOST = {\n  '[attr.data-uipkge]': '\"\"',\n  '[attr.tabindex]': '\"-1\"',\n  '[attr.data-orientation]': '\"vertical\"',\n  '[attr.data-highlighted]': 'highlighted ? \"\" : null',\n  '[attr.data-disabled]': 'disabled ? \"\" : null',\n  '[attr.aria-disabled]': 'disabled || null',\n  '[attr.data-text-value]': 'textValue ?? null',\n  '[class]': 'hostClass',\n  '(click)': 'onClick()',\n  '(keydown)': 'onKeydown($event)',\n  '(pointerdown)': 'onPointerDown()',\n  '(pointerup)': 'onPointerUp()',\n  '(pointermove)': 'onPointerMove($event)',\n  '(pointerleave)': 'onPointerLeave($event)',\n  '(focus)': 'onFocus()',\n  '(blur)': 'onBlur()',\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-item, [ui-context-menu-item]',\n  standalone: true,\n  host: {\n    ...ITEM_HOST,\n    '[attr.data-slot]': '\"context-menu-item\"',\n    '[attr.role]': '\"menuitem\"',\n    '[attr.data-variant]': 'variant',\n    '[attr.data-inset]': 'inset ? \"\" : null',\n  },\n  template: `<ng-content />`,\n})\nexport class UiContextMenuItemComponent extends ContextMenuItemBase {\n  @Input({ transform: booleanAttribute }) inset = false\n  @Input() variant: ContextMenuItemVariant = 'default'\n\n  get hostClass(): string {\n    return cn(contextMenuItemVariants({ variant: this.variant, inset: this.inset }), this.className)\n  }\n}\n\nconst CHECKABLE_ITEM_CLASS =\n  \"focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-checkbox-item, [ui-context-menu-checkbox-item]',\n  standalone: true,\n  host: {\n    ...ITEM_HOST,\n    '[attr.data-slot]': '\"context-menu-checkbox-item\"',\n    '[attr.role]': '\"menuitemcheckbox\"',\n    '[attr.aria-checked]': 'checked === \"indeterminate\" ? \"mixed\" : checked',\n    '[attr.data-state]': 'checkedState',\n  },\n  template: `\n    <span class=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n      @if (checked) {\n        <span [attr.data-state]=\"checkedState\"\n          ><svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"24\"\n            height=\"24\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"lucide lucide-check size-4\"\n            aria-hidden=\"true\"\n          >\n            <path d=\"M20 6 9 17l-5-5\" /></svg\n        ></span>\n      }\n    </span>\n    <ng-content />\n  `,\n})\nexport class UiContextMenuCheckboxItemComponent extends ContextMenuItemBase {\n  @Input() checked: ContextMenuCheckedState = false\n  @Output() checkedChange = new EventEmitter<boolean>()\n\n  get checkedState(): 'checked' | 'unchecked' | 'indeterminate' {\n    return this.checked === 'indeterminate' ? 'indeterminate' : this.checked ? 'checked' : 'unchecked'\n  }\n\n  get hostClass(): string {\n    return cn(CHECKABLE_ITEM_CLASS, this.className)\n  }\n\n  protected override afterSelect(): void {\n    this.checked = this.checked === 'indeterminate' ? true : !this.checked\n    this.checkedChange.emit(this.checked)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-radio-group, [ui-context-menu-radio-group]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"context-menu-radio-group\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.role]': '\"group\"',\n    class: 'block',\n  },\n  template: `<ng-content />`,\n})\nexport class UiContextMenuRadioGroupComponent {\n  @Input() value?: string\n  @Output() valueChange = new EventEmitter<string>()\n\n  setValue(value: string): void {\n    this.value = value\n    this.valueChange.emit(value)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-radio-item, [ui-context-menu-radio-item]',\n  standalone: true,\n  host: {\n    ...ITEM_HOST,\n    '[attr.data-slot]': '\"context-menu-radio-item\"',\n    '[attr.role]': '\"menuitemradio\"',\n    '[attr.aria-checked]': 'isChecked',\n    '[attr.data-state]': 'isChecked ? \"checked\" : \"unchecked\"',\n  },\n  template: `\n    <span class=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n      @if (isChecked) {\n        <span data-state=\"checked\"\n          ><svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"24\"\n            height=\"24\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"lucide lucide-circle size-2 fill-current\"\n            aria-hidden=\"true\"\n          >\n            <circle cx=\"12\" cy=\"12\" r=\"10\" /></svg\n        ></span>\n      }\n    </span>\n    <ng-content />\n  `,\n})\nexport class UiContextMenuRadioItemComponent extends ContextMenuItemBase {\n  private readonly group = inject(UiContextMenuRadioGroupComponent, { optional: true })\n  @Input() value = ''\n\n  get isChecked(): boolean {\n    return !!this.group && this.group.value === this.value\n  }\n\n  get hostClass(): string {\n    return cn(CHECKABLE_ITEM_CLASS, this.className)\n  }\n\n  protected override afterSelect(): void {\n    this.group?.setValue(this.value)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-label, [ui-context-menu-label]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"context-menu-label\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-inset]': 'inset ? \"\" : null',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiContextMenuLabelComponent {\n  @Input('class') className?: string\n  @Input({ transform: booleanAttribute }) inset = false\n\n  get hostClass(): string {\n    return cn('block text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', this.className)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-separator, [ui-context-menu-separator]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"context-menu-separator\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.role]': '\"separator\"',\n    '[attr.aria-orientation]': '\"horizontal\"',\n    '[class]': 'hostClass',\n  },\n  template: ``,\n})\nexport class UiContextMenuSeparatorComponent {\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn('block bg-border -mx-1 my-1 h-px', this.className)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-shortcut, [ui-context-menu-shortcut]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"context-menu-shortcut\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiContextMenuShortcutComponent {\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn('text-muted-foreground ml-auto text-xs tracking-widest', this.className)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-group, [ui-context-menu-group]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"context-menu-group\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.role]': '\"group\"',\n    class: 'block',\n  },\n  template: `<ng-content />`,\n})\nexport class UiContextMenuGroupComponent {}\n\n// ---------------------------------------------------------------------------------------\n// Submenus\n// ---------------------------------------------------------------------------------------\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-sub, [ui-context-menu-sub]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"context-menu-sub\"',\n    '[attr.data-uipkge]': '\"\"',\n    class: 'contents',\n  },\n  template: `<ng-content />`,\n})\nexport class UiContextMenuSubComponent implements OnChanges {\n  private readonly parent: ParentContent | null =\n    inject(UiContextMenuSubContentComponent, { optional: true }) ??\n    inject(UiContextMenuContentComponent, { optional: true })\n  @Input() open?: boolean\n  @Input() defaultOpen = false\n  @Output() openChange = new EventEmitter<boolean>()\n\n  readonly contentId = uniqueId('context-menu-sub-content')\n  private readonly _open = signal<boolean | null>(null)\n  trigger?: HTMLElement\n  content?: UiContextMenuSubContentComponent\n\n  get isOpen(): boolean {\n    if (this.open !== undefined) return this.open\n    return this._open() ?? this.defaultOpen\n  }\n\n  ngOnChanges(): void {\n    this.content?.sync()\n  }\n\n  setOpen(value: boolean): void {\n    if (value === this.isOpen) return\n    this._open.set(value)\n    this.openChange.emit(value)\n    if (this.parent) {\n      if (value) this.parent.panel.openSub = this\n      else if (this.parent.panel.openSub === this) this.parent.panel.openSub = undefined\n    }\n    this.content?.sync()\n  }\n\n  containsTarget(target: Node): boolean {\n    return !!this.content?.panelEl?.contains(target) || !!this.content?.panel.openSub?.containsTarget(target)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-sub-trigger, [ui-context-menu-sub-trigger]',\n  standalone: true,\n  host: {\n    ...ITEM_HOST,\n    '[attr.data-slot]': '\"context-menu-sub-trigger\"',\n    '[attr.role]': '\"menuitem\"',\n    '[attr.aria-haspopup]': '\"menu\"',\n    '[attr.aria-expanded]': 'sub.isOpen',\n    '[attr.aria-controls]': 'sub.isOpen ? sub.contentId : null',\n    '[attr.data-state]': 'sub.isOpen ? \"open\" : \"closed\"',\n    '[attr.data-inset]': 'inset ? \"\" : null',\n  },\n  template: `<ng-content /><svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      stroke-width=\"2\"\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      class=\"lucide lucide-chevron-right ml-auto\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"m9 18 6-6-6-6\" />\n    </svg>`,\n})\nexport class UiContextMenuSubTriggerComponent extends ContextMenuItemBase implements OnDestroy {\n  readonly sub = inject(UiContextMenuSubComponent)\n  @Input({ transform: booleanAttribute }) inset = false\n  private openTimer: ReturnType<typeof setTimeout> | undefined\n\n  constructor() {\n    super()\n    this.sub.trigger = this.el.nativeElement\n  }\n\n  get hostClass(): string {\n    return cn(\n      \"focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n      this.className,\n    )\n  }\n\n  private openSub(): void {\n    this.sub.setOpen(true)\n  }\n\n  /** Click focuses and opens; it never selects / closes the menu. */\n  override onClick(): void {\n    if (this.disabled) return\n    this.el.nativeElement.focus({ preventScroll: true })\n    if (!this.sub.isOpen) this.openSub()\n  }\n\n  override onPointerUp(): void {}\n\n  /** Hover opens after 100ms (Radix), unless the pointer is heading into another open submenu. */\n  override onPointerMove(event: PointerEvent): void {\n    super.onPointerMove(event)\n    if (!isMouse(event) || event.defaultPrevented || this.disabled || this.sub.isOpen || this.openTimer) return\n    if (this.parent?.panel.isMovingToSub(event)) return\n    this.parent?.panel.setGrace(null)\n    this.openTimer = setTimeout(() => {\n      this.openTimer = undefined\n      this.openSub()\n    }, 100)\n  }\n\n  /** Leaving toward the open submenu keeps it open through a triangle grace area. */\n  override onPointerLeave(event: PointerEvent): void {\n    if (!isMouse(event)) return\n    clearTimeout(this.openTimer)\n    this.openTimer = undefined\n    const content = this.sub.content?.panelEl\n    if (!this.sub.isOpen || !content) return this.parent?.panel.itemLeave(event)\n    const rect = content.getBoundingClientRect()\n    const right = (content.getAttribute('data-side') ?? 'right') === 'right'\n    const near = right ? rect.left : rect.right\n    const far = right ? rect.right : rect.left\n    this.parent?.panel.setGrace({\n      side: right ? 'right' : 'left',\n      area: [\n        { x: event.clientX + (right ? -5 : 5), y: event.clientY },\n        { x: near, y: rect.top },\n        { x: far, y: rect.top },\n        { x: far, y: rect.bottom },\n        { x: near, y: rect.bottom },\n      ],\n    })\n  }\n\n  override onKeydown(event: KeyboardEvent): void {\n    if (this.disabled) return\n    if (event.key === ' ' && this.parent?.panel.search) return\n    const openKey = this.menu.dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight'\n    if (event.key === 'Enter' || event.key === ' ' || event.key === openKey) {\n      event.preventDefault()\n      event.stopPropagation()\n      this.openSub()\n      this.sub.content?.focusFirstWhenOpen()\n    }\n  }\n\n  ngOnDestroy(): void {\n    clearTimeout(this.openTimer)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-context-menu-sub-content, [ui-context-menu-sub-content]',\n  standalone: true,\n  host: { class: 'hidden' },\n  template: `\n    <ng-template #panelTpl>\n      <div\n        [id]=\"sub.contentId\"\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        data-orientation=\"vertical\"\n        tabindex=\"-1\"\n        data-slot=\"context-menu-sub-content\"\n        data-uipkge=\"\"\n        [attr.dir]=\"menu.dir\"\n        [attr.data-state]=\"state()\"\n        [class]=\"panelClass\"\n        (keydown)=\"onKeydown($event)\"\n        (pointermove)=\"panel.pointerMove($event)\"\n        (focus)=\"panel.focusMoved($event.target)\"\n      >\n        <ng-content />\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiContextMenuSubContentComponent implements OnDestroy {\n  readonly sub = inject(UiContextMenuSubComponent)\n  readonly menu = inject(UiContextMenuComponent)\n  private readonly parent: ParentContent | null =\n    inject(UiContextMenuSubContentComponent, { optional: true, skipSelf: true }) ??\n    inject(UiContextMenuContentComponent, { optional: true })\n  private readonly portal = new BodyPortal(inject(ViewContainerRef))\n\n  @Input('class') className?: string\n  @Input() sideOffset = 0\n  @Input() alignOffset = 0\n  @Input({ transform: booleanAttribute }) avoidCollisions = true\n  @Input() collisionPadding = 0\n  @Input({ transform: booleanAttribute }) loop = false\n  @ViewChild('panelTpl', { static: true }) panelTpl!: TemplateRef<unknown>\n  readonly panel = new MenuPanel(() => this.loop)\n  readonly state = signal<'open' | 'closed'>('closed')\n  private cleanups: (() => void)[] = []\n\n  constructor() {\n    this.sub.content = this\n  }\n\n  get panelEl(): HTMLElement | undefined {\n    return this.panel.el\n  }\n\n  get panelClass(): string {\n    return cn(\n      'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',\n      this.className,\n    )\n  }\n\n  sync(): void {\n    if (this.sub.isOpen && (!this.portal.attached || this.state() === 'closed')) {\n      if (this.portal.attached) this.portal.detach()\n      this.show()\n    } else if (!this.sub.isOpen && this.portal.attached && this.state() === 'open') void this.hide()\n  }\n\n  /** Keyboard opens (ArrowRight / Enter / Space on the sub-trigger) land on the first item. */\n  focusFirstWhenOpen(): void {\n    queueMicrotask(() => this.panel.focusFirst())\n  }\n\n  private show(): void {\n    const trigger = this.sub.trigger\n    if (!trigger) return\n    this.state.set('open')\n    const panel = this.portal.attach(this.panelTpl).firstElementChild as HTMLElement\n    this.panel.el = panel\n    this.cleanups.push(\n      placeAndTrack(\n        trigger,\n        panel,\n        () => ({\n          side: this.menu.dir === 'rtl' ? 'left' : 'right',\n          align: 'start',\n          sideOffset: this.sideOffset,\n          alignOffset: this.alignOffset,\n          collisionPadding: this.collisionPadding,\n          avoidCollisions: this.avoidCollisions,\n        }),\n        'context-menu',\n      ),\n      pushDismissableLayer({\n        contains: (t) => panel.contains(t) || trigger.contains(t) || !!this.panel.openSub?.containsTarget(t),\n        // Radix: Escape inside a submenu closes the whole menu.\n        onEscape: () => this.menu.setOpen(false),\n        // Clicking back inside the parent menu closes only this submenu; anywhere else closes the whole menu.\n        onPointerDownOutside: (e) => {\n          if (this.parent?.panelEl?.contains(e.target as Node)) this.sub.setOpen(false)\n          else this.menu.setOpen(false)\n        },\n      }),\n      () => this.panel.destroy(),\n    )\n  }\n\n  private async hide(): Promise<void> {\n    this.panel.openSub?.setOpen(false)\n    this.state.set('closed')\n    const panel = this.panel.el\n    this.cleanups.splice(0).forEach((fn) => fn())\n    await afterExitAnimation(panel)\n    if (this.sub.isOpen) return\n    this.portal.detach()\n    this.panel.el = undefined\n  }\n\n  onKeydown(event: KeyboardEvent): void {\n    const closeKey = this.menu.dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft'\n    if (event.key === closeKey && this.panel.el?.contains(event.target as Node)) {\n      event.preventDefault()\n      this.sub.setOpen(false)\n      this.sub.trigger?.focus({ preventScroll: true })\n      return\n    }\n    this.panel.keydown(event)\n  }\n\n  ngOnDestroy(): void {\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.portal.detach()\n  }\n}\n\nexport { contextMenuItemVariants, type ContextMenuItemVariants }\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/context-menu/context-menu.component.ts"
    },
    {
      "path": "packages/registry-angular/components/context-menu/context-menu-item.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\n/**\n * Variant definitions live in their own file (rather than the package\n * `index.ts`) so consumers can import without creating a circular\n * dependency through the index. The class string is the React\n * ContextMenuItem one verbatim: `variant` / `inset` are applied through\n * `data-variant` / `data-inset` selectors already in it, so the variant\n * keys add nothing (they exist so `contextMenuItemVariants({ variant })`\n * keeps working for callers of the earlier API).\n */\nexport const contextMenuItemVariants = cva(\n  \"focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:[&>svg,&>lucide-icon>svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  {\n    variants: {\n      variant: { default: '', destructive: '' },\n      inset: { true: '', false: '' },\n    },\n    defaultVariants: { variant: 'default', inset: false },\n  },\n)\n\nexport type ContextMenuItemVariants = VariantProps<typeof contextMenuItemVariants>\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/context-menu/context-menu-item.variants.ts"
    },
    {
      "path": "packages/registry-angular/components/context-menu/index.ts",
      "content": "export {\n  UiContextMenuComponent,\n  UiContextMenuTriggerComponent,\n  UiContextMenuContentComponent,\n  UiContextMenuPortalComponent,\n  UiContextMenuItemComponent,\n  UiContextMenuCheckboxItemComponent,\n  UiContextMenuRadioGroupComponent,\n  UiContextMenuRadioItemComponent,\n  UiContextMenuLabelComponent,\n  UiContextMenuSeparatorComponent,\n  UiContextMenuShortcutComponent,\n  UiContextMenuGroupComponent,\n  UiContextMenuSubComponent,\n  UiContextMenuSubTriggerComponent,\n  UiContextMenuSubContentComponent,\n  CONTEXT_MENU_CONTENT_CLASS,\n  contextMenuItemVariants,\n  type ContextMenuItemVariants,\n  type ContextMenuItemVariant,\n  type ContextMenuCheckedState,\n  type ContextMenuDir,\n} from './context-menu.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/context-menu/index.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Right-click menu — same item family as Dropdown Menu (items, checkbox/radio items, labels, separators, shortcuts, submenus) but opened at the pointer by `contextmenu` or a touch long-press. Radix ContextMenu behaviour: portalled + flipped to stay on screen, roving focus, typeahead, Escape / outside-click dismiss, hover submenus with a pointer grace area.",
  "categories": [
    "overlay"
  ]
}