From b1a57e731f63be669b56efea29ec3d08156458f8 Mon Sep 17 00:00:00 2001 From: Kieran Boyle Date: Tue, 14 Nov 2023 11:57:35 +0900 Subject: [PATCH] refactor(tooltips): use absolute tooltip positioning --- package.json | 2 +- src/action-tooltip.snap.js | 2 +- src/action-tooltip.svelte | 23 ++++++++++-- src/action-tooltip.svelte.d.ts | 6 +++ src/action.js | 23 ++++++------ src/helpers.js | 67 +++++++++++++++++++++++++++++++--- src/tooltip.snap.js | 17 ++++----- src/tooltip.svelte | 46 ++++++++++++++--------- 8 files changed, 135 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 976f491..e3c8fc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@svelte-plugins/tooltips", - "version": "0.1.9", + "version": "1.0.0", "license": "MIT", "description": "A simple tooltip action and component designed for Svelte.", "author": "Kieran Boyle (https://github.com/dysfunc)", diff --git a/src/action-tooltip.snap.js b/src/action-tooltip.snap.js index b0fb8ce..e8ed031 100644 --- a/src/action-tooltip.snap.js +++ b/src/action-tooltip.snap.js @@ -5,7 +5,7 @@ exports[`Components: Tooltip should render the component 1`] = `
Hello World! diff --git a/src/action-tooltip.svelte b/src/action-tooltip.svelte index f92a57a..236eb37 100644 --- a/src/action-tooltip.svelte +++ b/src/action-tooltip.svelte @@ -2,9 +2,12 @@ // @ts-check import { onMount, onDestroy } from 'svelte'; - import { formatVariableKey, getMinWidth, isInViewport } from './helpers'; + import { computeTooltipPosition, formatVariableKey, getMinWidth, isElementInViewport } from './helpers'; import { inverse } from './constants'; + /** @type {HTMLElement | null} */ + export let targetElement = null; + /** @type {'hover' | 'click' | 'prop' | string} */ export let action = 'hover'; @@ -53,6 +56,14 @@ /** @type {boolean} */ let visible = false; + /** @type {{ bottom: number, top: number, right: number, left: number }} */ + let coords = { + bottom: 0, + top: 0, + right: 0, + left: 0 + }; + const delay = animation ? 200 : 0; onMount(() => { @@ -74,11 +85,14 @@ } } - if (autoPosition && !isInViewport(tooltipRef)) { + // @ts-ignore + if (autoPosition && !isElementInViewport(tooltipRef, targetElement, position)) { // @ts-ignore position = inverse[position]; } + coords = computeTooltipPosition(targetElement, tooltipRef, position, coords); + if (animation) { animationEffect = animation; } @@ -103,8 +117,7 @@ class="tooltip animation-{animationEffect} {position} {theme}" class:show={visible} class:arrowless={!arrow} - style="min-width: {minWidth}px; max-width: {maxWidth}px; text-align: {align};" - > + style="bottom: auto; right: auto; left: {coords.left}px; min-width: {minWidth}px; max-width: {maxWidth}px; text-align: {align}; top: {coords.top}px;"> {#if !isComponent} {@html content} {/if} @@ -130,6 +143,7 @@ --tooltip-offset-x: 12px; --tooltip-offset-y: 12px; --tooltip-padding: 12px; + --tooltip-pointer-events: none; --tooltip-white-space-hidden: nowrap; --tooltip-white-space-shown: normal; --tooltip-z-index: 100; @@ -151,6 +165,7 @@ font-weight: var(--tooltip-font-weight); line-height: var(--tooltip-line-height); padding: var(--tooltip-padding); + pointer-events: var(---tooltip-pointer-events); position: absolute; text-align: left; visibility: hidden; diff --git a/src/action-tooltip.svelte.d.ts b/src/action-tooltip.svelte.d.ts index 72333fa..a32fd13 100644 --- a/src/action-tooltip.svelte.d.ts +++ b/src/action-tooltip.svelte.d.ts @@ -61,6 +61,12 @@ export interface ComponentProps { */ style?: undefined; + /** + * The target element to bind the tooltip to. + * @default null + */ + targetElement?: HTMLElement | null, + /** * The theme of the tooltip. * @default '' diff --git a/src/action.js b/src/action.js index b3704ab..53d2a59 100644 --- a/src/action.js +++ b/src/action.js @@ -5,13 +5,14 @@ export const tooltip = (element, props) => { let title = element.getAttribute('title'); let action = props?.action || element.getAttribute('action') || 'hover'; + const config = { + ...props, + targetElement: element + }; + if (title) { element.removeAttribute('title'); - - props = { - content: title, - ...props - } + config.content = title; } const onClick = () => { @@ -26,7 +27,7 @@ export const tooltip = (element, props) => { if (!component) { component = new Tooltip({ target: element, - props + props: config }); } }; @@ -42,6 +43,10 @@ export const tooltip = (element, props) => { if (element !== null) { removeListeners(); + if (config.show) { + onShow(); + } + if (action === 'click') { element.addEventListener('click', onClick); } @@ -63,12 +68,6 @@ export const tooltip = (element, props) => { addListeners(); - element.style.position = 'relative'; - - if (props.show) { - onShow(); - } - return { destroy() { removeListeners(); diff --git a/src/helpers.js b/src/helpers.js index 1c5bdc5..936d70d 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -18,13 +18,68 @@ export const getMinWidth = (element, maxWidth) => { return Math.round(Math.min(maxWidth, contentWidth || maxWidth)); }; -export const isInViewport = (element) => { +export const isElementInViewport = (element, container = null, position) => { const rect = element.getBoundingClientRect(); + const viewportWidth = window.innerWidth || document.documentElement.clientWidth; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - return ( - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - rect.right <= (window.innerWidth || document.documentElement.clientWidth) + let isInsideViewport = ( + rect.bottom > 0 && + rect.top < viewportHeight && + rect.right > 0 && + rect.left < viewportWidth ); + + if (container) { + const containerRect = container.getBoundingClientRect(); + + if (position === 'top' || position === 'bottom') { + isInsideViewport = ( + (containerRect.bottom + containerRect.height) < viewportHeight && + containerRect.top < viewportHeight + ); + } else { + isInsideViewport = ( + (containerRect.right + containerRect.width) < viewportWidth && + containerRect.left < viewportWidth + ); + } + + return isInsideViewport; + } + + return isInsideViewport; +}; + +export const computeTooltipPosition = (containerRef, tooltipRef, position, coords) => { + if (!containerRef || !tooltipRef) { + return coords; + } + + const containerRect = containerRef.getBoundingClientRect(); + const tooltipRect = tooltipRef.getBoundingClientRect(); + + switch (position) { + case 'top': + coords.top = containerRect.top; + coords.left = containerRect.left + (containerRect.width / 2); + break; + case 'bottom': + coords.top = containerRect.top - tooltipRect.height; + coords.left = containerRect.left + (containerRect.width / 2); + break; + case 'left': + coords.left = containerRect.left; + coords.top = containerRect.top + (containerRect.height / 2); + break; + case 'right': + coords.left = containerRect.right - tooltipRect.width; + coords.top = containerRect.top + (containerRect.height / 2); + break; + } + + coords.top += window.scrollY; + coords.left += window.scrollX; + + return coords; }; diff --git a/src/tooltip.snap.js b/src/tooltip.snap.js index 2ef7061..bf5648d 100644 --- a/src/tooltip.snap.js +++ b/src/tooltip.snap.js @@ -5,16 +5,15 @@ exports[`Components: Tooltip should render the component 1`] = `
+ +
- -
- Hello World! - -
- + Hello World! + +
diff --git a/src/tooltip.svelte b/src/tooltip.svelte index 6f103e5..39fa065 100644 --- a/src/tooltip.svelte +++ b/src/tooltip.svelte @@ -2,7 +2,7 @@ // @ts-check import { onMount, onDestroy } from 'svelte'; - import { formatVariableKey, getMinWidth, isInViewport } from './helpers'; + import { computeTooltipPosition, formatVariableKey, getMinWidth, isElementInViewport } from './helpers'; import { inverse } from './constants'; /** @type {'hover' | 'click' | 'prop' | string} */ @@ -62,6 +62,14 @@ /** @type {boolean} */ let visible = false; + /** @type {{ bottom: number, top: number, right: number, left: number }} */ + let coords = { + bottom: 0, + top: 0, + right: 0, + left: 0 + }; + const onClick = () => { if (visible) { onHide(); @@ -73,11 +81,14 @@ const onShow = () => { const delay = animation ? 200 : 0; - if (autoPosition && !isInViewport(tooltipRef)) { + // @ts-ignore + if (autoPosition && !isElementInViewport(containerRef, tooltipRef, position)) { // @ts-ignore position = inverse[position]; } + coords = computeTooltipPosition(containerRef, tooltipRef, position, coords); + if (animation) { animationEffect = animation; } @@ -122,6 +133,8 @@ onMount(() => { addListeners(); + computeTooltipPosition(); + if (tooltipRef !== null) { if (isComponent && !component) { // @ts-ignore @@ -162,18 +175,17 @@ {#if content} -
- {#if !isComponent} - {@html content} - {/if} -
-
+ +
+ {#if !isComponent} + {@html content} + {/if} +
{:else} {/if} @@ -197,6 +209,7 @@ --tooltip-offset-x: 0px; --tooltip-offset-y: 0px; --tooltip-padding: 12px; + --tooltip-pointer-events: none; --tooltip-white-space-hidden: nowrap; --tooltip-white-space-shown: normal; --tooltip-z-index: 100; @@ -206,10 +219,6 @@ * Tooltip Styling *--------------------------*/ - .tooltip-container { - position: relative; - } - .tooltip { background-color: var(--tooltip-background-color); box-shadow: var(--tooltip-box-shadow); @@ -222,6 +231,7 @@ font-weight: var(--tooltip-font-weight); line-height: var(--tooltip-line-height); padding: var(--tooltip-padding); + pointer-events: var(--tooltip-pointer-events); position: absolute; text-align: left; visibility: hidden;