Files
Styleguide/scripts/tooltip-overlays.js
T

366 lines
11 KiB
JavaScript

(function initTooltipOverlayModule() {
let tooltipId = 0;
const states = new WeakMap();
const tooltipStates = new Set();
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;
state.wrap.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) => {
tooltipStates.forEach((otherState) => {
if (otherState !== state) {
closeTooltip(otherState);
}
});
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,
};
wrap.dataset.tooltipInit = 'true';
wrap.dataset.tooltipPlacement = 'bottom';
states.set(wrap, state);
tooltipStates.add(state);
ensureAccessibility(state);
setOpenState(state, false);
wrap.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
if (state.dismissed) {
return;
}
state.hoverTrigger = true;
openTooltip(state);
});
wrap.addEventListener('pointerleave', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
state.hoverTrigger = false;
state.dismissed = false;
if (!state.panel.contains(event.relatedTarget)) {
closeIfInactive(state);
}
});
state.panel.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
if (!state.dismissed) {
state.hoverPanel = true;
openTooltip(state);
}
});
state.panel.addEventListener('pointerleave', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
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) => {
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;
}
const owningWrap = root.closest('.sg-tooltip-wrap');
if (owningWrap) {
initTooltip(owningWrap);
}
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 });
const closeFromOutside = (event) => {
tooltipStates.forEach((state) => {
if (!state.wrap.contains(event.target) && !state.panel.contains(event.target)) {
closeTooltip(state);
}
});
};
document.addEventListener('pointerdown', closeFromOutside, true);
document.addEventListener('touchstart', closeFromOutside, true);
document.addEventListener('click', closeFromOutside, true);
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();
}
})();