@itsbede/guided-tour
Version:
A simple Stimulus controller that enables you to show a guided tour overlay on your website.
422 lines (372 loc) • 12.8 kB
JavaScript
import { Controller } from '@hotwired/stimulus';
import { Tooltip, Popover } from 'bootstrap';
class guided_tour_controller extends Controller {
// Define targets for the controller
static targets = ["overlay", "explanation"];
// Define values with types and defaults
static values = {
timeout: {type: Number, default: 1000},
stepLineText: { type: String, default: "Step {{current}} of {{total}}" },
nextBtnText: { type: String, default: "Next" },
doneBtnText: { type: String, default: "Done!" },
prevBtnText: { type: String, default: "Prev" }
}
/**
* Creates a debounced version of a function
* @param {Function} func - The function to debounce
* @param {number} wait - The debounce delay in milliseconds
* @returns {Function} The debounced function
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Initializes the controller when connected to the DOM
* Sets up initial state, event listeners, and starts the guided-tour process
*/
connect() {
document.head.appendChild(this.styles);
this.coordinates = [];
this.currentPopover = null;
this.currentExplanation = 0;
this.totalSteps = this.explanationTargets.length;
this.startButton = document.getElementById('guidedTourStarterBtn');
// if there is no explanation receiver hide everything
if (this.totalSteps <= this.currentExplanation) {
if (this.startButton) {
this.startButton.classList.add('d-none');
}
return
}
// Create debounced versions of navigation methods
this.debouncedPrevious = this.debounce(() => this.previousExplanation(), 250);
this.debouncedNext = this.debounce(() => this.nextExplanation(), 250);
// Add keyboard event listener
this.handleKeyPress = this.handleKeyPress.bind(this);
document.addEventListener('keydown', this.handleKeyPress);
// Configure custom allowlist for Bootstrap Popover
this.myCustomAllowList = {
...Tooltip.Default.allowList,
div: ['class'],
h1: [],
p: [],
button: ['class'],
'*': ['class']
};
// Set up demo button or start guidedTour automatically
if (this.startButton) {
console.log('Guided Tour: Button found');
this.startButton.addEventListener('click', () => {
this.initializeCoordinates();
this.currentExplanation = -1;
this.nextExplanation();
});
} else {
setTimeout(() => {
this.initializeCoordinates();
this.showExplanation(this.currentExplanation);
}, this.timeoutValue);
}
}
/**
* Cleanup when the controller is disconnected
*/
disconnect() {
document.removeEventListener('keydown', this.handleKeyPress);
}
/**
* Handles keyboard events for navigation
* @param {KeyboardEvent} event - The keyboard event
*/
handleKeyPress(event) {
// Only handle keyboard events when the tour is active
if (!this.overlayTarget.classList.contains('active')) return;
switch (event.key) {
case 'ArrowLeft':
this.debouncedPrevious();
break;
case 'ArrowRight':
this.debouncedNext();
break;
case 'Escape':
this.hideGuidedTour(); // Escape remains immediate
break;
}
}
/**
* Calculates and stores the coordinates of each target element
* These coordinates are used for positioning the overlay and popover
*/
initializeCoordinates() {
this.explanationTargets.forEach((target, _index) => {
const explainThis = document.getElementById(target.dataset.targetElement);
const rect = explainThis.getBoundingClientRect();
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
this.coordinates.push({
top: rect.top + scrollTop - 5,
left: rect.left + scrollLeft - 5,
width: rect.width + 10,
height: rect.height + 10,
explanation: target
});
});
}
/**
* Returns the configuration object for Bootstrap Popover
* @returns {Object} Popover configuration
*/
popoverConfig() {
return {
trigger: 'manual',
customClass: 'guided-tour--popover',
html: true,
placement: 'auto',
container: 'body',
allowList: this.myCustomAllowList
};
}
/**
* Disposes of the current Popover instance if it exists
*/
disposeCurrentPopover() {
if (this.currentPopover) {
this.currentPopover.dispose();
this.currentPopover = null;
}
}
/**
* Navigates to the previous explanation step
*/
previousExplanation() {
if (this.currentExplanation <= 0) return;
this.currentExplanation -= 1;
this.showExplanation();
}
/**
* Navigates to the next explanation step
* Hides guided-tour if at the last step
*/
nextExplanation() {
if (this.currentExplanation >= this.coordinates.length - 1) this.hideGuidedTour();
this.currentExplanation += 1;
this.showExplanation();
}
/**
* Displays the current step's explanation
* Positions the overlay, creates and shows the Popover
*/
showExplanation() {
this.disposeCurrentPopover();
if (this.currentExplanation <= this.coordinates.length) {
const matchingCoordinate = this.coordinates[this.currentExplanation];
this.overlayTarget.classList.add('active');
this.overlayTarget.style.top = `${matchingCoordinate.top}px`;
this.overlayTarget.style.left = `${matchingCoordinate.left}px`;
this.overlayTarget.style.width = `${matchingCoordinate.width}px`;
this.overlayTarget.style.height = `${matchingCoordinate.height}px`;
this.content = matchingCoordinate.explanation.innerHTML;
this.overlayTarget.dataset.bsContent = this.populatedTemplate;
// Create a MutationObserver to watch for when the popover is added to the DOM
const observer = new MutationObserver((mutations, obs) => {
const popover = document.querySelector('.guided-tour--popover');
if (popover) {
this.bindPopoverEvents();
obs.disconnect(); // Stop observing once we've found the popover
}
});
// Start observing the document with the configured parameters
observer.observe(document.body, {childList: true, subtree: true});
setTimeout(() => {
this.currentPopover = new Popover(this.overlayTarget, this.popoverConfig());
this.currentPopover.show();
// Create Intersection Observer to check top visibility
const intersectionObserver = new IntersectionObserver((entries) => {
const [entry] = entries;
// Get the position of the element relative to the viewport
const rect = entry.boundingClientRect;
const isTopInView = rect.top >= 0 && rect.top <= window.innerHeight;
if (!isTopInView) {
this.overlayTarget.scrollIntoView({ behavior: 'smooth' });
}
intersectionObserver.disconnect();
}, {
threshold: [0, 1],
rootMargin: "0px"
});
intersectionObserver.observe(this.overlayTarget);
}, 300);
} else {
this.overlayTarget.classList.remove('active');
console.error(`No matching explanation found for step: ${this.currentExplanation}`);
}
}
/**
* Hides the guided-tour overlay and disposes of the current Popover
*/
hideGuidedTour() {
this.disposeCurrentPopover();
this.overlayTarget.classList.remove('active');
this.overlayTarget.style.top = '0px';
this.overlayTarget.style.left = '0px';
this.overlayTarget.style.width = '0px';
this.overlayTarget.style.height = '0px';
}
get stepLine() {
return this.stepLineTextValue
.replace('{{current}}', this.currentExplanation + 1)
.replace('{{total}}', this.totalSteps);
}
/**
* Returns the populated HTML template for the Popover content
* @returns {string} Populated HTML template
*/
get populatedTemplate() {
return this.template
.replace('{{stepLine}}', this.stepLine)
.replace('{{totalSteps}}', this.totalSteps)
.replace('{{prevButton}}', this.prevButton)
.replace('{{nextButton}}', this.nextButton)
.replace('{{content}}', this.content);
}
/**
* Returns the HTML style tag with default styles
* @returns {string} HTML style tag
*/
get styles() {
const styleElement = document.createElement('style');
styleElement.textContent = `
/* Animation keyframes for sliding and jiggling from bottom right */
:root {
--guided-tour-jiggle-offset: 25px;
}
slideFromRight {
0% {
transform: translateX(100%);
opacity: 0;
}
50% {
transform: translateX(0);
opacity: 1;
}
60% {
transform: translateX(calc(var(--guided-tour-jiggle-offset) * -1));
}
70% {
transform: translateX(var(--guided-tour-jiggle-offset));
}
80% {
transform: translateX(calc(var(--guided-tour-jiggle-offset) * -1));
}
90% {
transform: translateX(var(--guided-tour-jiggle-offset));
}
100% {
transform: translateX(0);
opacity: 1;
}
}
.guided-tour--overlay {
box-shadow: rgb(233 77 77 / 80%) 0 0 1px 2px,
rgb(84 84 84 / 50%) 0 0 0 5000px;
box-sizing: content-box;
position: absolute;
border-radius: 4px;
transition: all .3s ease-out;
z-index: 9998;
pointer-events: none;
opacity: 0;
}
.guided-tour--starter-btn-wrapper {
position: absolute;
right: 1rem;
bottom: 1rem;
animation: slideFromRight 1.2s cubic-bezier(0.23, 1, 0.32, 1) forwards;
animation-delay: 1s;
/* Ensures button is hidden before animation starts */
opacity: 0;
/* Improve animation performance */
will-change: transform, opacity;
> .btn {
/* Prevent any layout shifts during animation */
transform-origin: right center;
z-index: 1000; /* Ensure button stays above other content */
}
}
.guided-tour--overlay.active {
opacity: 1;
}
.guided-tour--popover {
z-index: 9999;
max-width: 33.3%;
}
`;
return styleElement
}
/**
* Returns the HTML template structure for the Popover content
* @returns {string} HTML template
*/
get template() {
return `
<div class="overlay-template">
<div class="d-flex flex-row justify-content-between">
<h3>{{stepLine}}</h3>
<button type="button" class="btn-close guided-tour--close" aria-label="Close"></button>
</div>
<p class="fs-5">{{content}}</p>
<div class="d-flex flex-row justify-content-between">
{{prevButton}}
{{nextButton}}
</div>
</div>
`;
}
/**
* Returns the HTML for the previous button, or an empty string if on the first step
* @returns {string} Previous button HTML
*/
get prevButton() {
if (this.currentExplanation <= 0) return '';
return `<button class="btn btn-secondary guided-tour--prev">${this.prevBtnTextValue}</button>`;
}
/**
* Returns the HTML for the next button, or a "Done" button if on the last step
* @returns {string} Next or Done button HTML
*/
get nextButton() {
if (this.currentExplanation >= this.coordinates.length - 1) {
return `<button class="btn btn-primary guided-tour--finish">${this.doneBtnTextValue}</button>`;
} else {
return `<button class="btn btn-primary guided-tour--next">${this.nextBtnTextValue}</button>`;
}
}
/**
* Binds click events to the Popover navigation buttons
*/
bindPopoverEvents() {
const popoverElement = document.querySelector('.guided-tour--popover');
if (popoverElement) {
popoverElement.addEventListener('click', (event) => {
if (event.target.classList.contains('guided-tour--prev')) {
this.previousExplanation();
} else if (event.target.classList.contains('guided-tour--next')) {
this.nextExplanation();
} else if (event.target.classList.contains('guided-tour--finish')) {
this.hideGuidedTour();
} else if (event.target.classList.contains('guided-tour--close')) {
this.hideGuidedTour();
}
});
}
}
}
export { guided_tour_controller as default };