import { bringInView } from "./utils"; import { getConfig } from "./config"; import { getState, setState } from "./state"; import { DriveStep } from "./driver"; import { onDriverClick } from "./events"; import { emit } from "./emitter"; export type Side = "top" | "right" | "bottom" | "left" | "over"; export type Alignment = "start" | "center" | "end"; export type AllowedButtons = "next" | "previous" | "close"; export type Popover = { title?: string; description?: string; side?: Side; align?: Alignment; showButtons?: AllowedButtons[]; disableButtons?: AllowedButtons[]; popoverClass?: string; // Button texts doneBtnText?: string; closeBtnText?: string; nextBtnText?: string; prevBtnText?: string; // Called after the popover is rendered onPopoverRendered?: (popover: PopoverDOM) => void; // Button callbacks onNextClick?: (element: Element | undefined, step: DriveStep) => void; onPrevClick?: (element: Element | undefined, step: DriveStep) => void; onCloseClick?: (element: Element | undefined, step: DriveStep) => void; }; export type PopoverDOM = { wrapper: HTMLElement; arrow: HTMLElement; title: HTMLElement; description: HTMLElement; footer: HTMLElement; previousButton: HTMLElement; nextButton: HTMLElement; closeButton: HTMLElement; footerButtons: HTMLElement; }; export function hidePopover() { const popover = getState("popover"); if (!popover) { return; } popover.wrapper.style.display = "none"; } export function renderPopover(element: Element, step: DriveStep) { let popover = getState("popover"); if (popover) { document.body.removeChild(popover.wrapper); } popover = createPopover(); document.body.appendChild(popover.wrapper); const { title, description, showButtons, disableButtons, // doneBtnText = 'Done', closeBtnText = getConfig("closeBtnText") || "Close", nextBtnText = getConfig("nextBtnText") || "Next →", prevBtnText = getConfig("prevBtnText") || "← Previous", } = step.popover || {}; popover.nextButton.innerHTML = nextBtnText; popover.previousButton.innerHTML = prevBtnText; popover.closeButton.innerHTML = closeBtnText; if (title) { popover.title.innerText = title; popover.title.style.display = "block"; } else { popover.title.style.display = "none"; } if (description) { popover.description.innerHTML = description; popover.description.style.display = "block"; } else { popover.description.style.display = "none"; } const showButtonsConfig: AllowedButtons[] = showButtons || getConfig("showButtons")!; if (showButtonsConfig?.length! > 0) { popover.footer.style.display = "flex"; popover.nextButton.style.display = showButtonsConfig.includes("next") ? 'block' : 'none'; popover.previousButton.style.display = showButtonsConfig.includes("previous") ? 'block' : 'none'; popover.closeButton.style.display = showButtonsConfig.includes("close") ? 'block' : 'none'; } else { popover.footer.style.display = "none"; } const disabledButtonsConfig: AllowedButtons[] = disableButtons || getConfig('disableButtons')! || []; if (disabledButtonsConfig?.includes('next')) { popover.nextButton.classList.add("driver-popover-btn-disabled") } if (disabledButtonsConfig?.includes('previous')) { popover.previousButton.classList.add("driver-popover-btn-disabled") } if (disabledButtonsConfig?.includes('close')) { popover.closeButton.classList.add("driver-popover-btn-disabled") } // Reset the popover position const popoverWrapper = popover.wrapper; popoverWrapper.style.display = "block"; popoverWrapper.style.left = ""; popoverWrapper.style.top = ""; popoverWrapper.style.bottom = ""; popoverWrapper.style.right = ""; // Reset the classes responsible for the arrow position const popoverArrow = popover.arrow; popoverArrow.className = "driver-popover-arrow"; // Reset any custom classes on the popover const customPopoverClass = step.popover?.popoverClass || getConfig("popoverClass") || ""; popoverWrapper.className = `driver-popover ${customPopoverClass}`.trim(); // Handles the popover button clicks onDriverClick( popover.wrapper, e => { const target = e.target as HTMLElement; const onNextClick = step.popover?.onNextClick || getConfig("onNextClick"); const onPrevClick = step.popover?.onPrevClick || getConfig("onPrevClick"); const onCloseClick = step.popover?.onCloseClick || getConfig("onCloseClick"); if (target.classList.contains("driver-popover-next-btn")) { // If the user has provided a custom callback, call it // otherwise, emit the event. if (onNextClick) { return onNextClick(element, step); } else { return emit("nextClick"); } } if (target.classList.contains("driver-popover-prev-btn")) { if (onPrevClick) { return onPrevClick(element, step); } else { return emit("prevClick"); } } if (target.classList.contains("driver-popover-close-btn")) { if (onCloseClick) { return onCloseClick(element, step); } else { return emit("closeClick"); } } return undefined; }, target => { // Only prevent the default action if we're clicking on a button // This allows us to have links inside the popover title and description return !popover?.description.contains(target) && !popover?.title.contains(target); } ); setState("popover", popover); repositionPopover(element, step); bringInView(popoverWrapper); const onPopoverRendered = step.popover?.onPopoverRendered || getConfig('onPopoverRendered'); if (onPopoverRendered) { onPopoverRendered(popover); } } 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"); } } 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"; } } else { } 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}`); } } function createPopover(): PopoverDOM { const wrapper = document.createElement("div"); wrapper.classList.add("driver-popover"); const arrow = document.createElement("div"); arrow.classList.add("driver-popover-arrow"); const title = document.createElement("div"); title.classList.add("driver-popover-title"); title.style.display = "none"; title.innerText = "Popover Title"; const description = document.createElement("div"); description.classList.add("driver-popover-description"); description.style.display = "none"; description.innerText = "Popover description is here"; const footer = document.createElement("div"); footer.classList.add("driver-popover-footer"); const closeButton = document.createElement("button"); closeButton.classList.add("driver-popover-close-btn"); closeButton.innerText = "Close"; const footerButtons = document.createElement("span"); footerButtons.classList.add("driver-popover-navigation-btns"); const previousButton = document.createElement("button"); previousButton.classList.add("driver-popover-prev-btn"); previousButton.innerHTML = "← Previous"; const nextButton = document.createElement("button"); nextButton.classList.add("driver-popover-next-btn"); nextButton.innerHTML = "Next →"; footerButtons.appendChild(previousButton); footerButtons.appendChild(nextButton); footer.appendChild(closeButton); footer.appendChild(footerButtons); wrapper.appendChild(arrow); wrapper.appendChild(title); wrapper.appendChild(description); wrapper.appendChild(footer); return { wrapper, arrow, title, description, footer, previousButton, nextButton, closeButton, footerButtons, }; } export function destroyPopover() { const popover = getState("popover"); if (!popover) { return; } popover.wrapper.parentElement?.removeChild(popover.wrapper); }