3
0
Files
frontend/packages/tooltip/index.js

617 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
// Со стрелкой набор middleware другой
$el._tooltip._middleware = undefined;
}
};
// Удаляет стрелку из разметки
const unmountArrow = () => {
$el._tooltip.$arrow?.remove();
$el._tooltip.$arrow = undefined;
$el._tooltip._middleware = undefined;
};
$el._tooltip = {
options,
isVisible: false,
$tooltip: undefined,
$container: undefined,
$arrow: undefined,
$interactive: undefined,
// Кэши позиционирования
_side: undefined,
_middleware: 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.offset !== undefined ||
updatedOptions.shiftPadding !== undefined ||
updatedOptions.arrow !== undefined
) {
$el._tooltip._middleware = undefined;
}
if (
updatedOptions.placement !== undefined ||
updatedOptions.offset !== undefined ||
updatedOptions.interactive !== undefined
) {
$el._tooltip._side = undefined;
}
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;
const { computePosition, autoUpdate, offset, flip, shift, arrow } = await import('@floating-ui/dom');
if (generation !== $el._tooltip._generation) {
return;
}
if (!$el._tooltip.$tooltip) {
$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();
const buildMiddleware = () => {
const arrowSize = $el._tooltip.$arrow?.offsetWidth ?? 0;
return [
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) * arrowSize),
}),
];
};
$el._tooltip.updatePosition = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(async () => {
/*
Все чтения геометрии делаем до первой записи стилей, иначе браузер
вынужден делать принудительный reflow на каждом кадре прокрутки
*/
const tooltipWidth = $el._tooltip.$tooltip.offsetWidth;
const tooltipHeight = $el._tooltip.$tooltip.offsetHeight;
const arrowWidth = $el._tooltip.$arrow?.offsetWidth ?? 0;
const arrowHeight = $el._tooltip.$arrow?.offsetHeight ?? 0;
const { x, y, placement, middlewareData } = await computePosition(
options.virtualReference ?? $el,
$el._tooltip.$tooltip,
{
placement: options.placement,
middleware: ($el._tooltip._middleware ??= buildMiddleware()),
},
);
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];
// Стили стороны применяем только при её смене
if (side !== $el._tooltip._side) {
$el._tooltip._side = 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 = (tooltipHeight - arrowHeight) / 2;
} else {
x = (tooltipWidth - arrowWidth) / 2;
}
}
Object.assign($arrow.style, {
left: x === undefined ? '' : `${x}px`,
top: y === undefined ? '' : `${y}px`,
[staticSide]: `-${arrowWidth / 2}px`,
});
}
});
};
if (options.onMount) {
options.onMount($el._tooltip);
}
}
$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);
}
};