-
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,
};
}