/** Track listeners so a discarded controller leaves no active DOM callbacks. */ function listenerScope() { const removers = []; return { listen(target, type, callback, options) { if (!target) return; target.addEventListener(type, callback, options); removers.push(() => target.removeEventListener(type, callback, options)); }, destroy() { for (const remove of removers.splice(0)) remove(); }, }; } /** Bind collapse buttons and bubbling Escape to an existing panel. */ export function bindPanelDisclosure({ panel, buttons = [], onChange, onEscape, }) { if ( !panel || typeof onChange !== 'function' || typeof onEscape !== 'function' ) { throw new TypeError( 'Panel disclosure requires a panel, onChange and onEscape', ); } const scope = listenerScope(); for (const button of new Set(buttons)) { scope.listen(button, 'click', () => { onChange(!panel.classList.contains('collapsed'), { explicit: true }); }); } scope.listen(panel, 'keydown', onEscape); return { destroy: () => scope.destroy() }; } /** Close the nearest expanded panel and return focus to its disclosure. */ export function collapsePanelOnEscape( event, { panel, onChange, beforeCollapse }, ) { if (event.key !== 'Escape' || event.defaultPrevented) return false; if ( !panel || panel.classList.contains('collapsed') || !panel.contains(event.target) ) return false; const focusedPanel = event.target?.closest?.( '.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)', ); if (focusedPanel && focusedPanel !== panel) return false; event.preventDefault(); event.stopPropagation(); beforeCollapse?.(); onChange(true, { explicit: true }); const disclosure = panel.querySelector(`[data-dock-toggle-target="${panel.id}"]`) || panel.querySelector(`[data-collapse-target="${panel.id}"]`); const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); if (escapedFromDisclosure) disclosure?.blur?.(); else disclosure?.focus?.({ preventScroll: true }); return true; } /** * Own hover/focus timing for an existing dock tray. Application callbacks own * collapsed state and optional content focus; destroying cancels every timer * and listener without changing saved state or moving focus. */ export function createHoverDisclosure({ panel: panelEl, disclosure = null, onChange, onEscape, focusTarget = null, isActive = () => true, openDelayMs = 850, closeDelayMs = 1000, documentRef = panelEl?.ownerDocument, }) { if ( !panelEl || !documentRef || typeof onChange !== 'function' || typeof onEscape !== 'function' ) { throw new TypeError( 'Hover disclosure requires a panel, document, onChange and onEscape', ); } const document = documentRef; const window = document.defaultView; const clearTimeout = window.clearTimeout.bind(window); const performance = window.performance; const scope = listenerScope(); const listen = scope.listen; let destroyed = false; let openTimer = null; let closeTimer = null; let lastWheelTime = 0; let disclosureFocusTimer = null; let focusRequest = 0; const cancelPendingFocus = () => { clearTimeout(disclosureFocusTimer); disclosureFocusTimer = null; focusRequest += 1; }; const clearOpen = () => { if (!openTimer) return; clearTimeout(openTimer); openTimer = null; }; const clearClose = () => { if (!closeTimer) return; clearTimeout(closeTimer); closeTimer = null; }; const scheduleOpen = () => { clearOpen(); openTimer = window.setTimeout(() => { openTimer = null; if (destroyed || !isActive()) return; if (!panelEl.matches(':hover')) return; if (performance.now() - lastWheelTime < 280) return; if (!panelEl.classList.contains('collapsed')) return; onChange(false); }, openDelayMs); }; // Focus inside the tray defers the unpinned auto-dismiss, but only for the // KEYBOARD: the disclosure hands focus to a Map Source tile on Enter/Space, // and closing the tray out from under that focus would strand the caret. // Plain `document.activeElement` is the wrong test — Chromium focuses a //