From ce0a43e61f7ab933e59c543004056239af9da0a9 Mon Sep 17 00:00:00 2001 From: Parvin Date: Fri, 31 Oct 2025 18:31:57 +0300 Subject: [PATCH] fix all positions --- example/hello-world/withScroll.html | 4 +- example/html-tooltip/index.html | 5 +- src/packages/hint/option.ts | 4 +- src/packages/tooltip/index.ts | 2 +- src/packages/tooltip/tooltip.ts | 8 +- src/packages/tooltip/tooltipPosition.test.ts | 148 ++++++------ src/packages/tooltip/tooltipPosition.ts | 228 +++++++++---------- src/packages/tour/option.ts | 4 +- src/util/getOffset.ts | 110 +++++---- 9 files changed, 259 insertions(+), 254 deletions(-) diff --git a/example/hello-world/withScroll.html b/example/hello-world/withScroll.html index d499c261c..416c9e77f 100644 --- a/example/hello-world/withScroll.html +++ b/example/hello-world/withScroll.html @@ -51,7 +51,7 @@

Works with a Scrollable Elemen
-

Section One

+

Section One

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis mollis augue a neque cursus ac blandit orci faucibus. Phasellus nec metus purus.

Section Two

@@ -69,7 +69,7 @@

Section Four

Section Five

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis mollis augue a neque cursus ac blandit orci faucibus. Phasellus nec metus purus.

-

Section Six

+

Section Six

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis mollis augue a neque cursus ac blandit orci faucibus. Phasellus nec metus purus.

Section Seven

diff --git a/example/html-tooltip/index.html b/example/html-tooltip/index.html index cccc146c8..ea61f6e6c 100644 --- a/example/html-tooltip/index.html +++ b/example/html-tooltip/index.html @@ -94,11 +94,12 @@

Section Six

{ element: '#step4', intro: "Another step with new font!", - position: 'bottom' + position: 'bottom-middle-aligned' }, { element: '#step5', - intro: 'Get it, use it.' + intro: 'Get it, use it.', + position: 'top-middle-aligned' } ] }); diff --git a/src/packages/hint/option.ts b/src/packages/hint/option.ts index e2c0378a9..dac5fd611 100644 --- a/src/packages/hint/option.ts +++ b/src/packages/hint/option.ts @@ -1,4 +1,4 @@ -import { TooltipPosition } from "../../packages/tooltip"; +import { TooltipBasePosition } from "../../packages/tooltip"; import { HintItem, HintPosition } from "./hintItem"; import { Translator, LanguageCode } from "../../i18n/language"; @@ -28,7 +28,7 @@ export interface HintOptions { /* To determine the tooltip position automatically based on the window.width/height */ autoPosition: boolean; /* Precedence of positions, when auto is enabled */ - positionPrecedence: TooltipPosition[]; + positionPrecedence: TooltipBasePosition[]; /* Optional property to determine if content should be rendered as HTML */ tooltipRenderAsHtml?: boolean; /* Optional property to set the language of the hint. diff --git a/src/packages/tooltip/index.ts b/src/packages/tooltip/index.ts index 4d1ed76c7..3b1ce9076 100644 --- a/src/packages/tooltip/index.ts +++ b/src/packages/tooltip/index.ts @@ -1 +1 @@ -export { TooltipPosition } from "./tooltipPosition"; +export { TooltipPosition, TooltipBasePosition } from "./tooltipPosition"; diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index f81f6dbb4..f86a72e49 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -2,7 +2,11 @@ import getOffset, { Offset } from "../../util/getOffset"; import getWindowSize from "../../util/getWindowSize"; import dom, { ChildDom, State } from "../dom"; import { arrowClassName, tooltipClassName } from "../tour/classNames"; -import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; +import { + determineAutoPosition, + TooltipPosition, + TooltipBasePosition, +} from "./tooltipPosition"; const { div } = dom.tags; @@ -313,7 +317,7 @@ export type TooltipProps = { // auto-alignment properties autoPosition: boolean; - positionPrecedence: TooltipPosition[]; + positionPrecedence: TooltipBasePosition[]; onClick?: (e: any) => void; className?: string; diff --git a/src/packages/tooltip/tooltipPosition.test.ts b/src/packages/tooltip/tooltipPosition.test.ts index 392098fd4..ce1a9187b 100644 --- a/src/packages/tooltip/tooltipPosition.test.ts +++ b/src/packages/tooltip/tooltipPosition.test.ts @@ -1,91 +1,89 @@ -import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; +import { determineAutoPosition } from "../tooltip/tooltipPosition"; +import type { Offset } from "../../util/getOffset"; -const positionPrecedence: TooltipPosition[] = [ - "bottom", - "top", - "right", - "left", -]; +const mockViewport = { width: 1000, height: 800 }; -describe("placeTooltip", () => { - test("should automatically place the tooltip position when there is enough space", () => { - // Arrange - // Act - const position = determineAutoPosition( - positionPrecedence, - { - top: 200, - left: 200, - height: 100, - width: 100, - right: 300, - bottom: 300, - absoluteTop: 200, - absoluteLeft: 200, - absoluteRight: 300, - absoluteBottom: 300, - }, - 100, +const makeOffset = ( + left: number, + top: number, + width = 100, + height = 50 +): Offset => ({ + top, + left, + width, + height, + bottom: top + height, + right: left + width, + absoluteTop: top, + absoluteLeft: left, + absoluteBottom: top + height, + absoluteRight: left + width, +}); + +describe("determineAutoPosition", () => { + it("should return 'bottom-left-aligned' when there is enough space below", () => { + const target = makeOffset(400, 200); + const pos = determineAutoPosition( + ["bottom", "top"], + target, + 200, 100, - "top", - { height: 1000, width: 1000 } + "bottom", + mockViewport ); - - // Assert - expect(position).toBe("top-right-aligned"); + expect(pos).toBe("bottom-left-aligned"); }); - test("should use floating tooltips when height/width is limited", () => { - // Arrange - // Act - const position = determineAutoPosition( - positionPrecedence, - { - top: 0, - left: 0, - height: 100, - width: 100, - right: 0, - bottom: 0, - absoluteTop: 0, - absoluteLeft: 0, - absoluteRight: 0, - absoluteBottom: 0, - }, - 100, + it("should return 'top-left-aligned' when there is no space below", () => { + const target = makeOffset(400, 750); + const pos = determineAutoPosition( + ["bottom", "top"], + target, + 200, 100, - "top", - { height: 100, width: 100 } + "bottom", + mockViewport ); - - // Assert - expect(position).toBe("floating"); + expect(pos).toBe("top-left-aligned"); }); - test("should use bottom middle aligned when there is enough vertical space", () => { - // Arrange - // Act - const position = determineAutoPosition( - positionPrecedence, - { - top: 0, - left: 0, - height: 100, - width: 100, - right: 0, - bottom: 0, - absoluteTop: 0, - absoluteLeft: 0, - absoluteRight: 0, - absoluteBottom: 0, - }, + it("should switch to 'left' when right side has no space", () => { + const target = makeOffset(950, 400); + const pos = determineAutoPosition( + ["right", "left", "top", "bottom"], + target, 100, + 50, + "right", + mockViewport + ); + expect(pos).toBe("left"); + }); + + it("should fall back to 'floating' when no space anywhere", () => { + const target = makeOffset(0, 0, 1200, 900); + const pos = determineAutoPosition( + ["top", "bottom", "left", "right"], + target, + 200, 100, - "left", - { height: 500, width: 100 } + "bottom", + mockViewport ); + expect(pos).toBe("floating"); + }); - // Assert - expect(position).toBe("bottom-middle-aligned"); + it("should respect desired alignment if possible", () => { + const target = makeOffset(400, 200); + const pos = determineAutoPosition( + ["bottom", "top"], + target, + 200, + 100, + "bottom-right-aligned", + mockViewport + ); + expect(pos).toBe("bottom-right-aligned"); }); }); diff --git a/src/packages/tooltip/tooltipPosition.ts b/src/packages/tooltip/tooltipPosition.ts index 529becf7f..b5506c7be 100644 --- a/src/packages/tooltip/tooltipPosition.ts +++ b/src/packages/tooltip/tooltipPosition.ts @@ -1,162 +1,144 @@ -import removeEntry from "../../util/removeEntry"; import { Offset } from "../../util/getOffset"; -export type TooltipPosition = +export type TooltipBasePosition = | "floating" | "top" | "bottom" | "left" - | "right" - | "top-right-aligned" + | "right"; +export type TooltipAlignment = | "top-left-aligned" | "top-middle-aligned" - | "bottom-right-aligned" + | "top-right-aligned" | "bottom-left-aligned" - | "bottom-middle-aligned"; + | "bottom-middle-aligned" + | "bottom-right-aligned"; +export type TooltipPosition = TooltipBasePosition | TooltipAlignment; + +/** + * Get the center from a given offset + */ +function getCenterFromOffset(offset: Offset) { + return { + centerX: offset.left + offset.width / 2, + centerY: offset.top + offset.height / 2, + }; +} /** - * auto-determine alignment + * Determines top/bottom alignment */ function determineAutoAlignment( - offsetLeft: number, + centerX: number, tooltipWidth: number, - windowWidth: number, - desiredAlignment: TooltipPosition[] -): TooltipPosition | null { - const halfTooltipWidth = tooltipWidth / 2; - const winWidth = Math.min(windowWidth, window.screen.width); - - // valid left must be at least a tooltipWidth - // away from right side - if (winWidth - offsetLeft < tooltipWidth) { - removeEntry(desiredAlignment, "top-left-aligned"); - removeEntry(desiredAlignment, "bottom-left-aligned"); + viewportWidth: number, + desiredAlignments: TooltipAlignment[], + requestedAlignment?: TooltipAlignment +): TooltipAlignment | null { + const halfWidth = tooltipWidth / 2; + const margin = 8; + + if (requestedAlignment && desiredAlignments.includes(requestedAlignment)) { + return requestedAlignment; } - // valid middle must be at least half - // width away from both sides - if ( - offsetLeft < halfTooltipWidth || - winWidth - offsetLeft < halfTooltipWidth - ) { - removeEntry(desiredAlignment, "top-middle-aligned"); - removeEntry(desiredAlignment, "bottom-middle-aligned"); - } + const spaceLeft = centerX; + const spaceRight = viewportWidth - centerX; - // valid right must be at least a tooltipWidth - // width away from left side - if (offsetLeft < tooltipWidth) { - removeEntry(desiredAlignment, "top-right-aligned"); - removeEntry(desiredAlignment, "bottom-right-aligned"); - } + const canMiddle = + spaceLeft >= halfWidth + margin && spaceRight >= halfWidth + margin; + const canLeft = spaceLeft >= tooltipWidth / 2 + margin; + const canRight = spaceRight >= tooltipWidth / 2 + margin; - if (desiredAlignment.length) { - return desiredAlignment[0]; + for (const a of desiredAlignments) { + if (a.endsWith("middle-aligned") && canMiddle) return a; + if (a.endsWith("left-aligned") && canLeft) return a; + if (a.endsWith("right-aligned") && canRight) return a; } + if (canMiddle) + return desiredAlignments.find((d) => d.endsWith("middle-aligned"))!; + if (canRight) + return desiredAlignments.find((d) => d.endsWith("right-aligned"))!; + if (canLeft) + return desiredAlignments.find((d) => d.endsWith("left-aligned"))!; return null; } /** - * Determines the position of the tooltip based on the position precedence and availability - * of screen space. + * Determines the best tooltip position and alignment */ export function determineAutoPosition( - positionPrecedence: TooltipPosition[], - targetOffset: Offset, + positionPrecedence: TooltipBasePosition[], + target: Offset, tooltipWidth: number, tooltipHeight: number, desiredTooltipPosition: TooltipPosition, - windowSize: { width: number; height: number } + containerOrWindow?: HTMLElement | { width: number; height: number } ): TooltipPosition { - // Take a clone of position precedence. These will be the available - const possiblePositions = positionPrecedence.slice(); - - // Add some padding to the tooltip height and width for better positioning - tooltipHeight = tooltipHeight + 10; - tooltipWidth = tooltipWidth + 20; - - // If we check all the possible areas, and there are no valid places for the tooltip, the element - // must take up most of the screen real estate. Show the tooltip floating in the middle of the screen. - let calculatedPosition: TooltipPosition = "floating"; - - /* - * auto determine position - */ - - // Check for space below - if (targetOffset.absoluteBottom + tooltipHeight > windowSize.height) { - removeEntry(possiblePositions, "bottom"); - } - - // Check for space above - if (targetOffset.absoluteTop - tooltipHeight < 0) { - removeEntry(possiblePositions, "top"); - } - - // Check for space to the right - if (targetOffset.absoluteRight + tooltipWidth > windowSize.width) { - removeEntry(possiblePositions, "right"); + const viewportWidth = + "clientWidth" in (containerOrWindow ?? document.documentElement) + ? (containerOrWindow as HTMLElement).clientWidth + : (containerOrWindow as { width: number; height: number }).width; + const viewportHeight = + "clientHeight" in (containerOrWindow ?? document.documentElement) + ? (containerOrWindow as HTMLElement).clientHeight + : (containerOrWindow as { width: number; height: number }).height; + const tW = tooltipWidth + 12; + const tH = tooltipHeight + 12; + + let possible = positionPrecedence.slice(); + + if (target.bottom + tH > viewportHeight) + possible = possible.filter((p) => p !== "bottom"); + if (target.top - tH < 0) possible = possible.filter((p) => p !== "top"); + if (target.right + tW > viewportWidth) + possible = possible.filter((p) => p !== "right"); + if (target.left - tW < 0) possible = possible.filter((p) => p !== "left"); + + if (!possible.length) return "floating"; + + let baseRequested: TooltipBasePosition | undefined; + let requestedAlignment: TooltipAlignment | undefined; + + if (desiredTooltipPosition.includes("-")) { + const [base, align, side] = desiredTooltipPosition.split("-"); + baseRequested = base as TooltipBasePosition; + if (align && side) + requestedAlignment = `${base}-${align}-${side}` as TooltipAlignment; + } else { + baseRequested = desiredTooltipPosition as TooltipBasePosition; } - // Check for space to the left - if (targetOffset.absoluteLeft - tooltipWidth < 0) { - removeEntry(possiblePositions, "left"); - } + const chosenBase: TooltipBasePosition = + baseRequested && possible.includes(baseRequested) + ? baseRequested + : possible[0]; - // strip alignment from position - if (desiredTooltipPosition) { - // ex: "bottom-right-aligned" - // should return 'bottom' - desiredTooltipPosition = desiredTooltipPosition.split( - "-" - )[0] as TooltipPosition; - } + if (chosenBase === "top" || chosenBase === "bottom") { + const desiredAlignments: TooltipAlignment[] = + chosenBase === "top" + ? ["top-left-aligned", "top-middle-aligned", "top-right-aligned"] + : [ + "bottom-left-aligned", + "bottom-middle-aligned", + "bottom-right-aligned", + ]; - if (possiblePositions.length) { - // Pick the first valid position, in order - calculatedPosition = possiblePositions[0]; - - if (possiblePositions.includes(desiredTooltipPosition)) { - // If the requested position is in the list, choose that - calculatedPosition = desiredTooltipPosition; - } - } + const { centerX } = getCenterFromOffset(target); - // only "top" and "bottom" positions have optional alignments - if (calculatedPosition === "top" || calculatedPosition === "bottom") { - let defaultAlignment: TooltipPosition; - let desiredAlignment: TooltipPosition[] = []; - - if (calculatedPosition === "top") { - // if screen width is too small - // for ANY alignment, middle is - // probably the best for visibility - defaultAlignment = "top-middle-aligned"; - - desiredAlignment = [ - "top-left-aligned", - "top-middle-aligned", - "top-right-aligned", - ]; - } else { - defaultAlignment = "bottom-middle-aligned"; - - desiredAlignment = [ - "bottom-left-aligned", - "bottom-middle-aligned", - "bottom-right-aligned", - ]; - } - - calculatedPosition = + const alignment = determineAutoAlignment( - targetOffset.absoluteLeft, - tooltipWidth, - windowSize.width, - desiredAlignment - ) || defaultAlignment; + centerX, + tW, + viewportWidth, + desiredAlignments, + requestedAlignment + ) ?? + (chosenBase === "top" ? "top-middle-aligned" : "bottom-middle-aligned"); + + return alignment; } - return calculatedPosition; + return chosenBase; } diff --git a/src/packages/tour/option.ts b/src/packages/tour/option.ts index 54ad1bfbf..dec68c5de 100644 --- a/src/packages/tour/option.ts +++ b/src/packages/tour/option.ts @@ -1,4 +1,4 @@ -import { TooltipPosition } from "../../packages/tooltip"; +import { TooltipPosition, TooltipBasePosition } from "../../packages/tooltip"; import { TourStep, ScrollTo } from "./steps"; import { Translator, LanguageCode } from "../../i18n/language"; @@ -58,7 +58,7 @@ export interface TourOptions { /* To determine the tooltip position automatically based on the window.width/height */ autoPosition: boolean; /* Precedence of positions, when auto is enabled */ - positionPrecedence: TooltipPosition[]; + positionPrecedence: TooltipBasePosition[]; /* Disable an interaction with element? */ disableInteraction: boolean; /* To display the "Don't show again" checkbox in the tour */ diff --git a/src/util/getOffset.ts b/src/util/getOffset.ts index 55324400d..90edd88d7 100644 --- a/src/util/getOffset.ts +++ b/src/util/getOffset.ts @@ -15,65 +15,85 @@ export type Offset = { }; /** - * Get an element position on the page relative to another element (or body) including scroll offset - * Thanks to `meouw`: http://stackoverflow.com/a/442474/375966 - * - * @api private - * @returns Element's position info + * Returns all scrollable parents up to document root + */ +function getScrollParents(el: HTMLElement): HTMLElement[] { + const parents: HTMLElement[] = []; + let parent = el.parentElement; + while (parent) { + const style = getComputedStyle(parent); + if ( + /auto|scroll/.test(style.overflow + style.overflowY + style.overflowX) + ) { + parents.push(parent); + } + parent = parent.parentElement; + } + return parents; +} + +/** + * Returns element offset relative to a container or document. + * Handles scrollable containers, fixed/relative positioning and nested scrolls. */ export default function getOffset( element: HTMLElement, relativeEl?: HTMLElement ): Offset { - const body = document.body; const docEl = document.documentElement; - const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop; - const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft; - + const body = document.body; relativeEl = relativeEl || docEl || body; - const x = element.getBoundingClientRect(); - const xr = relativeEl.getBoundingClientRect(); - const relativeElPosition = getPropValue(relativeEl, "position"); + const rect = element.getBoundingClientRect(); + const relRect = relativeEl.getBoundingClientRect(); + const relPos = getPropValue(relativeEl, "position"); - let obj: { top: number; left: number } = { top: 0, left: 0 }; + // Default positions + let top = 0; + let left = 0; + + // Case 1: Fixed elements → use viewport-relative rect directly + if (isFixed(element)) { + top = rect.top; + left = rect.left; + } - if ( - (relativeEl.tagName.toLowerCase() !== "body" && - relativeElPosition === "relative") || - relativeElPosition === "sticky" + // Case 2: Relative or sticky container → position inside the container + else if ( + relativeEl.tagName.toLowerCase() !== "body" && + (relPos === "relative" || relPos === "sticky") ) { - // when the container of our target element is _not_ body and has either "relative" or "sticky" position, we should not - // consider the scroll position but we need to include the relative x/y of the container element - obj = Object.assign(obj, { - top: x.top - xr.top, - left: x.left - xr.left, - }); - } else { - if (isFixed(element)) { - obj = Object.assign(obj, { - top: x.top, - left: x.left, - }); - } else { - obj = Object.assign(obj, { - top: x.top + scrollTop, - left: x.left + scrollLeft, - }); + top = rect.top - relRect.top; + left = rect.left - relRect.left; + + // Add scroll offsets from parents until relativeEl + const scrollParents = getScrollParents(element); + for (const sp of scrollParents) { + top += sp.scrollTop; + left += sp.scrollLeft; + if (sp === relativeEl) break; } } + // Case 3: Normal document flow + else { + const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop; + const scrollLeft = + window.pageXOffset || docEl.scrollLeft || body.scrollLeft; + top = rect.top + scrollTop; + left = rect.left + scrollLeft; + } + return { - ...obj, - ...{ - width: x.width, - height: x.height, - bottom: obj.top + x.height, - right: obj.left + x.width, - absoluteTop: x.top, - absoluteLeft: x.left, - absoluteBottom: x.bottom, - absoluteRight: x.right, - }, + top, + left, + width: rect.width, + height: rect.height, + bottom: top + rect.height, + right: left + rect.width, + absoluteTop: rect.top + window.pageYOffset, + absoluteLeft: rect.left + window.pageXOffset, + absoluteBottom: rect.bottom + window.pageYOffset, + absoluteRight: rect.right + window.pageXOffset, }; }