UNPKG

onboardly

Version:

React component library for Onboardly

558 lines (550 loc) 17.1 kB
// 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 };