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: () => `
`, interactiveHelper: () => ` `, }; 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 = `