mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-10-30 19:27:40 +08:00
Rewrite popover arrow positioning
Position the arrow with a continuous inline offset that points at the element's center, replacing the three fixed start/center/end CSS slots so it stays accurate when the element and popover differ in size. - Aim at the center of the element/popover overlap, and fall back to the configured align only when the element spans the whole popover edge - Flip the arrow to the perpendicular edge when the element scrolls clear of the popover, so it keeps pointing at the element instead of a corner - Move positioning into src/position.ts with pure, unit-tested helpers - Default popover side is now bottom instead of left - Drop the internal "over" value from the public Side type
This commit is contained in:
+2
-37
@@ -202,43 +202,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Left/Start + Right/Start */
|
||||
.driver-popover-arrow-side-left.driver-popover-arrow-align-start,
|
||||
.driver-popover-arrow-side-right.driver-popover-arrow-align-start {
|
||||
top: 15px;
|
||||
}
|
||||
|
||||
/* Top/Start + Bottom/Start */
|
||||
.driver-popover-arrow-side-top.driver-popover-arrow-align-start,
|
||||
.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start {
|
||||
left: 15px;
|
||||
}
|
||||
|
||||
/* End/Left + End/Right */
|
||||
.driver-popover-arrow-align-end.driver-popover-arrow-side-left,
|
||||
.driver-popover-arrow-align-end.driver-popover-arrow-side-right {
|
||||
bottom: 15px;
|
||||
}
|
||||
|
||||
/* Top/End + Bottom/End */
|
||||
.driver-popover-arrow-side-top.driver-popover-arrow-align-end,
|
||||
.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end {
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
/* Left/Center + Right/Center */
|
||||
.driver-popover-arrow-side-left.driver-popover-arrow-align-center,
|
||||
.driver-popover-arrow-side-right.driver-popover-arrow-align-center {
|
||||
top: 50%;
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
/* Top/Center + Bottom/Center */
|
||||
.driver-popover-arrow-side-top.driver-popover-arrow-align-center,
|
||||
.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center {
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
}
|
||||
/* The offset of the arrow along its edge is set inline by the library so the
|
||||
tip points at the center of the highlighted element. */
|
||||
|
||||
/* No arrow */
|
||||
.driver-popover-arrow-none {
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { DriveStep } from "./driver";
|
||||
import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay";
|
||||
import { getConfig, getCurrentDriver } from "./config";
|
||||
import { hidePopover, renderPopover, repositionPopover } from "./popover";
|
||||
import { hidePopover, renderPopover } from "./popover";
|
||||
import { repositionPopover } from "./position";
|
||||
import { bringInView } from "./utils";
|
||||
import { getState, setState } from "./state";
|
||||
|
||||
|
||||
+2
-396
@@ -2,10 +2,11 @@ import { Config, DriverHook, getConfig, getCurrentDriver } from "./config";
|
||||
import { Driver, DriveStep } from "./driver";
|
||||
import { emit } from "./emitter";
|
||||
import { onDriverClick } from "./events";
|
||||
import { repositionPopover } from "./position";
|
||||
import { getState, setState, State } from "./state";
|
||||
import { bringInView, getFocusableElements } from "./utils";
|
||||
|
||||
export type Side = "top" | "right" | "bottom" | "left" | "over";
|
||||
export type Side = "top" | "right" | "bottom" | "left";
|
||||
export type Alignment = "start" | "center" | "end";
|
||||
export type AllowedButtons = "next" | "previous" | "close";
|
||||
|
||||
@@ -254,401 +255,6 @@ export function renderPopover(element: Element, step: DriveStep) {
|
||||
}
|
||||
}
|
||||
|
||||
type PopoverDimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
realWidth: number;
|
||||
realHeight: number;
|
||||
};
|
||||
|
||||
function getPopoverDimensions(): PopoverDimensions | undefined {
|
||||
const popover = getState("popover");
|
||||
if (!popover?.wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundingClientRect = popover.wrapper.getBoundingClientRect();
|
||||
|
||||
const stagePadding = getConfig("stagePadding") || 0;
|
||||
const popoverOffset = getConfig("popoverOffset") || 0;
|
||||
|
||||
return {
|
||||
width: boundingClientRect.width + stagePadding + popoverOffset,
|
||||
height: boundingClientRect.height + stagePadding + popoverOffset,
|
||||
|
||||
realWidth: boundingClientRect.width,
|
||||
realHeight: boundingClientRect.height,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateTopForLeftRight(
|
||||
alignment: Alignment,
|
||||
config: {
|
||||
elementDimensions: DOMRect;
|
||||
popoverDimensions: PopoverDimensions;
|
||||
popoverPadding: number;
|
||||
popoverArrowDimensions: { width: number; height: number };
|
||||
}
|
||||
): number {
|
||||
const { elementDimensions, popoverDimensions, popoverPadding, popoverArrowDimensions } = config;
|
||||
|
||||
if (alignment === "start") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.top - popoverPadding,
|
||||
window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
if (alignment === "end") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.top - popoverDimensions?.realHeight + elementDimensions.height + popoverPadding,
|
||||
window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
if (alignment === "center") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.top + elementDimensions.height / 2 - popoverDimensions?.realHeight / 2,
|
||||
window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate the left placement for top and bottom sides
|
||||
function calculateLeftForTopBottom(
|
||||
alignment: Alignment,
|
||||
config: {
|
||||
elementDimensions: DOMRect;
|
||||
popoverDimensions: PopoverDimensions;
|
||||
popoverPadding: number;
|
||||
popoverArrowDimensions: { width: number; height: number };
|
||||
}
|
||||
): number {
|
||||
const { elementDimensions, popoverDimensions, popoverPadding, popoverArrowDimensions } = config;
|
||||
|
||||
if (alignment === "start") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.left - popoverPadding,
|
||||
window.innerWidth - popoverDimensions!.realWidth - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
if (alignment === "end") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.left - popoverDimensions?.realWidth + elementDimensions.width + popoverPadding,
|
||||
window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
if (alignment === "center") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.left + elementDimensions.width / 2 - popoverDimensions?.realWidth / 2,
|
||||
window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function repositionPopover(element: Element, step: DriveStep) {
|
||||
const popover = getState("popover");
|
||||
if (!popover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { align = "start", side = "left" } = step?.popover || {};
|
||||
|
||||
// Configure the popover positioning
|
||||
const requiredAlignment: Alignment = align;
|
||||
const requiredSide: Side = element.id === "driver-dummy-element" ? "over" : side;
|
||||
const popoverPadding = getConfig("stagePadding") || 0;
|
||||
|
||||
const popoverDimensions = getPopoverDimensions()!;
|
||||
const popoverArrowDimensions = popover.arrow.getBoundingClientRect();
|
||||
const elementDimensions = element.getBoundingClientRect();
|
||||
|
||||
const topValue = elementDimensions.top - popoverDimensions!.height;
|
||||
let isTopOptimal = topValue >= 0;
|
||||
|
||||
const bottomValue = window.innerHeight - (elementDimensions.bottom + popoverDimensions!.height);
|
||||
let isBottomOptimal = bottomValue >= 0;
|
||||
|
||||
const leftValue = elementDimensions.left - popoverDimensions!.width;
|
||||
let isLeftOptimal = leftValue >= 0;
|
||||
|
||||
const rightValue = window.innerWidth - (elementDimensions.right + popoverDimensions!.width);
|
||||
let isRightOptimal = rightValue >= 0;
|
||||
|
||||
const noneOptimal = !isTopOptimal && !isBottomOptimal && !isLeftOptimal && !isRightOptimal;
|
||||
let popoverRenderedSide: Side = requiredSide;
|
||||
|
||||
if (requiredSide === "top" && isTopOptimal) {
|
||||
isRightOptimal = isLeftOptimal = isBottomOptimal = false;
|
||||
} else if (requiredSide === "bottom" && isBottomOptimal) {
|
||||
isRightOptimal = isLeftOptimal = isTopOptimal = false;
|
||||
} else if (requiredSide === "left" && isLeftOptimal) {
|
||||
isRightOptimal = isTopOptimal = isBottomOptimal = false;
|
||||
} else if (requiredSide === "right" && isRightOptimal) {
|
||||
isLeftOptimal = isTopOptimal = isBottomOptimal = false;
|
||||
}
|
||||
|
||||
if (requiredSide === "over") {
|
||||
const leftToSet = window.innerWidth / 2 - popoverDimensions!.realWidth / 2;
|
||||
const topToSet = window.innerHeight / 2 - popoverDimensions!.realHeight / 2;
|
||||
|
||||
popover.wrapper.style.left = `${leftToSet}px`;
|
||||
popover.wrapper.style.right = `auto`;
|
||||
popover.wrapper.style.top = `${topToSet}px`;
|
||||
popover.wrapper.style.bottom = `auto`;
|
||||
} else if (noneOptimal) {
|
||||
const leftValue = window.innerWidth / 2 - popoverDimensions?.realWidth! / 2;
|
||||
const bottomValue = 10;
|
||||
|
||||
popover.wrapper.style.left = `${leftValue}px`;
|
||||
popover.wrapper.style.right = `auto`;
|
||||
popover.wrapper.style.bottom = `${bottomValue}px`;
|
||||
popover.wrapper.style.top = `auto`;
|
||||
} else if (isLeftOptimal) {
|
||||
const leftToSet = Math.min(
|
||||
leftValue,
|
||||
window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
|
||||
);
|
||||
|
||||
const topToSet = calculateTopForLeftRight(requiredAlignment, {
|
||||
elementDimensions,
|
||||
popoverDimensions,
|
||||
popoverPadding,
|
||||
popoverArrowDimensions,
|
||||
});
|
||||
|
||||
popover.wrapper.style.left = `${leftToSet}px`;
|
||||
popover.wrapper.style.top = `${topToSet}px`;
|
||||
popover.wrapper.style.bottom = `auto`;
|
||||
popover.wrapper.style.right = "auto";
|
||||
|
||||
popoverRenderedSide = "left";
|
||||
} else if (isRightOptimal) {
|
||||
const rightToSet = Math.min(
|
||||
rightValue,
|
||||
window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
|
||||
);
|
||||
const topToSet = calculateTopForLeftRight(requiredAlignment, {
|
||||
elementDimensions,
|
||||
popoverDimensions,
|
||||
popoverPadding,
|
||||
popoverArrowDimensions,
|
||||
});
|
||||
|
||||
popover.wrapper.style.right = `${rightToSet}px`;
|
||||
popover.wrapper.style.top = `${topToSet}px`;
|
||||
popover.wrapper.style.bottom = `auto`;
|
||||
popover.wrapper.style.left = "auto";
|
||||
|
||||
popoverRenderedSide = "right";
|
||||
} else if (isTopOptimal) {
|
||||
const topToSet = Math.min(
|
||||
topValue,
|
||||
window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width
|
||||
);
|
||||
let leftToSet = calculateLeftForTopBottom(requiredAlignment, {
|
||||
elementDimensions,
|
||||
popoverDimensions,
|
||||
popoverPadding,
|
||||
popoverArrowDimensions,
|
||||
});
|
||||
|
||||
popover.wrapper.style.top = `${topToSet}px`;
|
||||
popover.wrapper.style.left = `${leftToSet}px`;
|
||||
popover.wrapper.style.bottom = `auto`;
|
||||
popover.wrapper.style.right = "auto";
|
||||
|
||||
popoverRenderedSide = "top";
|
||||
} else if (isBottomOptimal) {
|
||||
const bottomToSet = Math.min(
|
||||
bottomValue,
|
||||
window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
|
||||
);
|
||||
|
||||
let leftToSet = calculateLeftForTopBottom(requiredAlignment, {
|
||||
elementDimensions,
|
||||
popoverDimensions,
|
||||
popoverPadding,
|
||||
popoverArrowDimensions,
|
||||
});
|
||||
|
||||
popover.wrapper.style.left = `${leftToSet}px`;
|
||||
popover.wrapper.style.bottom = `${bottomToSet}px`;
|
||||
popover.wrapper.style.top = `auto`;
|
||||
popover.wrapper.style.right = "auto";
|
||||
|
||||
popoverRenderedSide = "bottom";
|
||||
}
|
||||
|
||||
// Popover stays on the screen if the element scrolls out of the visible area.
|
||||
// Render the arrow again to make sure it's in the correct position
|
||||
// e.g. if element scrolled out of the screen to the top, the arrow should be rendered
|
||||
// pointing to the top. If the element scrolled out of the screen to the bottom,
|
||||
// the arrow should be rendered pointing to the bottom.
|
||||
if (!noneOptimal) {
|
||||
renderPopoverArrow(requiredAlignment, popoverRenderedSide, element);
|
||||
} else {
|
||||
popover.arrow.classList.add("driver-popover-arrow-none");
|
||||
}
|
||||
|
||||
[...popover.wrapper.classList]
|
||||
.filter(className => className.startsWith("driver-popover-side-") || className.startsWith("driver-popover-align-"))
|
||||
.forEach(className => popover.wrapper.classList.remove(className));
|
||||
|
||||
popover.wrapper.classList.add(`driver-popover-side-${popoverRenderedSide}`);
|
||||
popover.wrapper.classList.add(`driver-popover-align-${requiredAlignment}`);
|
||||
}
|
||||
|
||||
function renderPopoverArrow(alignment: Alignment, side: Side, element: Element) {
|
||||
const popover = getState("popover");
|
||||
if (!popover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementDimensions = element.getBoundingClientRect();
|
||||
const popoverDimensions = getPopoverDimensions()!;
|
||||
const popoverArrow = popover.arrow;
|
||||
|
||||
const popoverWidth = popoverDimensions.width;
|
||||
const windowWidth = window.innerWidth;
|
||||
const elementWidth = elementDimensions.width;
|
||||
const elementLeft = elementDimensions.left;
|
||||
|
||||
const popoverHeight = popoverDimensions.height;
|
||||
const windowHeight = window.innerHeight;
|
||||
const elementTop = elementDimensions.top;
|
||||
const elementHeight = elementDimensions.height;
|
||||
|
||||
// Remove all arrow classes
|
||||
popoverArrow.className = "driver-popover-arrow";
|
||||
|
||||
let arrowSide = side;
|
||||
let arrowAlignment = alignment;
|
||||
|
||||
if (side === "top") {
|
||||
if (elementLeft + elementWidth <= 0) {
|
||||
arrowSide = "right";
|
||||
arrowAlignment = "end";
|
||||
} else if (elementLeft + elementWidth - popoverWidth <= 0) {
|
||||
arrowSide = "top";
|
||||
arrowAlignment = "start";
|
||||
}
|
||||
if (elementLeft >= windowWidth) {
|
||||
arrowSide = "left";
|
||||
arrowAlignment = "end";
|
||||
} else if (elementLeft + popoverWidth >= windowWidth) {
|
||||
arrowSide = "top";
|
||||
arrowAlignment = "end";
|
||||
}
|
||||
} else if (side === "bottom") {
|
||||
if (elementLeft + elementWidth <= 0) {
|
||||
arrowSide = "right";
|
||||
arrowAlignment = "start";
|
||||
} else if (elementLeft + elementWidth - popoverWidth <= 0) {
|
||||
arrowSide = "bottom";
|
||||
arrowAlignment = "start";
|
||||
}
|
||||
if (elementLeft >= windowWidth) {
|
||||
arrowSide = "left";
|
||||
arrowAlignment = "start";
|
||||
} else if (elementLeft + popoverWidth >= windowWidth) {
|
||||
arrowSide = "bottom";
|
||||
arrowAlignment = "end";
|
||||
}
|
||||
} else if (side === "left") {
|
||||
if (elementTop + elementHeight <= 0) {
|
||||
arrowSide = "bottom";
|
||||
arrowAlignment = "end";
|
||||
} else if (elementTop + elementHeight - popoverHeight <= 0) {
|
||||
arrowSide = "left";
|
||||
arrowAlignment = "start";
|
||||
}
|
||||
|
||||
if (elementTop >= windowHeight) {
|
||||
arrowSide = "top";
|
||||
arrowAlignment = "end";
|
||||
} else if (elementTop + popoverHeight >= windowHeight) {
|
||||
arrowSide = "left";
|
||||
arrowAlignment = "end";
|
||||
}
|
||||
} else if (side === "right") {
|
||||
if (elementTop + elementHeight <= 0) {
|
||||
arrowSide = "bottom";
|
||||
arrowAlignment = "start";
|
||||
} else if (elementTop + elementHeight - popoverHeight <= 0) {
|
||||
arrowSide = "right";
|
||||
arrowAlignment = "start";
|
||||
}
|
||||
|
||||
if (elementTop >= windowHeight) {
|
||||
arrowSide = "top";
|
||||
arrowAlignment = "start";
|
||||
} else if (elementTop + popoverHeight >= windowHeight) {
|
||||
arrowSide = "right";
|
||||
arrowAlignment = "end";
|
||||
}
|
||||
}
|
||||
|
||||
if (!arrowSide) {
|
||||
popoverArrow.classList.add("driver-popover-arrow-none");
|
||||
} else {
|
||||
popoverArrow.classList.add(`driver-popover-arrow-side-${arrowSide}`);
|
||||
popoverArrow.classList.add(`driver-popover-arrow-align-${arrowAlignment}`);
|
||||
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const arrowRect = popoverArrow.getBoundingClientRect();
|
||||
const stagePadding = getConfig("stagePadding") || 0;
|
||||
|
||||
const isElementPartiallyInViewPort =
|
||||
elementRect.left - stagePadding < window.innerWidth &&
|
||||
elementRect.right + stagePadding > 0 &&
|
||||
elementRect.top - stagePadding < window.innerHeight &&
|
||||
elementRect.bottom + stagePadding > 0;
|
||||
|
||||
if (side === "bottom" && isElementPartiallyInViewPort) {
|
||||
const isArrowWithinElementBounds =
|
||||
arrowRect.x > elementRect.x && arrowRect.x + arrowRect.width < elementRect.x + elementRect.width;
|
||||
|
||||
if (!isArrowWithinElementBounds) {
|
||||
popoverArrow.classList.remove(`driver-popover-arrow-align-${arrowAlignment}`);
|
||||
popoverArrow.classList.add(`driver-popover-arrow-none`);
|
||||
// reduce the top position by the padding
|
||||
popover.wrapper.style.transform = `translateY(-${stagePadding / 2}px)`;
|
||||
} else {
|
||||
popover.wrapper.style.transform = `translateY(0)`;
|
||||
}
|
||||
|
||||
// TODO: implement this using either of the following:
|
||||
// 1 - move the arrow to the center of the element and point it towards the popover. This way, scrolling or resizing the window will move the arrow to the correct position.
|
||||
// 2 - calculate the center position of the element and point the arrow towards the popover. However, we will have to re-calculate the position of the arrow when the window is resized or scrolled.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPopover(): PopoverDOM {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.classList.add("driver-popover");
|
||||
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
import { getConfig } from "./config";
|
||||
import { DriveStep } from "./driver";
|
||||
import type { Alignment, Side } from "./popover";
|
||||
import { getState } from "./state";
|
||||
|
||||
// Internal placement. Beyond the public sides it includes "over", used when
|
||||
// there is no target element (or the popover is intentionally centered like a
|
||||
// modal). "over" is not part of the public `side` option.
|
||||
type Placement = Side | "over";
|
||||
|
||||
type PopoverDimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
realWidth: number;
|
||||
realHeight: number;
|
||||
};
|
||||
|
||||
function getPopoverDimensions(): PopoverDimensions | undefined {
|
||||
const popover = getState("popover");
|
||||
if (!popover?.wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundingClientRect = popover.wrapper.getBoundingClientRect();
|
||||
|
||||
const stagePadding = getConfig("stagePadding") || 0;
|
||||
const popoverOffset = getConfig("popoverOffset") || 0;
|
||||
|
||||
return {
|
||||
width: boundingClientRect.width + stagePadding + popoverOffset,
|
||||
height: boundingClientRect.height + stagePadding + popoverOffset,
|
||||
|
||||
realWidth: boundingClientRect.width,
|
||||
realHeight: boundingClientRect.height,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateTopForLeftRight(
|
||||
alignment: Alignment,
|
||||
config: {
|
||||
elementDimensions: DOMRect;
|
||||
popoverDimensions: PopoverDimensions;
|
||||
popoverPadding: number;
|
||||
popoverArrowDimensions: { width: number; height: number };
|
||||
}
|
||||
): number {
|
||||
const { elementDimensions, popoverDimensions, popoverPadding, popoverArrowDimensions } = config;
|
||||
|
||||
if (alignment === "start") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.top - popoverPadding,
|
||||
window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
if (alignment === "end") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.top - popoverDimensions?.realHeight + elementDimensions.height + popoverPadding,
|
||||
window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
if (alignment === "center") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.top + elementDimensions.height / 2 - popoverDimensions?.realHeight / 2,
|
||||
window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate the left placement for top and bottom sides
|
||||
function calculateLeftForTopBottom(
|
||||
alignment: Alignment,
|
||||
config: {
|
||||
elementDimensions: DOMRect;
|
||||
popoverDimensions: PopoverDimensions;
|
||||
popoverPadding: number;
|
||||
popoverArrowDimensions: { width: number; height: number };
|
||||
}
|
||||
): number {
|
||||
const { elementDimensions, popoverDimensions, popoverPadding, popoverArrowDimensions } = config;
|
||||
|
||||
if (alignment === "start") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.left - popoverPadding,
|
||||
window.innerWidth - popoverDimensions!.realWidth - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
if (alignment === "end") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.left - popoverDimensions?.realWidth + elementDimensions.width + popoverPadding,
|
||||
window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
if (alignment === "center") {
|
||||
return Math.max(
|
||||
Math.min(
|
||||
elementDimensions.left + elementDimensions.width / 2 - popoverDimensions?.realWidth / 2,
|
||||
window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
|
||||
),
|
||||
popoverArrowDimensions.width
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function repositionPopover(element: Element, step: DriveStep) {
|
||||
const popover = getState("popover");
|
||||
if (!popover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { align = "start", side = "bottom" } = step?.popover || {};
|
||||
|
||||
// Configure the popover positioning
|
||||
const requiredAlignment: Alignment = align;
|
||||
const requiredSide: Placement = element.id === "driver-dummy-element" ? "over" : side;
|
||||
const popoverPadding = getConfig("stagePadding") || 0;
|
||||
|
||||
const popoverDimensions = getPopoverDimensions()!;
|
||||
const popoverArrowDimensions = popover.arrow.getBoundingClientRect();
|
||||
const elementDimensions = element.getBoundingClientRect();
|
||||
|
||||
const topValue = elementDimensions.top - popoverDimensions!.height;
|
||||
let isTopOptimal = topValue >= 0;
|
||||
|
||||
const bottomValue = window.innerHeight - (elementDimensions.bottom + popoverDimensions!.height);
|
||||
let isBottomOptimal = bottomValue >= 0;
|
||||
|
||||
const leftValue = elementDimensions.left - popoverDimensions!.width;
|
||||
let isLeftOptimal = leftValue >= 0;
|
||||
|
||||
const rightValue = window.innerWidth - (elementDimensions.right + popoverDimensions!.width);
|
||||
let isRightOptimal = rightValue >= 0;
|
||||
|
||||
const noneOptimal = !isTopOptimal && !isBottomOptimal && !isLeftOptimal && !isRightOptimal;
|
||||
let popoverRenderedSide: Placement = requiredSide;
|
||||
|
||||
if (requiredSide === "top" && isTopOptimal) {
|
||||
isRightOptimal = isLeftOptimal = isBottomOptimal = false;
|
||||
} else if (requiredSide === "bottom" && isBottomOptimal) {
|
||||
isRightOptimal = isLeftOptimal = isTopOptimal = false;
|
||||
} else if (requiredSide === "left" && isLeftOptimal) {
|
||||
isRightOptimal = isTopOptimal = isBottomOptimal = false;
|
||||
} else if (requiredSide === "right" && isRightOptimal) {
|
||||
isLeftOptimal = isTopOptimal = isBottomOptimal = false;
|
||||
}
|
||||
|
||||
if (requiredSide === "over") {
|
||||
const leftToSet = window.innerWidth / 2 - popoverDimensions!.realWidth / 2;
|
||||
const topToSet = window.innerHeight / 2 - popoverDimensions!.realHeight / 2;
|
||||
|
||||
popover.wrapper.style.left = `${leftToSet}px`;
|
||||
popover.wrapper.style.right = `auto`;
|
||||
popover.wrapper.style.top = `${topToSet}px`;
|
||||
popover.wrapper.style.bottom = `auto`;
|
||||
} else if (noneOptimal) {
|
||||
const leftValue = window.innerWidth / 2 - popoverDimensions?.realWidth! / 2;
|
||||
const bottomValue = 10;
|
||||
|
||||
popover.wrapper.style.left = `${leftValue}px`;
|
||||
popover.wrapper.style.right = `auto`;
|
||||
popover.wrapper.style.bottom = `${bottomValue}px`;
|
||||
popover.wrapper.style.top = `auto`;
|
||||
} else if (isLeftOptimal) {
|
||||
const leftToSet = Math.min(
|
||||
leftValue,
|
||||
window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
|
||||
);
|
||||
|
||||
const topToSet = calculateTopForLeftRight(requiredAlignment, {
|
||||
elementDimensions,
|
||||
popoverDimensions,
|
||||
popoverPadding,
|
||||
popoverArrowDimensions,
|
||||
});
|
||||
|
||||
popover.wrapper.style.left = `${leftToSet}px`;
|
||||
popover.wrapper.style.top = `${topToSet}px`;
|
||||
popover.wrapper.style.bottom = `auto`;
|
||||
popover.wrapper.style.right = "auto";
|
||||
|
||||
popoverRenderedSide = "left";
|
||||
} else if (isRightOptimal) {
|
||||
const rightToSet = Math.min(
|
||||
rightValue,
|
||||
window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
|
||||
);
|
||||
const topToSet = calculateTopForLeftRight(requiredAlignment, {
|
||||
elementDimensions,
|
||||
popoverDimensions,
|
||||
popoverPadding,
|
||||
popoverArrowDimensions,
|
||||
});
|
||||
|
||||
popover.wrapper.style.right = `${rightToSet}px`;
|
||||
popover.wrapper.style.top = `${topToSet}px`;
|
||||
popover.wrapper.style.bottom = `auto`;
|
||||
popover.wrapper.style.left = "auto";
|
||||
|
||||
popoverRenderedSide = "right";
|
||||
} else if (isTopOptimal) {
|
||||
const topToSet = Math.min(
|
||||
topValue,
|
||||
window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width
|
||||
);
|
||||
let leftToSet = calculateLeftForTopBottom(requiredAlignment, {
|
||||
elementDimensions,
|
||||
popoverDimensions,
|
||||
popoverPadding,
|
||||
popoverArrowDimensions,
|
||||
});
|
||||
|
||||
popover.wrapper.style.top = `${topToSet}px`;
|
||||
popover.wrapper.style.left = `${leftToSet}px`;
|
||||
popover.wrapper.style.bottom = `auto`;
|
||||
popover.wrapper.style.right = "auto";
|
||||
|
||||
popoverRenderedSide = "top";
|
||||
} else if (isBottomOptimal) {
|
||||
const bottomToSet = Math.min(
|
||||
bottomValue,
|
||||
window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
|
||||
);
|
||||
|
||||
let leftToSet = calculateLeftForTopBottom(requiredAlignment, {
|
||||
elementDimensions,
|
||||
popoverDimensions,
|
||||
popoverPadding,
|
||||
popoverArrowDimensions,
|
||||
});
|
||||
|
||||
popover.wrapper.style.left = `${leftToSet}px`;
|
||||
popover.wrapper.style.bottom = `${bottomToSet}px`;
|
||||
popover.wrapper.style.top = `auto`;
|
||||
popover.wrapper.style.right = "auto";
|
||||
|
||||
popoverRenderedSide = "bottom";
|
||||
}
|
||||
|
||||
// Point the arrow at the element. When no side is optimal the popover is
|
||||
// detached from the element (centered/pinned to the bottom of the screen),
|
||||
// so there is nothing sensible to point at and the arrow is hidden.
|
||||
renderPopoverArrow(noneOptimal ? "over" : popoverRenderedSide, requiredAlignment, element);
|
||||
|
||||
[...popover.wrapper.classList]
|
||||
.filter(className => className.startsWith("driver-popover-side-") || className.startsWith("driver-popover-align-"))
|
||||
.forEach(className => popover.wrapper.classList.remove(className));
|
||||
|
||||
popover.wrapper.classList.add(`driver-popover-side-${popoverRenderedSide}`);
|
||||
popover.wrapper.classList.add(`driver-popover-align-${requiredAlignment}`);
|
||||
}
|
||||
|
||||
// The arrow is a CSS triangle built from 5px borders, so its bounding box is
|
||||
// 10x10 regardless of where it is rendered.
|
||||
const ARROW_SIZE = 10;
|
||||
|
||||
// Keep the arrow this far from the popover's corners so it never collides with
|
||||
// the rounded corners.
|
||||
const ARROW_CORNER_INSET = 15;
|
||||
|
||||
// Finds the point the arrow should aim at along one axis, returned relative to
|
||||
// the popover's leading edge (`popoverStart`). All inputs are viewport coords.
|
||||
//
|
||||
// When the element fully spans the popover edge the arrow could point anywhere
|
||||
// along the popover and still land on the element, so there is slack to spend:
|
||||
// we resolve it with the configured alignment (start/center/end), which makes
|
||||
// the arrow echo the popover's own alignment.
|
||||
//
|
||||
// Otherwise we aim at the center of the region where the element and popover
|
||||
// overlap. This tracks the element rather than its geometric center (which can
|
||||
// sit far off-screen for an element much taller/wider than the popover), and
|
||||
// when the two don't overlap at all the clamped endpoints collapse onto the
|
||||
// nearest edge, pointing the arrow that way.
|
||||
export function calculateArrowTarget(
|
||||
elementStart: number,
|
||||
elementEnd: number,
|
||||
popoverStart: number,
|
||||
popoverEnd: number,
|
||||
alignment: Alignment
|
||||
): number {
|
||||
const popoverLength = popoverEnd - popoverStart;
|
||||
const fullySpansPopover = elementStart <= popoverStart && elementEnd >= popoverEnd;
|
||||
|
||||
if (fullySpansPopover) {
|
||||
if (alignment === "start") {
|
||||
return ARROW_CORNER_INSET + ARROW_SIZE / 2;
|
||||
}
|
||||
|
||||
if (alignment === "end") {
|
||||
return popoverLength - ARROW_CORNER_INSET - ARROW_SIZE / 2;
|
||||
}
|
||||
|
||||
return popoverLength / 2;
|
||||
}
|
||||
|
||||
const overlapStart = Math.min(Math.max(elementStart, popoverStart), popoverEnd);
|
||||
const overlapEnd = Math.min(Math.max(elementEnd, popoverStart), popoverEnd);
|
||||
|
||||
return (overlapStart + overlapEnd) / 2 - popoverStart;
|
||||
}
|
||||
|
||||
// Turns the target point into the inline offset for the arrow box, clamped so
|
||||
// the whole arrow stays attached to the popover body and clear of its rounded
|
||||
// corners. `popoverLength` is the popover's size along the arrow's edge.
|
||||
export function calculateArrowOffset(targetCenter: number, popoverLength: number): number {
|
||||
const minOffset = ARROW_CORNER_INSET;
|
||||
const maxOffset = popoverLength - ARROW_CORNER_INSET - ARROW_SIZE;
|
||||
|
||||
// The popover is too small to honor the corner insets; center the arrow.
|
||||
if (maxOffset < minOffset) {
|
||||
return Math.max(0, (popoverLength - ARROW_SIZE) / 2);
|
||||
}
|
||||
|
||||
const offset = targetCenter - ARROW_SIZE / 2;
|
||||
return Math.min(Math.max(offset, minOffset), maxOffset);
|
||||
}
|
||||
|
||||
// Decides which popover edge the arrow sits on. Normally this is the rendered
|
||||
// side, but when the element scrolls clear of the popover along that side's axis
|
||||
// (e.g. a left-placed popover whose element has scrolled above it), the arrow
|
||||
// moves to the perpendicular edge so it keeps pointing at the element instead of
|
||||
// sliding into a corner and pointing into empty space.
|
||||
//
|
||||
// Note the inverted naming of the arrow classes: "bottom" sits on the popover's
|
||||
// top edge pointing up, "top" sits on the bottom edge pointing down, "right"
|
||||
// sits on the left edge pointing left, and "left" sits on the right edge
|
||||
// pointing right.
|
||||
export function resolveArrowSide(
|
||||
side: Side,
|
||||
element: { top: number; bottom: number; left: number; right: number },
|
||||
popover: { top: number; bottom: number; left: number; right: number }
|
||||
): Side {
|
||||
if (side === "left" || side === "right") {
|
||||
const overlapsVertically = element.bottom > popover.top && element.top < popover.bottom;
|
||||
if (overlapsVertically) {
|
||||
return side;
|
||||
}
|
||||
|
||||
return element.bottom <= popover.top ? "bottom" : "top";
|
||||
}
|
||||
|
||||
const overlapsHorizontally = element.right > popover.left && element.left < popover.right;
|
||||
if (overlapsHorizontally) {
|
||||
return side;
|
||||
}
|
||||
|
||||
return element.right <= popover.left ? "right" : "left";
|
||||
}
|
||||
|
||||
function renderPopoverArrow(side: Placement, alignment: Alignment, element: Element) {
|
||||
const popover = getState("popover");
|
||||
if (!popover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const popoverArrow = popover.arrow;
|
||||
|
||||
// Reset everything a previous render may have set, both classes and the
|
||||
// inline offset, so we start from a clean slate.
|
||||
popoverArrow.className = "driver-popover-arrow";
|
||||
popoverArrow.style.top = "";
|
||||
popoverArrow.style.right = "";
|
||||
popoverArrow.style.bottom = "";
|
||||
popoverArrow.style.left = "";
|
||||
|
||||
// The popover is rendered over the element (e.g. a free floating popover) or
|
||||
// detached from it, so there is nothing to point at.
|
||||
if (side === "over") {
|
||||
popoverArrow.classList.add("driver-popover-arrow-none");
|
||||
return;
|
||||
}
|
||||
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const popoverRect = popover.wrapper.getBoundingClientRect();
|
||||
|
||||
const arrowSide = resolveArrowSide(side, elementRect, popoverRect);
|
||||
|
||||
// The side class controls which popover edge the arrow sits on (and the
|
||||
// direction it points). The offset along that edge is set inline below.
|
||||
popoverArrow.classList.add(`driver-popover-arrow-side-${arrowSide}`);
|
||||
|
||||
// Arrow sides left/right sit on vertical edges (positioned by `top`); top and
|
||||
// bottom sit on horizontal edges (positioned by `left`).
|
||||
if (arrowSide === "left" || arrowSide === "right") {
|
||||
const target = calculateArrowTarget(
|
||||
elementRect.top,
|
||||
elementRect.bottom,
|
||||
popoverRect.top,
|
||||
popoverRect.bottom,
|
||||
alignment
|
||||
);
|
||||
popoverArrow.style.top = `${calculateArrowOffset(target, popoverRect.height)}px`;
|
||||
} else {
|
||||
const target = calculateArrowTarget(
|
||||
elementRect.left,
|
||||
elementRect.right,
|
||||
popoverRect.left,
|
||||
popoverRect.right,
|
||||
alignment
|
||||
);
|
||||
popoverArrow.style.left = `${calculateArrowOffset(target, popoverRect.width)}px`;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Alignment, Side } from "../src/popover";
|
||||
import { createDriver, navButton, nextFrame, popoverEl, progressText, SAMPLE_STEPS, useDriverHarness } from "./utils";
|
||||
|
||||
useDriverHarness();
|
||||
@@ -21,6 +22,28 @@ describe("popover rendering", () => {
|
||||
expect(navButton("close")?.style.display).toBe("block");
|
||||
});
|
||||
|
||||
it("honours a step-level showButtons override across a tour", () => {
|
||||
const d = createDriver({
|
||||
animate: false,
|
||||
steps: [
|
||||
{ element: "#intro", popover: { title: "Step 1", showButtons: ["next"] } },
|
||||
{ element: "#card-1", popover: { title: "Step 2" } },
|
||||
{ element: ".feature-list", popover: { title: "Step 3" } },
|
||||
],
|
||||
});
|
||||
d.drive();
|
||||
|
||||
expect(navButton("next")?.style.display).toBe("block");
|
||||
expect(navButton("prev")?.style.display).toBe("none");
|
||||
|
||||
d.moveTo(1);
|
||||
expect(navButton("next")?.style.display).toBe("block");
|
||||
expect(navButton("prev")?.style.display).toBe("block");
|
||||
|
||||
d.moveTo(2);
|
||||
expect(navButton("prev")?.style.display).toBe("block");
|
||||
});
|
||||
|
||||
it("honours an explicit showButtons list", () => {
|
||||
const d = createDriver({ animate: false });
|
||||
d.highlight({ element: "#intro", popover: { title: "Intro", showButtons: ["close"] } });
|
||||
@@ -88,6 +111,13 @@ describe("popover rendering", () => {
|
||||
expect(popoverEl()?.classList.contains("my-custom-popover")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to the bottom side", () => {
|
||||
const d = createDriver({ animate: false });
|
||||
d.highlight({ element: "#intro", popover: { title: "Intro" } });
|
||||
|
||||
expect(popoverEl()?.classList.contains("driver-popover-side-bottom")).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes the rendered side and alignment as classes", () => {
|
||||
const d = createDriver({ animate: false });
|
||||
d.highlight({ element: "#intro", popover: { title: "Intro", side: "bottom", align: "center" } });
|
||||
@@ -141,3 +171,166 @@ describe("popover rendering", () => {
|
||||
expect(document.querySelector(".driver-popover .my-extra-btn")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("popover arrow", () => {
|
||||
const rect = (over: Partial<DOMRect>): DOMRect =>
|
||||
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
|
||||
|
||||
const arrowEl = () => document.querySelector<HTMLElement>(".driver-popover-arrow")!;
|
||||
|
||||
// jsdom doesn't lay anything out, so we feed both the element and the popover
|
||||
// their boxes, then refresh to run the positioning against those boxes.
|
||||
function positionArrow(opts: {
|
||||
side: Side;
|
||||
align?: Alignment;
|
||||
element: Partial<DOMRect>;
|
||||
popover: Partial<DOMRect>;
|
||||
}): Promise<void> {
|
||||
const el = document.querySelector<HTMLElement>("#intro")!;
|
||||
el.getBoundingClientRect = () => rect(opts.element);
|
||||
// jsdom has no scrollIntoView; off-screen element boxes would otherwise throw.
|
||||
el.scrollIntoView = () => {};
|
||||
|
||||
const d = createDriver({ animate: false });
|
||||
d.highlight({ element: "#intro", popover: { title: "Intro", side: opts.side, align: opts.align ?? "start" } });
|
||||
|
||||
const wrapper = popoverEl() as HTMLElement;
|
||||
wrapper.getBoundingClientRect = () => rect(opts.popover);
|
||||
|
||||
d.refresh();
|
||||
return nextFrame();
|
||||
}
|
||||
|
||||
it("points the arrow at the element's vertical center for a left/right placement", async () => {
|
||||
// Element sits to the right with its vertical center at y=230; popover box
|
||||
// spans top=100..400. The arrow's tip should land at 230 - 100 = 130, so the
|
||||
// 10px arrow box starts at 125.
|
||||
await positionArrow({
|
||||
side: "left",
|
||||
element: { top: 200, left: 800, right: 900, bottom: 260, width: 100, height: 60 },
|
||||
popover: { top: 100, left: 600, right: 800, bottom: 400, width: 200, height: 300 },
|
||||
});
|
||||
|
||||
expect(arrowEl().classList.contains("driver-popover-arrow-side-left")).toBe(true);
|
||||
expect(arrowEl().style.top).toBe("125px");
|
||||
expect(arrowEl().style.left).toBe("");
|
||||
});
|
||||
|
||||
it("points the arrow at the element's horizontal center for a top/bottom placement", async () => {
|
||||
// Element center x=400; popover box spans left=250..550. Tip at 400 - 250 =
|
||||
// 150, arrow box starts at 145.
|
||||
await positionArrow({
|
||||
side: "bottom",
|
||||
element: { top: 50, left: 300, right: 500, bottom: 80, width: 200, height: 30 },
|
||||
popover: { top: 100, left: 250, right: 550, bottom: 250, width: 300, height: 150 },
|
||||
});
|
||||
|
||||
expect(arrowEl().classList.contains("driver-popover-arrow-side-bottom")).toBe(true);
|
||||
expect(arrowEl().style.left).toBe("145px");
|
||||
expect(arrowEl().style.top).toBe("");
|
||||
});
|
||||
|
||||
it("clamps the arrow to the popover's bounds when the element center is past its edge", async () => {
|
||||
// Element still overlaps the popover vertically (380..400) but its center is
|
||||
// well below, so the arrow clamps to the bottom inset: 300 - 15 - 10 = 275.
|
||||
await positionArrow({
|
||||
side: "left",
|
||||
element: { top: 380, left: 800, right: 900, bottom: 700, width: 100, height: 320 },
|
||||
popover: { top: 100, left: 600, right: 800, bottom: 400, width: 200, height: 300 },
|
||||
});
|
||||
|
||||
expect(arrowEl().classList.contains("driver-popover-arrow-side-left")).toBe(true);
|
||||
expect(arrowEl().style.top).toBe("275px");
|
||||
});
|
||||
|
||||
it("flips the arrow to point up when the element scrolls above a side-placed popover", async () => {
|
||||
// The element has scrolled clear above the popover. The arrow should leave
|
||||
// the side edge and sit on the top edge pointing up (side "bottom"), offset
|
||||
// horizontally toward the element instead of clamping to a side corner.
|
||||
await positionArrow({
|
||||
side: "right",
|
||||
element: { top: -150, left: 800, right: 900, bottom: -50, width: 100, height: 100 },
|
||||
popover: { top: 100, left: 600, right: 800, bottom: 400, width: 200, height: 300 },
|
||||
});
|
||||
|
||||
expect(arrowEl().classList.contains("driver-popover-arrow-side-bottom")).toBe(true);
|
||||
expect(arrowEl().style.left).not.toBe("");
|
||||
expect(arrowEl().style.top).toBe("");
|
||||
});
|
||||
|
||||
// When the element spans the whole popover edge the arrow has slack, so it
|
||||
// follows the configured alignment. Popover height is 300, inset 15, arrow 10:
|
||||
// start → 15, center → 145, end → 275.
|
||||
it.each([
|
||||
["start", "15px"],
|
||||
["center", "145px"],
|
||||
["end", "275px"],
|
||||
] as const)("aligns the arrow to %s when the element spans the whole popover edge", async (align, expected) => {
|
||||
await positionArrow({
|
||||
side: "left",
|
||||
align,
|
||||
element: { top: 50, left: 800, right: 900, bottom: 700, width: 100, height: 650 },
|
||||
popover: { top: 100, left: 600, right: 800, bottom: 400, width: 200, height: 300 },
|
||||
});
|
||||
|
||||
expect(arrowEl().style.top).toBe(expected);
|
||||
});
|
||||
|
||||
it("ignores align for a small element and tracks its center", async () => {
|
||||
// The element doesn't span the popover, so align: "end" is overridden and
|
||||
// the arrow points at the element's center (y=230 → 130 → box at 125).
|
||||
await positionArrow({
|
||||
side: "left",
|
||||
align: "end",
|
||||
element: { top: 200, left: 800, right: 900, bottom: 260, width: 100, height: 60 },
|
||||
popover: { top: 100, left: 600, right: 800, bottom: 400, width: 200, height: 300 },
|
||||
});
|
||||
|
||||
expect(arrowEl().style.top).toBe("125px");
|
||||
});
|
||||
|
||||
it("clamps the arrow to the leading inset when the element center is before the popover", async () => {
|
||||
// Element still overlaps the popover horizontally (250..270) but its center
|
||||
// is near the leading edge, so the arrow clamps to the 15px inset.
|
||||
await positionArrow({
|
||||
side: "bottom",
|
||||
element: { top: 50, left: 230, right: 270, bottom: 80, width: 40, height: 30 },
|
||||
popover: { top: 100, left: 250, right: 550, bottom: 250, width: 300, height: 150 },
|
||||
});
|
||||
|
||||
expect(arrowEl().classList.contains("driver-popover-arrow-side-bottom")).toBe(true);
|
||||
expect(arrowEl().style.left).toBe("15px");
|
||||
});
|
||||
|
||||
it("hides the arrow for a free-floating (over) popover", () => {
|
||||
const d = createDriver({ animate: false });
|
||||
d.highlight({ popover: { title: "Floating" } });
|
||||
|
||||
expect(arrowEl().classList.contains("driver-popover-arrow-none")).toBe(true);
|
||||
expect([...arrowEl().classList].some(c => c.startsWith("driver-popover-arrow-side-"))).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the inline offset when the popover flips to an over/none placement", async () => {
|
||||
const el = document.querySelector<HTMLElement>("#intro")!;
|
||||
el.getBoundingClientRect = () => rect({ top: 200, left: 800, right: 900, bottom: 260, width: 100, height: 60 });
|
||||
|
||||
const d = createDriver({ animate: false });
|
||||
d.highlight({ element: "#intro", popover: { title: "Intro", side: "left" } });
|
||||
|
||||
const wrapper = popoverEl() as HTMLElement;
|
||||
wrapper.getBoundingClientRect = () =>
|
||||
rect({ top: 100, left: 600, right: 800, bottom: 400, width: 200, height: 300 });
|
||||
d.refresh();
|
||||
await nextFrame();
|
||||
expect(arrowEl().style.top).not.toBe("");
|
||||
|
||||
// No room on any side now, so the popover detaches and the arrow is hidden
|
||||
// with its stale offset cleared.
|
||||
el.getBoundingClientRect = () => rect({ top: 0, left: 0, right: 1024, bottom: 768, width: 1024, height: 768 });
|
||||
d.refresh();
|
||||
await nextFrame();
|
||||
|
||||
expect(arrowEl().classList.contains("driver-popover-arrow-none")).toBe(true);
|
||||
expect(arrowEl().style.top).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { calculateArrowOffset, calculateArrowTarget, resolveArrowSide } from "../src/position";
|
||||
|
||||
// These cover the pure geometry directly, without a DOM or the driver harness.
|
||||
// The integration counterparts live in popover.test.ts.
|
||||
|
||||
describe("resolveArrowSide", () => {
|
||||
// Popover box used across these cases.
|
||||
const popover = { top: 100, bottom: 400, left: 600, right: 800 };
|
||||
|
||||
it("keeps the side edge when the element overlaps the popover vertically", () => {
|
||||
const element = { top: 150, bottom: 250, left: 800, right: 900 };
|
||||
expect(resolveArrowSide("left", element, popover)).toBe("left");
|
||||
expect(resolveArrowSide("right", element, popover)).toBe("right");
|
||||
});
|
||||
|
||||
it("points up when a side-placed element scrolls above the popover", () => {
|
||||
// Element fully above the popover → arrow moves to the top edge (side "bottom").
|
||||
const element = { top: -200, bottom: -50, left: 800, right: 900 };
|
||||
expect(resolveArrowSide("left", element, popover)).toBe("bottom");
|
||||
expect(resolveArrowSide("right", element, popover)).toBe("bottom");
|
||||
});
|
||||
|
||||
it("points down when a side-placed element scrolls below the popover", () => {
|
||||
// Element fully below the popover → arrow moves to the bottom edge (side "top").
|
||||
const element = { top: 500, bottom: 600, left: 800, right: 900 };
|
||||
expect(resolveArrowSide("left", element, popover)).toBe("top");
|
||||
expect(resolveArrowSide("right", element, popover)).toBe("top");
|
||||
});
|
||||
|
||||
it("keeps the edge when a top/bottom element overlaps the popover horizontally", () => {
|
||||
const element = { top: 420, bottom: 520, left: 650, right: 750 };
|
||||
expect(resolveArrowSide("top", element, popover)).toBe("top");
|
||||
expect(resolveArrowSide("bottom", element, popover)).toBe("bottom");
|
||||
});
|
||||
|
||||
it("points sideways when a top/bottom element scrolls clear horizontally", () => {
|
||||
const toTheRight = { top: 420, bottom: 520, left: 900, right: 1000 };
|
||||
expect(resolveArrowSide("bottom", toTheRight, popover)).toBe("left");
|
||||
|
||||
const toTheLeft = { top: 420, bottom: 520, left: 0, right: 100 };
|
||||
expect(resolveArrowSide("bottom", toTheLeft, popover)).toBe("right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateArrowTarget", () => {
|
||||
it("aims at the element's center when it sits inside the popover edge", () => {
|
||||
// Element spans 200..260 (center 230) within a popover spanning 100..400.
|
||||
expect(calculateArrowTarget(200, 260, 100, 400, "start")).toBe(130);
|
||||
});
|
||||
|
||||
it("ignores alignment for an element that doesn't span the whole edge", () => {
|
||||
// Same element; align "end" must not change where it points.
|
||||
expect(calculateArrowTarget(200, 260, 100, 400, "end")).toBe(130);
|
||||
});
|
||||
|
||||
it("aims at the center of the overlap when the element runs past one edge", () => {
|
||||
// Element 300..500 against popover 100..400 overlaps on 300..400 → center 350.
|
||||
expect(calculateArrowTarget(300, 500, 100, 400, "center")).toBe(250);
|
||||
});
|
||||
|
||||
it("collapses onto the near edge when the element is entirely past the popover", () => {
|
||||
// Entirely below → both endpoints clamp to the popover's far edge.
|
||||
expect(calculateArrowTarget(500, 600, 100, 400, "center")).toBe(300);
|
||||
// Entirely above → both clamp to the leading edge.
|
||||
expect(calculateArrowTarget(-100, -50, 100, 400, "center")).toBe(0);
|
||||
});
|
||||
|
||||
describe("when the element spans the whole popover edge", () => {
|
||||
// Popover length 300. The element overlaps both ends, so alignment decides.
|
||||
it("places the target at the start inset for align: start", () => {
|
||||
expect(calculateArrowTarget(0, 500, 100, 400, "start")).toBe(20);
|
||||
});
|
||||
|
||||
it("centers the target for align: center", () => {
|
||||
expect(calculateArrowTarget(0, 500, 100, 400, "center")).toBe(150);
|
||||
});
|
||||
|
||||
it("places the target at the end inset for align: end", () => {
|
||||
expect(calculateArrowTarget(0, 500, 100, 400, "end")).toBe(280);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateArrowOffset", () => {
|
||||
it("centers the arrow box on the target", () => {
|
||||
// Target 130 → box top 125 (arrow box is 10px wide).
|
||||
expect(calculateArrowOffset(130, 300)).toBe(125);
|
||||
});
|
||||
|
||||
it("clamps to the leading corner inset", () => {
|
||||
expect(calculateArrowOffset(5, 300)).toBe(15);
|
||||
});
|
||||
|
||||
it("clamps to the trailing corner inset", () => {
|
||||
expect(calculateArrowOffset(295, 300)).toBe(275);
|
||||
});
|
||||
|
||||
it("centers the arrow when the popover is too small for the insets", () => {
|
||||
// length 30 can't honor a 15px inset on both sides → centered: (30-10)/2.
|
||||
expect(calculateArrowOffset(20, 30)).toBe(10);
|
||||
});
|
||||
|
||||
it("never returns a negative offset for a zero-length popover", () => {
|
||||
expect(calculateArrowOffset(0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user