From c501afc79033509d6f8f5f9cfb339711546574b6 Mon Sep 17 00:00:00 2001 From: Valentin Silytuin Date: Tue, 2 Sep 2025 21:40:58 +0400 Subject: [PATCH] v4.0.0 --- CHANGELOG.md | 8 ++ README.md | 10 +- index.js | 385 ++++++++++++++++++++++++++------------------------- 3 files changed, 213 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed42cc..d5cd5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## v4.0.0 + +- Переработана логика инициализации и обновления тултипа +- Добавлены новые триггеры: `focus`, `blur` +- Опция `hideOnClick` теперь поддерживает значения: `true`, `'all'`, `'toggle'` +- Исправлены ошибки с позиционированием, анимацией и стилями +- Оптимизировано управление событиями и очистка ресурсов + ## v3.0.1 - Исправлена работа событий diff --git a/README.md b/README.md index 6713068..d126e8b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ import { createTooltip } from '@advdominion/tooltip'; createTooltip(document.querySelector('button'), 'Подсказка'); ``` -#### Все настройки со значениями по-умолчанию +#### Все настройки со значениями по умолчанию ```js import { createTooltip } from '@advdominion/tooltip'; @@ -46,7 +46,7 @@ createTooltip(document.querySelector('button'), 'Подсказка', { trigger: 'mouseenter', virtualReference: undefined, zIndex: '', - // Callback-функции, по-умолчанию не заданы + // Callback-функции, по умолчанию не заданы onCreate(instance) {}, onMount(instance) {}, onShow(instance) {}, @@ -56,6 +56,12 @@ createTooltip(document.querySelector('button'), 'Подсказка', { }); ``` +#### hideOnClick + +- `true` (по умолчанию) — всплывающая подсказка скрывается при клике по любому элементу на странице (**кроме** самой всплывающей подсказки). +- `'all'` — всплывающая подсказка скрывается при клике по любому элементу на странице (**включая** саму всплывающую подсказку). +- `'toggle'` — всплывающая подсказка скрывается только при клике по элементу, который её вызывает. + ##### virtualReference Настройка используется для кастомного позиционирования, ожидает объект с методом `getBoundingClientRect`. diff --git a/index.js b/index.js index ec82492..39c5917 100644 --- a/index.js +++ b/index.js @@ -19,7 +19,7 @@ export const createTooltip = ($el, content, options) => { delay: [0, 0], duration: [0, 0], easing: ['linear', 'linear'], - hideOnClick: true, // Возможные значения: true, 'all', 'trigger' + hideOnClick: true, // Возможные значения: true, 'all', 'toggle' interactive: true, offset: [0, 8], placement: 'top', @@ -31,61 +31,52 @@ export const createTooltip = ($el, content, options) => { ...options, }; - const setOptionFromDataAttribute = (attribute) => { - if ($el.dataset[attribute] !== undefined) { - let value = $el.dataset[attribute]; + for (const [key, value] of Object.entries($el.dataset)) { + if (key.startsWith('tooltip')) { + let parsedValue = value; try { - value = JSON.parse($el.dataset[attribute]); - } catch {} - options[attributeToOption(attribute)] = value; + parsedValue = JSON.parse(value); + } catch {} // eslint-disable-line no-empty + options[attributeToOption(key)] = parsedValue; } - }; - - setOptionFromDataAttribute('tooltipAnimation'); - setOptionFromDataAttribute('tooltipAppendTo'); - setOptionFromDataAttribute('tooltipArrow'); - setOptionFromDataAttribute('tooltipDelay'); - setOptionFromDataAttribute('tooltipDuration'); - setOptionFromDataAttribute('tooltipEasing'); - setOptionFromDataAttribute('tooltipHideOnClick'); - setOptionFromDataAttribute('tooltipInteractive'); - setOptionFromDataAttribute('tooltipOffset'); - setOptionFromDataAttribute('tooltipPlacement'); - setOptionFromDataAttribute('tooltipShiftPadding'); - setOptionFromDataAttribute('tooltipTheme'); - setOptionFromDataAttribute('tooltipTrigger'); - setOptionFromDataAttribute('tooltipZIndex'); + } let showTimeout; let hideTimeout; - - let autoUpdateCleanup = new Function(); + let rafId; const listeners = []; $el._tooltip = { options, isVisible: false, + $tooltip: undefined, + $container: undefined, + $arrow: undefined, + $interactive: undefined, + autoUpdateCleanup: () => {}, + setContent(updatedContent) { if (updatedContent !== undefined) { content = updatedContent; } - if ($el._tooltip.$tooltip) { - const $container = $el._tooltip.$tooltip.querySelector('.tooltip__container'); + if ($el._tooltip.$container) { if (content instanceof HTMLElement) { - $container.innerHTML = ''; - $container.append(content); + $el._tooltip.$container.innerHTML = ''; + $el._tooltip.$container.append(content); } else { - $container.innerHTML = content; + $el._tooltip.$container.innerHTML = content; } } }, + async updateOptions(updatedOptions = {}) { for (const [name, value] of Object.entries(updatedOptions)) { options[name] = value; } + if (updatedOptions.arrow !== undefined && $el._tooltip.$tooltip) { if (options.arrow) { - if (!$el._tooltip.$tooltip.querySelector('.tooltip__arrow')) { + if (!$el._tooltip.$arrow) { $el._tooltip.$tooltip.insertAdjacentHTML( 'afterbegin', ` @@ -95,56 +86,82 @@ export const createTooltip = ($el, content, options) => { `, ); + $el._tooltip.$arrow = $el._tooltip.$tooltip.querySelector('.tooltip__arrow'); } } else { - $el._tooltip.$tooltip.querySelector('.tooltip__arrow')?.remove(); + $el._tooltip.$arrow?.remove(); + $el._tooltip.$arrow = undefined; } } + 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.$tooltip.querySelector('.tooltip__interactive-helper')) { + if (!$el._tooltip.$interactive) { $el._tooltip.$tooltip.insertAdjacentHTML( 'afterbegin', `
+ style="position: absolute; z-index: -1;">
`, ); + $el._tooltip.$interactive = + $el._tooltip.$tooltip.querySelector('.tooltip__interactive-helper'); } } else { - $el._tooltip.$tooltip.querySelector('.tooltip__interactive-helper')?.remove(); + $el._tooltip.$interactive?.remove(); + $el._tooltip.$interactive = undefined; } } } + if (updatedOptions.theme !== undefined && $el._tooltip.$tooltip) { const classIndex = [...$el._tooltip.$tooltip.classList].findIndex((className) => className.startsWith('tooltip_theme_'), ); - $el._tooltip.$tooltip.classList.replace( - $el._tooltip.$tooltip.classList[classIndex], - `tooltip_theme_${options.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 (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; + } + $el._tooltip.$tooltip?.remove(); - autoUpdateCleanup(); + + // Вызываем autoUpdateCleanup только если всплывающая подсказка была видна (иначе вызывать её не имеет смысла) + if ($el._tooltip.isVisible) { + $el._tooltip.autoUpdateCleanup(); + } + for (const { el, event, listener } of listeners) { el.removeEventListener(event, listener); } + listeners.length = 0; + delete $el._tooltip; }, }; @@ -153,9 +170,7 @@ export const createTooltip = ($el, content, options) => { options.onCreate($el._tooltip); } - $el._tooltip.show = async (params = {}) => { - const { immediately } = params; - + $el._tooltip.show = async ({ immediately } = {}) => { clearTimeout(hideTimeout); if (!$el._tooltip.$tooltip) { @@ -163,87 +178,9 @@ export const createTooltip = ($el, content, options) => { $el._tooltip.$tooltip = document.createElement('div'); $el._tooltip.$tooltip._reference = $el; - $el._tooltip.updatePosition = async () => { - const $arrow = $el._tooltip.$tooltip.querySelector('.tooltip__arrow'); - - 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: $arrow, - padding: Number.parseInt( - getComputedStyle($el._tooltip.$tooltip.querySelector('.tooltip__container'))[ - 'border-radius' - ], - ), - }), - ], - }, - ); - - 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}`); - - const $interactive = $el._tooltip.$tooltip.querySelector('.tooltip__interactive-helper'); - if ($interactive) { - Object.assign($interactive.style, { - width: side === 'left' || side === 'right' ? `${options.offset[1]}px` : '100%', - height: side === 'top' || side === 'bottom' ? `${options.offset[1]}px` : '100%', - top: '', - bottom: '', - left: '', - right: '', - [staticSide]: `-${options.offset[1]}px`, - }); - } - - if ($arrow && middlewareData.arrow) { - const { x, y } = middlewareData.arrow; - Object.assign($arrow.style, { - left: x === undefined ? '' : `${x}px`, - top: y === undefined ? '' : `${y}px`, - right: '', - bottom: '', - [staticSide]: `-${$arrow.offsetWidth / 2}px`, - }); - } - }; - $el._tooltip.$tooltip.classList.add('tooltip', `tooltip_theme_${options.theme}`); Object.assign($el._tooltip.$tooltip.style, { zIndex: options.zIndex }); + $el._tooltip.$tooltip.innerHTML = `
${ @@ -251,7 +188,7 @@ export const createTooltip = ($el, content, options) => { ? `
+ style="position: absolute; z-index: -1;">
` : '' @@ -269,8 +206,71 @@ export const createTooltip = ($el, content, options) => {
`; + + $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(); + $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 }), + ], + }, + ); + + 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 { x, y } = middlewareData.arrow; + Object.assign($el._tooltip.$arrow.style, { + left: x === undefined ? '' : `${x}px`, + top: y === undefined ? '' : `${y}px`, + [staticSide]: `-${$el._tooltip.$arrow.offsetWidth / 2}px`, + }); + } + }); + }; + if (options.onMount) { options.onMount($el._tooltip); } @@ -281,13 +281,15 @@ export const createTooltip = ($el, content, options) => { showTimeout = setTimeout( async () => { if (!$el._tooltip.isVisible) { - if (options.appendTo === 'parent') { - $el.parentElement.append($el._tooltip.$tooltip); - } else { - options.appendTo.append($el._tooltip.$tooltip); - } + (options.appendTo === 'parent' ? $el.parentElement : options.appendTo).append( + $el._tooltip.$tooltip, + ); $el._tooltip.isVisible = true; - autoUpdateCleanup = autoUpdate($el, $el._tooltip.$tooltip, $el._tooltip.updatePosition); + $el._tooltip.autoUpdateCleanup = autoUpdate( + $el, + $el._tooltip.$tooltip, + $el._tooltip.updatePosition, + ); if ( options.hideOnClick && @@ -305,10 +307,12 @@ export const createTooltip = ($el, content, options) => { options.onShow($el._tooltip); } - await $el._tooltip.$tooltip.querySelector('.tooltip__root').animate(options.animation[0], { - duration: immediately ? 0 : options.duration[0], - easing: options.easing[0], - }).finished; + try { + await $el._tooltip.$tooltip.querySelector('.tooltip__root').animate(options.animation[0], { + duration: immediately ? 0 : options.duration[0], + easing: options.easing[0], + }).finished; + } catch {} // eslint-disable-line no-empty if (options.onShown) { options.onShown($el._tooltip); @@ -319,11 +323,8 @@ export const createTooltip = ($el, content, options) => { ); }; - $el._tooltip.hide = (params = {}) => { - const { immediately } = params; - + $el._tooltip.hide = ({ immediately } = {}) => { clearTimeout(showTimeout); - hideTimeout = setTimeout( async () => { if ($el._tooltip.isVisible) { @@ -331,14 +332,18 @@ export const createTooltip = ($el, content, options) => { options.onHide($el._tooltip); } - await $el._tooltip.$tooltip.querySelector('.tooltip__root').animate(options.animation[1], { - duration: immediately ? 0 : options.duration[1], - easing: options.easing[1], - }).finished; + try { + await $el._tooltip.$tooltip.querySelector('.tooltip__root').animate(options.animation[1], { + duration: immediately ? 0 : options.duration[1], + easing: options.easing[1], + }).finished; + } catch {} // eslint-disable-line no-empty - $el._tooltip.$tooltip.remove(); + if ($el._tooltip.$tooltip) { + $el._tooltip.$tooltip.remove(); + } $el._tooltip.isVisible = false; - autoUpdateCleanup(); + $el._tooltip.autoUpdateCleanup(); if (options.onHidden) { options.onHidden($el._tooltip); @@ -350,14 +355,24 @@ export const createTooltip = ($el, content, options) => { }; $el._tooltip.hideOnClickListener = ({ target }) => { - if ( - $el._tooltip.isVisible && - (options.hideOnClick === 'all' || - $el.contains(target) || - (options.hideOnClick !== 'toggle' && !$el._tooltip.$tooltip.contains(target))) - ) { - document.body.removeEventListener('click', $el._tooltip.hideOnClickListener); + if (!$el._tooltip.isVisible) { + return; + } + + if (options.hideOnClick === 'all') { $el._tooltip.hide(); + document.body.removeEventListener('click', $el._tooltip.hideOnClickListener); + } else if (options.hideOnClick === 'toggle') { + if ($el.contains(target)) { + $el._tooltip.hide(); + document.body.removeEventListener('click', $el._tooltip.hideOnClickListener); + } + } else { + // options.hideOnClick === true + if ($el.contains(target) || !$el._tooltip.$tooltip.contains(target)) { + $el._tooltip.hide(); + document.body.removeEventListener('click', $el._tooltip.hideOnClickListener); + } } }; @@ -372,64 +387,58 @@ export const createTooltip = ($el, content, options) => { }; $el._tooltip.mouseLeaveListener = ({ relatedTarget }) => { - if (options.interactive) { - if ($el._tooltip.$tooltip.contains(relatedTarget)) { - $el._tooltip.$tooltip.addEventListener( - 'mouseleave', - ({ relatedTarget }) => { - if (!$el.contains(relatedTarget)) { - $el._tooltip.hide(); - } - }, - { - once: true, - }, - ); - } else { - $el._tooltip.hide(); - } + if (options.interactive && relatedTarget && $el._tooltip.$tooltip?.contains(relatedTarget)) { + $el._tooltip.$tooltip.addEventListener( + 'mouseleave', + ({ relatedTarget }) => { + if (!relatedTarget || !$el.contains(relatedTarget)) { + $el._tooltip.hide(); + } + }, + { once: true }, + ); } else { $el._tooltip.hide(); } }; + $el._tooltip.focusListener = () => { + $el._tooltip.show(); + }; + + $el._tooltip.blurListener = () => { + $el._tooltip.hide(); + }; + const registerListeners = () => { for (const trigger of options.trigger.split(' ')) { switch (trigger) { case 'mouseenter': { $el.addEventListener('mouseenter', $el._tooltip.mouseEnterListener); - listeners.push({ - el: $el, - event: 'mouseenter', - listener: $el._tooltip.mouseEnterListener, - }); + listeners.push({ el: $el, event: 'mouseenter', listener: $el._tooltip.mouseEnterListener }); $el.addEventListener('mouseleave', $el._tooltip.mouseLeaveListener); - listeners.push({ - el: $el, - event: 'mouseleave', - listener: $el._tooltip.mouseLeaveListener, - }); - + listeners.push({ el: $el, event: 'mouseleave', listener: $el._tooltip.mouseLeaveListener }); break; } case 'click': { $el.addEventListener('click', $el._tooltip.clickListener); - listeners.push({ - el: $el, - event: 'click', - listener: $el._tooltip.clickListener, - }); + listeners.push({ el: $el, event: 'click', listener: $el._tooltip.clickListener }); if (!options.interactive) { $el.addEventListener('mouseleave', $el._tooltip.mouseLeaveListener); - listeners.push({ - el: $el, - event: 'mouseleave', - listener: $el._tooltip.mouseLeaveListener, - }); + listeners.push({ el: $el, event: 'mouseleave', 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; } }