61e6cf430e15d54efb18fafd9455dca37fa2dc06
[moodle.git] / admin / tool / usertours / amd / src / tour.js
1 // jshint ignore: start
2 (function (root, factory) {
3   if (typeof define === 'function' && define.amd) {
4     // AMD. Register as an anonymous module unless amdModuleId is set
5     define(["jquery","./popper"], function (a0,b1) {
6       return (root['Tour'] = factory(a0,b1));
7     });
8   } else if (typeof module === 'object' && module.exports) {
9     // Node. Does not work with strict CommonJS, but
10     // only CommonJS-like environments that support module.exports,
11     // like Node.
12     module.exports = factory(require("jquery"),require("popper.js"));
13   } else {
14     root['Tour'] = factory(root["$"],root["Popper"]);
15   }
16 }(this, function ($, Popper) {
18 "use strict";
20 /**
21  * A Tour.
22  *
23  * @class   Tour
24  * @param   {object}    config  The configuration object.
25  */
27 var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; };
29 function Tour(config) {
30     this.init(config);
31 }
33 /**
34  * The name of the tour.
35  *
36  * @property    {String}    tourName
37  */
38 Tour.prototype.tourName;
40 /**
41  * The name of the tour storage key.
42  *
43  * @property    {String}    storageKey
44  */
45 Tour.prototype.storageKey;
47 /**
48  * The session storage object
49  *
50  * @property    {Storage}   storage
51  */
52 Tour.prototype.storage;
54 /**
55  * The original configuration as passed into the constructor.
56  *
57  * @property    {Object}    originalConfiguration
58  */
59 Tour.prototype.originalConfiguration;
61 /**
62  * The list of step listeners.
63  *
64  * @property    {Array}     listeners
65  */
66 Tour.prototype.listeners;
68 /**
69  * The list of event handlers.
70  *
71  * @property    {Object}    eventHandlers
72  */
73 Tour.prototype.eventHandlers;
75 /**
76  * The list of steps.
77  *
78  * @property    {Object[]}      steps
79  */
80 Tour.prototype.steps;
82 /**
83  * The current step node.
84  *
85  * @property    {jQuery}        currentStepNode
86  */
87 Tour.prototype.currentStepNode;
89 /**
90  * The current step number.
91  *
92  * @property    {Number}        currentStepNumber
93  */
94 Tour.prototype.currentStepNumber;
96 /**
97  * The popper for the current step.
98  *
99  * @property    {Popper}        currentStepPopper
100  */
101 Tour.prototype.currentStepPopper;
103 /**
104  * The config for the current step.
105  *
106  * @property    {Object}        currentStepConfig
107  */
108 Tour.prototype.currentStepConfig;
110 /**
111  * The template content.
112  *
113  * @property    {String}        templateContent
114  */
115 Tour.prototype.templateContent;
117 /**
118  * Initialise the tour.
119  *
120  * @method  init
121  * @param   {Object}    config  The configuration object.
122  * @chainable
123  */
124 Tour.prototype.init = function (config) {
125     // Unset all handlers.
126     this.eventHandlers = {};
128     // Reset the current tour states.
129     this.reset();
131     // Store the initial configuration.
132     this.originalConfiguration = config || {};
134     // Apply configuration.
135     this.configure.apply(this, arguments);
137     try {
138         this.storage = window.sessionStorage;
139         this.storageKey = 'tourstate_' + this.tourName;
140     } catch (e) {
141         this.storage = false;
142         this.storageKey = '';
143     }
145     return this;
146 };
148 /**
149  * Reset the current tour state.
150  *
151  * @method  reset
152  * @chainable
153  */
154 Tour.prototype.reset = function () {
155     // Hide the current step.
156     this.hide();
158     // Unset all handlers.
159     this.eventHandlers = [];
161     // Unset all listeners.
162     this.resetStepListeners();
164     // Unset the original configuration.
165     this.originalConfiguration = {};
167     // Reset the current step number and list of steps.
168     this.steps = [];
170     // Reset the current step number.
171     this.currentStepNumber = 0;
173     return this;
174 };
176 /**
177  * Prepare tour configuration.
178  *
179  * @method  configure
180  * @chainable
181  */
182 Tour.prototype.configure = function (config) {
183     var _this = this;
185     if ((typeof config === 'undefined' ? 'undefined' : _typeof(config)) === 'object') {
186         // Tour name.
187         if (typeof config.tourName !== 'undefined') {
188             this.tourName = config.tourName;
189         }
191         // Set up eventHandlers.
192         if (config.eventHandlers) {
193             (function () {
194                 var eventName = void 0;
195                 for (eventName in config.eventHandlers) {
196                     config.eventHandlers[eventName].forEach(function (handler) {
197                         this.addEventHandler(eventName, handler);
198                     }, _this);
199                 }
200             })();
201         }
203         // Reset the step configuration.
204         this.resetStepDefaults(true);
206         // Configure the steps.
207         if (_typeof(config.steps) === 'object') {
208             this.steps = config.steps;
209         }
211         if (typeof config.template !== 'undefined') {
212             this.templateContent = config.template;
213         }
214     }
216     // Check that we have enough to start the tour.
217     this.checkMinimumRequirements();
219     return this;
220 };
222 /**
223  * Check that the configuration meets the minimum requirements.
224  *
225  * @method  checkMinimumRequirements
226  * @chainable
227  */
228 Tour.prototype.checkMinimumRequirements = function () {
229     // Need a tourName.
230     if (!this.tourName) {
231         throw new Error("Tour Name required");
232     }
234     // Need a minimum of one step.
235     if (!this.steps || !this.steps.length) {
236         throw new Error("Steps must be specified");
237     }
238 };
240 /**
241  * Reset step default configuration.
242  *
243  * @method  resetStepDefaults
244  * @param   {Boolean}   loadOriginalConfiguration   Whether to load the original configuration supplied with the Tour.
245  * @chainable
246  */
247 Tour.prototype.resetStepDefaults = function (loadOriginalConfiguration) {
248     if (typeof loadOriginalConfiguration === 'undefined') {
249         loadOriginalConfiguration = true;
250     }
252     this.stepDefaults = {};
253     if (!loadOriginalConfiguration || typeof this.originalConfiguration.stepDefaults === 'undefined') {
254         this.setStepDefaults({});
255     } else {
256         this.setStepDefaults(this.originalConfiguration.stepDefaults);
257     }
259     return this;
260 };
262 /**
263  * Set the step defaults.
264  *
265  * @method  setStepDefaults
266  * @param   {Object}    stepDefaults                The step defaults to apply to all steps
267  * @chainable
268  */
269 Tour.prototype.setStepDefaults = function (stepDefaults) {
270     if (!this.stepDefaults) {
271         this.stepDefaults = {};
272     }
273     $.extend(this.stepDefaults, {
274         element: '',
275         placement: 'top',
276         delay: 0,
277         moveOnClick: false,
278         moveAfterTime: 0,
279         orphan: false,
280         direction: 1
281     }, stepDefaults);
283     return this;
284 };
286 /**
287  * Retrieve the current step number.
288  *
289  * @method  getCurrentStepNumber
290  * @return  {Integer}                   The current step number
291  */
292 Tour.prototype.getCurrentStepNumber = function () {
293     return parseInt(this.currentStepNumber, 10);
294 };
296 /**
297  * Store the current step number.
298  *
299  * @method  setCurrentStepNumber
300  * @param   {Integer}   stepNumber      The current step number
301  * @chainable
302  */
303 Tour.prototype.setCurrentStepNumber = function (stepNumber) {
304     this.currentStepNumber = stepNumber;
305     if (this.storage) {
306         try {
307             this.storage.setItem(this.storageKey, stepNumber);
308         } catch (e) {
309             if (e.code === DOMException.QUOTA_EXCEEDED_ERR) {
310                 this.storage.removeItem(this.storageKey);
311             }
312         }
313     }
314 };
316 /**
317  * Get the next step number after the currently displayed step.
318  *
319  * @method  getNextStepNumber
320  * @return  {Integer}    The next step number to display
321  */
322 Tour.prototype.getNextStepNumber = function (stepNumber) {
323     if (typeof stepNumber === 'undefined') {
324         stepNumber = this.getCurrentStepNumber();
325     }
326     var nextStepNumber = stepNumber + 1;
328     // Keep checking the remaining steps.
329     while (nextStepNumber <= this.steps.length) {
330         if (this.isStepPotentiallyVisible(this.getStepConfig(nextStepNumber))) {
331             return nextStepNumber;
332         }
333         nextStepNumber++;
334     }
336     return null;
337 };
339 /**
340  * Get the previous step number before the currently displayed step.
341  *
342  * @method  getPreviousStepNumber
343  * @return  {Integer}    The previous step number to display
344  */
345 Tour.prototype.getPreviousStepNumber = function (stepNumber) {
346     if (typeof stepNumber === 'undefined') {
347         stepNumber = this.getCurrentStepNumber();
348     }
349     var previousStepNumber = stepNumber - 1;
351     // Keep checking the remaining steps.
352     while (previousStepNumber >= 0) {
353         if (this.isStepPotentiallyVisible(this.getStepConfig(previousStepNumber))) {
354             return previousStepNumber;
355         }
356         previousStepNumber--;
357     }
359     return null;
360 };
362 /**
363  * Is the step the final step number?
364  *
365  * @method  isLastStep
366  * @param   {Integer}   stepNumber  Step number to test
367  * @return  {Boolean}               Whether the step is the final step
368  */
369 Tour.prototype.isLastStep = function (stepNumber) {
370     var nextStepNumber = this.getNextStepNumber(stepNumber);
372     return nextStepNumber === null;
373 };
375 /**
376  * Is the step the first step number?
377  *
378  * @method  isFirstStep
379  * @param   {Integer}   stepNumber  Step number to test
380  * @return  {Boolean}               Whether the step is the first step
381  */
382 Tour.prototype.isFirstStep = function (stepNumber) {
383     var previousStepNumber = this.getPreviousStepNumber(stepNumber);
385     return previousStepNumber === null;
386 };
388 /**
389  * Is this step potentially visible?
390  *
391  * @method  isStepPotentiallyVisible
392  * @param   {Integer}   stepNumber  Step number to test
393  * @return  {Boolean}               Whether the step is the potentially visible
394  */
395 Tour.prototype.isStepPotentiallyVisible = function (stepConfig) {
396     if (!stepConfig) {
397         // Without step config, there can be no step.
398         return false;
399     }
401     if (this.isStepActuallyVisible(stepConfig)) {
402         // If it is actually visible, it is already potentially visible.
403         return true;
404     }
406     if (typeof stepConfig.orphan !== 'undefined' && stepConfig.orphan) {
407         // Orphan steps have no target. They are always visible.
408         return true;
409     }
411     if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay) {
412         // Only return true if the activated has not been used yet.
413         return true;
414     }
416     // Not theoretically, or actually visible.
417     return false;
418 };
420 /**
421  * Is this step actually visible?
422  *
423  * @method  isStepActuallyVisible
424  * @param   {Integer}   stepNumber  Step number to test
425  * @return  {Boolean}               Whether the step is actually visible
426  */
427 Tour.prototype.isStepActuallyVisible = function (stepConfig) {
428     if (!stepConfig) {
429         // Without step config, there can be no step.
430         return false;
431     }
433     var target = this.getStepTarget(stepConfig);
434     if (target && target.length && target.is(':visible')) {
435         // Without a target, there can be no step.
436         return !!target.length;
437     }
439     return false;
440 };
442 /**
443  * Go to the next step in the tour.
444  *
445  * @method  next
446  * @chainable
447  */
448 Tour.prototype.next = function () {
449     return this.gotoStep(this.getNextStepNumber());
450 };
452 /**
453  * Go to the previous step in the tour.
454  *
455  * @method  previous
456  * @chainable
457  */
458 Tour.prototype.previous = function () {
459     return this.gotoStep(this.getPreviousStepNumber(), -1);
460 };
462 /**
463  * Go to the specified step in the tour.
464  *
465  * @method  gotoStep
466  * @param   {Integer}   stepNumber      The step number to display
467  * @chainable
468  */
469 Tour.prototype.gotoStep = function (stepNumber, direction) {
470     if (stepNumber < 0) {
471         return this.endTour();
472     }
474     var stepConfig = this.getStepConfig(stepNumber);
475     if (stepConfig === null) {
476         return this.endTour();
477     }
479     return this._gotoStep(stepConfig, direction);
480 };
482 Tour.prototype._gotoStep = function (stepConfig, direction) {
483     if (!stepConfig) {
484         return this.endTour();
485     }
487     if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay && !stepConfig.delayed) {
488         stepConfig.delayed = true;
489         window.setTimeout(this._gotoStep.bind(this), stepConfig.delay, stepConfig, direction);
491         return this;
492     } else if (!stepConfig.orphan && !this.isStepActuallyVisible(stepConfig)) {
493         var fn = direction == -1 ? 'getPreviousStepNumber' : 'getNextStepNumber';
494         return this.gotoStep(this[fn](stepConfig.stepNumber), direction);
495     }
497     this.hide();
499     this.fireEventHandlers('beforeRender', stepConfig);
500     this.renderStep(stepConfig);
501     this.fireEventHandlers('afterRender', stepConfig);
503     return this;
504 };
506 /**
507  * Fetch the normalised step configuration for the specified step number.
508  *
509  * @method  getStepConfig
510  * @param   {Integer}   stepNumber      The step number to fetch configuration for
511  * @return  {Object}                    The step configuration
512  */
513 Tour.prototype.getStepConfig = function (stepNumber) {
514     if (stepNumber === null || stepNumber < 0 || stepNumber >= this.steps.length) {
515         return null;
516     }
518     // Normalise the step configuration.
519     var stepConfig = this.normalizeStepConfig(this.steps[stepNumber]);
521     // Add the stepNumber to the stepConfig.
522     stepConfig = $.extend(stepConfig, { stepNumber: stepNumber });
524     return stepConfig;
525 };
527 /**
528  * Normalise the supplied step configuration.
529  *
530  * @method  normalizeStepConfig
531  * @param   {Object}    stepConfig      The step configuration to normalise
532  * @return  {Object}                    The normalised step configuration
533  */
534 Tour.prototype.normalizeStepConfig = function (stepConfig) {
536     if (typeof stepConfig.reflex !== 'undefined' && typeof stepConfig.moveAfterClick === 'undefined') {
537         stepConfig.moveAfterClick = stepConfig.reflex;
538     }
540     if (typeof stepConfig.element !== 'undefined' && typeof stepConfig.target === 'undefined') {
541         stepConfig.target = stepConfig.element;
542     }
544     if (typeof stepConfig.content !== 'undefined' && typeof stepConfig.body === 'undefined') {
545         stepConfig.body = stepConfig.content;
546     }
548     stepConfig = $.extend({}, this.stepDefaults, stepConfig);
550     stepConfig = $.extend({}, {
551         attachTo: stepConfig.target,
552         attachPoint: 'after'
553     }, stepConfig);
555     if (stepConfig.attachTo) {
556         stepConfig.attachTo = $(stepConfig.attachTo).first();
557     }
559     return stepConfig;
560 };
562 /**
563  * Fetch the actual step target from the selector.
564  *
565  * This should not be called until after any delay has completed.
566  *
567  * @method  getStepTarget
568  * @param   {Object}    stepConfig      The step configuration
569  * @return  {$}
570  */
571 Tour.prototype.getStepTarget = function (stepConfig) {
572     if (stepConfig.target) {
573         return $(stepConfig.target);
574     }
576     return null;
577 };
579 /**
580  * Fire any event handlers for the specified event.
581  *
582  * @param   {String}    eventName       The name of the event to handle
583  * @param   {Object}    data            Any data to pass to the event
584  * @chainable
585  */
586 Tour.prototype.fireEventHandlers = function (eventName, data) {
587     if (typeof this.eventHandlers[eventName] === 'undefined') {
588         return this;
589     }
591     this.eventHandlers[eventName].forEach(function (thisEvent) {
592         thisEvent.call(this, data);
593     }, this);
595     return this;
596 };
598 /**
599  * @method  addEventHandler
600  * @param   string      eventName       The name of the event to listen for
601  * @param   function    handler         The event handler to call
602  */
603 Tour.prototype.addEventHandler = function (eventName, handler) {
604     if (typeof this.eventHandlers[eventName] === 'undefined') {
605         this.eventHandlers[eventName] = [];
606     }
608     this.eventHandlers[eventName].push(handler);
610     return this;
611 };
613 /**
614  * Process listeners for the step being shown.
615  *
616  * @method  processStepListeners
617  * @param   {object}    stepConfig      The configuration for the step
618  * @chainable
619  */
620 Tour.prototype.processStepListeners = function (stepConfig) {
621     this.listeners.push(
622     // Next/Previous buttons.
623     {
624         node: this.currentStepNode,
625         args: ['click', '[data-role="next"]', $.proxy(this.next, this)]
626     }, {
627         node: this.currentStepNode,
628         args: ['click', '[data-role="previous"]', $.proxy(this.previous, this)]
629     },
631     // Close and end tour buttons.
632     {
633         node: this.currentStepNode,
634         args: ['click', '[data-role="end"]', $.proxy(this.endTour, this)]
635     },
637     // Click backdrop and hide tour.
638     {
639         node: $('[data-flexitour="backdrop"]'),
640         args: ['click', $.proxy(this.hide, this)]
641     },
643     // Click out and hide tour without backdrop.
644     {
645         node: $('body'),
646         args: ['click', $.proxy(function (e) {
647             // Handle click in or click out tour content,
648             // if click out, hide tour.
649             if (!this.currentStepNode.is(e.target) && $(e.target).closest('[data-role="flexitour-step"]').length === 0) {
650                 this.hide();
651             }
652         }, this)]
653     },
655     // Keypresses.
656     {
657         node: $('body'),
658         args: ['keydown', $.proxy(this.handleKeyDown, this)]
659     });
661     if (stepConfig.moveOnClick) {
662         var targetNode = this.getStepTarget(stepConfig);
663         this.listeners.push({
664             node: targetNode,
665             args: ['click', $.proxy(function (e) {
666                 if ($(e.target).parents('[data-flexitour="container"]').length === 0) {
667                     // Ignore clicks when they are in the flexitour.
668                     window.setTimeout($.proxy(this.next, this), 500);
669                 }
670             }, this)]
671         });
672     }
674     this.listeners.forEach(function (listener) {
675         listener.node.on.apply(listener.node, listener.args);
676     });
678     return this;
679 };
681 /**
682  * Reset step listeners.
683  *
684  * @method  resetStepListeners
685  * @chainable
686  */
687 Tour.prototype.resetStepListeners = function () {
688     // Stop listening to all external handlers.
689     if (this.listeners) {
690         this.listeners.forEach(function (listener) {
691             listener.node.off.apply(listener.node, listener.args);
692         });
693     }
694     this.listeners = [];
696     return this;
697 };
699 /**
700  * The standard step renderer.
701  *
702  * @method  renderStep
703  * @param   {Object}    stepConfig      The step configuration of the step
704  * @chainable
705  */
706 Tour.prototype.renderStep = function (stepConfig) {
707     // Store the current step configuration for later.
708     this.currentStepConfig = stepConfig;
709     this.setCurrentStepNumber(stepConfig.stepNumber);
711     // Fetch the template and convert it to a $ object.
712     var template = $(this.getTemplateContent());
714     // Title.
715     template.find('[data-placeholder="title"]').html(stepConfig.title);
717     // Body.
718     template.find('[data-placeholder="body"]').html(stepConfig.body);
720     // Is this the first step?
721     if (this.isFirstStep(stepConfig.stepNumber)) {
722         template.find('[data-role="previous"]').prop('disabled', true);
723     } else {
724         template.find('[data-role="previous"]').prop('disabled', false);
725     }
727     // Is this the final step?
728     if (this.isLastStep(stepConfig.stepNumber)) {
729         template.find('[data-role="next"]').prop('disabled', true);
730     } else {
731         template.find('[data-role="next"]').prop('disabled', false);
732     }
734     template.find('[data-role="previous"]').attr('role', 'button');
735     template.find('[data-role="next"]').attr('role', 'button');
736     template.find('[data-role="end"]').attr('role', 'button');
738     // Replace the template with the updated version.
739     stepConfig.template = template;
741     // Add to the page.
742     this.addStepToPage(stepConfig);
744     // Process step listeners after adding to the page.
745     // This uses the currentNode.
746     this.processStepListeners(stepConfig);
748     return this;
749 };
751 /**
752  * Getter for the template content.
753  *
754  * @method  getTemplateContent
755  * @return  {$}
756  */
757 Tour.prototype.getTemplateContent = function () {
758     return $(this.templateContent).clone();
759 };
761 /**
762  * Helper to add a step to the page.
763  *
764  * @method  addStepToPage
765  * @param   {Object}    stepConfig      The step configuration of the step
766  * @chainable
767  */
768 Tour.prototype.addStepToPage = function (stepConfig) {
769     var stepContent = stepConfig.template;
771     // Create the stepNode from the template data.
772     var currentStepNode = $('<span data-flexitour="container"></span>').html(stepConfig.template).hide();
774     // The scroll animation occurs on the body or html.
775     var animationTarget = $('body, html').stop(true, true);
777     if (this.isStepActuallyVisible(stepConfig)) {
778         var targetNode = this.getStepTarget(stepConfig);
780         targetNode.data('flexitour', 'target');
782         var zIndex = this.calculateZIndex(targetNode);
783         if (zIndex) {
784             stepConfig.zIndex = zIndex + 1;
785         }
787         if (stepConfig.zIndex) {
788             currentStepNode.css('zIndex', stepConfig.zIndex + 1);
789         }
791         // Add the backdrop.
792         this.positionBackdrop(stepConfig);
794         $(document.body).append(currentStepNode);
795         this.currentStepNode = currentStepNode;
797         // Ensure that the step node is positioned.
798         // Some situations mean that the value is not properly calculated without this step.
799         this.currentStepNode.css({
800             top: 0,
801             left: 0
802         });
804         animationTarget.animate({
805             scrollTop: this.calculateScrollTop(stepConfig)
806         }).promise().then(function () {
807             this.positionStep(stepConfig);
808             this.revealStep(stepConfig);
809         }.bind(this));
810     } else if (stepConfig.orphan) {
811         stepConfig.isOrphan = true;
813         // This will be appended to the body instead.
814         stepConfig.attachTo = $('body').first();
815         stepConfig.attachPoint = 'append';
817         // Add the backdrop.
818         this.positionBackdrop(stepConfig);
820         // This is an orphaned step.
821         currentStepNode.addClass('orphan');
823         // It lives in the body.
824         $(document.body).append(currentStepNode);
825         this.currentStepNode = currentStepNode;
827         this.currentStepNode.offset(this.calculateStepPositionInPage());
828         this.currentStepNode.css('position', 'fixed');
830         this.currentStepPopper = new Popper($('body'), this.currentStepNode[0], {
831             removeOnDestroy: true,
832             placement: stepConfig.placement + '-start',
833             arrowElement: '[data-role="arrow"]',
834             // Empty the modifiers. We've already placed the step and don't want it moved.
835             modifiers: {
836                 hide: {
837                     enabled: false
838                 },
839                 applyStyle: {
840                     onLoad: null,
841                     enabled: false
842                 }
843             }
844         });
846         this.revealStep(stepConfig);
847     }
849     return this;
850 };
852 Tour.prototype.revealStep = function (stepConfig) {
853     // Fade the step in.
854     this.currentStepNode.fadeIn('', $.proxy(function () {
855         // Announce via ARIA.
856         this.announceStep(stepConfig);
858         // Focus on the current step Node.
859         this.currentStepNode.focus();
860         window.setTimeout($.proxy(function () {
861             // After a brief delay, focus again.
862             // There seems to be an issue with Jaws where it only reads the dialogue title initially.
863             // This second focus helps it to read the full dialogue.
864             if (this.currentStepNode) {
865                 this.currentStepNode.focus();
866             }
867         }, this), 100);
868     }, this));
870     return this;
871 };
873 /**
874  * Helper to announce the step on the page.
875  *
876  * @method  announceStep
877  * @param   {Object}    stepConfig      The step configuration of the step
878  * @chainable
879  */
880 Tour.prototype.announceStep = function (stepConfig) {
881     // Setup the step Dialogue as per:
882     // * https://www.w3.org/TR/wai-aria-practices/#dialog_nonmodal
883     // * https://www.w3.org/TR/wai-aria-practices/#dialog_modal
885     // Generate an ID for the current step node.
886     var stepId = 'tour-step-' + this.tourName + '-' + stepConfig.stepNumber;
887     this.currentStepNode.attr('id', stepId);
889     var bodyRegion = this.currentStepNode.find('[data-placeholder="body"]').first();
890     bodyRegion.attr('id', stepId + '-body');
891     bodyRegion.attr('role', 'document');
893     var headerRegion = this.currentStepNode.find('[data-placeholder="title"]').first();
894     headerRegion.attr('id', stepId + '-title');
895     headerRegion.attr('aria-labelledby', stepId + '-body');
897     // Generally, a modal dialog has a role of dialog.
898     this.currentStepNode.attr('role', 'dialog');
899     this.currentStepNode.attr('tabindex', 0);
900     this.currentStepNode.attr('aria-labelledby', stepId + '-title');
901     this.currentStepNode.attr('aria-describedby', stepId + '-body');
903     // Configure ARIA attributes on the target.
904     var target = this.getStepTarget(stepConfig);
905     if (target) {
906         if (!target.attr('tabindex')) {
907             target.attr('tabindex', 0);
908         }
910         target.data('original-describedby', target.attr('aria-describedby')).attr('aria-describedby', stepId + '-body');
911     }
913     this.accessibilityShow(stepConfig);
915     return this;
916 };
918 /**
919  * Handle key down events.
920  *
921  * @method  handleKeyDown
922  * @param   {EventFacade} e
923  */
924 Tour.prototype.handleKeyDown = function (e) {
925     var tabbableSelector = 'a[href], link[href], [draggable=true], [contenteditable=true], :input:enabled, [tabindex], button:enabled';
926     switch (e.keyCode) {
927         case 27:
928             this.endTour();
929             break;
931         // 9 == Tab - trap focus for items with a backdrop.
932         case 9:
933             // Tab must be handled on key up only in this instance.
934             (function () {
935                 if (!this.currentStepConfig.hasBackdrop) {
936                     // Trapping tab focus is only handled for those steps with a backdrop.
937                     return;
938                 }
940                 // Find all tabbable locations.
941                 var activeElement = $(document.activeElement);
942                 var stepTarget = this.getStepTarget(this.currentStepConfig);
943                 var tabbableNodes = $(tabbableSelector);
944                 var dialogContainer = $('span[data-flexitour="container"]');
945                 var currentIndex = void 0;
946                 // Filter out element which is not belong to target section or dialogue.
947                 if (stepTarget) {
948                     tabbableNodes = tabbableNodes.filter(function (index, element) {
949                         return stepTarget != null && (stepTarget.has(element).length || dialogContainer.has(element).length || stepTarget.is(element) || dialogContainer.is(element));
950                     });
951                 }
953                 // Find index of focusing element.
954                 tabbableNodes.each(function (index, element) {
955                     if (activeElement.is(element)) {
956                         currentIndex = index;
957                         return false;
958                     }
959                 });
961                 var nextIndex = void 0;
962                 var nextNode = void 0;
963                 var focusRelevant = void 0;
964                 if (currentIndex != void 0) {
965                     var direction = 1;
966                     if (e.shiftKey) {
967                         direction = -1;
968                     }
969                     nextIndex = currentIndex;
970                     do {
971                         nextIndex += direction;
972                         nextNode = $(tabbableNodes[nextIndex]);
973                     } while (nextNode.length && nextNode.is(':disabled') || nextNode.is(':hidden'));
974                     if (nextNode.length) {
975                         // A new f
976                         focusRelevant = nextNode.closest(stepTarget).length;
977                         focusRelevant = focusRelevant || nextNode.closest(this.currentStepNode).length;
978                     } else {
979                         // Unable to find the target somehow.
980                         focusRelevant = false;
981                     }
982                 }
984                 if (focusRelevant) {
985                     nextNode.focus();
986                 } else {
987                     if (e.shiftKey) {
988                         // Focus on the last tabbable node in the step.
989                         this.currentStepNode.find(tabbableSelector).last().focus();
990                     } else {
991                         if (this.currentStepConfig.isOrphan) {
992                             // Focus on the step - there is no target.
993                             this.currentStepNode.focus();
994                         } else {
995                             // Focus on the step target.
996                             stepTarget.focus();
997                         }
998                     }
999                 }
1000                 e.preventDefault();
1001             }).call(this);
1002             break;
1003     }
1004 };
1006 /**
1007  * Start the current tour.
1008  *
1009  * @method  startTour
1010  * @param   {Integer}   startAt     Which step number to start at. If not specified, starts at the last point.
1011  * @chainable
1012  */
1013 Tour.prototype.startTour = function (startAt) {
1014     if (this.storage && typeof startAt === 'undefined') {
1015         var storageStartValue = this.storage.getItem(this.storageKey);
1016         if (storageStartValue) {
1017             var storageStartAt = parseInt(storageStartValue, 10);
1018             if (storageStartAt <= this.steps.length) {
1019                 startAt = storageStartAt;
1020             }
1021         }
1022     }
1024     if (typeof startAt === 'undefined') {
1025         startAt = this.getCurrentStepNumber();
1026     }
1028     this.fireEventHandlers('beforeStart', startAt);
1029     this.gotoStep(startAt);
1030     this.fireEventHandlers('afterStart', startAt);
1032     return this;
1033 };
1035 /**
1036  * Restart the tour from the beginning, resetting the completionlag.
1037  *
1038  * @method  restartTour
1039  * @chainable
1040  */
1041 Tour.prototype.restartTour = function () {
1042     return this.startTour(0);
1043 };
1045 /**
1046  * End the current tour.
1047  *
1048  * @method  endTour
1049  * @chainable
1050  */
1051 Tour.prototype.endTour = function () {
1052     this.fireEventHandlers('beforeEnd');
1054     if (this.currentStepConfig) {
1055         var previousTarget = this.getStepTarget(this.currentStepConfig);
1056         if (previousTarget) {
1057             if (!previousTarget.attr('tabindex')) {
1058                 previousTarget.attr('tabindex', '-1');
1059             }
1060             previousTarget.focus();
1061         }
1062     }
1064     this.hide(true);
1066     this.fireEventHandlers('afterEnd');
1068     return this;
1069 };
1071 /**
1072  * Hide any currently visible steps.
1073  *
1074  * @method hide
1075  * @chainable
1076  */
1077 Tour.prototype.hide = function (transition) {
1078     this.fireEventHandlers('beforeHide');
1080     if (this.currentStepNode && this.currentStepNode.length) {
1081         this.currentStepNode.hide();
1082         if (this.currentStepPopper) {
1083             this.currentStepPopper.destroy();
1084         }
1085     }
1087     // Restore original target configuration.
1088     if (this.currentStepConfig) {
1089         var target = this.getStepTarget(this.currentStepConfig);
1090         if (target) {
1091             if (target.data('original-labelledby')) {
1092                 target.attr('aria-labelledby', target.data('original-labelledby'));
1093             }
1095             if (target.data('original-describedby')) {
1096                 target.attr('aria-describedby', target.data('original-describedby'));
1097             }
1099             if (target.data('original-tabindex')) {
1100                 target.attr('tabindex', target.data('tabindex'));
1101             }
1102         }
1104         // Clear the step configuration.
1105         this.currentStepConfig = null;
1106     }
1108     var fadeTime = 0;
1109     if (transition) {
1110         fadeTime = 400;
1111     }
1113     // Remove the backdrop features.
1114     $('[data-flexitour="step-background"]').remove();
1115     $('[data-flexitour="step-backdrop"]').removeAttr('data-flexitour');
1116     $('[data-flexitour="backdrop"]').fadeOut(fadeTime, function () {
1117         $(this).remove();
1118     });
1120     // Remove aria-describedby and tabindex attributes.
1121     if (this.currentStepNode && this.currentStepNode.length) {
1122         var stepId = this.currentStepNode.attr('id');
1123         if (stepId) {
1124             var currentStepElement = '[aria-describedby="' + stepId + '-body"]';
1125             $(currentStepElement).removeAttr('tabindex');
1126             $(currentStepElement).removeAttr('aria-describedby');
1127         }
1128     }
1130     // Reset the listeners.
1131     this.resetStepListeners();
1133     this.accessibilityHide();
1135     this.fireEventHandlers('afterHide');
1137     this.currentStepNode = null;
1138     this.currentStepPopper = null;
1139     return this;
1140 };
1142 /**
1143  * Show the current steps.
1144  *
1145  * @method show
1146  * @chainable
1147  */
1148 Tour.prototype.show = function () {
1149     // Show the current step.
1150     var startAt = this.getCurrentStepNumber();
1152     return this.gotoStep(startAt);
1153 };
1155 /**
1156  * Return the current step node.
1157  *
1158  * @method  getStepContainer
1159  * @return  {jQuery}
1160  */
1161 Tour.prototype.getStepContainer = function () {
1162     return $(this.currentStepNode);
1163 };
1165 /**
1166  * Calculate scrollTop.
1167  *
1168  * @method  calculateScrollTop
1169  * @param   {Object}    stepConfig      The step configuration of the step
1170  * @return  {Number}
1171  */
1172 Tour.prototype.calculateScrollTop = function (stepConfig) {
1173     var scrollTop = $(window).scrollTop();
1174     var viewportHeight = $(window).height();
1175     var targetNode = this.getStepTarget(stepConfig);
1177     if (stepConfig.placement === 'top') {
1178         // If the placement is top, center scroll at the top of the target.
1179         scrollTop = targetNode.offset().top - viewportHeight / 2;
1180     } else if (stepConfig.placement === 'bottom') {
1181         // If the placement is bottom, center scroll at the bottom of the target.
1182         scrollTop = targetNode.offset().top + targetNode.height() - viewportHeight / 2;
1183     } else if (targetNode.height() <= viewportHeight * 0.8) {
1184         // If the placement is left/right, and the target fits in the viewport, centre screen on the target
1185         scrollTop = targetNode.offset().top - (viewportHeight - targetNode.height()) / 2;
1186     } else {
1187         // If the placement is left/right, and the target is bigger than the viewport, set scrollTop to target.top + buffer
1188         // and change step attachmentTarget to top+.
1189         scrollTop = targetNode.offset().top - viewportHeight * 0.2;
1190     }
1192     // Never scroll over the top.
1193     scrollTop = Math.max(0, scrollTop);
1195     // Never scroll beyond the bottom.
1196     scrollTop = Math.min($(document).height() - viewportHeight, scrollTop);
1198     return Math.ceil(scrollTop);
1199 };
1201 /**
1202  * Calculate dialogue position for page middle.
1203  *
1204  * @method  calculateScrollTop
1205  * @return  {Number}
1206  */
1207 Tour.prototype.calculateStepPositionInPage = function () {
1208     var viewportHeight = $(window).height();
1209     var stepHeight = this.currentStepNode.height();
1211     var viewportWidth = $(window).width();
1212     var stepWidth = this.currentStepNode.width();
1214     return {
1215         top: Math.ceil((viewportHeight - stepHeight) / 2),
1216         left: Math.ceil((viewportWidth - stepWidth) / 2)
1217     };
1218 };
1220 /**
1221  * Position the step on the page.
1222  *
1223  * @method  positionStep
1224  * @param   {Object}    stepConfig      The step configuration of the step
1225  * @chainable
1226  */
1227 Tour.prototype.positionStep = function (stepConfig) {
1228     var content = this.currentStepNode;
1229     if (!content || !content.length) {
1230         // Unable to find the step node.
1231         return this;
1232     }
1234     var flipBehavior = void 0;
1235     switch (stepConfig.placement) {
1236         case 'left':
1237             flipBehavior = ['left', 'right', 'top', 'bottom'];
1238             break;
1239         case 'right':
1240             flipBehavior = ['right', 'left', 'top', 'bottom'];
1241             break;
1242         case 'top':
1243             flipBehavior = ['top', 'bottom', 'right', 'left'];
1244             break;
1245         case 'bottom':
1246             flipBehavior = ['bottom', 'top', 'right', 'left'];
1247             break;
1248         default:
1249             flipBehavior = 'flip';
1250             break;
1251     }
1253     var target = this.getStepTarget(stepConfig);
1254     var config = {
1255         placement: stepConfig.placement + '-start',
1256         removeOnDestroy: true,
1257         modifiers: {
1258             flip: {
1259                 behaviour: flipBehavior
1260             },
1261             arrow: {
1262                 element: '[data-role="arrow"]'
1263             }
1264         },
1265         onCreate: function onCreate(data) {
1266             recalculateArrowPosition(data);
1267         },
1268         onUpdate: function onUpdate(data) {
1269             recalculateArrowPosition(data);
1270         }
1271     };
1273     var recalculateArrowPosition = function recalculateArrowPosition(data) {
1274         var placement = data.placement.split('-')[0];
1275         var isVertical = ['left', 'right'].indexOf(placement) !== -1;
1276         var arrowElement = data.instance.popper.querySelector('[data-role="arrow"]');
1277         var stepElement = $(data.instance.popper.querySelector('[data-role="flexitour-step"]'));
1278         if (isVertical) {
1279             var arrowHeight = parseFloat(window.getComputedStyle(arrowElement).height);
1280             var arrowOffset = parseFloat(window.getComputedStyle(arrowElement).top);
1281             var popperHeight = parseFloat(window.getComputedStyle(data.instance.popper).height);
1282             var popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).top);
1283             var popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
1284             var popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
1285             var arrowPos = arrowOffset + arrowHeight / 2;
1286             var maxPos = popperHeight + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
1287             var minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
1288             if (arrowPos >= maxPos || arrowPos <= minPos) {
1289                 var newArrowPos = 0;
1290                 if (arrowPos > popperHeight / 2) {
1291                     newArrowPos = maxPos - arrowHeight;
1292                 } else {
1293                     newArrowPos = minPos + arrowHeight;
1294                 }
1295                 $(arrowElement).css('top', newArrowPos);
1296             }
1297         } else {
1298             var arrowWidth = parseFloat(window.getComputedStyle(arrowElement).width);
1299             var _arrowOffset = parseFloat(window.getComputedStyle(arrowElement).left);
1300             var popperWidth = parseFloat(window.getComputedStyle(data.instance.popper).width);
1301             var _popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).left);
1302             var _popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
1303             var _popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
1304             var _arrowPos = _arrowOffset + arrowWidth / 2;
1305             var _maxPos = popperWidth + _popperOffset - _popperBorderWidth - _popperBorderRadiusWidth;
1306             var _minPos = _popperOffset + _popperBorderWidth + _popperBorderRadiusWidth;
1307             if (_arrowPos >= _maxPos || _arrowPos <= _minPos) {
1308                 var _newArrowPos = 0;
1309                 if (_arrowPos > popperWidth / 2) {
1310                     _newArrowPos = _maxPos - arrowWidth;
1311                 } else {
1312                     _newArrowPos = _minPos + arrowWidth;
1313                 }
1314                 $(arrowElement).css('left', _newArrowPos);
1315             }
1316         }
1317     };
1319     var background = $('[data-flexitour="step-background"]');
1320     if (background.length) {
1321         target = background;
1322     }
1323     this.currentStepPopper = new Popper(target, content[0], config);
1325     return this;
1326 };
1328 /**
1329  * Add the backdrop.
1330  *
1331  * @method  positionBackdrop
1332  * @param   {Object}    stepConfig      The step configuration of the step
1333  * @chainable
1334  */
1335 Tour.prototype.positionBackdrop = function (stepConfig) {
1336     if (stepConfig.backdrop) {
1337         this.currentStepConfig.hasBackdrop = true;
1338         var backdrop = $('<div data-flexitour="backdrop"></div>');
1340         if (stepConfig.zIndex) {
1341             if (stepConfig.attachPoint === 'append') {
1342                 stepConfig.attachTo.append(backdrop);
1343             } else {
1344                 backdrop.insertAfter(stepConfig.attachTo);
1345             }
1346         } else {
1347             $('body').append(backdrop);
1348         }
1350         if (this.isStepActuallyVisible(stepConfig)) {
1351             // The step has a visible target.
1352             // Punch a hole through the backdrop.
1353             var background = $('<div data-flexitour="step-background"></div>');
1355             var targetNode = this.getStepTarget(stepConfig);
1357             var buffer = 10;
1359             var colorNode = targetNode;
1360             if (buffer) {
1361                 colorNode = $('body');
1362             }
1364             background.css({
1365                 width: targetNode.outerWidth() + buffer + buffer,
1366                 height: targetNode.outerHeight() + buffer + buffer,
1367                 left: targetNode.offset().left - buffer,
1368                 top: targetNode.offset().top - buffer,
1369                 backgroundColor: this.calculateInherittedBackgroundColor(colorNode)
1370             });
1372             if (targetNode.offset().left < buffer) {
1373                 background.css({
1374                     width: targetNode.outerWidth() + targetNode.offset().left + buffer,
1375                     left: targetNode.offset().left
1376                 });
1377             }
1379             if (targetNode.offset().top < buffer) {
1380                 background.css({
1381                     height: targetNode.outerHeight() + targetNode.offset().top + buffer,
1382                     top: targetNode.offset().top
1383                 });
1384             }
1386             var targetRadius = targetNode.css('borderRadius');
1387             if (targetRadius && targetRadius !== $('body').css('borderRadius')) {
1388                 background.css('borderRadius', targetRadius);
1389             }
1391             var targetPosition = this.calculatePosition(targetNode);
1392             if (targetPosition === 'fixed') {
1393                 background.css('top', 0);
1394             } else if (targetPosition === 'absolute') {
1395                 background.css('position', 'fixed');
1396             }
1398             var fader = background.clone();
1399             fader.css({
1400                 backgroundColor: backdrop.css('backgroundColor'),
1401                 opacity: backdrop.css('opacity')
1402             });
1403             fader.attr('data-flexitour', 'step-background-fader');
1405             if (stepConfig.zIndex) {
1406                 if (stepConfig.attachPoint === 'append') {
1407                     stepConfig.attachTo.append(background);
1408                 } else {
1409                     fader.insertAfter(stepConfig.attachTo);
1410                     background.insertAfter(stepConfig.attachTo);
1411                 }
1412             } else {
1413                 $('body').append(fader);
1414                 $('body').append(background);
1415             }
1417             // Add the backdrop data to the actual target.
1418             // This is the part which actually does the work.
1419             targetNode.attr('data-flexitour', 'step-backdrop');
1421             if (stepConfig.zIndex) {
1422                 backdrop.css('zIndex', stepConfig.zIndex);
1423                 background.css('zIndex', stepConfig.zIndex + 1);
1424                 targetNode.css('zIndex', stepConfig.zIndex + 2);
1425             }
1427             fader.fadeOut('2000', function () {
1428                 $(this).remove();
1429             });
1430         }
1431     }
1432     return this;
1433 };
1435 /**
1436  * Calculate the inheritted z-index.
1437  *
1438  * @method  calculateZIndex
1439  * @param   {jQuery}    elem                        The element to calculate z-index for
1440  * @return  {Number}                                Calculated z-index
1441  */
1442 Tour.prototype.calculateZIndex = function (elem) {
1443     elem = $(elem);
1444     while (elem.length && elem[0] !== document) {
1445         // Ignore z-index if position is set to a value where z-index is ignored by the browser
1446         // This makes behavior of this function consistent across browsers
1447         // WebKit always returns auto if the element is positioned.
1448         var position = elem.css("position");
1449         if (position === "absolute" || position === "relative" || position === "fixed") {
1450             // IE returns 0 when zIndex is not specified
1451             // other browsers return a string
1452             // we ignore the case of nested elements with an explicit value of 0
1453             // <div style="z-index: -10;"><div style="z-index: 0;"></div></div>
1454             var value = parseInt(elem.css("zIndex"), 10);
1455             if (!isNaN(value) && value !== 0) {
1456                 return value;
1457             }
1458         }
1459         elem = elem.parent();
1460     }
1462     return 0;
1463 };
1465 /**
1466  * Calculate the inheritted background colour.
1467  *
1468  * @method  calculateInherittedBackgroundColor
1469  * @param   {jQuery}    elem                        The element to calculate colour for
1470  * @return  {String}                                Calculated background colour
1471  */
1472 Tour.prototype.calculateInherittedBackgroundColor = function (elem) {
1473     // Use a fake node to compare each element against.
1474     var fakeNode = $('<div>').hide();
1475     $('body').append(fakeNode);
1476     var fakeElemColor = fakeNode.css('backgroundColor');
1477     fakeNode.remove();
1479     elem = $(elem);
1480     while (elem.length && elem[0] !== document) {
1481         var color = elem.css('backgroundColor');
1482         if (color !== fakeElemColor) {
1483             return color;
1484         }
1485         elem = elem.parent();
1486     }
1488     return null;
1489 };
1491 /**
1492  * Calculate the inheritted position.
1493  *
1494  * @method  calculatePosition
1495  * @param   {jQuery}    elem                        The element to calculate position for
1496  * @return  {String}                                Calculated position
1497  */
1498 Tour.prototype.calculatePosition = function (elem) {
1499     elem = $(elem);
1500     while (elem.length && elem[0] !== document) {
1501         var position = elem.css('position');
1502         if (position !== 'static') {
1503             return position;
1504         }
1505         elem = elem.parent();
1506     }
1508     return null;
1509 };
1511 /**
1512  * Perform accessibility changes for step shown.
1513  *
1514  * This will add aria-hidden="true" to all siblings and parent siblings.
1515  *
1516  * @method  accessibilityShow
1517  */
1518 Tour.prototype.accessibilityShow = function () {
1519     var stateHolder = 'data-has-hidden';
1520     var attrName = 'aria-hidden';
1521     var hideFunction = function hideFunction(child) {
1522         var flexitourRole = child.data('flexitour');
1523         if (flexitourRole) {
1524             switch (flexitourRole) {
1525                 case 'container':
1526                 case 'target':
1527                     return;
1528             }
1529         }
1531         var hidden = child.attr(attrName);
1532         if (!hidden) {
1533             child.attr(stateHolder, true);
1534             child.attr(attrName, true);
1535         }
1536     };
1538     this.currentStepNode.siblings().each(function (index, node) {
1539         hideFunction($(node));
1540     });
1541     this.currentStepNode.parentsUntil('body').siblings().each(function (index, node) {
1542         hideFunction($(node));
1543     });
1544 };
1546 /**
1547  * Perform accessibility changes for step hidden.
1548  *
1549  * This will remove any newly added aria-hidden="true".
1550  *
1551  * @method  accessibilityHide
1552  */
1553 Tour.prototype.accessibilityHide = function () {
1554     var stateHolder = 'data-has-hidden';
1555     var attrName = 'aria-hidden';
1556     var showFunction = function showFunction(child) {
1557         var hidden = child.attr(stateHolder);
1558         if (typeof hidden !== 'undefined') {
1559             child.removeAttr(stateHolder);
1560             child.removeAttr(attrName);
1561         }
1562     };
1564     $('[' + stateHolder + ']').each(function (index, node) {
1565         showFunction($(node));
1566     });
1567 };
1569 if ((typeof exports === 'undefined' ? 'undefined' : _typeof(exports)) === 'object') {
1570     module.exports = Tour;
1573 return Tour;
1575 }));