admin/tool/usertours/amd/src/tour.js

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * A user tour.
  17. *
  18. * @module tool_usertours/tour
  19. * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. /**
  23. * A list of steps.
  24. *
  25. * @typedef {Object[]} StepList
  26. * @property {Number} stepId The id of the step in the database
  27. * @property {Number} position The position of the step within the tour (zero-indexed)
  28. */
  29. import $ from 'jquery';
  30. import * as Aria from 'core/aria';
  31. import Popper from 'core/popper';
  32. import {dispatchEvent} from 'core/event_dispatcher';
  33. import {eventTypes} from './events';
  34. import {getString} from 'core/str';
  35. import {prefetchStrings} from 'core/prefetch';
  36. import {notifyFilterContentUpdated} from 'core/event';
  37. import PendingPromise from 'core/pending';
  38. /**
  39. * The minimum spacing for tour step to display.
  40. *
  41. * @private
  42. * @constant
  43. * @type {number}
  44. */
  45. const MINSPACING = 10;
  46. const BUFFER = 10;
  47. /**
  48. * A user tour.
  49. *
  50. * @class tool_usertours/tour
  51. * @property {boolean} tourRunning Whether the tour is currently running.
  52. */
  53. const Tour = class {
  54. tourRunning = false;
  55. /**
  56. * @param {object} config The configuration object.
  57. */
  58. constructor(config) {
  59. this.init(config);
  60. }
  61. /**
  62. * Initialise the tour.
  63. *
  64. * @method init
  65. * @param {Object} config The configuration object.
  66. * @chainable
  67. * @return {Object} this.
  68. */
  69. init(config) {
  70. // Unset all handlers.
  71. this.eventHandlers = {};
  72. // Reset the current tour states.
  73. this.reset();
  74. // Store the initial configuration.
  75. this.originalConfiguration = config || {};
  76. // Apply configuration.
  77. this.configure.apply(this, arguments);
  78. // Unset recalculate state.
  79. this.possitionNeedToBeRecalculated = false;
  80. // Unset recalculate count.
  81. this.recalculatedNo = 0;
  82. try {
  83. this.storage = window.sessionStorage;
  84. this.storageKey = 'tourstate_' + this.tourName;
  85. } catch (e) {
  86. this.storage = false;
  87. this.storageKey = '';
  88. }
  89. prefetchStrings('tool_usertours', [
  90. 'nextstep_sequence',
  91. 'skip_tour'
  92. ]);
  93. return this;
  94. }
  95. /**
  96. * Reset the current tour state.
  97. *
  98. * @method reset
  99. * @chainable
  100. * @return {Object} this.
  101. */
  102. reset() {
  103. // Hide the current step.
  104. this.hide();
  105. // Unset all handlers.
  106. this.eventHandlers = [];
  107. // Unset all listeners.
  108. this.resetStepListeners();
  109. // Unset the original configuration.
  110. this.originalConfiguration = {};
  111. // Reset the current step number and list of steps.
  112. this.steps = [];
  113. // Reset the current step number.
  114. this.currentStepNumber = 0;
  115. return this;
  116. }
  117. /**
  118. * Prepare tour configuration.
  119. *
  120. * @method configure
  121. * @param {Object} config The configuration object.
  122. * @chainable
  123. * @return {Object} this.
  124. */
  125. configure(config) {
  126. if (typeof config === 'object') {
  127. // Tour name.
  128. if (typeof config.tourName !== 'undefined') {
  129. this.tourName = config.tourName;
  130. }
  131. // Set up eventHandlers.
  132. if (config.eventHandlers) {
  133. for (let eventName in config.eventHandlers) {
  134. config.eventHandlers[eventName].forEach(function(handler) {
  135. this.addEventHandler(eventName, handler);
  136. }, this);
  137. }
  138. }
  139. // Reset the step configuration.
  140. this.resetStepDefaults(true);
  141. // Configure the steps.
  142. if (typeof config.steps === 'object') {
  143. this.steps = config.steps;
  144. }
  145. if (typeof config.template !== 'undefined') {
  146. this.templateContent = config.template;
  147. }
  148. }
  149. // Check that we have enough to start the tour.
  150. this.checkMinimumRequirements();
  151. return this;
  152. }
  153. /**
  154. * Check that the configuration meets the minimum requirements.
  155. *
  156. * @method checkMinimumRequirements
  157. */
  158. checkMinimumRequirements() {
  159. // Need a tourName.
  160. if (!this.tourName) {
  161. throw new Error("Tour Name required");
  162. }
  163. // Need a minimum of one step.
  164. if (!this.steps || !this.steps.length) {
  165. throw new Error("Steps must be specified");
  166. }
  167. }
  168. /**
  169. * Reset step default configuration.
  170. *
  171. * @method resetStepDefaults
  172. * @param {Boolean} loadOriginalConfiguration Whether to load the original configuration supplied with the Tour.
  173. * @chainable
  174. * @return {Object} this.
  175. */
  176. resetStepDefaults(loadOriginalConfiguration) {
  177. if (typeof loadOriginalConfiguration === 'undefined') {
  178. loadOriginalConfiguration = true;
  179. }
  180. this.stepDefaults = {};
  181. if (!loadOriginalConfiguration || typeof this.originalConfiguration.stepDefaults === 'undefined') {
  182. this.setStepDefaults({});
  183. } else {
  184. this.setStepDefaults(this.originalConfiguration.stepDefaults);
  185. }
  186. return this;
  187. }
  188. /**
  189. * Set the step defaults.
  190. *
  191. * @method setStepDefaults
  192. * @param {Object} stepDefaults The step defaults to apply to all steps
  193. * @chainable
  194. * @return {Object} this.
  195. */
  196. setStepDefaults(stepDefaults) {
  197. if (!this.stepDefaults) {
  198. this.stepDefaults = {};
  199. }
  200. $.extend(
  201. this.stepDefaults,
  202. {
  203. element: '',
  204. placement: 'top',
  205. delay: 0,
  206. moveOnClick: false,
  207. moveAfterTime: 0,
  208. orphan: false,
  209. direction: 1,
  210. },
  211. stepDefaults
  212. );
  213. return this;
  214. }
  215. /**
  216. * Retrieve the current step number.
  217. *
  218. * @method getCurrentStepNumber
  219. * @return {Number} The current step number
  220. */
  221. getCurrentStepNumber() {
  222. return parseInt(this.currentStepNumber, 10);
  223. }
  224. /**
  225. * Store the current step number.
  226. *
  227. * @method setCurrentStepNumber
  228. * @param {Number} stepNumber The current step number
  229. * @chainable
  230. */
  231. setCurrentStepNumber(stepNumber) {
  232. this.currentStepNumber = stepNumber;
  233. if (this.storage) {
  234. try {
  235. this.storage.setItem(this.storageKey, stepNumber);
  236. } catch (e) {
  237. if (e.code === DOMException.QUOTA_EXCEEDED_ERR) {
  238. this.storage.removeItem(this.storageKey);
  239. }
  240. }
  241. }
  242. }
  243. /**
  244. * Get the next step number after the currently displayed step.
  245. *
  246. * @method getNextStepNumber
  247. * @param {Number} stepNumber The current step number
  248. * @return {Number} The next step number to display
  249. */
  250. getNextStepNumber(stepNumber) {
  251. if (typeof stepNumber === 'undefined') {
  252. stepNumber = this.getCurrentStepNumber();
  253. }
  254. let nextStepNumber = stepNumber + 1;
  255. // Keep checking the remaining steps.
  256. while (nextStepNumber <= this.steps.length) {
  257. if (this.isStepPotentiallyVisible(this.getStepConfig(nextStepNumber))) {
  258. return nextStepNumber;
  259. }
  260. nextStepNumber++;
  261. }
  262. return null;
  263. }
  264. /**
  265. * Get the previous step number before the currently displayed step.
  266. *
  267. * @method getPreviousStepNumber
  268. * @param {Number} stepNumber The current step number
  269. * @return {Number} The previous step number to display
  270. */
  271. getPreviousStepNumber(stepNumber) {
  272. if (typeof stepNumber === 'undefined') {
  273. stepNumber = this.getCurrentStepNumber();
  274. }
  275. let previousStepNumber = stepNumber - 1;
  276. // Keep checking the remaining steps.
  277. while (previousStepNumber >= 0) {
  278. if (this.isStepPotentiallyVisible(this.getStepConfig(previousStepNumber))) {
  279. return previousStepNumber;
  280. }
  281. previousStepNumber--;
  282. }
  283. return null;
  284. }
  285. /**
  286. * Is the step the final step number?
  287. *
  288. * @method isLastStep
  289. * @param {Number} stepNumber Step number to test
  290. * @return {Boolean} Whether the step is the final step
  291. */
  292. isLastStep(stepNumber) {
  293. let nextStepNumber = this.getNextStepNumber(stepNumber);
  294. return nextStepNumber === null;
  295. }
  296. /**
  297. * Is this step potentially visible?
  298. *
  299. * @method isStepPotentiallyVisible
  300. * @param {Object} stepConfig The step configuration to normalise
  301. * @return {Boolean} Whether the step is the potentially visible
  302. */
  303. isStepPotentiallyVisible(stepConfig) {
  304. if (!stepConfig) {
  305. // Without step config, there can be no step.
  306. return false;
  307. }
  308. if (this.isStepActuallyVisible(stepConfig)) {
  309. // If it is actually visible, it is already potentially visible.
  310. return true;
  311. }
  312. if (typeof stepConfig.orphan !== 'undefined' && stepConfig.orphan) {
  313. // Orphan steps have no target. They are always visible.
  314. return true;
  315. }
  316. if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay) {
  317. // Only return true if the activated has not been used yet.
  318. return true;
  319. }
  320. // Not theoretically, or actually visible.
  321. return false;
  322. }
  323. /**
  324. * Get potentially visible steps in a tour.
  325. *
  326. * @returns {StepList} A list of ordered steps
  327. */
  328. getPotentiallyVisibleSteps() {
  329. let position = 1;
  330. let result = [];
  331. // Checking the total steps.
  332. for (let stepNumber = 0; stepNumber < this.steps.length; stepNumber++) {
  333. const stepConfig = this.getStepConfig(stepNumber);
  334. if (this.isStepPotentiallyVisible(stepConfig)) {
  335. result[stepNumber] = {stepId: stepConfig.stepid, position: position};
  336. position++;
  337. }
  338. }
  339. return result;
  340. }
  341. /**
  342. * Is this step actually visible?
  343. *
  344. * @method isStepActuallyVisible
  345. * @param {Object} stepConfig The step configuration to normalise
  346. * @return {Boolean} Whether the step is actually visible
  347. */
  348. isStepActuallyVisible(stepConfig) {
  349. if (!stepConfig) {
  350. // Without step config, there can be no step.
  351. return false;
  352. }
  353. // Check if the CSS styles are allowed on the browser or not.
  354. if (!this.isCSSAllowed()) {
  355. return false;
  356. }
  357. let target = this.getStepTarget(stepConfig);
  358. if (target && target.length && target.is(':visible')) {
  359. // Without a target, there can be no step.
  360. return !!target.length;
  361. }
  362. return false;
  363. }
  364. /**
  365. * Is the browser actually allow CSS styles?
  366. *
  367. * @returns {boolean} True if the browser is allowing CSS styles
  368. */
  369. isCSSAllowed() {
  370. const testCSSElement = document.createElement('div');
  371. testCSSElement.classList.add('hide');
  372. document.body.appendChild(testCSSElement);
  373. const styles = window.getComputedStyle(testCSSElement);
  374. const isAllowed = styles.display === 'none';
  375. testCSSElement.remove();
  376. return isAllowed;
  377. }
  378. /**
  379. * Go to the next step in the tour.
  380. *
  381. * @method next
  382. * @chainable
  383. * @return {Object} this.
  384. */
  385. next() {
  386. return this.gotoStep(this.getNextStepNumber());
  387. }
  388. /**
  389. * Go to the previous step in the tour.
  390. *
  391. * @method previous
  392. * @chainable
  393. * @return {Object} this.
  394. */
  395. previous() {
  396. return this.gotoStep(this.getPreviousStepNumber(), -1);
  397. }
  398. /**
  399. * Go to the specified step in the tour.
  400. *
  401. * @method gotoStep
  402. * @param {Number} stepNumber The step number to display
  403. * @param {Number} direction Next or previous step
  404. * @chainable
  405. * @return {Object} this.
  406. * @fires tool_usertours/stepRender
  407. * @fires tool_usertours/stepRendered
  408. * @fires tool_usertours/stepHide
  409. * @fires tool_usertours/stepHidden
  410. */
  411. gotoStep(stepNumber, direction) {
  412. if (stepNumber < 0) {
  413. return this.endTour();
  414. }
  415. let stepConfig = this.getStepConfig(stepNumber);
  416. if (stepConfig === null) {
  417. return this.endTour();
  418. }
  419. return this._gotoStep(stepConfig, direction);
  420. }
  421. _gotoStep(stepConfig, direction) {
  422. if (!stepConfig) {
  423. return this.endTour();
  424. }
  425. const pendingPromise = new PendingPromise(`tool_usertours/tour:_gotoStep-${stepConfig.stepNumber}`);
  426. if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay && !stepConfig.delayed) {
  427. stepConfig.delayed = true;
  428. window.setTimeout(function(stepConfig, direction) {
  429. this._gotoStep(stepConfig, direction);
  430. pendingPromise.resolve();
  431. }, stepConfig.delay, stepConfig, direction);
  432. return this;
  433. } else if (!stepConfig.orphan && !this.isStepActuallyVisible(stepConfig)) {
  434. const fn = direction == -1 ? 'getPreviousStepNumber' : 'getNextStepNumber';
  435. this.gotoStep(this[fn](stepConfig.stepNumber), direction);
  436. pendingPromise.resolve();
  437. return this;
  438. }
  439. this.hide();
  440. const stepRenderEvent = this.dispatchEvent(eventTypes.stepRender, {stepConfig}, true);
  441. if (!stepRenderEvent.defaultPrevented) {
  442. this.renderStep(stepConfig);
  443. this.dispatchEvent(eventTypes.stepRendered, {stepConfig});
  444. }
  445. pendingPromise.resolve();
  446. return this;
  447. }
  448. /**
  449. * Fetch the normalised step configuration for the specified step number.
  450. *
  451. * @method getStepConfig
  452. * @param {Number} stepNumber The step number to fetch configuration for
  453. * @return {Object} The step configuration
  454. */
  455. getStepConfig(stepNumber) {
  456. if (stepNumber === null || stepNumber < 0 || stepNumber >= this.steps.length) {
  457. return null;
  458. }
  459. // Normalise the step configuration.
  460. let stepConfig = this.normalizeStepConfig(this.steps[stepNumber]);
  461. // Add the stepNumber to the stepConfig.
  462. stepConfig = $.extend(stepConfig, {stepNumber: stepNumber});
  463. return stepConfig;
  464. }
  465. /**
  466. * Normalise the supplied step configuration.
  467. *
  468. * @method normalizeStepConfig
  469. * @param {Object} stepConfig The step configuration to normalise
  470. * @return {Object} The normalised step configuration
  471. */
  472. normalizeStepConfig(stepConfig) {
  473. if (typeof stepConfig.reflex !== 'undefined' && typeof stepConfig.moveAfterClick === 'undefined') {
  474. stepConfig.moveAfterClick = stepConfig.reflex;
  475. }
  476. if (typeof stepConfig.element !== 'undefined' && typeof stepConfig.target === 'undefined') {
  477. stepConfig.target = stepConfig.element;
  478. }
  479. if (typeof stepConfig.content !== 'undefined' && typeof stepConfig.body === 'undefined') {
  480. stepConfig.body = stepConfig.content;
  481. }
  482. stepConfig = $.extend({}, this.stepDefaults, stepConfig);
  483. stepConfig = $.extend({}, {
  484. attachTo: stepConfig.target,
  485. attachPoint: 'after',
  486. }, stepConfig);
  487. if (stepConfig.attachTo) {
  488. stepConfig.attachTo = $(stepConfig.attachTo).first();
  489. }
  490. return stepConfig;
  491. }
  492. /**
  493. * Fetch the actual step target from the selector.
  494. *
  495. * This should not be called until after any delay has completed.
  496. *
  497. * @method getStepTarget
  498. * @param {Object} stepConfig The step configuration
  499. * @return {$}
  500. */
  501. getStepTarget(stepConfig) {
  502. if (stepConfig.target) {
  503. return $(stepConfig.target);
  504. }
  505. return null;
  506. }
  507. /**
  508. * Fire any event handlers for the specified event.
  509. *
  510. * @param {String} eventName The name of the event
  511. * @param {Object} [detail={}] Any additional details to pass into the eveent
  512. * @param {Boolean} [cancelable=false] Whether preventDefault() can be called
  513. * @returns {CustomEvent}
  514. */
  515. dispatchEvent(
  516. eventName,
  517. detail = {},
  518. cancelable = false
  519. ) {
  520. return dispatchEvent(eventName, {
  521. // Add the tour to the detail.
  522. tour: this,
  523. ...detail,
  524. }, document, {
  525. cancelable,
  526. });
  527. }
  528. /**
  529. * @method addEventHandler
  530. * @param {string} eventName The name of the event to listen for
  531. * @param {function} handler The event handler to call
  532. * @return {Object} this.
  533. */
  534. addEventHandler(eventName, handler) {
  535. if (typeof this.eventHandlers[eventName] === 'undefined') {
  536. this.eventHandlers[eventName] = [];
  537. }
  538. this.eventHandlers[eventName].push(handler);
  539. return this;
  540. }
  541. /**
  542. * Process listeners for the step being shown.
  543. *
  544. * @method processStepListeners
  545. * @param {object} stepConfig The configuration for the step
  546. * @chainable
  547. * @return {Object} this.
  548. */
  549. processStepListeners(stepConfig) {
  550. this.listeners.push(
  551. // Next button.
  552. {
  553. node: this.currentStepNode,
  554. args: ['click', '[data-role="next"]', $.proxy(this.next, this)]
  555. },
  556. // Close and end tour buttons.
  557. {
  558. node: this.currentStepNode,
  559. args: ['click', '[data-role="end"]', $.proxy(this.endTour, this)]
  560. },
  561. // Click backdrop and hide tour.
  562. {
  563. node: $('[data-flexitour="backdrop"]'),
  564. args: ['click', $.proxy(this.hide, this)]
  565. },
  566. // Keypresses.
  567. {
  568. node: $('body'),
  569. args: ['keydown', $.proxy(this.handleKeyDown, this)]
  570. });
  571. if (stepConfig.moveOnClick) {
  572. var targetNode = this.getStepTarget(stepConfig);
  573. this.listeners.push({
  574. node: targetNode,
  575. args: ['click', $.proxy(function(e) {
  576. if ($(e.target).parents('[data-flexitour="container"]').length === 0) {
  577. // Ignore clicks when they are in the flexitour.
  578. window.setTimeout($.proxy(this.next, this), 500);
  579. }
  580. }, this)]
  581. });
  582. }
  583. this.listeners.forEach(function(listener) {
  584. listener.node.on.apply(listener.node, listener.args);
  585. });
  586. return this;
  587. }
  588. /**
  589. * Reset step listeners.
  590. *
  591. * @method resetStepListeners
  592. * @chainable
  593. * @return {Object} this.
  594. */
  595. resetStepListeners() {
  596. // Stop listening to all external handlers.
  597. if (this.listeners) {
  598. this.listeners.forEach(function(listener) {
  599. listener.node.off.apply(listener.node, listener.args);
  600. });
  601. }
  602. this.listeners = [];
  603. return this;
  604. }
  605. /**
  606. * The standard step renderer.
  607. *
  608. * @method renderStep
  609. * @param {Object} stepConfig The step configuration of the step
  610. * @chainable
  611. * @return {Object} this.
  612. */
  613. renderStep(stepConfig) {
  614. // Store the current step configuration for later.
  615. this.currentStepConfig = stepConfig;
  616. this.setCurrentStepNumber(stepConfig.stepNumber);
  617. // Fetch the template and convert it to a $ object.
  618. let template = $(this.getTemplateContent());
  619. // Title.
  620. template.find('[data-placeholder="title"]')
  621. .html(stepConfig.title);
  622. // Body.
  623. template.find('[data-placeholder="body"]')
  624. .html(stepConfig.body);
  625. // Buttons.
  626. const nextBtn = template.find('[data-role="next"]');
  627. const endBtn = template.find('[data-role="end"]');
  628. // Is this the final step?
  629. if (this.isLastStep(stepConfig.stepNumber)) {
  630. nextBtn.hide();
  631. endBtn.removeClass("btn-secondary").addClass("btn-primary");
  632. } else {
  633. nextBtn.prop('disabled', false);
  634. // Use Skip tour label for the End tour button.
  635. getString('skip_tour', 'tool_usertours').then(value => {
  636. endBtn.html(value);
  637. return;
  638. }).catch();
  639. }
  640. nextBtn.attr('role', 'button');
  641. endBtn.attr('role', 'button');
  642. if (this.originalConfiguration.displaystepnumbers) {
  643. const stepsPotentiallyVisible = this.getPotentiallyVisibleSteps();
  644. const totalStepsPotentiallyVisible = stepsPotentiallyVisible.length;
  645. const position = stepsPotentiallyVisible[stepConfig.stepNumber].position;
  646. if (totalStepsPotentiallyVisible > 1) {
  647. // Change the label of the Next button to include the sequence.
  648. getString('nextstep_sequence', 'tool_usertours',
  649. {position: position, total: totalStepsPotentiallyVisible}).then(value => {
  650. nextBtn.html(value);
  651. return;
  652. }).catch();
  653. }
  654. }
  655. // Replace the template with the updated version.
  656. stepConfig.template = template;
  657. // Add to the page.
  658. this.addStepToPage(stepConfig);
  659. // Process step listeners after adding to the page.
  660. // This uses the currentNode.
  661. this.processStepListeners(stepConfig);
  662. return this;
  663. }
  664. /**
  665. * Getter for the template content.
  666. *
  667. * @method getTemplateContent
  668. * @return {$}
  669. */
  670. getTemplateContent() {
  671. return $(this.templateContent).clone();
  672. }
  673. /**
  674. * Helper to add a step to the page.
  675. *
  676. * @method addStepToPage
  677. * @param {Object} stepConfig The step configuration of the step
  678. * @chainable
  679. * @return {Object} this.
  680. */
  681. addStepToPage(stepConfig) {
  682. // Create the stepNode from the template data.
  683. let currentStepNode = $('<span data-flexitour="container"></span>')
  684. .html(stepConfig.template)
  685. .hide();
  686. // Trigger the Moodle filters.
  687. notifyFilterContentUpdated(currentStepNode);
  688. // The scroll animation occurs on the body or html.
  689. let animationTarget = $('body, html')
  690. .stop(true, true);
  691. if (this.isStepActuallyVisible(stepConfig)) {
  692. let targetNode = this.getStepTarget(stepConfig);
  693. targetNode.data('flexitour', 'target');
  694. // Add the backdrop.
  695. this.positionBackdrop(stepConfig);
  696. $(document.body).append(currentStepNode);
  697. this.currentStepNode = currentStepNode;
  698. // Ensure that the step node is positioned.
  699. // Some situations mean that the value is not properly calculated without this step.
  700. this.currentStepNode.css({
  701. top: 0,
  702. left: 0,
  703. });
  704. const pendingPromise = new PendingPromise(`tool_usertours/tour:addStepToPage-${stepConfig.stepNumber}`);
  705. animationTarget
  706. .animate({
  707. scrollTop: this.calculateScrollTop(stepConfig),
  708. }).promise().then(function() {
  709. this.positionStep(stepConfig);
  710. this.revealStep(stepConfig);
  711. pendingPromise.resolve();
  712. return;
  713. }.bind(this))
  714. .catch(function() {
  715. // Silently fail.
  716. });
  717. } else if (stepConfig.orphan) {
  718. stepConfig.isOrphan = true;
  719. // This will be appended to the body instead.
  720. stepConfig.attachTo = $('body').first();
  721. stepConfig.attachPoint = 'append';
  722. // Add the backdrop.
  723. this.positionBackdrop(stepConfig);
  724. // This is an orphaned step.
  725. currentStepNode.addClass('orphan');
  726. // It lives in the body.
  727. $(document.body).append(currentStepNode);
  728. this.currentStepNode = currentStepNode;
  729. this.currentStepNode.css('position', 'fixed');
  730. this.currentStepPopper = new Popper(
  731. $('body'),
  732. this.currentStepNode[0], {
  733. removeOnDestroy: true,
  734. placement: stepConfig.placement + '-start',
  735. arrowElement: '[data-role="arrow"]',
  736. // Empty the modifiers. We've already placed the step and don't want it moved.
  737. modifiers: {
  738. hide: {
  739. enabled: false,
  740. },
  741. applyStyle: {
  742. onLoad: null,
  743. enabled: false,
  744. },
  745. },
  746. onCreate: () => {
  747. // First, we need to check if the step's content contains any images.
  748. const images = this.currentStepNode.find('img');
  749. if (images.length) {
  750. // Images found, need to calculate the position when the image is loaded.
  751. images.on('load', () => {
  752. this.calculateStepPositionInPage(currentStepNode);
  753. });
  754. }
  755. this.calculateStepPositionInPage(currentStepNode);
  756. }
  757. }
  758. );
  759. this.revealStep(stepConfig);
  760. }
  761. return this;
  762. }
  763. /**
  764. * Make the given step visible.
  765. *
  766. * @method revealStep
  767. * @param {Object} stepConfig The step configuration of the step
  768. * @chainable
  769. * @return {Object} this.
  770. */
  771. revealStep(stepConfig) {
  772. // Fade the step in.
  773. const pendingPromise = new PendingPromise(`tool_usertours/tour:revealStep-${stepConfig.stepNumber}`);
  774. this.currentStepNode.fadeIn('', $.proxy(function() {
  775. // Announce via ARIA.
  776. this.announceStep(stepConfig);
  777. // Focus on the current step Node.
  778. this.currentStepNode.focus();
  779. window.setTimeout($.proxy(function() {
  780. // After a brief delay, focus again.
  781. // There seems to be an issue with Jaws where it only reads the dialogue title initially.
  782. // This second focus helps it to read the full dialogue.
  783. if (this.currentStepNode) {
  784. this.currentStepNode.focus();
  785. }
  786. pendingPromise.resolve();
  787. }, this), 100);
  788. }, this));
  789. return this;
  790. }
  791. /**
  792. * Helper to announce the step on the page.
  793. *
  794. * @method announceStep
  795. * @param {Object} stepConfig The step configuration of the step
  796. * @chainable
  797. * @return {Object} this.
  798. */
  799. announceStep(stepConfig) {
  800. // Setup the step Dialogue as per:
  801. // * https://www.w3.org/TR/wai-aria-practices/#dialog_nonmodal
  802. // * https://www.w3.org/TR/wai-aria-practices/#dialog_modal
  803. // Generate an ID for the current step node.
  804. let stepId = 'tour-step-' + this.tourName + '-' + stepConfig.stepNumber;
  805. this.currentStepNode.attr('id', stepId);
  806. let bodyRegion = this.currentStepNode.find('[data-placeholder="body"]').first();
  807. bodyRegion.attr('id', stepId + '-body');
  808. bodyRegion.attr('role', 'document');
  809. let headerRegion = this.currentStepNode.find('[data-placeholder="title"]').first();
  810. headerRegion.attr('id', stepId + '-title');
  811. headerRegion.attr('aria-labelledby', stepId + '-body');
  812. // Generally, a modal dialog has a role of dialog.
  813. this.currentStepNode.attr('role', 'dialog');
  814. this.currentStepNode.attr('tabindex', 0);
  815. this.currentStepNode.attr('aria-labelledby', stepId + '-title');
  816. this.currentStepNode.attr('aria-describedby', stepId + '-body');
  817. // Configure ARIA attributes on the target.
  818. let target = this.getStepTarget(stepConfig);
  819. if (target) {
  820. target.data('original-tabindex', target.attr('tabindex'));
  821. if (!target.attr('tabindex')) {
  822. target.attr('tabindex', 0);
  823. }
  824. target
  825. .data('original-describedby', target.attr('aria-describedby'))
  826. .attr('aria-describedby', stepId + '-body')
  827. ;
  828. }
  829. this.accessibilityShow(stepConfig);
  830. return this;
  831. }
  832. /**
  833. * Handle key down events.
  834. *
  835. * @method handleKeyDown
  836. * @param {EventFacade} e
  837. */
  838. handleKeyDown(e) {
  839. let tabbableSelector = 'a[href], link[href], [draggable=true], [contenteditable=true], ';
  840. tabbableSelector += ':input:enabled, [tabindex], button:enabled';
  841. switch (e.keyCode) {
  842. case 27:
  843. this.endTour();
  844. break;
  845. // 9 == Tab - trap focus for items with a backdrop.
  846. case 9:
  847. // Tab must be handled on key up only in this instance.
  848. (function() {
  849. if (!this.currentStepConfig.hasBackdrop) {
  850. // Trapping tab focus is only handled for those steps with a backdrop.
  851. return;
  852. }
  853. // Find all tabbable locations.
  854. let activeElement = $(document.activeElement);
  855. let stepTarget = this.getStepTarget(this.currentStepConfig);
  856. let tabbableNodes = $(tabbableSelector);
  857. let dialogContainer = $('span[data-flexitour="container"]');
  858. let currentIndex;
  859. // Filter out element which is not belong to target section or dialogue.
  860. if (stepTarget) {
  861. tabbableNodes = tabbableNodes.filter(function(index, element) {
  862. return stepTarget !== null
  863. && (stepTarget.has(element).length
  864. || dialogContainer.has(element).length
  865. || stepTarget.is(element)
  866. || dialogContainer.is(element));
  867. });
  868. }
  869. // Find index of focusing element.
  870. tabbableNodes.each(function(index, element) {
  871. if (activeElement.is(element)) {
  872. currentIndex = index;
  873. return false;
  874. }
  875. // Keep looping.
  876. return true;
  877. });
  878. let nextIndex;
  879. let nextNode;
  880. let focusRelevant;
  881. if (currentIndex != void 0) {
  882. let direction = 1;
  883. if (e.shiftKey) {
  884. direction = -1;
  885. }
  886. nextIndex = currentIndex;
  887. do {
  888. nextIndex += direction;
  889. nextNode = $(tabbableNodes[nextIndex]);
  890. } while (nextNode.length && nextNode.is(':disabled') || nextNode.is(':hidden'));
  891. if (nextNode.length) {
  892. // A new f
  893. focusRelevant = nextNode.closest(stepTarget).length;
  894. focusRelevant = focusRelevant || nextNode.closest(this.currentStepNode).length;
  895. } else {
  896. // Unable to find the target somehow.
  897. focusRelevant = false;
  898. }
  899. }
  900. if (focusRelevant) {
  901. nextNode.focus();
  902. } else {
  903. if (e.shiftKey) {
  904. // Focus on the last tabbable node in the step.
  905. this.currentStepNode.find(tabbableSelector).last().focus();
  906. } else {
  907. if (this.currentStepConfig.isOrphan) {
  908. // Focus on the step - there is no target.
  909. this.currentStepNode.focus();
  910. } else {
  911. // Focus on the step target.
  912. stepTarget.focus();
  913. }
  914. }
  915. }
  916. e.preventDefault();
  917. }).call(this);
  918. break;
  919. }
  920. }
  921. /**
  922. * Start the current tour.
  923. *
  924. * @method startTour
  925. * @param {Number} startAt Which step number to start at. If not specified, starts at the last point.
  926. * @chainable
  927. * @return {Object} this.
  928. * @fires tool_usertours/tourStart
  929. * @fires tool_usertours/tourStarted
  930. */
  931. startTour(startAt) {
  932. if (this.storage && typeof startAt === 'undefined') {
  933. let storageStartValue = this.storage.getItem(this.storageKey);
  934. if (storageStartValue) {
  935. let storageStartAt = parseInt(storageStartValue, 10);
  936. if (storageStartAt <= this.steps.length) {
  937. startAt = storageStartAt;
  938. }
  939. }
  940. }
  941. if (typeof startAt === 'undefined') {
  942. startAt = this.getCurrentStepNumber();
  943. }
  944. const tourStartEvent = this.dispatchEvent(eventTypes.tourStart, {startAt}, true);
  945. if (!tourStartEvent.defaultPrevented) {
  946. this.gotoStep(startAt);
  947. this.tourRunning = true;
  948. this.dispatchEvent(eventTypes.tourStarted, {startAt});
  949. }
  950. return this;
  951. }
  952. /**
  953. * Restart the tour from the beginning, resetting the completionlag.
  954. *
  955. * @method restartTour
  956. * @chainable
  957. * @return {Object} this.
  958. */
  959. restartTour() {
  960. return this.startTour(0);
  961. }
  962. /**
  963. * End the current tour.
  964. *
  965. * @method endTour
  966. * @chainable
  967. * @return {Object} this.
  968. * @fires tool_usertours/tourEnd
  969. * @fires tool_usertours/tourEnded
  970. */
  971. endTour() {
  972. const tourEndEvent = this.dispatchEvent(eventTypes.tourEnd, {}, true);
  973. if (tourEndEvent.defaultPrevented) {
  974. return this;
  975. }
  976. if (this.currentStepConfig) {
  977. let previousTarget = this.getStepTarget(this.currentStepConfig);
  978. if (previousTarget) {
  979. if (!previousTarget.attr('tabindex')) {
  980. previousTarget.attr('tabindex', '-1');
  981. }
  982. previousTarget.first().focus();
  983. }
  984. }
  985. this.hide(true);
  986. this.tourRunning = false;
  987. this.dispatchEvent(eventTypes.tourEnded);
  988. return this;
  989. }
  990. /**
  991. * Hide any currently visible steps.
  992. *
  993. * @method hide
  994. * @param {Bool} transition Animate the visibility change
  995. * @chainable
  996. * @return {Object} this.
  997. * @fires tool_usertours/stepHide
  998. * @fires tool_usertours/stepHidden
  999. */
  1000. hide(transition) {
  1001. const stepHideEvent = this.dispatchEvent(eventTypes.stepHide, {}, true);
  1002. if (stepHideEvent.defaultPrevented) {
  1003. return this;
  1004. }
  1005. const pendingPromise = new PendingPromise('tool_usertours/tour:hide');
  1006. if (this.currentStepNode && this.currentStepNode.length) {
  1007. this.currentStepNode.hide();
  1008. if (this.currentStepPopper) {
  1009. this.currentStepPopper.destroy();
  1010. }
  1011. }
  1012. // Restore original target configuration.
  1013. if (this.currentStepConfig) {
  1014. let target = this.getStepTarget(this.currentStepConfig);
  1015. if (target) {
  1016. if (target.data('original-labelledby')) {
  1017. target.attr('aria-labelledby', target.data('original-labelledby'));
  1018. }
  1019. if (target.data('original-describedby')) {
  1020. target.attr('aria-describedby', target.data('original-describedby'));
  1021. }
  1022. if (target.data('original-tabindex')) {
  1023. target.attr('tabindex', target.data('tabindex'));
  1024. } else {
  1025. // If the target does not have the tabindex attribute at the beginning. We need to remove it.
  1026. // We should wait a little here before removing the attribute to prevent the browser from adding it again.
  1027. window.setTimeout(() => {
  1028. target.removeAttr('tabindex');
  1029. }, 400);
  1030. }
  1031. }
  1032. // Clear the step configuration.
  1033. this.currentStepConfig = null;
  1034. }
  1035. // Remove the highlight attribute when the hide occurs.
  1036. $('[data-flexitour="highlight"]').removeAttr('data-flexitour');
  1037. const backdrop = $('[data-flexitour="backdrop"]');
  1038. if (backdrop.length) {
  1039. if (transition) {
  1040. const backdropRemovalPromise = new PendingPromise('tool_usertours/tour:hide:backdrop');
  1041. backdrop.fadeOut(400, function() {
  1042. $(this).remove();
  1043. backdropRemovalPromise.resolve();
  1044. });
  1045. } else {
  1046. backdrop.remove();
  1047. }
  1048. }
  1049. // Remove aria-describedby and tabindex attributes.
  1050. if (this.currentStepNode && this.currentStepNode.length) {
  1051. let stepId = this.currentStepNode.attr('id');
  1052. if (stepId) {
  1053. let currentStepElement = '[aria-describedby="' + stepId + '-body"]';
  1054. $(currentStepElement).removeAttr('tabindex');
  1055. $(currentStepElement).removeAttr('aria-describedby');
  1056. }
  1057. }
  1058. // Reset the listeners.
  1059. this.resetStepListeners();
  1060. this.accessibilityHide();
  1061. this.dispatchEvent(eventTypes.stepHidden);
  1062. this.currentStepNode = null;
  1063. this.currentStepPopper = null;
  1064. pendingPromise.resolve();
  1065. return this;
  1066. }
  1067. /**
  1068. * Show the current steps.
  1069. *
  1070. * @method show
  1071. * @chainable
  1072. * @return {Object} this.
  1073. */
  1074. show() {
  1075. // Show the current step.
  1076. let startAt = this.getCurrentStepNumber();
  1077. return this.gotoStep(startAt);
  1078. }
  1079. /**
  1080. * Return the current step node.
  1081. *
  1082. * @method getStepContainer
  1083. * @return {jQuery}
  1084. */
  1085. getStepContainer() {
  1086. return $(this.currentStepNode);
  1087. }
  1088. /**
  1089. * Check whether the target node has a fixed position, or is nested within one.
  1090. *
  1091. * @param {Object} targetNode The target element to check.
  1092. * @return {Boolean} Return true if fixed position found.
  1093. */
  1094. hasFixedPosition = (targetNode) => {
  1095. let currentElement = targetNode[0];
  1096. while (currentElement) {
  1097. const computedStyle = window.getComputedStyle(currentElement);
  1098. if (computedStyle.position === 'fixed') {
  1099. return true;
  1100. }
  1101. currentElement = currentElement.parentElement;
  1102. }
  1103. return false;
  1104. };
  1105. /**
  1106. * Calculate scrollTop.
  1107. *
  1108. * @method calculateScrollTop
  1109. * @param {Object} stepConfig The step configuration of the step
  1110. * @return {Number}
  1111. */
  1112. calculateScrollTop(stepConfig) {
  1113. let viewportHeight = $(window).height();
  1114. let targetNode = this.getStepTarget(stepConfig);
  1115. let scrollParent = $(window);
  1116. if (targetNode.parents('[data-usertour="scroller"]').length) {
  1117. scrollParent = targetNode.parents('[data-usertour="scroller"]');
  1118. }
  1119. let scrollTop = scrollParent.scrollTop();
  1120. if (this.hasFixedPosition(targetNode)) {
  1121. // Target must be in a fixed or custom position. No need to modify the scrollTop.
  1122. } else if (stepConfig.placement === 'top') {
  1123. // If the placement is top, center scroll at the top of the target.
  1124. scrollTop = targetNode.offset().top - (viewportHeight / 2);
  1125. } else if (stepConfig.placement === 'bottom') {
  1126. // If the placement is bottom, center scroll at the bottom of the target.
  1127. scrollTop = targetNode.offset().top + targetNode.height() + scrollTop - (viewportHeight / 2);
  1128. } else if (targetNode.height() <= (viewportHeight * 0.8)) {
  1129. // If the placement is left/right, and the target fits in the viewport, centre screen on the target
  1130. scrollTop = targetNode.offset().top - ((viewportHeight - targetNode.height()) / 2);
  1131. } else {
  1132. // If the placement is left/right, and the target is bigger than the viewport, set scrollTop to target.top + buffer
  1133. // and change step attachmentTarget to top+.
  1134. scrollTop = targetNode.offset().top - (viewportHeight * 0.2);
  1135. }
  1136. // Never scroll over the top.
  1137. scrollTop = Math.max(0, scrollTop);
  1138. // Never scroll beyond the bottom.
  1139. scrollTop = Math.min($(document).height() - viewportHeight, scrollTop);
  1140. return Math.ceil(scrollTop);
  1141. }
  1142. /**
  1143. * Calculate dialogue position for page middle.
  1144. *
  1145. * @param {jQuery} currentStepNode Current step node
  1146. * @method calculateScrollTop
  1147. */
  1148. calculateStepPositionInPage(currentStepNode) {
  1149. let top = MINSPACING;
  1150. const viewportHeight = $(window).height();
  1151. const stepHeight = currentStepNode.height();
  1152. const viewportWidth = $(window).width();
  1153. const stepWidth = currentStepNode.width();
  1154. if (viewportHeight >= (stepHeight + (MINSPACING * 2))) {
  1155. top = Math.ceil((viewportHeight - stepHeight) / 2);
  1156. } else {
  1157. const headerHeight = currentStepNode.find('.modal-header').first().outerHeight() ?? 0;
  1158. const footerHeight = currentStepNode.find('.modal-footer').first().outerHeight() ?? 0;
  1159. const currentStepBody = currentStepNode.find('[data-placeholder="body"]').first();
  1160. const maxHeight = viewportHeight - (MINSPACING * 2) - headerHeight - footerHeight;
  1161. currentStepBody.css({
  1162. 'max-height': maxHeight + 'px',
  1163. 'overflow': 'auto',
  1164. });
  1165. }
  1166. currentStepNode.offset({
  1167. top: top,
  1168. left: Math.ceil((viewportWidth - stepWidth) / 2)
  1169. });
  1170. }
  1171. /**
  1172. * Position the step on the page.
  1173. *
  1174. * @method positionStep
  1175. * @param {Object} stepConfig The step configuration of the step
  1176. * @chainable
  1177. * @return {Object} this.
  1178. */
  1179. positionStep(stepConfig) {
  1180. let content = this.currentStepNode;
  1181. let thisT = this;
  1182. if (!content || !content.length) {
  1183. // Unable to find the step node.
  1184. return this;
  1185. }
  1186. stepConfig.placement = this.recalculatePlacement(stepConfig);
  1187. let flipBehavior;
  1188. switch (stepConfig.placement) {
  1189. case 'left':
  1190. flipBehavior = ['left', 'right', 'top', 'bottom'];
  1191. break;
  1192. case 'right':
  1193. flipBehavior = ['right', 'left', 'top', 'bottom'];
  1194. break;
  1195. case 'top':
  1196. flipBehavior = ['top', 'bottom', 'right', 'left'];
  1197. break;
  1198. case 'bottom':
  1199. flipBehavior = ['bottom', 'top', 'right', 'left'];
  1200. break;
  1201. default:
  1202. flipBehavior = 'flip';
  1203. break;
  1204. }
  1205. let offset = '0';
  1206. if (stepConfig.backdrop) {
  1207. // Offset the arrow so that it points to the cut-out in the backdrop.
  1208. offset = `-${BUFFER}, ${BUFFER}`;
  1209. }
  1210. let target = this.getStepTarget(stepConfig);
  1211. var config = {
  1212. placement: stepConfig.placement + '-start',
  1213. removeOnDestroy: true,
  1214. modifiers: {
  1215. flip: {
  1216. behaviour: flipBehavior,
  1217. },
  1218. arrow: {
  1219. element: '[data-role="arrow"]',
  1220. },
  1221. offset: {
  1222. offset: offset
  1223. }
  1224. },
  1225. onCreate: function(data) {
  1226. recalculateArrowPosition(data);
  1227. recalculateStepPosition(data);
  1228. },
  1229. onUpdate: function(data) {
  1230. recalculateArrowPosition(data);
  1231. if (thisT.possitionNeedToBeRecalculated) {
  1232. thisT.recalculatedNo++;
  1233. thisT.possitionNeedToBeRecalculated = false;
  1234. recalculateStepPosition(data);
  1235. }
  1236. // Reset backdrop position when things update.
  1237. thisT.recalculateBackdropPosition(stepConfig);
  1238. },
  1239. };
  1240. let recalculateArrowPosition = function(data) {
  1241. let placement = data.placement.split('-')[0];
  1242. const isVertical = ['left', 'right'].indexOf(placement) !== -1;
  1243. const arrowElement = data.instance.popper.querySelector('[data-role="arrow"]');
  1244. const stepElement = $(data.instance.popper.querySelector('[data-role="flexitour-step"]'));
  1245. if (isVertical) {
  1246. let arrowHeight = parseFloat(window.getComputedStyle(arrowElement).height);
  1247. let arrowOffset = parseFloat(window.getComputedStyle(arrowElement).top);
  1248. let popperHeight = parseFloat(window.getComputedStyle(data.instance.popper).height);
  1249. let popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).top);
  1250. let popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
  1251. let popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
  1252. let arrowPos = arrowOffset + (arrowHeight / 2);
  1253. let maxPos = popperHeight + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
  1254. let minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
  1255. if (arrowPos >= maxPos || arrowPos <= minPos) {
  1256. let newArrowPos = 0;
  1257. if (arrowPos > (popperHeight / 2)) {
  1258. newArrowPos = maxPos - arrowHeight;
  1259. } else {
  1260. newArrowPos = minPos + arrowHeight;
  1261. }
  1262. $(arrowElement).css('top', newArrowPos);
  1263. }
  1264. } else {
  1265. let arrowWidth = parseFloat(window.getComputedStyle(arrowElement).width);
  1266. let arrowOffset = parseFloat(window.getComputedStyle(arrowElement).left);
  1267. let popperWidth = parseFloat(window.getComputedStyle(data.instance.popper).width);
  1268. let popperOffset = parseFloat(window.getComputedStyle(data.instance.popper).left);
  1269. let popperBorderWidth = parseFloat(stepElement.css('borderTopWidth'));
  1270. let popperBorderRadiusWidth = parseFloat(stepElement.css('borderTopLeftRadius')) * 2;
  1271. let arrowPos = arrowOffset + (arrowWidth / 2);
  1272. let maxPos = popperWidth + popperOffset - popperBorderWidth - popperBorderRadiusWidth;
  1273. let minPos = popperOffset + popperBorderWidth + popperBorderRadiusWidth;
  1274. if (arrowPos >= maxPos || arrowPos <= minPos) {
  1275. let newArrowPos = 0;
  1276. if (arrowPos > (popperWidth / 2)) {
  1277. newArrowPos = maxPos - arrowWidth;
  1278. } else {
  1279. newArrowPos = minPos + arrowWidth;
  1280. }
  1281. $(arrowElement).css('left', newArrowPos);
  1282. }
  1283. }
  1284. };
  1285. const recalculateStepPosition = function(data) {
  1286. const placement = data.placement.split('-')[0];
  1287. const isVertical = ['left', 'right'].indexOf(placement) !== -1;
  1288. const popperElement = $(data.instance.popper);
  1289. const targetElement = $(data.instance.reference);
  1290. const arrowElement = popperElement.find('[data-role="arrow"]');
  1291. const stepElement = popperElement.find('[data-role="flexitour-step"]');
  1292. const viewportHeight = $(window).height();
  1293. const viewportWidth = $(window).width();
  1294. const arrowHeight = parseFloat(arrowElement.outerHeight(true));
  1295. const popperHeight = parseFloat(popperElement.outerHeight(true));
  1296. const targetHeight = parseFloat(targetElement.outerHeight(true));
  1297. const arrowWidth = parseFloat(arrowElement.outerWidth(true));
  1298. const popperWidth = parseFloat(popperElement.outerWidth(true));
  1299. const targetWidth = parseFloat(targetElement.outerWidth(true));
  1300. let maxHeight;
  1301. if (thisT.recalculatedNo > 1) {
  1302. // The current screen is too small, and cannot fit with the original placement.
  1303. // We should set the placement to auto so the PopperJS can calculate the perfect placement.
  1304. thisT.currentStepPopper.options.placement = isVertical ? 'auto-left' : 'auto-bottom';
  1305. }
  1306. if (thisT.recalculatedNo > 2) {
  1307. // Return here to prevent recursive calling.
  1308. return;
  1309. }
  1310. if (isVertical) {
  1311. // Find the best place to put the tour: Left of right.
  1312. const leftSpace = targetElement.offset().left > 0 ? targetElement.offset().left : 0;
  1313. const rightSpace = viewportWidth - leftSpace - targetWidth;
  1314. const remainingSpace = leftSpace >= rightSpace ? leftSpace : rightSpace;
  1315. maxHeight = viewportHeight - MINSPACING * 2;
  1316. if (remainingSpace < (popperWidth + arrowWidth)) {
  1317. const maxWidth = remainingSpace - MINSPACING - arrowWidth;
  1318. if (maxWidth > 0) {
  1319. popperElement.css({
  1320. 'max-width': maxWidth + 'px',
  1321. });
  1322. // Not enough space, flag true to make Popper to recalculate the position.
  1323. thisT.possitionNeedToBeRecalculated = true;
  1324. }
  1325. } else if (maxHeight < popperHeight) {
  1326. // Check if the Popper's height can fit the viewport height or not.
  1327. // If not, set the correct max-height value for the Popper element.
  1328. popperElement.css({
  1329. 'max-height': maxHeight + 'px',
  1330. });
  1331. }
  1332. } else {
  1333. // Find the best place to put the tour: Top of bottom.
  1334. const topSpace = targetElement.offset().top > 0 ? targetElement.offset().top : 0;
  1335. const bottomSpace = viewportHeight - topSpace - targetHeight;
  1336. const remainingSpace = topSpace >= bottomSpace ? topSpace : bottomSpace;
  1337. maxHeight = remainingSpace - MINSPACING - arrowHeight;
  1338. if (remainingSpace < (popperHeight + arrowHeight)) {
  1339. // Not enough space, flag true to make Popper to recalculate the position.
  1340. thisT.possitionNeedToBeRecalculated = true;
  1341. }
  1342. }
  1343. // Check if the Popper's height can fit the viewport height or not.
  1344. // If not, set the correct max-height value for the body.
  1345. const currentStepBody = stepElement.find('[data-placeholder="body"]').first();
  1346. const headerEle = stepElement.find('.modal-header').first();
  1347. const footerEle = stepElement.find('.modal-footer').first();
  1348. const headerHeight = headerEle.outerHeight(true) ?? 0;
  1349. const footerHeight = footerEle.outerHeight(true) ?? 0;
  1350. maxHeight = maxHeight - headerHeight - footerHeight;
  1351. if (maxHeight > 0) {
  1352. headerEle.removeClass('minimal');
  1353. footerEle.removeClass('minimal');
  1354. currentStepBody.css({
  1355. 'max-height': maxHeight + 'px',
  1356. 'overflow': 'auto',
  1357. });
  1358. } else {
  1359. headerEle.addClass('minimal');
  1360. footerEle.addClass('minimal');
  1361. }
  1362. // Call the Popper update method to update the position.
  1363. thisT.currentStepPopper.update();
  1364. };
  1365. let background = $('[data-flexitour="highlight"]');
  1366. if (background.length) {
  1367. target = background;
  1368. }
  1369. this.currentStepPopper = new Popper(target, content[0], config);
  1370. return this;
  1371. }
  1372. /**
  1373. * For left/right placement, checks that there is room for the step at current window size.
  1374. *
  1375. * If there is not enough room, changes placement to 'top'.
  1376. *
  1377. * @method recalculatePlacement
  1378. * @param {Object} stepConfig The step configuration of the step
  1379. * @return {String} The placement after recalculate
  1380. */
  1381. recalculatePlacement(stepConfig) {
  1382. const arrowWidth = 16;
  1383. let target = this.getStepTarget(stepConfig);
  1384. let widthContent = this.currentStepNode.width() + arrowWidth;
  1385. let targetOffsetLeft = target.offset().left - BUFFER;
  1386. let targetOffsetRight = target.offset().left + target.width() + BUFFER;
  1387. let placement = stepConfig.placement;
  1388. if (['left', 'right'].indexOf(placement) !== -1) {
  1389. if ((targetOffsetLeft < (widthContent + BUFFER)) &&
  1390. ((targetOffsetRight + widthContent + BUFFER) > document.documentElement.clientWidth)) {
  1391. placement = 'top';
  1392. }
  1393. }
  1394. return placement;
  1395. }
  1396. /**
  1397. * Recaculate where the backdrop and its cut-out should be.
  1398. *
  1399. * This is needed when highlighted elements are off the page.
  1400. * This can be called on update to recalculate it all.
  1401. *
  1402. * @method recalculateBackdropPosition
  1403. * @param {Object} stepConfig The step configuration of the step
  1404. */
  1405. recalculateBackdropPosition(stepConfig) {
  1406. if (stepConfig.backdrop) {
  1407. this.positionBackdrop(stepConfig);
  1408. }
  1409. }
  1410. /**
  1411. * Add the backdrop.
  1412. *
  1413. * @method positionBackdrop
  1414. * @param {Object} stepConfig The step configuration of the step
  1415. * @chainable
  1416. * @return {Object} this.
  1417. */
  1418. positionBackdrop(stepConfig) {
  1419. if (stepConfig.backdrop) {
  1420. this.currentStepConfig.hasBackdrop = true;
  1421. // Position our backdrop above everything else.
  1422. let backdrop = $('div[data-flexitour="backdrop"]');
  1423. if (!backdrop.length) {
  1424. backdrop = $('<div data-flexitour="backdrop"></div>');
  1425. $('body').append(backdrop);
  1426. }
  1427. if (this.isStepActuallyVisible(stepConfig)) {
  1428. let targetNode = this.getStepTarget(stepConfig);
  1429. targetNode.attr('data-flexitour', 'highlight');
  1430. let distanceFromTop = targetNode[0].getBoundingClientRect().top;
  1431. let relativeTop = targetNode.offset().top - distanceFromTop;
  1432. /*
  1433. Draw a clip-path that makes the backdrop a window.
  1434. The clip-path is drawn with x/y coordinates in the following sequence.
  1435. 1--------------------------------------------------2
  1436. 11 |
  1437. | |
  1438. | 8-----------------------------7 |
  1439. | | | |
  1440. | | | |
  1441. | | | |
  1442. 10-------9 | |
  1443. 5--------------------------------------6 |
  1444. | |
  1445. | |
  1446. 4--------------------------------------------------3
  1447. */
  1448. // These values will help us draw the backdrop.
  1449. const viewportHeight = $(window).height();
  1450. const viewportWidth = $(window).width();
  1451. const elementWidth = targetNode.outerWidth() + (BUFFER * 2);
  1452. let elementHeight = targetNode.outerHeight() + (BUFFER * 2);
  1453. const elementLeft = targetNode.offset().left - BUFFER;
  1454. let elementTop = targetNode.offset().top - BUFFER - relativeTop;
  1455. // Check the amount of navbar overlap the highlight element has.
  1456. // We will adjust the backdrop shape to compensate for the fixed navbar.
  1457. let navbarOverlap = 0;
  1458. if (targetNode.parents('[data-usertour="scroller"]').length) {
  1459. // Determine the navbar height.
  1460. const scrollerElement = targetNode.parents('[data-usertour="scroller"]');
  1461. const navbarHeight = scrollerElement.offset().top;
  1462. navbarOverlap = Math.max(Math.ceil(navbarHeight - elementTop), 0);
  1463. elementTop = elementTop + navbarOverlap;
  1464. elementHeight = elementHeight - navbarOverlap;
  1465. }
  1466. // Check if the step container is in the 'top' position.
  1467. // We will re-anchor the step container to the shifted backdrop edge as opposed to the actual element.
  1468. if (this.currentStepNode && this.currentStepNode.length) {
  1469. const xPlacement = this.currentStepNode[0].getAttribute('x-placement');
  1470. if (xPlacement === 'top-start') {
  1471. this.currentStepNode[0].style.top = `${navbarOverlap}px`;
  1472. } else {
  1473. this.currentStepNode[0].style.top = '0px';
  1474. }
  1475. }
  1476. let backdropPath = document.querySelector('div[data-flexitour="backdrop"]');
  1477. const radius = 10;
  1478. const bottomRight = {
  1479. 'x1': elementLeft + elementWidth - radius,
  1480. 'y1': elementTop + elementHeight,
  1481. 'x2': elementLeft + elementWidth,
  1482. 'y2': elementTop + elementHeight - radius,
  1483. };
  1484. const topRight = {
  1485. 'x1': elementLeft + elementWidth,
  1486. 'y1': elementTop + radius,
  1487. 'x2': elementLeft + elementWidth - radius,
  1488. 'y2': elementTop,
  1489. };
  1490. const topLeft = {
  1491. 'x1': elementLeft + radius,
  1492. 'y1': elementTop,
  1493. 'x2': elementLeft,
  1494. 'y2': elementTop + radius,
  1495. };
  1496. const bottomLeft = {
  1497. 'x1': elementLeft,
  1498. 'y1': elementTop + elementHeight - radius,
  1499. 'x2': elementLeft + radius,
  1500. 'y2': elementTop + elementHeight,
  1501. };
  1502. // L = line.
  1503. // C = Bezier curve.
  1504. // Z = Close path.
  1505. backdropPath.style.clipPath = `path('M 0 0 \
  1506. L ${viewportWidth} 0 \
  1507. L ${viewportWidth} ${viewportHeight} \
  1508. L 0 ${viewportHeight} \
  1509. L 0 ${elementTop + elementHeight} \
  1510. L ${bottomRight.x1} ${bottomRight.y1} \
  1511. C ${bottomRight.x1} ${bottomRight.y1} ${bottomRight.x2} ${bottomRight.y1} ${bottomRight.x2} ${bottomRight.y2} \
  1512. L ${topRight.x1} ${topRight.y1} \
  1513. C ${topRight.x1} ${topRight.y1} ${topRight.x1} ${topRight.y2} ${topRight.x2} ${topRight.y2} \
  1514. L ${topLeft.x1} ${topLeft.y1} \
  1515. C ${topLeft.x1} ${topLeft.y1} ${topLeft.x2} ${topLeft.y1} ${topLeft.x2} ${topLeft.y2} \
  1516. L ${bottomLeft.x1} ${bottomLeft.y1} \
  1517. C ${bottomLeft.x1} ${bottomLeft.y1} ${bottomLeft.x1} ${bottomLeft.y2} ${bottomLeft.x2} ${bottomLeft.y2} \
  1518. L 0 ${elementTop + elementHeight} \
  1519. Z'
  1520. )`;
  1521. }
  1522. }
  1523. return this;
  1524. }
  1525. /**
  1526. * Calculate the inheritted position.
  1527. *
  1528. * @method calculatePosition
  1529. * @param {jQuery} elem The element to calculate position for
  1530. * @return {String} Calculated position
  1531. */
  1532. calculatePosition(elem) {
  1533. elem = $(elem);
  1534. while (elem.length && elem[0] !== document) {
  1535. let position = elem.css('position');
  1536. if (position !== 'static') {
  1537. return position;
  1538. }
  1539. elem = elem.parent();
  1540. }
  1541. return null;
  1542. }
  1543. /**
  1544. * Perform accessibility changes for step shown.
  1545. *
  1546. * This will add aria-hidden="true" to all siblings and parent siblings.
  1547. *
  1548. * @method accessibilityShow
  1549. */
  1550. accessibilityShow() {
  1551. let stateHolder = 'data-has-hidden';
  1552. let attrName = 'aria-hidden';
  1553. let hideFunction = function(child) {
  1554. let flexitourRole = child.data('flexitour');
  1555. if (flexitourRole) {
  1556. switch (flexitourRole) {
  1557. case 'container':
  1558. case 'target':
  1559. return;
  1560. }
  1561. }
  1562. let hidden = child.attr(attrName);
  1563. if (!hidden) {
  1564. child.attr(stateHolder, true);
  1565. Aria.hide(child);
  1566. }
  1567. };
  1568. this.currentStepNode.siblings().each(function(index, node) {
  1569. hideFunction($(node));
  1570. });
  1571. this.currentStepNode.parentsUntil('body').siblings().each(function(index, node) {
  1572. hideFunction($(node));
  1573. });
  1574. }
  1575. /**
  1576. * Perform accessibility changes for step hidden.
  1577. *
  1578. * This will remove any newly added aria-hidden="true".
  1579. *
  1580. * @method accessibilityHide
  1581. */
  1582. accessibilityHide() {
  1583. let stateHolder = 'data-has-hidden';
  1584. let showFunction = function(child) {
  1585. let hidden = child.attr(stateHolder);
  1586. if (typeof hidden !== 'undefined') {
  1587. child.removeAttr(stateHolder);
  1588. Aria.unhide(child);
  1589. }
  1590. };
  1591. $('[' + stateHolder + ']').each(function(index, node) {
  1592. showFunction($(node));
  1593. });
  1594. }
  1595. };
  1596. export default Tour;