585 lines
23 KiB
JavaScript
585 lines
23 KiB
JavaScript
const roundByDPR = (value) => {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
return Math.round(value * dpr) / dpr;
|
||
};
|
||
|
||
const attributeToOption = (attribute) => {
|
||
const option = attribute.replace('tooltip', '');
|
||
return option.charAt(0).toLowerCase() + option.slice(1);
|
||
};
|
||
|
||
/*
|
||
Приводит опцию arrow к единому виду { active, centered, padding }.
|
||
Поддерживает булево значение для обратной совместимости: arrow: true/false
|
||
*/
|
||
const normalizeArrow = (arrow) => {
|
||
const defaults = { active: Boolean(arrow), centered: false, padding: undefined };
|
||
return typeof arrow === 'object' && arrow !== null ? { ...defaults, active: true, ...arrow } : defaults;
|
||
};
|
||
|
||
// Слушатель visibilitychange один на всю страницу
|
||
let visibilityListenerRegistered = false;
|
||
|
||
/*
|
||
Подсказки, оставшиеся открытыми при уходе со страницы, скрываются,
|
||
а их отложенный показ отменяется
|
||
*/
|
||
const handleVisibilityChange = () => {
|
||
if (document.hidden) {
|
||
for (const $tooltip of document.querySelectorAll('.tooltip')) {
|
||
const ref = $tooltip._reference;
|
||
if (ref?._tooltip) {
|
||
clearTimeout(ref._tooltip._showTimeout);
|
||
if (ref._tooltip.isVisible) {
|
||
ref._tooltip.hide({ immediately: true });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const templates = {
|
||
arrow: () => `
|
||
<div class="tooltip__arrow" style="pointer-events: none; position: absolute; transform: rotate(45deg);"></div>
|
||
`,
|
||
interactiveHelper: () => `
|
||
<div class="tooltip__interactive-helper" style="position: absolute; z-index: -1;"></div>
|
||
`,
|
||
};
|
||
|
||
export const createTooltip = ($el, initialContent, initialOptions) => {
|
||
let content = initialContent;
|
||
const options = {
|
||
animation: [
|
||
[{ opacity: 0 }, { opacity: 1 }],
|
||
[{ opacity: 1 }, { opacity: 0 }],
|
||
],
|
||
appendTo: document.body,
|
||
arrow: true,
|
||
delay: [0, 0],
|
||
duration: [0, 0],
|
||
easing: ['linear', 'linear'],
|
||
hideOnClick: true, // Возможные значения: true, 'all', 'toggle'
|
||
interactive: true,
|
||
offset: [0, 8],
|
||
placement: 'top',
|
||
shiftPadding: [8, 0],
|
||
theme: 'light',
|
||
trigger: 'mouseenter click',
|
||
virtualReference: undefined,
|
||
zIndex: '',
|
||
...initialOptions,
|
||
};
|
||
|
||
for (const [key, value] of Object.entries($el.dataset)) {
|
||
if (key.startsWith('tooltip')) {
|
||
let parsedValue = value;
|
||
try {
|
||
parsedValue = JSON.parse(value);
|
||
} catch {} // eslint-disable-line no-empty
|
||
options[attributeToOption(key)] = parsedValue;
|
||
}
|
||
}
|
||
|
||
/*
|
||
Нормализуем arrow после применения data-атрибутов,
|
||
чтобы поддержать и объект, и булево значение
|
||
*/
|
||
options.arrow = normalizeArrow(options.arrow);
|
||
|
||
let rafId;
|
||
const listeners = [];
|
||
|
||
const registerListeners = () => {
|
||
for (const trigger of options.trigger.split(' ')) {
|
||
switch (trigger) {
|
||
case 'mouseenter': {
|
||
$el.addEventListener('pointerenter', $el._tooltip.mouseEnterListener);
|
||
listeners.push({ el: $el, event: 'pointerenter', listener: $el._tooltip.mouseEnterListener });
|
||
|
||
$el.addEventListener('pointerleave', $el._tooltip.mouseLeaveListener);
|
||
listeners.push({ el: $el, event: 'pointerleave', listener: $el._tooltip.mouseLeaveListener });
|
||
break;
|
||
}
|
||
case 'click': {
|
||
$el.addEventListener('click', $el._tooltip.clickListener);
|
||
listeners.push({ el: $el, event: 'click', listener: $el._tooltip.clickListener });
|
||
|
||
if (!options.interactive) {
|
||
$el.addEventListener('pointerleave', $el._tooltip.mouseLeaveListener);
|
||
listeners.push({ el: $el, event: 'pointerleave', listener: $el._tooltip.mouseLeaveListener });
|
||
}
|
||
break;
|
||
}
|
||
case 'focus': {
|
||
$el.addEventListener('focus', $el._tooltip.focusListener);
|
||
listeners.push({ el: $el, event: 'focus', listener: $el._tooltip.focusListener });
|
||
break;
|
||
}
|
||
case 'blur': {
|
||
$el.addEventListener('blur', $el._tooltip.blurListener);
|
||
listeners.push({ el: $el, event: 'blur', listener: $el._tooltip.blurListener });
|
||
break;
|
||
}
|
||
default: {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Проигрывает анимацию появления (0) или скрытия (1) на корне подсказки
|
||
const animateRoot = async (index, immediately) => {
|
||
try {
|
||
const animation = $el._tooltip.$tooltip.querySelector('.tooltip__root').animate(options.animation[index], {
|
||
duration: immediately ? 0 : options.duration[index],
|
||
easing: options.easing[index],
|
||
});
|
||
$el._tooltip._currentAnimation = animation;
|
||
await animation.finished;
|
||
} catch {} // eslint-disable-line no-empty
|
||
};
|
||
|
||
// Добавляет стрелку, если она включена и ещё не создана
|
||
const mountArrow = () => {
|
||
if (options.arrow.active && !$el._tooltip.$arrow && $el._tooltip.$tooltip) {
|
||
$el._tooltip.$tooltip.querySelector('.tooltip__root').insertAdjacentHTML('afterbegin', templates.arrow());
|
||
$el._tooltip.$arrow = $el._tooltip.$tooltip.querySelector('.tooltip__arrow');
|
||
}
|
||
};
|
||
|
||
// Удаляет стрелку из разметки
|
||
const unmountArrow = () => {
|
||
$el._tooltip.$arrow?.remove();
|
||
$el._tooltip.$arrow = undefined;
|
||
};
|
||
|
||
$el._tooltip = {
|
||
options,
|
||
isVisible: false,
|
||
$tooltip: undefined,
|
||
$container: undefined,
|
||
$arrow: undefined,
|
||
$interactive: undefined,
|
||
_showTimeout: undefined,
|
||
_hideTimeout: undefined,
|
||
_currentAnimation: undefined,
|
||
// Счётчик поколений show/hide: отменяет устаревшие асинхронные операции
|
||
_generation: 0,
|
||
/*
|
||
Заглушки до первого show(): настоящие функции создаются в show().
|
||
Нужны, чтобы вызовы из updateOptions/destroy не падали с ошибкой
|
||
*/
|
||
autoUpdateCleanup: () => {
|
||
// Переопределяется в show() после первого показа
|
||
},
|
||
updatePosition: () => {
|
||
// Переопределяется в show() после первого показа
|
||
},
|
||
|
||
setContent(updatedContent) {
|
||
if (updatedContent !== undefined) {
|
||
content = updatedContent;
|
||
}
|
||
if ($el._tooltip.$container) {
|
||
if (content instanceof HTMLElement) {
|
||
$el._tooltip.$container.innerHTML = '';
|
||
$el._tooltip.$container.append(content);
|
||
} else {
|
||
$el._tooltip.$container.innerHTML = content;
|
||
}
|
||
}
|
||
},
|
||
|
||
async updateOptions(updatedOptions = {}) {
|
||
for (const [name, value] of Object.entries(updatedOptions)) {
|
||
options[name] = value;
|
||
}
|
||
|
||
if (updatedOptions.arrow !== undefined) {
|
||
/*
|
||
Опция могла прийти булевым значением — приводим к единому виду.
|
||
Нормализуем всегда, даже если подсказка ещё не создана
|
||
*/
|
||
options.arrow = normalizeArrow(options.arrow);
|
||
if ($el._tooltip.$tooltip) {
|
||
if (options.arrow.active) {
|
||
mountArrow();
|
||
} else {
|
||
unmountArrow();
|
||
}
|
||
}
|
||
}
|
||
|
||
if (updatedOptions.interactive !== undefined) {
|
||
for (const { el, event, listener } of listeners) {
|
||
el.removeEventListener(event, listener);
|
||
}
|
||
listeners.length = 0;
|
||
registerListeners();
|
||
if ($el._tooltip.$tooltip) {
|
||
if (options.interactive) {
|
||
if (!$el._tooltip.$interactive) {
|
||
$el._tooltip.$tooltip
|
||
.querySelector('.tooltip__root')
|
||
.insertAdjacentHTML('afterbegin', templates.interactiveHelper());
|
||
$el._tooltip.$interactive =
|
||
$el._tooltip.$tooltip.querySelector('.tooltip__interactive-helper');
|
||
}
|
||
} else {
|
||
$el._tooltip.$interactive?.remove();
|
||
$el._tooltip.$interactive = undefined;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (updatedOptions.theme !== undefined && $el._tooltip.$tooltip) {
|
||
const themeClass = [...$el._tooltip.$tooltip.classList].find((className) =>
|
||
className.startsWith('tooltip_theme_'),
|
||
);
|
||
if (themeClass) {
|
||
$el._tooltip.$tooltip.classList.remove(themeClass);
|
||
}
|
||
$el._tooltip.$tooltip.classList.add(`tooltip_theme_${options.theme}`);
|
||
}
|
||
|
||
if (updatedOptions.zIndex !== undefined && $el._tooltip.$tooltip) {
|
||
Object.assign($el._tooltip.$tooltip.style, { zIndex: options.zIndex });
|
||
}
|
||
|
||
if ($el._tooltip.$tooltip) {
|
||
await $el._tooltip.updatePosition();
|
||
}
|
||
},
|
||
|
||
destroy() {
|
||
if (!$el._tooltip) {
|
||
return;
|
||
}
|
||
|
||
clearTimeout($el._tooltip._showTimeout);
|
||
clearTimeout($el._tooltip._hideTimeout);
|
||
$el._tooltip._currentAnimation?.cancel();
|
||
cancelAnimationFrame(rafId);
|
||
|
||
$el._tooltip.$tooltip?.remove();
|
||
|
||
// autoUpdateCleanup нужен только для показанной подсказки
|
||
if ($el._tooltip.isVisible) {
|
||
$el._tooltip.autoUpdateCleanup();
|
||
}
|
||
|
||
for (const { el, event, listener } of listeners) {
|
||
el.removeEventListener(event, listener);
|
||
}
|
||
listeners.length = 0;
|
||
|
||
document.body.removeEventListener('click', $el._tooltip.hideOnClickListener);
|
||
|
||
delete $el._tooltip;
|
||
},
|
||
};
|
||
|
||
$el._tooltip.show = async ({ immediately } = {}) => {
|
||
clearTimeout($el._tooltip._hideTimeout);
|
||
clearTimeout($el._tooltip._showTimeout);
|
||
|
||
$el._tooltip._generation += 1;
|
||
const generation = $el._tooltip._generation;
|
||
|
||
if (!$el._tooltip.$tooltip) {
|
||
const { computePosition, offset, flip, shift, arrow } = await import('@floating-ui/dom');
|
||
|
||
if (generation !== $el._tooltip._generation) {
|
||
return;
|
||
}
|
||
|
||
$el._tooltip.$tooltip = document.createElement('div');
|
||
$el._tooltip.$tooltip._reference = $el;
|
||
$el._tooltip.$tooltip.classList.add('tooltip', `tooltip_theme_${options.theme}`);
|
||
Object.assign($el._tooltip.$tooltip.style, { zIndex: options.zIndex });
|
||
|
||
$el._tooltip.$tooltip.innerHTML = `
|
||
<div class="tooltip__root">
|
||
${options.interactive ? templates.interactiveHelper() : ''}
|
||
<div class="tooltip__container"></div>
|
||
</div>
|
||
`;
|
||
|
||
$el._tooltip.$container = $el._tooltip.$tooltip.querySelector('.tooltip__container');
|
||
$el._tooltip.$interactive = $el._tooltip.$tooltip.querySelector('.tooltip__interactive-helper');
|
||
|
||
$el._tooltip.setContent();
|
||
mountArrow();
|
||
|
||
$el._tooltip.updatePosition = () => {
|
||
cancelAnimationFrame(rafId);
|
||
rafId = requestAnimationFrame(async () => {
|
||
const { x, y, placement, middlewareData } = await computePosition(
|
||
options.virtualReference ?? $el,
|
||
$el._tooltip.$tooltip,
|
||
{
|
||
placement: options.placement,
|
||
middleware: [
|
||
offset({ mainAxis: options.offset[1], crossAxis: options.offset[0] }),
|
||
flip(),
|
||
shift({
|
||
padding: {
|
||
top: options.shiftPadding[1],
|
||
right: options.shiftPadding[0],
|
||
bottom: options.shiftPadding[1],
|
||
left: options.shiftPadding[0],
|
||
},
|
||
}),
|
||
arrow({
|
||
element: $el._tooltip.$arrow,
|
||
/*
|
||
Отступ стрелки от краёв подсказки. По умолчанию считаем из размера стрелки:
|
||
квадрат со стороной size, повёрнутый на 45°, выступает за свою рамку
|
||
на (√2 − 1) / 2 · size — это не даёт его углам вылезать за границы.
|
||
Можно задать вручную через arrow.padding
|
||
*/
|
||
padding:
|
||
options.arrow.padding ??
|
||
Math.ceil(((Math.SQRT2 - 1) / 2) * ($el._tooltip.$arrow?.offsetWidth ?? 0)),
|
||
}),
|
||
],
|
||
},
|
||
);
|
||
|
||
Object.assign($el._tooltip.$tooltip.style, {
|
||
transform: `translate(${roundByDPR(x)}px, ${roundByDPR(y)}px)`,
|
||
});
|
||
|
||
const side = placement.split('-')[0];
|
||
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[side];
|
||
|
||
$el._tooltip.$tooltip.classList.remove(
|
||
'tooltip_side_top',
|
||
'tooltip_side_bottom',
|
||
'tooltip_side_left',
|
||
'tooltip_side_right',
|
||
);
|
||
$el._tooltip.$tooltip.classList.add(`tooltip_side_${side}`);
|
||
|
||
if ($el._tooltip.$interactive) {
|
||
Object.assign($el._tooltip.$interactive.style, {
|
||
width: side === 'left' || side === 'right' ? `${options.offset[1]}px` : '100%',
|
||
height: side === 'top' || side === 'bottom' ? `${options.offset[1]}px` : '100%',
|
||
[staticSide]: `-${options.offset[1]}px`,
|
||
});
|
||
}
|
||
|
||
if ($el._tooltip.$arrow && middlewareData.arrow) {
|
||
const { $arrow } = $el._tooltip;
|
||
let { x, y } = middlewareData.arrow;
|
||
|
||
/*
|
||
centered: фиксируем стрелку по центру подсказки, чтобы она не смещалась
|
||
вслед за опорным элементом при прокрутке (например, когда подсказка
|
||
прижата к краю экрана). Ось центрирования зависит от стороны:
|
||
для left/right — вертикаль, для top/bottom — горизонталь
|
||
*/
|
||
if (options.arrow.centered) {
|
||
if (side === 'left' || side === 'right') {
|
||
y = ($el._tooltip.$tooltip.offsetHeight - $arrow.offsetHeight) / 2;
|
||
} else {
|
||
x = ($el._tooltip.$tooltip.offsetWidth - $arrow.offsetWidth) / 2;
|
||
}
|
||
}
|
||
|
||
Object.assign($arrow.style, {
|
||
left: x === undefined ? '' : `${x}px`,
|
||
top: y === undefined ? '' : `${y}px`,
|
||
[staticSide]: `-${$arrow.offsetWidth / 2}px`,
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
if (options.onMount) {
|
||
options.onMount($el._tooltip);
|
||
}
|
||
}
|
||
|
||
const { autoUpdate } = await import('@floating-ui/dom');
|
||
|
||
if (generation !== $el._tooltip._generation) {
|
||
return;
|
||
}
|
||
|
||
$el._tooltip._showTimeout = setTimeout(
|
||
async () => {
|
||
if (generation !== $el._tooltip._generation) {
|
||
return;
|
||
}
|
||
|
||
const isMouseEnterTrigger = options.trigger.includes('mouseenter');
|
||
const isHovering = $el.matches(':hover');
|
||
|
||
// За время задержки состояние могло измениться
|
||
if (
|
||
!$el._tooltip ||
|
||
$el._tooltip.isVisible ||
|
||
(isMouseEnterTrigger && !isHovering) ||
|
||
document.hidden
|
||
) {
|
||
return;
|
||
}
|
||
|
||
(options.appendTo === 'parent' ? $el.parentElement : options.appendTo).append($el._tooltip.$tooltip);
|
||
$el._tooltip.isVisible = true;
|
||
$el._tooltip.autoUpdateCleanup = autoUpdate(
|
||
options.virtualReference ?? $el,
|
||
$el._tooltip.$tooltip,
|
||
$el._tooltip.updatePosition,
|
||
);
|
||
|
||
if (options.hideOnClick && (options.trigger.includes('click') || options.trigger.includes('manual'))) {
|
||
document.body.addEventListener('click', $el._tooltip.hideOnClickListener);
|
||
}
|
||
|
||
if (options.onShow) {
|
||
options.onShow($el._tooltip);
|
||
}
|
||
|
||
await animateRoot(0, immediately);
|
||
|
||
if (generation !== $el._tooltip._generation) {
|
||
return;
|
||
}
|
||
|
||
if (options.onShown) {
|
||
options.onShown($el._tooltip);
|
||
}
|
||
},
|
||
immediately ? 0 : options.delay[0],
|
||
);
|
||
};
|
||
|
||
$el._tooltip.hide = ({ immediately } = {}) => {
|
||
clearTimeout($el._tooltip._showTimeout);
|
||
|
||
$el._tooltip._generation += 1;
|
||
const generation = $el._tooltip._generation;
|
||
|
||
$el._tooltip._hideTimeout = setTimeout(
|
||
async () => {
|
||
if (generation !== $el._tooltip._generation) {
|
||
return;
|
||
}
|
||
|
||
// Подсказку могли удалить или скрыть за время задержки
|
||
if (!$el._tooltip || !$el._tooltip.isVisible) {
|
||
return;
|
||
}
|
||
|
||
if (options.onHide) {
|
||
options.onHide($el._tooltip);
|
||
}
|
||
|
||
document.body.removeEventListener('click', $el._tooltip.hideOnClickListener);
|
||
|
||
await animateRoot(1, immediately);
|
||
|
||
// Пока шла анимация, подсказку могли скрыть повторно или удалить
|
||
if (generation !== $el._tooltip._generation || !$el._tooltip) {
|
||
return;
|
||
}
|
||
|
||
if ($el._tooltip.$tooltip) {
|
||
$el._tooltip.$tooltip.remove();
|
||
}
|
||
$el._tooltip.isVisible = false;
|
||
$el._tooltip.autoUpdateCleanup();
|
||
|
||
if (options.onHidden) {
|
||
options.onHidden($el._tooltip);
|
||
}
|
||
},
|
||
immediately ? 0 : options.delay[1],
|
||
);
|
||
};
|
||
|
||
$el._tooltip.hideOnClickListener = ({ target }) => {
|
||
if (!$el._tooltip.isVisible) {
|
||
return;
|
||
}
|
||
|
||
if (options.hideOnClick === 'all') {
|
||
$el._tooltip.hide();
|
||
} else if (options.hideOnClick === 'toggle') {
|
||
if ($el.contains(target)) {
|
||
$el._tooltip.hide();
|
||
}
|
||
} else {
|
||
// options.hideOnClick === true
|
||
if ($el.contains(target) || !$el._tooltip.$tooltip.contains(target)) {
|
||
$el._tooltip.hide();
|
||
}
|
||
}
|
||
};
|
||
|
||
$el._tooltip.clickListener = () => {
|
||
if (document.hidden) {
|
||
return;
|
||
}
|
||
if (!$el._tooltip.isVisible) {
|
||
$el._tooltip.show();
|
||
}
|
||
};
|
||
|
||
$el._tooltip.mouseEnterListener = (event) => {
|
||
if (event.pointerType !== 'mouse' || document.hidden) {
|
||
return;
|
||
}
|
||
$el._tooltip.show();
|
||
};
|
||
|
||
$el._tooltip.mouseLeaveListener = (event) => {
|
||
if (event.pointerType !== 'mouse') {
|
||
return;
|
||
}
|
||
|
||
if (options.interactive && event.relatedTarget && $el._tooltip.$tooltip?.contains(event.relatedTarget)) {
|
||
$el._tooltip.$tooltip.addEventListener(
|
||
'pointerleave',
|
||
(event) => {
|
||
if (event.pointerType !== 'mouse') {
|
||
return;
|
||
}
|
||
if (!event.relatedTarget || !$el.contains(event.relatedTarget)) {
|
||
$el._tooltip.hide();
|
||
}
|
||
},
|
||
{ once: true },
|
||
);
|
||
} else {
|
||
$el._tooltip.hide();
|
||
}
|
||
};
|
||
|
||
$el._tooltip.focusListener = () => {
|
||
if (document.hidden) {
|
||
return;
|
||
}
|
||
$el._tooltip.show();
|
||
};
|
||
|
||
$el._tooltip.blurListener = () => {
|
||
$el._tooltip.hide();
|
||
};
|
||
|
||
registerListeners();
|
||
|
||
if (!visibilityListenerRegistered) {
|
||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||
visibilityListenerRegistered = true;
|
||
}
|
||
|
||
// Вызываем onCreate в конце, когда все методы и слушатели уже инициализированы
|
||
if (options.onCreate) {
|
||
options.onCreate($el._tooltip);
|
||
}
|
||
};
|