/** * @file toolbar.js * * Defines the behavior of the Drupal administration toolbar. */ (function ($, Drupal, drupalSettings, Backbone) { "use strict"; // Merge run-time settings with the defaults. var options = $.extend({ breakpoints: { 'module.toolbar.narrow': '', 'module.toolbar.standard': '', 'module.toolbar.wide': '' }, strings: { horizontal: Drupal.t('Horizontal orientation'), vertical: Drupal.t('Vertical orientation') } }, drupalSettings.toolbar); /** * Registers tabs with the toolbar. * * The Drupal toolbar allows modules to register top-level tabs. These may point * directly to a resource or toggle the visibility of a tray. * * Modules register tabs with hook_toolbar(). */ Drupal.behaviors.toolbar = { attach: function (context) { // Verify that the user agent understands media queries. Complex admin // toolbar layouts require media query support. if (!window.matchMedia('only screen').matches) { return; } // Process the administrative toolbar. $(context).find('#toolbar-administration').once('toolbar', function () { // Establish the toolbar models and views. var model = Drupal.toolbar.models.toolbarModel = new Drupal.toolbar.ToolbarModel({ locked: JSON.parse(localStorage.getItem('Drupal.toolbar.trayVerticalLocked')) || false, activeTab: document.getElementById(JSON.parse(localStorage.getItem('Drupal.toolbar.activeTabID'))) }); Drupal.toolbar.views.toolbarVisualView = new Drupal.toolbar.ToolbarVisualView({ el: this, model: model, strings: options.strings }); Drupal.toolbar.views.toolbarAuralView = new Drupal.toolbar.ToolbarAuralView({ el: this, model: model, strings: options.strings }); Drupal.toolbar.views.bodyVisualView = new Drupal.toolbar.BodyVisualView({ el: this, model: model }); // Render collapsible menus. var menuModel = Drupal.toolbar.models.menuModel = new Drupal.toolbar.MenuModel(); Drupal.toolbar.views.menuVisualView = new Drupal.toolbar.MenuVisualView({ el: $(this).find('.toolbar-menu-administration').get(0), model: menuModel, strings: options.strings }); // Handle the resolution of Drupal.toolbar.setSubtrees. // This is handled with a deferred so that the function may be invoked // asynchronously. Drupal.toolbar.setSubtrees.done(function (subtrees) { menuModel.set('subtrees', subtrees); localStorage.setItem('Drupal.toolbar.subtrees', JSON.stringify(subtrees)); // Indicate on the toolbarModel that subtrees are now loaded. model.set('areSubtreesLoaded', true); }); // Attach a listener to the configured media query breakpoints. for (var label in options.breakpoints) { if (options.breakpoints.hasOwnProperty(label)) { var mq = options.breakpoints[label]; var mql = Drupal.toolbar.mql[label] = window.matchMedia(mq); // Curry the model and the label of the media query breakpoint to the // mediaQueryChangeHandler function. mql.addListener(Drupal.toolbar.mediaQueryChangeHandler.bind(null, model, label)); // Fire the mediaQueryChangeHandler for each configured breakpoint // so that they process once. Drupal.toolbar.mediaQueryChangeHandler.call(null, model, label, mql); } } // Trigger an initial attempt to load menu subitems. This first attempt // is made after the media query handlers have had an opportunity to // process. The toolbar starts in the vertical orientation by default, // unless the viewport is wide enough to accomodate a horizontal // orientation. Thus we give the Toolbar a chance to determine if it // should be set to horizontal orientation before attempting to load menu // subtrees. Drupal.toolbar.views.toolbarVisualView.loadSubtrees(); $(document) // Update the model when the viewport offset changes. .on('drupalViewportOffsetChange.toolbar', function (event, offsets) { model.set('offsets', offsets); }) // The overlay will hide viewport overflow, potentially stranding tray // items that are offscreen. The toolbar will adjust tray presentation // to prevent this when viewport overflow is hidden. .on('drupalOverlayOpen.toolbar', function () { model.set('isViewportOverflowConstrained', true); }) .on('drupalOverlayClose.toolbar', function () { model.set('isViewportOverflowConstrained', false); }); // Broadcast model changes to other modules. model .on('change:orientation', function (model, orientation) { $(document).trigger('drupalToolbarOrientationChange', orientation); }) .on('change:activeTab', function (model, tab) { $(document).trigger('drupalToolbarTabChange', tab); }) .on('change:activeTray', function (model, tray) { $(document).trigger('drupalToolbarTrayChange', tray); }); }); } }; /** * Toolbar methods of Backbone objects. */ Drupal.toolbar = { // A hash of View instances. views: {}, // A hash of Model instances. models: {}, // A hash of MediaQueryList objects tracked by the toolbar. mql: {}, /** * Accepts a list of subtree menu elements. * * A deferred object that is resolved by an inlined JavaScript callback. * * JSONP callback. * @see toolbar_subtrees_jsonp(). */ setSubtrees: new $.Deferred(), /** * Respond to configured narrow media query changes. */ mediaQueryChangeHandler: function (model, label, mql) { switch (label) { case 'module.toolbar.narrow': model.set({ 'isOriented': mql.matches, 'isTrayToggleVisible': false }); // If the toolbar doesn't have an explicit orientation yet, or if the // narrow media query doesn't match then set the orientation to // vertical. if (!mql.matches || !model.get('orientation')) { model.set({'orientation': 'vertical'}, {validate: true}); } break; case 'module.toolbar.standard': model.set({ 'isFixed': mql.matches }); break; case 'module.toolbar.wide': model.set({ 'orientation': ((mql.matches) ? 'horizontal' : 'vertical') }, {validate: true}); // The tray orientation toggle visibility does not need to be validated. model.set({ 'isTrayToggleVisible': mql.matches }); break; default: break; } }, /** * Backbone model for the toolbar. */ ToolbarModel: Backbone.Model.extend({ defaults: { // The active toolbar tab. All other tabs should be inactive under // normal circumstances. It will remain active across page loads. The // active item is stored as a DOM element, not a jQuery set. activeTab: null, // Represents whether a tray is open or not. Stored as a DOM element, not // a jQuery set. activeTray: null, // Indicates whether the toolbar is displayed in an oriented fashion, // either horizontal or vertical. isOriented: false, // Indicates whether the toolbar is positioned absolute (false) or fixed // (true). isFixed: false, // Menu subtrees are loaded through an AJAX request only when the Toolbar // is set to a vertical orientation. areSubtreesLoaded: false, // If the viewport overflow becomes constrained, such as when the overlay // is open, isFixed must be true so that elements in the trays aren't // lost offscreen and impossible to get to. isViewportOverflowConstrained: false, // The orientation of the active tray. orientation: 'vertical', // A tray is locked if a user toggled it to vertical. Otherwise a tray // will switch between vertical and horizontal orientation based on the // configured breakpoints. The locked state will be maintained across page // loads. locked: false, // Indicates whether the tray orientation toggle is visible. isTrayToggleVisible: false, // The height of the toolbar. height: null, // The current viewport offsets determined by Drupal.displace(). The // offsets suggest how a module might position is components relative to // the viewport. offsets: { top: 0, right: 0, bottom: 0, left: 0 } }, /** * {@inheritdoc} */ validate: function (attributes, options) { // Prevent the orientation being set to horizontal if it is locked, unless // override has not been passed as an option. if (attributes.orientation === 'horizontal' && this.get('locked') && !options.override) { return Drupal.t('The toolbar cannot be set to a horizontal orientation when it is locked.'); } } }), /** * Backbone view for the aural feedback of the toolbar. */ ToolbarAuralView: Backbone.View.extend({ /** * {@inheritdoc} */ initialize: function (options) { this.strings = options.strings; this.model.on('change:orientation', this.onOrientationChange, this); this.model.on('change:activeTray', this.onActiveTrayChange, this); }, /** * Announces an orientation change. * * @param Drupal.Toolbar.ToolbarModel model * @param String orientation * The new value of the orientation attribute in the model. */ onOrientationChange: function (model, orientation) { Drupal.announce(Drupal.t('Tray orientation changed to @orientation.', { '@orientation': orientation })); }, /** * Announces a changed active tray. * * @param Drupal.Toolbar.ToolbarModel model * @param Element orientation * The new value of the tray attribute in the model. */ onActiveTrayChange: function (model, tray) { var relevantTray = (tray === null) ? model.previous('activeTray') : tray; var trayName = relevantTray.querySelector('.toolbar-tray-name').textContent; var text; if (tray === null) { text = Drupal.t('Tray "@tray" closed.', { '@tray': trayName }); } else { text = Drupal.t('Tray "@tray" opened.', { '@tray': trayName }); } Drupal.announce(text); } }), /** * Backbone view for the toolbar element. */ ToolbarVisualView: Backbone.View.extend({ events: { 'click .toolbar-bar .toolbar-tab': 'onTabClick', 'click .toolbar-toggle-orientation button': 'onOrientationToggleClick' }, /** * {@inheritdoc} */ initialize: function (options) { this.strings = options.strings; this.model.on('change:activeTab change:orientation change:isOriented change:isTrayToggleVisible', this.render, this); this.model.on('change:mqMatches', this.onMediaQueryChange, this); this.model.on('change:offsets', this.adjustPlacement, this); // Add the tray orientation toggles. this.$el .find('.toolbar-tray .toolbar-lining') .append(Drupal.theme('toolbarOrientationToggle')); // Trigger an activeTab change so that listening scripts can respond on // page load. This will call render. this.model.trigger('change:activeTab'); }, /** * {@inheritdoc} */ render: function () { this.updateTabs(); this.updateTrayOrientation(); this.updateBarAttributes(); // Load the subtrees if the orientation of the toolbar is changed to // vertical. This condition responds to the case that the toolbar switches // from horizontal to vertical orientation. The toolbar starts in a // vertical orientation by default and then switches to horizontal during // initialization if the media query conditions are met. Simply checking // that the orientation is vertical here would result in the subtrees // always being loaded, even when the toolbar initialization ultimately // results in a horizontal orientation. // // @see Drupal.behaviors.toolbar.attach() where admin menu subtrees // loading is invoked during initialization after media query conditions // have been processed. if (this.model.changed.orientation === 'vertical' || this.model.changed.activeTab) { this.loadSubtrees(); } // Trigger a recalculation of viewport displacing elements. Use setTimeout // to ensure this recalculation happens after changes to visual elements // have processed. window.setTimeout(function () { Drupal.displace(true); }, 0); return this; }, /** * Responds to a toolbar tab click. * * @param jQuery.Event event */ onTabClick: function (event) { // If this tab has a tray associated with it, it is considered an // activatable tab. if (event.target.hasAttribute('data-toolbar-tray')) { var tab = this.model.get('activeTab'); // Set the event target as the active item if it is not already. this.model.set('activeTab', (!tab || event.target !== tab) ? event.target : null); event.preventDefault(); event.stopPropagation(); } }, /** * Toggles the orientation of a toolbar tray. * * @param jQuery.Event event */ onOrientationToggleClick: function (event) { var orientation = this.model.get('orientation'); // Determine the toggle-to orientation. var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical'; var locked = (antiOrientation === 'vertical') ? true : false; // Remember the locked state. if (locked) { localStorage.setItem('Drupal.toolbar.trayVerticalLocked', 'true'); } else { localStorage.removeItem('Drupal.toolbar.trayVerticalLocked'); } // Update the model. this.model.set({ locked: locked, orientation: antiOrientation }, { validate: true, override: true }); event.preventDefault(); event.stopPropagation(); }, /** * Updates the display of the tabs: toggles a tab and the associated tray. */ updateTabs: function () { var $tab = $(this.model.get('activeTab')); // Deactivate the previous tab. $(this.model.previous('activeTab')) .removeClass('active') .prop('aria-pressed', false); // Deactivate the previous tray. $(this.model.previous('activeTray')) .removeClass('active'); // Activate the selected tab. if ($tab.length > 0) { $tab .addClass('active') // Mark the tab as pressed. .prop('aria-pressed', true); var name = $tab.attr('data-toolbar-tray'); // Store the active tab name or remove the setting. var id = $tab.get(0).id; if (id) { localStorage.setItem('Drupal.toolbar.activeTabID', JSON.stringify(id)); } // Activate the associated tray. var $tray = this.$el.find('[data-toolbar-tray="' + name + '"].toolbar-tray'); if ($tray.length) { $tray.addClass('active'); this.model.set('activeTray', $tray.get(0)); } else { // There is no active tray. this.model.set('activeTray', null); } } else { // There is no active tray. this.model.set('activeTray', null); localStorage.removeItem('Drupal.toolbar.activeTabID'); } }, /** * Update the attributes of the toolbar bar element. */ updateBarAttributes: function () { var isOriented = this.model.get('isOriented'); if (isOriented) { this.$el.find('.toolbar-bar').attr('data-offset-top', ''); } else { this.$el.find('.toolbar-bar').removeAttr('data-offset-top'); } // Toggle between a basic vertical view and a more sophisticated // horizontal and vertical display of the toolbar bar and trays. this.$el.toggleClass('toolbar-oriented', isOriented); }, /** * Updates the orientation of the active tray if necessary. */ updateTrayOrientation: function () { var orientation = this.model.get('orientation'); // The antiOrientation is used to render the view of action buttons like // the tray orientation toggle. var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical'; // Update the orientation of the trays. var $trays = this.$el.find('.toolbar-tray') .removeClass('toolbar-tray-horizontal toolbar-tray-vertical') .addClass('toolbar-tray-' + orientation); // Update the tray orientation toggle button. var iconClass = 'toolbar-icon-toggle-' + orientation; var iconAntiClass = 'toolbar-icon-toggle-' + antiOrientation; var $orientationToggle = this.$el.find('.toolbar-toggle-orientation') .toggle(this.model.get('isTrayToggleVisible')); $orientationToggle.find('button') .val(antiOrientation) .text(this.strings[antiOrientation]) .removeClass(iconClass) .addClass(iconAntiClass); // Update data offset attributes for the trays. var dir = document.documentElement.dir; var edge = (dir === 'rtl') ? 'right' : 'left'; // Remove data-offset attributes from the trays so they can be refreshed. $trays.removeAttr('data-offset-left data-offset-right data-offset-top'); // If an active vertical tray exists, mark it as an offset element. $trays.filter('.toolbar-tray-vertical.active').attr('data-offset-' + edge, ''); // If an active horizontal tray exists, mark it as an offset element. $trays.filter('.toolbar-tray-horizontal.active').attr('data-offset-top', ''); }, /** * Sets the tops of the trays so that they align with the bottom of the bar. */ adjustPlacement: function () { var $trays = this.$el.find('.toolbar-tray'); if (!this.model.get('isOriented')) { $trays.css('padding-top', 0); $trays.removeClass('toolbar-tray-horizontal').addClass('toolbar-tray-vertical'); } else { // The navbar container is invisible. Its placement is used to determine // the container for the trays. $trays.css('padding-top', this.$el.find('.toolbar-bar').outerHeight()); } }, /** * Calls the endpoint URI that will return rendered subtrees with JSONP. * * The rendered admin menu subtrees HTML is cached on the client in * localStorage until the cache of the admin menu subtrees on the server- * side is invalidated. The subtreesHash is stored in localStorage as well * and compared to the subtreesHash in drupalSettings to determine when the * admin menu subtrees cache has been invalidated. */ loadSubtrees: function () { var $activeTab = $(this.model.get('activeTab')); var orientation = this.model.get('orientation'); // Only load and render the admin menu subtrees if: // (1) They have not been loaded yet. // (2) The active tab is the administration menu tab, indicated by the // presence of the data-drupal-subtrees attribute. // (3) The orientation of the tray is vertical. if (!this.model.get('areSubtreesLoaded') && $activeTab.data('drupal-subtrees') !== undefined && orientation === 'vertical') { var subtreesHash = drupalSettings.toolbar.subtreesHash; var endpoint = Drupal.url('toolbar/subtrees/' + subtreesHash); var cachedSubtreesHash = localStorage.getItem('Drupal.toolbar.subtreesHash'); var cachedSubtrees = JSON.parse(localStorage.getItem('Drupal.toolbar.subtrees')); var isVertical = this.model.get('orientation') === 'vertical'; // If we have the subtrees in localStorage and the subtree hash has not // changed, then use the cached data. if (isVertical && subtreesHash === cachedSubtreesHash && cachedSubtrees) { Drupal.toolbar.setSubtrees.resolve(cachedSubtrees); } // Only make the call to get the subtrees if the orientation of the // toolbar is vertical. else if (isVertical) { // Remove the cached menu information. localStorage.removeItem('Drupal.toolbar.subtreesHash'); localStorage.removeItem('Drupal.toolbar.subtrees'); // The response from the server will call the resolve method of the // Drupal.toolbar.setSubtrees Promise. $.ajax(endpoint); // Cache the hash for the subtrees locally. localStorage.setItem('Drupal.toolbar.subtreesHash', subtreesHash); } } } }), /** * Backbone Model for collapsible menus. */ MenuModel: Backbone.Model.extend({ defaults: { subtrees: {} } }), /** * Backbone View for collapsible menus. */ MenuVisualView: Backbone.View.extend({ /** * {@inheritdoc} */ initialize: function () { this.model.on('change:subtrees', this.render, this); }, /** * {@inheritdoc} */ render: function () { var subtrees = this.model.get('subtrees'); // Add subtrees. for (var id in subtrees) { if (subtrees.hasOwnProperty(id)) { this.$el .find('#toolbar-link-' + id) .once('toolbar-subtrees') .after(subtrees[id]); } } // Render the main menu as a nested, collapsible accordion. if ('drupalToolbarMenu' in $.fn) { this.$el .children('.menu') .drupalToolbarMenu(); } } }), /** * Adjusts the body element with the toolbar position and dimension changes. */ BodyVisualView: Backbone.View.extend({ /** * {@inheritdoc} */ initialize: function () { this.model.on('change:orientation change:offsets change:activeTray change:isOriented change:isFixed change:isViewportOverflowConstrained', this.render, this); }, /** * {@inheritdoc} */ render: function () { var $body = $('body'); var orientation = this.model.get('orientation'); var isOriented = this.model.get('isOriented'); var isViewportOverflowConstrained = this.model.get('isViewportOverflowConstrained'); $body // We are using JavaScript to control media-query handling for two // reasons: (1) Using JavaScript let's us leverage the breakpoint // configurations and (2) the CSS is really complex if we try to hide // some styling from browsers that don't understand CSS media queries. // If we drive the CSS from classes added through JavaScript, // then the CSS becomes simpler and more robust. .toggleClass('toolbar-vertical', (orientation === 'vertical')) .toggleClass('toolbar-horizontal', (isOriented && orientation === 'horizontal')) // When the toolbar is fixed, it will not scroll with page scrolling. .toggleClass('toolbar-fixed', (isViewportOverflowConstrained || this.model.get('isFixed'))) // Toggle the toolbar-tray-open class on the body element. The class is // applied when a toolbar tray is active. Padding might be applied to // the body element to prevent the tray from overlapping content. .toggleClass('toolbar-tray-open', !!this.model.get('activeTray')) // Apply padding to the top of the body to offset the placement of the // toolbar bar element. .css('padding-top', this.model.get('offsets').top); } }) }; /** * A toggle is an interactive element often bound to a click handler. * * @return {String} * A string representing a DOM fragment. */ Drupal.theme.toolbarOrientationToggle = function () { return '
'; }; }(jQuery, Drupal, drupalSettings, Backbone));