diff --git a/packages/tooltip/CHANGELOG.md b/packages/tooltip/CHANGELOG.md index 3aab18e..cb3dbee 100644 --- a/packages/tooltip/CHANGELOG.md +++ b/packages/tooltip/CHANGELOG.md @@ -1,3 +1,11 @@ +## v7.1.1 + +- Позиция при прокрутке обновляется без принудительного reflow: все измерения геометрии выполняются до записи стилей +- Массив middleware собирается один раз и переиспользуется между кадрами (сбрасывается при смене `offset`, `shiftPadding`, `arrow`) +- Классы и стили, зависящие от стороны размещения, применяются только при её смене +- `@floating-ui/dom` подгружается одним динамическим импортом вместо двух +- В пример базовых стилей добавлен `will-change: transform` — выносит подсказку на отдельный слой и убирает рывки при прокрутке + ## v7.1.0 - Опция `arrow` теперь принимает объект: `active`, `centered`, `padding` (булево значение по-прежнему поддерживается) diff --git a/packages/tooltip/README.md b/packages/tooltip/README.md index 714d08b..6e5d1f0 100644 --- a/packages/tooltip/README.md +++ b/packages/tooltip/README.md @@ -170,6 +170,7 @@ $b: '.tooltip'; position: absolute; top: 0; width: max-content; + will-change: transform; z-index: 200; @media (min-width: 900px) { diff --git a/packages/tooltip/index.js b/packages/tooltip/index.js index 72a1d54..96239a2 100644 --- a/packages/tooltip/index.js +++ b/packages/tooltip/index.js @@ -145,6 +145,8 @@ export const createTooltip = ($el, initialContent, initialOptions) => { 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'); + // Со стрелкой набор middleware другой + $el._tooltip._middleware = undefined; } }; @@ -152,6 +154,7 @@ export const createTooltip = ($el, initialContent, initialOptions) => { const unmountArrow = () => { $el._tooltip.$arrow?.remove(); $el._tooltip.$arrow = undefined; + $el._tooltip._middleware = undefined; }; $el._tooltip = { @@ -161,6 +164,9 @@ export const createTooltip = ($el, initialContent, initialOptions) => { $container: undefined, $arrow: undefined, $interactive: undefined, + // Кэши позиционирования + _side: undefined, + _middleware: undefined, _showTimeout: undefined, _hideTimeout: undefined, _currentAnimation: undefined, @@ -196,6 +202,21 @@ export const createTooltip = ($el, initialContent, initialOptions) => { options[name] = value; } + if ( + updatedOptions.offset !== undefined || + updatedOptions.shiftPadding !== undefined || + updatedOptions.arrow !== undefined + ) { + $el._tooltip._middleware = undefined; + } + if ( + updatedOptions.placement !== undefined || + updatedOptions.offset !== undefined || + updatedOptions.interactive !== undefined + ) { + $el._tooltip._side = undefined; + } + if (updatedOptions.arrow !== undefined) { /* Опция могла прийти булевым значением — приводим к единому виду. @@ -287,13 +308,13 @@ export const createTooltip = ($el, initialContent, initialOptions) => { $el._tooltip._generation += 1; const generation = $el._tooltip._generation; + const { computePosition, autoUpdate, offset, flip, shift, arrow } = await import('@floating-ui/dom'); + + if (generation !== $el._tooltip._generation) { + return; + } + if (!$el._tooltip.$tooltip) { - 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._reference = $el; $el._tooltip.$tooltip.classList.add('tooltip', `tooltip_theme_${options.theme}`); @@ -312,38 +333,50 @@ export const createTooltip = ($el, initialContent, initialOptions) => { $el._tooltip.setContent(); mountArrow(); + const buildMiddleware = () => { + const arrowSize = $el._tooltip.$arrow?.offsetWidth ?? 0; + return [ + 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, + /* + Отступ стрелки от краёв подсказки. По умолчанию считаем из размера стрелки: + квадрат со стороной size, повёрнутый на 45°, выступает за свою рамку + на (√2 − 1) / 2 · size — это не даёт его углам вылезать за границы. + Можно задать вручную через arrow.padding + */ + padding: options.arrow.padding ?? Math.ceil(((Math.SQRT2 - 1) / 2) * arrowSize), + }), + ]; + }; + $el._tooltip.updatePosition = () => { cancelAnimationFrame(rafId); rafId = requestAnimationFrame(async () => { + /* + Все чтения геометрии делаем до первой записи стилей, иначе браузер + вынужден делать принудительный reflow на каждом кадре прокрутки + */ + const tooltipWidth = $el._tooltip.$tooltip.offsetWidth; + const tooltipHeight = $el._tooltip.$tooltip.offsetHeight; + const arrowWidth = $el._tooltip.$arrow?.offsetWidth ?? 0; + const arrowHeight = $el._tooltip.$arrow?.offsetHeight ?? 0; + 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, - /* - Отступ стрелки от краёв подсказки. По умолчанию считаем из размера стрелки: - квадрат со стороной size, повёрнутый на 45°, выступает за свою рамку - на (√2 − 1) / 2 · size — это не даёт его углам вылезать за границы. - Можно задать вручную через arrow.padding - */ - padding: - options.arrow.padding ?? - Math.ceil(((Math.SQRT2 - 1) / 2) * ($el._tooltip.$arrow?.offsetWidth ?? 0)), - }), - ], + middleware: ($el._tooltip._middleware ??= buildMiddleware()), }, ); @@ -354,20 +387,25 @@ export const createTooltip = ($el, initialContent, initialOptions) => { 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 (side !== $el._tooltip._side) { + $el._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`, - }); + $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) { @@ -382,16 +420,16 @@ export const createTooltip = ($el, initialContent, initialOptions) => { */ if (options.arrow.centered) { if (side === 'left' || side === 'right') { - y = ($el._tooltip.$tooltip.offsetHeight - $arrow.offsetHeight) / 2; + y = (tooltipHeight - arrowHeight) / 2; } else { - x = ($el._tooltip.$tooltip.offsetWidth - $arrow.offsetWidth) / 2; + x = (tooltipWidth - arrowWidth) / 2; } } Object.assign($arrow.style, { left: x === undefined ? '' : `${x}px`, top: y === undefined ? '' : `${y}px`, - [staticSide]: `-${$arrow.offsetWidth / 2}px`, + [staticSide]: `-${arrowWidth / 2}px`, }); } }); @@ -402,12 +440,6 @@ export const createTooltip = ($el, initialContent, initialOptions) => { } } - const { autoUpdate } = await import('@floating-ui/dom'); - - if (generation !== $el._tooltip._generation) { - return; - } - $el._tooltip._showTimeout = setTimeout( async () => { if (generation !== $el._tooltip._generation) {