From f76c615286b303049b4a1277db5d866a4882e4ab Mon Sep 17 00:00:00 2001 From: Valentin Silyutin Date: Thu, 24 Sep 2026 16:50:42 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D1=8F=20tooltip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tooltip/CHANGELOG.md | 11 ++ packages/tooltip/README.md | 22 ++- packages/tooltip/index.js | 344 ++++++++++++++++++++++------------ 3 files changed, 251 insertions(+), 126 deletions(-) diff --git a/packages/tooltip/CHANGELOG.md b/packages/tooltip/CHANGELOG.md index e155d2d..69f1cfc 100644 --- a/packages/tooltip/CHANGELOG.md +++ b/packages/tooltip/CHANGELOG.md @@ -1,3 +1,14 @@ +## v7.1.0 + +- Опция `arrow` теперь принимает объект: `active`, `centered`, `padding` (булево значение по-прежнему поддерживается) +- Добавлено поле `arrow.centered` — фиксирует стрелку по центру всплывающей подсказки, чтобы она не смещалась при прокрутке +- Рассчитываем `padding` стрелки автоматически, чтобы её повёрнутые углы не выходили за границы всплывающей подсказки +- `arrow` теперь корректно применяется через `updateOptions` — и до первого показа, и с уже показанной подсказкой (включение/выключение стрелки на лету) +- `updateOptions({ interactive })` больше не отключает скрытие подсказки по клику вне её +- Устранены гонки при вызове `show`/`hide` подряд: устаревшие асинхронные операции отменяются +- `autoUpdate` теперь следит за виртуальным элементом (`virtualReference`) +- `onCreate` вызывается после инициализации методов, поэтому `show`/`hide` доступны внутри него + ## v7.0.1 - Исправление работы анимации diff --git a/packages/tooltip/README.md b/packages/tooltip/README.md index e0e75f8..714d08b 100644 --- a/packages/tooltip/README.md +++ b/packages/tooltip/README.md @@ -33,7 +33,7 @@ createTooltip(document.querySelector('button'), 'Подсказка', { [{ opacity: 1 }, { opacity: 0 }], ], appendTo: document.body, - arrow: true, + arrow: { active: true, centered: false, padding: undefined }, delay: [0, 0], duration: [0, 0], easing: ['linear', 'linear'], @@ -62,6 +62,26 @@ createTooltip(document.querySelector('button'), 'Подсказка', { - `'all'` — всплывающая подсказка скрывается при клике по любому элементу на странице (**включая** саму всплывающую подсказку). - `'toggle'` — всплывающая подсказка скрывается только при клике по элементу, который её вызывает. +#### arrow + +Настройка отображения и позиционирования стрелки. Поддерживает булево значение (`true`/`false`) для обратной совместимости либо объект: + +- `active` (`true`) — показывать ли стрелку. Эквивалент `arrow: true`/`arrow: false`. +- `centered` (`false`) — фиксировать стрелку по центру всплывающей подсказки, вместо того чтобы следовать за центром элемента, который её вызывает. Полезно, когда подсказка прижата к краю области видимости и стрелка смещается при прокрутке. +- `padding` (`undefined`) — отступ стрелки от краёв всплывающей подсказки. По умолчанию рассчитывается автоматически исходя из размера стрелки, чтобы её повёрнутые под 45° углы не выходили за границы подсказки. + +```js +createTooltip(document.querySelector('button'), 'Подсказка', { + arrow: { active: true, centered: true }, +}); +``` + +Так же настраивается через data-атрибут: + +```html + +``` + ##### virtualReference Настройка используется для кастомного позиционирования, ожидает объект с методом `getBoundingClientRect`. diff --git a/packages/tooltip/index.js b/packages/tooltip/index.js index 3577440..72a1d54 100644 --- a/packages/tooltip/index.js +++ b/packages/tooltip/index.js @@ -1,39 +1,55 @@ const roundByDPR = (value) => { - const dpr = window.devicePixelRatio || 1; - return Math.round(value * dpr) / dpr; - }, - attributeToOption = (attribute) => { - attribute = attribute.replace('tooltip', ''); - return attribute.charAt(0).toLowerCase() + attribute.slice(1); - }; + 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 }); - } + 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 }); } } } - }, - templates = { - arrow: () => ` + } +}; + +const templates = { + arrow: () => `
`, - interactiveHelper: () => ` + interactiveHelper: () => `
`, - }; +}; -export const createTooltip = ($el, content, options) => { - options = { +export const createTooltip = ($el, initialContent, initialOptions) => { + let content = initialContent; + const options = { animation: [ [{ opacity: 0 }, { opacity: 1 }], [{ opacity: 1 }, { opacity: 0 }], @@ -52,7 +68,7 @@ export const createTooltip = ($el, content, options) => { trigger: 'mouseenter click', virtualReference: undefined, zIndex: '', - ...options, + ...initialOptions, }; for (const [key, value] of Object.entries($el.dataset)) { @@ -65,9 +81,79 @@ export const createTooltip = ($el, content, options) => { } } + /* + Нормализуем 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, @@ -78,8 +164,18 @@ export const createTooltip = ($el, content, options) => { _showTimeout: undefined, _hideTimeout: undefined, _currentAnimation: undefined, - autoUpdateCleanup: () => {}, - updatePosition: async () => {}, + // Счётчик поколений show/hide: отменяет устаревшие асинхронные операции + _generation: 0, + /* + Заглушки до первого show(): настоящие функции создаются в show(). + Нужны, чтобы вызовы из updateOptions/destroy не падали с ошибкой + */ + autoUpdateCleanup: () => { + // Переопределяется в show() после первого показа + }, + updatePosition: () => { + // Переопределяется в show() после первого показа + }, setContent(updatedContent) { if (updatedContent !== undefined) { @@ -100,17 +196,18 @@ export const createTooltip = ($el, content, options) => { options[name] = value; } - if (updatedOptions.arrow !== undefined && $el._tooltip.$tooltip) { - if (options.arrow) { - if (!$el._tooltip.$arrow) { - $el._tooltip.$tooltip - .querySelector('.tooltip__root') - .insertAdjacentHTML('afterbegin', templates.arrow()); - $el._tooltip.$arrow = $el._tooltip.$tooltip.querySelector('.tooltip__arrow'); + if (updatedOptions.arrow !== undefined) { + /* + Опция могла прийти булевым значением — приводим к единому виду. + Нормализуем всегда, даже если подсказка ещё не создана + */ + options.arrow = normalizeArrow(options.arrow); + if ($el._tooltip.$tooltip) { + if (options.arrow.active) { + mountArrow(); + } else { + unmountArrow(); } - } else { - $el._tooltip.$arrow?.remove(); - $el._tooltip.$arrow = undefined; } } @@ -137,17 +234,13 @@ export const createTooltip = ($el, content, options) => { } if (updatedOptions.theme !== undefined && $el._tooltip.$tooltip) { - const classIndex = [...$el._tooltip.$tooltip.classList].findIndex((className) => + const themeClass = [...$el._tooltip.$tooltip.classList].find((className) => className.startsWith('tooltip_theme_'), ); - if (classIndex === -1) { - $el._tooltip.$tooltip.classList.add(`tooltip_theme_${options.theme}`); - } else { - $el._tooltip.$tooltip.classList.replace( - $el._tooltip.$tooltip.classList[classIndex], - `tooltip_theme_${options.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) { @@ -171,7 +264,7 @@ export const createTooltip = ($el, content, options) => { $el._tooltip.$tooltip?.remove(); - // Вызываем autoUpdateCleanup только если всплывающая подсказка была видна (иначе вызывать её не имеет смысла) + // autoUpdateCleanup нужен только для показанной подсказки if ($el._tooltip.isVisible) { $el._tooltip.autoUpdateCleanup(); } @@ -187,16 +280,20 @@ export const createTooltip = ($el, content, options) => { }, }; - if (options.onCreate) { - options.onCreate($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}`); @@ -205,16 +302,15 @@ export const createTooltip = ($el, content, options) => { $el._tooltip.$tooltip.innerHTML = `
${options.interactive ? templates.interactiveHelper() : ''} - ${options.arrow ? templates.arrow() : ''}
`; $el._tooltip.$container = $el._tooltip.$tooltip.querySelector('.tooltip__container'); - $el._tooltip.$arrow = $el._tooltip.$tooltip.querySelector('.tooltip__arrow'); $el._tooltip.$interactive = $el._tooltip.$tooltip.querySelector('.tooltip__interactive-helper'); $el._tooltip.setContent(); + mountArrow(); $el._tooltip.updatePosition = () => { cancelAnimationFrame(rafId); @@ -235,7 +331,18 @@ export const createTooltip = ($el, content, options) => { left: options.shiftPadding[0], }, }), - arrow({ element: $el._tooltip.$arrow }), + 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)), + }), ], }, ); @@ -244,8 +351,8 @@ export const createTooltip = ($el, content, options) => { transform: `translate(${roundByDPR(x)}px, ${roundByDPR(y)}px)`, }); - const side = placement.split('-')[0], - staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[side]; + const side = placement.split('-')[0]; + const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[side]; $el._tooltip.$tooltip.classList.remove( 'tooltip_side_top', @@ -264,11 +371,27 @@ export const createTooltip = ($el, content, options) => { } if ($el._tooltip.$arrow && middlewareData.arrow) { - const { x, y } = middlewareData.arrow; - Object.assign($el._tooltip.$arrow.style, { + 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]: `-${$el._tooltip.$arrow.offsetWidth / 2}px`, + [staticSide]: `-${$arrow.offsetWidth / 2}px`, }); } }); @@ -281,12 +404,20 @@ export const createTooltip = ($el, content, options) => { const { autoUpdate } = await import('@floating-ui/dom'); + if (generation !== $el._tooltip._generation) { + return; + } + $el._tooltip._showTimeout = setTimeout( async () => { - const isMouseEnterTrigger = options.trigger.includes('mouseenter'), - isHovering = $el.matches(':hover'); + if (generation !== $el._tooltip._generation) { + return; + } - // Проверяем $el._tooltip на сущестование и актуальность показа всплывающей подсказки + const isMouseEnterTrigger = options.trigger.includes('mouseenter'); + const isHovering = $el.matches(':hover'); + + // За время задержки состояние могло измениться if ( !$el._tooltip || $el._tooltip.isVisible || @@ -298,31 +429,25 @@ export const createTooltip = ($el, content, options) => { (options.appendTo === 'parent' ? $el.parentElement : options.appendTo).append($el._tooltip.$tooltip); $el._tooltip.isVisible = true; - $el._tooltip.autoUpdateCleanup = autoUpdate($el, $el._tooltip.$tooltip, $el._tooltip.updatePosition); + $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); - listeners.push({ - el: document.body, - event: 'click', - listener: $el._tooltip.hideOnClickListener, - }); } if (options.onShow) { options.onShow($el._tooltip); } - try { - const animation = $el._tooltip.$tooltip - .querySelector('.tooltip__root') - .animate(options.animation[0], { - duration: immediately ? 0 : options.duration[0], - easing: options.easing[0], - }); - $el._tooltip._currentAnimation = animation; - await animation.finished; - } catch {} // eslint-disable-line no-empty + await animateRoot(0, immediately); + + if (generation !== $el._tooltip._generation) { + return; + } if (options.onShown) { options.onShown($el._tooltip); @@ -334,9 +459,17 @@ export const createTooltip = ($el, content, options) => { $el._tooltip.hide = ({ immediately } = {}) => { clearTimeout($el._tooltip._showTimeout); + + $el._tooltip._generation += 1; + const generation = $el._tooltip._generation; + $el._tooltip._hideTimeout = setTimeout( async () => { - // Проверяем $el._tooltip на сущестование + if (generation !== $el._tooltip._generation) { + return; + } + + // Подсказку могли удалить или скрыть за время задержки if (!$el._tooltip || !$el._tooltip.isVisible) { return; } @@ -347,19 +480,10 @@ export const createTooltip = ($el, content, options) => { document.body.removeEventListener('click', $el._tooltip.hideOnClickListener); - try { - const animation = $el._tooltip.$tooltip - .querySelector('.tooltip__root') - .animate(options.animation[1], { - duration: immediately ? 0 : options.duration[1], - easing: options.easing[1], - }); - $el._tooltip._currentAnimation = animation; - await animation.finished; - } catch {} // eslint-disable-line no-empty + await animateRoot(1, immediately); - // Ещё одна проверка на сущестование $el._tooltip после await - if (!$el._tooltip) { + // Пока шла анимация, подсказку могли скрыть повторно или удалить + if (generation !== $el._tooltip._generation || !$el._tooltip) { return; } @@ -446,45 +570,15 @@ export const createTooltip = ($el, content, options) => { $el._tooltip.hide(); }; - 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; - } - } - } - }; - registerListeners(); if (!visibilityListenerRegistered) { document.addEventListener('visibilitychange', handleVisibilityChange); visibilityListenerRegistered = true; } + + // Вызываем onCreate в конце, когда все методы и слушатели уже инициализированы + if (options.onCreate) { + options.onCreate($el._tooltip); + } };