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