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