/** * @file * Attaches behaviors for the Tour module's toolbar tab. */ (($, Backbone, Drupal, settings, document, Shepherd) => { const queryString = decodeURI(window.location.search); /** * Attaches the tour's toolbar tab behavior. * * It uses the query string for: * - tour: When ?tour=1 is present, the tour will start automatically after * the page has loaded. * - tips: Pass ?tips=class in the url to filter the available tips to the * subset which match the given class. * * @example * http://example.com/foo?tour=1&tips=bar * * @type {Drupal~behavior} * * @prop {Drupal~behaviorAttach} attach * Attach tour functionality on `tour` events. */ Drupal.behaviors.tour = { attach(context) { once('tour', 'body').forEach(() => { const model = new Drupal.tour.models.StateModel(); // eslint-disable-next-line no-new new Drupal.tour.views.ToggleTourView({ el: $(context).find('#toolbar-tab-tour'), model, }); model // Allow other scripts to respond to tour events. .on('change:isActive', (tourModel, isActive) => { $(document).trigger( isActive ? 'drupalTourStarted' : 'drupalTourStopped', ); }); // Initialization: check whether a tour is available on the current // page. if (settings._tour_internal) { model.set('tour', settings._tour_internal); } // Start the tour immediately if toggled via query string. if (/tour=?/i.test(queryString)) { model.set('isActive', true); } }); }, }; /** * @namespace */ Drupal.tour = Drupal.tour || { /** * @namespace Drupal.tour.models */ models: {}, /** * @namespace Drupal.tour.views */ views: {}, }; /** * Backbone Model for tours. * * @constructor * * @augments Backbone.Model */ Drupal.tour.models.StateModel = Backbone.Model.extend( /** @lends Drupal.tour.models.StateModel# */ { /** * @type {object} */ defaults: /** @lends Drupal.tour.models.StateModel# */ { /** * Indicates whether the Drupal root window has a tour. * * @type {Array} */ tour: [], /** * Indicates whether the tour is currently running. * * @type {bool} */ isActive: false, /** * Indicates which tour is the active one (necessary to cleanly stop). * * @type {Array} */ activeTour: [], }, }, ); Drupal.tour.views.ToggleTourView = Backbone.View.extend( /** @lends Drupal.tour.views.ToggleTourView# */ { /** * @type {object} */ events: { click: 'onClick' }, /** * Handles edit mode toggle interactions. * * @constructs * * @augments Backbone.View */ initialize() { this.listenTo(this.model, 'change:tour change:isActive', this.render); this.listenTo(this.model, 'change:isActive', this.toggleTour); }, /** * {@inheritdoc} * * @return {Drupal.tour.views.ToggleTourView} * The `ToggleTourView` view. */ render() { // Render the visibility. this.$el.toggleClass('hidden', this._getTour().length === 0); // Render the state. const isActive = this.model.get('isActive'); this.$el .find('button') .toggleClass('is-active', isActive) .attr('aria-pressed', isActive); return this; }, /** * Model change handler; starts or stops the tour. */ toggleTour() { if (this.model.get('isActive')) { this._removeIrrelevantTourItems(this._getTour()); const tourItems = this.model.get('tour'); const that = this; if (tourItems.length) { // If Joyride is positioned relative to the top or bottom of an // element, and its secondary position is right or left, then the // arrow is also positioned right or left. Shepherd defaults to // center positioning the arrow. // // In most cases, this arrow positioning difference has // little impact. However, tours built with Joyride may have tips // using a higher level selector than the element the tip is // expected to point to, and relied on Joyride's arrow positioning // to align the arrow with the expected reference element. Joyride's // arrow positioning behavior is replicated here to prevent those // use cases from causing UI regressions. // // This modifier is provided here instead of TourViewBuilder (where // most position modifications are) because it includes adding a // JavaScript callback function. settings.tourShepherdConfig.defaultStepOptions.popperOptions.modifiers.push( { name: 'moveArrowJoyridePosition', enabled: true, phase: 'write', fn({ state }) { const { arrow } = state.elements; const { placement } = state; if ( arrow && /^top|bottom/.test(placement) && /-start|-end$/.test(placement) ) { const horizontalPosition = placement.split('-')[1]; const offset = horizontalPosition === 'start' ? 28 : state.elements.popper.clientWidth - 56; arrow.style.transform = `translate3d(${offset}px, 0px, 0px)`; } }, }, ); const shepherdTour = new Shepherd.Tour(settings.tourShepherdConfig); shepherdTour.on('cancel', () => { that.model.set('isActive', false); }); shepherdTour.on('complete', () => { that.model.set('isActive', false); }); tourItems.forEach((tourStepConfig, index) => { // Create the configuration for a given tour step by using values // defined in TourViewBuilder. // @see \Drupal\tour\TourViewBuilder::viewMultiple() const tourItemOptions = { title: tourStepConfig.title ? Drupal.checkPlain(tourStepConfig.title) : null, text: () => Drupal.theme('tourItemContent', tourStepConfig), attachTo: tourStepConfig.attachTo, buttons: [Drupal.tour.nextButton(shepherdTour, tourStepConfig)], classes: tourStepConfig.classes, index, }; tourItemOptions.when = { show() { const nextButton = shepherdTour.currentStep.el.querySelector('footer button'); // Drupal disables Shepherd's built in focus after item // creation functionality due to focus being set on the tour // item container after every scroll and resize event. In its // place, the 'next' button is focused here. nextButton.focus(); // When Stable or Stable 9 are part of the active theme, the // Drupal.tour.convertToJoyrideMarkup() function is available. // This function converts Shepherd markup to Joyride markup, // facilitating the use of the Shepherd library that is // backwards compatible with customizations intended for // Joyride. // The Drupal.tour.convertToJoyrideMarkup() function is // internal, and will eventually be removed from Drupal core. if (Drupal.tour.hasOwnProperty('convertToJoyrideMarkup')) { Drupal.tour.convertToJoyrideMarkup(shepherdTour); } }, }; shepherdTour.addStep(tourItemOptions); }); shepherdTour.start(); this.model.set({ isActive: true, activeTour: shepherdTour }); } } else { this.model.get('activeTour').cancel(); this.model.set({ isActive: false, activeTour: [] }); } }, /** * Toolbar tab click event handler; toggles isActive. * * @param {jQuery.Event} event * The click event. */ onClick(event) { this.model.set('isActive', !this.model.get('isActive')); event.preventDefault(); event.stopPropagation(); }, /** * Gets the tour. * * @return {array} * An array of Shepherd tour item objects. */ _getTour() { return this.model.get('tour'); }, /** * Removes tour items for elements that don't have matching page elements. * * Or that are explicitly filtered out via the 'tips' query string. * * @example *