@fbaconversio/onborda
Version:
The ultimate product tour library for Next.js
567 lines (566 loc) • 29.4 kB
JavaScript
"use client";
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useMemo, useRef, useState } from "react";
import { useOnborda } from "./OnbordaContext";
import { motion } from "framer-motion";
import { usePathname, useRouter } from "next/navigation";
import { Portal } from "@radix-ui/react-portal";
import { getArrowStyle } from "./OnbordaStyles";
import useBreakpoint from "./hooks/useBreakpoint";
/**
* Onborda Component
* @param {OnbordaProps} props
* @constructor
*/
export const defaultBreakpoints = {
xs: 480,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
"3xl": 1920,
};
const Onborda = ({ children, shadowRgb = "0, 0, 0", shadowOpacity = "0.2", cardTransition = { type: "spring", damping: 26, stiffness: 170 }, cardComponent: CardComponent, tourComponent: TourComponent, debug = false, observerTimeout = 5000, breakpoints = defaultBreakpoints, extendSides = {}, }) => {
const { currentTour, currentStep, setCurrentStep, isOnbordaVisible, currentTourSteps, completedSteps, setCompletedSteps, tours, closeOnborda, setOnbordaVisible, isStepChanging, setIsStepChanging, } = useOnborda();
// Merge and sort breakpoints, cached with useMemo to prevent recalculations on every render
const mergedBreakpoints = useMemo(() => {
// First merge the default breakpoints with custom ones
const merged = {
...defaultBreakpoints,
...breakpoints,
};
// Convert to array and sort by numeric value
const entries = Object.entries(merged);
entries.sort((a, b) => a[1] - b[1]); // Sort ascending by breakpoint value
// Convert back to object
return entries.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {});
}, [breakpoints]); // Only recalculate when breakpoints prop changes
const [elementToScroll, setElementToScroll] = useState(null);
const [pointerPosition, setPointerPosition] = useState(null);
const currentElementRef = useRef(null);
// Add a ref to track if we've triggered positioning for the current element
const positioningTriggeredRef = useRef(false);
// - -
// Route Changes
const router = useRouter();
const path = usePathname();
const [currentRoute, setCurrentRoute] = useState(path);
const [pendingRouteChange, setPendingRouteChange] = useState(false);
// Add a state to track if we're currently scrolling
const [isScrolling, setIsScrolling] = useState(false);
const hasSelector = (step) => {
return !!step?.selector || !!step?.customQuerySelector;
};
const getStepSelectorElement = (step) => {
return step?.selector
? document.querySelector(step.selector)
: step?.customQuerySelector
? step.customQuerySelector()
: null;
};
// Get the current tour object
const currentTourObject = useMemo(() => {
return tours.find((tour) => tour.tour === currentTour);
}, [currentTour, isOnbordaVisible]);
// Update the current route on route changes
useEffect(() => {
!pendingRouteChange && setCurrentRoute(path);
}, [path, pendingRouteChange]);
// - -
// Initialisze
useEffect(() => {
let cleanup = [];
if (isOnbordaVisible && currentTourSteps) {
// Reset the positioning triggered flag when step changes
positioningTriggeredRef.current = false;
debug &&
console.log("Onborda: Current Step Changed", currentStep, completedSteps);
const step = currentTourSteps[currentStep];
if (step) {
let elementFound = false;
// Check if the step has a selector
if (hasSelector(step)) {
// This step has a selector. Lets find the element
const element = getStepSelectorElement(step);
// Check if the element is found
if (element) {
// Check if the element is visible in the viewport or needs scrolling
const rect = element.getBoundingClientRect();
const isVisible = rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <=
(window.innerWidth || document.documentElement.clientWidth);
// Set the element to scroll to
setElementToScroll(element);
currentElementRef.current = element;
elementFound = true;
// Enable pointer events on the element
if (step.interactable) {
const htmlElement = element;
htmlElement.style.pointerEvents = "auto";
}
// If element is already visible, show the pointer immediately
// Otherwise, the scrolling effect will handle showing it after scrolling
if (isVisible) {
setIsScrolling(false);
updatePointerPosition();
positioningTriggeredRef.current = true;
}
else {
// Element needs scrolling, hide pointer until scrolling completes
setIsScrolling(true);
positioningTriggeredRef.current = false;
}
}
// Even if the element is already found, we still need to check if the route is different from the current route
// do we have a route to navigate to?
if (step.route) {
// Check if the route is set and different from the current route
if (currentRoute == null || !currentRoute?.endsWith(step.route)) {
debug && console.log("Onborda: Navigating to route", step.route);
// Trigger the next route
router.push(step.route);
// Use MutationObserver to detect when the target element is available in the DOM
const observer = new MutationObserver((mutations, observer) => {
const shouldSelect = hasSelector(currentTourSteps[currentStep]);
if (shouldSelect) {
const element = getStepSelectorElement(currentTourSteps[currentStep]);
if (element) {
// Check if the element is visible in the viewport or needs scrolling
const rect = element.getBoundingClientRect();
const isVisible = rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight ||
document.documentElement.clientHeight) &&
rect.right <=
(window.innerWidth ||
document.documentElement.clientWidth);
// Set the element to scroll to
setElementToScroll(element);
currentElementRef.current = element;
// Enable pointer events on the element
if (step.interactable) {
const htmlElement = element;
htmlElement.style.pointerEvents = "auto";
}
// If element is already visible, show the pointer immediately
// Otherwise, the scrolling effect will handle showing it after scrolling
if (isVisible) {
setIsScrolling(false);
updatePointerPosition();
positioningTriggeredRef.current = true;
}
else {
// Element needs scrolling, hide pointer until scrolling completes
setIsScrolling(true);
positioningTriggeredRef.current = false;
}
// Stop observing after the element is found
observer.disconnect();
debug &&
console.log("Onborda: Observer disconnected after element found", element);
}
else {
debug &&
console.log("Onborda: Observing for element...", currentTourSteps[currentStep]);
}
}
else {
setCurrentStep(currentStep);
observer.disconnect();
debug &&
console.log("Onborda: Observer disconnected after no selector set", currentTourSteps[currentStep]);
}
});
// Start observing the document body for changes
observer.observe(document.body, {
childList: true,
subtree: true,
});
setPendingRouteChange(true);
// Set a timeout to disconnect the observer if the element is not found within a certain period
const timeoutId = setTimeout(() => {
observer.disconnect();
console.error("Onborda: Observer Timeout", currentTourSteps[currentStep]);
}, observerTimeout); // Adjust the timeout period as needed
// Clear the timeout if the observer disconnects successfully
const originalDisconnect = observer.disconnect.bind(observer);
observer.disconnect = () => {
setPendingRouteChange(false);
clearTimeout(timeoutId);
originalDisconnect();
};
}
}
}
else {
// no selector, but might still need to navigate to a route
if (step.route &&
(currentRoute == null || !currentRoute?.endsWith(step.route))) {
// Trigger the next route
debug && console.log("Onborda: Navigating to route", step.route);
router.push(step.route);
}
else if (!completedSteps.has(currentStep)) {
// don't have a route to navigate to, but the step is not completed
debug &&
console.log("Onborda: Step Completed via no selector", currentStep, step);
if (step?.onComplete) {
const tour = tours.find((t) => t.tour === currentTour);
if (tour) {
step.onComplete(tour);
}
}
setCompletedSteps(completedSteps.add(currentStep));
}
}
// No element set for this step? Place the pointer at the center of the screen
if (!elementFound) {
// For center positioning, we can set it immediately without delay
setPointerPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
width: 0,
height: 0,
});
positioningTriggeredRef.current = true;
setIsScrolling(false); // Make sure the cursor is visible for center positioning
setElementToScroll(null);
currentElementRef.current = null;
}
// Prefetch the next route
const nextStep = currentTourSteps[currentStep + 1];
if (nextStep && nextStep?.route) {
debug &&
console.log("Onborda: Prefetching Next Route", nextStep.route);
router.prefetch(nextStep.route);
}
}
}
return () => {
// Disable pointer events on the element on cleanup
if (currentElementRef.current) {
const htmlElement = currentElementRef.current;
htmlElement.style.pointerEvents = "";
}
// Cleanup any event listeners we may have added
cleanup.forEach((fn) => fn());
};
}, [
currentTour, // Re-run the effect when the current tour changes
currentStep, // Re-run the effect when the current step changes
currentTourSteps, // Re-run the effect when the current tour steps change
isOnbordaVisible, // Re-run the effect when the onborda visibility changes
currentRoute, // Re-run the effect when the current route changes
completedSteps, // Re-run the effect when the completed steps change
]);
// - -
// Helper function to get element position
const getElementPosition = (element) => {
const { top, left, width, height } = element.getBoundingClientRect();
// Always use the latest scroll position values to ensure accuracy during scrolling
const scrollTop = window.pageYOffset ||
document.documentElement.scrollTop ||
document.body.scrollTop ||
0;
const scrollLeft = window.pageXOffset ||
document.documentElement.scrollLeft ||
document.body.scrollLeft ||
0;
debug &&
console.log(`Onborda: Getting element position: ${element.id}`, {
top,
left,
width,
height,
scrollTop,
scrollLeft,
finalY: top + scrollTop,
finalX: left + scrollLeft,
});
return {
x: left + scrollLeft,
y: top + scrollTop,
width,
height,
};
};
const getInScrollContainerPosition = (element) => {
// Get the scroll container
if (!currentTourObject?.scrollContainer)
return null;
const selector = currentTourObject?.steps?.[currentStep]?.scrollContainerOveride ??
currentTourObject?.scrollContainer;
const scrollContainer = document.querySelector(selector ?? "");
if (!scrollContainer) {
debug &&
console.log("Onborda: Scroll container not found, using default positioning");
return getElementPosition(element); // Fallback to default positioning
}
// Get the element's position relative to the viewport
const elementRect = element.getBoundingClientRect();
// Get the container's position relative to the viewport
const containerRect = scrollContainer.getBoundingClientRect();
// Calculate position relative to the container
const relativeTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
const relativeLeft = elementRect.left - containerRect.left + scrollContainer.scrollLeft;
debug &&
console.log(`Onborda: Getting element position in container: ${element.id}`, {
elementRect,
containerRect,
containerScroll: {
top: scrollContainer.scrollTop,
left: scrollContainer.scrollLeft,
},
relative: {
top: relativeTop,
left: relativeLeft,
},
});
return {
x: relativeLeft,
y: relativeTop,
width: elementRect.width,
height: elementRect.height,
};
};
// - -
// Scroll to the element when the elementToScroll changes
useEffect(() => {
if (elementToScroll && isOnbordaVisible) {
debug && console.log("Onborda: Element to Scroll Changed");
// Get viewport dimensions
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
debug && console.log("Onborda: viewportHeight", viewportHeight);
// Get element position
const position = getElementPosition(elementToScroll);
const positionInScrollContainer = getInScrollContainerPosition(elementToScroll);
debug &&
console.log(`Onborda: position: ${elementToScroll.id}`, position, positionInScrollContainer);
// Check if element can be centered
const threshold = (viewportHeight - position.height) / 2;
debug &&
console.log(`Onborda: threshold: ${elementToScroll.id}`, threshold);
if (positionInScrollContainer) {
if (positionInScrollContainer.y >= threshold) {
// Hide the pointer during scrolling
setIsScrolling(true);
}
}
else {
if (position.y >= threshold) {
// Hide the pointer during scrolling
setIsScrolling(true);
}
}
// Start scroll animation
elementToScroll.scrollIntoView({
block: "center",
inline: "center",
behavior: "smooth",
});
// Wait for scrolling to complete, then show the pointer
const scrollTimer = setTimeout(() => {
// Update the position once before showing
updatePointerPosition();
// Show the pointer with the correct position
setIsScrolling(false);
positioningTriggeredRef.current = true;
}, 600); // Matches the typical smooth scroll duration
return () => {
clearTimeout(scrollTimer);
};
}
}, [elementToScroll, isOnbordaVisible]);
// - -
// Update pointer position on window resize
const updatePointerPosition = () => {
if (currentTourSteps) {
const step = currentTourSteps[currentStep];
if (step) {
const element = getStepSelectorElement(step);
if (element) {
const position = getElementPosition(element);
debug && console.log("Onborda: Pointer Position Updated", position);
setPointerPosition(position);
}
else {
// if the element is not found, place the pointer at the center of the screen
setPointerPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
width: 0,
height: 0,
});
setElementToScroll(null);
currentElementRef.current = null;
}
}
}
};
// - -
// Update pointer position on window resize and scroll
useEffect(() => {
if (isOnbordaVisible) {
// Only listen for resize events here
window.addEventListener("resize", updatePointerPosition);
return () => {
window.removeEventListener("resize", updatePointerPosition);
};
}
}, [currentStep, currentTourSteps, isOnbordaVisible]);
function simulateClick(selector) {
if (!selector)
return;
debug && console.log("Onborda: Simulating click", selector);
const element = document.querySelector(selector);
if (element instanceof HTMLElement) {
element.click();
}
else if (element) {
// Create and dispatch a click event for non-HTMLElements
const clickEvent = new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
});
element.dispatchEvent(clickEvent);
}
}
// - -
const nextStep = async () => {
if (isStepChanging)
return;
setIsStepChanging(true);
debug && console.log("Onborda: Next Step", currentTourSteps?.[currentStep]);
if (currentTourSteps?.[currentStep]?.clickElementOnNext) {
simulateClick(currentTourSteps?.[currentStep]?.clickElementOnNext);
}
setTimeout(async () => {
const nextStepIndex = currentStep + 1;
await changeStep(nextStepIndex);
}, 100);
setTimeout(() => setIsStepChanging(false), 500);
};
const prevStep = async () => {
if (isStepChanging)
return;
setIsStepChanging(true);
debug &&
console.log("Onborda: Previous Step", currentTourSteps?.[currentStep]);
if (currentTourSteps?.[currentStep]?.clickElementOnPrev) {
simulateClick(currentTourSteps?.[currentStep]?.clickElementOnPrev);
}
setTimeout(async () => {
const prevStepIndex = currentStep - 1;
await changeStep(prevStepIndex);
}, 100);
setTimeout(() => setIsStepChanging(false), 500);
};
const [previousStep, setPreviousStep] = useState(null);
const changeStep = async (step) => {
if (step === null)
return;
const setStepIndex = typeof step === "string"
? currentTourSteps.findIndex((s) => s?.id === step)
: step;
setCurrentStep(setStepIndex);
};
const setStep = async (step) => {
if (isStepChanging)
return;
setIsStepChanging(true);
if (currentTourSteps?.[Number(previousStep)]?.clickElementOnUnset &&
previousStep !== null) {
debug &&
console.log("Onborda: Simulating click", currentTourSteps?.[Number(previousStep)]?.clickElementOnUnset);
simulateClick(currentTourSteps?.[Number(previousStep)]?.clickElementOnUnset);
}
if (currentTourSteps?.[Number(step)]?.clickElementOnSet) {
debug &&
console.log("Onborda: Simulating click", currentTourSteps?.[Number(step)]?.clickElementOnSet);
simulateClick(currentTourSteps?.[Number(step)]?.clickElementOnSet);
}
setTimeout(async () => {
const setStepIndex = typeof step === "string"
? currentTourSteps.findIndex((s) => s?.id === step)
: step;
setPreviousStep(Number(step));
setCurrentStep(setStepIndex);
}, 100);
setTimeout(() => setIsStepChanging(false), 500);
};
// - -
// Card Arrow
const CardArrow = ({ isVisible }) => {
if (!isVisible) {
return null;
}
return (_jsx("svg", { viewBox: "0 0 54 54", "data-name": "onborda-arrow", className: "absolute w-6 h-6 origin-center", style: getArrowStyle(currentTourSteps?.[currentStep]?.side), children: _jsx("path", { id: "triangle", d: "M27 27L0 0V54L27 27Z", fill: "currentColor" }) }));
};
// - -
// Overlay Variants
const variants = {
visible: { opacity: 1 },
hidden: { opacity: 0 },
};
// - -
// Pointer Options
const pointerPadding = currentTourSteps?.[currentStep]?.pointerPadding ?? 30;
const pointerPadOffset = pointerPadding / 2;
const pointerRadius = currentTourSteps?.[currentStep]?.pointerRadius ?? 28;
const pointerEvents = pointerPosition && isOnbordaVisible ? "pointer-events-none" : "";
const { currentSide, breakpoint, style } = useBreakpoint({
breakpoints: mergedBreakpoints,
extendSides,
currentStep: currentTourSteps?.[currentStep],
});
debug &&
console.log("Onborda: currentSide, breakpoint", currentSide, breakpoint);
debug && console.log("Onborda: breakpoints", mergedBreakpoints);
return (_jsxs(_Fragment, { children: [_jsx("div", { "data-name": "onborda-site-wrapper", className: ` ${pointerEvents} `, children: children }), pointerPosition &&
isOnbordaVisible &&
CardComponent &&
currentTourObject &&
isScrolling && (_jsx(motion.div, { className: "fixed inset-0 pointer-events-none z-30", style: {
background: `rgba(${shadowRgb}, ${shadowOpacity})`,
} })), pointerPosition &&
isOnbordaVisible &&
CardComponent &&
currentTourObject && (_jsxs(Portal, { children: [_jsx(motion.div, { "data-name": "onborda-overlay", className: "absolute inset-0 pointer-events-none z-30", initial: "hidden", animate: isOnbordaVisible ? "visible" : "hidden", variants: variants, transition: { duration: 0.5 }, children: _jsx(motion.div, { "data-name": "onborda-pointer", className: "relative z-40", style: {
boxShadow: `0 0 200vw 200vh rgba(${shadowRgb}, ${shadowOpacity})`,
borderRadius: `${pointerRadius}px ${pointerRadius}px ${pointerRadius}px ${pointerRadius}px`,
}, initial: pointerPosition
? {
x: pointerPosition.x - pointerPadOffset,
y: pointerPosition.y - pointerPadOffset,
width: pointerPosition.width + pointerPadding,
height: pointerPosition.height + pointerPadding,
}
: {}, animate: pointerPosition
? {
x: pointerPosition.x - pointerPadOffset,
y: pointerPosition.y - pointerPadOffset,
width: pointerPosition.width + pointerPadding,
height: pointerPosition.height + pointerPadding,
opacity: isScrolling ? 0 : 1,
}
: {}, transition: {
...cardTransition,
opacity: { duration: 0 }, // Smooth fade for opacity
}, children: _jsx(motion.div, { className: "absolute flex flex-col max-w-[100%] transition-all min-w-min pointer-events-auto z-50", "data-name": "onborda-card", animate: {
opacity: isScrolling ? 0 : 1,
}, style: style, children: _jsx(CardComponent, { step: currentTourSteps?.[currentStep], tour: currentTourObject, tours: tours, currentStep: currentStep, totalSteps: currentTourSteps?.length ?? 0, nextStep: nextStep, prevStep: prevStep, setStep: setStep, closeOnborda: closeOnborda, setOnbordaVisible: setOnbordaVisible, arrow: _jsx(CardArrow, { isVisible: currentTourSteps?.[currentStep]
? hasSelector(currentTourSteps?.[currentStep])
: false }), completedSteps: Array.from(completedSteps), pendingRouteChange: pendingRouteChange }) }) }) }), TourComponent && (_jsx(motion.div, { "data-name": "onborda-tour-wrapper", animate: {
opacity: isScrolling ? 0 : 1,
}, className: "fixed top-0 left-0 z-40 w-screen h-screen pointer-events-none", children: _jsx(motion.div, { "data-name": "onborda-tour", className: "pointer-events-auto", children: _jsx(TourComponent, { tour: currentTourObject, currentTour: currentTour, currentStep: currentStep, setStep: setStep, completedSteps: Array.from(completedSteps), closeOnborda: closeOnborda }) }) }))] }))] }));
};
export default Onborda;