diff --git a/index.html b/index.html index 6a35359d..7987adc1 100644 --- a/index.html +++ b/index.html @@ -177,6 +177,10 @@

Highlight Feature

+ + + + @@ -409,6 +413,77 @@

Usage and Demo

}, ]; + + const sameElementSteps = [ + { + element: ".page-header h1", + popover: { + title: "1 popup on the same element", + description: + "Unlike the older version, new version doesn't work with z-indexes so no more positional issues.", + side: "left", + align: "start", + }, + }, + { + element: ".page-header h1", + popover: { + title: "2 popup on the same element", + description: + "Unlike the older version, new version doesn't work with z-indexes so no more positional issues.", + side: "left", + align: "start", + }, + }, + { + element: ".page-header h1", + popover: { + title: "3 popup on the same element", + description: + "Unlike the older version, new version doesn't work with z-indexes so no more positional issues.", + side: "left", + align: "start", + }, + }, + { + element: ".page-header sup", + popover: { + title: "Improved Hooks", + description: + "Unlike the older version, new version doesn't work with z-indexes so no more positional issues.", + side: "bottom", + align: "start", + }, + }, + { + popover: { + title: "No Element", + description: "You can now have popovers without elements as well", + }, + }, + { + element: '.buttons', + popover: { + title: "Buttons", + description: "Here are some buttons", + }, + }, + { + element: "#scrollable-area", + popover: { + title: "Scrollable Areas", + description: "There are no issues with scrollable element tours as well.", + }, + }, + { + element: "#third-scroll-paragraph", + popover: { + title: "Nested Scrolls", + description: "Even the nested scrollable elements work now.", + }, + }, + ]; + document.getElementById("non-animated-tour").addEventListener("click", () => { const driverObj = driver({ animate: false, @@ -877,6 +952,47 @@

Usage and Demo

}); }); + document.getElementById("backdrop-disabled-btn").addEventListener("click", () => { + driver({ + overlayEnable: false, + }).highlight({ + element: "h2", + popover: { + title: "MIT License", + description: "A lightweight, no-dependency JavaScript engine to drive user's focus.", + side: "bottom", + align: "start", + }, + });; + }); + + document.getElementById("disabled-stick-to-viewport").addEventListener("click", () => { + const driverObj = driver({ + popoverStickToViewport: false, + steps: basicTourSteps, + }); + + driverObj.drive(); + }); + + document.getElementById("disabled-scroll").addEventListener("click", () => { + const driverObj = driver({ + allowScroll: false, + steps: basicTourSteps, + }); + + driverObj.drive(); + }); + + document.getElementById("disabled-same-element-animation").addEventListener("click", () => { + const driverObj = driver({ + animateBetweenSameElements: false, + steps: sameElementSteps, + }); + + driverObj.drive(); + }); + document.getElementById("activate-check-btn").addEventListener("click", () => { const driverObj = driver({ showButtons: false, diff --git a/src/config.ts b/src/config.ts index ed1ce772..a12f1f85 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,10 +8,13 @@ export type Config = { steps?: DriveStep[]; animate?: boolean; + animateBetweenSameElements?: boolean + overlayEnable?: boolean; overlayColor?: string; overlayOpacity?: number; smoothScroll?: boolean; allowClose?: boolean; + allowScroll?: boolean; stagePadding?: number; stageRadius?: number; @@ -22,6 +25,7 @@ export type Config = { // Popover specific configuration popoverClass?: string; popoverOffset?: number; + popoverStickToViewport?: boolean; showButtons?: AllowedButtons[]; disableButtons?: AllowedButtons[]; showProgress?: boolean; @@ -53,14 +57,18 @@ let currentConfig: Config = {}; export function configure(config: Config = {}) { currentConfig = { animate: true, + animateBetweenSameElements: true, allowClose: true, + allowScroll: true, overlayOpacity: 0.7, + overlayEnable: true, smoothScroll: false, disableActiveInteraction: false, showProgress: false, stagePadding: 10, stageRadius: 5, popoverOffset: 10, + popoverStickToViewport: true, showButtons: ["next", "previous", "close"], disableButtons: [], overlayColor: "#000", diff --git a/src/driver.css b/src/driver.css index 0128f76d..f47fb149 100644 --- a/src/driver.css +++ b/src/driver.css @@ -2,14 +2,18 @@ pointer-events: none; } -.driver-active * { +.driver-active.driver-no-scroll { + overflow: hidden; +} + +.driver-active:not(.driver-no-overlay) * { pointer-events: none; } .driver-active .driver-active-element, .driver-active .driver-active-element *, -.driver-popover, -.driver-popover * { +.driver-active .driver-popover, +.driver-active .driver-popover * { pointer-events: auto; } @@ -27,7 +31,7 @@ animation: animate-fade-in 200ms ease-in-out; } -.driver-fade .driver-popover { +.driver-fade .driver-popover:not(.driver-no-animation) { animation: animate-fade-in 200ms; } diff --git a/src/driver.ts b/src/driver.ts index 564f55b0..2aeab45c 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -8,7 +8,7 @@ import { getState, resetState, setState } from "./state"; import "./driver.css"; export type DriveStep = { - element?: string | Element; + element?: string | Element | (() => Element | null); onHighlightStarted?: DriverHook; onHighlighted?: DriverHook; onDeselected?: DriverHook; @@ -131,6 +131,7 @@ export function driver(options: Config = {}) { listen("overlayClick", handleClose); listen("escapePress", handleClose); + listen("windowClick", handleClose); listen("arrowLeftPress", handleArrowLeft); listen("arrowRightPress", handleArrowRight); } @@ -234,7 +235,7 @@ export function driver(options: Config = {}) { const onDeselected = activeStep?.onDeselected || getConfig("onDeselected"); const onDestroyed = getConfig("onDestroyed"); - document.body.classList.remove("driver-active", "driver-fade", "driver-simple"); + document.body.classList.remove("driver-active", "driver-fade", "driver-simple", "driver-no-overlay", "driver-no-scroll"); destroyEvents(); destroyPopover(); diff --git a/src/emitter.ts b/src/emitter.ts index 7964dfd7..7a514aa6 100644 --- a/src/emitter.ts +++ b/src/emitter.ts @@ -5,7 +5,8 @@ type allowedEvents = | "prevClick" | "closeClick" | "arrowRightPress" - | "arrowLeftPress"; + | "arrowLeftPress" + | "windowClick"; let registeredListeners: Partial<{ [key in allowedEvents]: () => void }> = {}; diff --git a/src/events.ts b/src/events.ts index ae643f71..17a60061 100644 --- a/src/events.ts +++ b/src/events.ts @@ -4,6 +4,19 @@ import { getState, setState } from "./state"; import { getConfig } from "./config"; import { getFocusableElements } from "./utils"; +function onWindowClick(e: MouseEvent) { + const isOverlayEnabled = getConfig('overlayEnable') + if (isOverlayEnabled) return; + + const isPopoverContent = (e.target as HTMLElement)?.closest('#driver-popover-content') + const isActive = getState('activeStep') + const isTransitioning = getState("__transitionCallback") + + if (isTransitioning || !isActive || isPopoverContent) return; + + emit('windowClick') +} + export function requireRefresh() { const resizeTimeout = getState("__resizeTimeout"); if (resizeTimeout) { @@ -118,10 +131,14 @@ export function initEvents() { window.addEventListener("keydown", trapFocus, false); window.addEventListener("resize", requireRefresh); window.addEventListener("scroll", requireRefresh); + if (!getConfig('overlayEnable')) { + window.addEventListener('click', onWindowClick) + } } export function destroyEvents() { window.removeEventListener("keyup", onKeyup); window.removeEventListener("resize", requireRefresh); window.removeEventListener("scroll", requireRefresh); + window.removeEventListener("click", onWindowClick); } diff --git a/src/highlight.ts b/src/highlight.ts index 961a26af..f3057517 100644 --- a/src/highlight.ts +++ b/src/highlight.ts @@ -27,9 +27,30 @@ function mountDummyElement(): Element { return element; } -export function highlight(step: DriveStep) { +function getStepElement(step: DriveStep): Element | null { const { element } = step; - let elemObj = typeof element === "string" ? document.querySelector(element) : element; + + if (typeof element === 'function') { + return element(); + } + + if (typeof element === 'string') { + return document.querySelector(element); + } + + return element ?? null; +} + +export function highlight(step: DriveStep) { + let elemObj = getStepElement(step); + + if (!getConfig("overlayEnable")) { + document.body.classList.add("driver-no-overlay"); + } + + if (!getConfig("allowScroll")) { + document.body.classList.add("driver-no-scroll"); + } // If the element is not found, we mount a 1px div // at the center of the screen to highlight and show @@ -62,11 +83,12 @@ function transferHighlight(toElement: Element, toStep: DriveStep) { const fromStep = getState("__activeStep"); const fromElement = getState("__activeElement") || toElement; + const isSameElement = fromElement === toElement; // If it's the first time we're highlighting an element, we show // the popover immediately. Otherwise, we wait for the animation // to finish before showing the popover. - const isFirstHighlight = !fromElement || fromElement === toElement; + const isFirstHighlight = !fromElement || isSameElement; const isToDummyElement = toElement.id === "driver-dummy-element"; const isFromDummyElement = fromElement.id === "driver-dummy-element"; @@ -102,6 +124,8 @@ function transferHighlight(toElement: Element, toStep: DriveStep) { setState("activeStep", toStep); setState("activeElement", toElement); + const isSameElementAnimationEnabled = !isSameElement || !!getConfig("animateBetweenSameElements"); + const animate = () => { const transitionCallback = getState("__transitionCallback"); @@ -112,12 +136,15 @@ function transferHighlight(toElement: Element, toStep: DriveStep) { return; } + setState("__activeElement", toElement); + const elapsed = Date.now() - start; const timeRemaining = duration - elapsed; const isHalfwayThrough = timeRemaining <= duration / 2; + if (toStep.popover && isHalfwayThrough && !isPopoverRendered && hasDelayedPopover) { - renderPopover(toElement, toStep); + renderPopover(toElement, toStep, isSameElementAnimationEnabled); isPopoverRendered = true; } @@ -137,7 +164,6 @@ function transferHighlight(toElement: Element, toStep: DriveStep) { setState("__previousStep", fromStep); setState("__previousElement", fromElement); setState("__activeStep", toStep); - setState("__activeElement", toElement); } window.requestAnimationFrame(animate); @@ -149,7 +175,7 @@ function transferHighlight(toElement: Element, toStep: DriveStep) { bringInView(toElement); if (!hasDelayedPopover && toStep.popover) { - renderPopover(toElement, toStep); + renderPopover(toElement, toStep, isSameElementAnimationEnabled); } fromElement.classList.remove("driver-active-element", "driver-no-interaction"); diff --git a/src/overlay.ts b/src/overlay.ts index cffb4e16..36da707d 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -55,6 +55,9 @@ export function trackActiveElement(element: Element) { } export function refreshOverlay() { + const isOverlayEnabled = getConfig('overlayEnable') + if (!isOverlayEnabled) return; + const activeStagePosition = getState("__activeStagePosition"); const overlaySvg = getState("__overlaySvg"); @@ -90,6 +93,9 @@ function mountOverlay(stagePosition: StageDefinition) { } function renderOverlay(stagePosition: StageDefinition) { + const isOverlayEnabled = getConfig('overlayEnable') + if (!isOverlayEnabled) return; + const overlaySvg = getState("__overlaySvg"); // TODO: cancel rendering if element is not visible diff --git a/src/popover.ts b/src/popover.ts index a11ca97c..ccd45395 100644 --- a/src/popover.ts +++ b/src/popover.ts @@ -1,4 +1,4 @@ -import { bringInView, getFocusableElements } from "./utils"; +import { getFocusableElements } from "./utils"; import { Config, DriverHook, getConfig } from "./config"; import { getState, setState, State } from "./state"; import { DriveStep } from "./driver"; @@ -58,7 +58,7 @@ export function hidePopover() { popover.wrapper.style.display = "none"; } -export function renderPopover(element: Element, step: DriveStep) { +export function renderPopover(element: Element, step: DriveStep, isAnimationEnabled: boolean = true) { let popover = getState("popover"); if (popover) { document.body.removeChild(popover.wrapper); @@ -151,6 +151,10 @@ export function renderPopover(element: Element, step: DriveStep) { const customPopoverClass = step.popover?.popoverClass || getConfig("popoverClass") || ""; popoverWrapper.className = `driver-popover ${customPopoverClass}`.trim(); + if (!isAnimationEnabled) { + popoverWrapper.classList.add("driver-no-animation"); + } + // Handles the popover button clicks onDriverClick( popover.wrapper, @@ -221,7 +225,6 @@ export function renderPopover(element: Element, step: DriveStep) { } repositionPopover(element, step); - bringInView(popoverWrapper); // Focus on the first focusable element in active element or popover const isToDummyElement = element.classList.contains("driver-dummy-element"); @@ -268,6 +271,8 @@ function calculateTopForLeftRight( } ): number { const { elementDimensions, popoverDimensions, popoverPadding, popoverArrowDimensions } = config; + const shouldStickToViewport = getConfig('popoverStickToViewport'); + const safeZone = shouldStickToViewport ? popoverArrowDimensions.width : Number.MIN_SAFE_INTEGER if (alignment === "start") { return Math.max( @@ -275,7 +280,7 @@ function calculateTopForLeftRight( elementDimensions.top - popoverPadding, window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width ), - popoverArrowDimensions.width + safeZone ); } @@ -285,7 +290,7 @@ function calculateTopForLeftRight( elementDimensions.top - popoverDimensions?.realHeight + elementDimensions.height + popoverPadding, window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width ), - popoverArrowDimensions.width + safeZone ); } @@ -295,7 +300,7 @@ function calculateTopForLeftRight( elementDimensions.top + elementDimensions.height / 2 - popoverDimensions?.realHeight / 2, window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width ), - popoverArrowDimensions.width + safeZone ); } @@ -313,6 +318,8 @@ function calculateLeftForTopBottom( } ): number { const { elementDimensions, popoverDimensions, popoverPadding, popoverArrowDimensions } = config; + const shouldStickToViewport = getConfig('popoverStickToViewport'); + const safeZone = shouldStickToViewport ? popoverArrowDimensions.width : Number.MIN_SAFE_INTEGER if (alignment === "start") { return Math.max( @@ -320,7 +327,7 @@ function calculateLeftForTopBottom( elementDimensions.left - popoverPadding, window.innerWidth - popoverDimensions!.realWidth - popoverArrowDimensions.width ), - popoverArrowDimensions.width + safeZone ); } @@ -330,7 +337,7 @@ function calculateLeftForTopBottom( elementDimensions.left - popoverDimensions?.realWidth + elementDimensions.width + popoverPadding, window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width ), - popoverArrowDimensions.width + safeZone ); } @@ -340,7 +347,7 @@ function calculateLeftForTopBottom( elementDimensions.left + elementDimensions.width / 2 - popoverDimensions?.realWidth / 2, window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width ), - popoverArrowDimensions.width + safeZone ); } @@ -379,6 +386,8 @@ export function repositionPopover(element: Element, step: DriveStep) { const noneOptimal = !isTopOptimal && !isBottomOptimal && !isLeftOptimal && !isRightOptimal; let popoverRenderedSide: Side = requiredSide; + const shouldStickToViewport = getConfig('popoverStickToViewport'); + if (requiredSide === "top" && isTopOptimal) { isRightOptimal = isLeftOptimal = isBottomOptimal = false; } else if (requiredSide === "bottom" && isBottomOptimal) { @@ -443,10 +452,11 @@ export function repositionPopover(element: Element, step: DriveStep) { popoverRenderedSide = "right"; } else if (isTopOptimal) { - const topToSet = Math.min( + const topToSet = shouldStickToViewport ? Math.min( topValue, window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width - ); + ) : topValue; + let leftToSet = calculateLeftForTopBottom(requiredAlignment, { elementDimensions, popoverDimensions, @@ -461,10 +471,10 @@ export function repositionPopover(element: Element, step: DriveStep) { popoverRenderedSide = "top"; } else if (isBottomOptimal) { - const bottomToSet = Math.min( + const bottomToSet = shouldStickToViewport ? Math.min( bottomValue, window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width - ); + ) : bottomValue; let leftToSet = calculateLeftForTopBottom(requiredAlignment, { elementDimensions,