From 2df8145a6011ce6d3ee1530ab659cb6b75b5e6f0 Mon Sep 17 00:00:00 2001
From: Mark Carver <mark.carver@me.com>
Date: Thu, 18 Jul 2013 09:31:36 -0500
Subject: Issue #1860434 by Mark Carver, jessebeach, Wim Leers, droplet, oresh:
 Refactor the Toolbar JavaScript to use Backbone; fix several poorly
 functioning behaviors

---
 core/modules/contextual/contextual.module          |   2 +-
 core/modules/toolbar/config/toolbar.settings.yml   |   1 +
 core/modules/toolbar/css/toolbar.module.css        |  14 +-
 core/modules/toolbar/js/toolbar.js                 | 912 +++++++++++++--------
 core/modules/toolbar/js/toolbar.menu.js            |  18 +-
 .../Drupal/toolbar/Routing/ToolbarController.php   |   2 +-
 .../tests/modules/toolbar_test/toolbar_test.module |   2 +-
 core/modules/toolbar/toolbar.module                |  48 +-
 8 files changed, 594 insertions(+), 405 deletions(-)

diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module
index 03cd17e..10d57b1 100644
--- a/core/modules/contextual/contextual.module
+++ b/core/modules/contextual/contextual.module
@@ -28,7 +28,6 @@ function contextual_toolbar() {
   }

   $tab['contextual'] = array(
-    '#type' => 'toolbar_item',
     'tab' => array(
       '#type' => 'html_tag',
       '#tag' => 'button',
@@ -39,6 +38,7 @@ function contextual_toolbar() {
         'aria-pressed' => 'false',
       ),
     ),
+    '#theme_wrappers' => array('toolbar_tab_wrapper'),
     '#wrapper_attributes' => array(
       'class' => array('hidden', 'contextual-toolbar-tab'),
     ),
diff --git a/core/modules/toolbar/config/toolbar.settings.yml b/core/modules/toolbar/config/toolbar.settings.yml
index 4df2f2a..1f2de55 100644
--- a/core/modules/toolbar/config/toolbar.settings.yml
+++ b/core/modules/toolbar/config/toolbar.settings.yml
@@ -1,3 +1,4 @@
 breakpoints:
   - module.toolbar.narrow
+  - module.toolbar.standard
   - module.toolbar.wide
diff --git a/core/modules/toolbar/css/toolbar.module.css b/core/modules/toolbar/css/toolbar.module.css
index d3e3f9e..279d57b 100644
--- a/core/modules/toolbar/css/toolbar.module.css
+++ b/core/modules/toolbar/css/toolbar.module.css
@@ -55,7 +55,7 @@
  * is active. The toolbar container, that contains the bar and the trays, is
  * position absolutely so that it scrolls with the page. Otherwise, on smaller
  * screens, the components of the admin toolbar are positioned statically. */
-body.toolbar-paneled .toolbar-oriented,
+body.toolbar-fixed .toolbar-oriented,
 .toolbar-oriented .toolbar-bar,
 .toolbar-oriented .toolbar-tray {
   left: 0;
@@ -69,13 +69,13 @@ body.toolbar-paneled .toolbar-oriented,
 }
 /* Position the admin toolbar fixed when the configured standard breakpoint is
  * active. */
-body.toolbar-paneled .toolbar-oriented .toolbar-bar {
+body.toolbar-fixed .toolbar-oriented .toolbar-bar {
   position: fixed;
 }
 /* When the configured narrow breakpoint is active, the toolbar is sized to wrap
  * around the trays in order to provide a context for scrolling tray content
  * that is taller than the viewport. */
-body.toolbar-tray-open.toolbar-paneled.toolbar-vertical .toolbar-oriented {
+body.toolbar-tray-open.toolbar-fixed.toolbar-vertical .toolbar-oriented {
   bottom: 0;
   width: 240px;
   width: 15rem;
@@ -175,13 +175,13 @@ body.toolbar-tray-open.toolbar-paneled.toolbar-vertical .toolbar-oriented {
 /* When the configured standard breakpoint is active and the tray is in a
  * horizontal position, the tray is fixed to the top of the viewport and does
  * not scroll with the page contents. */
-body.toolbar-paneled .toolbar .toolbar-tray-horizontal {
+body.toolbar-fixed .toolbar .toolbar-tray-horizontal {
   position: fixed;
 }
 /* When the configured standard breakpoint is active and the tray is in a
  * vertical position, the tray does not scroll with the page. The contents of
  * the tray scroll within the confines of the viewport. */
-body.toolbar-paneled .toolbar .toolbar-tray-vertical {
+body.toolbar-fixed .toolbar .toolbar-tray-vertical {
   height: 100%;
   overflow-x: hidden;
   overflow-y: auto;
@@ -200,11 +200,11 @@ body.toolbar-paneled .toolbar .toolbar-tray-vertical {
 }
 /* When the configured standard breakpoint is active, the tray appears to push
  * the page content away from the edge of the viewport. */
-body.toolbar-vertical.toolbar-paneled {
+body.toolbar-tray-open.toolbar-vertical.toolbar-fixed {
   margin-left: 240px; /* LTR */
   margin-left: 15rem; /* LTR */
 }
-[dir="rtl"] body.toolbar-vertical.toolbar-paneled {
+[dir="rtl"] body.toolbar-tray-open.toolbar-vertical.toolbar-fixed {
   margin-left: auto;
   margin-left: auto;
   margin-right: 240px;
diff --git a/core/modules/toolbar/js/toolbar.js b/core/modules/toolbar/js/toolbar.js
index dea38b5..56a9fde 100644
--- a/core/modules/toolbar/js/toolbar.js
+++ b/core/modules/toolbar/js/toolbar.js
@@ -3,41 +3,27 @@
  *
  * Defines the behavior of the Drupal administration toolbar.
  */
-(function ($, Drupal, drupalSettings) {
+(function ($, Drupal, drupalSettings, Backbone) {

 "use strict";

-Drupal.toolbar = Drupal.toolbar || {};
+// Merge run-time settings with the defaults.
+var options = $.extend({
+  breakpoints: {
+    'module.toolbar.narrow': '',
+    'module.toolbar.standard': '',
+    'module.toolbar.wide': ''
+  },
+  strings: {
+    opened: Drupal.t('opened'),
+    closed: Drupal.t('closed'),
+    horizontal: Drupal.t('Horizontal orientation'),
+    vertical: Drupal.t('Vertical orientation')
+  }
+}, drupalSettings.toolbar);

 /**
- * Store the state of the active tab so it will remain active across page loads.
- */
-var activeTab = JSON.parse(localStorage.getItem('Drupal.toolbar.activeTab'));
-
-/**
- * Store the state of the trays to maintain them across page loads.
- */
-var locked = JSON.parse(localStorage.getItem('Drupal.toolbar.trayVerticalLocked')) || false;
-var orientation = (locked) ? 'vertical' : 'horizontal';
-
-/**
- * Holds the jQuery objects of the toolbar DOM element, the trays and messages.
- */
-var $toolbar;
-var $trays;
-var $messages;
-
-/**
- * Holds the mediaQueryList object.
- */
-var mql = {
-  narrow: null,
-  standard: null,
-  wide: null
-};
-
-/**
- * Register tabs with the 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.
@@ -45,331 +31,551 @@ var mql = {
  * Modules register tabs with hook_toolbar().
  */
 Drupal.behaviors.toolbar = {
-  attach: function(context) {
-    // Verify that the sser agent understands media queries. Complex admin
+
+  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;
-    };
-    var options = $.extend(this.options, drupalSettings.toolbar);
-    var $toolbarOnce = $(context).find('#toolbar-administration').once('toolbar');
-    if ($toolbarOnce.length) {
-      // Assign the $toolbar variable in the closure.
-      $toolbar = $toolbarOnce;
-      // Add subtrees.
-      // @todo Optimize this to delay adding each subtree to the DOM until it is
-      //   needed; however, take into account screen readers for determining
-      //   when the DOM elements are needed.
-      var subtrees = Drupal.toolbar.subtrees;
-      if (subtrees) {
-        for (var id in subtrees) {
-          if (subtrees.hasOwnProperty(id)) {
-            $('#toolbar-link-' + id).after(subtrees[id]);
-          }
+    }
+    // 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);
+      });
+
+      // 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);
         }
       }
-      // Append a messages element for appending interaction updates for screen
-      // readers.
-      $messages = $(Drupal.theme('toolbarMessageBox')).appendTo($toolbar);
-      // Store the trays in a scoped variable.
-      $trays = $toolbar.find('.toolbar-tray');
-      $trays
-        // Add the tray orientation toggles.
-        .find('.toolbar-lining')
+
+      $(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);
+        });
+
+       // Call displace to get the initial placement of offset elements.
+      Drupal.displace();
+    });
+  }
+};
+
+/**
+ * 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,
+      // 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 state = (tray === null) ? this.strings.closed : this.strings.opened;
+      Drupal.announce(Drupal.t('"@tray" tray @state.', {
+        '@tray': relevantTray.querySelector('.toolbar-tray-name').textContent,
+        '@state': state
+      }));
+    }
+  }),
+
+  /**
+   * 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'));
-      // Store media queries and attach a handler.
-      mql.narrow = window.matchMedia(options.breakpoints['module.toolbar.narrow']);
-      mql.narrow.addListener(Drupal.toolbar.narrowMediaQueryChangeHandler);
-      mql.standard = window.matchMedia(options.breakpoints['module.toolbar.standard']);
-      mql.standard.addListener(Drupal.toolbar.standardMediaQueryChangeHandler);
-      mql.wide = window.matchMedia(options.breakpoints['module.toolbar.wide']);
-      mql.wide.addListener(Drupal.toolbar.wideMediaQueryChangeHandler);
-      // Fire each MediaQuery change handler so they process once.
-      Drupal.toolbar.narrowMediaQueryChangeHandler.call(this, mql.narrow);
-      Drupal.toolbar.standardMediaQueryChangeHandler.call(this, mql.standard);
-      Drupal.toolbar.wideMediaQueryChangeHandler.call(this, mql.wide);
-      // Set the orientation of the tray.
-      // If the tray is set to vertical in localStorage, persist the vertical
-      // presentation. If the tray is not locked to vertical, let the media
-      // query application decide the orientation.
-      changeOrientation((locked) ? 'vertical' : ((mql.wide.matches) ? 'horizontal' : 'vertical'), locked);
+
+      // Trigger an activeTab change so that listening scripts can respond on
+      // page load. This will call render.
+      this.model.trigger('change:activeTab');
+
+      // Invoke Drupal.displace() to get the current viewport offset values.
+      Drupal.displace();
+    },
+
+    /**
+     * {@inheritdoc}
+     */
+    render: function (model) {
+      this.updateTabs();
+      this.updateTrayOrientation();
+      this.updateBarAttributes();
+      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'));
+      var $tray = $();
+      // 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.
+        $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', '');
+      // Trigger a recalculation of viewport displacing elements.
+      Drupal.displace();
+    },
+
+    /**
+     * 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());
+      }
+    }
+  }),
+
+  /**
+   * 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.
-      $toolbar.find('.toolbar-menu-administration > .menu').toolbarMenu();
-      // Attach behaviors to the document.
-      $(document)
-        .on('drupalViewportOffsetChange.toolbar', Drupal.toolbar.adjustPlacement);
-      // Attach behaviors to the toolbar.
-      $toolbar
-        .on('click.toolbar', '.toolbar-bar a', Drupal.toolbar.toggleTray)
-        .on('click.toolbar', '.toolbar-toggle-orientation button', Drupal.toolbar.orientationChangeHandler);
-      // Restore the open tab. Only open the tab on wide screens.
-      if (activeTab && window.matchMedia(options.breakpoints['module.toolbar.standard']).matches) {
-        $toolbar.find('[data-toolbar-tray="' + activeTab + '"]').trigger('click.toolbar');
+      if ('drupalToolbarMenu' in $.fn) {
+        this.$el
+          .children('.menu')
+          .drupalToolbarMenu();
       }
-      else {
-        // Update the page and toolbar dimension indicators.
-        updatePeripherals();
-      }
-      // Call displace to get the initial placement of offset elements.
-      Drupal.displace();
-    }
-  },
-  // Default options.
-  options: {
-    breakpoints: {
-      'module.toolbar.narrow': '',
-      'module.toolbar.standard': '',
-      'module.toolbar.wide': ''
-    }
-  }
-};
-
-/**
- * Set subtrees.
- *
- * JSONP callback.
- * @see toolbar_subtrees_jsonp().
- */
-Drupal.toolbar.setSubtrees = function(subtrees) {
-  Drupal.toolbar.subtrees = subtrees;
-};
-
-/**
- * Toggle a toolbar tab and the associated tray.
- */
-Drupal.toolbar.toggleTray = function (event) {
-  var strings = {
-    opened: Drupal.t('opened'),
-    closed: Drupal.t('closed')
-  };
-  var $tab = $(event.target);
-  var name = $tab.attr('data-toolbar-tray');
-  // Activate the selected tab and associated tray.
-  var $activateTray = $toolbar.find('[data-toolbar-tray="' + name + '"].toolbar-tray').toggleClass('active');
-  if ($activateTray.length) {
-    event.preventDefault();
-    event.stopPropagation();
-    $tab.toggleClass('active');
-    // Toggle aria-pressed.
-    var value = $tab.prop('aria-pressed');
-    $tab.prop('aria-pressed', (value === 'false') ? 'true' : 'false');
-    // Append a message that a tray has been opened.
-    setMessage(Drupal.t('@tray tray @state.', {
-      '@tray': name,
-      '@state': (value === 'true') ? strings.closed : strings.opened
-    }));
-    // Store the active tab name or remove the setting.
-    if ($tab.hasClass('active')) {
-      localStorage.setItem('Drupal.toolbar.activeTab', JSON.stringify(name));
     }
-    else {
-      localStorage.removeItem('Drupal.toolbar.activeTab');
+  }),
+
+  /**
+   * 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);
     }
-    // Disable non-selected tabs and trays.
-    $toolbar.find('.toolbar-bar .trigger')
-      .not($tab)
-      .removeClass('active')
-      // Set aria-pressed to false.
-      .prop('aria-pressed', false);
-    $toolbar.find('.toolbar-tray').not($activateTray).removeClass('active');
-  }
-  // Update the page and toolbar dimension indicators.
-  updatePeripherals();
-};
-
-/**
- * Repositions trays and sets body padding according to the height of the bar.
- *
- * @param {Event} event
- *   - jQuery Event object.
- *
- * @param {Object} offsets
- *   - Contains for keys -- top, right, bottom and left -- that indicate the
- *   viewport offset distances calculated by Drupal.displace().
- */
-Drupal.toolbar.adjustPlacement = function (event, offsets) {
-  if (!mql.narrow.matches) {
-    var $body = $('body');
-    var $trays = $toolbar.find('.toolbar-tray');
-    // Alter the padding on the top of the body element.
-    $body.css('padding-top', 0);
-    $trays.css('padding-top', 0);
-    // Remove any orientation classes. Make vertical the default for trays.
-    $body.removeClass('toolbar-vertical toolbar-horizontal');
-    $trays.removeClass('toolbar-tray-horizontal').addClass('toolbar-tray-vertical');
-  }
-  else {
-    // Alter the padding on the top of the body element.
-    $('body').css('padding-top', offsets.top);
-    // The navbar container is invisible. Its placement is used to determine the
-    // container for the trays.
-    $toolbar.find('.toolbar-tray').css('padding-top', $toolbar.find('.toolbar-bar').outerHeight());
-  }
-};
-
-/**
- * Sets the width of a vertical tray in a data attribute.
- *
- * If the width of the tray changed, Drupal.displace is called so that elements
- * can adjust to the placement of the tray.
- */
-Drupal.toolbar.setTrayWidth = function () {
-  var dir = document.documentElement.dir;
-  var edge = (dir === 'rtl') ? 'right' : 'left';
-  // Remove the side offset from the trays.
-  $toolbar.find('.toolbar-tray').removeAttr('data-offset-' + edge + ' data-offset-top');
-  // If the page is wider than the narrow media query, apply offset attributes.
-  if (mql.narrow.matches) {
-    // If an active vertical tray exists, mark it as an offset element.
-    $toolbar.find('.toolbar-tray.toolbar-tray-vertical.active').attr('data-offset-' + edge, '');
-    // If an active horizontal tray exists, mark it as an offset element.
-    $toolbar.find('.toolbar-tray.toolbar-tray-horizontal.active').attr('data-offset-top', '');
-  }
-  // Trigger a recalculation of viewport displacing elements.
-  Drupal.displace();
-};
-
-/**
- * Respond to configured narrow media query changes.
- */
-Drupal.toolbar.narrowMediaQueryChangeHandler = function (mql) {
-  var $bar = $toolbar.find('.toolbar-bar');
-  if (mql.matches) {
-    $bar.attr('data-offset-top', '');
-  }
-  else {
-    $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.
-  $toolbar.toggleClass('toolbar-oriented', mql.matches);
-  if (mql.matches) {
-    changeOrientation('vertical');
-  }
-  // Update the page and toolbar dimension indicators.
-  updatePeripherals();
+  })
 };

 /**
- * Respond to configured standard media query changes.
- */
-Drupal.toolbar.standardMediaQueryChangeHandler = function (mql) {
-  $('body').toggleClass('toolbar-paneled', mql.matches);
-  // Update the page and toolbar dimension indicators.
-  updatePeripherals();
-};
-
-/**
- * Respond to configured wide media query changes.
- */
-Drupal.toolbar.wideMediaQueryChangeHandler = function (mql) {
-  var orientation = (mql.matches) ? 'horizontal' : 'vertical';
-  changeOrientation(orientation);
-  // Update the page and toolbar dimension indicators.
-  updatePeripherals();
-};
-
-/**
- * Respond to the toggling of the tray orientation.
- */
-Drupal.toolbar.orientationChangeHandler = function (event) {
-  event.preventDefault();
-  event.stopPropagation();
-  var orientation = event.target.value;
-  changeOrientation(orientation, true);
-  // Update the page and toolbar dimension indicators.
-  updatePeripherals();
-};
-
-/**
- * Change the orientation of the tray between vertical and horizontal.
- *
- * @param {String} newOrientation
- *   Either 'vertical' or 'horizontal'. The orientation to change the tray to.
- *
- * @param {Boolean} isLock
- *   Whether the orientation of the tray should be locked if it is being toggled
- *   to vertical.
- */
-function changeOrientation (newOrientation, isLock) {
-  if (isLock) {
-    locked = (newOrientation === 'vertical');
-    if (locked) {
-      localStorage.setItem('Drupal.toolbar.trayVerticalLocked', JSON.stringify(locked));
-    }
-    else {
-      localStorage.removeItem('Drupal.toolbar.trayVerticalLocked');
-    }
-  }
-  if ((!locked && newOrientation === 'horizontal') || newOrientation === 'vertical') {
-    $trays
-      .removeClass('toolbar-tray-horizontal toolbar-tray-vertical')
-      .addClass('toolbar-tray-' + newOrientation);
-    orientation = newOrientation;
-    toggleOrientationToggle((newOrientation === 'vertical') ? 'horizontal' : 'vertical');
-  }
-}
-
-/**
- * Mark up the body tag to reflect the current state of the toolbar.
- */
-function setBodyState () {
-  var $activeTray = $trays.filter('.active');
-  $('body')
-    .toggleClass('toolbar-tray-open', !!$activeTray.length)
-    .toggleClass('toolbar-vertical', (!!$activeTray.length && orientation === 'vertical'))
-    .toggleClass('toolbar-horizontal', (!!$activeTray.length && orientation === 'horizontal'));
-}
-
-/**
- * Change the orientation toggle active state.
- */
-function toggleOrientationToggle (orientation) {
-  var strings = {
-    horizontal: Drupal.t('Horizontal orientation'),
-    vertical: Drupal.t('Vertical orientation')
-  };
-  var antiOrientation = (orientation === 'vertical') ? 'horizontal' : 'vertical';
-  var iconClass = 'toolbar-icon-toggle-' + orientation;
-  var iconAntiClass = 'toolbar-icon-toggle-' + antiOrientation;
-  // Append a message that the tray orientation has been changed.
-  setMessage(Drupal.t('Tray orientation changed to @orientation.', {
-    '@orientation': antiOrientation
-  }));
-  // Change the tray orientation.
-  $trays.find('.toolbar-toggle-orientation button')
-    .val(orientation)
-    .text(strings[orientation])
-    .removeClass(iconAntiClass)
-    .addClass(iconClass);
-}
-
-/**
- * Updates elements peripheral to the toolbar.
- *
- * When the dimensions and orientation of the toolbar change, elements on the
- * page must either be changed or informed of the changes.
- */
-function updatePeripherals () {
-  // Adjust the body to accommodate trays.
-  setBodyState();
-  // Adjust the tray width for vertical trays.
-  Drupal.toolbar.setTrayWidth();
-}
-
-/**
- * Places the message in the toolbar's ARIA live message area.
- *
- * The message will be read by speaking User Agents.
- *
- * @param {String} message
- *   A string to be inserted into the message area.
- */
-function setMessage (message) {
-  $messages.html(Drupal.theme('toolbarTrayMessage', message));
-}
-
-/**
  * A toggle is an interactive element often bound to a click handler.
  *
  * @return {String}
@@ -381,24 +587,4 @@ Drupal.theme.toolbarOrientationToggle = function () {
     '</div></div>';
 };

-/**
- * A region to post messages that a screen reading UA will announce.
- *
- * @return {String}
- *   A string representing a DOM fragment.
- */
-Drupal.theme.toolbarMessageBox = function () {
-  return '<div id="toolbar-messages" class="visually-hidden" role="region" aria-live="polite"></div>';
-};
-
-/**
- * Wrap a message string in a p tag.
- *
- * @return {String}
- *   A string representing a DOM fragment.
- */
-Drupal.theme.toolbarTrayMessage = function (message) {
-  return '<p>' + message + '</p>';
-};
-
-}(jQuery, Drupal, drupalSettings));
+}(jQuery, Drupal, drupalSettings, Backbone));
diff --git a/core/modules/toolbar/js/toolbar.menu.js b/core/modules/toolbar/js/toolbar.menu.js
index 4671fce..e440f23 100644
--- a/core/modules/toolbar/js/toolbar.menu.js
+++ b/core/modules/toolbar/js/toolbar.menu.js
@@ -2,19 +2,19 @@
  * Builds a nested accordion widget.
  *
  * Invoke on an HTML list element with the jQuery plugin pattern.
- * - For example, $('.menu').toolbarMenu();
+ * - For example, $('.menu').drupalToolbarMenu();
  */

-(function ($, Drupal) {
+(function ($, Drupal, drupalSettings) {

 "use strict";

 /**
  * Store the open menu tray.
  */
-var activeItem = drupalSettings.basePath + drupalSettings.currentPath;
+var activeItem = drupalSettings.basePath + Drupal.encodePath(drupalSettings.currentPath);

-  $.fn.toolbarMenu = function () {
+  $.fn.drupalToolbarMenu = function () {

     var ui = {
       'handleOpen': Drupal.t('Extend'),
@@ -46,7 +46,7 @@ var activeItem = drupalSettings.basePath + drupalSettings.currentPath;
      *   simply toggling its presence.
      */
     function toggleList ($item, switcher) {
-      var $toggle = $item.find('> .toolbar-box > .toolbar-handle');
+      var $toggle = $item.children('.toolbar-box').children('.toolbar-handle');
       switcher = (typeof switcher !== 'undefined') ? switcher : !$item.hasClass('open');
       // Toggle the item open state.
       $item.toggleClass('open', switcher);
@@ -80,7 +80,7 @@ var activeItem = drupalSettings.basePath + drupalSettings.currentPath;
         // Add a handle to each list item if it has a menu.
       $menu.find('li').each(function (index, element) {
           var $item = $(element);
-          if ($item.find('> ul.menu').length) {
+          if ($item.children('ul.menu').length) {
             var $box = $item.children('.toolbar-box');
             options.text = Drupal.t('@label', {'@label': $box.find('a').text()});
             $item.children('.toolbar-box')
@@ -102,8 +102,8 @@ var activeItem = drupalSettings.basePath + drupalSettings.currentPath;
      */
     function markListLevels ($lists, level) {
       level = (!level) ? 1 : level;
-      var $lis = $lists.find('> li').addClass('level-' + level);
-      $lists = $lis.find('> ul');
+      var $lis = $lists.children('li').addClass('level-' + level);
+      $lists = $lis.children('ul');
       if ($lists.length) {
         markListLevels($lists, level + 1);
       }
@@ -154,4 +154,4 @@ var activeItem = drupalSettings.basePath + drupalSettings.currentPath;
     return '<button class="' + options['class'] + '"><span class="action">' + options.action + '</span><span class="label">' + options.text + '</span></button>';
   };

-}(jQuery, Drupal));
+}(jQuery, Drupal, drupalSettings));
diff --git a/core/modules/toolbar/lib/Drupal/toolbar/Routing/ToolbarController.php b/core/modules/toolbar/lib/Drupal/toolbar/Routing/ToolbarController.php
index af1935f..7404555 100644
--- a/core/modules/toolbar/lib/Drupal/toolbar/Routing/ToolbarController.php
+++ b/core/modules/toolbar/lib/Drupal/toolbar/Routing/ToolbarController.php
@@ -23,7 +23,7 @@ public function subtreesJsonp() {
     _toolbar_initialize_page_cache();
     $subtrees = toolbar_get_rendered_subtrees();
     $response = new JsonResponse($subtrees);
-    $response->setCallback('Drupal.toolbar.setSubtrees');
+    $response->setCallback('Drupal.toolbar.setSubtrees.resolve');
     return $response;
   }

diff --git a/core/modules/toolbar/tests/modules/toolbar_test/toolbar_test.module b/core/modules/toolbar/tests/modules/toolbar_test/toolbar_test.module
index 3ccbf0a..bc2ce99 100644
--- a/core/modules/toolbar/tests/modules/toolbar_test/toolbar_test.module
+++ b/core/modules/toolbar/tests/modules/toolbar_test/toolbar_test.module
@@ -25,6 +25,7 @@ function toolbar_test_toolbar() {
       ),
     ),
     'tray' => array(
+      '#heading' => t('Test tray'),
       '#wrapper_attributes' => array(
         'id' => 'toolbar-tray-testing',
       ),
@@ -35,7 +36,6 @@ function toolbar_test_toolbar() {
           l(t('link 2'), '<front>', array('attributes' => array('title' => 'Test link 2 title'))),
           l(t('link 3'), '<front>', array('attributes' => array('title' => 'Test link 3 title'))),
         ),
-        '#prefix' => '<h2 class="visually-hidden">' . t('Test tray') . '</h2>',
         '#attributes' => array(
           'class' => array('menu'),
         ),
diff --git a/core/modules/toolbar/toolbar.module b/core/modules/toolbar/toolbar.module
index bae1b07..b0147b7 100644
--- a/core/modules/toolbar/toolbar.module
+++ b/core/modules/toolbar/toolbar.module
@@ -80,8 +80,6 @@ function toolbar_element_info() {
       // The id cannot be simply "toolbar" or it will clash with the simpletest
       // tests listing which produces a checkbox with attribute id="toolbar"
       'id' => 'toolbar-administration',
-      // The 'overlay-displace-top' class pushes the overlay down, so it appears
-      // below the toolbar.
       'class' => array('toolbar',),
       'role' => 'navigation',
     ),
@@ -251,22 +249,24 @@ function theme_toolbar(&$variables) {
  *   A renderable array.
  */
 function toolbar_pre_render_item($element) {
+  // Assign each item a unique ID.
+  $id = drupal_html_id('toolbar-item');
+
+  // Provide attributes for a toolbar item.
+  $attributes = array(
+    'id' => $id,
+  );

   // If tray content is present, markup the tray and its associated trigger.
   if (!empty($element['tray'])) {
-
-    // Trays are associated with their trigger by a unique ID.
-    $id = drupal_html_id('toolbar-tray');
-    $element['tab']['#tray_id'] = $id;
-
-    // Provide attributes for a tray trigger.
-    $attributes = array(
-      'id' => 'toolbar-tab-' . $id,
-      'data-toolbar-tray' => $id,
+    // Provide attributes necessary for trays.
+    $attributes += array(
+      'data-toolbar-tray' => $id . '-tray',
       'aria-owns' => $id,
       'role' => 'button',
       'aria-pressed' => 'false',
     );
+
     // Merge in module-provided attributes.
     $element['tab'] += array('#attributes' => array());
     $element['tab']['#attributes'] += $attributes;
@@ -274,9 +274,9 @@ function toolbar_pre_render_item($element) {

     // Provide attributes for the tray theme wrapper.
     $attributes = array(
-      'id' => $id,
-      'data-toolbar-tray' => $id,
-      'aria-owned-by' => 'toolbar-tab-' . $id,
+      'id' => $id . '-tray',
+      'data-toolbar-tray' => $id . '-tray',
+      'aria-owned-by' => $id,
     );
     // Merge in module-provided attributes.
     if (!isset($element['tray']['#wrapper_attributes'])) {
@@ -290,11 +290,8 @@ function toolbar_pre_render_item($element) {
     }
     // Add the standard theme_wrapper for trays.
     array_unshift($element['tray']['#theme_wrappers'], 'toolbar_tray_wrapper');
-    // If a #heading is provided for the tray, provided a #theme_wrapper
-    // function to append it.
-    if (!empty($element['tray']['#heading'])) {
-      array_unshift($element['tray']['#theme_wrappers'], 'toolbar_tray_heading_wrapper');
-    }
+    // Add the theme wrapper for the tray heading.
+    array_unshift($element['tray']['#theme_wrappers'], 'toolbar_tray_heading_wrapper');
   }

   return $element;
@@ -373,10 +370,15 @@ function theme_toolbar_tray_wrapper(&$variables) {
  *     the tray. Properties used: #children and #heading.
  */
 function theme_toolbar_tray_heading_wrapper(&$variables) {
-  if (!empty($variables['element']['#children'])) {
-    $element = $variables['element'];
-    return '<h3 class="visually-hidden">' . $element['#heading'] . '</h3>' . $element['#children'];
+  $element = $variables['element'];
+  $output = '';
+  if (!empty($element['#heading'])) {
+    $output .= '<h3 class="toolbar-tray-name visually-hidden">' . $element['#heading'] . '</h3>';
   }
+  if (!empty($element['#children'])) {
+    $output .= $element['#children'];
+  }
+  return $output;
 }

 /**
@@ -597,9 +599,9 @@ function toolbar_library_info() {
       array('system', 'jquery'),
       array('system', 'drupal'),
       array('system', 'drupalSettings'),
+      array('system', 'backbone'),
       array('system', 'matchmedia'),
       array('system', 'jquery.once'),
-      array('system', 'drupal.debounce'),
       array('system', 'drupal.displace'),
       array('toolbar', 'toolbar.menu'),
     ),
--
1.8.2

