.../tour/config/tour.tooltip.admin-content-en.yml | 2 - .../modules/tour/config/tour.tooltip.search-en.yml | 2 - core/modules/tour/css/tour-rtl.css | 13 + core/modules/tour/css/tour.css | 35 ++- core/modules/tour/js/tour.js | 326 ++++++++++++-------- core/modules/tour/tour.info | 2 +- core/modules/tour/tour.module | 68 ++-- 7 files changed, 281 insertions(+), 167 deletions(-) diff --git a/core/modules/tour/config/tour.tooltip.admin-content-en.yml b/core/modules/tour/config/tour.tooltip.admin-content-en.yml index 064051a..bae9577 100644 --- a/core/modules/tour/config/tour.tooltip.admin-content-en.yml +++ b/core/modules/tour/config/tour.tooltip.admin-content-en.yml @@ -9,5 +9,3 @@ attributes: data-options: tipLocation:top data-id: node-admin-content data-text: Next - class: - - custom diff --git a/core/modules/tour/config/tour.tooltip.search-en.yml b/core/modules/tour/config/tour.tooltip.search-en.yml index 51f824b..7593177 100644 --- a/core/modules/tour/config/tour.tooltip.search-en.yml +++ b/core/modules/tour/config/tour.tooltip.search-en.yml @@ -9,5 +9,3 @@ weight: "1" attributes: data-id: search-block-form data-text: Next - class: - - custom diff --git a/core/modules/tour/css/tour-rtl.css b/core/modules/tour/css/tour-rtl.css new file mode 100644 index 0000000..5e19f7b --- /dev/null +++ b/core/modules/tour/css/tour-rtl.css @@ -0,0 +1,13 @@ +/** + * @file + * RTL styling for tour module. + */ + +.js .toolbar .bar .tour-toolbar-tab.tab { + float: left; +} + +.tour-progress { + right: 0; + left: 15px; +} diff --git a/core/modules/tour/css/tour.css b/core/modules/tour/css/tour.css index 9125c61..9f71ccb 100644 --- a/core/modules/tour/css/tour.css +++ b/core/modules/tour/css/tour.css @@ -1,21 +1,36 @@ -#tour-items { - display: none; -} - -.toolbar .icon-help.tour-no-items-hidden { - display: none; -} +/** + * @file + * Styling for tour module. + */ +/* Tab appearance. */ .js .toolbar .bar .tour-toolbar-tab.tab { - float: right; + float: right; /* LTR */ +} +.js .toolbar .bar .tour-toolbar-tab button { + padding-bottom: 1em; + padding-top: 1em; + color: #fff; + font-weight: bold; +} +.js .toolbar .bar .tour-toolbar-tab button.active { + background-image: -webkit-linear-gradient(rgba(255, 255, 255, 0.25) 20%, transparent 200%); + background-image: linear-gradient(rgba(255, 255, 255, 0.25) 20%, transparent 200%); } +/* Joyride tips should always be on top of everything else. */ .joyride-tip-guide { - z-index: 502 + z-index: 999; } +/* Override placement of the tour progress indicator. */ .tour-progress { position: absolute; bottom: 10px; - right: 15px; + right: 15px; /* LTR */ +} + +/* @todo Remove once http://drupal.org/node/1916690 is resolved. */ +.js .toolbar .bar .tour-toolbar-tab.tab.element-hidden { + display: none; } diff --git a/core/modules/tour/js/tour.js b/core/modules/tour/js/tour.js index 70e97f5..975a258 100644 --- a/core/modules/tour/js/tour.js +++ b/core/modules/tour/js/tour.js @@ -1,130 +1,202 @@ -(function ($, Drupal) { - - "use strict"; - - Drupal.behaviors.tour = { - attach: function (context) { - - var toolbar_id = "#toolbar-tab-tour"; - var toolbar_class = "tour-no-items-hidden"; - var tour_items = "#tour-items"; - var overlay_content = "#overlay-content " + tour_items; - - /** - * Initializes the tour. - */ - function setupTour(items, scope) { - var $toolbar = $(toolbar_id, scope); - - if ($(items).length) { - $toolbar.removeClass(toolbar_class); - - // Loop over items and remove missing tour items. - $("li", items).each(function (index) { - var tour_item_id = $(this).data("id"); - var tour_item_class = $(this).data("class"); - if (!$("#" + tour_item_id).length && !$("." + tour_item_class).length) { - $(this).remove(); - } - else { - $(this).data("text", Drupal.t("Next")); - } - }); - - // Update the last item to have "End tour" as the button. - $("li", items).last().data("text", Drupal.t("End tour")); - } - else { - $toolbar.addClass(toolbar_class); - } - $toolbar.bind("click.tour", function() { - if ((Drupal.overlay && Drupal.overlay.isOpen)|| !window) { - return false; - } - if ($(toolbar_id + ".touring", scope).length) { - // Allow other scripts to respond to this event. - $(document).trigger('drupalTourBeforeClose'); - - $(items).prop("hidden", true).joyride("destroy"); - closeTour(scope); - } - else { - // Allow other scripts to respond to this event. - $(document).trigger('drupalTourBeforeOpen'); - - $toolbar.addClass("touring"); - $(items).prop("hidden", false).joyride({ - "postRideCallback": function() { - $toolbar.removeClass("touring"); - $(document).trigger('drupalTourFinished'); - } - }); - } - return false; - }); - } - - /** - * Cleans up the tour. - */ - function closeTour(scope) { - $(toolbar_id, scope).removeClass("touring"); - } - - /** - * Detaches up the tour. - */ - function detachTour(scope) { - closeTour(scope); - $(toolbar_id, scope).unbind("click.tour"); - } - - /** - * Initialize the appropriate tour. - */ - if ($(overlay_content).length) { - $(document).bind('drupalOverlayBeforeLoad.drupal-overlay.drupal-overlay-child-loading', function() { - // Remove tour events associated with overlay. - detachTour(window.parent.document); - }); - - // Remove tour events associated with overlay. - detachTour(window.parent.document); - // Attach it based on outer content. - setupTour(overlay_content, window.parent.document); - } - else { - $(document).bind("drupalOverlayBeforeClose", function() { - // Remove tour events associated with overlay. - detachTour(window.document); - // Attach it based on outer content. - setupTour(tour_items, window.document); - }); - $(document).bind("drupalOverlayBeforeLoad", function() { - // Remove tour events associated with overlay. - detachTour(window.document); - }); - $(document).bind("drupalOverlayReady", function() { - if ($(tour_items).length) { - $(toolbar_id, window.parent.document).addClass(toolbar_class); - } - else { - $(toolbar_id, window.parent.document).removeClass(toolbar_class); - } - }); - - // Don't run this in overlay. - // This only runs once in the parent. - if (window == window.parent) { - $("body").once("tour", function() { - setupTour(tour_items, window.document); - }); - } - else { - setupTour(tour_items, window.document); +/** + * @file + * Attaches behaviors for the Tour module's toolbar tab. + */ + +(function ($, Backbone, Drupal, document) { + +"use strict"; + +/** + * Attaches the tour's toolbar tab behavior. + */ +Drupal.behaviors.tour = { + attach: function (context) { + var model = new Drupal.tour.models.StateModel(); + var view = new Drupal.tour.views.ToggleTourView({ + el: $(context).find('#toolbar-tab-tour'), + model: model + }); + + // Update the model based on Overlay events. + $(document) + // Overlay is opening: cancel tour if active and mark overlay as open. + .on('drupalOverlayOpen.tour', function () { + model.set({ isActive: false, overlayIsOpen: true }); + }) + // Overlay is loading a new URL: clear tour & cancel if active. + .on('drupalOverlayBeforeLoad.tour', function () { + model.set({ isActive: false, overlayTour: [] }); + }) + // Overlay is closing: clear tour & cancel if active, mark overlay closed. + .on('drupalOverlayClose.tour', function () { + model.set({ isActive: false, overlayIsOpen: false, overlayTour: [] }); + }) + // Overlay has loaded DOM: check whether a tour is available. + .on('drupalOverlayReady.tour', function () { + // We must select the tour in the Overlay's window using the Overlay's + // jQuery, because the joyride plugin only works for the window in which + // it was loaded. + // @todo Make upstream contribution so this can be simplified, which + // should also allow us to *not* load jquery.joyride.js in the Overlay, + // resulting in better front-end performance. + var overlay = Drupal.overlay.iframeWindow; + var $overlayContext = overlay.jQuery(overlay.document); + model.set('overlayTour', $overlayContext.find('#tour')); + }); + + model + // Allow other scripts to respond to tour events. + .on('change:isActive', function (model, isActive) { + $(document).trigger((isActive) ? 'drupalTourStarted' : 'drupalTourStopped'); + }) + // Initialization: check whether a tour is available on the current page. + .set('tour', $(context).find('#tour')); + } +}; + +Drupal.tour = Drupal.tour || { models: {}, views: {}}; + +/** + * Backbone Model for tours. + */ +Drupal.tour.models.StateModel = Backbone.Model.extend({ + defaults: { + // Indicates whether the Drupal root window has a tour. + tour: [], + // Indicates whether the Overlay is open. + overlayIsOpen: false, + // Indicates whether the Overlay window has a tour. + overlayTour: [], + // Indicates whether the tour is currently running. + isActive: false, + // Indicates which tour is the active one (necessary to cleanly stop). + activeTour: [] + } +}); + +/** + * Handles edit mode toggle interactions. + */ +Drupal.tour.views.ToggleTourView = Backbone.View.extend({ + + events: { 'click': 'onClick' }, + + /** + * Implements Backbone Views' initialize(). + */ + initialize: function () { + this.model.on('change:tour change:overlayTour change:overlayIsOpen change:isActive', this.render, this); + this.model.on('change:isActive', this.toggleTour, this); + }, + + /** + * Implements Backbone Views' render(). + */ + render: function () { + // Render the visibility. + this.$el.toggleClass('element-hidden', this._getTour().length === 0); + // Render the state. + var isActive = this.model.get('isActive'); + this.$el.find('button') + .toggleClass('active', isActive) + .attr('aria-pressed', isActive); + return this; + }, + + /** + * Model change handler; starts or stops the tour. + */ + toggleTour: function() { + if (this.model.get('isActive')) { + var $tour = this._getTour(); + this._removeIrrelevantTourItems($tour, this._getDocument()); + var that = this; + $tour.joyride({ + postRideCallback: function () { that.model.set('isActive', false); } + }); + this.model.set({ isActive: true, activeTour: $tour }); + } + else { + this.model.get('activeTour').joyride('destroy'); + this.model.set({ isActive: false, activeTour: [] }); + } + }, + + /** + * Toolbar tab click event handler; toggles isActive. + */ + onClick: function (event) { + this.model.set('isActive', !this.model.get('isActive')); + event.preventDefault(); + event.stopPropagation(); + }, + + /** + * Gets the tour. + * + * @return jQuery + * A jQuery element pointing to a
    containing tour items. + */ + _getTour: function () { + var whichTour = (this.model.get('overlayIsOpen')) ? 'overlayTour' : 'tour'; + return this.model.get(whichTour); + }, + + /** + * Gets the relevant document as a jQuery element. + * + * @return jQuery + * A jQuery element pointing to the document within which a tour would be + * started given the current state. I.e. when the Overlay is open, this will + * point to the HTML document inside the Overlay's iframe, otherwise it will + * point to the Drupal root window. + */ + _getDocument: function () { + return (this.model.get('overlayIsOpen')) ? $(Drupal.overlay.iframeWindow.document) : $(document); + }, + + /** + * Removes tour items for elements that don't exist. + * + * @param jQuery $tour + * A jQuery element pointing to a
      containing tour items. + * @param jQuery $document + * A jQuery element pointing to the document within which the elements + * should be sought. + * + * @see _getDocument() + */ + _removeIrrelevantTourItems: function ($tour, $document) { + var removals = false; + $tour + .find('li') + .each(function () { + var $this = $(this); + var itemId = $this.attr('data-id'); + var itemClass = $this.attr('data-class'); + if ($document.find('#' + itemId + ', .' + itemClass).length === 0) { + removals = true; + $this.remove(); } - } + }); + + // If there were removals, we'll have to do some clean-up. + if (removals) { + var total = $tour.find('li').length; + $tour + .find('li') + // Rebuild the progress data. + .each(function (index) { + var progress = Drupal.t('!tour_item of !total', { '!tour_item': index + 1, '!total': total }); + $(this).find('.tour-progress').text(progress); + }) + // Update the last item to have "End tour" as the button. + .last() + .attr('data-text', Drupal.t('End tour')); } - }; + } + +}); -})(jQuery, Drupal); +})(jQuery, Backbone, Drupal, document); diff --git a/core/modules/tour/tour.info b/core/modules/tour/tour.info index 77abd7b..341cb0d 100644 --- a/core/modules/tour/tour.info +++ b/core/modules/tour/tour.info @@ -1,5 +1,5 @@ name = Tour -description = Provides a guided tour of the Drupal interface. +description = Provides guided tours. package = Core version = VERSION core = 8.x diff --git a/core/modules/tour/tour.module b/core/modules/tour/tour.module index 7c6725a..8af489c 100644 --- a/core/modules/tour/tour.module +++ b/core/modules/tour/tour.module @@ -21,38 +21,46 @@ function tour_permission() { * Implements hook_library_info(). */ function tour_library_info() { - $tour_path = drupal_get_path('module', 'tour'); + $path = drupal_get_path('module', 'tour'); $libraries['tour'] = array( 'title' => 'Tour', - 'website' => '', - 'version' => '', + 'version' => VERSION, 'js' => array( - $tour_path . '/js/tour.js' => array(), + // Add the JavaScript, with a group and weight such that it will run + // before modules/overlay/overlay-parent.js. + $path . '/js/tour.js' => array('group' => JS_LIBRARY, 'weight' => -1), ), - 'css' => array( - $tour_path . '/css/tour.css' => array( - 'media' => 'screen', - ), + 'dependencies' => array( + array('system', 'jquery'), + array('system', 'drupal'), + array('system', 'backbone'), + array('tour', 'jquery.joyride'), + array('tour', 'tour-styling'), ), ); + $libraries['tour-styling'] = array( + 'title' => 'Tour', + 'version' => VERSION, + 'css' => array( + $path . '/css/tour.css' => array('media' => 'screen'), + ) + ); + $libraries['jquery.joyride'] = array( 'title' => 'Joyride', 'website' => 'https://github.com/zurb/joyride', 'version' => '2.0.3', 'js' => array( - $tour_path . '/js/jquery.joyride-2.0.3.js' => array(), + $path . '/js/jquery.joyride-2.0.3.js' => array(), ), 'css' => array( - $tour_path . '/css/joyride-2.0.3.css' => array( - 'media' => 'screen', - ), + $path . '/css/joyride-2.0.3.css' => array('media' => 'screen'), ), 'dependencies' => array( array('system', 'jquery'), array('system', 'jquery.cookie'), - array('tour', 'tour'), ), ); @@ -70,19 +78,18 @@ function tour_toolbar() { $tab['tour'] = array( '#type' => 'toolbar_item', 'tab' => array( - '#type' => 'link', - '#title' => t('Tour'), - '#href' => '', - '#options' => array( - 'html' => FALSE, - 'attributes' => array( - 'class' => array('icon', 'icon-help', 'tour-no-items-hidden'), - 'id' => 'toolbar-tab-tour', - ), + '#type' => 'html_tag', + '#tag' => 'button', + '#value' => t('Tour'), + '#attributes' => array( + 'class' => array('icon', 'icon-help'), + 'role' => 'button', + 'aria-pressed' => 'false', ), ), '#wrapper_attributes' => array( - 'class' => array('tour-toolbar-tab'), + 'class' => array('tour-toolbar-tab', 'element-hidden'), + 'id' => 'toolbar-tab-tour', ), '#attached' => array( 'library' => array( @@ -157,12 +164,23 @@ function tour_preprocess_page(&$variables) { '#items' => $list_items, '#type' => 'ol', '#attributes' => array( - 'id' => 'tour-items', - 'hidden' => TRUE, + 'id' => 'tour', + 'class' => 'element-hidden', ), '#attached' => array( 'library' => array( + // We must also attach the jquery.joyride library here, because it only + // works within the window within which it is loaded. This means that if + // we want the Tour module to work inside the Overlay, we must ensure + // that jquery.joyride also is loaded there. (And since the Toolbar does + // not get loaded inside the Overlay, we cannot rely on it being loaded + // that way.) + // If this a non-overlay page, then Drupal's dependency checking will + // ensure this gets loaded only once. array('tour', 'jquery.joyride'), + // Similarly, we must load tour's CSS, in order to style the tour tips + // in the desired way inside the Overlay. + array('tour', 'tour-styling'), ), ), );