guida.js
Version:
A modern, lightweight onboarding library with spotlight highlighting and smooth animations
959 lines (905 loc) • 25.2 kB
JavaScript
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
};