MDL-68390 tool_usertours: Update to use aria-hidden module
[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 * as Aria from 'core/aria';
25 import Popper from 'core/popper';
27 /**
28  * A Tour.
29  *
30  * @class Tour
31  */
32 export default class Tour {
33     /**
34      * @param   {object}    config  The configuration object.
35      */
36     constructor(config) {
37         this.init(config);
38     }
40     /**
41      * Initialise the tour.
42      *
43      * @method  init
44      * @param   {Object}    config  The configuration object.
45      * @chainable
46      * @return {Object} this.
47      */
48     init(config) {
49         // Unset all handlers.
50         this.eventHandlers = {};
52         // Reset the current tour states.
53         this.reset();
55         // Store the initial configuration.
56         this.originalConfiguration = config || {};
58         // Apply configuration.
59         this.configure.apply(this, arguments);
61         try {
62             this.storage = window.sessionStorage;
63             this.storageKey = 'tourstate_' + this.tourName;
64         } catch (e) {
65             this.storage = false;
66             this.storageKey = '';
67         }
69         return this;
70     }
72     /**
73      * Reset the current tour state.
74      *
75      * @method  reset
76      * @chainable
77      * @return {Object} this.
78      */
79     reset() {
80         // Hide the current step.
81         this.hide();
83         // Unset all handlers.
84         this.eventHandlers = [];
86         // Unset all listeners.
87         this.resetStepListeners();
89         // Unset the original configuration.
90         this.originalConfiguration = {};
92         // Reset the current step number and list of steps.
93         this.steps = [];
95         // Reset the current step number.
96         this.currentStepNumber = 0;
98         return this;
99     }
101     /**
102      * Prepare tour configuration.
103      *
104      * @method  configure
105      * @param {Object} config The configuration object.
106      * @chainable
107      * @return {Object} this.
108      */
109     configure(config) {
110         if (typeof config === 'object') {
111             // Tour name.
112             if (typeof config.tourName !== 'undefined') {
113                 this.tourName = config.tourName;
114             }
116             // Set up eventHandlers.
117             if (config.eventHandlers) {
118                 for (let eventName in config.eventHandlers) {
119                     config.eventHandlers[eventName].forEach(function(handler) {
120                         this.addEventHandler(eventName, handler);
121                     }, this);
122                 }
123             }
125             // Reset the step configuration.
126             this.resetStepDefaults(true);
128             // Configure the steps.
129             if (typeof config.steps === 'object') {
130                 this.steps = config.steps;
131             }
133             if (typeof config.template !== 'undefined') {
134                 this.templateContent = config.template;
135             }
136         }
138         // Check that we have enough to start the tour.
139         this.checkMinimumRequirements();
141         return this;
142     }
144     /**
145      * Check that the configuration meets the minimum requirements.
146      *
147      * @method  checkMinimumRequirements
148      */
149     checkMinimumRequirements() {
150         // Need a tourName.
151         if (!this.tourName) {
152             throw new Error("Tour Name required");
153         }
155         // Need a minimum of one step.
156         if (!this.steps || !this.steps.length) {
157             throw new Error("Steps must be specified");
158         }
159     }
161     /**
162      * Reset step default configuration.
163      *
164      * @method  resetStepDefaults
165      * @param   {Boolean}   loadOriginalConfiguration   Whether to load the original configuration supplied with the Tour.
166      * @chainable
167      * @return {Object} this.
168      */
169     resetStepDefaults(loadOriginalConfiguration) {
170         if (typeof loadOriginalConfiguration === 'undefined') {
171             loadOriginalConfiguration = true;
172         }
174         this.stepDefaults = {};
175         if (!loadOriginalConfiguration || typeof this.originalConfiguration.stepDefaults === 'undefined') {
176             this.setStepDefaults({});
177         } else {
178             this.setStepDefaults(this.originalConfiguration.stepDefaults);
179         }
181         return this;
182     }
184     /**
185      * Set the step defaults.
186      *
187      * @method  setStepDefaults
188      * @param   {Object}    stepDefaults                The step defaults to apply to all steps
189      * @chainable
190      * @return {Object} this.
191      */
192     setStepDefaults(stepDefaults) {
193         if (!this.stepDefaults) {
194             this.stepDefaults = {};
195         }
196         $.extend(
197             this.stepDefaults,
198             {
199                 element:        '',
200                 placement:      'top',
201                 delay:          0,
202                 moveOnClick:    false,
203                 moveAfterTime:  0,
204                 orphan:         false,
205                 direction:      1,
206             },
207             stepDefaults
208         );
210         return this;
211     }
213     /**
214      * Retrieve the current step number.
215      *
216      * @method  getCurrentStepNumber
217      * @return  {Integer}                   The current step number
218      */
219     getCurrentStepNumber() {
220         return parseInt(this.currentStepNumber, 10);
221     }
223     /**
224      * Store the current step number.
225      *
226      * @method  setCurrentStepNumber
227      * @param   {Integer}   stepNumber      The current step number
228      * @chainable
229      */
230     setCurrentStepNumber(stepNumber) {
231         this.currentStepNumber = stepNumber;
232         if (this.storage) {
233             try {
234                 this.storage.setItem(this.storageKey, stepNumber);
235             } catch (e) {
236                 if (e.code === DOMException.QUOTA_EXCEEDED_ERR) {
237                     this.storage.removeItem(this.storageKey);
238                 }
239             }
240         }
241     }
243     /**
244      * Get the next step number after the currently displayed step.
245      *
246      * @method  getNextStepNumber
247      * @param   {Integer}   stepNumber      The current step number
248      * @return  {Integer}    The next step number to display
249      */
250     getNextStepNumber(stepNumber) {
251         if (typeof stepNumber === 'undefined') {
252             stepNumber = this.getCurrentStepNumber();
253         }
254         let nextStepNumber = stepNumber + 1;
256         // Keep checking the remaining steps.
257         while (nextStepNumber <= this.steps.length) {
258             if (this.isStepPotentiallyVisible(this.getStepConfig(nextStepNumber))) {
259                 return nextStepNumber;
260             }
261             nextStepNumber++;
262         }
264         return null;
265     }
267     /**
268      * Get the previous step number before the currently displayed step.
269      *
270      * @method  getPreviousStepNumber
271      * @param   {Integer}   stepNumber      The current step number
272      * @return  {Integer}    The previous step number to display
273      */
274     getPreviousStepNumber(stepNumber) {
275         if (typeof stepNumber === 'undefined') {
276             stepNumber = this.getCurrentStepNumber();
277         }
278         let previousStepNumber = stepNumber - 1;
280         // Keep checking the remaining steps.
281         while (previousStepNumber >= 0) {
282             if (this.isStepPotentiallyVisible(this.getStepConfig(previousStepNumber))) {
283                 return previousStepNumber;
284             }
285             previousStepNumber--;
286         }
288         return null;
289     }
291     /**
292      * Is the step the final step number?
293      *
294      * @method  isLastStep
295      * @param   {Integer}   stepNumber  Step number to test
296      * @return  {Boolean}               Whether the step is the final step
297      */
298     isLastStep(stepNumber) {
299         let nextStepNumber = this.getNextStepNumber(stepNumber);
301         return nextStepNumber === null;
302     }
304     /**
305      * Is the step the first step number?
306      *
307      * @method  isFirstStep
308      * @param   {Integer}   stepNumber  Step number to test
309      * @return  {Boolean}               Whether the step is the first step
310      */
311     isFirstStep(stepNumber) {
312         let previousStepNumber = this.getPreviousStepNumber(stepNumber);
314         return previousStepNumber === null;
315     }
317     /**
318      * Is this step potentially visible?
319      *
320      * @method  isStepPotentiallyVisible
321      * @param   {Object}    stepConfig      The step configuration to normalise
322      * @return  {Boolean}               Whether the step is the potentially visible
323      */
324     isStepPotentiallyVisible(stepConfig) {
325         if (!stepConfig) {
326             // Without step config, there can be no step.
327             return false;
328         }
330         if (this.isStepActuallyVisible(stepConfig)) {
331             // If it is actually visible, it is already potentially visible.
332             return true;
333         }
335         if (typeof stepConfig.orphan !== 'undefined' && stepConfig.orphan) {
336             // Orphan steps have no target. They are always visible.
337             return true;
338         }
340         if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay) {
341             // Only return true if the activated has not been used yet.
342             return true;
343         }
345         // Not theoretically, or actually visible.
346         return false;
347     }
349     /**
350      * Is this step actually visible?
351      *
352      * @method  isStepActuallyVisible
353      * @param   {Object}    stepConfig      The step configuration to normalise
354      * @return  {Boolean}               Whether the step is actually visible
355      */
356     isStepActuallyVisible(stepConfig) {
357         if (!stepConfig) {
358             // Without step config, there can be no step.
359             return false;
360         }
362         let target = this.getStepTarget(stepConfig);
363         if (target && target.length && target.is(':visible')) {
364             // Without a target, there can be no step.
365             return !!target.length;
366         }
368         return false;
369     }
371     /**
372      * Go to the next step in the tour.
373      *
374      * @method  next
375      * @chainable
376      * @return {Object} this.
377      */
378     next() {
379         return this.gotoStep(this.getNextStepNumber());
380     }
382     /**
383      * Go to the previous step in the tour.
384      *
385      * @method  previous
386      * @chainable
387      * @return {Object} this.
388      */
389     previous() {
390         return this.gotoStep(this.getPreviousStepNumber(), -1);
391     }
393     /**
394      * Go to the specified step in the tour.
395      *
396      * @method  gotoStep
397      * @param   {Integer}   stepNumber     The step number to display
398      * @param   {Integer}   direction      Next or previous step
399      * @chainable
400      * @return {Object} this.
401      */
402     gotoStep(stepNumber, direction) {
403         if (stepNumber < 0) {
404             return this.endTour();
405         }
407         let stepConfig = this.getStepConfig(stepNumber);
408         if (stepConfig === null) {
409             return this.endTour();
410         }
412         return this._gotoStep(stepConfig, direction);
413     }
415     _gotoStep(stepConfig, direction) {
416         if (!stepConfig) {
417             return this.endTour();
418         }
420         if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay && !stepConfig.delayed) {
421             stepConfig.delayed = true;
422             window.setTimeout(this._gotoStep.bind(this), stepConfig.delay, stepConfig, direction);
424             return this;
425         } else if (!stepConfig.orphan && !this.isStepActuallyVisible(stepConfig)) {
426             let fn = direction == -1 ? 'getPreviousStepNumber' : 'getNextStepNumber';
427             return this.gotoStep(this[fn](stepConfig.stepNumber), direction);
428         }
430         this.hide();
432         this.fireEventHandlers('beforeRender', stepConfig);
433         this.renderStep(stepConfig);
434         this.fireEventHandlers('afterRender', stepConfig);
436         return this;
437     }
439     /**
440      * Fetch the normalised step configuration for the specified step number.
441      *
442      * @method  getStepConfig
443      * @param   {Integer}   stepNumber      The step number to fetch configuration for
444      * @return  {Object}                    The step configuration
445      */
446     getStepConfig(stepNumber) {
447         if (stepNumber === null || stepNumber < 0 || stepNumber >= this.steps.length) {
448             return null;
449         }
451         // Normalise the step configuration.
452         let stepConfig = this.normalizeStepConfig(this.steps[stepNumber]);
454         // Add the stepNumber to the stepConfig.
455         stepConfig = $.extend(stepConfig, {stepNumber: stepNumber});
457         return stepConfig;
458     }
460     /**
461      * Normalise the supplied step configuration.
462      *
463      * @method  normalizeStepConfig
464      * @param   {Object}    stepConfig      The step configuration to normalise
465      * @return  {Object}                    The normalised step configuration
466      */
467     normalizeStepConfig(stepConfig) {
469         if (typeof stepConfig.reflex !== 'undefined' && typeof stepConfig.moveAfterClick === 'undefined') {
470             stepConfig.moveAfterClick = stepConfig.reflex;
471         }
473         if (typeof stepConfig.element !== 'undefined' && typeof stepConfig.target === 'undefined') {
474             stepConfig.target = stepConfig.element;
475         }
477         if (typeof stepConfig.content !== 'undefined' && typeof stepConfig.body === 'undefined') {
478             stepConfig.body = stepConfig.content;
479         }
481         stepConfig = $.extend({}, this.stepDefaults, stepConfig);
483         stepConfig = $.extend({}, {
484             attachTo: stepConfig.target,
485             attachPoint: 'after',
486         }, stepConfig);
488         if (stepConfig.attachTo) {
489             stepConfig.attachTo = $(stepConfig.attachTo).first();
490         }
492         return stepConfig;
493     }
495     /**
496      * Fetch the actual step target from the selector.
497      *
498      * This should not be called until after any delay has completed.
499      *
500      * @method  getStepTarget
501      * @param   {Object}    stepConfig      The step configuration
502      * @return  {$}
503      */
504     getStepTarget(stepConfig) {
505         if (stepConfig.target) {
506             return $(stepConfig.target);
507         }
509         return null;
510     }
512     /**
513      * Fire any event handlers for the specified event.
514      *
515      * @param   {String}    eventName       The name of the event to handle
516      * @param   {Object}    data            Any data to pass to the event
517      * @chainable
518      * @return {Object} this.
519      */
520     fireEventHandlers(eventName, data) {
521         if (typeof this.eventHandlers[eventName] === 'undefined') {
522             return this;
523         }
525         this.eventHandlers[eventName].forEach(function(thisEvent) {
526             thisEvent.call(this, data);
527         }, this);
529         return this;
530     }
532     /**
533      * @method addEventHandler
534      * @param  {string}      eventName       The name of the event to listen for
535      * @param  {function}    handler         The event handler to call
536      * @return {Object} this.
537      */
538     addEventHandler(eventName, handler) {
539         if (typeof this.eventHandlers[eventName] === 'undefined') {
540             this.eventHandlers[eventName] = [];
541         }
543         this.eventHandlers[eventName].push(handler);
545         return this;
546     }
548     /**
549      * Process listeners for the step being shown.
550      *
551      * @method  processStepListeners
552      * @param   {object}    stepConfig      The configuration for the step
553      * @chainable
554      * @return {Object} this.
555      */
556     processStepListeners(stepConfig) {
557         this.listeners.push(
558         // Next/Previous buttons.
559         {
560             node: this.currentStepNode,
561             args: ['click', '[data-role="next"]', $.proxy(this.next, this)]
562         }, {
563             node: this.currentStepNode,
564             args: ['click', '[data-role="previous"]', $.proxy(this.previous, this)]
565         },
567         // Close and end tour buttons.
568         {
569             node: this.currentStepNode,
570             args: ['click', '[data-role="end"]', $.proxy(this.endTour, this)]
571         },
573         // Click backdrop and hide tour.
574         {
575             node: $('[data-flexitour="backdrop"]'),
576             args: ['click', $.proxy(this.hide, this)]
577         },
579         // Keypresses.
580         {
581             node: $('body'),
582             args: ['keydown', $.proxy(this.handleKeyDown, this)]
583         });
585         if (stepConfig.moveOnClick) {
586             var targetNode = this.getStepTarget(stepConfig);
587             this.listeners.push({
588                 node: targetNode,
589                 args: ['click', $.proxy(function(e) {
590                     if ($(e.target).parents('[data-flexitour="container"]').length === 0) {
591                         // Ignore clicks when they are in the flexitour.
592                         window.setTimeout($.proxy(this.next, this), 500);
593                     }
594                 }, this)]
595             });
596         }
598         this.listeners.forEach(function(listener) {
599             listener.node.on.apply(listener.node, listener.args);
600         });
602         return this;
603     }
605     /**
606      * Reset step listeners.
607      *
608      * @method  resetStepListeners
609      * @chainable
610      * @return {Object} this.
611      */
612     resetStepListeners() {
613         // Stop listening to all external handlers.
614         if (this.listeners) {
615             this.listeners.forEach(function(listener) {
616                 listener.node.off.apply(listener.node, listener.args);
617             });
618         }
619         this.listeners = [];
621         return this;
622     }
624     /**
625      * The standard step renderer.
626      *
627      * @method  renderStep
628      * @param   {Object}    stepConfig      The step configuration of the step
629      * @chainable
630      * @return {Object} this.
631      */
632     renderStep(stepConfig) {
633         // Store the current step configuration for later.
634         this.currentStepConfig = stepConfig;
635         this.setCurrentStepNumber(stepConfig.stepNumber);
637         // Fetch the template and convert it to a $ object.
638         let template = $(this.getTemplateContent());
640         // Title.
641         template.find('[data-placeholder="title"]')
642             .html(stepConfig.title);
644         // Body.
645         template.find('[data-placeholder="body"]')
646             .html(stepConfig.body);
648         // Is this the first step?
649         if (this.isFirstStep(stepConfig.stepNumber)) {
650             template.find('[data-role="previous"]').hide();
651         } else {
652             template.find('[data-role="previous"]').prop('disabled', false);
653         }
655         // Is this the final step?
656         if (this.isLastStep(stepConfig.stepNumber)) {
657             template.find('[data-role="next"]').hide();
658             template.find('[data-role="end"]').removeClass("btn-secondary").addClass("btn-primary");
659         } else {
660             template.find('[data-role="next"]').prop('disabled', false);
661         }
663         template.find('[data-role="previous"]').attr('role', 'button');
664         template.find('[data-role="next"]').attr('role', 'button');
665         template.find('[data-role="end"]').attr('role', 'button');
667         // Replace the template with the updated version.
668         stepConfig.template = template;
670         // Add to the page.
671         this.addStepToPage(stepConfig);
673         // Process step listeners after adding to the page.
674         // This uses the currentNode.
675         this.processStepListeners(stepConfig);
677         return this;
678     }
680     /**
681      * Getter for the template content.
682      *
683      * @method  getTemplateContent
684      * @return  {$}
685      */
686     getTemplateContent() {
687         return $(this.templateContent).clone();
688     }
690     /**
691      * Helper to add a step to the page.
692      *
693      * @method  addStepToPage
694      * @param   {Object}    stepConfig      The step configuration of the step
695      * @chainable
696      * @return {Object} this.
697      */
698     addStepToPage(stepConfig) {
699         // Create the stepNode from the template data.
700         let currentStepNode = $('<span data-flexitour="container"></span>')
701             .html(stepConfig.template)
702             .hide();
704         // The scroll animation occurs on the body or html.
705         let animationTarget = $('body, html')
706             .stop(true, true);
708         if (this.isStepActuallyVisible(stepConfig)) {
709             let targetNode = this.getStepTarget(stepConfig);
711             targetNode.data('flexitour', 'target');
713             let zIndex = this.calculateZIndex(targetNode);
714             if (zIndex) {
715                 stepConfig.zIndex = zIndex + 1;
716             }
718             if (stepConfig.zIndex) {
719                 currentStepNode.css('zIndex', stepConfig.zIndex + 1);
720             }
722             // Add the backdrop.
723             this.positionBackdrop(stepConfig);
725             $(document.body).append(currentStepNode);
726             this.currentStepNode = currentStepNode;
728             // Ensure that the step node is positioned.
729             // Some situations mean that the value is not properly calculated without this step.
730             this.currentStepNode.css({
731                 top: 0,
732                 left: 0,
733             });
735             animationTarget
736                 .animate({
737                     scrollTop: this.calculateScrollTop(stepConfig),
738                 }).promise().then(function() {
739                         this.positionStep(stepConfig);
740                         this.revealStep(stepConfig);
741                         return;
742                     }.bind(this))
743                     .catch(function() {
744                         // Silently fail.
745                     });
747         } else if (stepConfig.orphan) {
748             stepConfig.isOrphan = true;
750             // This will be appended to the body instead.
751             stepConfig.attachTo = $('body').first();
752             stepConfig.attachPoint = 'append';
754             // Add the backdrop.
755             this.positionBackdrop(stepConfig);
757             // This is an orphaned step.
758             currentStepNode.addClass('orphan');
760             // It lives in the body.
761             $(document.body).append(currentStepNode);
762             this.currentStepNode = currentStepNode;
764             this.currentStepNode.offset(this.calculateStepPositionInPage());
765             this.currentStepNode.css('position', 'fixed');
767             this.currentStepPopper = new Popper(
768                 $('body'),
769                 this.currentStepNode[0], {
770                     removeOnDestroy: true,
771                     placement: stepConfig.placement + '-start',
772                     arrowElement: '[data-role="arrow"]',
773                     // Empty the modifiers. We've already placed the step and don't want it moved.
774                     modifiers: {
775                         hide: {
776                             enabled: false,
777                         },
778                         applyStyle: {
779                             onLoad: null,
780                             enabled: false,
781                         },
782                     }
783                 }
784             );
786             this.revealStep(stepConfig);
787         }
789         return this;
790     }
792     /**
793      * Make the given step visible.
794      *
795      * @method revealStep
796      * @param {Object} stepConfig The step configuration of the step
797      * @chainable
798      * @return {Object} this.
799      */
800     revealStep(stepConfig) {
801         // Fade the step in.
802         this.currentStepNode.fadeIn('', $.proxy(function() {
803                 // Announce via ARIA.
804                 this.announceStep(stepConfig);
806                 // Focus on the current step Node.
807                 this.currentStepNode.focus();
808                 window.setTimeout($.proxy(function() {
809                     // After a brief delay, focus again.
810                     // There seems to be an issue with Jaws where it only reads the dialogue title initially.
811                     // This second focus helps it to read the full dialogue.
812                     if (this.currentStepNode) {
813                         this.currentStepNode.focus();
814                     }
815                 }, this), 100);
817             }, this));
819         return this;
820     }
822     /**
823      * Helper to announce the step on the page.
824      *
825      * @method  announceStep
826      * @param   {Object}    stepConfig      The step configuration of the step
827      * @chainable
828      * @return {Object} this.
829      */
830     announceStep(stepConfig) {
831         // Setup the step Dialogue as per:
832         // * https://www.w3.org/TR/wai-aria-practices/#dialog_nonmodal
833         // * https://www.w3.org/TR/wai-aria-practices/#dialog_modal
835         // Generate an ID for the current step node.
836         let stepId = 'tour-step-' + this.tourName + '-' + stepConfig.stepNumber;
837         this.currentStepNode.attr('id', stepId);
839         let bodyRegion = this.currentStepNode.find('[data-placeholder="body"]').first();
840         bodyRegion.attr('id', stepId + '-body');
841         bodyRegion.attr('role', 'document');
843         let headerRegion = this.currentStepNode.find('[data-placeholder="title"]').first();
844         headerRegion.attr('id', stepId + '-title');
845         headerRegion.attr('aria-labelledby', stepId + '-body');
847         // Generally, a modal dialog has a role of dialog.
848         this.currentStepNode.attr('role', 'dialog');
849         this.currentStepNode.attr('tabindex', 0);
850         this.currentStepNode.attr('aria-labelledby', stepId + '-title');
851         this.currentStepNode.attr('aria-describedby', stepId + '-body');
853         // Configure ARIA attributes on the target.
854         let target = this.getStepTarget(stepConfig);
855         if (target) {
856             if (!target.attr('tabindex')) {
857                 target.attr('tabindex', 0);
858             }
860             target
861                 .data('original-describedby', target.attr('aria-describedby'))
862                 .attr('aria-describedby', stepId + '-body')
863                 ;
864         }
866         this.accessibilityShow(stepConfig);
868         return this;
869     }
871     /**
872      * Handle key down events.
873      *
874      * @method  handleKeyDown
875      * @param   {EventFacade} e
876      */
877     handleKeyDown(e) {
878         let tabbableSelector = 'a[href], link[href], [draggable=true], [contenteditable=true], ';
879         tabbableSelector += ':input:enabled, [tabindex], button:enabled';
880         switch (e.keyCode) {
881             case 27:
882                 this.endTour();
883                 break;
885             // 9 == Tab - trap focus for items with a backdrop.
886             case 9:
887                 // Tab must be handled on key up only in this instance.
888                 (function() {
889                     if (!this.currentStepConfig.hasBackdrop) {
890                         // Trapping tab focus is only handled for those steps with a backdrop.
891                         return;
892                     }
894                     // Find all tabbable locations.
895                     let activeElement = $(document.activeElement);
896                     let stepTarget = this.getStepTarget(this.currentStepConfig);
897                     let tabbableNodes = $(tabbableSelector);
898                     let dialogContainer = $('span[data-flexitour="container"]');
899                     let currentIndex;
900                     // Filter out element which is not belong to target section or dialogue.
901                     if (stepTarget) {
902                         tabbableNodes = tabbableNodes.filter(function(index, element) {
903                             return stepTarget !== null
904                                 && (stepTarget.has(element).length
905                                     || dialogContainer.has(element).length
906                                     || stepTarget.is(element)
907                                     || dialogContainer.is(element));
908                         });
909                     }
911                     // Find index of focusing element.
912                     tabbableNodes.each(function(index, element) {
913                         if (activeElement.is(element)) {
914                             currentIndex = index;
915                             return false;
916                         }
917                         // Keep looping.
918                         return true;
919                     });
921                     let nextIndex;
922                     let nextNode;
923                     let focusRelevant;
924                     if (currentIndex != void 0) {
925                         let direction = 1;
926                         if (e.shiftKey) {
927                             direction = -1;
928                         }
929                         nextIndex = currentIndex;
930                         do {
931                             nextIndex += direction;
932                             nextNode = $(tabbableNodes[nextIndex]);
933                         } while (nextNode.length && nextNode.is(':disabled') || nextNode.is(':hidden'));
934                         if (nextNode.length) {
935                             // A new f
936                             focusRelevant = nextNode.closest(stepTarget).length;
937                             focusRelevant = focusRelevant || nextNode.closest(this.currentStepNode).length;
938                         } else {
939                             // Unable to find the target somehow.
940                             focusRelevant = false;
941                         }
942                     }
944                     if (focusRelevant) {
945                         nextNode.focus();
946                     } else {
947                         if (e.shiftKey) {
948                             // Focus on the last tabbable node in the step.
949                             this.currentStepNode.find(tabbableSelector).last().focus();
950                         } else {
951                             if (this.currentStepConfig.isOrphan) {
952                                 // Focus on the step - there is no target.
953                                 this.currentStepNode.focus();
954                             } else {
955                                 // Focus on the step target.
956                                 stepTarget.focus();
957                             }
958                         }
959                     }
960                     e.preventDefault();
961                 }).call(this);
962                 break;
963         }
964     }
966     /**
967      * Start the current tour.
968      *
969      * @method  startTour
970      * @param   {Integer}   startAt     Which step number to start at. If not specified, starts at the last point.
971      * @chainable
972      * @return {Object} this.
973      */
974     startTour(startAt) {
975         if (this.storage && typeof startAt === 'undefined') {
976             let storageStartValue = this.storage.getItem(this.storageKey);
977             if (storageStartValue) {
978                 let storageStartAt = parseInt(storageStartValue, 10);
979                 if (storageStartAt <= this.steps.length) {
980                     startAt = storageStartAt;
981                 }
982             }
983         }
985         if (typeof startAt === 'undefined') {
986             startAt = this.getCurrentStepNumber();
987         }
989         this.fireEventHandlers('beforeStart', startAt);
990         this.gotoStep(startAt);
991         this.fireEventHandlers('afterStart', startAt);
993         return this;
994     }
996     /**
997      * Restart the tour from the beginning, resetting the completionlag.
998      *
999      * @method  restartTour
1000      * @chainable
1001      * @return {Object} this.
1002      */
1003     restartTour() {
1004         return this.startTour(0);
1005     }
1007     /**
1008      * End the current tour.
1009      *
1010      * @method  endTour
1011      * @chainable
1012      * @return {Object} this.
1013      */
1014     endTour() {
1015         this.fireEventHandlers('beforeEnd');
1017         if (this.currentStepConfig) {
1018             let previousTarget = this.getStepTarget(this.currentStepConfig);
1019             if (previousTarget) {
1020                 if (!previousTarget.attr('tabindex')) {
1021                     previousTarget.attr('tabindex', '-1');
1022                 }
1023                 previousTarget.focus();
1024             }
1025         }
1027         this.hide(true);
1029         this.fireEventHandlers('afterEnd');
1031         return this;
1032     }
1034     /**
1035      * Hide any currently visible steps.
1036      *
1037      * @method hide
1038      * @param {Bool} transition Animate the visibility change
1039      * @chainable
1040      * @return {Object} this.
1041      */
1042     hide(transition) {
1043         this.fireEventHandlers('beforeHide');
1045         if (this.currentStepNode && this.currentStepNode.length) {
1046             this.currentStepNode.hide();
1047             if (this.currentStepPopper) {
1048                 this.currentStepPopper.destroy();
1049             }
1050         }
1052         // Restore original target configuration.
1053         if (this.currentStepConfig) {
1054             let target = this.getStepTarget(this.currentStepConfig);
1055             if (target) {
1056                 if (target.data('original-labelledby')) {
1057                     target.attr('aria-labelledby', target.data('original-labelledby'));
1058                 }
1060                 if (target.data('original-describedby')) {
1061                     target.attr('aria-describedby', target.data('original-describedby'));
1062                 }
1064                 if (target.data('original-tabindex')) {
1065                     target.attr('tabindex', target.data('tabindex'));
1066                 }
1067             }
1069             // Clear the step configuration.
1070             this.currentStepConfig = null;
1071         }
1073         let fadeTime = 0;
1074         if (transition) {
1075             fadeTime = 400;
1076         }
1078         // Remove the backdrop features.
1079         $('[data-flexitour="step-background"]').remove();
1080         $('[data-flexitour="step-backdrop"]').removeAttr('data-flexitour');
1081         $('[data-flexitour="backdrop"]').fadeOut(fadeTime, function() {
1082             $(this).remove();
1083         });
1085         // Remove aria-describedby and tabindex attributes.
1086         if (this.currentStepNode && this.currentStepNode.length) {
1087             let stepId = this.currentStepNode.attr('id');
1088             if (stepId) {
1089                 let currentStepElement = '[aria-describedby="' + stepId + '-body"]';
1090                 $(currentStepElement).removeAttr('tabindex');
1091                 $(currentStepElement).removeAttr('aria-describedby');
1092             }
1093         }
1095         // Reset the listeners.
1096         this.resetStepListeners();
1098         this.accessibilityHide();
1100         this.fireEventHandlers('afterHide');
1102         this.currentStepNode = null;
1103         this.currentStepPopper = null;
1104         return this;
1105     }
1107     /**
1108      * Show the current steps.
1109      *
1110      * @method show
1111      * @chainable
1112      * @return {Object} this.
1113      */
1114     show() {
1115         // Show the current step.
1116         let startAt = this.getCurrentStepNumber();
1118         return this.gotoStep(startAt);
1119     }
1121     /**
1122      * Return the current step node.
1123      *
1124      * @method  getStepContainer
1125      * @return  {jQuery}
1126      */
1127     getStepContainer() {
1128         return $(this.currentStepNode);
1129     }
1131     /**
1132      * Calculate scrollTop.
1133      *
1134      * @method  calculateScrollTop
1135      * @param   {Object}    stepConfig      The step configuration of the step
1136      * @return  {Number}
1137      */
1138     calculateScrollTop(stepConfig) {
1139         let scrollTop = $(window).scrollTop();
1140         let viewportHeight = $(window).height();
1141         let targetNode = this.getStepTarget(stepConfig);
1143         if (stepConfig.placement === 'top') {
1144             // If the placement is top, center scroll at the top of the target.
1145             scrollTop = targetNode.offset().top - (viewportHeight / 2);
1146         } else if (stepConfig.placement === 'bottom') {
1147             // If the placement is bottom, center scroll at the bottom of the target.
1148             scrollTop = targetNode.offset().top + targetNode.height() - (viewportHeight / 2);
1149         } else if (targetNode.height() <= (viewportHeight * 0.8)) {
1150             // If the placement is left/right, and the target fits in the viewport, centre screen on the target
1151             scrollTop = targetNode.offset().top - ((viewportHeight - targetNode.height()) / 2);
1152         } else {
1153             // If the placement is left/right, and the target is bigger than the viewport, set scrollTop to target.top + buffer
1154             // and change step attachmentTarget to top+.
1155             scrollTop = targetNode.offset().top - (viewportHeight * 0.2);
1156         }
1158         // Never scroll over the top.
1159         scrollTop = Math.max(0, scrollTop);
1161         // Never scroll beyond the bottom.
1162         scrollTop = Math.min($(document).height() - viewportHeight, scrollTop);
1164         return Math.ceil(scrollTop);
1165     }
1167     /**
1168      * Calculate dialogue position for page middle.
1169      *
1170      * @method  calculateScrollTop
1171      * @return  {Number}
1172      */
1173     calculateStepPositionInPage() {
1174         let viewportHeight = $(window).height();
1175         let stepHeight = this.currentStepNode.height();
1177         let viewportWidth = $(window).width();
1178         let stepWidth = this.currentStepNode.width();
1180         return {
1181             top: Math.ceil((viewportHeight - stepHeight) / 2),
1182             left: Math.ceil((viewportWidth - stepWidth) / 2)
1183         };
1184     }
1186     /**
1187      * Position the step on the page.
1188      *
1189      * @method  positionStep
1190      * @param   {Object}    stepConfig      The step configuration of the step
1191      * @chainable
1192      * @return {Object} this.
1193      */
1194     positionStep(stepConfig) {
1195         let content = this.currentStepNode;
1196         if (!content || !content.length) {
1197             // Unable to find the step node.
1198             return this;
1199         }
1201         let flipBehavior;
1202         switch (stepConfig.placement) {
1203             case 'left':
1204                 flipBehavior = ['left', 'right', 'top', 'bottom'];
1205                 break;
1206             case 'right':
1207                 flipBehavior = ['right', 'left', 'top', 'bottom'];
1208                 break;
1209             case 'top':
1210                 flipBehavior = ['top', 'bottom', 'right', 'left'];
1211                 break;
1212             case 'bottom':
1213                 flipBehavior = ['bottom', 'top', 'right', 'left'];
1214                 break;
1215             default:
1216                 flipBehavior = 'flip';
1217                 break;
1218         }
1220         let target = this.getStepTarget(stepConfig);
1221         var config = {
1222             placement: stepConfig.placement + '-start',
1223             removeOnDestroy: true,
1224             modifiers: {
1225                 flip: {
1226                     behaviour: flipBehavior,
1227                 },
1228                 arrow: {
1229                     element: '[data-role="arrow"]',
1230                 },
1231             },
1232             onCreate: function(data) {
1233                 recalculateArrowPosition(data);
1234             },
1235             onUpdate: function(data) {
1236                 recalculateArrowPosition(data);
1237             },
1238         };
1240         let recalculateArrowPosition = function(data) {
1241             let placement = data.placement.split('-')[0];
1242             const isVertical = ['left', 'right'].indexOf(placement) !== -1;
1243             const arrowElement = data.instance.popper.querySelector('[data-role="arrow"]');
1244             const stepElement = $(data.instance.popper.querySelector('[data-role="flexitour-step"]'));
1245             if (isVertical) {
1246                 let arrowHeight = parseFloat(window.getComputedStyle(arrowElement).height);
1247                 let arrowOffset = parseFloat(window.getComputedStyle(arrowElement).top);
1248                 let popperHeight = parseFloat(window.getComputedStyle(data.instance.popper).height);
1249                 let popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).top);
1250                 let popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
1251                 let popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
1252                 let arrowPos = arrowOffset + (arrowHeight / 2);
1253                 let maxPos = popperHeight + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
1254                 let minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
1255                 if (arrowPos >= maxPos || arrowPos <= minPos) {
1256                     let newArrowPos = 0;
1257                     if (arrowPos > (popperHeight / 2)) {
1258                         newArrowPos = maxPos - arrowHeight;
1259                     } else {
1260                         newArrowPos = minPos + arrowHeight;
1261                     }
1262                     $(arrowElement).css('top', newArrowPos);
1263                 }
1264             } else {
1265                 let arrowWidth = parseFloat(window.getComputedStyle(arrowElement).width);
1266                 let arrowOffset = parseFloat(window.getComputedStyle(arrowElement).left);
1267                 let popperWidth = parseFloat(window.getComputedStyle(data.instance.popper).width);
1268                 let popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).left);
1269                 let popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
1270                 let popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
1271                 let arrowPos = arrowOffset + (arrowWidth / 2);
1272                 let maxPos = popperWidth + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
1273                 let minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
1274                 if (arrowPos >= maxPos || arrowPos <= minPos) {
1275                     let newArrowPos = 0;
1276                     if (arrowPos > (popperWidth / 2)) {
1277                         newArrowPos = maxPos - arrowWidth;
1278                     } else {
1279                         newArrowPos = minPos + arrowWidth;
1280                     }
1281                     $(arrowElement).css('left', newArrowPos);
1282                 }
1283             }
1284         };
1286         let background = $('[data-flexitour="step-background"]');
1287         if (background.length) {
1288             target = background;
1289         }
1290         this.currentStepPopper = new Popper(target, content[0], config);
1292         return this;
1293     }
1295     /**
1296      * Add the backdrop.
1297      *
1298      * @method  positionBackdrop
1299      * @param   {Object}    stepConfig      The step configuration of the step
1300      * @chainable
1301      * @return {Object} this.
1302      */
1303     positionBackdrop(stepConfig) {
1304         if (stepConfig.backdrop) {
1305             this.currentStepConfig.hasBackdrop = true;
1306             let backdrop = $('<div data-flexitour="backdrop"></div>');
1308             if (stepConfig.zIndex) {
1309                 if (stepConfig.attachPoint === 'append') {
1310                     stepConfig.attachTo.append(backdrop);
1311                 } else {
1312                     backdrop.insertAfter(stepConfig.attachTo);
1313                 }
1314             } else {
1315                 $('body').append(backdrop);
1316             }
1318             if (this.isStepActuallyVisible(stepConfig)) {
1319                 // The step has a visible target.
1320                 // Punch a hole through the backdrop.
1321                 let background = $('<div data-flexitour="step-background"></div>');
1323                 let targetNode = this.getStepTarget(stepConfig);
1325                 let buffer = 10;
1327                 let colorNode = targetNode;
1328                 if (buffer) {
1329                     colorNode = $('body');
1330                 }
1332                 background.css({
1333                     width: targetNode.outerWidth() + buffer + buffer,
1334                     height: targetNode.outerHeight() + buffer + buffer,
1335                     left: targetNode.offset().left - buffer,
1336                     top: targetNode.offset().top - buffer,
1337                     backgroundColor: this.calculateInherittedBackgroundColor(colorNode),
1338                 });
1340                 if (targetNode.offset().left < buffer) {
1341                     background.css({
1342                         width: targetNode.outerWidth() + targetNode.offset().left + buffer,
1343                         left: targetNode.offset().left,
1344                     });
1345                 }
1347                 if (targetNode.offset().top < buffer) {
1348                     background.css({
1349                         height: targetNode.outerHeight() + targetNode.offset().top + buffer,
1350                         top: targetNode.offset().top,
1351                     });
1352                 }
1354                 let targetRadius = targetNode.css('borderRadius');
1355                 if (targetRadius && targetRadius !== $('body').css('borderRadius')) {
1356                     background.css('borderRadius', targetRadius);
1357                 }
1359                 let targetPosition = this.calculatePosition(targetNode);
1360                 if (targetPosition === 'fixed') {
1361                     background.css('top', 0);
1362                 } else if (targetPosition === 'absolute') {
1363                     background.css('position', 'fixed');
1364                 }
1366                 let fader = background.clone();
1367                 fader.css({
1368                     backgroundColor: backdrop.css('backgroundColor'),
1369                     opacity: backdrop.css('opacity'),
1370                 });
1371                 fader.attr('data-flexitour', 'step-background-fader');
1373                 if (stepConfig.zIndex) {
1374                     if (stepConfig.attachPoint === 'append') {
1375                         stepConfig.attachTo.append(background);
1376                     } else {
1377                         fader.insertAfter(stepConfig.attachTo);
1378                         background.insertAfter(stepConfig.attachTo);
1379                     }
1380                 } else {
1381                     $('body').append(fader);
1382                     $('body').append(background);
1383                 }
1385                 // Add the backdrop data to the actual target.
1386                 // This is the part which actually does the work.
1387                 targetNode.attr('data-flexitour', 'step-backdrop');
1389                 if (stepConfig.zIndex) {
1390                     backdrop.css('zIndex', stepConfig.zIndex);
1391                     background.css('zIndex', stepConfig.zIndex + 1);
1392                     targetNode.css('zIndex', stepConfig.zIndex + 2);
1393                 }
1395                 fader.fadeOut('2000', function() {
1396                     $(this).remove();
1397                 });
1398             }
1399         }
1400         return this;
1401     }
1403     /**
1404      * Calculate the inheritted z-index.
1405      *
1406      * @method  calculateZIndex
1407      * @param   {jQuery}    elem                        The element to calculate z-index for
1408      * @return  {Number}                                Calculated z-index
1409      */
1410     calculateZIndex(elem) {
1411         elem = $(elem);
1412         while (elem.length && elem[0] !== document) {
1413             // Ignore z-index if position is set to a value where z-index is ignored by the browser
1414             // This makes behavior of this function consistent across browsers
1415             // WebKit always returns auto if the element is positioned.
1416             let position = elem.css("position");
1417             if (position === "absolute" || position === "relative" || position === "fixed") {
1418                 // IE returns 0 when zIndex is not specified
1419                 // other browsers return a string
1420                 // we ignore the case of nested elements with an explicit value of 0
1421                 // <div style="z-index: -10;"><div style="z-index: 0;"></div></div>
1422                 let value = parseInt(elem.css("zIndex"), 10);
1423                 if (!isNaN(value) && value !== 0) {
1424                     return value;
1425                 }
1426             }
1427             elem = elem.parent();
1428         }
1430         return 0;
1431     }
1433     /**
1434      * Calculate the inheritted background colour.
1435      *
1436      * @method  calculateInherittedBackgroundColor
1437      * @param   {jQuery}    elem                        The element to calculate colour for
1438      * @return  {String}                                Calculated background colour
1439      */
1440     calculateInherittedBackgroundColor(elem) {
1441         // Use a fake node to compare each element against.
1442         let fakeNode = $('<div>').hide();
1443         $('body').append(fakeNode);
1444         let fakeElemColor = fakeNode.css('backgroundColor');
1445         fakeNode.remove();
1447         elem = $(elem);
1448         while (elem.length && elem[0] !== document) {
1449             let color = elem.css('backgroundColor');
1450             if (color !== fakeElemColor) {
1451                 return color;
1452             }
1453             elem = elem.parent();
1454         }
1456         return null;
1457     }
1459     /**
1460      * Calculate the inheritted position.
1461      *
1462      * @method  calculatePosition
1463      * @param   {jQuery}    elem                        The element to calculate position for
1464      * @return  {String}                                Calculated position
1465      */
1466     calculatePosition(elem) {
1467         elem = $(elem);
1468         while (elem.length && elem[0] !== document) {
1469             let position = elem.css('position');
1470             if (position !== 'static') {
1471                 return position;
1472             }
1473             elem = elem.parent();
1474         }
1476         return null;
1477     }
1479     /**
1480      * Perform accessibility changes for step shown.
1481      *
1482      * This will add aria-hidden="true" to all siblings and parent siblings.
1483      *
1484      * @method  accessibilityShow
1485      */
1486     accessibilityShow() {
1487         let stateHolder = 'data-has-hidden';
1488         let attrName = 'aria-hidden';
1489         let hideFunction = function(child) {
1490             let flexitourRole = child.data('flexitour');
1491             if (flexitourRole) {
1492                 switch (flexitourRole) {
1493                     case 'container':
1494                     case 'target':
1495                         return;
1496                 }
1497             }
1499             let hidden = child.attr(attrName);
1500             if (!hidden) {
1501                 child.attr(stateHolder, true);
1502                 Aria.hide(child);
1503             }
1504         };
1506         this.currentStepNode.siblings().each(function(index, node) {
1507             hideFunction($(node));
1508         });
1509         this.currentStepNode.parentsUntil('body').siblings().each(function(index, node) {
1510             hideFunction($(node));
1511         });
1512     }
1514     /**
1515      * Perform accessibility changes for step hidden.
1516      *
1517      * This will remove any newly added aria-hidden="true".
1518      *
1519      * @method  accessibilityHide
1520      */
1521     accessibilityHide() {
1522         let stateHolder = 'data-has-hidden';
1523         let showFunction = function(child) {
1524             let hidden = child.attr(stateHolder);
1525             if (typeof hidden !== 'undefined') {
1526                 child.removeAttr(stateHolder);
1527                 Aria.unhide(child);
1528             }
1529         };
1531         $('[' + stateHolder + ']').each(function(index, node) {
1532             showFunction($(node));
1533         });
1534     }