393f546b7e7792cdad51f4592cbdf83106747e8a
[moodle.git] / admin / tool / usertours / amd / src / tour.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * Manage user tours in Moodle.
18  *
19  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
20  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
21  */
23 import $ from 'jquery';
24 import Popper from 'core/popper';
26 /**
27  * A Tour.
28  *
29  * @class Tour
30  */
31 export default class Tour {
32     /**
33      * @param   {object}    config  The configuration object.
34      */
35     constructor(config) {
36         this.init(config);
37     }
39     /**
40      * Initialise the tour.
41      *
42      * @method  init
43      * @param   {Object}    config  The configuration object.
44      * @chainable
45      * @return {Object} this.
46      */
47     init(config) {
48         // Unset all handlers.
49         this.eventHandlers = {};
51         // Reset the current tour states.
52         this.reset();
54         // Store the initial configuration.
55         this.originalConfiguration = config || {};
57         // Apply configuration.
58         this.configure.apply(this, arguments);
60         try {
61             this.storage = window.sessionStorage;
62             this.storageKey = 'tourstate_' + this.tourName;
63         } catch (e) {
64             this.storage = false;
65             this.storageKey = '';
66         }
68         return this;
69     }
71     /**
72      * Reset the current tour state.
73      *
74      * @method  reset
75      * @chainable
76      * @return {Object} this.
77      */
78     reset() {
79         // Hide the current step.
80         this.hide();
82         // Unset all handlers.
83         this.eventHandlers = [];
85         // Unset all listeners.
86         this.resetStepListeners();
88         // Unset the original configuration.
89         this.originalConfiguration = {};
91         // Reset the current step number and list of steps.
92         this.steps = [];
94         // Reset the current step number.
95         this.currentStepNumber = 0;
97         return this;
98     }
100     /**
101      * Prepare tour configuration.
102      *
103      * @method  configure
104      * @param {Object} config The configuration object.
105      * @chainable
106      * @return {Object} this.
107      */
108     configure(config) {
109         if (typeof config === 'object') {
110             // Tour name.
111             if (typeof config.tourName !== 'undefined') {
112                 this.tourName = config.tourName;
113             }
115             // Set up eventHandlers.
116             if (config.eventHandlers) {
117                 for (let eventName in config.eventHandlers) {
118                     config.eventHandlers[eventName].forEach(function(handler) {
119                         this.addEventHandler(eventName, handler);
120                     }, this);
121                 }
122             }
124             // Reset the step configuration.
125             this.resetStepDefaults(true);
127             // Configure the steps.
128             if (typeof config.steps === 'object') {
129                 this.steps = config.steps;
130             }
132             if (typeof config.template !== 'undefined') {
133                 this.templateContent = config.template;
134             }
135         }
137         // Check that we have enough to start the tour.
138         this.checkMinimumRequirements();
140         return this;
141     }
143     /**
144      * Check that the configuration meets the minimum requirements.
145      *
146      * @method  checkMinimumRequirements
147      */
148     checkMinimumRequirements() {
149         // Need a tourName.
150         if (!this.tourName) {
151             throw new Error("Tour Name required");
152         }
154         // Need a minimum of one step.
155         if (!this.steps || !this.steps.length) {
156             throw new Error("Steps must be specified");
157         }
158     }
160     /**
161      * Reset step default configuration.
162      *
163      * @method  resetStepDefaults
164      * @param   {Boolean}   loadOriginalConfiguration   Whether to load the original configuration supplied with the Tour.
165      * @chainable
166      * @return {Object} this.
167      */
168     resetStepDefaults(loadOriginalConfiguration) {
169         if (typeof loadOriginalConfiguration === 'undefined') {
170             loadOriginalConfiguration = true;
171         }
173         this.stepDefaults = {};
174         if (!loadOriginalConfiguration || typeof this.originalConfiguration.stepDefaults === 'undefined') {
175             this.setStepDefaults({});
176         } else {
177             this.setStepDefaults(this.originalConfiguration.stepDefaults);
178         }
180         return this;
181     }
183     /**
184      * Set the step defaults.
185      *
186      * @method  setStepDefaults
187      * @param   {Object}    stepDefaults                The step defaults to apply to all steps
188      * @chainable
189      * @return {Object} this.
190      */
191     setStepDefaults(stepDefaults) {
192         if (!this.stepDefaults) {
193             this.stepDefaults = {};
194         }
195         $.extend(
196             this.stepDefaults,
197             {
198                 element:        '',
199                 placement:      'top',
200                 delay:          0,
201                 moveOnClick:    false,
202                 moveAfterTime:  0,
203                 orphan:         false,
204                 direction:      1,
205             },
206             stepDefaults
207         );
209         return this;
210     }
212     /**
213      * Retrieve the current step number.
214      *
215      * @method  getCurrentStepNumber
216      * @return  {Integer}                   The current step number
217      */
218     getCurrentStepNumber() {
219         return parseInt(this.currentStepNumber, 10);
220     }
222     /**
223      * Store the current step number.
224      *
225      * @method  setCurrentStepNumber
226      * @param   {Integer}   stepNumber      The current step number
227      * @chainable
228      */
229     setCurrentStepNumber(stepNumber) {
230         this.currentStepNumber = stepNumber;
231         if (this.storage) {
232             try {
233                 this.storage.setItem(this.storageKey, stepNumber);
234             } catch (e) {
235                 if (e.code === DOMException.QUOTA_EXCEEDED_ERR) {
236                     this.storage.removeItem(this.storageKey);
237                 }
238             }
239         }
240     }
242     /**
243      * Get the next step number after the currently displayed step.
244      *
245      * @method  getNextStepNumber
246      * @param   {Integer}   stepNumber      The current step number
247      * @return  {Integer}    The next step number to display
248      */
249     getNextStepNumber(stepNumber) {
250         if (typeof stepNumber === 'undefined') {
251             stepNumber = this.getCurrentStepNumber();
252         }
253         let nextStepNumber = stepNumber + 1;
255         // Keep checking the remaining steps.
256         while (nextStepNumber <= this.steps.length) {
257             if (this.isStepPotentiallyVisible(this.getStepConfig(nextStepNumber))) {
258                 return nextStepNumber;
259             }
260             nextStepNumber++;
261         }
263         return null;
264     }
266     /**
267      * Get the previous step number before the currently displayed step.
268      *
269      * @method  getPreviousStepNumber
270      * @param   {Integer}   stepNumber      The current step number
271      * @return  {Integer}    The previous step number to display
272      */
273     getPreviousStepNumber(stepNumber) {
274         if (typeof stepNumber === 'undefined') {
275             stepNumber = this.getCurrentStepNumber();
276         }
277         let previousStepNumber = stepNumber - 1;
279         // Keep checking the remaining steps.
280         while (previousStepNumber >= 0) {
281             if (this.isStepPotentiallyVisible(this.getStepConfig(previousStepNumber))) {
282                 return previousStepNumber;
283             }
284             previousStepNumber--;
285         }
287         return null;
288     }
290     /**
291      * Is the step the final step number?
292      *
293      * @method  isLastStep
294      * @param   {Integer}   stepNumber  Step number to test
295      * @return  {Boolean}               Whether the step is the final step
296      */
297     isLastStep(stepNumber) {
298         let nextStepNumber = this.getNextStepNumber(stepNumber);
300         return nextStepNumber === null;
301     }
303     /**
304      * Is the step the first step number?
305      *
306      * @method  isFirstStep
307      * @param   {Integer}   stepNumber  Step number to test
308      * @return  {Boolean}               Whether the step is the first step
309      */
310     isFirstStep(stepNumber) {
311         let previousStepNumber = this.getPreviousStepNumber(stepNumber);
313         return previousStepNumber === null;
314     }
316     /**
317      * Is this step potentially visible?
318      *
319      * @method  isStepPotentiallyVisible
320      * @param   {Object}    stepConfig      The step configuration to normalise
321      * @return  {Boolean}               Whether the step is the potentially visible
322      */
323     isStepPotentiallyVisible(stepConfig) {
324         if (!stepConfig) {
325             // Without step config, there can be no step.
326             return false;
327         }
329         if (this.isStepActuallyVisible(stepConfig)) {
330             // If it is actually visible, it is already potentially visible.
331             return true;
332         }
334         if (typeof stepConfig.orphan !== 'undefined' && stepConfig.orphan) {
335             // Orphan steps have no target. They are always visible.
336             return true;
337         }
339         if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay) {
340             // Only return true if the activated has not been used yet.
341             return true;
342         }
344         // Not theoretically, or actually visible.
345         return false;
346     }
348     /**
349      * Is this step actually visible?
350      *
351      * @method  isStepActuallyVisible
352      * @param   {Object}    stepConfig      The step configuration to normalise
353      * @return  {Boolean}               Whether the step is actually visible
354      */
355     isStepActuallyVisible(stepConfig) {
356         if (!stepConfig) {
357             // Without step config, there can be no step.
358             return false;
359         }
361         let target = this.getStepTarget(stepConfig);
362         if (target && target.length && target.is(':visible')) {
363             // Without a target, there can be no step.
364             return !!target.length;
365         }
367         return false;
368     }
370     /**
371      * Go to the next step in the tour.
372      *
373      * @method  next
374      * @chainable
375      * @return {Object} this.
376      */
377     next() {
378         return this.gotoStep(this.getNextStepNumber());
379     }
381     /**
382      * Go to the previous step in the tour.
383      *
384      * @method  previous
385      * @chainable
386      * @return {Object} this.
387      */
388     previous() {
389         return this.gotoStep(this.getPreviousStepNumber(), -1);
390     }
392     /**
393      * Go to the specified step in the tour.
394      *
395      * @method  gotoStep
396      * @param   {Integer}   stepNumber     The step number to display
397      * @param   {Integer}   direction      Next or previous step
398      * @chainable
399      * @return {Object} this.
400      */
401     gotoStep(stepNumber, direction) {
402         if (stepNumber < 0) {
403             return this.endTour();
404         }
406         let stepConfig = this.getStepConfig(stepNumber);
407         if (stepConfig === null) {
408             return this.endTour();
409         }
411         return this._gotoStep(stepConfig, direction);
412     }
414     _gotoStep(stepConfig, direction) {
415         if (!stepConfig) {
416             return this.endTour();
417         }
419         if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay && !stepConfig.delayed) {
420             stepConfig.delayed = true;
421             window.setTimeout(this._gotoStep.bind(this), stepConfig.delay, stepConfig, direction);
423             return this;
424         } else if (!stepConfig.orphan && !this.isStepActuallyVisible(stepConfig)) {
425             let fn = direction == -1 ? 'getPreviousStepNumber' : 'getNextStepNumber';
426             return this.gotoStep(this[fn](stepConfig.stepNumber), direction);
427         }
429         this.hide();
431         this.fireEventHandlers('beforeRender', stepConfig);
432         this.renderStep(stepConfig);
433         this.fireEventHandlers('afterRender', stepConfig);
435         return this;
436     }
438     /**
439      * Fetch the normalised step configuration for the specified step number.
440      *
441      * @method  getStepConfig
442      * @param   {Integer}   stepNumber      The step number to fetch configuration for
443      * @return  {Object}                    The step configuration
444      */
445     getStepConfig(stepNumber) {
446         if (stepNumber === null || stepNumber < 0 || stepNumber >= this.steps.length) {
447             return null;
448         }
450         // Normalise the step configuration.
451         let stepConfig = this.normalizeStepConfig(this.steps[stepNumber]);
453         // Add the stepNumber to the stepConfig.
454         stepConfig = $.extend(stepConfig, {stepNumber: stepNumber});
456         return stepConfig;
457     }
459     /**
460      * Normalise the supplied step configuration.
461      *
462      * @method  normalizeStepConfig
463      * @param   {Object}    stepConfig      The step configuration to normalise
464      * @return  {Object}                    The normalised step configuration
465      */
466     normalizeStepConfig(stepConfig) {
468         if (typeof stepConfig.reflex !== 'undefined' && typeof stepConfig.moveAfterClick === 'undefined') {
469             stepConfig.moveAfterClick = stepConfig.reflex;
470         }
472         if (typeof stepConfig.element !== 'undefined' && typeof stepConfig.target === 'undefined') {
473             stepConfig.target = stepConfig.element;
474         }
476         if (typeof stepConfig.content !== 'undefined' && typeof stepConfig.body === 'undefined') {
477             stepConfig.body = stepConfig.content;
478         }
480         stepConfig = $.extend({}, this.stepDefaults, stepConfig);
482         stepConfig = $.extend({}, {
483             attachTo: stepConfig.target,
484             attachPoint: 'after',
485         }, stepConfig);
487         if (stepConfig.attachTo) {
488             stepConfig.attachTo = $(stepConfig.attachTo).first();
489         }
491         return stepConfig;
492     }
494     /**
495      * Fetch the actual step target from the selector.
496      *
497      * This should not be called until after any delay has completed.
498      *
499      * @method  getStepTarget
500      * @param   {Object}    stepConfig      The step configuration
501      * @return  {$}
502      */
503     getStepTarget(stepConfig) {
504         if (stepConfig.target) {
505             return $(stepConfig.target);
506         }
508         return null;
509     }
511     /**
512      * Fire any event handlers for the specified event.
513      *
514      * @param   {String}    eventName       The name of the event to handle
515      * @param   {Object}    data            Any data to pass to the event
516      * @chainable
517      * @return {Object} this.
518      */
519     fireEventHandlers(eventName, data) {
520         if (typeof this.eventHandlers[eventName] === 'undefined') {
521             return this;
522         }
524         this.eventHandlers[eventName].forEach(function(thisEvent) {
525             thisEvent.call(this, data);
526         }, this);
528         return this;
529     }
531     /**
532      * @method addEventHandler
533      * @param  {string}      eventName       The name of the event to listen for
534      * @param  {function}    handler         The event handler to call
535      * @return {Object} this.
536      */
537     addEventHandler(eventName, handler) {
538         if (typeof this.eventHandlers[eventName] === 'undefined') {
539             this.eventHandlers[eventName] = [];
540         }
542         this.eventHandlers[eventName].push(handler);
544         return this;
545     }
547     /**
548      * Process listeners for the step being shown.
549      *
550      * @method  processStepListeners
551      * @param   {object}    stepConfig      The configuration for the step
552      * @chainable
553      * @return {Object} this.
554      */
555     processStepListeners(stepConfig) {
556         this.listeners.push(
557         // Next/Previous buttons.
558         {
559             node: this.currentStepNode,
560             args: ['click', '[data-role="next"]', $.proxy(this.next, this)]
561         }, {
562             node: this.currentStepNode,
563             args: ['click', '[data-role="previous"]', $.proxy(this.previous, this)]
564         },
566         // Close and end tour buttons.
567         {
568             node: this.currentStepNode,
569             args: ['click', '[data-role="end"]', $.proxy(this.endTour, this)]
570         },
572         // Click backdrop and hide tour.
573         {
574             node: $('[data-flexitour="backdrop"]'),
575             args: ['click', $.proxy(this.hide, this)]
576         },
578         // Keypresses.
579         {
580             node: $('body'),
581             args: ['keydown', $.proxy(this.handleKeyDown, this)]
582         });
584         if (stepConfig.moveOnClick) {
585             var targetNode = this.getStepTarget(stepConfig);
586             this.listeners.push({
587                 node: targetNode,
588                 args: ['click', $.proxy(function(e) {
589                     if ($(e.target).parents('[data-flexitour="container"]').length === 0) {
590                         // Ignore clicks when they are in the flexitour.
591                         window.setTimeout($.proxy(this.next, this), 500);
592                     }
593                 }, this)]
594             });
595         }
597         this.listeners.forEach(function(listener) {
598             listener.node.on.apply(listener.node, listener.args);
599         });
601         return this;
602     }
604     /**
605      * Reset step listeners.
606      *
607      * @method  resetStepListeners
608      * @chainable
609      * @return {Object} this.
610      */
611     resetStepListeners() {
612         // Stop listening to all external handlers.
613         if (this.listeners) {
614             this.listeners.forEach(function(listener) {
615                 listener.node.off.apply(listener.node, listener.args);
616             });
617         }
618         this.listeners = [];
620         return this;
621     }
623     /**
624      * The standard step renderer.
625      *
626      * @method  renderStep
627      * @param   {Object}    stepConfig      The step configuration of the step
628      * @chainable
629      * @return {Object} this.
630      */
631     renderStep(stepConfig) {
632         // Store the current step configuration for later.
633         this.currentStepConfig = stepConfig;
634         this.setCurrentStepNumber(stepConfig.stepNumber);
636         // Fetch the template and convert it to a $ object.
637         let template = $(this.getTemplateContent());
639         // Title.
640         template.find('[data-placeholder="title"]')
641             .html(stepConfig.title);
643         // Body.
644         template.find('[data-placeholder="body"]')
645             .html(stepConfig.body);
647         // Is this the first step?
648         if (this.isFirstStep(stepConfig.stepNumber)) {
649             template.find('[data-role="previous"]').hide();
650         } else {
651             template.find('[data-role="previous"]').prop('disabled', false);
652         }
654         // Is this the final step?
655         if (this.isLastStep(stepConfig.stepNumber)) {
656             template.find('[data-role="next"]').hide();
657             template.find('[data-role="end"]').removeClass("btn-secondary").addClass("btn-primary");
658         } else {
659             template.find('[data-role="next"]').prop('disabled', false);
660         }
662         template.find('[data-role="previous"]').attr('role', 'button');
663         template.find('[data-role="next"]').attr('role', 'button');
664         template.find('[data-role="end"]').attr('role', 'button');
666         // Replace the template with the updated version.
667         stepConfig.template = template;
669         // Add to the page.
670         this.addStepToPage(stepConfig);
672         // Process step listeners after adding to the page.
673         // This uses the currentNode.
674         this.processStepListeners(stepConfig);
676         return this;
677     }
679     /**
680      * Getter for the template content.
681      *
682      * @method  getTemplateContent
683      * @return  {$}
684      */
685     getTemplateContent() {
686         return $(this.templateContent).clone();
687     }
689     /**
690      * Helper to add a step to the page.
691      *
692      * @method  addStepToPage
693      * @param   {Object}    stepConfig      The step configuration of the step
694      * @chainable
695      * @return {Object} this.
696      */
697     addStepToPage(stepConfig) {
698         // Create the stepNode from the template data.
699         let currentStepNode = $('<span data-flexitour="container"></span>')
700             .html(stepConfig.template)
701             .hide();
703         // The scroll animation occurs on the body or html.
704         let animationTarget = $('body, html')
705             .stop(true, true);
707         if (this.isStepActuallyVisible(stepConfig)) {
708             let targetNode = this.getStepTarget(stepConfig);
710             targetNode.data('flexitour', 'target');
712             let zIndex = this.calculateZIndex(targetNode);
713             if (zIndex) {
714                 stepConfig.zIndex = zIndex + 1;
715             }
717             if (stepConfig.zIndex) {
718                 currentStepNode.css('zIndex', stepConfig.zIndex + 1);
719             }
721             // Add the backdrop.
722             this.positionBackdrop(stepConfig);
724             $(document.body).append(currentStepNode);
725             this.currentStepNode = currentStepNode;
727             // Ensure that the step node is positioned.
728             // Some situations mean that the value is not properly calculated without this step.
729             this.currentStepNode.css({
730                 top: 0,
731                 left: 0,
732             });
734             animationTarget
735                 .animate({
736                     scrollTop: this.calculateScrollTop(stepConfig),
737                 }).promise().then(function() {
738                         this.positionStep(stepConfig);
739                         this.revealStep(stepConfig);
740                         return;
741                     }.bind(this))
742                     .catch(function() {
743                         // Silently fail.
744                     });
746         } else if (stepConfig.orphan) {
747             stepConfig.isOrphan = true;
749             // This will be appended to the body instead.
750             stepConfig.attachTo = $('body').first();
751             stepConfig.attachPoint = 'append';
753             // Add the backdrop.
754             this.positionBackdrop(stepConfig);
756             // This is an orphaned step.
757             currentStepNode.addClass('orphan');
759             // It lives in the body.
760             $(document.body).append(currentStepNode);
761             this.currentStepNode = currentStepNode;
763             this.currentStepNode.offset(this.calculateStepPositionInPage());
764             this.currentStepNode.css('position', 'fixed');
766             this.currentStepPopper = new Popper(
767                 $('body'),
768                 this.currentStepNode[0], {
769                     removeOnDestroy: true,
770                     placement: stepConfig.placement + '-start',
771                     arrowElement: '[data-role="arrow"]',
772                     // Empty the modifiers. We've already placed the step and don't want it moved.
773                     modifiers: {
774                         hide: {
775                             enabled: false,
776                         },
777                         applyStyle: {
778                             onLoad: null,
779                             enabled: false,
780                         },
781                     }
782                 }
783             );
785             this.revealStep(stepConfig);
786         }
788         return this;
789     }
791     /**
792      * Make the given step visible.
793      *
794      * @method revealStep
795      * @param {Object} stepConfig The step configuration of the step
796      * @chainable
797      * @return {Object} this.
798      */
799     revealStep(stepConfig) {
800         // Fade the step in.
801         this.currentStepNode.fadeIn('', $.proxy(function() {
802                 // Announce via ARIA.
803                 this.announceStep(stepConfig);
805                 // Focus on the current step Node.
806                 this.currentStepNode.focus();
807                 window.setTimeout($.proxy(function() {
808                     // After a brief delay, focus again.
809                     // There seems to be an issue with Jaws where it only reads the dialogue title initially.
810                     // This second focus helps it to read the full dialogue.
811                     if (this.currentStepNode) {
812                         this.currentStepNode.focus();
813                     }
814                 }, this), 100);
816             }, this));
818         return this;
819     }
821     /**
822      * Helper to announce the step on the page.
823      *
824      * @method  announceStep
825      * @param   {Object}    stepConfig      The step configuration of the step
826      * @chainable
827      * @return {Object} this.
828      */
829     announceStep(stepConfig) {
830         // Setup the step Dialogue as per:
831         // * https://www.w3.org/TR/wai-aria-practices/#dialog_nonmodal
832         // * https://www.w3.org/TR/wai-aria-practices/#dialog_modal
834         // Generate an ID for the current step node.
835         let stepId = 'tour-step-' + this.tourName + '-' + stepConfig.stepNumber;
836         this.currentStepNode.attr('id', stepId);
838         let bodyRegion = this.currentStepNode.find('[data-placeholder="body"]').first();
839         bodyRegion.attr('id', stepId + '-body');
840         bodyRegion.attr('role', 'document');
842         let headerRegion = this.currentStepNode.find('[data-placeholder="title"]').first();
843         headerRegion.attr('id', stepId + '-title');
844         headerRegion.attr('aria-labelledby', stepId + '-body');
846         // Generally, a modal dialog has a role of dialog.
847         this.currentStepNode.attr('role', 'dialog');
848         this.currentStepNode.attr('tabindex', 0);
849         this.currentStepNode.attr('aria-labelledby', stepId + '-title');
850         this.currentStepNode.attr('aria-describedby', stepId + '-body');
852         // Configure ARIA attributes on the target.
853         let target = this.getStepTarget(stepConfig);
854         if (target) {
855             if (!target.attr('tabindex')) {
856                 target.attr('tabindex', 0);
857             }
859             target
860                 .data('original-describedby', target.attr('aria-describedby'))
861                 .attr('aria-describedby', stepId + '-body')
862                 ;
863         }
865         this.accessibilityShow(stepConfig);
867         return this;
868     }
870     /**
871      * Handle key down events.
872      *
873      * @method  handleKeyDown
874      * @param   {EventFacade} e
875      */
876     handleKeyDown(e) {
877         let tabbableSelector = 'a[href], link[href], [draggable=true], [contenteditable=true], ';
878         tabbableSelector += ':input:enabled, [tabindex], button:enabled';
879         switch (e.keyCode) {
880             case 27:
881                 this.endTour();
882                 break;
884             // 9 == Tab - trap focus for items with a backdrop.
885             case 9:
886                 // Tab must be handled on key up only in this instance.
887                 (function() {
888                     if (!this.currentStepConfig.hasBackdrop) {
889                         // Trapping tab focus is only handled for those steps with a backdrop.
890                         return;
891                     }
893                     // Find all tabbable locations.
894                     let activeElement = $(document.activeElement);
895                     let stepTarget = this.getStepTarget(this.currentStepConfig);
896                     let tabbableNodes = $(tabbableSelector);
897                     let dialogContainer = $('span[data-flexitour="container"]');
898                     let currentIndex;
899                     // Filter out element which is not belong to target section or dialogue.
900                     if (stepTarget) {
901                         tabbableNodes = tabbableNodes.filter(function(index, element) {
902                             return stepTarget !== null
903                                 && (stepTarget.has(element).length
904                                     || dialogContainer.has(element).length
905                                     || stepTarget.is(element)
906                                     || dialogContainer.is(element));
907                         });
908                     }
910                     // Find index of focusing element.
911                     tabbableNodes.each(function(index, element) {
912                         if (activeElement.is(element)) {
913                             currentIndex = index;
914                             return false;
915                         }
916                         // Keep looping.
917                         return true;
918                     });
920                     let nextIndex;
921                     let nextNode;
922                     let focusRelevant;
923                     if (currentIndex != void 0) {
924                         let direction = 1;
925                         if (e.shiftKey) {
926                             direction = -1;
927                         }
928                         nextIndex = currentIndex;
929                         do {
930                             nextIndex += direction;
931                             nextNode = $(tabbableNodes[nextIndex]);
932                         } while (nextNode.length && nextNode.is(':disabled') || nextNode.is(':hidden'));
933                         if (nextNode.length) {
934                             // A new f
935                             focusRelevant = nextNode.closest(stepTarget).length;
936                             focusRelevant = focusRelevant || nextNode.closest(this.currentStepNode).length;
937                         } else {
938                             // Unable to find the target somehow.
939                             focusRelevant = false;
940                         }
941                     }
943                     if (focusRelevant) {
944                         nextNode.focus();
945                     } else {
946                         if (e.shiftKey) {
947                             // Focus on the last tabbable node in the step.
948                             this.currentStepNode.find(tabbableSelector).last().focus();
949                         } else {
950                             if (this.currentStepConfig.isOrphan) {
951                                 // Focus on the step - there is no target.
952                                 this.currentStepNode.focus();
953                             } else {
954                                 // Focus on the step target.
955                                 stepTarget.focus();
956                             }
957                         }
958                     }
959                     e.preventDefault();
960                 }).call(this);
961                 break;
962         }
963     }
965     /**
966      * Start the current tour.
967      *
968      * @method  startTour
969      * @param   {Integer}   startAt     Which step number to start at. If not specified, starts at the last point.
970      * @chainable
971      * @return {Object} this.
972      */
973     startTour(startAt) {
974         if (this.storage && typeof startAt === 'undefined') {
975             let storageStartValue = this.storage.getItem(this.storageKey);
976             if (storageStartValue) {
977                 let storageStartAt = parseInt(storageStartValue, 10);
978                 if (storageStartAt <= this.steps.length) {
979                     startAt = storageStartAt;
980                 }
981             }
982         }
984         if (typeof startAt === 'undefined') {
985             startAt = this.getCurrentStepNumber();
986         }
988         this.fireEventHandlers('beforeStart', startAt);
989         this.gotoStep(startAt);
990         this.fireEventHandlers('afterStart', startAt);
992         return this;
993     }
995     /**
996      * Restart the tour from the beginning, resetting the completionlag.
997      *
998      * @method  restartTour
999      * @chainable
1000      * @return {Object} this.
1001      */
1002     restartTour() {
1003         return this.startTour(0);
1004     }
1006     /**
1007      * End the current tour.
1008      *
1009      * @method  endTour
1010      * @chainable
1011      * @return {Object} this.
1012      */
1013     endTour() {
1014         this.fireEventHandlers('beforeEnd');
1016         if (this.currentStepConfig) {
1017             let previousTarget = this.getStepTarget(this.currentStepConfig);
1018             if (previousTarget) {
1019                 if (!previousTarget.attr('tabindex')) {
1020                     previousTarget.attr('tabindex', '-1');
1021                 }
1022                 previousTarget.focus();
1023             }
1024         }
1026         this.hide(true);
1028         this.fireEventHandlers('afterEnd');
1030         return this;
1031     }
1033     /**
1034      * Hide any currently visible steps.
1035      *
1036      * @method hide
1037      * @param {Bool} transition Animate the visibility change
1038      * @chainable
1039      * @return {Object} this.
1040      */
1041     hide(transition) {
1042         this.fireEventHandlers('beforeHide');
1044         if (this.currentStepNode && this.currentStepNode.length) {
1045             this.currentStepNode.hide();
1046             if (this.currentStepPopper) {
1047                 this.currentStepPopper.destroy();
1048             }
1049         }
1051         // Restore original target configuration.
1052         if (this.currentStepConfig) {
1053             let target = this.getStepTarget(this.currentStepConfig);
1054             if (target) {
1055                 if (target.data('original-labelledby')) {
1056                     target.attr('aria-labelledby', target.data('original-labelledby'));
1057                 }
1059                 if (target.data('original-describedby')) {
1060                     target.attr('aria-describedby', target.data('original-describedby'));
1061                 }
1063                 if (target.data('original-tabindex')) {
1064                     target.attr('tabindex', target.data('tabindex'));
1065                 }
1066             }
1068             // Clear the step configuration.
1069             this.currentStepConfig = null;
1070         }
1072         let fadeTime = 0;
1073         if (transition) {
1074             fadeTime = 400;
1075         }
1077         // Remove the backdrop features.
1078         $('[data-flexitour="step-background"]').remove();
1079         $('[data-flexitour="step-backdrop"]').removeAttr('data-flexitour');
1080         $('[data-flexitour="backdrop"]').fadeOut(fadeTime, function() {
1081             $(this).remove();
1082         });
1084         // Remove aria-describedby and tabindex attributes.
1085         if (this.currentStepNode && this.currentStepNode.length) {
1086             let stepId = this.currentStepNode.attr('id');
1087             if (stepId) {
1088                 let currentStepElement = '[aria-describedby="' + stepId + '-body"]';
1089                 $(currentStepElement).removeAttr('tabindex');
1090                 $(currentStepElement).removeAttr('aria-describedby');
1091             }
1092         }
1094         // Reset the listeners.
1095         this.resetStepListeners();
1097         this.accessibilityHide();
1099         this.fireEventHandlers('afterHide');
1101         this.currentStepNode = null;
1102         this.currentStepPopper = null;
1103         return this;
1104     }
1106     /**
1107      * Show the current steps.
1108      *
1109      * @method show
1110      * @chainable
1111      * @return {Object} this.
1112      */
1113     show() {
1114         // Show the current step.
1115         let startAt = this.getCurrentStepNumber();
1117         return this.gotoStep(startAt);
1118     }
1120     /**
1121      * Return the current step node.
1122      *
1123      * @method  getStepContainer
1124      * @return  {jQuery}
1125      */
1126     getStepContainer() {
1127         return $(this.currentStepNode);
1128     }
1130     /**
1131      * Calculate scrollTop.
1132      *
1133      * @method  calculateScrollTop
1134      * @param   {Object}    stepConfig      The step configuration of the step
1135      * @return  {Number}
1136      */
1137     calculateScrollTop(stepConfig) {
1138         let scrollTop = $(window).scrollTop();
1139         let viewportHeight = $(window).height();
1140         let targetNode = this.getStepTarget(stepConfig);
1142         if (stepConfig.placement === 'top') {
1143             // If the placement is top, center scroll at the top of the target.
1144             scrollTop = targetNode.offset().top - (viewportHeight / 2);
1145         } else if (stepConfig.placement === 'bottom') {
1146             // If the placement is bottom, center scroll at the bottom of the target.
1147             scrollTop = targetNode.offset().top + targetNode.height() - (viewportHeight / 2);
1148         } else if (targetNode.height() <= (viewportHeight * 0.8)) {
1149             // If the placement is left/right, and the target fits in the viewport, centre screen on the target
1150             scrollTop = targetNode.offset().top - ((viewportHeight - targetNode.height()) / 2);
1151         } else {
1152             // If the placement is left/right, and the target is bigger than the viewport, set scrollTop to target.top + buffer
1153             // and change step attachmentTarget to top+.
1154             scrollTop = targetNode.offset().top - (viewportHeight * 0.2);
1155         }
1157         // Never scroll over the top.
1158         scrollTop = Math.max(0, scrollTop);
1160         // Never scroll beyond the bottom.
1161         scrollTop = Math.min($(document).height() - viewportHeight, scrollTop);
1163         return Math.ceil(scrollTop);
1164     }
1166     /**
1167      * Calculate dialogue position for page middle.
1168      *
1169      * @method  calculateScrollTop
1170      * @return  {Number}
1171      */
1172     calculateStepPositionInPage() {
1173         let viewportHeight = $(window).height();
1174         let stepHeight = this.currentStepNode.height();
1176         let viewportWidth = $(window).width();
1177         let stepWidth = this.currentStepNode.width();
1179         return {
1180             top: Math.ceil((viewportHeight - stepHeight) / 2),
1181             left: Math.ceil((viewportWidth - stepWidth) / 2)
1182         };
1183     }
1185     /**
1186      * Position the step on the page.
1187      *
1188      * @method  positionStep
1189      * @param   {Object}    stepConfig      The step configuration of the step
1190      * @chainable
1191      * @return {Object} this.
1192      */
1193     positionStep(stepConfig) {
1194         let content = this.currentStepNode;
1195         if (!content || !content.length) {
1196             // Unable to find the step node.
1197             return this;
1198         }
1200         let flipBehavior;
1201         switch (stepConfig.placement) {
1202             case 'left':
1203                 flipBehavior = ['left', 'right', 'top', 'bottom'];
1204                 break;
1205             case 'right':
1206                 flipBehavior = ['right', 'left', 'top', 'bottom'];
1207                 break;
1208             case 'top':
1209                 flipBehavior = ['top', 'bottom', 'right', 'left'];
1210                 break;
1211             case 'bottom':
1212                 flipBehavior = ['bottom', 'top', 'right', 'left'];
1213                 break;
1214             default:
1215                 flipBehavior = 'flip';
1216                 break;
1217         }
1219         let target = this.getStepTarget(stepConfig);
1220         var config = {
1221             placement: stepConfig.placement + '-start',
1222             removeOnDestroy: true,
1223             modifiers: {
1224                 flip: {
1225                     behaviour: flipBehavior,
1226                 },
1227                 arrow: {
1228                     element: '[data-role="arrow"]',
1229                 },
1230             },
1231             onCreate: function(data) {
1232                 recalculateArrowPosition(data);
1233             },
1234             onUpdate: function(data) {
1235                 recalculateArrowPosition(data);
1236             },
1237         };
1239         let recalculateArrowPosition = function(data) {
1240             let placement = data.placement.split('-')[0];
1241             const isVertical = ['left', 'right'].indexOf(placement) !== -1;
1242             const arrowElement = data.instance.popper.querySelector('[data-role="arrow"]');
1243             const stepElement = $(data.instance.popper.querySelector('[data-role="flexitour-step"]'));
1244             if (isVertical) {
1245                 let arrowHeight = parseFloat(window.getComputedStyle(arrowElement).height);
1246                 let arrowOffset = parseFloat(window.getComputedStyle(arrowElement).top);
1247                 let popperHeight = parseFloat(window.getComputedStyle(data.instance.popper).height);
1248                 let popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).top);
1249                 let popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
1250                 let popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
1251                 let arrowPos = arrowOffset + (arrowHeight / 2);
1252                 let maxPos = popperHeight + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
1253                 let minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
1254                 if (arrowPos >= maxPos || arrowPos <= minPos) {
1255                     let newArrowPos = 0;
1256                     if (arrowPos > (popperHeight / 2)) {
1257                         newArrowPos = maxPos - arrowHeight;
1258                     } else {
1259                         newArrowPos = minPos + arrowHeight;
1260                     }
1261                     $(arrowElement).css('top', newArrowPos);
1262                 }
1263             } else {
1264                 let arrowWidth = parseFloat(window.getComputedStyle(arrowElement).width);
1265                 let arrowOffset = parseFloat(window.getComputedStyle(arrowElement).left);
1266                 let popperWidth = parseFloat(window.getComputedStyle(data.instance.popper).width);
1267                 let popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).left);
1268                 let popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
1269                 let popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
1270                 let arrowPos = arrowOffset + (arrowWidth / 2);
1271                 let maxPos = popperWidth + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
1272                 let minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
1273                 if (arrowPos >= maxPos || arrowPos <= minPos) {
1274                     let newArrowPos = 0;
1275                     if (arrowPos > (popperWidth / 2)) {
1276                         newArrowPos = maxPos - arrowWidth;
1277                     } else {
1278                         newArrowPos = minPos + arrowWidth;
1279                     }
1280                     $(arrowElement).css('left', newArrowPos);
1281                 }
1282             }
1283         };
1285         let background = $('[data-flexitour="step-background"]');
1286         if (background.length) {
1287             target = background;
1288         }
1289         this.currentStepPopper = new Popper(target, content[0], config);
1291         return this;
1292     }
1294     /**
1295      * Add the backdrop.
1296      *
1297      * @method  positionBackdrop
1298      * @param   {Object}    stepConfig      The step configuration of the step
1299      * @chainable
1300      * @return {Object} this.
1301      */
1302     positionBackdrop(stepConfig) {
1303         if (stepConfig.backdrop) {
1304             this.currentStepConfig.hasBackdrop = true;
1305             let backdrop = $('<div data-flexitour="backdrop"></div>');
1307             if (stepConfig.zIndex) {
1308                 if (stepConfig.attachPoint === 'append') {
1309                     stepConfig.attachTo.append(backdrop);
1310                 } else {
1311                     backdrop.insertAfter(stepConfig.attachTo);
1312                 }
1313             } else {
1314                 $('body').append(backdrop);
1315             }
1317             if (this.isStepActuallyVisible(stepConfig)) {
1318                 // The step has a visible target.
1319                 // Punch a hole through the backdrop.
1320                 let background = $('<div data-flexitour="step-background"></div>');
1322                 let targetNode = this.getStepTarget(stepConfig);
1324                 let buffer = 10;
1326                 let colorNode = targetNode;
1327                 if (buffer) {
1328                     colorNode = $('body');
1329                 }
1331                 background.css({
1332                     width: targetNode.outerWidth() + buffer + buffer,
1333                     height: targetNode.outerHeight() + buffer + buffer,
1334                     left: targetNode.offset().left - buffer,
1335                     top: targetNode.offset().top - buffer,
1336                     backgroundColor: this.calculateInherittedBackgroundColor(colorNode),
1337                 });
1339                 if (targetNode.offset().left < buffer) {
1340                     background.css({
1341                         width: targetNode.outerWidth() + targetNode.offset().left + buffer,
1342                         left: targetNode.offset().left,
1343                     });
1344                 }
1346                 if (targetNode.offset().top < buffer) {
1347                     background.css({
1348                         height: targetNode.outerHeight() + targetNode.offset().top + buffer,
1349                         top: targetNode.offset().top,
1350                     });
1351                 }
1353                 let targetRadius = targetNode.css('borderRadius');
1354                 if (targetRadius && targetRadius !== $('body').css('borderRadius')) {
1355                     background.css('borderRadius', targetRadius);
1356                 }
1358                 let targetPosition = this.calculatePosition(targetNode);
1359                 if (targetPosition === 'fixed') {
1360                     background.css('top', 0);
1361                 } else if (targetPosition === 'absolute') {
1362                     background.css('position', 'fixed');
1363                 }
1365                 let fader = background.clone();
1366                 fader.css({
1367                     backgroundColor: backdrop.css('backgroundColor'),
1368                     opacity: backdrop.css('opacity'),
1369                 });
1370                 fader.attr('data-flexitour', 'step-background-fader');
1372                 if (stepConfig.zIndex) {
1373                     if (stepConfig.attachPoint === 'append') {
1374                         stepConfig.attachTo.append(background);
1375                     } else {
1376                         fader.insertAfter(stepConfig.attachTo);
1377                         background.insertAfter(stepConfig.attachTo);
1378                     }
1379                 } else {
1380                     $('body').append(fader);
1381                     $('body').append(background);
1382                 }
1384                 // Add the backdrop data to the actual target.
1385                 // This is the part which actually does the work.
1386                 targetNode.attr('data-flexitour', 'step-backdrop');
1388                 if (stepConfig.zIndex) {
1389                     backdrop.css('zIndex', stepConfig.zIndex);
1390                     background.css('zIndex', stepConfig.zIndex + 1);
1391                     targetNode.css('zIndex', stepConfig.zIndex + 2);
1392                 }
1394                 fader.fadeOut('2000', function() {
1395                     $(this).remove();
1396                 });
1397             }
1398         }
1399         return this;
1400     }
1402     /**
1403      * Calculate the inheritted z-index.
1404      *
1405      * @method  calculateZIndex
1406      * @param   {jQuery}    elem                        The element to calculate z-index for
1407      * @return  {Number}                                Calculated z-index
1408      */
1409     calculateZIndex(elem) {
1410         elem = $(elem);
1411         while (elem.length && elem[0] !== document) {
1412             // Ignore z-index if position is set to a value where z-index is ignored by the browser
1413             // This makes behavior of this function consistent across browsers
1414             // WebKit always returns auto if the element is positioned.
1415             let position = elem.css("position");
1416             if (position === "absolute" || position === "relative" || position === "fixed") {
1417                 // IE returns 0 when zIndex is not specified
1418                 // other browsers return a string
1419                 // we ignore the case of nested elements with an explicit value of 0
1420                 // <div style="z-index: -10;"><div style="z-index: 0;"></div></div>
1421                 let value = parseInt(elem.css("zIndex"), 10);
1422                 if (!isNaN(value) && value !== 0) {
1423                     return value;
1424                 }
1425             }
1426             elem = elem.parent();
1427         }
1429         return 0;
1430     }
1432     /**
1433      * Calculate the inheritted background colour.
1434      *
1435      * @method  calculateInherittedBackgroundColor
1436      * @param   {jQuery}    elem                        The element to calculate colour for
1437      * @return  {String}                                Calculated background colour
1438      */
1439     calculateInherittedBackgroundColor(elem) {
1440         // Use a fake node to compare each element against.
1441         let fakeNode = $('<div>').hide();
1442         $('body').append(fakeNode);
1443         let fakeElemColor = fakeNode.css('backgroundColor');
1444         fakeNode.remove();
1446         elem = $(elem);
1447         while (elem.length && elem[0] !== document) {
1448             let color = elem.css('backgroundColor');
1449             if (color !== fakeElemColor) {
1450                 return color;
1451             }
1452             elem = elem.parent();
1453         }
1455         return null;
1456     }
1458     /**
1459      * Calculate the inheritted position.
1460      *
1461      * @method  calculatePosition
1462      * @param   {jQuery}    elem                        The element to calculate position for
1463      * @return  {String}                                Calculated position
1464      */
1465     calculatePosition(elem) {
1466         elem = $(elem);
1467         while (elem.length && elem[0] !== document) {
1468             let position = elem.css('position');
1469             if (position !== 'static') {
1470                 return position;
1471             }
1472             elem = elem.parent();
1473         }
1475         return null;
1476     }
1478     /**
1479      * Perform accessibility changes for step shown.
1480      *
1481      * This will add aria-hidden="true" to all siblings and parent siblings.
1482      *
1483      * @method  accessibilityShow
1484      */
1485     accessibilityShow() {
1486         let stateHolder = 'data-has-hidden';
1487         let attrName = 'aria-hidden';
1488         let hideFunction = function(child) {
1489             let flexitourRole = child.data('flexitour');
1490             if (flexitourRole) {
1491                 switch (flexitourRole) {
1492                     case 'container':
1493                     case 'target':
1494                         return;
1495                 }
1496             }
1498             let hidden = child.attr(attrName);
1499             if (!hidden) {
1500                 child.attr(stateHolder, true);
1501                 child.attr(attrName, true);
1502             }
1503         };
1505         this.currentStepNode.siblings().each(function(index, node) {
1506             hideFunction($(node));
1507         });
1508         this.currentStepNode.parentsUntil('body').siblings().each(function(index, node) {
1509             hideFunction($(node));
1510         });
1511     }
1513     /**
1514      * Perform accessibility changes for step hidden.
1515      *
1516      * This will remove any newly added aria-hidden="true".
1517      *
1518      * @method  accessibilityHide
1519      */
1520     accessibilityHide() {
1521         let stateHolder = 'data-has-hidden';
1522         let attrName = 'aria-hidden';
1523         let showFunction = function(child) {
1524             let hidden = child.attr(stateHolder);
1525             if (typeof hidden !== 'undefined') {
1526                 child.removeAttr(stateHolder);
1527                 child.removeAttr(attrName);
1528             }
1529         };
1531         $('[' + stateHolder + ']').each(function(index, node) {
1532             showFunction($(node));
1533         });
1534     }