3
0

Новая версия tooltip

This commit is contained in:
2026-09-24 16:50:42 +04:00
parent c5a92339ec
commit f76c615286
3 changed files with 251 additions and 126 deletions

View File

@@ -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 ## v7.0.1
- Исправление работы анимации - Исправление работы анимации

View File

@@ -33,7 +33,7 @@ createTooltip(document.querySelector('button'), 'Подсказка', {
[{ opacity: 1 }, { opacity: 0 }], [{ opacity: 1 }, { opacity: 0 }],
], ],
appendTo: document.body, appendTo: document.body,
arrow: true, arrow: { active: true, centered: false, padding: undefined },
delay: [0, 0], delay: [0, 0],
duration: [0, 0], duration: [0, 0],
easing: ['linear', 'linear'], easing: ['linear', 'linear'],
@@ -62,6 +62,26 @@ createTooltip(document.querySelector('button'), 'Подсказка', {
- `'all'` — всплывающая подсказка скрывается при клике по любому элементу на странице (**включая** саму всплывающую подсказку). - `'all'` — всплывающая подсказка скрывается при клике по любому элементу на странице (**включая** саму всплывающую подсказку).
- `'toggle'` — всплывающая подсказка скрывается только при клике по элементу, который её вызывает. - `'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
<button type="button" data-tooltip-arrow='{"centered":true}'>Кнопка</button>
```
##### virtualReference ##### virtualReference
Настройка используется для кастомного позиционирования, ожидает объект с методом `getBoundingClientRect`. Настройка используется для кастомного позиционирования, ожидает объект с методом `getBoundingClientRect`.

View File

@@ -1,20 +1,34 @@
const roundByDPR = (value) => { const roundByDPR = (value) => {
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
return Math.round(value * dpr) / dpr; return Math.round(value * dpr) / dpr;
}, };
attributeToOption = (attribute) => {
attribute = attribute.replace('tooltip', '');
return attribute.charAt(0).toLowerCase() + attribute.slice(1);
};
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; let visibilityListenerRegistered = false;
/*
Подсказки, оставшиеся открытыми при уходе со страницы, скрываются,
а их отложенный показ отменяется
*/
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.hidden) { if (document.hidden) {
// Скрываем все активные всплывающие подсказки
for (const $tooltip of document.querySelectorAll('.tooltip')) { for (const $tooltip of document.querySelectorAll('.tooltip')) {
const ref = $tooltip._reference; const ref = $tooltip._reference;
if (ref?._tooltip) { if (ref?._tooltip) {
// Очищаем таймеры появления при скрытии страницы
clearTimeout(ref._tooltip._showTimeout); clearTimeout(ref._tooltip._showTimeout);
if (ref._tooltip.isVisible) { if (ref._tooltip.isVisible) {
ref._tooltip.hide({ immediately: true }); ref._tooltip.hide({ immediately: true });
@@ -22,18 +36,20 @@ const handleVisibilityChange = () => {
} }
} }
} }
}, };
templates = {
const templates = {
arrow: () => ` arrow: () => `
<div class="tooltip__arrow" style="pointer-events: none; position: absolute; transform: rotate(45deg);"></div> <div class="tooltip__arrow" style="pointer-events: none; position: absolute; transform: rotate(45deg);"></div>
`, `,
interactiveHelper: () => ` interactiveHelper: () => `
<div class="tooltip__interactive-helper" style="position: absolute; z-index: -1;"></div> <div class="tooltip__interactive-helper" style="position: absolute; z-index: -1;"></div>
`, `,
}; };
export const createTooltip = ($el, content, options) => { export const createTooltip = ($el, initialContent, initialOptions) => {
options = { let content = initialContent;
const options = {
animation: [ animation: [
[{ opacity: 0 }, { opacity: 1 }], [{ opacity: 0 }, { opacity: 1 }],
[{ opacity: 1 }, { opacity: 0 }], [{ opacity: 1 }, { opacity: 0 }],
@@ -52,7 +68,7 @@ export const createTooltip = ($el, content, options) => {
trigger: 'mouseenter click', trigger: 'mouseenter click',
virtualReference: undefined, virtualReference: undefined,
zIndex: '', zIndex: '',
...options, ...initialOptions,
}; };
for (const [key, value] of Object.entries($el.dataset)) { 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; let rafId;
const listeners = []; 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 = { $el._tooltip = {
options, options,
isVisible: false, isVisible: false,
@@ -78,8 +164,18 @@ export const createTooltip = ($el, content, options) => {
_showTimeout: undefined, _showTimeout: undefined,
_hideTimeout: undefined, _hideTimeout: undefined,
_currentAnimation: undefined, _currentAnimation: undefined,
autoUpdateCleanup: () => {}, // Счётчик поколений show/hide: отменяет устаревшие асинхронные операции
updatePosition: async () => {}, _generation: 0,
/*
Заглушки до первого show(): настоящие функции создаются в show().
Нужны, чтобы вызовы из updateOptions/destroy не падали с ошибкой
*/
autoUpdateCleanup: () => {
// Переопределяется в show() после первого показа
},
updatePosition: () => {
// Переопределяется в show() после первого показа
},
setContent(updatedContent) { setContent(updatedContent) {
if (updatedContent !== undefined) { if (updatedContent !== undefined) {
@@ -100,17 +196,18 @@ export const createTooltip = ($el, content, options) => {
options[name] = value; options[name] = value;
} }
if (updatedOptions.arrow !== undefined && $el._tooltip.$tooltip) { if (updatedOptions.arrow !== undefined) {
if (options.arrow) { /*
if (!$el._tooltip.$arrow) { Опция могла прийти булевым значением — приводим к единому виду.
$el._tooltip.$tooltip Нормализуем всегда, даже если подсказка ещё не создана
.querySelector('.tooltip__root') */
.insertAdjacentHTML('afterbegin', templates.arrow()); options.arrow = normalizeArrow(options.arrow);
$el._tooltip.$arrow = $el._tooltip.$tooltip.querySelector('.tooltip__arrow'); if ($el._tooltip.$tooltip) {
} if (options.arrow.active) {
mountArrow();
} else { } else {
$el._tooltip.$arrow?.remove(); unmountArrow();
$el._tooltip.$arrow = undefined; }
} }
} }
@@ -137,17 +234,13 @@ export const createTooltip = ($el, content, options) => {
} }
if (updatedOptions.theme !== undefined && $el._tooltip.$tooltip) { 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_'), className.startsWith('tooltip_theme_'),
); );
if (classIndex === -1) { if (themeClass) {
$el._tooltip.$tooltip.classList.add(`tooltip_theme_${options.theme}`); $el._tooltip.$tooltip.classList.remove(themeClass);
} else {
$el._tooltip.$tooltip.classList.replace(
$el._tooltip.$tooltip.classList[classIndex],
`tooltip_theme_${options.theme}`,
);
} }
$el._tooltip.$tooltip.classList.add(`tooltip_theme_${options.theme}`);
} }
if (updatedOptions.zIndex !== undefined && $el._tooltip.$tooltip) { if (updatedOptions.zIndex !== undefined && $el._tooltip.$tooltip) {
@@ -171,7 +264,7 @@ export const createTooltip = ($el, content, options) => {
$el._tooltip.$tooltip?.remove(); $el._tooltip.$tooltip?.remove();
// Вызываем autoUpdateCleanup только если всплывающая подсказка была видна (иначе вызывать её не имеет смысла) // autoUpdateCleanup нужен только для показанной подсказки
if ($el._tooltip.isVisible) { if ($el._tooltip.isVisible) {
$el._tooltip.autoUpdateCleanup(); $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 } = {}) => { $el._tooltip.show = async ({ immediately } = {}) => {
clearTimeout($el._tooltip._hideTimeout); clearTimeout($el._tooltip._hideTimeout);
clearTimeout($el._tooltip._showTimeout);
$el._tooltip._generation += 1;
const generation = $el._tooltip._generation;
if (!$el._tooltip.$tooltip) { if (!$el._tooltip.$tooltip) {
const { computePosition, offset, flip, shift, arrow } = await import('@floating-ui/dom'); 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 = document.createElement('div');
$el._tooltip.$tooltip._reference = $el; $el._tooltip.$tooltip._reference = $el;
$el._tooltip.$tooltip.classList.add('tooltip', `tooltip_theme_${options.theme}`); $el._tooltip.$tooltip.classList.add('tooltip', `tooltip_theme_${options.theme}`);
@@ -205,16 +302,15 @@ export const createTooltip = ($el, content, options) => {
$el._tooltip.$tooltip.innerHTML = ` $el._tooltip.$tooltip.innerHTML = `
<div class="tooltip__root"> <div class="tooltip__root">
${options.interactive ? templates.interactiveHelper() : ''} ${options.interactive ? templates.interactiveHelper() : ''}
${options.arrow ? templates.arrow() : ''}
<div class="tooltip__container"></div> <div class="tooltip__container"></div>
</div> </div>
`; `;
$el._tooltip.$container = $el._tooltip.$tooltip.querySelector('.tooltip__container'); $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.$interactive = $el._tooltip.$tooltip.querySelector('.tooltip__interactive-helper');
$el._tooltip.setContent(); $el._tooltip.setContent();
mountArrow();
$el._tooltip.updatePosition = () => { $el._tooltip.updatePosition = () => {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
@@ -235,7 +331,18 @@ export const createTooltip = ($el, content, options) => {
left: options.shiftPadding[0], left: options.shiftPadding[0],
}, },
}), }),
arrow({ element: $el._tooltip.$arrow }), arrow({
element: $el._tooltip.$arrow,
/*
Отступ стрелки от краёв подсказки. По умолчанию считаем из размера стрелки:
квадрат со стороной size, повёрнутый на 45°, выступает за свою рамку
на (√2 − 1) / 2 · size — это не даёт его углам вылезать за границы.
Можно задать вручную через arrow.padding
*/
padding:
options.arrow.padding ??
Math.ceil(((Math.SQRT2 - 1) / 2) * ($el._tooltip.$arrow?.offsetWidth ?? 0)),
}),
], ],
}, },
); );
@@ -244,8 +351,8 @@ export const createTooltip = ($el, content, options) => {
transform: `translate(${roundByDPR(x)}px, ${roundByDPR(y)}px)`, transform: `translate(${roundByDPR(x)}px, ${roundByDPR(y)}px)`,
}); });
const side = placement.split('-')[0], const side = placement.split('-')[0];
staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[side]; const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[side];
$el._tooltip.$tooltip.classList.remove( $el._tooltip.$tooltip.classList.remove(
'tooltip_side_top', 'tooltip_side_top',
@@ -264,11 +371,27 @@ export const createTooltip = ($el, content, options) => {
} }
if ($el._tooltip.$arrow && middlewareData.arrow) { if ($el._tooltip.$arrow && middlewareData.arrow) {
const { x, y } = middlewareData.arrow; const { $arrow } = $el._tooltip;
Object.assign($el._tooltip.$arrow.style, { let { x, y } = middlewareData.arrow;
/*
centered: фиксируем стрелку по центру подсказки, чтобы она не смещалась
вслед за опорным элементом при прокрутке (например, когда подсказка
прижата к краю экрана). Ось центрирования зависит от стороны:
для left/right — вертикаль, для top/bottom — горизонталь
*/
if (options.arrow.centered) {
if (side === 'left' || side === 'right') {
y = ($el._tooltip.$tooltip.offsetHeight - $arrow.offsetHeight) / 2;
} else {
x = ($el._tooltip.$tooltip.offsetWidth - $arrow.offsetWidth) / 2;
}
}
Object.assign($arrow.style, {
left: x === undefined ? '' : `${x}px`, left: x === undefined ? '' : `${x}px`,
top: y === undefined ? '' : `${y}px`, top: y === undefined ? '' : `${y}px`,
[staticSide]: `-${$el._tooltip.$arrow.offsetWidth / 2}px`, [staticSide]: `-${$arrow.offsetWidth / 2}px`,
}); });
} }
}); });
@@ -281,12 +404,20 @@ export const createTooltip = ($el, content, options) => {
const { autoUpdate } = await import('@floating-ui/dom'); const { autoUpdate } = await import('@floating-ui/dom');
if (generation !== $el._tooltip._generation) {
return;
}
$el._tooltip._showTimeout = setTimeout( $el._tooltip._showTimeout = setTimeout(
async () => { async () => {
const isMouseEnterTrigger = options.trigger.includes('mouseenter'), if (generation !== $el._tooltip._generation) {
isHovering = $el.matches(':hover'); return;
}
// Проверяем $el._tooltip на сущестование и актуальность показа всплывающей подсказки const isMouseEnterTrigger = options.trigger.includes('mouseenter');
const isHovering = $el.matches(':hover');
// За время задержки состояние могло измениться
if ( if (
!$el._tooltip || !$el._tooltip ||
$el._tooltip.isVisible || $el._tooltip.isVisible ||
@@ -298,31 +429,25 @@ export const createTooltip = ($el, content, options) => {
(options.appendTo === 'parent' ? $el.parentElement : options.appendTo).append($el._tooltip.$tooltip); (options.appendTo === 'parent' ? $el.parentElement : options.appendTo).append($el._tooltip.$tooltip);
$el._tooltip.isVisible = true; $el._tooltip.isVisible = true;
$el._tooltip.autoUpdateCleanup = autoUpdate($el, $el._tooltip.$tooltip, $el._tooltip.updatePosition); $el._tooltip.autoUpdateCleanup = autoUpdate(
options.virtualReference ?? $el,
$el._tooltip.$tooltip,
$el._tooltip.updatePosition,
);
if (options.hideOnClick && (options.trigger.includes('click') || options.trigger.includes('manual'))) { if (options.hideOnClick && (options.trigger.includes('click') || options.trigger.includes('manual'))) {
document.body.addEventListener('click', $el._tooltip.hideOnClickListener); document.body.addEventListener('click', $el._tooltip.hideOnClickListener);
listeners.push({
el: document.body,
event: 'click',
listener: $el._tooltip.hideOnClickListener,
});
} }
if (options.onShow) { if (options.onShow) {
options.onShow($el._tooltip); options.onShow($el._tooltip);
} }
try { await animateRoot(0, immediately);
const animation = $el._tooltip.$tooltip
.querySelector('.tooltip__root') if (generation !== $el._tooltip._generation) {
.animate(options.animation[0], { return;
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) { if (options.onShown) {
options.onShown($el._tooltip); options.onShown($el._tooltip);
@@ -334,9 +459,17 @@ export const createTooltip = ($el, content, options) => {
$el._tooltip.hide = ({ immediately } = {}) => { $el._tooltip.hide = ({ immediately } = {}) => {
clearTimeout($el._tooltip._showTimeout); clearTimeout($el._tooltip._showTimeout);
$el._tooltip._generation += 1;
const generation = $el._tooltip._generation;
$el._tooltip._hideTimeout = setTimeout( $el._tooltip._hideTimeout = setTimeout(
async () => { async () => {
// Проверяем $el._tooltip на сущестование if (generation !== $el._tooltip._generation) {
return;
}
// Подсказку могли удалить или скрыть за время задержки
if (!$el._tooltip || !$el._tooltip.isVisible) { if (!$el._tooltip || !$el._tooltip.isVisible) {
return; return;
} }
@@ -347,19 +480,10 @@ export const createTooltip = ($el, content, options) => {
document.body.removeEventListener('click', $el._tooltip.hideOnClickListener); document.body.removeEventListener('click', $el._tooltip.hideOnClickListener);
try { await animateRoot(1, immediately);
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) { if (generation !== $el._tooltip._generation || !$el._tooltip) {
return; return;
} }
@@ -446,45 +570,15 @@ export const createTooltip = ($el, content, options) => {
$el._tooltip.hide(); $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(); registerListeners();
if (!visibilityListenerRegistered) { if (!visibilityListenerRegistered) {
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
visibilityListenerRegistered = true; visibilityListenerRegistered = true;
} }
// Вызываем onCreate в конце, когда все методы и слушатели уже инициализированы
if (options.onCreate) {
options.onCreate($el._tooltip);
}
}; };