From e2e5dc0f3dc9d3f64352e9528469cac3b20f3c2a Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Wed, 24 Jun 2026 02:16:29 +0100 Subject: [PATCH] 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 --- src/driver.css | 39 +--- src/highlight.ts | 3 +- src/popover.ts | 398 +------------------------------------- src/position.ts | 424 +++++++++++++++++++++++++++++++++++++++++ tests/popover.test.ts | 193 +++++++++++++++++++ tests/position.test.ts | 107 +++++++++++ 6 files changed, 730 insertions(+), 434 deletions(-) create mode 100644 src/position.ts create mode 100644 tests/position.test.ts diff --git a/src/driver.css b/src/driver.css index 57baccc..a2e5e33 100644 --- a/src/driver.css +++ b/src/driver.css @@ -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 { diff --git a/src/highlight.ts b/src/highlight.ts index 0e5e55f..9ef84c3 100644 --- a/src/highlight.ts +++ b/src/highlight.ts @@ -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"; diff --git a/src/popover.ts b/src/popover.ts index e2747e7..86fee7f 100644 --- a/src/popover.ts +++ b/src/popover.ts @@ -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"); diff --git a/src/position.ts b/src/position.ts new file mode 100644 index 0000000..a96d534 --- /dev/null +++ b/src/position.ts @@ -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`; + } +} diff --git a/tests/popover.test.ts b/tests/popover.test.ts index 1198351..21ad3a3 100644 --- a/tests/popover.test.ts +++ b/tests/popover.test.ts @@ -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 => + ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect; + + const arrowEl = () => document.querySelector(".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; + popover: Partial; + }): Promise { + const el = document.querySelector("#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("#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(""); + }); +}); diff --git a/tests/position.test.ts b/tests/position.test.ts new file mode 100644 index 0000000..2e6dc52 --- /dev/null +++ b/tests/position.test.ts @@ -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); + }); +});