feat(org-chart): add core cards and fixed-layout integration

This commit is contained in:
shkim4 committed 2026-10-12 17:58:30 +09:00
1 parent d18e3c3380
commit 61c6f8d1f1
161 files changed
+13044 -1

No files matched your search

@@ -0,0 +1,72 @@
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 });
}