338 lines
10 KiB
JavaScript
338 lines
10 KiB
JavaScript
(function initTooltipOverlayModule() {
|
|
let tooltipId = 0;
|
|
const states = new WeakMap();
|
|
|
|
const getViewport = () => ({
|
|
width: window.visualViewport?.width || window.innerWidth,
|
|
height: window.visualViewport?.height || window.innerHeight,
|
|
});
|
|
|
|
const getPixelValue = (propertyName) => {
|
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
const rawValue = rootStyles.getPropertyValue(propertyName).trim();
|
|
const rootFontSize = parseFloat(rootStyles.fontSize) || 16;
|
|
const value = parseFloat(rawValue);
|
|
|
|
if (Number.isNaN(value)) {
|
|
return 0;
|
|
}
|
|
if (rawValue.endsWith('rem')) {
|
|
return value * rootFontSize;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const getElements = (wrap) => ({
|
|
trigger: wrap.querySelector('[data-component-part="tooltip-trigger"]'),
|
|
panel: wrap.querySelector('[data-component-part="tooltip-panel"]'),
|
|
});
|
|
|
|
const ensureAccessibility = (state) => {
|
|
const { trigger, panel } = state;
|
|
panel.setAttribute('role', 'tooltip');
|
|
|
|
if (!panel.id) {
|
|
tooltipId += 1;
|
|
panel.id = `sg-tooltip-panel-${tooltipId}`;
|
|
}
|
|
|
|
const describedBy = (trigger.getAttribute('aria-describedby') || '')
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
if (!describedBy.includes(panel.id)) {
|
|
trigger.setAttribute('aria-describedby', [...describedBy, panel.id].join(' '));
|
|
}
|
|
|
|
const hasAccessibleName = Boolean(
|
|
trigger.getAttribute('aria-label')
|
|
|| trigger.getAttribute('aria-labelledby')
|
|
|| trigger.getAttribute('title')
|
|
|| trigger.textContent.trim(),
|
|
);
|
|
if (!hasAccessibleName && panel.textContent.trim()) {
|
|
trigger.setAttribute('aria-label', panel.textContent.trim());
|
|
}
|
|
|
|
if (!trigger.matches('a,button,input,select,textarea,[tabindex]')) {
|
|
trigger.setAttribute('tabindex', '0');
|
|
}
|
|
};
|
|
|
|
const setOpenState = (state, isOpen) => {
|
|
const { wrap, panel, trigger } = state;
|
|
wrap.dataset.tooltipOpen = String(isOpen);
|
|
panel.hidden = !isOpen;
|
|
|
|
if (trigger.matches('button,[role="button"],[aria-expanded]')) {
|
|
trigger.setAttribute('aria-expanded', String(isOpen));
|
|
}
|
|
};
|
|
|
|
const restorePanel = (state) => {
|
|
const { panel, placeholder } = state;
|
|
panel.hidden = true;
|
|
panel.removeAttribute('data-tooltip-portal');
|
|
panel.removeAttribute('data-tooltip-placement');
|
|
panel.style.removeProperty('left');
|
|
panel.style.removeProperty('top');
|
|
panel.style.removeProperty('visibility');
|
|
panel.style.removeProperty('--tooltip-arrow-offset');
|
|
|
|
if (placeholder.parentNode) {
|
|
placeholder.parentNode.insertBefore(panel, placeholder.nextSibling);
|
|
}
|
|
};
|
|
|
|
const movePanelToPortal = (state) => {
|
|
const { panel } = state;
|
|
if (panel.parentNode !== document.body) {
|
|
document.body.appendChild(panel);
|
|
}
|
|
panel.setAttribute('data-tooltip-portal', 'true');
|
|
panel.hidden = false;
|
|
panel.style.visibility = 'hidden';
|
|
};
|
|
|
|
const setArrowOffset = (state, placement, panelLeft, panelTop, panelWidth, panelHeight) => {
|
|
const { trigger, panel } = state;
|
|
const triggerRect = trigger.getBoundingClientRect();
|
|
const offset = placement === 'left' || placement === 'right'
|
|
? triggerRect.top + (triggerRect.height / 2) - panelTop
|
|
: triggerRect.left + (triggerRect.width / 2) - panelLeft;
|
|
const maxOffset = placement === 'left' || placement === 'right' ? panelHeight : panelWidth;
|
|
panel.style.setProperty('--tooltip-arrow-offset', `${Math.max(0, Math.min(offset, maxOffset))}px`);
|
|
};
|
|
|
|
const positionTooltip = (state) => {
|
|
const { trigger, panel } = state;
|
|
if (panel.hidden || panel.parentNode !== document.body) {
|
|
return;
|
|
}
|
|
|
|
const viewport = getViewport();
|
|
const safeInset = getPixelValue('--spacing-large');
|
|
const gap = getPixelValue('--spacing-small');
|
|
const triggerRect = trigger.getBoundingClientRect();
|
|
const panelWidth = panel.offsetWidth;
|
|
const panelHeight = panel.offsetHeight;
|
|
const centeredLeft = triggerRect.left + (triggerRect.width / 2) - (panelWidth / 2);
|
|
const centeredRight = centeredLeft + panelWidth;
|
|
const hasBottomSpace = triggerRect.bottom + gap + panelHeight <= viewport.height - safeInset;
|
|
const hasTopSpace = triggerRect.top - gap - panelHeight >= safeInset;
|
|
const hasLeftSpace = triggerRect.left - gap - panelWidth >= safeInset;
|
|
const hasRightSpace = triggerRect.right + gap + panelWidth <= viewport.width - safeInset;
|
|
|
|
let placement = 'bottom';
|
|
if (centeredRight > viewport.width - safeInset && hasLeftSpace) {
|
|
placement = 'left';
|
|
} else if (centeredLeft < safeInset && hasRightSpace) {
|
|
placement = 'right';
|
|
} else if (!hasBottomSpace && hasTopSpace) {
|
|
placement = 'top';
|
|
}
|
|
|
|
let panelLeft = centeredLeft;
|
|
let panelTop = triggerRect.bottom + gap;
|
|
|
|
if (placement === 'top') {
|
|
panelTop = triggerRect.top - gap - panelHeight;
|
|
} else if (placement === 'left') {
|
|
panelLeft = triggerRect.left - gap - panelWidth;
|
|
panelTop = triggerRect.top + (triggerRect.height / 2) - (panelHeight / 2);
|
|
} else if (placement === 'right') {
|
|
panelLeft = triggerRect.right + gap;
|
|
panelTop = triggerRect.top + (triggerRect.height / 2) - (panelHeight / 2);
|
|
}
|
|
|
|
panelLeft = Math.max(safeInset, Math.min(panelLeft, viewport.width - safeInset - panelWidth));
|
|
panelTop = Math.max(safeInset, Math.min(panelTop, viewport.height - safeInset - panelHeight));
|
|
|
|
panel.dataset.tooltipPlacement = placement;
|
|
panel.style.left = `${Math.round(panelLeft)}px`;
|
|
panel.style.top = `${Math.round(panelTop)}px`;
|
|
setArrowOffset(state, placement, panelLeft, panelTop, panelWidth, panelHeight);
|
|
panel.style.visibility = 'visible';
|
|
};
|
|
|
|
const openTooltip = (state) => {
|
|
movePanelToPortal(state);
|
|
setOpenState(state, true);
|
|
positionTooltip(state);
|
|
};
|
|
|
|
const closeTooltip = (state) => {
|
|
state.pinned = false;
|
|
state.hoverTrigger = false;
|
|
state.hoverPanel = false;
|
|
state.focused = false;
|
|
state.dismissed = false;
|
|
setOpenState(state, false);
|
|
restorePanel(state);
|
|
};
|
|
|
|
const closeIfInactive = (state) => {
|
|
if (!state.pinned && !state.hoverTrigger && !state.hoverPanel && !state.focused) {
|
|
setOpenState(state, false);
|
|
restorePanel(state);
|
|
}
|
|
};
|
|
|
|
const togglePinned = (state) => {
|
|
if (state.pinned) {
|
|
state.pinned = false;
|
|
state.dismissed = true;
|
|
setOpenState(state, false);
|
|
restorePanel(state);
|
|
return;
|
|
}
|
|
|
|
state.pinned = true;
|
|
state.dismissed = false;
|
|
openTooltip(state);
|
|
};
|
|
|
|
const initTooltip = (wrap) => {
|
|
if (states.has(wrap)) {
|
|
return;
|
|
}
|
|
|
|
const elements = getElements(wrap);
|
|
if (!elements.trigger || !elements.panel || !elements.panel.parentNode) {
|
|
return;
|
|
}
|
|
|
|
const placeholder = document.createComment('sg-tooltip-panel-placeholder');
|
|
elements.panel.parentNode.insertBefore(placeholder, elements.panel);
|
|
|
|
const state = {
|
|
wrap,
|
|
trigger: elements.trigger,
|
|
panel: elements.panel,
|
|
placeholder,
|
|
pinned: false,
|
|
hoverTrigger: false,
|
|
hoverPanel: false,
|
|
focused: false,
|
|
dismissed: false,
|
|
};
|
|
states.set(wrap, state);
|
|
ensureAccessibility(state);
|
|
setOpenState(state, false);
|
|
|
|
wrap.addEventListener('pointerenter', () => {
|
|
if (state.dismissed) {
|
|
return;
|
|
}
|
|
state.hoverTrigger = true;
|
|
openTooltip(state);
|
|
});
|
|
|
|
wrap.addEventListener('pointerleave', (event) => {
|
|
state.hoverTrigger = false;
|
|
state.dismissed = false;
|
|
if (!state.panel.contains(event.relatedTarget)) {
|
|
closeIfInactive(state);
|
|
}
|
|
});
|
|
|
|
state.panel.addEventListener('pointerenter', () => {
|
|
if (!state.dismissed) {
|
|
state.hoverPanel = true;
|
|
openTooltip(state);
|
|
}
|
|
});
|
|
|
|
state.panel.addEventListener('pointerleave', () => {
|
|
state.hoverPanel = false;
|
|
state.dismissed = false;
|
|
closeIfInactive(state);
|
|
});
|
|
|
|
wrap.addEventListener('focusin', () => {
|
|
if (!state.dismissed) {
|
|
state.focused = true;
|
|
openTooltip(state);
|
|
}
|
|
});
|
|
|
|
wrap.addEventListener('focusout', (event) => {
|
|
if (wrap.contains(event.relatedTarget) || state.panel.contains(event.relatedTarget)) {
|
|
return;
|
|
}
|
|
state.focused = false;
|
|
state.dismissed = false;
|
|
closeIfInactive(state);
|
|
});
|
|
|
|
state.trigger.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
togglePinned(state);
|
|
});
|
|
|
|
state.trigger.addEventListener('keydown', (event) => {
|
|
const isNativeInteractive = state.trigger.matches('a,button,input,select,textarea');
|
|
if (!isNativeInteractive && (event.key === 'Enter' || event.key === ' ')) {
|
|
event.preventDefault();
|
|
togglePinned(state);
|
|
}
|
|
});
|
|
};
|
|
|
|
const initializeWithin = (root) => {
|
|
if (root.nodeType === Node.DOCUMENT_NODE) {
|
|
root.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip);
|
|
return;
|
|
}
|
|
if (root.nodeType !== Node.ELEMENT_NODE) {
|
|
return;
|
|
}
|
|
if (root.matches('.sg-tooltip-wrap')) {
|
|
initTooltip(root);
|
|
}
|
|
root.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip);
|
|
};
|
|
|
|
const bootstrap = () => {
|
|
window.sgInitTooltips = (options = {}) => {
|
|
const root = options.root || document.body;
|
|
initializeWithin(root);
|
|
};
|
|
|
|
window.sgInitTooltips();
|
|
|
|
const observer = new MutationObserver((records) => {
|
|
records.forEach((record) => {
|
|
record.addedNodes.forEach(initializeWithin);
|
|
});
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
|
|
document.addEventListener('click', (event) => {
|
|
document.querySelectorAll('.sg-tooltip-wrap').forEach((wrap) => {
|
|
const state = states.get(wrap);
|
|
if (state && !wrap.contains(event.target) && !state.panel.contains(event.target)) {
|
|
closeTooltip(state);
|
|
}
|
|
});
|
|
});
|
|
|
|
const repositionOpenTooltips = () => {
|
|
document.querySelectorAll('.sg-tooltip-wrap[data-tooltip-open="true"]').forEach((wrap) => {
|
|
const state = states.get(wrap);
|
|
if (state) {
|
|
positionTooltip(state);
|
|
}
|
|
});
|
|
};
|
|
|
|
window.addEventListener('resize', repositionOpenTooltips);
|
|
window.addEventListener('scroll', repositionOpenTooltips, true);
|
|
};
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
|
|
} else {
|
|
bootstrap();
|
|
}
|
|
})();
|