export interface VisibilityManagerOptions { host: HTMLElement; target: HTMLElement | null; enableVisibilityEffect?: boolean; enableTabClose?: boolean; onAnchorOutOfView?: (root?: Element | Document | null) => void; onDocumentHidden?: () => void; emit?: (event: string, detail?: Record) => void; isOpen?: () => boolean; } export class VisibilityManager { private observers: IntersectionObserver[] = []; private options: VisibilityManagerOptions; private handleVisibilityChange: () => void; private rootVisibility = new Map(); private hasEmittedOutOfView = false; constructor(options: VisibilityManagerOptions) { this.options = options; this.handleVisibilityChange = this.onDocumentVisibilityChange.bind(this); } setup(): void { const { target, enableVisibilityEffect, enableTabClose, } = this.options; if (!enableVisibilityEffect || !target) return; this.cleanup(); const parentRoot = this.getScrollableAncestors(this.options.host)[0] ?? null; const scrollContainers = parentRoot ? [parentRoot, null] : [null]; scrollContainers.forEach(rootContainer => { const observer = new IntersectionObserver( entries => { for (const entry of entries) { if (!entry.isIntersecting && this.options.isOpen?.()) { this.options.onAnchorOutOfView?.(rootContainer); this.options.emit?.('visibility-change', { visible: false, reason: 'anchor-out-of-view', root: rootContainer, }); return; } if (!this.options.isOpen?.()) return; const visible = entry.intersectionRatio > 0; this.rootVisibility.set(rootContainer, visible); const reported = [...this.rootVisibility.values()]; const anyOutOfView = reported.length > 0 && reported.some(v => v === false); if (anyOutOfView && !this.hasEmittedOutOfView) { this.hasEmittedOutOfView = true; this.options.onAnchorOutOfView?.(rootContainer); this.options.emit?.('visibility-change', { visible: false, reason: 'anchor-out-of-view', root: rootContainer, }); } if (!anyOutOfView) { this.hasEmittedOutOfView = false; } } }, { root: rootContainer, threshold: 0.1 } ); observer.observe(this.options.host); this.observers.push(observer); }); if (enableTabClose) { document.addEventListener('visibilitychange', this.handleVisibilityChange); } } cleanup(): void { this.observers.forEach(o => o.disconnect()); this.observers = []; this.rootVisibility.clear(); document.removeEventListener('visibilitychange', this.handleVisibilityChange); } private onDocumentVisibilityChange(): void { const { enableTabClose, isOpen, onDocumentHidden, emit } = this.options; if (!enableTabClose) return; if (document.visibilityState === 'hidden' && isOpen?.()) { onDocumentHidden?.(); emit?.('visibility-change', { visible: false, reason: 'document-hidden', }); } } private getScrollableAncestors(element: HTMLElement): HTMLElement[] { const scrollables: HTMLElement[] = []; let parent = element.parentElement; while (parent) { const style = getComputedStyle(parent); const canScroll = /(auto|scroll)/.test( style.overflow + style.overflowY + style.overflowX ); if (canScroll) scrollables.push(parent); parent = parent.parentElement; } return scrollables; } }