onboardly
Version:
React component library for Onboardly
558 lines (550 loc) • 17.1 kB
JavaScript
// src/components/Onboardly.tsx
import ReactDOM from "react-dom";
// src/components/context.tsx
import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react";
// src/components/utils.ts
var getDefaultOptions = (options) => {
const defaultOptions = {
spotlightPadding: 10,
maskColor: "#000",
maskOpacity: 0.7,
animationDuration: 300,
highlightPulsate: true,
showProgressDots: true,
exitOnEscape: true,
disableOverlayClose: false,
disableKeyboardNavigation: false,
hideBackButtonOnFirstStep: false,
hideSkipButton: false
};
return { ...defaultOptions, ...options };
};
var getDefaultLabels = (labels) => {
const defaultLabels = {
nextButton: "Next",
backButton: "Back",
skipButton: "Skip",
finishButton: "Finish"
};
return { ...defaultLabels, ...labels };
};
var calculateBoundingBox = (targetElements, padding = 10) => {
if (targetElements.length === 0) return null;
const rects = targetElements.map((el) => el.getBoundingClientRect());
let minX = Math.min(...rects.map((r) => r.left));
let minY = Math.min(...rects.map((r) => r.top));
let maxX = Math.max(...rects.map((r) => r.right));
let maxY = Math.max(...rects.map((r) => r.bottom));
minX -= padding;
minY -= padding;
maxX += padding;
maxY += padding;
return {
top: minY,
left: minX,
width: maxX - minX,
height: maxY - minY,
rects
};
};
// src/components/context.tsx
import { jsx } from "react/jsx-runtime";
var OnboardlyContext = createContext(void 0);
var OnboardlyProvider = ({
children,
steps,
isActive,
onStart,
onEnd,
currentStep: controlledCurrentStep,
onStepChange,
classNames,
labels,
options,
onBeforeStepChange,
onAfterStepChange
}) => {
const [internalCurrentStep, setInternalCurrentStep] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
const isControlled = controlledCurrentStep !== void 0;
const currentStepIndex = isControlled ? controlledCurrentStep : internalCurrentStep;
const targetElementsRef = useRef([]);
const mergedOptions = getDefaultOptions(options);
const mergedLabels = getDefaultLabels(labels);
const calculateTooltipPosition = useCallback(() => {
if (targetElementsRef.current.length === 0) return;
const rects = targetElementsRef.current.map((el) => el.getBoundingClientRect());
const centerX = rects.reduce((sum, rect2) => sum + (rect2.left + rect2.width / 2), 0) / rects.length;
const centerY = rects.reduce((sum, rect2) => sum + (rect2.top + rect2.height / 2), 0) / rects.length;
const position = steps[currentStepIndex].position || "bottom";
const rect = rects[0];
let top = centerY;
let left = centerX;
switch (position) {
case "top":
top = rect.top - 10;
left = centerX - 150;
break;
case "bottom":
top = rect.bottom + 10;
left = centerX - 150;
break;
case "left":
top = centerY - 100;
left = rect.left - 310;
break;
case "right":
top = centerY - 100;
left = rect.right + 10;
break;
}
setTooltipPosition({ top, left });
}, [currentStepIndex, steps]);
const findTargetElements = useCallback(() => {
const currentStep = steps[currentStepIndex];
const targetIds = Array.isArray(currentStep.target) ? currentStep.target : [currentStep.target];
const elements = targetIds.map((id) => document.getElementById(id)).filter((el) => el !== null);
targetElementsRef.current = elements;
if (elements.length > 0 && mergedOptions.scrollIntoViewOptions !== void 0) {
elements[0].scrollIntoView(mergedOptions.scrollIntoViewOptions || { behavior: "smooth", block: "center" });
}
calculateTooltipPosition();
}, [currentStepIndex, steps, calculateTooltipPosition, mergedOptions.scrollIntoViewOptions]);
const changeStep = useCallback(async (nextStepIndex) => {
const currentStep = steps[currentStepIndex];
if (currentStep && currentStep.cleanup) {
currentStep.cleanup();
}
if (onBeforeStepChange) {
const shouldProceed = await onBeforeStepChange(currentStepIndex, nextStepIndex);
if (!shouldProceed) return;
}
if (isControlled) {
onStepChange?.(nextStepIndex);
} else {
setInternalCurrentStep(nextStepIndex);
}
if (onAfterStepChange) {
onAfterStepChange(nextStepIndex);
}
}, [currentStepIndex, steps, onBeforeStepChange, isControlled, onStepChange, onAfterStepChange]);
const handleNext = useCallback(() => {
const nextStep = currentStepIndex + 1;
if (nextStep < steps.length) {
changeStep(nextStep);
} else {
setIsVisible(false);
onEnd?.();
}
}, [currentStepIndex, steps, changeStep, onEnd]);
const handleBack = useCallback(() => {
if (currentStepIndex > 0) {
changeStep(currentStepIndex - 1);
}
}, [currentStepIndex, changeStep]);
const handleSkip = useCallback(() => {
setIsVisible(false);
onEnd?.();
}, [onEnd]);
const handleKeyDown = useCallback((e) => {
if (mergedOptions.disableKeyboardNavigation) return;
switch (e.key) {
case "ArrowRight":
case "Enter":
handleNext();
break;
case "ArrowLeft":
handleBack();
break;
case "Escape":
if (mergedOptions.exitOnEscape !== false) {
handleSkip();
}
break;
}
}, [handleNext, handleBack, handleSkip, mergedOptions]);
const executeStepSetup = useCallback(async () => {
const currentStep = steps[currentStepIndex];
if (currentStep && currentStep.setup) {
await Promise.resolve(currentStep.setup());
}
findTargetElements();
}, [currentStepIndex, steps, findTargetElements]);
useEffect(() => {
if (isActive && steps.length > 0) {
setIsVisible(true);
onStart?.();
if (!isControlled) {
setInternalCurrentStep(0);
}
} else {
setIsVisible(false);
}
}, [isActive, steps, onStart, isControlled]);
useEffect(() => {
if (isVisible) {
executeStepSetup();
if (!mergedOptions.disableKeyboardNavigation) {
window.addEventListener("keydown", handleKeyDown);
}
const handleResize = () => {
findTargetElements();
calculateTooltipPosition();
};
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleResize);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", handleResize);
};
}
}, [
isVisible,
currentStepIndex,
executeStepSetup,
handleKeyDown,
mergedOptions.disableKeyboardNavigation,
findTargetElements,
calculateTooltipPosition
]);
const contextValue = {
currentStepIndex,
steps,
isVisible,
targetElements: targetElementsRef.current,
tooltipPosition,
options: mergedOptions,
labels: mergedLabels,
classNames,
changeStep,
handleNext,
handleBack,
handleSkip
};
return /* @__PURE__ */ jsx(OnboardlyContext.Provider, { value: contextValue, children });
};
var useOnboardly = () => {
const context = useContext(OnboardlyContext);
if (context === void 0) {
throw new Error("useOnboardly must be used within an OnboardlyProvider");
}
return context;
};
// src/components/SpotlightOverlay.tsx
import { jsx as jsx2 } from "react/jsx-runtime";
var SpotlightOverlay = ({
targetElements,
options,
onClick,
customStyles
}) => {
const defaultOptions = {
spotlightPadding: 10,
maskColor: "#000",
maskOpacity: 0.7,
animationDuration: 300
};
const mergedOptions = { ...defaultOptions, ...options };
const getSpotlightStyles = () => {
const boundingBox = calculateBoundingBox(
targetElements,
mergedOptions.spotlightPadding || 0
);
if (!boundingBox) return null;
return {
position: "fixed",
top: `${boundingBox.top}px`,
left: `${boundingBox.left}px`,
width: `${boundingBox.width}px`,
height: `${boundingBox.height}px`,
boxShadow: `0 0 0 9999px rgba(${mergedOptions.maskColor === "#000" ? "0, 0, 0" : "255, 255, 255"}, ${mergedOptions.maskOpacity})`,
borderRadius: "4px",
transition: `all ${mergedOptions.animationDuration}ms ease-in-out`,
...customStyles
};
};
const spotlightStyles = getSpotlightStyles();
if (!spotlightStyles) return null;
return /* @__PURE__ */ jsx2(
"div",
{
onClick,
style: {
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 9998,
pointerEvents: "all"
},
children: /* @__PURE__ */ jsx2(
"div",
{
style: spotlightStyles,
onClick: (e) => e.stopPropagation()
}
)
}
);
};
// src/components/PulsatingBorder.tsx
import { jsx as jsx3 } from "react/jsx-runtime";
var PulsatingBorder = ({
targetElements,
options,
customStyles,
className
}) => {
const defaultOptions = {
spotlightPadding: 10,
animationDuration: 300
};
const mergedOptions = { ...defaultOptions, ...options };
const getBorderStyles = () => {
const boundingBox = calculateBoundingBox(
targetElements,
mergedOptions.spotlightPadding || 0
);
if (!boundingBox) return null;
return {
position: "fixed",
top: `${boundingBox.top}px`,
left: `${boundingBox.left}px`,
width: `${boundingBox.width}px`,
height: `${boundingBox.height}px`,
border: "2px solid #4A90E2",
borderRadius: "4px",
pointerEvents: "none",
transition: `all ${mergedOptions.animationDuration}ms ease-in-out`,
animation: options?.highlightPulsate ? "onboardly-pulse 1.5s infinite" : "none",
...customStyles
};
};
const borderStyles = getBorderStyles();
if (!borderStyles) return null;
return /* @__PURE__ */ jsx3(
"div",
{
style: borderStyles,
className
}
);
};
// src/components/Tooltip.tsx
import { useRef as useRef2 } from "react";
import { jsx as jsx4, jsxs } from "react/jsx-runtime";
var Tooltip = ({
step,
position,
isFirstStep,
isLastStep,
onNext,
onBack,
onSkip,
totalSteps,
currentStepIndex,
options,
labels,
classNames
}) => {
const tooltipRef = useRef2(null);
const getTooltipPosition = () => {
const pos = { ...position };
const stepPosition = step.position || "bottom";
const margin = 10;
if (tooltipRef.current) {
const tooltipRect = tooltipRef.current.getBoundingClientRect();
if (stepPosition === "top") {
pos.top -= tooltipRect.height + margin;
} else if (stepPosition === "bottom") {
pos.top += margin;
} else if (stepPosition === "left") {
pos.left -= tooltipRect.width + margin;
} else if (stepPosition === "right") {
pos.left += margin;
}
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (pos.left < 0) pos.left = margin;
if (pos.top < 0) pos.top = margin;
if (pos.left + tooltipRect.width > viewportWidth) {
pos.left = viewportWidth - tooltipRect.width - margin;
}
if (pos.top + tooltipRect.height > viewportHeight) {
pos.top = viewportHeight - tooltipRect.height - margin;
}
}
return {
top: `${pos.top}px`,
left: `${pos.left}px`
};
};
const tooltipStyles = {
position: "fixed",
zIndex: 9999,
backgroundColor: "white",
boxShadow: "0 2px 10px rgba(0, 0, 0, 0.2)",
borderRadius: "4px",
padding: "15px",
width: "300px",
transition: `all ${options?.animationDuration || 300}ms ease-in-out`,
...getTooltipPosition(),
...step.styles?.tooltip
};
return /* @__PURE__ */ jsxs(
"div",
{
ref: tooltipRef,
style: tooltipStyles,
className: classNames?.tooltip,
children: [
/* @__PURE__ */ jsx4("h3", { className: classNames?.tooltipTitle, style: { margin: "0 0 10px 0" }, children: step.title }),
/* @__PURE__ */ jsx4("div", { className: classNames?.tooltipContent, style: { marginBottom: "15px" }, children: step.content }),
/* @__PURE__ */ jsxs("div", { className: classNames?.navigationContainer, style: { display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [
options?.showProgressDots !== false && /* @__PURE__ */ jsx4("div", { className: classNames?.navigationDots, style: { display: "flex", gap: "5px" }, children: Array.from({ length: totalSteps }).map((_, i) => /* @__PURE__ */ jsx4(
"div",
{
style: {
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: i === currentStepIndex ? "#4A90E2" : "#D8D8D8"
}
},
i
)) }),
/* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "10px" }, children: [
!isFirstStep || !options?.hideBackButtonOnFirstStep ? /* @__PURE__ */ jsx4(
"button",
{
onClick: onBack,
disabled: isFirstStep,
className: classNames?.backButton,
style: {
padding: "8px 12px",
borderRadius: "4px",
border: "1px solid #D8D8D8",
backgroundColor: "white",
cursor: isFirstStep ? "not-allowed" : "pointer",
opacity: isFirstStep ? 0.5 : 1
},
children: labels.backButton
}
) : null,
!options?.hideSkipButton && !isLastStep && /* @__PURE__ */ jsx4(
"button",
{
onClick: onSkip,
className: classNames?.skipButton,
style: {
padding: "8px 12px",
borderRadius: "4px",
border: "none",
backgroundColor: "transparent",
cursor: "pointer"
},
children: labels.skipButton
}
),
/* @__PURE__ */ jsx4(
"button",
{
onClick: onNext,
className: classNames?.nextButton,
style: {
padding: "8px 16px",
borderRadius: "4px",
border: "none",
backgroundColor: "#4A90E2",
color: "white",
cursor: "pointer"
},
children: isLastStep ? labels.finishButton : labels.nextButton
}
)
] })
] })
]
}
);
};
// src/components/Onboardly.tsx
import { Fragment, jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
var KeyframeStyles = () => /* @__PURE__ */ jsx5("style", { children: `
@keyframes onboardly-pulse {
0% { box-shadow: 0 0 0 0 rgba(74, 144, 226, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(74, 144, 226, 0); }
100% { box-shadow: 0 0 0 0 rgba(74, 144, 226, 0); }
}
` });
var OnboardlyContent = () => {
const {
steps,
currentStepIndex,
targetElements,
tooltipPosition,
options,
labels,
classNames,
handleNext,
handleBack,
handleSkip,
isVisible
} = useOnboardly();
if (!isVisible) return null;
return ReactDOM.createPortal(
/* @__PURE__ */ jsxs2(Fragment, { children: [
/* @__PURE__ */ jsx5(KeyframeStyles, {}),
/* @__PURE__ */ jsx5(
SpotlightOverlay,
{
targetElements,
options,
onClick: () => {
if (!options.disableOverlayClose) {
handleSkip();
}
},
customStyles: steps[currentStepIndex].styles?.spotlightMask
}
),
options.highlightPulsate !== false && /* @__PURE__ */ jsx5(
PulsatingBorder,
{
targetElements,
options,
customStyles: steps[currentStepIndex].styles?.highlight,
className: classNames?.highlightBorder
}
),
/* @__PURE__ */ jsx5(
Tooltip,
{
step: steps[currentStepIndex],
position: tooltipPosition,
isFirstStep: currentStepIndex === 0,
isLastStep: currentStepIndex === steps.length - 1,
onNext: handleNext,
onBack: handleBack,
onSkip: handleSkip,
totalSteps: steps.length,
currentStepIndex,
options,
labels,
classNames
}
)
] }),
document.body
);
};
var Onboardly = (props) => {
return /* @__PURE__ */ jsx5(OnboardlyProvider, { ...props, children: /* @__PURE__ */ jsx5(OnboardlyContent, {}) });
};
export {
Onboardly,
useOnboardly
};