let tooltipSequence = 0; /** Presentation-only overflow. Keep the complete text node for accessibility; * cropped names enter the tab order and expose a hoverable/dismissible tooltip. * Each observer/listener/tooltip belongs to the existing card abort boundary. */ export function observePersonNameOverflow(card: HTMLElement, name: HTMLElement, signal: AbortSignal): void { const document = card.ownerDocument; const window = document.defaultView; if (!window) return; const tooltip = document.createElement('span'); tooltip.id = `hm-orgchart-person-name-tip-${++tooltipSequence}`; tooltip.className = 'hm-orgchart-person-name-tooltip'; tooltip.setAttribute('role', 'tooltip'); tooltip.textContent = name.textContent; tooltip.hidden = true; let timer: number | undefined; let hoveringTooltip = false; let scaleListening = false; const focused = () => (name.getRootNode() as ShadowRoot).activeElement === name; function cancelTimer(): void { if (timer !== undefined) window!.clearTimeout(timer); timer = undefined; } function hide(): void { cancelTimer(); tooltip.hidden = true; name.dataset.tooltipOpen = 'false'; } function refresh(): void { if (signal.aborted || !name.isConnected || name.clientWidth === 0) return; const overflow = name.scrollWidth > name.clientWidth; name.dataset.nameOverflow = String(overflow); if (overflow) { name.tabIndex = 0; name.setAttribute('aria-describedby', tooltip.id); // Fixed positioning avoids adding hidden content to tree/document scroll // extents. Append inside the same Shadow Root for IDREF accessibility. const shell = card.closest('.hm-orgchart-shell'); shell?.append(tooltip); // Full Composition scale changes move cards without a window resize. if (shell && !scaleListening) { scaleListening = true; shell.addEventListener('hm-orgchart-scale', hide, { signal }); } } else { name.removeAttribute('tabindex'); name.removeAttribute('aria-describedby'); hide(); tooltip.remove(); } } function show(): void { cancelTimer(); refresh(); if (name.dataset.nameOverflow !== 'true') return; tooltip.hidden = false; name.dataset.tooltipOpen = 'true'; const bounds = name.getBoundingClientRect(); const tip = tooltip.getBoundingClientRect(); const left = Math.max(8, Math.min(bounds.left, window!.innerWidth - tip.width - 8)); const below = bounds.bottom + 6; const top = below + tip.height <= window!.innerHeight - 8 ? below : Math.max(8, bounds.top - tip.height - 6); tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; } name.addEventListener('mouseenter', show, { signal }); name.addEventListener('mouseleave', () => { if (!focused()) timer = window.setTimeout(() => { if (!hoveringTooltip) hide(); }, 180); }, { signal }); name.addEventListener('focus', show, { signal }); name.addEventListener('blur', () => { if (!hoveringTooltip) hide(); }, { signal }); name.addEventListener('keydown', event => { if (event.key === 'Escape') hide(); }, { signal }); tooltip.addEventListener('mouseenter', () => { hoveringTooltip = true; cancelTimer(); }, { signal }); tooltip.addEventListener('mouseleave', () => { hoveringTooltip = false; if (!focused()) hide(); }, { signal }); window.addEventListener('scroll', hide, { capture: true, signal }); window.addEventListener('resize', hide, { signal }); document.fonts.addEventListener('loadingdone', refresh, { signal }); const observer = new window.ResizeObserver(refresh); observer.observe(name); void document.fonts.ready.then(refresh); signal.addEventListener('abort', () => { cancelTimer(); observer.disconnect(); tooltip.remove(); }, { once: true }); }