/** * @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 * This will filter out tips that do not have a matching * page element or don't have the "bar" class. * http://example.com/foo?tips=bar * * @param {Object[]} tourItems * An array containing tour Step config objects. * The object properties relevant to this function: * - classes {string}: A string of classes to be added to the tour step * when rendered. * - selector {string}: The selector a tour step is associated with. */ _removeIrrelevantTourItems(tourItems) { const tips = /tips=([^&]+)/.exec(queryString); const filteredTour = tourItems.filter((tourItem) => { // If the query parameter 'tips' is set, remove all tips that don't // have the matching class. The `tourItem` variable is a step config // object, and the 'classes' property is a ShepherdJS Step() config // option that provides a string. if ( tips && tourItem.hasOwnProperty('classes') && tourItem.classes.indexOf(tips[1]) === -1 ) { return false; } // If a selector is configured but there isn't a matching element, // return false. return !( tourItem.selector && !document.querySelector(tourItem.selector) ); }); // If there are tours filtered, we'll have to update model. if (tourItems.length !== filteredTour.length) { filteredTour.forEach((filteredTourItem, filteredTourItemId) => { filteredTour[filteredTourItemId].counter = Drupal.t( '!tour_item of !total', { '!tour_item': filteredTourItemId + 1, '!total': filteredTour.length, }, ); if (filteredTourItemId === filteredTour.length - 1) { filteredTour[filteredTourItemId].cancelText = Drupal.t('End tour'); } }); this.model.set('tour', filteredTour); } }, }, ); /** * Provides an object that will become the tour item's 'next' button. * * Similar to a theme function, themes can override this function to customize * the resulting button. Unlike a theme function, it returns an object instead * of a string, which is why it is not part of Drupal.theme. * * @param {Tour} shepherdTour * A class representing a Shepherd site tour. * @param {Object} tourStepConfig * An object generated in TourViewBuilder used for creating the options * passed to `Tour.addStep(options)`. * Contains the following properties: * - id {string}: The tour.tip ID specified by its config * - selector {string|null}: The selector of the element the tour step is * attaching to. * - module {string}: The module providing the tip plugin used by this step. * - counter {string}: A string indicating which tour step this is out of * how many total steps. * - attachTo {Object} This is directly mapped to the `attachTo` Step() * option. It has two properties: * - element {string}: The selector of the element the step attaches to. * - on {string}: a PopperJS compatible string to specify step position. * - classes {string}: Will be added to the class attribute of the step. * - body {string}: Markup that is mapped to the `text` Step() option. Will * become the step content. * - title {string}: is mapped to the `title` Step() option. * * @return {{classes: string, action: string, text: string}} * An object structured in the manner Shepherd requires to create the * 'next' button. * * @see https://shepherdjs.dev/docs/Tour.html * @see \Drupal\tour\TourViewBuilder::viewMultiple() * @see https://shepherdjs.dev/docs/Step.html */ Drupal.tour.nextButton = (shepherdTour, tourStepConfig) => { return { classes: 'button button--primary', text: tourStepConfig.cancelText ? tourStepConfig.cancelText : Drupal.t('Next'), action: tourStepConfig.cancelText ? shepherdTour.cancel : shepherdTour.next, }; }; /** * Theme function for tour item content. * * @param {Object} tourStepConfig * An object generated in TourViewBuilder used for creating the options * passed to `Tour.addStep(options)`. * Contains the following properties: * - id {string}: The tour.tip ID specified by its config * - selector {string|null}: The selector of the element the tour step is * attaching to. * - module {string}: The module providing the tip plugin used by this step. * - counter {string}: A string indicating which tour step this is out of * how many total steps. * - attachTo {Object} This is directly mapped to the `attachTo` Step() * option. It has two properties: * - element {string}: The selector of the element the step attaches to. * - on {string}: a PopperJS compatible string to specify step position. * - classes {string}: Will be added to the class attribute of the step. * - body {string}: Markup that is mapped to the `text` Step() option. Will * become the step content. * - title {string}: is mapped to the `title` Step() option. * * @return {string} * The tour item content markup. * * @see \Drupal\tour\TourViewBuilder::viewMultiple() * @see https://shepherdjs.dev/docs/Step.html */ Drupal.theme.tourItemContent = (tourStepConfig) => `${tourStepConfig.body}
${tourStepConfig.counter}
`; })(jQuery, Backbone, Drupal, drupalSettings, document, window.Shepherd);