UNPKG

guida.js

Version:

A modern, lightweight onboarding library with spotlight highlighting and smooth animations

959 lines (905 loc) 25.2 kB
const DEFAULT_STYLES = ` /* Guida.js Onboarding Styles */ .guida-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 10000; pointer-events: none; } .guida-backdrop { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); backdrop-filter: blur(2px); transition: clip-path 0.3s ease-in-out; } .guida-highlight { position: relative; z-index: 10001; border-radius: 8px; transition: all 0.3s ease-in-out; } .guida-highlight::before { content: ''; position: absolute; top: -4px; left: -4px; right: -4px; bottom: -4px; border: 2px solid #007acc; border-radius: 12px; box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2), 0 0 20px rgba(0, 122, 204, 0.3); pointer-events: none; z-index: -1; animation: guida-pulse 2s infinite; } @keyframes guida-pulse { 0%, 100% { box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2), 0 0 20px rgba(0, 122, 204, 0.3); } 50% { box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.4), 0 0 30px rgba(0, 122, 204, 0.5); } } .guida-tooltip { position: fixed; z-index: 10002; background: white; border-radius: 12px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); padding: 0; opacity: 0; transform: scale(0.9) translateY(10px); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); pointer-events: auto; border: 1px solid rgba(0, 0, 0, 0.1); max-width: 400px; min-width: 300px; } .guida-tooltip.guida-visible { opacity: 1; transform: scale(1) translateY(0); } .guida-tooltip-content { padding: 16px; } .guida-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; gap: 12px; } .guida-header h3 { margin: 0; font-size: 16px; font-weight: 600; color: #1a1a1a; line-height: 1.3; flex: 1; } .guida-progress { background: #f8f9fa; padding: 2px 8px; border-radius: 12px; font-size: 11px; color: #6c757d; font-weight: 500; white-space: nowrap; border: 1px solid #e9ecef; } .guida-tooltip-content p { margin: 0 0 16px 0; color: #495057; line-height: 1.4; font-size: 13px; } .guida-actions { display: flex; flex-direction: column; gap: 0; } .guida-navigation { display: none; } .guida-controls { display: flex; gap: 6px; justify-content: space-between; flex-wrap: wrap; } .guida-btn { padding: 6px 12px; border-radius: 4px; border: none; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; min-width: 50px; height: 28px; display: inline-flex; align-items: center; justify-content: center; } .guida-btn-secondary { background: #f8f9fa; color: #495057; border: 1px solid #dee2e6; } .guida-btn-secondary:hover { background: #e9ecef; border-color: #adb5bd; } .guida-btn-text { background: transparent; color: #6c757d; border: 1px solid #dee2e6; min-width: 45px; } .guida-btn-text:hover { background: #f8f9fa; border-color: #adb5bd; color: #495057; } .guida-skip { background: transparent; color: #6c757d; border: 1px solid #dee2e6; } .guida-skip:hover { background: #f8f9fa; border-color: #adb5bd; } .guida-next { background: #007acc; color: white; min-width: 60px; } .guida-next:hover { background: #0056b3; } .guida-close { background: #dc3545; color: white; min-width: 45px; } .guida-close:hover { background: #c82333; } /* Tooltip arrows */ .guida-arrow { position: absolute; width: 0; height: 0; border: 8px solid transparent; } .guida-arrow-top { bottom: -16px; left: 50%; transform: translateX(-50%); border-top-color: white; } .guida-arrow-bottom { top: -16px; left: 50%; transform: translateX(-50%); border-bottom-color: white; } .guida-arrow-left { right: -16px; top: 50%; transform: translateY(-50%); border-left-color: white; } .guida-arrow-right { left: -16px; top: 50%; transform: translateY(-50%); border-right-color: white; } /* Completion styles */ .guida-completion { text-align: center; } .guida-completion-icon { font-size: 48px; margin-bottom: 16px; } .guida-completion h3 { color: #28a745; margin-bottom: 12px; } /* Responsive adjustments */ @media (max-width: 768px) { .guida-tooltip { max-width: calc(100vw - 40px); min-width: 280px; } .guida-tooltip-content { padding: 20px; } .guida-header { flex-direction: column; align-items: flex-start; gap: 8px; } .guida-actions { flex-direction: column; } .guida-btn { width: 100%; } } /* Dark theme support */ @media (prefers-color-scheme: dark) { .guida-tooltip { background: #2d2d2d; border-color: rgba(255, 255, 255, 0.1); } .guida-header h3 { color: #ffffff; } .guida-tooltip-content p { color: #cccccc; } .guida-progress { background: #404040; color: #cccccc; } .guida-skip { background: transparent; color: #cccccc; border-color: #555 !important; } .guida-skip:hover { background: #404040; border-color: #666 !important; } .guida-arrow-top { border-top-color: #2d2d2d; } .guida-arrow-bottom { border-bottom-color: #2d2d2d; } .guida-arrow-left { border-left-color: #2d2d2d; } .guida-arrow-right { border-right-color: #2d2d2d; } } /* High contrast mode support */ @media (prefers-contrast: high) { .guida-backdrop { background-color: rgba(0, 0, 0, 0.9); } .guida-highlight::before { border-color: #ffffff; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5), 0 0 20px rgba(255, 255, 255, 0.8); } .guida-tooltip { border: 2px solid #000000; } } /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { .guida-backdrop, .guida-tooltip, .guida-highlight, .guida-btn { transition: none; } .guida-highlight::before { animation: none; } } `; class Guida { constructor(config) { this.eventListeners = /* @__PURE__ */ new Map(); this.config = this.mergeConfig(config); this.state = { currentStep: 0, isActive: false, isCompleted: false, overlay: null, tooltip: null, currentHighlightedElement: null, currentStepConfig: null, resizeHandler: null, scrollHandler: null }; this.init(); } /** * Merge user config with defaults */ mergeConfig(config) { var _a, _b, _c, _d, _e, _f, _g, _h; return { steps: config.steps, storageKey: config.storageKey || "guida-js-completed", autoStart: (_a = config.autoStart) != null ? _a : true, startDelay: (_b = config.startDelay) != null ? _b : 1e3, spotlight: { borderRadius: (_d = (_c = config.spotlight) == null ? void 0 : _c.borderRadius) != null ? _d : 8, padding: (_f = (_e = config.spotlight) == null ? void 0 : _e.padding) != null ? _f : 8, backdropOpacity: (_h = (_g = config.spotlight) == null ? void 0 : _g.backdropOpacity) != null ? _h : 50 }, customClasses: { overlay: "", backdrop: "", tooltip: "", highlight: "", ...config.customClasses }, callbacks: { onStart: () => { }, onComplete: () => { }, onClose: () => { }, onStepChange: () => { }, ...config.callbacks } }; } /** * Initialize the onboarding system */ init() { this.injectStyles(); if (this.config.autoStart && !this.isCompleted()) { setTimeout(() => { this.start(); }, this.config.startDelay); } } /** * Inject default styles into the document */ injectStyles() { if (document.getElementById("guida-js-styles")) { return; } const styleElement = document.createElement("style"); styleElement.id = "guida-js-styles"; styleElement.textContent = DEFAULT_STYLES; document.head.appendChild(styleElement); } /** * Start the onboarding flow */ start() { var _a, _b; if (this.state.isActive || this.config.steps.length === 0) { return; } this.state.isActive = true; this.state.currentStep = 0; this.createOverlay(); this.setupResizeHandler(); this.showStep(this.state.currentStep); this.emit("start"); (_b = (_a = this.config.callbacks).onStart) == null ? void 0 : _b.call(_a); } /** * Create the overlay and backdrop elements */ createOverlay() { this.state.overlay = document.createElement("div"); this.state.overlay.className = `guida-overlay ${this.config.customClasses.overlay}`; this.state.overlay.innerHTML = ` <div class="guida-backdrop ${this.config.customClasses.backdrop}"></div> `; const backdrop = this.state.overlay.querySelector(".guida-backdrop"); if (backdrop) { const opacity = this.config.spotlight.backdropOpacity / 100; backdrop.style.backgroundColor = `rgba(0, 0, 0, ${opacity})`; } this.state.tooltip = document.createElement("div"); this.state.tooltip.className = `guida-tooltip ${this.config.customClasses.tooltip}`; document.body.appendChild(this.state.overlay); document.body.appendChild(this.state.tooltip); } /** * Show a specific step */ showStep(stepIndex) { var _a, _b; if (stepIndex >= this.config.steps.length) { this.complete(); return; } const step = this.config.steps[stepIndex]; this.state.currentStepConfig = step; const target = document.querySelector(step.target); if (!target) { console.warn(`Spotlight Onboarding: Target not found: ${step.target}`); this.nextStep(); return; } this.emit("stepChange", { stepIndex, step }); (_b = (_a = this.config.callbacks).onStepChange) == null ? void 0 : _b.call(_a, stepIndex, step); this.highlightElement(target, step.highlight, step); this.showTooltip(target, step); this.setupStepInteraction(target, step); } /** * Highlight an element with spotlight effect */ highlightElement(element, shouldHighlight, step) { var _a; document.querySelectorAll(".guida-highlight").forEach((el) => { el.classList.remove("guida-highlight"); if (this.config.customClasses.highlight) { el.classList.remove(this.config.customClasses.highlight); } }); const backdrop = (_a = this.state.overlay) == null ? void 0 : _a.querySelector(".guida-backdrop"); this.state.currentHighlightedElement = shouldHighlight ? element : null; if (shouldHighlight && backdrop) { element.classList.add("guida-highlight"); if (this.config.customClasses.highlight) { element.classList.add(this.config.customClasses.highlight); } this.updateClipPath(element, backdrop, step); element.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" }); } else if (backdrop) { backdrop.style.clipPath = "none"; } } /** * Update the clip-path for the backdrop to create spotlight effect with border radius */ updateClipPath(element, backdrop, step) { var _a, _b; const rect = element.getBoundingClientRect(); const stepSpotlight = step == null ? void 0 : step.spotlight; const globalSpotlight = this.config.spotlight; const borderRadius = (_a = stepSpotlight == null ? void 0 : stepSpotlight.borderRadius) != null ? _a : globalSpotlight.borderRadius; const padding = (_b = stepSpotlight == null ? void 0 : stepSpotlight.padding) != null ? _b : globalSpotlight.padding; const opacity = globalSpotlight.backdropOpacity / 100; backdrop.style.backgroundColor = `rgba(0, 0, 0, ${opacity})`; const x1 = Math.max(0, rect.left - padding); const y1 = Math.max(0, rect.top - padding); const x2 = Math.min(window.innerWidth, rect.right + padding); const y2 = Math.min(window.innerHeight, rect.bottom + padding); if (borderRadius > 0) { this.createRoundedSpotlight(backdrop, x1, y1, x2, y2, borderRadius); } else { backdrop.innerHTML = ""; backdrop.style.backdropFilter = "blur(2px)"; const clipPath = `polygon( 0% 0%, 0% 100%, ${x1}px 100%, ${x1}px ${y1}px, ${x2}px ${y1}px, ${x2}px ${y2}px, ${x1}px ${y2}px, ${x1}px 100%, 100% 100%, 100% 0% )`; backdrop.style.clipPath = clipPath; } } /** * Create a rounded spotlight effect using SVG mask for proper rounded corners */ createRoundedSpotlight(backdrop, x1, y1, x2, y2, borderRadius) { const width = x2 - x1; const height = y2 - y1; const maxRadius = Math.min(width / 2, height / 2, borderRadius); backdrop.style.clipPath = "none"; const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.style.position = "absolute"; svg.style.top = "0"; svg.style.left = "0"; svg.style.width = "100%"; svg.style.height = "100%"; svg.style.pointerEvents = "none"; const cutoutId = "spotlight-cutout-" + Date.now(); const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); const mask = document.createElementNS("http://www.w3.org/2000/svg", "mask"); mask.id = cutoutId; const background = document.createElementNS("http://www.w3.org/2000/svg", "rect"); background.setAttribute("width", "100%"); background.setAttribute("height", "100%"); background.setAttribute("fill", "white"); const cutout = document.createElementNS("http://www.w3.org/2000/svg", "rect"); cutout.setAttribute("x", x1.toString()); cutout.setAttribute("y", y1.toString()); cutout.setAttribute("width", width.toString()); cutout.setAttribute("height", height.toString()); cutout.setAttribute("rx", maxRadius.toString()); cutout.setAttribute("ry", maxRadius.toString()); cutout.setAttribute("fill", "black"); mask.appendChild(background); mask.appendChild(cutout); defs.appendChild(mask); svg.appendChild(defs); const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); rect.setAttribute("width", "100%"); rect.setAttribute("height", "100%"); rect.setAttribute("fill", "rgba(0, 0, 0, 0.7)"); rect.setAttribute("mask", `url(#${cutoutId})`); svg.appendChild(rect); backdrop.innerHTML = ""; backdrop.appendChild(svg); } /** * Setup resize handler to update clip-path on window resize */ setupResizeHandler() { this.state.resizeHandler = () => { if (this.state.currentHighlightedElement && this.state.overlay && this.state.currentStepConfig) { const backdrop = this.state.overlay.querySelector(".guida-backdrop"); if (backdrop) { this.updateClipPath(this.state.currentHighlightedElement, backdrop, this.state.currentStepConfig); } if (this.state.tooltip && this.state.currentStepConfig) { this.showTooltip(this.state.currentHighlightedElement, this.state.currentStepConfig); } } }; this.state.scrollHandler = () => { if (this.state.currentHighlightedElement && this.state.overlay && this.state.currentStepConfig) { const backdrop = this.state.overlay.querySelector(".guida-backdrop"); if (backdrop) { this.updateClipPath(this.state.currentHighlightedElement, backdrop, this.state.currentStepConfig); } if (this.state.tooltip && this.state.currentStepConfig) { this.showTooltip(this.state.currentHighlightedElement, this.state.currentStepConfig); } } }; window.addEventListener("resize", this.state.resizeHandler); window.addEventListener("scroll", this.state.scrollHandler, true); document.addEventListener("scroll", this.state.scrollHandler, true); } /** * Show tooltip for the current step */ showTooltip(target, step) { if (!this.state.tooltip) return; const rect = target.getBoundingClientRect(); const tooltipWidth = 320; const isFirstStep = this.state.currentStep === 0; const isLastStep = this.state.currentStep === this.config.steps.length - 1; this.state.tooltip.innerHTML = ` <div class="guida-tooltip-content"> <div class="guida-header"> <h3>${step.title}</h3> <div class="guida-progress"> <span>${this.state.currentStep + 1} of ${this.config.steps.length}</span> </div> </div> <p>${step.description}</p> <div class="guida-actions"> <div class="guida-controls"> ${!isFirstStep ? '<button class="guida-btn guida-btn-secondary guida-prev">← Previous</button>' : ""} ${step.action === "observe" && !isLastStep ? '<button class="guida-btn guida-next">Next →</button>' : ""} ${step.skipable ? '<button class="guida-btn guida-btn-text guida-skip">Skip</button>' : ""} <button class="guida-btn guida-btn-text guida-close">Close</button> </div> </div> </div> <div class="guida-arrow guida-arrow-${step.position}"></div> `; this.state.tooltip.style.width = `${tooltipWidth}px`; this.state.tooltip.style.visibility = "hidden"; this.state.tooltip.style.opacity = "1"; this.state.tooltip.classList.add("guida-visible"); const tooltipHeight = this.state.tooltip.offsetHeight; let left, top; switch (step.position) { case "top": left = rect.left + rect.width / 2 - tooltipWidth / 2; top = rect.top - tooltipHeight - 20; break; case "bottom": left = rect.left + rect.width / 2 - tooltipWidth / 2; top = rect.bottom + 20; break; case "left": left = rect.left - tooltipWidth - 20; top = rect.top + rect.height / 2 - tooltipHeight / 2; break; case "right": left = rect.right + 20; top = rect.top + rect.height / 2 - tooltipHeight / 2; break; default: left = rect.right + 20; top = rect.top; } left = Math.max(20, Math.min(left, window.innerWidth - tooltipWidth - 20)); top = Math.max(20, Math.min(top, window.innerHeight - tooltipHeight - 20)); this.state.tooltip.style.left = `${left}px`; this.state.tooltip.style.top = `${top}px`; this.state.tooltip.style.visibility = "visible"; this.setupTooltipEvents(); } /** * Setup event listeners for tooltip buttons */ setupTooltipEvents() { if (!this.state.tooltip) return; const prevBtn = this.state.tooltip.querySelector(".guida-prev"); const skipBtn = this.state.tooltip.querySelector(".guida-skip"); const nextBtn = this.state.tooltip.querySelector(".guida-next"); const closeBtn = this.state.tooltip.querySelector(".guida-close"); if (prevBtn) { prevBtn.addEventListener("click", () => this.previousStep()); } if (skipBtn) { skipBtn.addEventListener("click", () => this.nextStep()); } if (nextBtn) { nextBtn.addEventListener("click", () => this.nextStep()); } if (closeBtn) { closeBtn.addEventListener("click", () => this.close()); } } /** * Setup interaction handling for the current step */ setupStepInteraction(target, step) { if (step.action === "click") { const handleClick = () => { target.removeEventListener("click", handleClick); setTimeout(() => { this.nextStep(); }, 500); }; target.addEventListener("click", handleClick); } } /** * Move to the next step */ nextStep() { const currentStep = this.config.steps[this.state.currentStep]; this.state.currentStep++; this.hideTooltip(); this.emit("stepNavigation", { direction: "next", fromStep: this.state.currentStep - 1, toStep: this.state.currentStep, fromStepConfig: currentStep }); setTimeout(() => { this.showStep(this.state.currentStep); }, 300); } /** * Move to the previous step */ previousStep() { if (this.state.currentStep > 0) { const currentStep = this.config.steps[this.state.currentStep]; this.state.currentStep--; this.hideTooltip(); this.emit("stepNavigation", { direction: "previous", fromStep: this.state.currentStep + 1, toStep: this.state.currentStep, fromStepConfig: currentStep }); setTimeout(() => { this.showStep(this.state.currentStep); }, 300); } } /** * Go to a specific step */ goToStep(stepIndex) { if (stepIndex >= 0 && stepIndex < this.config.steps.length) { this.state.currentStep = stepIndex; this.hideTooltip(); setTimeout(() => { this.showStep(this.state.currentStep); }, 300); } } /** * Hide the tooltip */ hideTooltip() { if (this.state.tooltip) { this.state.tooltip.classList.remove("guida-visible"); } } /** * Complete the onboarding flow */ complete() { var _a, _b; this.clearHighlights(); this.showCompletionMessage(); this.markAsCompleted(); this.emit("complete"); (_b = (_a = this.config.callbacks).onComplete) == null ? void 0 : _b.call(_a); setTimeout(() => { this.cleanup(); }, 3e3); } /** * Show completion message */ showCompletionMessage() { if (this.state.tooltip) { this.state.tooltip.innerHTML = ` <div class="guida-tooltip-content guida-completion"> <div class="guida-completion-icon">🎉</div> <h3>You're All Set!</h3> <p>You've completed the tour! You can now use all the features.</p> </div> `; this.state.tooltip.classList.add("guida-visible"); } } /** * Close the onboarding flow */ close() { var _a, _b; this.markAsCompleted(); this.emit("close"); (_b = (_a = this.config.callbacks).onClose) == null ? void 0 : _b.call(_a); this.cleanup(); } /** * Clean up all onboarding elements and listeners */ cleanup() { this.state.isActive = false; this.clearHighlights(); if (this.state.resizeHandler) { window.removeEventListener("resize", this.state.resizeHandler); this.state.resizeHandler = null; } if (this.state.scrollHandler) { window.removeEventListener("scroll", this.state.scrollHandler, true); document.removeEventListener("scroll", this.state.scrollHandler, true); this.state.scrollHandler = null; } if (this.state.overlay) { this.state.overlay.remove(); this.state.overlay = null; } if (this.state.tooltip) { this.state.tooltip.remove(); this.state.tooltip = null; } this.state.currentHighlightedElement = null; this.state.currentStepConfig = null; } /** * Clear all highlights and reset backdrop */ clearHighlights() { var _a; document.querySelectorAll(".guida-highlight").forEach((el) => { el.classList.remove("guida-highlight"); if (this.config.customClasses.highlight) { el.classList.remove(this.config.customClasses.highlight); } }); const backdrop = (_a = this.state.overlay) == null ? void 0 : _a.querySelector(".guida-backdrop"); if (backdrop) { backdrop.style.clipPath = "none"; } } /** * Mark onboarding as completed in localStorage */ markAsCompleted() { this.state.isCompleted = true; localStorage.setItem(this.config.storageKey, "true"); } /** * Check if onboarding has been completed */ isCompleted() { return localStorage.getItem(this.config.storageKey) === "true"; } /** * Reset onboarding (remove completion flag) */ reset() { localStorage.removeItem(this.config.storageKey); this.state.isCompleted = false; } /** * Restart the onboarding flow */ restart() { this.reset(); this.cleanup(); setTimeout(() => { this.start(); }, 500); } /** * Get current step information */ getCurrentStep() { return { index: this.state.currentStep, step: this.config.steps[this.state.currentStep] || null }; } /** * Check if onboarding is currently active */ isActive() { return this.state.isActive; } /** * Add event listener */ on(event, callback) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, []); } this.eventListeners.get(event).push(callback); } /** * Remove event listener */ off(event, callback) { const listeners = this.eventListeners.get(event); if (listeners) { const index = listeners.indexOf(callback); if (index > -1) { listeners.splice(index, 1); } } } /** * Emit event to all listeners */ emit(event, data) { const listeners = this.eventListeners.get(event); if (listeners) { listeners.forEach((callback) => callback(data)); } } } function createOnboarding(config) { return new Guida(config); } function quickStart(steps) { return new Guida({ steps, autoStart: true, startDelay: 1e3 }); } export { Guida, createOnboarding, quickStart };