chaperone
Version:
A responsive web tour guide
656 lines (575 loc) • 25.4 kB
JavaScript
/* global DocumentTouch */
;
module.exports = {
/**
* Setup global options
* These can and will be overwriteen if a config object is passed into this.init()
*/
options: {
breakpoints: {
mobile: 420,
mobileLandscape: 740,
tablet: 1024
},
throbberHTML: '<span class="throbber"><span class="dot"></span></span>',
chaperoneHTML: '<div class="chaperone"><div class="chaperone__header"><div class="chaperone__title" data-hook="chaperone-title"></div><div class="chaperone__progress" data-hook="chaperone-progress">X of X</div></div><div class="chaperone__body" data-hook="chaperone-text"></div><div class="chaperone__controls"><div class="chaperone__controls__wrapper"><a class="close-chaperone" data-hook="close-chaperone"><span class="close thick"></span></a><a class="chaperone-btn" data-hook="chaperone-back">Back</a><a class="chaperone-btn chaperone-btn--next" data-hook="chaperone-next">Next</a><a class="chaperone-btn chaperone-btn--finish hide" data-hook="chaperone-finish">Finish</a></div></div></div>',
pageContainerSelector: '',
progressSelector: '[data-hook="chaperone-progress"]',
textSelector: '[data-hook="chaperone-text"]',
backSelector: '[data-hook="chaperone-back"]',
nextSelector: '[data-hook="chaperone-next"]',
finishSelector: '[data-hook="chaperone-finish"]',
finishCallback: function() { return; },
animationTime: 300,
cycle: false,
autoStart: true,
steps: [
{
position: 'fixed',
location: 'windowMiddle',
title: 'Welcome to Chaperone.',
message: 'Add some more steps already!'
}
]
},
/**
* initializeTour - Initialize function to bind events and set any global data
* @version 1.0.0
* @example
* tour.init();
* @param {object} (config) - Congifuration object that is used to overwrite the defaults in this.options
* @return {void}
*/
init: function initializeTour( tour ) {
var self = this;
// Check to see if we should use document.body or document.documentElement
document.documentElement.scrollTop = 1;
this.documentElement = document.documentElement.scrollTop === 1 ? document.documentElement : document.body;
// touch event testing
if ( ( 'ontouchstart' in window ) || window.DocumentTouch && document instanceof DocumentTouch ) {
document.body.style.cursor = 'pointer';
}
// Copy over ininitialization options to options object
if ( tour instanceof Object ) {
for ( var option in tour ) {
if ( window.Object.hasOwnProperty.call( tour, option ) && window.Object.hasOwnProperty.call( this.options, option )) {
// If it's a nested object, loop through that one too
if ( typeof tour[ option ] === 'object' && !Array.isArray( tour[ option ] )) {
for ( var subkey in tour[ option ] ) {
if ( window.Object.hasOwnProperty.call( tour[ option ], subkey ) && window.Object.hasOwnProperty.call( this.options[ option ], subkey )) {
this.options[ option ][ subkey ] = tour[ option ][ subkey ];
}
}
} else {
this.options[ option ] = tour[ option ];
}
}
}
}
// Logic for handling a click event
self.clickHandler = function clickHandler( evt ) {
if ( !evt ) {
evt = window.event;
}
var trigger = evt.target || evt.srcElement,
stepId,
finishClose;
// check to see if this is the throbber dot and if it is.. make the trigger the throbber instead
if ( self.hasClass( trigger , 'dot' ) || self.hasClass( trigger , 'close' )) {
trigger = trigger.parentNode;
}
// set the step ID based on the data-stepid attr
stepId = trigger.getAttribute( 'data-stepid' );
// check if the trigger matches the selector set in the options or the close button
finishClose = trigger.matches( self.options.finishSelector ) || trigger.matches( '[data-hook="close-chaperone"]' );
// lets do something
if ( stepId ) {
if ( self.currentStep ) {
// if the user is clicking on the throbber for the open step itself...
if ( trigger === self.currentStep || trigger === self.currentTrigger ) {
return;
} else {
var children = self.currentStep.childNodes;
// loop through the child elements in the tooltip to see if one of them has been clicked
for ( var childNode in children ) {
if ( window.Object.hasOwnProperty( children, childNode )) {
if ( children[ childNode ] === trigger ) {
return;
}
}
}
self.close();
}
}
// Store the trigger element
self.currentTrigger = trigger;
// Open the step!
setTimeout( function() {
self.open( stepId );
}, self.options.animationTime );
}
if ( finishClose ) {
self.close();
self.currentStep = null;
self.endTour();
}
};
// place the steps
this.placeSteps( self.options.steps );
// Add the global click handler
this.addEventListener( document.body, 'click', self.clickHandler );
this.windowChangeHandler = function windowChangeHandler() {
var stepTextContainer = document.body.querySelector( self.options.textSelector ),
chaperoneActive = document.querySelector( '.chaperone-active' );
stepTextContainer.innerText = 'Your window has been resized. The tour is variable based on your screen size, please refresh your browser then, if necessary, restart the tour.';
self.addClass( chaperoneActive, 'message' );
return;
};
// If a throbber is open and the user resizes the page, tour needs to keep up with the trigger
this.addEventListener( window, 'resize', this.windowChangeHandler );
},
/**
* placeSteps - create the throbbers on the page appropriate to the screen size.
* @version 1.0.0
* @example
* this.placeSteps( steps )
* @param [array] (steps) - All of the steps passed in to the init through (tour)
* @return {void}
*/
placeSteps: function placeSteps( steps ) {
var self = this,
currentSize = self.getCurrentScreenSize();
// function to filter the steps down to just what we need for the current sceen size
self.shownSteps = steps.filter( function( step ) {
var hasCurrentSize;
if ( step.shownOn ) {
hasCurrentSize = self.arrayContains( step.shownOn, currentSize );
} else {
hasCurrentSize = true;
}
return hasCurrentSize;
});
self.shownSteps.forEach( function( step, i ) {
var throbber = self.createDOMElement( self.options.throbberHTML ),
target = step.target ? document.body.querySelector( step.target ) : document.body,
location = step.location,
targetPosLeft = self.getOffset( target ).left,
targetPosTop = step.position === 'fixed' ? target.offsetTop : self.getOffset( target ).top,
// targetPosRight = targetPosLeft + target.offsetWidth,
targetPosBottom = targetPosTop + target.getBoundingClientRect().height,
targetPosVertMiddle = targetPosTop + ( target.getBoundingClientRect().height / 2 ),
targetPosCenter = targetPosLeft + ( target.getBoundingClientRect().width / 2 ),
targetZindex = step.zIndex || parseInt( self.getZindex( target ) ) + 1,
windowPosVertMiddle = window.innerHeight / 2,
windowPosCenter = window.innerWidth / 2;
self.addClass( self.options.pageContainerSelector ? document.body.querySelector( self.options.pageContainerSelector ) : document.body , 'chaperone-active' );
// put the stepid/index in an attribute on the throbber
throbber.setAttribute( 'data-stepid', i );
// handle locked positioning
if ( step.position === 'locked' ) {
throbber.style.position = 'absolute';
throbber.style.marginTop = step.lockedTop + 'px';
throbber.style.marginLeft = step.lockedLeft + 'px';
} else {
throbber.style.position = step.position || 'absolute';
}
// set the z-index to +1 of the target
throbber.style.zIndex = targetZindex;
// handle different position options
switch ( location ) {
case 'bottomMiddle':
throbber.style.left = targetPosCenter + 'px';
throbber.style.top = targetPosBottom + 'px';
break;
case 'centerMiddle':
throbber.style.left = targetPosCenter + 'px';
throbber.style.top = targetPosVertMiddle + 'px';
break;
case 'windowMiddle':
throbber.style.zIndex = 1000;
throbber.style.left = windowPosCenter + 'px';
throbber.style.top = windowPosVertMiddle + 'px';
break;
}
// Append the throbber based on its desired position
if ( step.position === 'locked' ) {
target.insertAdjacentHTML( 'afterend', throbber.outerHTML );
} else {
document.body.appendChild( throbber );
}
});
if ( self.options.autoStart ) {
self.open( 0 );
}
},
/**
* openStep - Main open function to prepare and insert the chaperone with the step loaded
* @version 1.0.0
* @example
* tour.open( 1 );
* @param {number} stepId - The index of the step to be opened
* @return {object} - Returns the step object
*/
open: function openStep( stepId ) {
var self = this,
stepIndex = parseInt( stepId ),
step = self.shownSteps[ stepIndex ],
stepTitle = step.title,
stepText = step.message,
stepNumber = parseInt( stepIndex + 1 ),
stepsTotal = self.shownSteps.length,
currentThrobber = document.body.querySelector( '[data-stepid="' + stepId + '"]' ),
chaperone = self.createDOMElement( self.options.chaperoneHTML ),
stepTextContainer,
chaperoneActive,
titleContainer,
progressContainer,
nextBtn,
backBtn,
finishBtn,
currentElement,
targetPosTop,
chaperoneHeight;
// if there is a target to the step, select the element and find its position.
if ( step.target ) {
currentElement = document.body.querySelector( step.target );
targetPosTop = Math.round( currentElement.offsetTop ) - 150;
}
// scroll to the throbber
if ( step.position !== 'fixed' ) {
console.log( targetPosTop );
self.scrollTo( document.body, targetPosTop, self.options.animationTime );
}
// activate the throbber
self.addClass( currentThrobber, 'active' );
// Insert the html for the chaperone
document.body.appendChild( chaperone );
chaperoneHeight = chaperone.offsetHeight;
// Show the chaperone
setTimeout( function() {
// fill vars with elements as they now exist
progressContainer = document.body.querySelector( self.options.progressSelector );
stepTextContainer = document.body.querySelector( self.options.textSelector );
chaperoneActive = document.querySelector( '.chaperone-active' );
titleContainer = document.body.querySelector( '[data-hook="chaperone-title"]' );
nextBtn = document.body.querySelector( self.options.nextSelector );
backBtn = document.body.querySelector( self.options.backSelector );
finishBtn = document.body.querySelector( self.options.finishSelector );
// Place the step progress in the chaperone
progressContainer.innerText = stepNumber + ' of ' + stepsTotal;
// Place the help text in the chaperone
if ( stepTitle ) {
titleContainer.innerText = stepTitle;
}
stepTextContainer.innerText = stepText;
// Set up the buttons
nextBtn.setAttribute( 'data-stepid', parseInt( stepIndex + 1 ));
backBtn.setAttribute( 'data-stepid', parseInt( stepIndex - 1 ));
// Show the chaperone
self.addClass( chaperone, 'active' );
// hide the back button if this is the first step
if ( stepNumber === 1 ) {
self.addClass( backBtn, 'chaperone-disabled' );
}
// if this is the last step then show the finish button instead of the next button
if ( stepNumber === stepsTotal ) {
self.addClass( nextBtn, 'hide' );
self.removeClass( finishBtn, 'hide' );
}
// if there is an openEvent callback then run it
if ( step.openEvent ) {
step.openEvent();
}
if ( self.getCurrentScreenSize() === 'mobileLandscape' ) {
stepTextContainer.innerText = 'This tour does not fit mobile landscape screens. Please turn your phone vertical and refresh the page then, if needed, relaunch the tour.';
self.addClass( chaperoneActive, 'message' );
}
}, 10 );
// set the currentStep
self.currentStep = stepNumber;
},
/**
* closeStep - Main close function to close a specific step
* @version 1.0.0
* @example
* tour.closeStep();
* @return {void}
*/
close: function closeStep() {
var self = this,
currentChaperone = document.body.querySelector( '.chaperone' ),
activeThrobber = document.body.querySelector( '.throbber.active' ),
activeIndex = activeThrobber.getAttribute( 'data-stepid' );
// deactivate old throbber
if ( activeThrobber ) {
self.removeClass( activeThrobber, 'active' );
}
// if there's a closeEvent let's do it
if ( self.shownSteps[ activeIndex ].closeEvent ) {
self.shownSteps[ activeIndex ].closeEvent();
}
// animate the old step out
if ( currentChaperone ) {
self.removeClass( currentChaperone, 'active' );
setTimeout( function() {
// remove the old step
currentChaperone.parentNode.removeChild( currentChaperone );
}, self.options.animationTime );
}
self.currentStep = null;
},
endTour: function endTour() {
'use strict';
var self = this,
throbbers = Array.prototype.slice.call( document.body.querySelectorAll( '.throbber' ) );
throbbers.forEach( function( throbber ) {
throbber.parentNode.removeChild( throbber );
});
self.removeClass( self.options.pageContainerSelector ? document.body.querySelector( self.options.pageContainerSelector ) : document.body , 'chaperone-active' );
},
/**
* addEventListener - Small function to add an event listener. Should be compatible with IE8+
* @version 1.0.0
* @example
* this.addEventListener( document.body, 'click', this.open( this.currentTooltip ));
* @param {element} el - The element node that needs to have the event listener added
* @param {string} eventName - The event name (sans the "on")
* @param {function} handler - The function to be run when the event is triggered
* @return {element} - The element that had an event bound
* @api private
*/
addEventListener: function addEventListener( el, eventName, handler, useCapture ) {
if ( !useCapture ) {
useCapture = false;
}
if ( el.addEventListener ) {
el.addEventListener( eventName, handler, useCapture );
return el;
} else {
if ( eventName === 'focus' ) {
eventName = 'focusin';
}
el.attachEvent( 'on' + eventName, function() {
handler.call( el );
});
return el;
}
},
/**
* removeEventListener - Small function to remove and event listener. Should be compatible with IE8+
* @version 1.0.0
* @example
* this.removeEventListener( document.body, 'click', this.open( this.currentTooltip ));
* @param {element} el - The element node that needs to have the event listener removed
* @param {string} eventName - The event name (sans the "on")
* @param {function} handler - The function that was to be run when the event is triggered
* @return {element} - The element that had an event removed
* @api private
*/
removeEventListener: function removeEventListener( el, eventName, handler, useCapture ) {
if ( !useCapture ) {
useCapture = false;
}
if ( !el ) {
return;
}
if ( el.removeEventListener ) {
el.removeEventListener( eventName, handler, useCapture );
} else {
if ( eventName === 'focus' ) {
eventName = 'focusin';
}
el.detachEvent( 'on' + eventName, function() {
handler.call( el );
});
}
return el;
},
/**
* hasClass - Small function to see if an element has a specific class. Should be compatible with IE8+
* @version 1.0.0
* @example
* this.hasClass( this.currentTooltip, 'visible' );
* @param {element} el - The element to check the class existence on
* @param {string} className - The class to check for
* @return {boolean} - True or false depending on if the element has the class
* @api private
*/
hasClass: function hasClass( el, className ) {
if ( el.classList ) {
return el.classList.contains( className );
} else {
return new RegExp( '(^| )' + className + '( |$)', 'gi' ).test( el.className );
}
},
/**
* addClass - Small function to add a class to an element. Should be compatible with IE8+
* @version 1.0.0
* @example
* this.addClass( this.currentTooltip, 'visible' );
* @param {element} el - The element to add the class to
* @param {string} className - The class name to add to the element
* @return {element} - The element that had the class added to it
* @api private
*/
addClass: function addClass( el, className ) {
if ( el.classList ) {
el.classList.add( className );
} else {
el.className += ' ' + className;
}
return el;
},
/**
* removeClass - Small function to remove a class from an element. Should be compatible with IE8+
* @version 1.0.0
* @example
* this.removeClass( this.currentTooltip, 'visible' );
* @param {element} el - The element to remove the class from
* @param {string} className - The class name to remove from the element
* @return {element} - The element that had the class removed from it
* @api private
*/
removeClass: function removeClass( el, className ) {
if ( el ) {
if ( el.classList ) {
el.classList.remove( className );
} else {
el.className = el.className.replace( new RegExp( '(^|\\b)' + className.split( ' ' ).join( '|' ) + '(\\b|$)', 'gi' ), ' ' );
}
}
return el;
},
/**
* setInnerText - Small function to set the inner text of an element. Should be compatible with IE8+
* @version 1.0.0
* @example
* this.setInnerText( this.currentTooltip, 'Hello world' );
* @param {element} el - The element to have the text inserted into
* @param {string} text - The text to insert into the element
* @return {element} - The element with the new inner text
* @api private
*/
setInnerText: function setInnerText( el, text ) {
if ( el.textContent !== undefined ) {
el.textcontent = text;
} else {
el.innerText = text;
}
return el;
},
/**
* createDOMElement - Creates a DOM element. Should be compatible with IE8+
* @version 1.0.0
* @example
* this.createDOMElement( '<p>Paragraph!</p>');
* @param {string} html - the string to be converted into a DOM element
* @api private
*/
createDOMElement: function createDOMElement( html ) {
var div = document.createElement( 'div' );
div.innerHTML = html;
return div.firstChild;
},
/**
* getOffset - get the top/left of a DOM Element
* @version 1.0.0
* @example
* this.getOffset( el );
* @param {element} el - the element you need an offset for.
* @api private
*/
getOffset: function getOffset( el ) {
var rect = el.getBoundingClientRect(),
scrollLeft = window.pageXOffset || document.documentElement.scrollLeft,
scrollTop = window.pageYOffset || document.documentElement.scrollTop;
return {
top: rect.top + scrollTop,
left: rect.left + scrollLeft
};
},
/**
* getZindex - get the current css z-index of an element
* @version 1.0.0
* @example
* this.getZindex( el );
* @param {element} el - the element for which you need a z-index
* @api private
*/
getZindex: function getZindex( e ) {
var self = this,
z,
dv = document.defaultView || window;
if ( dv.getComputedStyle ) {
z = dv.getComputedStyle( e ).getPropertyValue( 'z-index' );
} else {
z = e.currentStyle.zindex;
}
if ( isNaN( z )) {
return self.getZindex( e.parentNode );
}
return z;
},
/**
* scrollTo - scroll the screen to a position
* @version 1.0.0
* @example
* this.scrollTo( el, 1000, 300 );
* @param {element} el - the element you want to scroll
* @param {number} to - position (px) you want to scroll to
* @param {number} duration - time (ms) you want to take to animate the scroll
* @api private
*/
scrollTo: function scrollTo( element, to, duration ) {
if ( duration < 0 ) {
return;
}
var difference = to - element.scrollTop,
perTick = difference / duration * 10;
setTimeout( function() {
element.scrollTop = element.scrollTop + perTick;
if ( element.scrollTop === to ) {
return;
}
scrollTo( element, to, duration - 10 );
}, 10 );
},
/**
* arrayContains - see of an array contains something for filtering
* @version 1.0.0
* @example
* this.arrayContains( obj )
* @param {any} obj - the object, string, element or number you are searching for
* @api private
*/
arrayContains: function arrayContains( a, obj ) {
var i = a.length;
while ( i-- ) {
if ( a[ i ] === obj ) {
return true;
}
}
return false;
},
/**
* getCurrentScreenSize - get the size of the screen based on options set in the tour
* @version 1.0.0
* @example
* this.getCurrentScreenSize()
* @api private
*/
getCurrentScreenSize: function getCurrentScreenSize() {
var self = this,
currentSize;
if ( document.documentElement.clientWidth < self.options.breakpoints.mobile ) {
currentSize = 'mobile';
} else if ( document.documentElement.clientWidth < self.options.breakpoints.mobileLandscape && window.innerHeight < window.innerWidth ) {
currentSize = 'mobileLandscape';
} else if ( document.documentElement.clientWidth > self.options.breakpoints.mobilePortrait && document.documentElement.clientWidth < self.options.breakpoints.tablet ) {
currentSize = 'tablet';
} else {
currentSize = 'desktop';
}
return currentSize;
}
};