/** * Copyright Aquera Inc 2026 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, CSSResultArray, TemplateResult } from 'lit'; import type { PropertyValues } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { styles } from './nile-context-menu.css'; import NileElement from '../internal/nile-element'; import type { NileContextMenuItem } from '../nile-context-menu-item'; import type { NileContextMenuGroup } from '../nile-context-menu-group'; import '../nile-floating-panel'; import '../nile-context-menu-group'; import '../nile-context-menu-item'; import '../nile-context-submenu'; // Data-driven menu entry export interface NileContextMenuItemData { type: 'item'; id: string; label: string; value?: string; disabled?: boolean; icon?: string; iconSet?: string; iconSize?: string; iconMethod?: 'fill' | 'stroke' | string; iconColor?: string; submenu?: NileContextMenuData[]; open?: boolean; } export interface NileContextMenuGroupData { type: 'group'; name?: string; data: NileContextMenuItemData[]; } export type NileContextMenuData = NileContextMenuGroupData; const ITEM_TAG = 'nile-context-menu-item'; const SUBMENU_TAG = 'nile-context-submenu'; const MENU_CONTAINER_CLASS = 'nile-context-menu__menu'; const OBSERVED_ITEM_ATTRS = ['value', 'disabled']; export type NileContextMenuTrigger = 'right' | 'left' | 'both' | 'manual' | 'global'; export interface NileContextMenuOpenOptions { x: number; y: number; target?: Element; originalEvent?: Event; } export type NileContextMenuCloseReason = | 'select' | 'outside-click' | 'escape' | 'programmatic'; interface NileContextMenuOpenContext { x: number; y: number; target: Element | null; originalEvent: Event | null; } let proxyIdSeq = 0; /** * Nile context-menu component. * * @tag nile-context-menu * * @slot Default menu content. * * @event nile-change Fired on menu lifecycle and selection. `detail.type` is one of * `'open'`, `'click'`, or `'close'`; remaining fields depend on the type. */ @customElement('nile-context-menu') export class NileContextMenu extends NileElement { public static get styles(): CSSResultArray { return [styles]; } @property({ attribute: true, type: String, reflect: true }) for = ''; @property({ attribute: true, type: String, reflect: true }) trigger: NileContextMenuTrigger = 'right'; @property({ attribute: true, type: String, reflect: true }) skipOn = ''; @property({ attribute: true, type: Number, reflect: true }) zIndex = 9999; @property({ attribute: true, type: Boolean, reflect: true }) open = false; @property({ attribute: false }) public items: NileContextMenuData[] = []; private get _isDataMode(): boolean { return Array.isArray(this.items) && this.items.length > 0; } @query('nile-floating-panel') private _floatingPanel?: HTMLElement; @state() private _items: NileContextMenuItem[] = []; @state() private _open = false; private _pinned = false; protected _openContext: NileContextMenuOpenContext | null = null; protected _targetEl: Element | null = null; private _previouslyFocused: HTMLElement | null = null; private static _openInstances = new Set(); private _detachTriggers?: () => void; private _lightObserver?: MutationObserver; private _menuObserver?: MutationObserver; private _proxyEl?: HTMLDivElement; private readonly _proxyId = `nile-context-menu-anchor-${++proxyIdSeq}`; private _wasDataMode = false; public override connectedCallback(): void { super.connectedCallback(); this._ensureProxy(); this._resolveTarget(); this._attachTriggers(); this._lightObserver = new MutationObserver(() => { if (this._isDataMode) return; this._relocateLightChildren(); }); this._lightObserver.observe(this, { childList: true }); // Restore a declaratively-opened menu after a disconnect/reconnect. if (this.open && !this._open) { this.updateComplete.then(() => { if (this.isConnected && this.open && !this._open) { this._pinned = true; this._openAtTarget(); } }); } } protected override updated(changed: PropertyValues): void { super.updated(changed); if ((changed.has('for') || changed.has('trigger') || changed.has('skipOn')) && this._open) { this.close('programmatic'); } if (changed.has('for')) { this._resolveTarget(); this._attachTriggers(); } else if (changed.has('trigger') || changed.has('skipOn')) { this._attachTriggers(); } if (changed.has('items') && this._menuContainerRef) { if (this._isDataMode) { this._renderDataItems(); this._wasDataMode = true; } else if (this._wasDataMode) { this._renderDataItems(); this._wasDataMode = false; } } if (changed.has('open')) { if (this.open && !this._open) { this._pinned = true; this._openAtTarget(); } else if (!this.open && this._open) { this.close('programmatic'); } } } /** Open anchored to the `for` target (used when the `open` property is set). */ private _openAtTarget(): void { const rect = this._targetEl?.getBoundingClientRect(); this.openAt({ x: rect ? rect.left : 0, y: rect ? rect.bottom : 0, target: this._targetEl ?? undefined, }); } /** Re-anchor a pinned menu to its target after layout changes. */ private _onPinnedReposition = (): void => { if (!this._open || !this._pinned || !this._targetEl) return; const rect = this._targetEl.getBoundingClientRect(); this._positionProxyAt(rect.left, rect.bottom); }; private _resolveTarget(): void { const value = this.for?.trim(); if (!value) { this._targetEl = null; return; } const root = (this.getRootNode() as Document | ShadowRoot) ?? document; if (/^[#.\[:]/.test(value)) { this._targetEl = (root as ParentNode).querySelector(value); } else if ('getElementById' in root) { this._targetEl = (root as Document | ShadowRoot).getElementById(value); } else { this._targetEl = document.getElementById(value); } } public get targetElement(): Element | null { return this._targetEl; } private _attachTriggers(): void { this._detachTriggers?.(); this._detachTriggers = undefined; const trigger = this.trigger; if (trigger === 'manual') return; const cleanups: Array<() => void> = []; if (trigger === 'global') { const skipSelector = this.skipOn?.trim() ?? ''; const isInteractive = (el: Element | null): boolean => { if (!el || !skipSelector) return false; return !!el.closest(skipSelector); }; const handler = (e: Event) => { const me = e as MouseEvent; if (isInteractive(me.target as Element | null)) return; me.preventDefault(); if (this._open) return; this.openAt({ x: me.clientX, y: me.clientY, target: (me.target as Element | null) ?? undefined, originalEvent: me, }); }; window.addEventListener('contextmenu', handler, true); cleanups.push(() => window.removeEventListener('contextmenu', handler, true)); this._detachTriggers = () => cleanups.forEach(fn => fn()); return; } const target = this._targetEl; if (!target) return; const wantsContext = trigger === 'right' || trigger === 'both'; const wantsClick = trigger === 'left' || trigger === 'both'; if (wantsContext) { const handler = (e: Event) => { const me = e as MouseEvent; me.preventDefault(); this.openAt({ x: me.clientX, y: me.clientY, target, originalEvent: me, }); }; target.addEventListener('contextmenu', handler); cleanups.push(() => target.removeEventListener('contextmenu', handler)); } if (wantsClick) { const handler = (e: Event) => { const me = e as MouseEvent; this.openAt({ x: me.clientX, y: me.clientY, target, originalEvent: me, }); }; target.addEventListener('click', handler); cleanups.push(() => target.removeEventListener('click', handler)); } if (cleanups.length === 0) return; this._detachTriggers = () => cleanups.forEach(fn => fn()); } public override disconnectedCallback(): void { super.disconnectedCallback(); NileContextMenu._openInstances.delete(this); this._open = false; this._detachTriggers?.(); this._detachTriggers = undefined; this._lightObserver?.disconnect(); this._lightObserver = undefined; this._menuObserver?.disconnect(); this._menuObserver = undefined; this._menuContainerRef?.removeEventListener('click', this._onMenuClick); this._menuContainerRef?.removeEventListener('mouseover', this._onMenuMouseOver); this._menuContainerRef?.removeEventListener( 'nile-change', this._onDescendantSelect as EventListener, ); document.removeEventListener('pointerdown', this._onOutsidePointer, true); document.removeEventListener('keydown', this._onKeydown, true); window.removeEventListener('scroll', this._onScroll, true); window.removeEventListener('resize', this._onPinnedReposition); this._proxyEl?.remove(); this._proxyEl = undefined; } private _menuContainerRef: HTMLDivElement | null = null; protected override firstUpdated(): void { this._menuContainerRef = this.renderRoot.querySelector('.nile-context-menu__menu') as HTMLDivElement | null; if (this._isDataMode) { this._renderDataItems(); this._wasDataMode = true; } else { this._relocateLightChildren(); } if (this._menuContainerRef) { this._menuObserver = new MutationObserver(() => this._parseChildren()); this._menuObserver.observe(this._menuContainerRef, { childList: true, subtree: true, attributes: true, attributeFilter: OBSERVED_ITEM_ATTRS, }); this._menuContainerRef.addEventListener('click', this._onMenuClick); this._menuContainerRef.addEventListener('mouseover', this._onMenuMouseOver); this._menuContainerRef.addEventListener( 'nile-change', this._onDescendantSelect as EventListener, ); } this._parseChildren(); } private _ensureProxy(): void { if (this._proxyEl) return; const el = document.createElement('div'); el.id = this._proxyId; el.setAttribute('aria-hidden', 'true'); el.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; pointer-events: none;'; document.body.appendChild(el); this._proxyEl = el; } private _positionProxyAt(x: number, y: number): void { if (!this._proxyEl) return; if (this._pinned && this._targetEl) { // menu attaches below it pinned menus (or flips cleanly above it) const rect = this._targetEl.getBoundingClientRect(); this._proxyEl.style.position = 'absolute'; this._proxyEl.style.left = `${rect.left + window.scrollX}px`; this._proxyEl.style.top = `${rect.top + window.scrollY}px`; this._proxyEl.style.width = `${rect.width}px`; this._proxyEl.style.height = `${rect.height}px`; return; } this._proxyEl.style.position = 'fixed'; this._proxyEl.style.left = `${x}px`; this._proxyEl.style.top = `${y}px`; this._proxyEl.style.width = '0'; this._proxyEl.style.height = '0'; } private _relocateLightChildren(): void { if (!this._menuContainerRef) return; const kids = Array.from(this.children); if (kids.length === 0) return; for (const kid of kids) { this._menuContainerRef.appendChild(kid); } } private _renderDataItems(): void { const container = this._menuContainerRef; if (!container) return; while (container.firstChild) container.removeChild(container.firstChild); for (const entry of this.items) { const node = this._createDataNode(entry); if (node) container.appendChild(node); } } private _createDataNode(entry: NileContextMenuData): HTMLElement | null { if (entry.type === 'group') { const group = document.createElement('nile-context-menu-group') as NileContextMenuGroup; if (entry.name) group.label = entry.name; for (const item of entry.data ?? []) { group.appendChild(this._createItemNode(item)); } return group; } return null; } private _createItemNode(data: NileContextMenuItemData): NileContextMenuItem { const item = document.createElement('nile-context-menu-item') as NileContextMenuItem; if (data.id) item.id = data.id; item.value = data.value ?? data.id ?? ''; if (data.disabled) item.disabled = true; if (data.open) item.open = true; if (data.icon) { const glyph = document.createElement('nile-glyph'); glyph.setAttribute('slot', 'icon'); glyph.setAttribute('name', data.icon); if (data.iconSet) glyph.setAttribute('set', data.iconSet); if (data.iconSize) glyph.setAttribute('size', data.iconSize); if (data.iconMethod) glyph.setAttribute('method', data.iconMethod); if (data.iconColor) glyph.setAttribute('color', data.iconColor); item.appendChild(glyph); } item.appendChild(document.createTextNode(data.label)); if (data.submenu && data.submenu.length > 0) { const submenu = document.createElement('nile-context-submenu'); for (const entry of data.submenu) { const node = this._createDataNode(entry); if (node) submenu.appendChild(node); } item.appendChild(submenu); } return item; } private _parseChildren(): void { const root: Element = this._menuContainerRef ?? this; const all = Array.from(root.querySelectorAll(ITEM_TAG)) as NileContextMenuItem[]; this._items = all.filter(item => !this._isInsideDescendantSubmenu(item, root)); } private _isInsideDescendantSubmenu(item: Element, container: Element): boolean { let cur: Element | null = item.parentElement; while (cur && cur !== container) { if (cur.tagName.toLowerCase() === SUBMENU_TAG) return true; cur = cur.parentElement; } return false; } public get menuItems(): readonly NileContextMenuItem[] { return this._items; } public getItemByValue(value: string): NileContextMenuItem | undefined { return this._items.find(item => item.value === value); } public get isOpen(): boolean { return this._open; } public openAt(options: NileContextMenuOpenOptions): void { if (this._open) return; for (const other of NileContextMenu._openInstances) { if (other !== this) other.close('programmatic'); } this._previouslyFocused = document.activeElement as HTMLElement | null; this._openContext = { x: options.x, y: options.y, target: options.target ?? null, originalEvent: options.originalEvent ?? null, }; this._ensureProxy(); this._positionProxyAt(options.x, options.y); this._open = true; this.open = true; NileContextMenu._openInstances.add(this); document.addEventListener('pointerdown', this._onOutsidePointer, true); document.addEventListener('keydown', this._onKeydown, true); if (this._pinned) window.addEventListener('resize', this._onPinnedReposition); requestAnimationFrame(() => { if (this._open) window.addEventListener('scroll', this._onScroll, true); requestAnimationFrame(() => this._openPinnedSubmenus()); }); this.emit('nile-change', { type: 'open', ...this._openContext }); } public close(reason: NileContextMenuCloseReason = 'programmatic'): void { if (!this._open) return; this._open = false; this.open = false; this._pinned = false; this._openContext = null; NileContextMenu._openInstances.delete(this); const subs = this._menuContainerRef?.querySelectorAll(SUBMENU_TAG); subs?.forEach(s => (s as unknown as { closeSubmenu?: () => void }).closeSubmenu?.()); document.removeEventListener('pointerdown', this._onOutsidePointer, true); document.removeEventListener('keydown', this._onKeydown, true); window.removeEventListener('scroll', this._onScroll, true); window.removeEventListener('resize', this._onPinnedReposition); const active = document.activeElement; const focusInMenu = active === this || this._items.some(item => item === active || item.shadowRoot?.contains(active as Node)); if (focusInMenu) { this._previouslyFocused?.focus?.(); } this._previouslyFocused = null; this.emit('nile-change', { type: 'close', reason }); } private _enabledItems(): NileContextMenuItem[] { return this._items.filter(item => !item.disabled); } private _getFocusedItem(): NileContextMenuItem | null { const active = document.activeElement; for (const item of this._items) { if (item === active || item.shadowRoot?.contains(active as Node)) return item; } return null; } private _getFocusedItemAnyLevel(): { item: NileContextMenuItem | null; container: Element | null; } { let node: Element | null = document.activeElement as Element | null; while (node && node.tagName?.toLowerCase() !== ITEM_TAG) { const root = node.getRootNode(); node = root instanceof ShadowRoot ? root.host : node.parentElement; } if (!node) return { item: null, container: null }; const container = node.closest(`.${MENU_CONTAINER_CLASS}`); return { item: node as NileContextMenuItem, container }; } private _enabledItemsIn(container: Element): NileContextMenuItem[] { const all = Array.from(container.querySelectorAll(ITEM_TAG)) as NileContextMenuItem[]; return all.filter(item => { if (item.disabled) return false; return !this._isInsideDescendantSubmenu(item, container); }); } private _focusFirstEnabled(): void { const enabled = this._enabledItems(); enabled[0]?.focus(); } private _moveFocus(delta: number): void { const { item: focused, container } = this._getFocusedItemAnyLevel(); const root: Element = container ?? this._menuContainerRef ?? this; const enabled = this._enabledItemsIn(root); if (enabled.length === 0) return; const current = focused ? enabled.indexOf(focused) : -1; const next = current + delta; const wrapped = ((next % enabled.length) + enabled.length) % enabled.length; enabled[wrapped].focus(); } private _onKeydown = (e: KeyboardEvent): void => { if (!this._open) return; switch (e.key) { case 'Escape': e.preventDefault(); this.close('escape'); return; case 'Tab': this.close('programmatic'); return; case 'ArrowDown': e.preventDefault(); this._moveFocus(1); return; case 'ArrowUp': e.preventDefault(); this._moveFocus(-1); return; case 'ArrowRight': { const { item } = this._getFocusedItemAnyLevel(); const sub = item?.querySelector(`:scope > ${SUBMENU_TAG}`) as | (HTMLElement & { openSubmenu?: () => void }) | null; if (!sub) return; e.preventDefault(); sub.openSubmenu?.(); requestAnimationFrame(() => { (sub as unknown as { focusFirstItem?: () => void }).focusFirstItem?.(); }); return; } case 'ArrowLeft': { const { item, container } = this._getFocusedItemAnyLevel(); if (!item || !container) return; const sub = this._findSubmenuOwning(container); if (!sub) return; e.preventDefault(); // Pinned submenus (parent item has `open`) stay open; just move focus. if (!sub.parentItem?.open) sub.closeSubmenu?.(); sub.parentItem?.focus(); return; } case 'Enter': case ' ': { e.preventDefault(); const { item } = this._getFocusedItemAnyLevel(); if (!item || item.disabled) return; const sub = item.querySelector(`:scope > ${SUBMENU_TAG}`) as | (HTMLElement & { openSubmenu?: () => void }) | null; if (sub) { sub.openSubmenu?.(); requestAnimationFrame(() => { const panel = sub.shadowRoot?.querySelector(`.${MENU_CONTAINER_CLASS}`); const first = panel ? (this._enabledItemsIn(panel)[0] as NileContextMenuItem | undefined) : undefined; first?.focus(); }); return; } this._selectItem(item); return; } } }; private _findSubmenuOwning(container: Element): | (HTMLElement & { closeSubmenu?: () => void; parentItem?: NileContextMenuItem | null }) | null { type SubCtor = { findByContainer?: (c: Element) => HTMLElement & { closeSubmenu?: () => void; parentItem?: NileContextMenuItem | null; } | null; }; const customElements = window.customElements; const ctor = customElements.get(SUBMENU_TAG) as unknown as SubCtor | undefined; return ctor?.findByContainer?.(container) ?? null; } private _onScroll = (e: Event): void => { if (!this._open) return; if (this._pinned) { this._onPinnedReposition(); return; } const path = e.composedPath(); // Scroll inside the root menu container? if (this._menuContainerRef && path.includes(this._menuContainerRef)) return; // Scroll inside any descendant submenu's portaled panel? for (const node of path) { if (node instanceof HTMLElement && node.classList?.contains(MENU_CONTAINER_CLASS)) { return; } } this.close('programmatic'); }; private _onOutsidePointer = (e: Event): void => { if (!this._open) return; const path = e.composedPath(); if (this._menuContainerRef && path.includes(this._menuContainerRef)) return; for (const node of path) { if ( node instanceof HTMLElement && node.classList?.contains(MENU_CONTAINER_CLASS) ) { return; } } this.close('outside-click'); }; private _onDescendantSelect = (e: CustomEvent): void => { if (e.detail?.type !== 'click') return; this.close('select'); }; private _stop(e: Event): void { e.stopPropagation(); } private _onPanelShown = (e: Event): void => { e.stopPropagation(); this._openPinnedSubmenus(); }; /** Open the submenu of every top-level item carrying the `open` attribute. */ private _openPinnedSubmenus(): void { if (!this._open || !this._menuContainerRef) return; for (const item of this._items) { if (!item.open || item.disabled) continue; const sub = item.querySelector(`:scope > ${SUBMENU_TAG}`) as | (HTMLElement & { openSubmenu?: () => void }) | null; sub?.openSubmenu?.(); } } private _onMenuMouseOver = (e: MouseEvent): void => { const item = e .composedPath() .find( node => node instanceof HTMLElement && node.tagName.toLowerCase() === ITEM_TAG ) as NileContextMenuItem | undefined; if (!item || item.disabled) return; item.focus(); }; private _onMenuClick = (e: MouseEvent): void => { const item = e .composedPath() .find( node => node instanceof HTMLElement && node.tagName.toLowerCase() === ITEM_TAG ) as NileContextMenuItem | undefined; if (!item || item.disabled) return; if (item.querySelector(`:scope > ${SUBMENU_TAG}`)) return; this._selectItem(item); }; private _selectItem(item: NileContextMenuItem): void { const detail = { id: item.id, value: item.value, name: (item.textContent ?? '').trim(), target: this._openContext?.target ?? null, originalEvent: this._openContext?.originalEvent ?? null, }; try { item.onSelect?.(detail); } catch (err) { console.error('[nile-context-menu] onSelect callback threw:', err); } this.emit('nile-change', { type: 'click', ...detail }); this.close('select'); } public render(): TemplateResult { return html` `; } } export default NileContextMenu; declare global { interface HTMLElementTagNameMap { 'nile-context-menu': NileContextMenu; } }