/** * 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, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import NileElement from '../internal/nile-element'; import type { NileContextMenuItem } from '../nile-context-menu-item'; import '../nile-floating-panel'; import type { NileFloatingPanel } from '../nile-floating-panel'; const ITEM_TAG = 'nile-context-menu-item'; const SUBMENU_TAG = 'nile-context-submenu'; const HOVER_OPEN_DELAY = 100; const HOVER_CLOSE_DELAY = 300; let submenuIdSeq = 0; /** * Nested submenu inside a `nile-context-menu-item`. * @tag nile-context-submenu */ @customElement('nile-context-submenu') export class NileContextSubmenu extends NileElement { public static get styles(): CSSResultArray { return [ css` :host { display: contents; } `, ]; } @property({ attribute: true, type: Number, reflect: true }) zIndex = 9999; @state() private _open = false; public static openStack: NileContextSubmenu[] = []; private _parentItem: NileContextMenuItem | null = null; private _proxyId = `nile-context-submenu-anchor-${++submenuIdSeq}`; private _proxyEl?: HTMLDivElement; private _floatingPanelEl?: NileFloatingPanel; private _menuContainerRef: HTMLDivElement | null = null; private _openTimer?: number; private _closeTimer?: number; private _setupDone = false; private _parentObserver?: MutationObserver; private _repositionQueued = false; private get _pinnedOpen(): boolean { return !!(this._parentItem?.open && !this._parentItem.disabled); } public override connectedCallback(): void { super.connectedCallback(); let p: Element | null = this.parentElement; while (p && p.tagName.toLowerCase() !== ITEM_TAG) p = p.parentElement; this._parentItem = p as NileContextMenuItem | null; this._ensureProxy(); if (this._parentItem) { this._parentItem.setSubmenuExpanded?.(false); this._parentItem.addEventListener('mouseenter', this._onParentEnter); this._parentItem.addEventListener('mouseleave', this._onParentLeave); this._parentItem.addEventListener('click', this._onParentClick, true); // Open/close the submenu when the parent item's `open` attribute changes. this._parentObserver = new MutationObserver(() => { if (this._pinnedOpen) { if (this._isParentVisible()) this.openSubmenu(); } else if (this._open) { this.closeSubmenu(); } }); this._parentObserver.observe(this._parentItem, { attributes: true, attributeFilter: ['open'], }); } this._ensureBodyPanel(); } private _isParentVisible(): boolean { const rect = this._parentItem?.getBoundingClientRect(); return !!rect && rect.width > 0 && rect.height > 0; } 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 _syncProxyToParent(): void { if (!this._proxyEl || !this._parentItem) return; const rect = this._parentItem.getBoundingClientRect(); if (this._pinnedOpen) { // Pinned submenus anchor in document coordinates. this._proxyEl.style.position = 'absolute'; this._proxyEl.style.left = `${rect.left + window.scrollX}px`; this._proxyEl.style.top = `${rect.top + window.scrollY}px`; } else { this._proxyEl.style.position = 'fixed'; this._proxyEl.style.left = `${rect.left}px`; this._proxyEl.style.top = `${rect.top}px`; } this._proxyEl.style.width = `${rect.width}px`; this._proxyEl.style.height = `${rect.height}px`; } public override disconnectedCallback(): void { super.disconnectedCallback(); if (this._parentItem) { this._parentItem.removeEventListener('mouseenter', this._onParentEnter); this._parentItem.removeEventListener('mouseleave', this._onParentLeave); this._parentItem.removeEventListener('click', this._onParentClick, true); } this._parentObserver?.disconnect(); this._parentObserver = undefined; this._clearTimers(); if (this._open) this.closeSubmenu(); this._teardownBodyArtifacts(); NileContextSubmenu.openStack = NileContextSubmenu.openStack.filter(s => s !== this); } private _teardownBodyArtifacts(): void { const menu = this._menuContainerRef; if (menu) { menu.removeEventListener('click', this._onMenuClick); menu.removeEventListener('mouseover', this._onMenuMouseOver); menu.removeEventListener('mouseenter', this._onPanelEnter); menu.removeEventListener('mouseleave', this._onPanelLeave); menu.removeEventListener('nile-change', this._onNestedSelect as EventListener); while (menu.firstChild) this.appendChild(menu.firstChild); } this._floatingPanelEl?.remove(); this._floatingPanelEl = undefined; this._menuContainerRef = null; this._setupDone = false; this._proxyEl?.remove(); this._proxyEl = undefined; } private _ensureBodyPanel(): void { if (this._setupDone) return; const fp = document.createElement('nile-floating-panel') as NileFloatingPanel; fp.setAttribute('for', this._proxyId); fp.setAttribute('trigger', 'manual'); fp.setAttribute('placement', 'right-start'); fp.panelClass = 'nile-context-menu-panel'; fp.zIndex = this.zIndex; fp.interactive = true; fp.hideOnClick = false; fp.closeOnEscape = false; (fp as unknown as { arrow: 'none' }).arrow = 'none'; fp.distance = 0; const menu = document.createElement('div'); menu.className = 'nile-context-menu__menu'; menu.setAttribute('role', 'menu'); fp.appendChild(menu); document.body.appendChild(fp); this._floatingPanelEl = fp; this._menuContainerRef = menu; menu.addEventListener('click', this._onMenuClick); menu.addEventListener('mouseover', this._onMenuMouseOver); menu.addEventListener('mouseenter', this._onPanelEnter); menu.addEventListener('mouseleave', this._onPanelLeave); menu.addEventListener('nile-change', this._onNestedSelect as EventListener); this._relocateLightChildren(); this._setupDone = true; } protected override firstUpdated(): void { this._relocateLightChildren(); } private _relocateLightChildren(): void { if (!this._menuContainerRef) return; for (const kid of Array.from(this.children)) { this._menuContainerRef.appendChild(kid); } } private _clearTimers(): void { if (this._openTimer != null) { clearTimeout(this._openTimer); this._openTimer = undefined; } if (this._closeTimer != null) { clearTimeout(this._closeTimer); this._closeTimer = undefined; } } private _onParentEnter = (): void => { if (this._parentItem?.disabled) return; this._clearTimers(); if (this._open) return; this._openTimer = window.setTimeout(() => this.openSubmenu(), HOVER_OPEN_DELAY); }; private _onParentLeave = (): void => { if (this._openTimer != null) { clearTimeout(this._openTimer); this._openTimer = undefined; } if (this._pinnedOpen) return; if (this._open) { this._closeTimer = window.setTimeout(() => this.closeSubmenu(), HOVER_CLOSE_DELAY); } }; private _onParentClick = (e: MouseEvent): void => { e.stopPropagation(); if (this._parentItem?.disabled) return; this._clearTimers(); if (this._open) { if (!this._pinnedOpen) this.closeSubmenu(); } else { this.openSubmenu(); } }; private _onPanelEnter = (): void => { this._clearTimers(); }; //Re-anchor to the parent item after scroll/resize private _onReposition = (): void => { if (this._repositionQueued || !this._open) return; this._repositionQueued = true; requestAnimationFrame(() => { this._repositionQueued = false; if (!this._open) return; this._syncProxyToParent(); this._floatingPanelEl?.reposition(); }); }; private _onPanelLeave = (): void => { if (this._pinnedOpen) return; if (this._open) { this._closeTimer = window.setTimeout(() => { const hasOpenDescendant = NileContextSubmenu.openStack.some( sub => sub !== this && this._menuContainerRef?.contains(sub), ); if (hasOpenDescendant) return; this.closeSubmenu(); }, HOVER_CLOSE_DELAY); } }; public openSubmenu(): void { if (this._open) return; if (this._parentItem?.disabled) return; this._closeSiblingSubmenus(); this._ensureProxy(); this._ensureBodyPanel(); this._syncProxyToParent(); this._open = true; if (this._floatingPanelEl) { // open pinned descendants only once this panel is fully shown this._floatingPanelEl.addEventListener( 'nile-after-show', () => { if (this._open) this._openPinnedDescendants(); }, { once: true }, ); this._floatingPanelEl.open = true; } NileContextSubmenu.openStack.push(this); window.addEventListener('scroll', this._onReposition, true); window.addEventListener('resize', this._onReposition); requestAnimationFrame(() => requestAnimationFrame(() => { if (this._open) this._openPinnedDescendants(); })); this._parentItem?.setSubmenuExpanded?.(true); } /** Open the submenu of every direct child item carrying the `open` attribute. */ private _openPinnedDescendants(): void { if (!this._menuContainerRef) return; const items = Array.from( this._menuContainerRef.querySelectorAll(ITEM_TAG) ) as NileContextMenuItem[]; for (const item of items) { if (!this._isDirectChildItem(item)) continue; if (!item.open || item.disabled) continue; const sub = item.querySelector(`:scope > ${SUBMENU_TAG}`) as NileContextSubmenu | null; sub?.openSubmenu(); } } /** True if `item` sits at this submenu's own level (not inside a deeper submenu). */ private _isDirectChildItem(item: Element): boolean { let cur: Element | null = item.parentElement; while (cur && cur !== this._menuContainerRef) { if (cur.tagName.toLowerCase() === SUBMENU_TAG) return false; cur = cur.parentElement; } return true; } public closeSubmenu(): void { if (!this._open) return; for (const sub of [...NileContextSubmenu.openStack]) { if (sub !== this && this._menuContainerRef?.contains(sub)) { sub.closeSubmenu(); } } this._open = false; if (this._floatingPanelEl) this._floatingPanelEl.open = false; window.removeEventListener('scroll', this._onReposition, true); window.removeEventListener('resize', this._onReposition); NileContextSubmenu.openStack = NileContextSubmenu.openStack.filter(s => s !== this); this._clearTimers(); this._parentItem?.setSubmenuExpanded?.(false); } public get isOpen(): boolean { return this._open; } public static findByContainer(container: Element): NileContextSubmenu | null { for (const sub of NileContextSubmenu.openStack) { if (sub._menuContainerRef === container) return sub; } return null; } public get parentItem(): NileContextMenuItem | null { return this._parentItem; } public focusFirstItem(): void { if (!this._menuContainerRef) return; const items = Array.from( this._menuContainerRef.querySelectorAll(ITEM_TAG) ) as NileContextMenuItem[]; for (const item of items) { if (!this._isDirectChildItem(item)) continue; if (!item.disabled) { item.focus(); return; } } } private _closeSiblingSubmenus(): void { const myLevel = this._parentLevel(); for (const sub of [...NileContextSubmenu.openStack]) { if (sub === this) continue; if (sub._pinnedOpen) continue; if (sub._parentLevel() === myLevel) sub.closeSubmenu(); } } private _parentLevel(): Element | null { const item = this._parentItem; if (!item) return null; return item.parentElement?.closest('.nile-context-menu__menu') ?? null; } private _onMenuMouseOver = (e: MouseEvent): void => { const item = e.composedPath().find( n => n instanceof HTMLElement && n.tagName.toLowerCase() === ITEM_TAG ) as NileContextMenuItem | undefined; if (!item || item.disabled) return; item.focus(); }; private _onNestedSelect = (e: CustomEvent): void => { if (e.target === this) return; if (e.detail?.type !== 'click') return; this.emit('nile-change', e.detail); }; private _onMenuClick = (e: MouseEvent): void => { const item = e.composedPath().find( n => n instanceof HTMLElement && n.tagName.toLowerCase() === ITEM_TAG ) as NileContextMenuItem | undefined; if (!item || item.disabled) return; if (item.querySelector(`:scope > ${SUBMENU_TAG}`)) return; const detail = { id: item.id, value: item.value, name: (item.textContent ?? '').trim(), target: null, originalEvent: e, }; try { (item as unknown as { onSelect?: (d: typeof detail) => void }).onSelect?.(detail); } catch (err) { console.error('[nile-context-submenu] onSelect callback threw:', err); } this.emit('nile-change', { type: 'click', ...detail }); }; public render(): TemplateResult { return html``; } } export default NileContextSubmenu; declare global { interface HTMLElementTagNameMap { 'nile-context-submenu': NileContextSubmenu; } }