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 = `
`;
+
+ $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;
}
}