import { Dropdown } from 'bootstrap'; import { globalState } from '../../app/global-state'; import { PortalUtils } from './utils'; /** * How long after opening the clone ignores scroll events. Bootstrap's * keyboard-open path focuses the first item of the source menu, and the * browser may auto-scroll the clipped table viewport to reveal it — that * programmatic scroll must not close the dropdown the user just opened. * Human-initiated scrolling starts later than this window. */ const OPEN_SCROLL_GRACE_MS = 200; export default class DropdownUtils { static bindDropdownOverflowHack( selector: string, cacheKey: string, clonedRootCssClass: string, hide: boolean, isEnabled?: () => boolean, ): void { if (!globalState.windowExists) { return; } if (DropdownUtils[cacheKey] != null) { return; } DropdownUtils[cacheKey] = true; document.addEventListener('show.bs.dropdown', (e) => { const thisDropdown = (e.target as Element | null)?.closest(selector); if (thisDropdown == null) { return; } // Read the predicate at fire time so consumers can toggle the // behaviour at runtime (the listener is registered once at module // load — gating earlier would freeze the value). if (isEnabled != null && !isEnabled()) { return; } // Native cloneNode doesn't copy event listeners, but the original jQuery // clone is only used to swap-in clicks we re-register manually below, so // that's fine. const thisDropdownCopy = thisDropdown.cloneNode(true) as HTMLElement; const ddItems = thisDropdownCopy.querySelectorAll('a'); const originalItems = thisDropdown.querySelectorAll('a'); const randomId = `dd${PortalUtils.randomString(10)}`; thisDropdown.setAttribute('data-hcid', randomId); thisDropdownCopy.setAttribute('data-hcid', `${randomId}-clone`); for (let i = 0, len = ddItems.length; i < len; i++) { const clonedAnchor = ddItems[i]; const originalAnchor = originalItems[i]; clonedAnchor.addEventListener('click', (ev) => { thisDropdownCopy.remove(); originalAnchor.click(); ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation(); }); } thisDropdownCopy.querySelectorAll('button').forEach(el => el.classList.add('show')); thisDropdownCopy.querySelectorAll('.dropdown-menu').forEach(el => el.classList.add('show')); thisDropdownCopy.classList.add( clonedRootCssClass, randomId, 'show', ); // When the source dropdown lives inside a modal, anchor the clone to the // modal element itself rather than . This ties the clone's lifetime // and visibility to the modal purely through the DOM tree: Bootstrap hiding // the modal (display:none) hides the clone via the CSS cascade, and the modal // unmounting removes the clone with it — so a modal that closes while its // dropdown is open can never leave an orphaned clone floating on . // No scroll/global listeners or observers are involved. const modalHost = thisDropdown.closest('.modal'); const rect = thisDropdown.getBoundingClientRect(); if (modalHost != null) { // `position: fixed` is viewport-relative, so the change of offset parent // (body → modal) doesn't affect placement and we don't add scroll offsets. // Sit above the modal dialog within the modal's stacking context. thisDropdownCopy.style.position = 'fixed'; thisDropdownCopy.style.left = `${rect.left}px`; thisDropdownCopy.style.top = `${rect.top}px`; thisDropdownCopy.style.zIndex = '1060'; modalHost.appendChild(thisDropdownCopy); } else { // `offset()` is jQuery's "document-relative position": viewport rect plus // the page scroll. We don't include scrollY here because the original // jQuery code positions an `absolute` element appended to , and // `position: absolute` is relative to the offset parent (body), so the // values we need are document-relative — same as jQuery's offset(). thisDropdownCopy.style.position = 'absolute'; thisDropdownCopy.style.left = `${rect.left + globalState.scrollX}px`; thisDropdownCopy.style.top = `${rect.top + globalState.scrollY}px`; document.body.appendChild(thisDropdownCopy); } DropdownUtils.bindCloneLifecycleWatchers( thisDropdown, thisDropdownCopy, modalHost != null, ); requestAnimationFrame(() => { const menu = thisDropdownCopy.querySelector('.dropdown-menu'); if (menu == null) { return; } const menuRect = menu.getBoundingClientRect(); if (menuRect.bottom > globalState.innerHeight) { const overflow = menuRect.bottom - globalState.innerHeight + 8; const currentTop = parseFloat(thisDropdownCopy.style.top) || 0; thisDropdownCopy.style.top = `${currentTop - overflow}px`; } }); if (hide) { thisDropdown.style.visibility = 'hidden'; thisDropdown.style.display = 'none'; } }); document.addEventListener('hidden.bs.dropdown', (e) => { const dropdown = (e.target as Element | null)?.closest('.dropdown'); if (dropdown == null) { return; } const id = dropdown.getAttribute('data-hcid') || ''; if (id.length == 0) { return; } const clonedId = id.includes('-clone') ? id : `${id}-clone`; const clonedDropdown = document.querySelector(`[data-hcid='${clonedId}']`); if (clonedDropdown != null) { clonedDropdown.remove(); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } }); } /** * The clone is a static copy pinned to coordinates captured at open time — * nothing else keeps it in sync with its source dropdown afterwards. Two * lifecycle gaps used to leave "dead" clones floating over the page: * * 1. Scrolling a container that holds the source (the datatable's * `overflow: auto` viewport, a fullsize `.card-body`, a modal body) * moves the source but not the clone. The clone can't follow — the row * may even scroll out of the table viewport — so the sane reaction is * closing the dropdown, like portal-style popups do elsewhere. * * 2. Disposing the source while the menu is open (row re-render under * polling, pagination, table unmount) means `hidden.bs.dropdown` never * fires, so nothing removed the clone — and its items forward clicks to * detached anchors, i.e. an orphaned menu that reacts to nothing and * swallows pointer events over whatever is beneath it. * * Everything here is scoped to this one clone and dies with it: whichever * path removes the clone (item click, `hidden.bs.dropdown`, * `disposeAllClones`, the watchers themselves), the observer sees the * disconnect, tears the scroll listener and itself down, and the closures * become garbage. No module-level state is kept anywhere. */ private static bindCloneLifecycleWatchers( source: HTMLElement, clone: HTMLElement, cloneIsViewportFixed: boolean, ): void { const openedAt = performance.now(); const closeSourceDropdown = () => { const toggle = source.querySelector('[data-bs-toggle="dropdown"]'); if (source.isConnected && toggle != null) { // Close through Bootstrap so `hidden.bs.dropdown` fires and the // regular removal path keeps aria/show state consistent. Dropdown.getOrCreateInstance(toggle).hide(); } else { clone.remove(); } }; const onScroll = (ev: Event) => { const target = ev.target as Node | null; if (target == null || clone.contains(target)) { // The cloned menu scrolling its own overflow must not close it. return; } if (performance.now() - openedAt < OPEN_SCROLL_GRACE_MS) { return; } // An element scroll only desyncs the clone when the scrolled container // actually contains the source; unrelated scrollers are ignored. A // document scroll keeps the body-anchored (absolute) clone aligned for // free and only desyncs the viewport-fixed (modal-hosted) variant. const desyncsClone = target instanceof Element ? target.contains(source) : cloneIsViewportFixed; if (desyncsClone) { closeSourceDropdown(); } }; let observer: MutationObserver = null; const teardown = () => { document.removeEventListener( 'scroll', onScroll, true, ); observer?.disconnect(); }; observer = new MutationObserver(() => { if (!clone.isConnected) { teardown(); return; } if (!source.isConnected) { clone.remove(); teardown(); } }); // A removed element generates its mutation record at the removal point — // the parent whose childList changed — never deeper in the removed // subtree. Vue may dispose the source by removing any of its ancestors // (row re-render, table unmount, modal teardown), so watching a single // node can't cover it, and watching `document.body` with `subtree` would // observe the whole page. Watching the source's ancestor chain (childList // only, no subtree) covers exactly the relevant levels: whichever one is // removed, its parent is also in the chain and fires. The clone's own // removal is caught the same way — it hangs off or the modal host, // both part of the chain — so every removal path reaches teardown() and // nothing outlives the clone. for (let node = source.parentElement; node != null; node = node.parentElement) { observer.observe(node, { childList: true }); if (node == document.body) { break; } } // Capture phase because scroll events don't bubble — the only way one // listener can see scrolls of arbitrary nested containers. document.addEventListener( 'scroll', onScroll, { capture: true, passive: true }, ); } /** * Remove every overflow-escaping clone currently in the document. * * Clones are normally disposed when their source dropdown fires * `hidden.bs.dropdown`, but that link can break: if the source table * re-renders while the menu is open (e.g. the order-management modal's * task table under active-task polling), the original `.dropdown` node is * replaced and loses its `data-hcid`, so the close event can no longer find * its clone — leaving an orphan. Every clone carries a `data-hcid` ending in * `-clone`, so this purges them regardless of source. * * Intended to be called from a deterministic, listener-free point such as a * modal's `show()` — reopening a reused modal must never surface a stale * clone left over from a previous interaction. */ static disposeAllClones(): void { if (!globalState.windowExists) { return; } document.querySelectorAll('[data-hcid$="-clone"]').forEach(el => el.remove()); } }