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:
Kamran Ahmed
2026-06-24 02:16:29 +01:00
parent a8fcd260af
commit e2e5dc0f3d
6 changed files with 730 additions and 434 deletions
+2 -37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`;
}
}
+193
View File
@@ -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("");
});
});
+107
View File
@@ -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);
});
});