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 = `