73 lines
3.7 KiB
TypeScript
73 lines
3.7 KiB
TypeScript
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 });
|
|
}
|