MDL-56596 tool_usertours: Update to tours v0.9.9
[moodle.git] / admin / tool / usertours / amd / src / tour.js
1 // jshint ignore: start
2 (function (root, factory) {
3   if (typeof define === 'function' && define.amd) {
4     // AMD. Register as an anonymous module unless amdModuleId is set
5     define(["jquery","./popper"], function (a0,b1) {
6       return (root['Tour'] = factory(a0,b1));
7     });
8   } else if (typeof exports === 'object') {
9     // Node. Does not work with strict CommonJS, but
10     // only CommonJS-like environments that support module.exports,
11     // like Node.
12     module.exports = factory(require("jquery"),require("popper.js"));
13   } else {
14     root['Tour'] = factory($,Popper);
15   }
16 }(this, function ($, Popper) {
18 "use strict";
20 /**
21  * A Tour.
22  *
23  * @class   Tour
24  * @param   {object}    config  The configuration object.
25  */
27 var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; };
29 function Tour(config) {
30     this.init(config);
31 }
33 /**
34  * The name of the tour.
35  *
36  * @property    {String}    tourName
37  */
38 Tour.prototype.tourName;
40 /**
41  * The original configuration as passed into the constructor.
42  *
43  * @property    {Object}    originalConfiguration
44  */
45 Tour.prototype.originalConfiguration;
47 /**
48  * The list of step listeners.
49  *
50  * @property    {Array}     listeners
51  */
52 Tour.prototype.listeners;
54 /**
55  * The list of event handlers.
56  *
57  * @property    {Object}    eventHandlers
58  */
59 Tour.prototype.eventHandlers;
61 /**
62  * The list of steps.
63  *
64  * @property    {Object[]}      steps
65  */
66 Tour.prototype.steps;
68 /**
69  * The current step node.
70  *
71  * @property    {jQuery}        currentStepNode
72  */
73 Tour.prototype.currentStepNode;
75 /**
76  * The current step number.
77  *
78  * @property    {Number}        currentStepNumber
79  */
80 Tour.prototype.currentStepNumber;
82 /**
83  * The popper for the current step.
84  *
85  * @property    {Popper}        currentStepPopper
86  */
87 Tour.prototype.currentStepPopper;
89 /**
90  * The config for the current step.
91  *
92  * @property    {Object}        currentStepConfig
93  */
94 Tour.prototype.currentStepConfig;
96 /**
97  * The template content.
98  *
99  * @property    {String}        templateContent
100  */
101 Tour.prototype.templateContent;
103 /**
104  * Initialise the tour.
105  *
106  * @method  init
107  * @param   {Object}    config  The configuration object.
108  * @chainable
109  */
110 Tour.prototype.init = function (config) {
111     // Unset all handlers.
112     this.eventHandlers = {};
114     // Reset the current tour states.
115     this.reset();
117     // Store the initial configuration.
118     this.originalConfiguration = config || {};
120     // Apply configuration.
121     this.configure.apply(this, arguments);
123     return this;
124 };
126 /**
127  * Reset the current tour state.
128  *
129  * @method  reset
130  * @chainable
131  */
132 Tour.prototype.reset = function () {
133     // Hide the current step.
134     this.hide();
136     // Unset all handlers.
137     this.eventHandlers = [];
139     // Unset all listeners.
140     this.resetStepListeners();
142     // Unset the original configuration.
143     this.originalConfiguration = {};
145     // Reset the current step number and list of steps.
146     this.steps = [];
148     // Reset the current step number.
149     this.currentStepNumber = 0;
151     return this;
152 };
154 /**
155  * Prepare tour configuration.
156  *
157  * @method  configure
158  * @chainable
159  */
160 Tour.prototype.configure = function (config) {
161     var _this = this;
163     if ((typeof config === 'undefined' ? 'undefined' : _typeof(config)) === 'object') {
164         // Tour name.
165         if (typeof config.tourName !== 'undefined') {
166             this.tourName = config.tourName;
167         }
169         // Set up eventHandlers.
170         if (config.eventHandlers) {
171             (function () {
172                 var eventName = void 0;
173                 for (eventName in config.eventHandlers) {
174                     config.eventHandlers[eventName].forEach(function (handler) {
175                         this.addEventHandler(eventName, handler);
176                     }, _this);
177                 }
178             })();
179         }
181         // Reset the step configuration.
182         this.resetStepDefaults(true);
184         // Configure the steps.
185         if (_typeof(config.steps) === 'object') {
186             this.steps = config.steps;
187         }
189         if (typeof config.template !== 'undefined') {
190             this.templateContent = config.template;
191         }
192     }
194     // Check that we have enough to start the tour.
195     this.checkMinimumRequirements();
197     return this;
198 };
200 /**
201  * Check that the configuration meets the minimum requirements.
202  *
203  * @method  checkMinimumRequirements
204  * @chainable
205  */
206 Tour.prototype.checkMinimumRequirements = function () {
207     // Need a tourName.
208     if (!this.tourName) {
209         throw new Error("Tour Name required");
210     }
212     // Need a minimum of one step.
213     if (!this.steps || !this.steps.length) {
214         throw new Error("Steps must be specified");
215     }
216 };
218 /**
219  * Reset step default configuration.
220  *
221  * @method  resetStepDefaults
222  * @param   {Boolean}   loadOriginalConfiguration   Whether to load the original configuration supplied with the Tour.
223  * @chainable
224  */
225 Tour.prototype.resetStepDefaults = function (loadOriginalConfiguration) {
226     if (typeof loadOriginalConfiguration === 'undefined') {
227         loadOriginalConfiguration = true;
228     }
230     this.stepDefaults = {};
231     if (!loadOriginalConfiguration || typeof this.originalConfiguration.stepDefaults === 'undefined') {
232         this.setStepDefaults({});
233     } else {
234         this.setStepDefaults(this.originalConfiguration.stepDefaults);
235     }
237     return this;
238 };
240 /**
241  * Set the step defaults.
242  *
243  * @method  setStepDefaults
244  * @param   {Object}    stepDefaults                The step defaults to apply to all steps
245  * @chainable
246  */
247 Tour.prototype.setStepDefaults = function (stepDefaults) {
248     if (!this.stepDefaults) {
249         this.stepDefaults = {};
250     }
251     $.extend(this.stepDefaults, {
252         element: '',
253         placement: 'top',
254         delay: 0,
255         moveOnClick: false,
256         moveAfterTime: 0,
257         orphan: false,
258         direction: 1
259     }, stepDefaults);
261     return this;
262 };
264 /**
265  * Retrieve the current step number.
266  *
267  * @method  getCurrentStepNumber
268  * @return  {Integer}                   The current step number
269  */
270 Tour.prototype.getCurrentStepNumber = function () {
271     return parseInt(this.currentStepNumber, 10);
272 };
274 /**
275  * Store the current step number.
276  *
277  * @method  setCurrentStepNumber
278  * @param   {Integer}   stepNumber      The current step number
279  * @chainable
280  */
281 Tour.prototype.setCurrentStepNumber = function (stepNumber) {
282     this.currentStepNumber = stepNumber;
283 };
285 /**
286  * Get the next step number after the currently displayed step.
287  *
288  * @method  getNextStepNumber
289  * @return  {Integer}    The next step number to display
290  */
291 Tour.prototype.getNextStepNumber = function (stepNumber) {
292     if (typeof stepNumber === 'undefined') {
293         stepNumber = this.getCurrentStepNumber();
294     }
295     var nextStepNumber = stepNumber + 1;
297     // Keep checking the remaining steps.
298     while (nextStepNumber <= this.steps.length) {
299         if (this.isStepPotentiallyVisible(this.getStepConfig(nextStepNumber))) {
300             return nextStepNumber;
301         }
302         nextStepNumber++;
303     }
305     return null;
306 };
308 /**
309  * Get the previous step number before the currently displayed step.
310  *
311  * @method  getPreviousStepNumber
312  * @return  {Integer}    The previous step number to display
313  */
314 Tour.prototype.getPreviousStepNumber = function (stepNumber) {
315     if (typeof stepNumber === 'undefined') {
316         stepNumber = this.getCurrentStepNumber();
317     }
318     var previousStepNumber = stepNumber - 1;
320     // Keep checking the remaining steps.
321     while (previousStepNumber >= 0) {
322         if (this.isStepPotentiallyVisible(this.getStepConfig(previousStepNumber))) {
323             return previousStepNumber;
324         }
325         previousStepNumber--;
326     }
328     return null;
329 };
331 /**
332  * Is the step the final step number?
333  *
334  * @method  isLastStep
335  * @param   {Integer}   stepNumber  Step number to test
336  * @return  {Boolean}               Whether the step is the final step
337  */
338 Tour.prototype.isLastStep = function (stepNumber) {
339     var nextStepNumber = this.getNextStepNumber(stepNumber);
341     return nextStepNumber === null;
342 };
344 /**
345  * Is the step the first step number?
346  *
347  * @method  isFirstStep
348  * @param   {Integer}   stepNumber  Step number to test
349  * @return  {Boolean}               Whether the step is the first step
350  */
351 Tour.prototype.isFirstStep = function (stepNumber) {
352     var previousStepNumber = this.getPreviousStepNumber(stepNumber);
354     return previousStepNumber === null;
355 };
357 /**
358  * Is this step potentially visible?
359  *
360  * @method  isStepPotentiallyVisible
361  * @param   {Integer}   stepNumber  Step number to test
362  * @return  {Boolean}               Whether the step is the potentially visible
363  */
364 Tour.prototype.isStepPotentiallyVisible = function (stepConfig) {
365     if (!stepConfig) {
366         // Without step config, there can be no step.
367         return false;
368     }
370     if (this.isStepActuallyVisible(stepConfig)) {
371         // If it is actually visible, it is already potentially visible.
372         return true;
373     }
375     if (typeof stepConfig.orphan !== 'undefined' && stepConfig.orphan) {
376         // Orphan steps have no target. They are always visible.
377         return true;
378     }
380     if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay) {
381         // Only return true if the activated has not been used yet.
382         return true;
383     }
385     // Not theoretically, or actually visible.
386     return false;
387 };
389 /**
390  * Is this step actually visible?
391  *
392  * @method  isStepActuallyVisible
393  * @param   {Integer}   stepNumber  Step number to test
394  * @return  {Boolean}               Whether the step is actually visible
395  */
396 Tour.prototype.isStepActuallyVisible = function (stepConfig) {
397     if (!stepConfig) {
398         // Without step config, there can be no step.
399         return false;
400     }
402     var target = this.getStepTarget(stepConfig);
403     if (target && target.length && target.is(':visible')) {
404         // Without a target, there can be no step.
405         return !!target.length;
406     }
408     return false;
409 };
411 /**
412  * Go to the next step in the tour.
413  *
414  * @method  next
415  * @chainable
416  */
417 Tour.prototype.next = function () {
418     return this.gotoStep(this.getNextStepNumber());
419 };
421 /**
422  * Go to the previous step in the tour.
423  *
424  * @method  previous
425  * @chainable
426  */
427 Tour.prototype.previous = function () {
428     return this.gotoStep(this.getPreviousStepNumber(), -1);
429 };
431 /**
432  * Go to the specified step in the tour.
433  *
434  * @method  gotoStep
435  * @param   {Integer}   stepNumber      The step number to display
436  * @chainable
437  */
438 Tour.prototype.gotoStep = function (stepNumber, direction) {
439     if (stepNumber < 0) {
440         return this.endTour();
441     }
443     var stepConfig = this.getStepConfig(stepNumber);
444     if (stepConfig === null) {
445         return this.endTour();
446     }
448     return this._gotoStep(stepConfig, direction);
449 };
451 Tour.prototype._gotoStep = function (stepConfig, direction) {
452     if (!stepConfig) {
453         return this.endTour();
454     }
456     if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay && !stepConfig.delayed) {
457         stepConfig.delayed = true;
458         window.setTimeout(this._gotoStep.bind(this), stepConfig.delay, stepConfig, direction);
460         return this;
461     } else if (!stepConfig.orphan && !this.isStepActuallyVisible(stepConfig)) {
462         var fn = direction == -1 ? 'getPreviousStepNumber' : 'getNextStepNumber';
463         return this.gotoStep(this[fn](stepConfig.stepNumber), direction);
464     }
466     this.hide();
468     this.fireEventHandlers('beforeRender', stepConfig);
469     this.renderStep(stepConfig);
470     this.fireEventHandlers('afterRender', stepConfig);
472     return this;
473 };
475 /**
476  * Fetch the normalised step configuration for the specified step number.
477  *
478  * @method  getStepConfig
479  * @param   {Integer}   stepNumber      The step number to fetch configuration for
480  * @return  {Object}                    The step configuration
481  */
482 Tour.prototype.getStepConfig = function (stepNumber) {
483     if (stepNumber === null || stepNumber < 0 || stepNumber >= this.steps.length) {
484         return null;
485     }
487     // Normalise the step configuration.
488     var stepConfig = this.normalizeStepConfig(this.steps[stepNumber]);
490     // Add the stepNumber to the stepConfig.
491     stepConfig = $.extend(stepConfig, { stepNumber: stepNumber });
493     return stepConfig;
494 };
496 /**
497  * Normalise the supplied step configuration.
498  *
499  * @method  normalizeStepConfig
500  * @param   {Object}    stepConfig      The step configuration to normalise
501  * @return  {Object}                    The normalised step configuration
502  */
503 Tour.prototype.normalizeStepConfig = function (stepConfig) {
505     if (typeof stepConfig.reflex !== 'undefined' && typeof stepConfig.moveAfterClick === 'undefined') {
506         stepConfig.moveAfterClick = stepConfig.reflex;
507     }
509     if (typeof stepConfig.element !== 'undefined' && typeof stepConfig.target === 'undefined') {
510         stepConfig.target = stepConfig.element;
511     }
513     if (typeof stepConfig.content !== 'undefined' && typeof stepConfig.body === 'undefined') {
514         stepConfig.body = stepConfig.content;
515     }
517     stepConfig = $.extend({}, this.stepDefaults, stepConfig);
519     stepConfig = $.extend({}, {
520         attachTo: stepConfig.target,
521         attachPoint: 'after'
522     }, stepConfig);
524     if (stepConfig.attachTo) {
525         stepConfig.attachTo = $(stepConfig.attachTo).first();
526     }
528     return stepConfig;
529 };
531 /**
532  * Fetch the actual step target from the selector.
533  *
534  * This should not be called until after any delay has completed.
535  *
536  * @method  getStepTarget
537  * @param   {Object}    stepConfig      The step configuration
538  * @return  {$}
539  */
540 Tour.prototype.getStepTarget = function (stepConfig) {
541     if (stepConfig.target) {
542         return $(stepConfig.target);
543     }
545     return null;
546 };
548 /**
549  * Fire any event handlers for the specified event.
550  *
551  * @param   {String}    eventName       The name of the event to handle
552  * @param   {Object}    data            Any data to pass to the event
553  * @chainable
554  */
555 Tour.prototype.fireEventHandlers = function (eventName, data) {
556     if (typeof this.eventHandlers[eventName] === 'undefined') {
557         return this;
558     }
560     this.eventHandlers[eventName].forEach(function (thisEvent) {
561         thisEvent.call(this, data);
562     }, this);
564     return this;
565 };
567 /**
568  * @method  addEventHandler
569  * @param   string      eventName       The name of the event to listen for
570  * @param   function    handler         The event handler to call
571  */
572 Tour.prototype.addEventHandler = function (eventName, handler) {
573     if (typeof this.eventHandlers[eventName] === 'undefined') {
574         this.eventHandlers[eventName] = [];
575     }
577     this.eventHandlers[eventName].push(handler);
579     return this;
580 };
582 /**
583  * Process listeners for the step being shown.
584  *
585  * @method  processStepListeners
586  * @param   {object}    stepConfig      The configuration for the step
587  * @chainable
588  */
589 Tour.prototype.processStepListeners = function (stepConfig) {
590     this.listeners.push(
591     // Next/Previous buttons.
592     {
593         node: this.currentStepNode,
594         args: ['click', '[data-role="next"]', $.proxy(this.next, this)]
595     }, {
596         node: this.currentStepNode,
597         args: ['click', '[data-role="previous"]', $.proxy(this.previous, this)]
598     },
600     // Close and end tour buttons.
601     {
602         node: this.currentStepNode,
603         args: ['click', '[data-role="end"]', $.proxy(this.endTour, this)]
604     },
606     // Keypresses.
607     {
608         node: $('body'),
609         args: ['keydown', $.proxy(this.handleKeyDown, this)]
610     });
612     if (stepConfig.moveOnClick) {
613         var targetNode = this.getStepTarget(stepConfig);
614         this.listeners.push({
615             node: targetNode,
616             args: ['click', $.proxy(function (e) {
617                 if ($(e.target).parents('[data-flexitour="container"]').length === 0) {
618                     // Ignore clicks when they are in the flexitour.
619                     window.setTimeout($.proxy(this.next, this), 500);
620                 }
621             }, this)]
622         });
623     }
625     this.listeners.forEach(function (listener) {
626         listener.node.on.apply(listener.node, listener.args);
627     });
629     return this;
630 };
632 /**
633  * Reset step listeners.
634  *
635  * @method  resetStepListeners
636  * @chainable
637  */
638 Tour.prototype.resetStepListeners = function () {
639     // Stop listening to all external handlers.
640     if (this.listeners) {
641         this.listeners.forEach(function (listener) {
642             listener.node.off.apply(listener.node, listener.args);
643         });
644     }
645     this.listeners = [];
647     return this;
648 };
650 /**
651  * The standard step renderer.
652  *
653  * @method  renderStep
654  * @param   {Object}    stepConfig      The step configuration of the step
655  * @chainable
656  */
657 Tour.prototype.renderStep = function (stepConfig) {
658     // Store the current step configuration for later.
659     this.currentStepConfig = stepConfig;
660     this.setCurrentStepNumber(stepConfig.stepNumber);
662     // Fetch the template and convert it to a $ object.
663     var template = $(this.getTemplateContent());
665     // Title.
666     template.find('[data-placeholder="title"]').html(stepConfig.title);
668     // Body.
669     template.find('[data-placeholder="body"]').html(stepConfig.body);
671     // Is this the first step?
672     if (this.isFirstStep(stepConfig.stepNumber)) {
673         template.find('[data-role="previous"]').prop('disabled', true);
674     } else {
675         template.find('[data-role="previous"]').prop('disabled', false);
676     }
678     // Is this the final step?
679     if (this.isLastStep(stepConfig.stepNumber)) {
680         template.find('[data-role="next"]').prop('disabled', true);
681     } else {
682         template.find('[data-role="next"]').prop('disabled', false);
683     }
685     template.find('[data-role="previous"]').attr('role', 'button');
686     template.find('[data-role="next"]').attr('role', 'button');
687     template.find('[data-role="end"]').attr('role', 'button');
689     // Replace the template with the updated version.
690     stepConfig.template = template;
692     // Add to the page.
693     this.addStepToPage(stepConfig);
695     // Process step listeners after adding to the page.
696     // This uses the currentNode.
697     this.processStepListeners(stepConfig);
699     return this;
700 };
702 /**
703  * Getter for the template content.
704  *
705  * @method  getTemplateContent
706  * @return  {$}
707  */
708 Tour.prototype.getTemplateContent = function () {
709     return $(this.templateContent).clone();
710 };
712 /**
713  * Helper to add a step to the page.
714  *
715  * @method  addStepToPage
716  * @param   {Object}    stepConfig      The step configuration of the step
717  * @chainable
718  */
719 Tour.prototype.addStepToPage = function (stepConfig) {
720     var stepContent = stepConfig.template;
722     // Create the stepNode from the template data.
723     var currentStepNode = $('<span data-flexitour="container"></span>').html(stepConfig.template).hide();
725     // The scroll animation occurs on the body or html.
726     var animationTarget = $('body, html').stop(true, true);
728     if (this.isStepActuallyVisible(stepConfig)) {
729         var targetNode = this.getStepTarget(stepConfig);
731         targetNode.data('flexitour', 'target');
733         var zIndex = this.calculateZIndex(targetNode);
734         if (zIndex) {
735             stepConfig.zIndex = zIndex + 1;
736         }
738         if (stepConfig.zIndex) {
739             currentStepNode.css('zIndex', stepConfig.zIndex + 1);
740         }
742         // Add the backdrop.
743         this.positionBackdrop(stepConfig);
745         if (stepConfig.attachPoint === 'append') {
746             stepConfig.attachTo.append(currentStepNode);
747             this.currentStepNode = currentStepNode;
748         } else {
749             this.currentStepNode = currentStepNode.insertAfter(stepConfig.attachTo);
750         }
752         // Ensure that the step node is positioned.
753         // Some situations mean that the value is not properly calculated without this step.
754         this.currentStepNode.css({
755             top: 0,
756             left: 0
757         });
759         animationTarget.animate({
760             scrollTop: this.calculateScrollTop(stepConfig)
761         }).promise().then(function () {
762             this.positionStep(stepConfig);
763             this.revealStep(stepConfig);
764         }.bind(this));
765     } else if (stepConfig.orphan) {
766         stepConfig.isOrphan = true;
768         // This will be appended to the body instead.
769         stepConfig.attachTo = $('body').first();
770         stepConfig.attachPoint = 'append';
772         // Add the backdrop.
773         this.positionBackdrop(stepConfig);
775         // This is an orphaned step.
776         currentStepNode.addClass('orphan');
778         // It lives in the body.
779         stepConfig.attachTo.append(currentStepNode);
780         this.currentStepNode = currentStepNode;
782         this.currentStepNode.offset(this.calculateStepPositionInPage());
783         this.currentStepNode.css('position', 'fixed');
785         this.currentStepPopper = new Popper($('body'), this.currentStepNode[0], {
786             removeOnDestroy: true,
787             placement: stepConfig.placement + '-start',
788             arrowElement: '[data-role="arrow"]',
789             // Empty the modifiers. We've already placed the step and don't want it moved.
790             modifiers: {
791                 hide: {
792                     enabled: false
793                 },
794                 applyStyle: {
795                     onLoad: null,
796                     enabled: false
797                 }
798             }
799         });
801         this.revealStep(stepConfig);
802     }
804     return this;
805 };
807 Tour.prototype.revealStep = function (stepConfig) {
808     // Fade the step in.
809     this.currentStepNode.fadeIn('', $.proxy(function () {
810         // Announce via ARIA.
811         this.announceStep(stepConfig);
813         // Focus on the current step Node.
814         this.currentStepNode.focus();
815         window.setTimeout($.proxy(function () {
816             // After a brief delay, focus again.
817             // There seems to be an issue with Jaws where it only reads the dialogue title initially.
818             // This second focus helps it to read the full dialogue.
819             if (this.currentStepNode) {
820                 this.currentStepNode.focus();
821             }
822         }, this), 100);
823     }, this));
825     return this;
826 };
828 /**
829  * Helper to announce the step on the page.
830  *
831  * @method  announceStep
832  * @param   {Object}    stepConfig      The step configuration of the step
833  * @chainable
834  */
835 Tour.prototype.announceStep = function (stepConfig) {
836     // Setup the step Dialogue as per:
837     // * https://www.w3.org/TR/wai-aria-practices/#dialog_nonmodal
838     // * https://www.w3.org/TR/wai-aria-practices/#dialog_modal
840     // Generate an ID for the current step node.
841     var stepId = 'tour-step-' + this.tourName + '-' + stepConfig.stepNumber;
842     this.currentStepNode.attr('id', stepId);
844     var bodyRegion = this.currentStepNode.find('[data-placeholder="body"]').first();
845     bodyRegion.attr('id', stepId + '-body');
846     bodyRegion.attr('role', 'document');
848     var headerRegion = this.currentStepNode.find('[data-placeholder="title"]').first();
849     headerRegion.attr('id', stepId + '-title');
850     headerRegion.attr('aria-labelledby', stepId + '-body');
852     // Generally, a modal dialog has a role of dialog.
853     this.currentStepNode.attr('role', 'dialog');
854     this.currentStepNode.attr('tabindex', 0);
855     this.currentStepNode.attr('aria-labelledby', stepId + '-title');
856     this.currentStepNode.attr('aria-describedby', stepId + '-body');
858     // Configure ARIA attributes on the target.
859     var target = this.getStepTarget(stepConfig);
860     if (target) {
861         if (!target.attr('tabindex')) {
862             target.attr('tabindex', 0);
863         }
865         target.data('original-describedby', target.attr('aria-describedby')).attr('aria-describedby', stepId + '-body');
866     }
868     this.accessibilityShow(stepConfig);
870     return this;
871 };
873 /**
874  * Handle key down events.
875  *
876  * @method  handleKeyDown
877  * @param   {EventFacade} e
878  */
879 Tour.prototype.handleKeyDown = function (e) {
880     var tabbableSelector = 'a[href], link[href], [draggable=true], [contenteditable=true], :input:enabled, [tabindex], button';
881     switch (e.keyCode) {
882         case 27:
883             this.endTour();
884             break;
886         // 9 == Tab - trap focus for items with a backdrop.
887         case 9:
888             // Tab must be handled on key up only in this instance.
889             (function () {
890                 if (!this.currentStepConfig.hasBackdrop) {
891                     // Trapping tab focus is only handled for those steps with a backdrop.
892                     return;
893                 }
895                 // Find all tabbable locations.
896                 var activeElement = $(document.activeElement);
897                 var stepTarget = this.getStepTarget(this.currentStepConfig);
898                 var tabbableNodes = $(tabbableSelector);
899                 var currentIndex = void 0;
900                 tabbableNodes.filter(function (index, element) {
901                     if (activeElement.is(element)) {
902                         currentIndex = index;
903                         return false;
904                     }
905                 });
907                 var nextIndex = void 0;
908                 var nextNode = void 0;
909                 var focusRelevant = void 0;
910                 if (currentIndex) {
911                     var direction = 1;
912                     if (e.shiftKey) {
913                         direction = -1;
914                     }
915                     nextIndex = currentIndex;
916                     do {
917                         nextIndex += direction;
918                         nextNode = $(tabbableNodes[nextIndex]);
919                     } while (nextNode.length && nextNode.is(':disabled') || nextNode.is(':hidden'));
920                     if (nextNode.length) {
921                         // A new f
922                         focusRelevant = nextNode.closest(stepTarget).length;
923                         focusRelevant = focusRelevant || nextNode.closest(this.currentStepNode).length;
924                     } else {
925                         // Unable to find the target somehow.
926                         focusRelevant = false;
927                     }
928                 }
930                 if (focusRelevant) {
931                     nextNode.focus();
932                 } else {
933                     if (e.shiftKey) {
934                         // Focus on the last tabbable node in the step.
935                         this.currentStepNode.find(tabbableSelector).last().focus();
936                     } else {
937                         if (this.currentStepConfig.isOrphan) {
938                             // Focus on the step - there is no target.
939                             this.currentStepNode.focus();
940                         } else {
941                             // Focus on the step target.
942                             stepTarget.focus();
943                         }
944                     }
945                 }
946                 e.preventDefault();
947             }).call(this);
948             break;
949     }
950 };
952 /**
953  * Start the current tour.
954  *
955  * @method  startTour
956  * @param   {Integer}   startAt     Which step number to start at. If not specified, starts at the last point.
957  * @chainable
958  */
959 Tour.prototype.startTour = function (startAt) {
960     if (typeof startAt === 'undefined') {
961         startAt = this.getCurrentStepNumber();
962     }
964     this.fireEventHandlers('beforeStart', startAt);
965     this.gotoStep(startAt);
966     this.fireEventHandlers('afterStart', startAt);
968     return this;
969 };
971 /**
972  * Restart the tour from the beginning, resetting the completionlag.
973  *
974  * @method  restartTour
975  * @chainable
976  */
977 Tour.prototype.restartTour = function () {
978     return this.startTour(0);
979 };
981 /**
982  * End the current tour.
983  *
984  * @method  endTour
985  * @chainable
986  */
987 Tour.prototype.endTour = function () {
988     this.fireEventHandlers('beforeEnd');
990     if (this.currentStepConfig) {
991         var previousTarget = this.getStepTarget(this.currentStepConfig);
992         if (previousTarget) {
993             if (!previousTarget.attr('tabindex')) {
994                 previousTarget.attr('tabindex', '-1');
995             }
996             previousTarget.focus();
997         }
998     }
1000     this.hide(true);
1002     this.fireEventHandlers('afterEnd');
1004     return this;
1005 };
1007 /**
1008  * Hide any currently visible steps.
1009  *
1010  * @method hide
1011  * @chainable
1012  */
1013 Tour.prototype.hide = function (transition) {
1014     this.fireEventHandlers('beforeHide');
1016     if (this.currentStepNode && this.currentStepNode.length) {
1017         this.currentStepNode.hide();
1018         if (this.currentStepPopper) {
1019             this.currentStepPopper.destroy();
1020         }
1021     }
1023     // Restore original target configuration.
1024     if (this.currentStepConfig) {
1025         var target = this.getStepTarget(this.currentStepConfig);
1026         if (target) {
1027             if (target.data('original-labelledby')) {
1028                 target.attr('aria-labelledby', target.data('original-labelledby'));
1029             }
1031             if (target.data('original-describedby')) {
1032                 target.attr('aria-describedby', target.data('original-describedby'));
1033             }
1035             if (target.data('original-tabindex')) {
1036                 target.attr('tabindex', target.data('tabindex'));
1037             }
1038         }
1040         // Clear the step configuration.
1041         this.currentStepConfig = null;
1042     }
1044     var fadeTime = 0;
1045     if (transition) {
1046         fadeTime = 400;
1047     }
1049     // Remove the backdrop features.
1050     $('[data-flexitour="step-background"]').remove();
1051     $('[data-flexitour="step-backdrop"]').removeAttr('data-flexitour');
1052     $('[data-flexitour="backdrop"]').fadeOut(fadeTime, function () {
1053         $(this).remove();
1054     });
1056     // Reset the listeners.
1057     this.resetStepListeners();
1059     this.accessibilityHide();
1061     this.fireEventHandlers('afterHide');
1063     this.currentStepNode = null;
1064     this.currentStepPopper = null;
1065     return this;
1066 };
1068 /**
1069  * Show the current steps.
1070  *
1071  * @method show
1072  * @chainable
1073  */
1074 Tour.prototype.show = function () {
1075     // Show the current step.
1076     var startAt = this.getCurrentStepNumber();
1078     return this.gotoStep(startAt);
1079 };
1081 /**
1082  * Return the current step node.
1083  *
1084  * @method  getStepContainer
1085  * @return  {jQuery}
1086  */
1087 Tour.prototype.getStepContainer = function () {
1088     return $(this.currentStepNode);
1089 };
1091 /**
1092  * Calculate scrollTop.
1093  *
1094  * @method  calculateScrollTop
1095  * @param   {Object}    stepConfig      The step configuration of the step
1096  * @return  {Number}
1097  */
1098 Tour.prototype.calculateScrollTop = function (stepConfig) {
1099     var scrollTop = $(window).scrollTop();
1100     var viewportHeight = $(window).height();
1101     var targetNode = this.getStepTarget(stepConfig);
1103     if (stepConfig.placement === 'top') {
1104         // If the placement is top, center scroll at the top of the target.
1105         scrollTop = targetNode.offset().top - viewportHeight / 2;
1106     } else if (stepConfig.placement === 'bottom') {
1107         // If the placement is bottom, center scroll at the bottom of the target.
1108         scrollTop = targetNode.offset().top + targetNode.height() - viewportHeight / 2;
1109     } else if (targetNode.height() <= viewportHeight * 0.8) {
1110         // If the placement is left/right, and the target fits in the viewport, centre screen on the target
1111         scrollTop = targetNode.offset().top - (viewportHeight - targetNode.height()) / 2;
1112     } else {
1113         // If the placement is left/right, and the target is bigger than the viewport, set scrollTop to target.top + buffer
1114         // and change step attachmentTarget to top+.
1115         scrollTop = targetNode.offset().top - viewportHeight * 0.2;
1116     }
1118     // Never scroll over the top.
1119     scrollTop = Math.max(0, scrollTop);
1121     // Never scroll beyond the bottom.
1122     scrollTop = Math.min($(document).height() - viewportHeight, scrollTop);
1124     return Math.ceil(scrollTop);
1125 };
1127 /**
1128  * Calculate dialogue position for page middle.
1129  *
1130  * @method  calculateScrollTop
1131  * @return  {Number}
1132  */
1133 Tour.prototype.calculateStepPositionInPage = function () {
1134     var viewportHeight = $(window).height();
1135     var stepHeight = this.currentStepNode.height();
1137     var viewportWidth = $(window).width();
1138     var stepWidth = this.currentStepNode.width();
1140     return {
1141         top: Math.ceil((viewportHeight - stepHeight) / 2),
1142         left: Math.ceil((viewportWidth - stepWidth) / 2)
1143     };
1144 };
1146 /**
1147  * Position the step on the page.
1148  *
1149  * @method  positionStep
1150  * @param   {Object}    stepConfig      The step configuration of the step
1151  * @chainable
1152  */
1153 Tour.prototype.positionStep = function (stepConfig) {
1154     var content = this.currentStepNode;
1155     if (!content || !content.length) {
1156         // Unable to find the step node.
1157         return this;
1158     }
1160     var flipBehavior = void 0;
1161     switch (stepConfig.placement) {
1162         case 'left':
1163             flipBehavior = ['left', 'right', 'top', 'bottom'];
1164             break;
1165         case 'right':
1166             flipBehavior = ['right', 'left', 'top', 'bottom'];
1167             break;
1168         case 'top':
1169             flipBehavior = ['top', 'bottom', 'right', 'left'];
1170             break;
1171         case 'bottom':
1172             flipBehavior = ['bottom', 'top', 'right', 'left'];
1173             break;
1174         default:
1175             flipBehavior = 'flip';
1176             break;
1177     }
1179     var target = this.getStepTarget(stepConfig);
1180     var config = {
1181         placement: stepConfig.placement + '-start',
1182         removeOnDestroy: true,
1183         modifiers: {
1184             flip: {
1185                 behaviour: flipBehavior
1186             },
1187             arrow: {
1188                 element: '[data-role="arrow"]'
1189             }
1190         }
1191     };
1193     var boundaryElement = target.closest('section');
1194     if (boundaryElement.length) {
1195         config.boundariesElement = boundaryElement[0];
1196     }
1198     var background = $('[data-flexitour="step-background"]');
1199     if (background.length) {
1200         target = background;
1201     }
1202     this.currentStepPopper = new Popper(target, content[0], config);
1204     return this;
1205 };
1207 /**
1208  * Add the backdrop.
1209  *
1210  * @method  positionBackdrop
1211  * @param   {Object}    stepConfig      The step configuration of the step
1212  * @chainable
1213  */
1214 Tour.prototype.positionBackdrop = function (stepConfig) {
1215     if (stepConfig.backdrop) {
1216         this.currentStepConfig.hasBackdrop = true;
1217         var backdrop = $('<div data-flexitour="backdrop"></div>');
1219         if (stepConfig.zIndex) {
1220             if (stepConfig.attachPoint === 'append') {
1221                 stepConfig.attachTo.append(backdrop);
1222             } else {
1223                 backdrop.insertAfter(stepConfig.attachTo);
1224             }
1225         } else {
1226             $('body').append(backdrop);
1227         }
1229         if (this.isStepActuallyVisible(stepConfig)) {
1230             // The step has a visible target.
1231             // Punch a hole through the backdrop.
1232             var background = $('<div data-flexitour="step-background"></div>');
1234             var targetNode = this.getStepTarget(stepConfig);
1236             var buffer = 10;
1238             var colorNode = targetNode;
1239             if (buffer) {
1240                 colorNode = $('body');
1241             }
1243             background.css({
1244                 width: targetNode.outerWidth() + buffer + buffer,
1245                 height: targetNode.outerHeight() + buffer + buffer,
1246                 left: targetNode.offset().left - buffer,
1247                 top: targetNode.offset().top - buffer,
1248                 backgroundColor: this.calculateInherittedBackgroundColor(colorNode)
1249             });
1251             if (targetNode.offset().left < buffer) {
1252                 background.css({
1253                     width: targetNode.outerWidth() + targetNode.offset().left + buffer,
1254                     left: targetNode.offset().left
1255                 });
1256             }
1258             if (targetNode.offset().top < buffer) {
1259                 background.css({
1260                     height: targetNode.outerHeight() + targetNode.offset().top + buffer,
1261                     top: targetNode.offset().top
1262                 });
1263             }
1265             var targetRadius = targetNode.css('borderRadius');
1266             if (targetRadius && targetRadius !== $('body').css('borderRadius')) {
1267                 background.css('borderRadius', targetRadius);
1268             }
1270             var targetPosition = this.calculatePosition(targetNode);
1271             if (targetPosition === 'fixed') {
1272                 background.css('top', 0);
1273             }
1275             var fader = background.clone();
1276             fader.css({
1277                 backgroundColor: backdrop.css('backgroundColor'),
1278                 opacity: backdrop.css('opacity')
1279             });
1280             fader.attr('data-flexitour', 'step-background-fader');
1282             if (stepConfig.zIndex) {
1283                 if (stepConfig.attachPoint === 'append') {
1284                     stepConfig.attachTo.append(background);
1285                 } else {
1286                     fader.insertAfter(stepConfig.attachTo);
1287                     background.insertAfter(stepConfig.attachTo);
1288                 }
1289             } else {
1290                 $('body').append(fader);
1291                 $('body').append(background);
1292             }
1294             // Add the backdrop data to the actual target.
1295             // This is the part which actually does the work.
1296             targetNode.attr('data-flexitour', 'step-backdrop');
1298             if (stepConfig.zIndex) {
1299                 backdrop.css('zIndex', stepConfig.zIndex);
1300                 background.css('zIndex', stepConfig.zIndex + 1);
1301                 targetNode.css('zIndex', stepConfig.zIndex + 2);
1302             }
1304             fader.fadeOut('2000', function () {
1305                 $(this).remove();
1306             });
1307         }
1308     }
1309     return this;
1310 };
1312 /**
1313  * Calculate the inheritted z-index.
1314  *
1315  * @method  calculateZIndex
1316  * @param   {jQuery}    elem                        The element to calculate z-index for
1317  * @return  {Number}                                Calculated z-index
1318  */
1319 Tour.prototype.calculateZIndex = function (elem) {
1320     elem = $(elem);
1321     while (elem.length && elem[0] !== document) {
1322         // Ignore z-index if position is set to a value where z-index is ignored by the browser
1323         // This makes behavior of this function consistent across browsers
1324         // WebKit always returns auto if the element is positioned.
1325         var position = elem.css("position");
1326         if (position === "absolute" || position === "relative" || position === "fixed") {
1327             // IE returns 0 when zIndex is not specified
1328             // other browsers return a string
1329             // we ignore the case of nested elements with an explicit value of 0
1330             // <div style="z-index: -10;"><div style="z-index: 0;"></div></div>
1331             var value = parseInt(elem.css("zIndex"), 10);
1332             if (!isNaN(value) && value !== 0) {
1333                 return value;
1334             }
1335         }
1336         elem = elem.parent();
1337     }
1339     return 0;
1340 };
1342 /**
1343  * Calculate the inheritted background colour.
1344  *
1345  * @method  calculateInherittedBackgroundColor
1346  * @param   {jQuery}    elem                        The element to calculate colour for
1347  * @return  {String}                                Calculated background colour
1348  */
1349 Tour.prototype.calculateInherittedBackgroundColor = function (elem) {
1350     // Use a fake node to compare each element against.
1351     var fakeNode = $('<div>').hide();
1352     $('body').append(fakeNode);
1353     var fakeElemColor = fakeNode.css('backgroundColor');
1354     fakeNode.remove();
1356     elem = $(elem);
1357     while (elem.length && elem[0] !== document) {
1358         var color = elem.css('backgroundColor');
1359         if (color !== fakeElemColor) {
1360             return color;
1361         }
1362         elem = elem.parent();
1363     }
1365     return null;
1366 };
1368 /**
1369  * Calculate the inheritted position.
1370  *
1371  * @method  calculatePosition
1372  * @param   {jQuery}    elem                        The element to calculate position for
1373  * @return  {String}                                Calculated position
1374  */
1375 Tour.prototype.calculatePosition = function (elem) {
1376     elem = $(elem);
1377     while (elem.length && elem[0] !== document) {
1378         var position = elem.css('position');
1379         if (position !== 'static') {
1380             return position;
1381         }
1382         elem = elem.parent();
1383     }
1385     return null;
1386 };
1388 /**
1389  * Perform accessibility changes for step shown.
1390  *
1391  * This will add aria-hidden="true" to all siblings and parent siblings.
1392  *
1393  * @method  accessibilityShow
1394  */
1395 Tour.prototype.accessibilityShow = function () {
1396     var stateHolder = 'data-has-hidden';
1397     var attrName = 'aria-hidden';
1398     var hideFunction = function hideFunction(child) {
1399         var flexitourRole = child.data('flexitour');
1400         if (flexitourRole) {
1401             switch (flexitourRole) {
1402                 case 'container':
1403                 case 'target':
1404                     return;
1405             }
1406         }
1408         var hidden = child.attr(attrName);
1409         if (!hidden) {
1410             child.attr(stateHolder, true);
1411             child.attr(attrName, true);
1412         }
1413     };
1415     this.currentStepNode.siblings().each(function (index, node) {
1416         hideFunction($(node));
1417     });
1418     this.currentStepNode.parentsUntil('body').siblings().each(function (index, node) {
1419         hideFunction($(node));
1420     });
1421 };
1423 /**
1424  * Perform accessibility changes for step hidden.
1425  *
1426  * This will remove any newly added aria-hidden="true".
1427  *
1428  * @method  accessibilityHide
1429  */
1430 Tour.prototype.accessibilityHide = function () {
1431     var stateHolder = 'data-has-hidden';
1432     var attrName = 'aria-hidden';
1433     var showFunction = function showFunction(child) {
1434         var hidden = child.attr(stateHolder);
1435         if (typeof hidden !== 'undefined') {
1436             child.removeAttr(stateHolder);
1437             child.removeAttr(attrName);
1438         }
1439     };
1441     $('[' + stateHolder + ']').each(function (index, node) {
1442         showFunction($(node));
1443     });
1444 };
1446 if ((typeof exports === 'undefined' ? 'undefined' : _typeof(exports)) === 'object') {
1447     module.exports = Tour;
1450 return Tour;
1452 }));