diff --git a/packages/tooltip/CHANGELOG.md b/packages/tooltip/CHANGELOG.md new file mode 100644 index 0000000..e155d2d --- /dev/null +++ b/packages/tooltip/CHANGELOG.md @@ -0,0 +1,58 @@ +## v7.0.1 + +- Исправление работы анимации + +## v7.0.0 + +- Изменено значение по умолчанию для параметра `trigger` +- Рефакторинг кода и устранение ошибок + +## v6.0.0 + +- Корректная работа сообытий mouseenter/mouseleave на тач-устройствах +- Предовтращаем лишние показы всплывающей подсказки + +## v5.0.0 + +- Регистрируем сообытия mouseenter/mouseleave на тач-устройствах + +## v4.1.1 + +- Исправление зависаний (в некоторых случаях) всплывающих подсказок + +## v4.1.0 + +- Отключаем события mouseenter/mouseleave для toch-устройств + +## v4.0.0 + +- Переработана логика инициализации и обновления тултипа +- Добавлены новые триггеры: `focus`, `blur` +- Опция `hideOnClick` теперь поддерживает значения: `true`, `'all'`, `'toggle'` +- Исправлены ошибки с позиционированием, анимацией и стилями +- Оптимизировано управление событиями и очистка ресурсов + +## v3.0.1 + +- Исправлена работа событий + +## v3.0.0 + +- Исправлено название опции с `virtialReference` на `virtualReference` + +## v2.1.0 + +- Для опции `appendTo` добавлена возможность указать значение `parent` + +## v2.0.0 + +- К `_tooltip.$tooltip` добавлено свойство `_reference` для доступа к элементу, на котором был вызван `createTooltip` +- Для функций `show/hide` добавлен параметр `immediately` для мнгновенного открытия/закрытия всплывающей подсказки (`_tooltip.hide({ immediately: true })`) +- Добавлена опция `shiftPadding` для добавления отступов от краёв области видимости по осям x/y +- Добавлена опция `animation` для кастомизации анимации при открытии/закрытии всплывающей подсказки (для этого всё внутри `.tooltip` было обёрнуто в `.tooltip__root` и анимация применяется именно к этому блоку) +- Опция `_tooltip._options` переименована в `_tooltip.options` +- Оптимизция кода, исправление ошибок +- Доработки по README.md +- Доработки по .npmignore + +## v1.0.0 diff --git a/packages/tooltip/README.md b/packages/tooltip/README.md new file mode 100644 index 0000000..841ed95 --- /dev/null +++ b/packages/tooltip/README.md @@ -0,0 +1,177 @@ +# tooltip + +## Требования + +- [Floating UI](https://floating-ui.com) версии ^1.0.0 + +## Подключение и настройка + +### HTML + +```html + +``` + +### JS + +#### Минимальные настройки + +```js +import { createTooltip } from '@advdominion/tooltip'; + +createTooltip(document.querySelector('button'), 'Подсказка'); +``` + +#### Все настройки со значениями по умолчанию + +```js +import { createTooltip } from '@advdominion/tooltip'; + +createTooltip(document.querySelector('button'), 'Подсказка', { + 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, + interactive: true, + offset: [0, 8], + placement: 'top', + shiftPadding: [8, 0], + theme: 'light', + trigger: 'mouseenter click', + virtualReference: undefined, + zIndex: '', + // Callback-функции, по умолчанию не заданы + onCreate(instance) {}, + onMount(instance) {}, + onShow(instance) {}, + onShown(instance) {}, + onHide(instance) {}, + onHidden(instance) {}, +}); +``` + +#### hideOnClick + +- `true` (по умолчанию) — всплывающая подсказка скрывается при клике по любому элементу на странице (**кроме** самой всплывающей подсказки). +- `'all'` — всплывающая подсказка скрывается при клике по любому элементу на странице (**включая** саму всплывающую подсказку). +- `'toggle'` — всплывающая подсказка скрывается только при клике по элементу, который её вызывает. + +##### virtualReference + +Настройка используется для кастомного позиционирования, ожидает объект с методом `getBoundingClientRect`. + +Например, позиционирование всплывающей подсказки относительно виртуального элемента размером 100×50, который располагается в левой верхней точке экрана: + +```js +createTooltip(document.querySelector('button'), 'Подсказка', { + virtualReference: { + getBoundingClientRect() { + return { + x: 0, + y: 0, + top: 0, + left: 0, + bottom: 50, + right: 100, + width: 100, + height: 50, + }; + }, + }, +}); +``` + +#### Свойства + +#### $tooltip + +DOM-элемент всплывающей подсказки (имеет свойство `_reference` для доступа к элементу, на котором был вызван `createTooltip`) + +#### options + +Текущие настройки + +#### isVisible + +Видимость всплывающей подсказки - `true`/`false` + +#### Методы + +##### show + +```js +document.querySelector('button')._tooltip.show(); +``` + +##### hide + +```js +document.querySelector('button')._tooltip.hide(); +``` + +##### setContent + +```js +document.querySelector('button')._tooltip.setContent('Новая подсказка'); +``` + +##### updateOptions + +```js +document.querySelector('button')._tooltip.updateOptions({ placement: 'bottom' }); +``` + +Настройки так же можно переназначать, используя data-атрибуты: + +```html + +``` + +##### destroy + +```js +document.querySelector('button')._tooltip.destroy(); +``` + +### Стили + +```scss +$b: '.tooltip'; + +#{$b} { + left: 0; + max-width: calc(100vw - 32px); + position: absolute; + top: 0; + width: max-content; + z-index: 200; + + @media (min-width: 900px) { + max-width: 300px; + } + + &__root { + } + + &__arrow { + #{$b}_theme_light & { + background-color: white; + height: 8px; + width: 8px; + } + } + + &__container { + #{$b}_theme_light & { + background-color: white; + box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.2); + } + } +} +``` diff --git a/packages/tooltip/index.js b/packages/tooltip/index.js new file mode 100644 index 0000000..3577440 --- /dev/null +++ b/packages/tooltip/index.js @@ -0,0 +1,490 @@ +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); + }; + +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 }); + } + } + } + } + }, + templates = { + arrow: () => ` +
+ `, + interactiveHelper: () => ` +
+ `, + }; + +export const createTooltip = ($el, content, options) => { + 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: '', + ...options, + }; + + 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; + } + } + + let rafId; + const listeners = []; + + $el._tooltip = { + options, + isVisible: false, + $tooltip: undefined, + $container: undefined, + $arrow: undefined, + $interactive: undefined, + _showTimeout: undefined, + _hideTimeout: undefined, + _currentAnimation: undefined, + autoUpdateCleanup: () => {}, + updatePosition: async () => {}, + + 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 && $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'); + } + } else { + $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.$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 classIndex = [...$el._tooltip.$tooltip.classList].findIndex((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 (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; + }, + }; + + if (options.onCreate) { + options.onCreate($el._tooltip); + } + + $el._tooltip.show = async ({ immediately } = {}) => { + clearTimeout($el._tooltip._hideTimeout); + + if (!$el._tooltip.$tooltip) { + const { computePosition, offset, flip, shift, arrow } = await import('@floating-ui/dom'); + + $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 = ` +
+ ${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(); + + $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], + 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); + } + } + + const { autoUpdate } = await import('@floating-ui/dom'); + + $el._tooltip._showTimeout = setTimeout( + async () => { + const isMouseEnterTrigger = options.trigger.includes('mouseenter'), + isHovering = $el.matches(':hover'); + + // Проверяем $el._tooltip на сущестование и актуальность показа всплывающей подсказки + 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($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 + + if (options.onShown) { + options.onShown($el._tooltip); + } + }, + immediately ? 0 : options.delay[0], + ); + }; + + $el._tooltip.hide = ({ immediately } = {}) => { + clearTimeout($el._tooltip._showTimeout); + $el._tooltip._hideTimeout = setTimeout( + async () => { + // Проверяем $el._tooltip на сущестование + if (!$el._tooltip || !$el._tooltip.isVisible) { + return; + } + + if (options.onHide) { + options.onHide($el._tooltip); + } + + 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 + + // Ещё одна проверка на сущестование $el._tooltip после await + if (!$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(); + }; + + 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; + } +}; diff --git a/packages/tooltip/package.json b/packages/tooltip/package.json new file mode 100644 index 0000000..1a40ace --- /dev/null +++ b/packages/tooltip/package.json @@ -0,0 +1,17 @@ +{ + "name": "@advdominion/tooltip", + "version": "7.0.1", + "type": "module", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://gitea.optiweb.ru/public/frontend.git" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0" + } +}