Bug 1354141 - Introduce a new binding for Photon panels that allows for more granular control in behavior and to fork the styles entirely. r?Gijs draft
authorMike de Boer <mdeboer@mozilla.com>
Mon, 08 May 2017 16:55:51 -0400
changeset 574438 fbd66409d1ad3dde5cfa244d48857eb75d304d9b
parent 574414 602786220871f7d0454db9b1102ef97cb53c51ba
child 627596 b3351c58c29683aac6fcd7da9ba2527839785ed7
push id57710
push usermdeboer@mozilla.com
push dateMon, 08 May 2017 21:15:15 +0000
reviewersGijs
bugs1354141
milestone55.0a1
Bug 1354141 - Introduce a new binding for Photon panels that allows for more granular control in behavior and to fork the styles entirely. r?Gijs MozReview-Commit-ID: CDzn5r0YmYO
browser/base/content/browser.css
browser/components/customizableui/PanelMultiView.jsm
browser/components/customizableui/content/panelUI.css
browser/components/customizableui/content/panelUI.inc.xul
browser/components/customizableui/content/panelUI.js
browser/components/customizableui/content/panelUI.xml
browser/themes/shared/customizableui/panelUI.inc.css
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -90,30 +90,38 @@ toolbar[customizable="true"] {
   padding: 0;
   margin: 0;
 }
 
 panelmultiview {
   -moz-binding: url("chrome://browser/content/customizableui/panelUI.xml#panelmultiview");
 }
 
+photonpanelmultiview {
+  -moz-binding: url("chrome://browser/content/customizableui/panelUI.xml#photonpanelmultiview");
+}
+
 panelview {
   -moz-binding: url("chrome://browser/content/customizableui/panelUI.xml#panelview");
   -moz-box-orient: vertical;
 }
 
 .panel-mainview {
   transition: transform var(--panelui-subview-transition-duration);
 }
 
 panelview:not([mainview]):not([current]) {
   transition: visibility 0s linear var(--panelui-subview-transition-duration);
   visibility: collapse;
 }
 
+panelview:not([title]) > .panel-header {
+  display: none;
+}
+
 tabbrowser {
   -moz-binding: url("chrome://browser/content/tabbrowser.xml#tabbrowser");
 }
 
 .tabbrowser-tabs {
   -moz-binding: url("chrome://browser/content/tabbrowser.xml#tabbrowser-tabs");
 }
 
--- a/browser/components/customizableui/PanelMultiView.jsm
+++ b/browser/components/customizableui/PanelMultiView.jsm
@@ -1,16 +1,147 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["PanelMultiView"];
 
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+/**
+ * Simple implementation of the sliding window pattern; panels are added to a
+ * linked list, in-order, and the currently shown panel is remembered using a
+ * marker. The marker shifts as navigation between panels is continued, where
+ * the panel at index 0 is always the starting point:
+ *           ┌────┬────┬────┬────┐
+ *           │▓▓▓▓│    │    │    │ Start
+ *           └────┴────┴────┴────┘
+ *      ┌────┬────┬────┬────┐
+ *      │    │▓▓▓▓│    │    │      Forward
+ *      └────┴────┴────┴────┘
+ * ┌────┬────┬────┬────┐
+ * │    │    │▓▓▓▓│    │           Forward
+ * └────┴────┴────┴────┘
+ *      ┌────┬────┬────┬────┐
+ *      │    │▓▓▓▓│    │    │      Back
+ *      └────┴────┴────┴────┘
+ */
+class SlidingPanelViews extends Array {
+  constructor() {
+    super();
+    this._marker = 0;
+  }
+
+  /**
+   * Get the index that points to the currently selected view.
+   *
+   * @return {Number}
+   */
+  get current() {
+    return this._marker;
+  }
+
+  /**
+   * Setter for the current index, which changes the order of elements and
+   * updates the internal marker for the currently selected view.
+   * We're manipulating the array directly to have it reflect the order of
+   * navigation, instead of continuously growing the array with the next selected
+   * view to keep memory usage within reasonable proportions. With this method,
+   * the data structure grows no larger than the number of panels inside the
+   * panelMultiView.
+   *
+   * @param  {Number} index Index of the item to move to the current position.
+   * @return {Number} The new marker index.
+   */
+  set current(index) {
+    if (index == this._marker) {
+      // Never change a winning team.
+      return index;
+    }
+    if (index == -1 || index > (this.length - 1)) {
+      throw new Error(`SlidingPanelViews :: index ${index} out of bounds`);
+    }
+
+    let view = this.splice(index, 1)[0];
+    if (this._marker > index) {
+      // Correct the current marker if the view-to-select was removed somewhere
+      // before it.
+      --this._marker;
+    }
+    // Then add the view-to-select right after the currently selected view.
+    this.splice(++this._marker, 0, view);
+    return this._marker;
+  }
+
+  /**
+   * Getter for the currently selected view node.
+   *
+   * @return {panelview}
+   */
+  get currentView() {
+    return this[this._marker];
+  }
+
+  /**
+   * Setter for the currently selected view node.
+   *
+   * @param  {panelview} view
+   * @return {Number} Index of the currently selected view.
+   */
+  set currentView(view) {
+    if (!view)
+      return this.current;
+    // This will throw an error if the view could not be found.
+    return this.current = this.indexOf(view);
+  }
+
+  /**
+   * Getter for the previous view, which is always positioned one position after
+   * the current view.
+   *
+   * @return {panelview}
+   */
+  get previousView() {
+    return this[this._marker + 1];
+  }
+
+  /**
+   * Going back is an explicit action on the data structure, moving the marker
+   * one step back.
+   *
+   * @return {Array} A list of two items: the newly selected view and the previous one.
+   */
+  back() {
+    if (this._marker > 0)
+      --this._marker;
+    return [this.currentView, this.previousView];
+  }
+
+  /**
+   * Reset the data structure to its original construct, removing all references
+   * to view nodes.
+   */
+  clear() {
+    this._marker = 0;
+    this.splice(0, this.length);
+  }
+
+  /**
+   * Return a convenient string representation, useful for unit tests. The
+   * currently selected view is denoted by '<' and '>'.
+   *
+   * @return {String}
+   */
+  toJSON() {
+    return `[ ${this.map((view, idx) => idx == this._marker ? `<${view.id}>` : view.id).join(", ")} ]`;
+  }
+}
+
 /**
  * This is the implementation of the panelUI.xml XBL binding, moved to this
  * module, to make it easier to fork the logic for the newer photon structure.
  * Goals are:
  * 1. to make it easier to programmatically extend the list of panels,
  * 2. allow for navigation between panels multiple levels deep and
  * 3. maintain the pre-photon structure with as little effort possible.
  *
@@ -25,17 +156,17 @@ this.PanelMultiView = class {
     return this.node.ownerGlobal;
   }
 
   get _panel() {
     return this.node.parentNode;
   }
 
   get showingSubView() {
-    return this._viewStack.getAttribute("viewtype") == "subview";
+    return this.node.getAttribute("viewtype") == "subview";
   }
   get _mainViewId() {
     return this.node.getAttribute("mainViewId");
   }
   set _mainViewId(val) {
     this.node.setAttribute("mainViewId", val);
     return val;
   }
@@ -67,16 +198,44 @@ this.PanelMultiView = class {
     this.__transitioning = val;
     if (val) {
       this.node.setAttribute("transitioning", "true");
     } else {
       this.node.removeAttribute("transitioning");
     }
   }
 
+  get panelViews() {
+    // If there's a dedicated subViews container, we're not in the right binding
+    // to use SlidingPanelViews.
+    if (this._subViews)
+      return null;
+
+    if (this._panelViews)
+      return this._panelViews;
+
+    this._panelViews = new SlidingPanelViews();
+    this._panelViews.push(...this.node.getElementsByTagName("panelview"));
+    return this._panelViews;
+  }
+  get _dwu() {
+    return this.window.QueryInterface(Ci.nsIInterfaceRequestor)
+                      .getInterface(Ci.nsIDOMWindowUtils);
+  }
+  get _currentSubView() {
+    return this.panelViews ? this.panelViews.currentView : this.__currentSubView;
+  }
+  set _currentSubView(panel) {
+    if (this.panelViews)
+      this.panelViews.currentView = panel;
+    else
+      this.__currentSubView = panel;
+    return panel;
+  }
+
   constructor(xulNode) {
     this.node = xulNode;
 
     this._currentSubView = this._anchorElement = this._subViewObserver = null;
     this._mainViewHeight = 0;
     this.__transitioning = this._ignoreMutations = false;
 
     const {document, window} = this;
@@ -87,112 +246,150 @@ this.PanelMultiView = class {
       document.getAnonymousElementByAttribute(this.node, "anonid", "viewContainer");
     this._mainViewContainer =
       document.getAnonymousElementByAttribute(this.node, "anonid", "mainViewContainer");
     this._subViews =
       document.getAnonymousElementByAttribute(this.node, "anonid", "subViews");
     this._viewStack =
       document.getAnonymousElementByAttribute(this.node, "anonid", "viewStack");
 
-    this._clickCapturer.addEventListener("click", this);
     this._panel.addEventListener("popupshowing", this);
-    this._panel.addEventListener("popupshown", this);
     this._panel.addEventListener("popuphidden", this);
-    this._subViews.addEventListener("overflow", this);
-    this._mainViewContainer.addEventListener("overflow", this);
+    if (this.panelViews) {
+      let cs = window.getComputedStyle(document.documentElement);
+      // Set CSS-determined attributes now to prevent a layout flush when we do
+      // it when transitioning between panels.
+      this._dir = cs.direction;
+      this.setMainView(this.panelViews.currentView);
+      this.showMainView();
+    } else {
+      this._panel.addEventListener("popupshown", this);
+      this._clickCapturer.addEventListener("click", this);
+      this._subViews.addEventListener("overflow", this);
+      this._mainViewContainer.addEventListener("overflow", this);
+      this._subViews.addEventListener("overflow", this);
+      this._mainViewContainer.addEventListener("overflow", this);
 
-    // Get a MutationObserver ready to react to subview size changes. We
-    // only attach this MutationObserver when a subview is being displayed.
-    this._subViewObserver = new window.MutationObserver(this._syncContainerWithSubView.bind(this));
-    this._mainViewObserver = new window.MutationObserver(this._syncContainerWithMainView.bind(this));
+      // Get a MutationObserver ready to react to subview size changes. We
+      // only attach this MutationObserver when a subview is being displayed.
+      this._subViewObserver = new window.MutationObserver(this._syncContainerWithSubView.bind(this));
+      this._mainViewObserver = new window.MutationObserver(this._syncContainerWithMainView.bind(this));
 
-    this._mainViewContainer.setAttribute("panelid", this._panel.id);
+      this._mainViewContainer.setAttribute("panelid", this._panel.id);
 
-    if (this._mainView) {
-      this.setMainView(this._mainView);
+      if (this._mainView) {
+        this.setMainView(this._mainView);
+      }
     }
+
     this.node.setAttribute("viewtype", "main");
 
     // Proxy these public properties and methods, as used elsewhere by various
     // parts of the browser, to this instance.
     ["_mainView", "ignoreMutations", "showingSubView"].forEach(property => {
       Object.defineProperty(this.node, property, {
         enumerable: true,
         get: () => this[property],
         set: (val) => this[property] = val
       });
     });
-    ["setHeightToFit", "setMainView", "showMainView", "showSubView"].forEach(method => {
+    ["goBack", "setHeightToFit", "setMainView", "showMainView", "showSubView"].forEach(method => {
       Object.defineProperty(this.node, method, {
         enumerable: true,
         value: (...args) => this[method](...args)
       });
     });
   }
 
   destructor() {
     if (this._mainView) {
       this._mainView.removeAttribute("mainview");
     }
-    this._mainViewObserver.disconnect();
-    this._subViewObserver.disconnect();
+    if (this.panelViews) {
+      this.panelViews.clear();
+    } else {
+      this._mainViewObserver.disconnect();
+      this._subViewObserver.disconnect();
+      this._subViews.removeEventListener("overflow", this);
+      this._mainViewContainer.removeEventListener("overflow", this);
+      this._clickCapturer.removeEventListener("click", this);
+    }
     this._panel.removeEventListener("popupshowing", this);
     this._panel.removeEventListener("popupshown", this);
     this._panel.removeEventListener("popuphidden", this);
-    this._subViews.removeEventListener("overflow", this);
-    this._mainViewContainer.removeEventListener("overflow", this);
-    this._clickCapturer.removeEventListener("click", this);
+    this.node = this._clickCapturer = this._viewContainer = this._mainViewContainer =
+      this._subViews = this._viewStack = null;
+  }
 
-    this.node = this.__clickCapturer = this.__viewContainer = this.__mainViewContainer =
-      this.__subViews = this.__viewStack = null;
+  goBack(target) {
+    let [current, previous] = this.panelViews.back();
+    return this.showSubView(current, target, previous);
   }
 
   setMainView(aNewMainView) {
-    if (this._mainView) {
-      this._mainViewObserver.disconnect();
-      this._subViews.appendChild(this._mainView);
-      this._mainView.removeAttribute("mainview");
+    if (this.panelViews) {
+      // If the new main view is not yet in the zeroth position, make sure it's
+      // inserted there.
+      if (aNewMainView.parentNode != this._viewStack && this._viewStack.firstChild != aNewMainView) {
+        this._viewStack.insertBefore(aNewMainView, this._viewStack.firstChild);
+      }
+    } else {
+      if (this._mainView) {
+        this._mainViewObserver.disconnect();
+        this._subViews.appendChild(this._mainView);
+        this._mainView.removeAttribute("mainview");
+      }
+      this._mainViewId = aNewMainView.id;
+      aNewMainView.setAttribute("mainview", "true");
+      this._mainViewContainer.appendChild(aNewMainView);
     }
-    this._mainViewId = aNewMainView.id;
-    aNewMainView.setAttribute("mainview", "true");
-    this._mainViewContainer.appendChild(aNewMainView);
   }
 
   showMainView() {
-    if (this.showingSubView) {
-      let viewNode = this._currentSubView;
-      let evt = new this.window.CustomEvent("ViewHiding", { bubbles: true, cancelable: true });
-      viewNode.dispatchEvent(evt);
-
-      viewNode.removeAttribute("current");
-      this._currentSubView = null;
+    if (this.panelViews) {
+      this.showSubView(this._mainViewId);
+    } else {
+      if (this.showingSubView) {
+        let viewNode = this._currentSubView;
+        let evt = new this.window.CustomEvent("ViewHiding", { bubbles: true, cancelable: true });
+        viewNode.dispatchEvent(evt);
 
-      this._subViewObserver.disconnect();
+        viewNode.removeAttribute("current");
+        this._currentSubView = null;
 
-      this._setViewContainerHeight(this._mainViewHeight);
+        this._subViewObserver.disconnect();
 
-      this.node.setAttribute("viewtype", "main");
+        this._setViewContainerHeight(this._mainViewHeight);
+
+        this.node.setAttribute("viewtype", "main");
+      }
+
+      this._shiftMainView();
     }
-
-    this._shiftMainView();
   }
 
-  showSubView(aViewId, aAnchor) {
+  showSubView(aViewId, aAnchor, aPreviousView) {
     const {document, window} = this;
-    window.Task.spawn(function*() {
-      let viewNode = this.node.querySelector("#" + aViewId);
+    return window.Task.spawn(function*() {
+      // Support passing in the node directly.
+      let viewNode = typeof aViewId == "string" ? this.node.querySelector("#" + aViewId) : aViewId;
       if (!viewNode) {
         viewNode = document.getElementById(aViewId);
         if (viewNode) {
-          this._subViews.appendChild(viewNode);
+          if (this.panelViews) {
+            this._viewStack.appendChild(viewNode);
+            this.panelViews.push(viewNode);
+          } else {
+            this._subViews.appendChild(viewNode);
+          }
         } else {
           throw new Error(`Subview ${aViewId} doesn't exist!`);
         }
       }
-      viewNode.setAttribute("current", true);
+
       // Emit the ViewShowing event so that the widget definition has a chance
       // to lazily populate the subview with things.
       let detail = {
         blockers: new Set(),
         addBlocker(aPromise) {
           this.blockers.add(aPromise);
         },
       };
@@ -201,52 +398,183 @@ this.PanelMultiView = class {
       viewNode.dispatchEvent(evt);
 
       let cancel = evt.defaultPrevented;
       if (detail.blockers.size) {
         try {
           let results = yield window.Promise.all(detail.blockers);
           cancel = cancel || results.some(val => val === false);
         } catch (e) {
-          Components.utils.reportError(e);
+          Cu.reportError(e);
           cancel = true;
         }
       }
 
       if (cancel) {
         return;
       }
 
+      let reverse = !!aPreviousView;
+      let previousViewNode = aPreviousView || this._currentSubView;
       this._currentSubView = viewNode;
+      let playTransition = (!!previousViewNode && previousViewNode != viewNode);
+
+      let dwu, previousRect;
+      if (playTransition) {
+        dwu = this._dwu;
+        previousRect = previousViewNode.__lastKnownBoundingRect =
+          dwu.getBoundsWithoutFlushing(previousViewNode);
+      }
+
+      viewNode.setAttribute("current", true);
 
       // Now we have to transition the panel. There are a few parts to this:
       //
       // 1) The main view content gets shifted so that the center of the anchor
       //    node is at the left-most edge of the panel.
       // 2) The subview deck slides in so that it takes up almost all of the
       //    panel.
       // 3) If the subview is taller then the main panel contents, then the panel
       //    must grow to meet that new height. Otherwise, it must shrink.
       //
       // All three of these actions make use of CSS transformations, so they
       // should all occur simultaneously.
       this.node.setAttribute("viewtype", "subview");
-      this._shiftMainView(aAnchor);
+
+      if (this.panelViews && playTransition) {
+        // Sliding the next subview in means that the previous panelview stays
+        // where it is and the active panelview slides in from the left in LTR
+        // mode, right in RTL mode.
+        let onTransitionEnd = () => {
+          evt = new window.CustomEvent("ViewHiding", { bubbles: true, cancelable: true });
+          previousViewNode.dispatchEvent(evt);
+          previousViewNode.removeAttribute("current");
+        };
+
+        // There's absolutely no need to show off our epic animation skillz when
+        // the panel's not even open.
+        if (this._panel.state != "open") {
+          onTransitionEnd();
+          return;
+        }
+
+        if (aAnchor)
+          aAnchor.setAttribute("open", true);
+        this._viewContainer.style.height = previousRect.height + "px";
+        this._viewContainer.style.width = previousRect.width + "px";
+
+        this._transitioning = true;
+        this._viewContainer.setAttribute("transition-reverse", reverse);
+        let nodeToAnimate = reverse ? previousViewNode : viewNode;
 
-      this._mainViewHeight = this._viewStack.clientHeight;
+        if (!reverse) {
+          // We set the margin here to make sure the view is positioned next
+          // to the view that is currently visible. The animation is taken
+          // care of by transitioning the `transform: translateX()` property
+          // instead.
+          // Once the transition finished, we clean both properties up.
+          nodeToAnimate.style.marginInlineStart = `${previousRect.width}px`;
+        }
+
+        // Set the transition style and listen for its end to clean up and
+        // make sure the box sizing becomes dynamic again.
+        // Somehow, putting these properties in PanelUI.css doesn't work for
+        // newly shown nodes in a XUL parent node.
+        nodeToAnimate.style.transition = "transform ease-" + (reverse ? "in" : "out") +
+          " var(--panelui-subview-transition-duration)";
+        nodeToAnimate.style.willChange = "transform";
 
-      let newHeight = this._heightOfSubview(viewNode, this._subViews);
-      this._setViewContainerHeight(newHeight);
+        // Wait until after the first paint to ensure setting 'current=true'
+        // has taken full effect; once both views are visible, we want to
+        // correctly measure rects using `dwu.getBoundsWithoutFlushing`.
+        window.addEventListener("MozAfterPaint", () => {
+          let viewRect = viewNode.__lastKnownBoundingRect;
+          if (!viewRect) {
+            viewRect = dwu.getBoundsWithoutFlushing(viewNode);
+            if (!reverse) {
+              // When showing two nodes at the same time (one partly out of view,
+              // but that doesn't seem to make a difference in this case) inside
+              // a XUL node container, the flexible box layout on the vertical
+              // axis gets confused. I.e. it lies.
+              // So what we need to resort to here is count the height of each
+              // individual child element of the view.
+              viewRect.height = [viewNode.header, ...viewNode.children].reduce((acc, node) => {
+                return acc + dwu.getBoundsWithoutFlushing(node).height;
+              }, 0);
+            }
+          }
 
-      this._subViewObserver.observe(viewNode, {
-        attributes: true,
-        characterData: true,
-        childList: true,
-        subtree: true
-      });
+          // Set the viewContainer dimensions to make sure only the current view
+          // is visible.
+          this._viewContainer.style.height = viewRect.height + "px";
+          this._viewContainer.style.width = viewRect.width + "px";
+
+          // The 'magic' part: build up the amount of pixels to move right or left.
+          let moveToLeft = (this._dir == "rtl" && !reverse) || (this._dir == "ltr" && reverse);
+          let movementX = reverse ? viewRect.width : previousRect.width;
+          let moveX = (moveToLeft ? "" : "-") + movementX;
+          nodeToAnimate.style.transform = "translateX(" + moveX + "px)";
+          // We're setting the width property to prevent flickering during the
+          // sliding animation with smaller views.
+          nodeToAnimate.style.width = viewRect.width + "px";
+
+          let listener;
+          let seen = 0;
+          this._viewContainer.addEventListener("transitionend", listener = ev => {
+            if (ev.target == this._viewContainer && ev.propertyName == "height") {
+              // Myeah, panel layout auto-resizing is a funky thing. We'll wait
+              // another few milliseconds to remove the width and height 'fixtures',
+              // to be sure we don't flicker annoyingly.
+              // NB: HACK!
+              window.setTimeout(() => {
+                this._viewContainer.style.removeProperty("height");
+                this._viewContainer.style.removeProperty("width");
+              }, 500);
+              ++seen;
+            } else if (ev.target == nodeToAnimate && ev.propertyName == "transform") {
+              onTransitionEnd();
+              this._transitioning = false;
+
+              // Take another breather, just like before, to wait for the 'current'
+              // attribute removal to take effect. This prevents a flicker.
+              // The cleanup we do doesn't affect the display anymore, so we're not
+              // too fussed about the timing here.
+              window.addEventListener("MozAfterPaint", () => {
+                nodeToAnimate.style.removeProperty("transition");
+                nodeToAnimate.style.removeProperty("transform");
+                nodeToAnimate.style.removeProperty("width");
+
+                if (!reverse)
+                  viewNode.style.removeProperty("margin-inline-start");
+                if (aAnchor)
+                  aAnchor.removeAttribute("open");
+
+                this._viewContainer.removeAttribute("transition-reverse");
+              }, { once: true });
+              ++seen;
+            }
+            if (seen == 2)
+              this._viewContainer.removeEventListener("transitionend", listener);
+          });
+        }, { once: true });
+      } else if (!this.panelViews) {
+        this._shiftMainView(aAnchor);
+
+        this._mainViewHeight = this._viewStack.clientHeight;
+
+        let newHeight = this._heightOfSubview(viewNode, this._subViews);
+        this._setViewContainerHeight(newHeight);
+
+        this._subViewObserver.observe(viewNode, {
+          attributes: true,
+          characterData: true,
+          childList: true,
+          subtree: true
+        });
+      }
     }.bind(this));
   }
 
   _setViewContainerHeight(aHeight) {
     let container = this._viewContainer;
     this._transitioning = true;
 
     let onTransitionEnd = () => {
@@ -301,51 +629,53 @@ this.PanelMultiView = class {
     }
     switch (aEvent.type) {
       case "click":
         if (aEvent.originalTarget == this._clickCapturer) {
           this.showMainView();
         }
         break;
       case "overflow":
-        if (aEvent.target.localName == "vbox") {
+        if (!this.panelViews && aEvent.target.localName == "vbox") {
           // Resize the right view on the next tick.
           if (this.showingSubView) {
             this.window.setTimeout(this._syncContainerWithSubView.bind(this), 0);
           } else if (!this.transitioning) {
             this.window.setTimeout(this._syncContainerWithMainView.bind(this), 0);
           }
         }
         break;
       case "popupshowing":
         this.node.setAttribute("panelopen", "true");
         // Bug 941196 - The panel can get taller when opening a subview. Disabling
         // autoPositioning means that the panel won't jump around if an opened
         // subview causes the panel to exceed the dimensions of the screen in the
         // direction that the panel originally opened in. This property resets
         // every time the popup closes, which is why we have to set it each time.
         this._panel.autoPosition = false;
-        this._syncContainerWithMainView();
 
-        this._mainViewObserver.observe(this._mainView, {
-          attributes: true,
-          characterData: true,
-          childList: true,
-          subtree: true
-        });
-
+        if (!this.panelViews) {
+          this._syncContainerWithMainView();
+          this._mainViewObserver.observe(this._mainView, {
+            attributes: true,
+            characterData: true,
+            childList: true,
+            subtree: true
+          });
+        }
         break;
       case "popupshown":
         this._setMaxHeight();
         break;
       case "popuphidden":
         this.node.removeAttribute("panelopen");
         this._mainView.style.removeProperty("height");
         this.showMainView();
-        this._mainViewObserver.disconnect();
+        if (!this.panelViews)
+          this._mainViewObserver.disconnect();
         break;
     }
   }
 
   _shouldSetPosition() {
     return this.node.getAttribute("nosubviews") == "true";
   }
 
--- a/browser/components/customizableui/content/panelUI.css
+++ b/browser/components/customizableui/content/panelUI.css
@@ -21,11 +21,30 @@
   transform: translateX(0);
   overflow-y: auto;
 }
 
 .panel-subviews[panelopen] {
   transition: transform var(--panelui-subview-transition-duration);
 }
 
-.panel-viewcontainer[panelopen]:-moz-any(:not([viewtype="main"]),[transitioning="true"]) {
-  transition: height var(--panelui-subview-transition-duration);
+.panel-viewcontainer[panelopen]:-moz-any(:not([viewtype="main"]),[transitioning]) {
+  transition-property: height;
+  transition-timing-function: ease-in;
+  transition-duration: var(--panelui-subview-transition-duration);
+  will-change: height;
+}
+
+.panel-viewcontainer[panelopen]:-moz-any(:not([viewtype="main"]),[transitioning])[transition-reverse] {
+  transition-timing-function: ease-out;
 }
+
+/* START photon adjustments */
+
+photonpanelmultiview > .panel-viewcontainer > .panel-viewstack {
+  overflow: visible;
+}
+
+photonpanelmultiview[transitioning] {
+  pointer-events: none;
+}
+
+/* END photon adjustments */
--- a/browser/components/customizableui/content/panelUI.inc.xul
+++ b/browser/components/customizableui/content/panelUI.inc.xul
@@ -492,17 +492,17 @@
 <panel id="appMenu-popup"
        class="cui-widget-panel"
        role="group"
        type="arrow"
        hidden="true"
        flip="slide"
        position="bottomcenter topright"
        noautofocus="true">
-  <panelmultiview id="appMenu-multiView" mainViewId="appMenu-mainView">
+  <photonpanelmultiview id="appMenu-multiView" mainViewId="appMenu-mainView">
     <panelview id="appMenu-mainView" class="cui-widget-panelview PanelUI-subView">
       <vbox class="panel-subview-body">
         <vbox id="appMenu-addon-banners"/>
         <toolbarbutton class="panel-banner-item"
                        label-update-available="&updateAvailable.panelUI.label;"
                        label-update-manual="&updateManual.panelUI.label;"
                        label-update-restart="&updateRestart.panelUI.label;"
                        oncommand="PanelUI._onBannerItemSelected(event)"
@@ -541,12 +541,44 @@
                        label="&printButton.label;"
                        key="printKb"
 #ifdef XP_MACOSX
                        command="cmd_print"
 #else
                        command="cmd_printPreview"
 #endif
                        />
+        <toolbarbutton id="appMenu-subiew-button"
+                       class="subviewbutton subviewbutton-nav"
+                       label="Go to no. 2"
+                       onclick="document.getBindingParent(this).showSubView('appMenu-second', this)"/>
       </vbox>
     </panelview>
-  </panelmultiview>
+    <panelview id="appMenu-second" title="Second View" class="cui-widget-panelview PanelUI-subView">
+      <vbox class="panel-subview-body">
+        <toolbarbutton id="appMenu-sampleButton1"
+                       class="subviewbutton subviewbutton-nav"
+                       label="Sample Button 1"
+                       onclick="PanelUI.showSubView('appMenu-third', this)"/>
+        <toolbarbutton id="appMenu-sampleButton2"
+                       class="subviewbutton"
+                       label="Le Sample Button 2 With Le Very Long Label"/>
+        <toolbarseparator/>
+        <toolbarbutton id="appMenu-sampleButton3"
+                       class="subviewbutton"
+                       label="Sample Button 3"/>
+        <toolbarbutton id="appMenu-sampleFooter"
+                       class="panel-subview-footer subviewbutton"
+                       label="Le Footer"/>
+      </vbox>
+    </panelview>
+    <panelview id="appMenu-third" title="Third View" class="cui-widget-panelview PanelUI-subView">
+      <vbox class="panel-subview-body">
+        <toolbarbutton id="appMenu-sampleButton4"
+                       class="subviewbutton"
+                       label="Sample Button 4"/>
+        <toolbarbutton id="appMenu-sampleFooter2"
+                       class="panel-subview-footer subviewbutton"
+                       label="Le Footer, Part Deux"/>
+      </vbox>
+    </panelview>
+  </photonpanelmultiview>
 </panel>
--- a/browser/components/customizableui/content/panelUI.js
+++ b/browser/components/customizableui/content/panelUI.js
@@ -446,17 +446,17 @@ const PanelUI = {
       return;
     }
 
     if (!aAnchor) {
       Cu.reportError("Expected an anchor when opening subview with id: " + aViewId);
       return;
     }
 
-    let container = aAnchor.closest("panelmultiview");
+    let container = aAnchor.closest("panelmultiview,photonpanelmultiview");
     if (container) {
       container.showSubView(aViewId, aAnchor);
     } else if (!aAnchor.open) {
       aAnchor.open = true;
 
       let tempPanel = document.createElement("panel");
       tempPanel.setAttribute("type", "arrow");
       tempPanel.setAttribute("id", "customizationui-widget-panel");
@@ -750,17 +750,18 @@ const PanelUI = {
   _refreshNotificationPanel(notification) {
     this._clearNotificationPanel();
 
     let popupnotificationID = this._getPopupId(notification);
     let popupnotification = document.getElementById(popupnotificationID);
 
     popupnotification.setAttribute("id", popupnotificationID);
     popupnotification.setAttribute("buttoncommand", "PanelUI._onNotificationButtonEvent(event, 'buttoncommand');");
-    popupnotification.setAttribute("secondarybuttoncommand", "PanelUI._onNotificationButtonEvent(event, 'secondarybuttoncommand');");
+    popupnotification.setAttribute("secondarybuttoncommand",
+      "PanelUI._onNotificationButtonEvent(event, 'secondarybuttoncommand');");
 
     popupnotification.notification = notification;
     popupnotification.hidden = false;
   },
 
   _showBadge(notification) {
     let badgeStatus = this._getBadgeStatus(notification);
     this.menuButton.setAttribute("badge-status", badgeStatus);
--- a/browser/components/customizableui/content/panelUI.xml
+++ b/browser/components/customizableui/content/panelUI.xml
@@ -1,25 +1,30 @@
 <?xml version="1.0"?>
 <!-- This Source Code Form is subject to the terms of the Mozilla Public
    - License, v. 2.0. If a copy of the MPL was not distributed with this
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 
+<!DOCTYPE bindings [
+  <!ENTITY % browserDTD SYSTEM "chrome://browser/locale/browser.dtd">
+  %browserDTD;
+]>
+
 <bindings id="browserPanelUIBindings"
           xmlns="http://www.mozilla.org/xbl"
           xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
           xmlns:xbl="http://www.mozilla.org/xbl">
 
   <binding id="panelmultiview">
     <resources>
       <stylesheet src="chrome://browser/content/customizableui/panelUI.css"/>
     </resources>
     <content>
       <xul:box anonid="viewContainer" class="panel-viewcontainer" xbl:inherits="panelopen,viewtype,transitioning">
-        <xul:stack anonid="viewStack" xbl:inherits="viewtype,transitioning" viewtype="main" class="panel-viewstack">
+        <xul:stack anonid="viewStack" xbl:inherits="viewtype,transitioning" class="panel-viewstack">
           <xul:vbox anonid="mainViewContainer" class="panel-mainview" xbl:inherits="viewtype"/>
 
           <!-- Used to capture click events over the PanelUI-mainView if we're in
                subview mode. That way, any click on the PanelUI-mainView causes us
                to revert to the mainView mode, whereupon PanelUI-click-capture then
                allows click events to go through it. -->
           <xul:vbox anonid="clickCapturer" class="panel-clickcapturer"/>
 
@@ -41,21 +46,43 @@
        ]]></constructor>
  
        <destructor><![CDATA[
         this.instance.destructor();
        ]]></destructor>
      </implementation>
   </binding>
 
+  <binding id="photonpanelmultiview" extends="chrome://browser/content/customizableui/panelUI.xml#panelmultiview">
+    <content>
+      <xul:box anonid="viewContainer" class="panel-viewcontainer" xbl:inherits="panelopen,transitioning">
+        <xul:stack anonid="viewStack" xbl:inherits="transitioning" class="panel-viewstack">
+          <children includes="panelview"/>
+        </xul:stack>
+      </xul:box>
+    </content>
+  </binding>
+
   <binding id="panelview">
+    <content>
+      <xul:box class="panel-header" anonid="header">
+        <xul:toolbarbutton class="subviewbutton subviewbutton-iconic subviewbutton-back"
+                           tooltip="&backCmd.label;"
+                           onclick="document.getBindingParent(this).panelMultiView.goBack()"/>
+        <xul:label xbl:inherits="value=title"/>
+      </xul:box>
+      <children/>
+    </content>
     <implementation>
+      <property name="header"
+                readonly="true"
+                onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'header');"/>
       <property name="panelMultiView" readonly="true">
         <getter><![CDATA[
-          if (this.parentNode.localName != "panelmultiview") {
+          if (!this.parentNode.localName.endsWith("panelmultiview")) {
             return document.getBindingParent(this.parentNode);
           }
 
           return this.parentNode;
         ]]></getter>
       </property>
     </implementation>
   </binding>
--- a/browser/themes/shared/customizableui/panelUI.inc.css
+++ b/browser/themes/shared/customizableui/panelUI.inc.css
@@ -318,16 +318,30 @@ panelview:not([mainview]) .toolbarbutton
   text-align: start;
   display: -moz-box;
 }
 
 .cui-widget-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 4px 0;
 }
 
+/* START photonpanelview adjustments */
+
+photonpanelmultiview panelview {
+  background: var(--arrowpanel-background);
+}
+
+photonpanelmultiview .panel-subview-body {
+  /*XXXmikedeboer this flex is unnecessary, so I unset it for our case. It might
+                  break other panels, though, so I refrain from removing it above. */
+  -moz-box-flex: unset;
+}
+
+/* END photonpanelview adjustments */
+
 .cui-widget-panel.cui-widget-panelWithFooter > .panel-arrowcontainer > .panel-arrowcontent {
   padding-bottom: 0;
 }
 
 #PanelUI-contents {
   display: block;
   flex: 1 0 auto;
   margin-left: auto;