browser/components/customizableui/PanelMultiView.jsm
author Mike de Boer <mdeboer@mozilla.com>
Tue, 10 Oct 2017 12:42:43 +0200
changeset 432460 567e84287828cffa7f68dd45bcef816ee9525da5
parent 432377 da0c1e20a0cc7b310dfbcf967192ffc7b0b43812
child 436553 44d7a43c481ad4fae099feda776776b3185ab14b
permissions -rw-r--r--
Bug 1405942 - Make sure to always apply the descriptionHeightWorkaround in Photon panels on popupshown, because it might've missed elements that were hidden. r=Paolo, a=ritu MozReview-Commit-ID: 9xSqoZGJ3MF

/* 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;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
  "resource://gre/modules/AppConstants.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
  "resource://gre/modules/BrowserUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
  "resource:///modules/CustomizableUI.jsm");

const TRANSITION_PHASES = Object.freeze({
  START: 1,
  PREPARE: 2,
  TRANSITION: 3,
  END: 4
});

/**
 * 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);
  }
}

/**
 * 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.
 *
 * @type {PanelMultiView}
 */
this.PanelMultiView = class {
  get document() {
    return this.node.ownerDocument;
  }

  get window() {
    return this.node.ownerGlobal;
  }

  get _panel() {
    return this.node.parentNode;
  }

  get showingSubView() {
    return this.node.getAttribute("viewtype") == "subview";
  }
  get _mainViewId() {
    return this.node.getAttribute("mainViewId");
  }
  set _mainViewId(val) {
    this.node.setAttribute("mainViewId", val);
    return val;
  }
  get _mainView() {
    return this._mainViewId ? this.document.getElementById(this._mainViewId) : null;
  }

  get _transitioning() {
    return this.__transitioning;
  }
  set _transitioning(val) {
    this.__transitioning = val;
    if (val) {
      this.node.setAttribute("transitioning", "true");
    } else {
      this.node.removeAttribute("transitioning");
    }
  }

  /**
   * @return {Boolean} |true| when the 'ephemeral' attribute is set, which means
   *                   that this instance should be ready to be thrown away at
   *                   any time.
   */
  get _ephemeral() {
    return this.node.hasAttribute("ephemeral");
  }

  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() {
    if (this.__dwu)
      return this.__dwu;
    return this.__dwu = this.window.QueryInterface(Ci.nsIInterfaceRequestor)
                                   .getInterface(Ci.nsIDOMWindowUtils);
  }
  get _screenManager() {
    if (this.__screenManager)
      return this.__screenManager;
    return this.__screenManager = Cc["@mozilla.org/gfx/screenmanager;1"]
                                    .getService(Ci.nsIScreenManager);
  }
  /**
   * @return {panelview} the currently visible subview OR the subview that is
   *                     about to be shown whilst a 'ViewShowing' event is being
   *                     dispatched.
   */
  get current() {
    return this._viewShowing || this._currentSubView
  }
  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;
  }
  /**
   * @return {Promise} showSubView() returns a promise, which is kept here for
   *                   random access.
   */
  get currentShowPromise() {
    return this._currentShowPromise || Promise.resolve();
  }
  get _keyNavigationMap() {
    if (!this.__keyNavigationMap)
      this.__keyNavigationMap = new Map();
    return this.__keyNavigationMap;
  }
  get _multiLineElementsMap() {
    if (!this.__multiLineElementsMap)
      this.__multiLineElementsMap = new WeakMap();
    return this.__multiLineElementsMap;
  }

  constructor(xulNode, testMode = false) {
    this.node = xulNode;
    // If `testMode` is `true`, the consumer is only interested in accessing the
    // methods of this instance. (E.g. in unit tests.)
    if (testMode)
      return;

    this._currentSubView = this._anchorElement = this._subViewObserver = null;
    this._mainViewHeight = 0;
    this.__transitioning = this._ignoreMutations = false;

    const {document, window} = this;

    this._clickCapturer =
      document.getAnonymousElementByAttribute(this.node, "anonid", "clickCapturer");
    this._viewContainer =
      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._offscreenViewStack =
      document.getAnonymousElementByAttribute(this.node, "anonid", "offscreenViewStack");

    XPCOMUtils.defineLazyGetter(this, "_panelViewCache", () => {
      let viewCacheId = this.node.getAttribute("viewCacheId");
      return viewCacheId ? document.getElementById(viewCacheId) : null;
    });

    this._panel.addEventListener("popupshowing", this);
    this._panel.addEventListener("popuphidden", this);
    this._panel.addEventListener("popupshown", 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._clickCapturer.addEventListener("click", this);

      this._mainViewContainer.setAttribute("panelid", this._panel.id);

      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",
     "_panelViews"].forEach(property => {
      Object.defineProperty(this.node, property, {
        enumerable: true,
        get: () => this[property],
        set: (val) => this[property] = val
      });
    });
    ["goBack", "descriptionHeightWorkaround", "setMainView", "showMainView",
     "showSubView"].forEach(method => {
      Object.defineProperty(this.node, method, {
        enumerable: true,
        value: (...args) => this[method](...args)
      });
    });
    ["current", "currentShowPromise"].forEach(property => {
      Object.defineProperty(this.node, property, {
        enumerable: true,
        get: () => this[property]
      });
    });
  }

  destructor() {
    // Guard against re-entrancy.
    if (!this.node)
      return;

    if (this._ephemeral)
      this.hideAllViewsExcept(null);
    let mainView = this._mainView;
    if (mainView) {
      if (this._panelViewCache)
        this._panelViewCache.appendChild(mainView);
      mainView.removeAttribute("mainview");
    }
    if (this._subViews)
      this._moveOutKids(this._subViews);

    if (this.panelViews) {
      this._moveOutKids(this._viewStack);
      this.panelViews.clear();
    } else {
      this._clickCapturer.removeEventListener("click", this);
    }
    this._panel.removeEventListener("mousemove", this);
    this._panel.removeEventListener("popupshowing", this);
    this._panel.removeEventListener("popupshown", this);
    this._panel.removeEventListener("popuphidden", this);
    this.window.removeEventListener("keydown", this);
    this.node = this._clickCapturer = this._viewContainer = this._mainViewContainer =
      this._subViews = this._viewStack = this.__dwu = this._panelViewCache =
        this._transitionDetails = null;
  }

  /**
   * Remove any child subviews into the panelViewCache, to ensure
   * they remain usable even if this panelmultiview instance is removed
   * from the DOM.
   * @param viewNodeContainer the container from which to remove subviews
   */
  _moveOutKids(viewNodeContainer) {
    if (!this._panelViewCache)
      return;

    // Node.children and Node.childNodes is live to DOM changes like the
    // ones we're about to do, so iterate over a static copy:
    let subviews = Array.from(viewNodeContainer.childNodes);
    for (let subview of subviews) {
      // XBL lists the 'children' XBL element explicitly. :-(
      if (subview.nodeName != "children")
        this._panelViewCache.appendChild(subview);
    }
  }

  _placeSubView(viewNode) {
    if (this.panelViews) {
      this._viewStack.appendChild(viewNode);
      if (!this.panelViews.includes(viewNode))
        this.panelViews.push(viewNode);
    } else {
      this._subViews.appendChild(viewNode);
    }
  }

  goBack(target) {
    let [current, previous] = this.panelViews.back();
    return this.showSubView(current, target, previous);
  }

  /**
   * Checks whether it is possible to navigate backwards currently. Returns
   * false if this is the panelmultiview's mainview, true otherwise.
   *
   * @param  {panelview} view View to check, defaults to the currently active view.
   * @return {Boolean}
   */
  _canGoBack(view = this._currentSubView) {
    return view.id != this._mainViewId;
  }

  setMainView(aNewMainView) {
    if (!aNewMainView)
      return;

    if (this._mainView) {
      if (!this.panelViews)
        this._subViews.appendChild(this._mainView);
      this._mainView.removeAttribute("mainview");
    }
    this._mainViewId = aNewMainView.id;
    aNewMainView.setAttribute("mainview", "true");
    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 {
      this._mainViewContainer.appendChild(aNewMainView);
    }
  }

  showMainView() {
    if (!this._mainViewId)
      return Promise.resolve();

    if (this.panelViews)
      return this.showSubView(this._mainView);

    if (this.showingSubView) {
      let viewNode = this._currentSubView;
      this._dispatchViewEvent(viewNode, "ViewHiding");
      this._transitionHeight(() => {
        viewNode.removeAttribute("current");
        this._currentSubView = null;
        this.node.setAttribute("viewtype", "main");
      });
    }

    this._shiftMainView();
    return Promise.resolve();
  }

  /**
   * Ensures that all the panelviews, that are currently part of this instance,
   * are hidden, except one specifically.
   *
   * @param {panelview} [theOne] The panelview DOM node to ensure is visible.
   *                             Optional.
   */
  hideAllViewsExcept(theOne = null) {
    for (let panelview of this._panelViews) {
      // When the panelview was already reparented, don't interfere any more.
      if (panelview == theOne || !this.node || panelview.panelMultiView != this.node)
        continue;
      if (panelview.hasAttribute("current"))
        this._dispatchViewEvent(panelview, "ViewHiding");
      panelview.removeAttribute("current");
    }

    this._viewShowing = null;

    if (!this.node || !theOne)
      return;

    this._currentSubView = theOne;
    if (!theOne.hasAttribute("current")) {
      theOne.setAttribute("current", true);
      this.descriptionHeightWorkaround(theOne);
      this._dispatchViewEvent(theOne, "ViewShown");
    }
    this.node.setAttribute("viewtype", (theOne.id == this._mainViewId) ? "main" : "subview");
  }

  showSubView(aViewId, aAnchor, aPreviousView) {
    this._currentShowPromise = (async () => {
      // Support passing in the node directly.
      let viewNode = typeof aViewId == "string" ? this.node.querySelector("#" + aViewId) : aViewId;
      if (!viewNode) {
        viewNode = this.document.getElementById(aViewId);
        if (viewNode) {
          this._placeSubView(viewNode);
        } else {
          throw new Error(`Subview ${aViewId} doesn't exist!`);
        }
      } else if (viewNode.parentNode == this._panelViewCache) {
        this._placeSubView(viewNode);
      }

      let reverse = !!aPreviousView;
      let previousViewNode = aPreviousView || this._currentSubView;
      // If the panelview to show is the same as the previous one, the 'ViewShowing'
      // event has already been dispatched. Don't do it twice.
      let showingSameView = viewNode == previousViewNode;
      let playTransition = (!!previousViewNode && !showingSameView && this._panel.state == "open");
      let isMainView = viewNode.id == this._mainViewId;

      let dwu, previousRect;
      if (playTransition || this.panelViews) {
        dwu = this._dwu;
        previousRect = previousViewNode.__lastKnownBoundingRect =
          dwu.getBoundsWithoutFlushing(previousViewNode);
        if (this.panelViews) {
          // Cache the measures that have the same caching lifetime as the width
          // or height of the main view, i.e. whilst the panel is shown and/ or
          // visible.
          if (!this._mainViewWidth) {
            this._mainViewWidth = previousRect.width;
            let top = dwu.getBoundsWithoutFlushing(previousViewNode.firstChild || previousViewNode).top;
            let bottom = dwu.getBoundsWithoutFlushing(previousViewNode.lastChild || previousViewNode).bottom;
            this._viewVerticalPadding = previousRect.height - (bottom - top);
          }
          if (!this._mainViewHeight) {
            this._mainViewHeight = previousRect.height;
            this._viewContainer.style.minHeight = this._mainViewHeight + "px";
          }
        }
      }

      this._viewShowing = viewNode;
      // Because the 'mainview' attribute may be out-of-sync, due to view node
      // reparenting in combination with ephemeral PanelMultiView instances,
      // this is the best place to correct it (just before showing).
      if (isMainView)
        viewNode.setAttribute("mainview", true);
      else
        viewNode.removeAttribute("mainview");

      // Make sure that new panels always have a title set.
      if (this.panelViews && aAnchor) {
        if (!viewNode.hasAttribute("title"))
          viewNode.setAttribute("title", aAnchor.getAttribute("label"));
        viewNode.classList.add("PanelUI-subView");
      }
      if (this.panelViews && !isMainView && this._mainViewWidth)
        viewNode.style.maxWidth = viewNode.style.minWidth = this._mainViewWidth + "px";

      if (!showingSameView || !viewNode.hasAttribute("current")) {
        // Emit the ViewShowing event so that the widget definition has a chance
        // to lazily populate the subview with things or perhaps even cancel this
        // whole operation.
        let detail = {
          blockers: new Set(),
          addBlocker(promise) {
            this.blockers.add(promise);
          }
        };
        let cancel = this._dispatchViewEvent(viewNode, "ViewShowing", aAnchor, detail);
        if (detail.blockers.size) {
          try {
            let results = await Promise.all(detail.blockers);
            cancel = cancel || results.some(val => val === false);
          } catch (e) {
            Cu.reportError(e);
            cancel = true;
          }
        }

        if (cancel) {
          this._viewShowing = null;
          return;
        }
      }

      // Now we have to transition the panel.
      if (this.panelViews) {
        // If we've got an older transition still running, make sure to clean it up.
        await this._cleanupTransitionPhase();
        if (playTransition) {
          await this._transitionViews(previousViewNode, viewNode, reverse, previousRect, aAnchor);
          this._updateKeyboardFocus(viewNode);
        } else {
          this.hideAllViewsExcept(viewNode);
        }
      } else {
        this._currentSubView = viewNode;
        this._transitionHeight(() => {
          viewNode.setAttribute("current", true);
          if (viewNode.id == this._mainViewId) {
            this.node.setAttribute("viewtype", "main");
          } else {
            this.node.setAttribute("viewtype", "subview");
          }
          // Now that the subview is visible, we can check the height of the
          // description elements it contains.
          this.descriptionHeightWorkaround(viewNode);
          this._dispatchViewEvent(viewNode, "ViewShown");
        });
        this._shiftMainView(aAnchor);
      }
    })().catch(e => Cu.reportError(e));
    return this._currentShowPromise;
  }

  /**
   * Apply a transition to 'slide' from the currently active view to the next
   * one.
   * 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.
   *
   * @param {panelview} previousViewNode Node that is currently shown as active,
   *                                     but is about to be transitioned away.
   * @param {panelview} viewNode         Node that will becode the active view,
   *                                     after the transition has finished.
   * @param {Boolean}   reverse          Whether we're navigation back to a
   *                                     previous view or forward to a next view.
   * @param {Object}    previousRect     Rect object, with the same structure as
   *                                     a DOMRect, of the `previousViewNode`.
   * @param {Element}   anchor           the anchor for which we're opening
   *                                     a new panelview, if any
   */
  async _transitionViews(previousViewNode, viewNode, reverse, previousRect, anchor) {
    // There's absolutely no need to show off our epic animation skillz when
    // the panel's not even open.
    if (this._panel.state != "open") {
      return;
    }

    const {window, document} = this;

    if (this._autoResizeWorkaroundTimer)
      window.clearTimeout(this._autoResizeWorkaroundTimer);

    let details = this._transitionDetails = {
      phase: TRANSITION_PHASES.START,
      previousViewNode, viewNode, reverse, anchor
    };

    if (anchor)
      anchor.setAttribute("open", "true");

    // Since we're going to show two subview at the same time, don't abuse the
    // 'current' attribute, since it's needed for other state-keeping, but use
    // a separate 'in-transition' attribute instead.
    previousViewNode.setAttribute("in-transition", true);
    // Set the viewContainer dimensions to make sure only the current view is
    // visible.
    this._viewContainer.style.height = Math.max(previousRect.height, this._mainViewHeight) + "px";
    this._viewContainer.style.width = previousRect.width + "px";
    // Lock the dimensions of the window that hosts the popup panel.
    let rect = this._panel.popupBoxObject.getOuterScreenRect();
    this._panel.setAttribute("width", rect.width);
    this._panel.setAttribute("height", rect.height);

    let viewRect;
    if (viewNode.__lastKnownBoundingRect) {
      viewRect = viewNode.__lastKnownBoundingRect;
      viewNode.setAttribute("in-transition", true);
    } else if (viewNode.customRectGetter) {
      // Can't use Object.assign directly with a DOM Rect object because its properties
      // aren't enumerable.
      let {height, width} = previousRect;
      viewRect = Object.assign({height, width}, viewNode.customRectGetter());
      let {header} = viewNode;
      if (header) {
        viewRect.height += this._dwu.getBoundsWithoutFlushing(header).height;
      }
      viewNode.setAttribute("in-transition", true);
    } else {
      let oldSibling = viewNode.nextSibling || null;
      this._offscreenViewStack.appendChild(viewNode);
      viewNode.setAttribute("in-transition", true);

      viewRect = await BrowserUtils.promiseLayoutFlushed(this.document, "layout", () => {
        return this._dwu.getBoundsWithoutFlushing(viewNode);
      });

      try {
        this._viewStack.insertBefore(viewNode, oldSibling);
      } catch (ex) {
        this._viewStack.appendChild(viewNode);
      }
    }

    this._transitioning = true;
    details.phase = TRANSITION_PHASES.PREPARE;

    // 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 deltaX = previousRect.width;
    let deepestNode = reverse ? previousViewNode : viewNode;

    // With a transition when navigating backwards - user hits the 'back'
    // button - we need to make sure that the views are positioned in a way
    // that a translateX() unveils the previous view from the right direction.
    if (reverse)
      this._viewStack.style.marginInlineStart = "-" + deltaX + "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.
    this._viewStack.style.transition = "transform var(--animation-easing-function)" +
      " var(--panelui-subview-transition-duration)";
    this._viewStack.style.willChange = "transform";
    deepestNode.style.borderInlineStart = "1px solid var(--panel-separator-color)";

    // Now set the viewContainer dimensions to that of the new view, which
    // kicks of the height animation.
    this._viewContainer.style.height = Math.max(viewRect.height, this._mainViewHeight) + "px";
    this._viewContainer.style.width = viewRect.width + "px";
    this._panel.removeAttribute("width");
    this._panel.removeAttribute("height");
    // We're setting the width property to prevent flickering during the
    // sliding animation with smaller views.
    viewNode.style.width = viewRect.width + "px";

    await BrowserUtils.promiseLayoutFlushed(document, "layout", () => {});

    // Kick off the transition!
    details.phase = TRANSITION_PHASES.TRANSITION;
    this._viewStack.style.transform = "translateX(" + (moveToLeft ? "" : "-") + deltaX + "px)";

    await new Promise(resolve => {
      details.resolve = resolve;
      this._viewContainer.addEventListener("transitionend", details.listener = ev => {
        // It's quite common that `height` on the view container doesn't need
        // to transition, so we make sure to do all the work on the transform
        // transition-end, because that is guaranteed to happen.
        if (ev.target != this._viewStack || ev.propertyName != "transform")
          return;
        this._viewContainer.removeEventListener("transitionend", details.listener);
        delete details.listener;
        resolve();
      });
    });

    details.phase = TRANSITION_PHASES.END;

    await this._cleanupTransitionPhase(details);
  }

  /**
   * Attempt to clean up the attributes and properties set by `_transitionViews`
   * above. Which attributes and properties depends on the phase the transition
   * was left from - normally that'd be `TRANSITION_PHASES.END`.
   */
  async _cleanupTransitionPhase(details = this._transitionDetails) {
    // Make sure to only clean up a phase from the most recent transition.
    if (!this._transitionDetails || details != this._transitionDetails)
      return;

    let {phase, previousViewNode, viewNode, reverse, resolve, listener, anchor} = this._transitionDetails;
    this._transitionDetails = null;

    // Do the things we _always_ need to do whenever the transition ends or is
    // interrupted.
    this.hideAllViewsExcept(viewNode);
    previousViewNode.removeAttribute("in-transition");
    viewNode.removeAttribute("in-transition");
    if (reverse)
      this._resetKeyNavigation(previousViewNode);

    if (anchor)
      anchor.removeAttribute("open");

    if (phase >= TRANSITION_PHASES.START) {
      this._panel.removeAttribute("width");
      this._panel.removeAttribute("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! Bug 1363756 is there to fix this.
      this._autoResizeWorkaroundTimer = this.window.setTimeout(() => {
        if (!this._viewContainer)
          return;
        this._viewContainer.style.removeProperty("height");
        this._viewContainer.style.removeProperty("width");
      }, 500);
    }
    if (phase >= TRANSITION_PHASES.PREPARE) {
      this._transitioning = false;
      if (reverse)
        this._viewStack.style.removeProperty("margin-inline-start");
      let deepestNode = reverse ? previousViewNode : viewNode;
      deepestNode.style.removeProperty("border-inline-start");
      this._viewStack.style.removeProperty("transition");
    }
    if (phase >= TRANSITION_PHASES.TRANSITION) {
      this._viewStack.style.removeProperty("transform");
      viewNode.style.removeProperty("width");
      if (listener)
        this._viewContainer.removeEventListener("transitionend", listener);
      if (resolve)
        resolve();
    }
    if (phase >= TRANSITION_PHASES.END) {
      // We force 'display: none' on the previous view node to make sure that it
      // doesn't cause an annoying flicker whilst resetting the styles above.
      previousViewNode.style.display = "none";
      await BrowserUtils.promiseLayoutFlushed(this.document, "layout", () => {});
      previousViewNode.style.removeProperty("display");
    }
  }

  /**
   * Helper method to emit an event on a panelview, whilst also making sure that
   * the correct method is called on CustomizableWidget instances.
   *
   * @param  {panelview} viewNode  Target of the event to dispatch.
   * @param  {String}    eventName Name of the event to dispatch.
   * @param  {DOMNode}   [anchor]  Node where the panel is anchored to. Optional.
   * @param  {Object}    [detail]  Event detail object. Optional.
   * @return {Boolean} `true` if the event was canceled by an event handler, `false`
   *                   otherwise.
   */
  _dispatchViewEvent(viewNode, eventName, anchor, detail) {
    let cancel = false;
    if (eventName != "PanelMultiViewHidden") {
      // Don't need to do this for PanelMultiViewHidden event
      CustomizableUI.ensureSubviewListeners(viewNode);
    }

    let evt = new this.window.CustomEvent(eventName, {
      detail,
      bubbles: true,
      cancelable: eventName == "ViewShowing"
    });
    viewNode.dispatchEvent(evt);
    if (!cancel)
      cancel = evt.defaultPrevented;
    return cancel;
  }

  /**
   * Applies the height transition for which <panelmultiview> is designed.
   *
   * The height transition involves two elements, the viewContainer and its only
   * immediate child the viewStack. In order for this to work correctly, the
   * viewContainer must have "overflow: hidden;" and the two elements must have
   * no margins or padding. This means that the height of the viewStack is never
   * limited by the viewContainer, but when the height of the container is not
   * constrained it matches the height of the viewStack.
   *
   * @param changeFn
   *        This synchronous function is called to make the DOM changes
   *        that will result in a new height of the viewStack.
   */
  _transitionHeight(changeFn) {
    if (this._panel.state != "open") {
      changeFn();
      return;
    }

    // Lock the dimensions of the window that hosts the popup panel. This
    // in turn constrains the height of the viewContainer.
    let rect = this._panel.popupBoxObject.getOuterScreenRect();
    this._panel.setAttribute("width", rect.width);
    this._panel.setAttribute("height", rect.height);

    // Read the current height of the viewStack. If we are in the middle
    // of a transition, this is the actual height of the element at this
    // point in time.
    let oldHeight = this._dwu.getBoundsWithoutFlushing(this._viewStack).height;

    // Make the necessary DOM changes, and remove the "height" property of the
    // viewStack to ensure that we read its final value even if we are in the
    // middle of a transition. To avoid flickering, we have to prevent the panel
    // from being painted in this temporary state, which requires a synchronous
    // layout when reading the new height.
    this._viewStack.style.removeProperty("height");
    changeFn();
    let newHeight = this._viewStack.getBoundingClientRect().height;

    // Now we can allow the popup panel to resize again. This must occur
    // in the same tick as the code below, but we can do this before
    // setting the starting height in case the transition is not needed.
    this._panel.removeAttribute("width");
    this._panel.removeAttribute("height");

    if (oldHeight != newHeight) {
      // Height transitions can only occur between two numeric values, and
      // cannot start if the height is not set. In case a transition is
      // needed, we have to set the height to the old value, then force a
      // synchronous layout so the panel won't resize unexpectedly.
      this._viewStack.style.height = oldHeight + "px";
      this._viewStack.getBoundingClientRect().height;

      // We can now set the new height to start the transition, but
      // before doing that we set up a listener to reset the height to
      // "auto" at the end, so that DOM changes made after the
      // transition ends are still reflected by the height of the panel.
      let onTransitionEnd = event => {
        if (event.target != this._viewStack) {
          return;
        }
        this._viewStack.removeEventListener("transitionend", onTransitionEnd);
        this._viewStack.style.removeProperty("height");
      };
      this._viewStack.addEventListener("transitionend", onTransitionEnd);
      this._viewStack.style.height = newHeight + "px";
    }
  }

  _shiftMainView(aAnchor) {
    if (aAnchor) {
      // We need to find the edge of the anchor, relative to the main panel.
      // Then we need to add half the width of the anchor. This is the target
      // that we need to transition to.
      let anchorRect = aAnchor.getBoundingClientRect();
      let mainViewRect = this._mainViewContainer.getBoundingClientRect();
      let center = aAnchor.clientWidth / 2;
      let direction = aAnchor.ownerGlobal.getComputedStyle(aAnchor).direction;
      let edge;
      if (direction == "ltr") {
        edge = anchorRect.left - mainViewRect.left;
      } else {
        edge = mainViewRect.right - anchorRect.right;
      }

      // If the anchor is an element on the far end of the mainView we
      // don't want to shift the mainView too far, we would reveal empty
      // space otherwise.
      let cstyle = this.window.getComputedStyle(this.document.documentElement);
      let exitSubViewGutterWidth =
        cstyle.getPropertyValue("--panel-ui-exit-subview-gutter-width");
      let maxShift = mainViewRect.width - parseInt(exitSubViewGutterWidth);
      let target = Math.min(maxShift, edge + center);

      let neg = direction == "ltr" ? "-" : "";
      this._mainViewContainer.style.transform = `translateX(${neg}${target}px)`;
      aAnchor.setAttribute("panel-multiview-anchor", true);
    } else {
      this._mainViewContainer.style.transform = "";
      if (this.anchorElement)
        this.anchorElement.removeAttribute("panel-multiview-anchor");
    }
    this.anchorElement = aAnchor;
  }

  handleEvent(aEvent) {
    if (aEvent.type.startsWith("popup") && aEvent.target != this._panel) {
      // Shouldn't act on e.g. context menus being shown from within the panel.
      return;
    }
    switch (aEvent.type) {
      case "click":
        if (aEvent.originalTarget == this._clickCapturer) {
          this.showMainView();
        }
        break;
      case "keydown":
        this._keyNavigation(aEvent);
        break;
      case "mousemove":
        this._resetKeyNavigation();
        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;
        if (this.panelViews && !this.node.hasAttribute("disablekeynav")) {
          this.window.addEventListener("keydown", this);
          this._panel.addEventListener("mousemove", this);
        }

        // Before opening the panel, we have to limit the maximum height of any
        // view based on the space that will be available. We cannot just use
        // window.screen.availTop and availHeight because these may return an
        // incorrect value when the window spans multiple screens.
        let anchorBox = this._panel.anchorNode.boxObject;
        let screen = this._screenManager.screenForRect(anchorBox.screenX,
                                                       anchorBox.screenY,
                                                       anchorBox.width,
                                                       anchorBox.height);
        let availTop = {}, availHeight = {};
        screen.GetAvailRect({}, availTop, {}, availHeight);
        let cssAvailTop = availTop.value / screen.defaultCSSScaleFactor;

        // The distance from the anchor to the available margin of the screen is
        // based on whether the panel will open towards the top or the bottom.
        let maxHeight;
        if (this._panel.alignmentPosition.startsWith("before_")) {
          maxHeight = anchorBox.screenY - cssAvailTop;
        } else {
          let anchorScreenBottom = anchorBox.screenY + anchorBox.height;
          let cssAvailHeight = availHeight.value / screen.defaultCSSScaleFactor;
          maxHeight = cssAvailTop + cssAvailHeight - anchorScreenBottom;
        }

        // To go from the maximum height of the panel to the maximum height of
        // the view stack, we need to subtract the height of the arrow and the
        // height of the opposite margin, but we cannot get their actual values
        // because the panel is not visible yet. However, we know that this is
        // currently 11px on Mac, 13px on Windows, and 13px on Linux. We also
        // want an extra margin, both for visual reasons and to prevent glitches
        // due to small rounding errors. So, we just use a value that makes
        // sense for all platforms. If the arrow visuals change significantly,
        // this value will be easy to adjust.
        const EXTRA_MARGIN_PX = 20;
        maxHeight -= EXTRA_MARGIN_PX;
        this._viewStack.style.maxHeight = maxHeight + "px";

        // When using block-in-box layout inside a scrollable frame, like in the
        // main menu contents scroller, if we allow the contents to scroll then
        // it will not cause its container to expand. Thus, we layout first
        // without any scrolling (using "display: flex;"), and only if the view
        // exceeds the available space we set the height explicitly and enable
        // scrolling.
        let mainView = this._mainView;
        if (mainView && mainView.hasAttribute("blockinboxworkaround")) {
          let blockInBoxWorkaround = () => {
            let mainViewHeight =
                this._dwu.getBoundsWithoutFlushing(mainView).height;
            if (mainViewHeight > maxHeight) {
              mainView.style.height = maxHeight + "px";
              mainView.setAttribute("exceeding", "true");
            }
          };
          // On Windows, we cannot measure the full height of the main view
          // until it is visible. Unfortunately, this causes a visible jump when
          // the view needs to scroll, but there is no easy way around this.
          if (AppConstants.platform == "win") {
            // We register a "once" listener so we don't need to store the value
            // of maxHeight elsewhere on the object.
            this._panel.addEventListener("popupshown", blockInBoxWorkaround,
                                         { once: true });
          } else {
            blockInBoxWorkaround();
          }
        }
        break;
      }
      case "popupshown":
        // Now that the main view is visible, we can check the height of the
        // description elements it contains.
        this.descriptionHeightWorkaround();
        break;
      case "popuphidden": {
        // WebExtensions consumers can hide the popup from viewshowing, or
        // mid-transition, which disrupts our state:
        this._viewShowing = null;
        this._transitioning = false;
        this.node.removeAttribute("panelopen");
        this.showMainView();
        if (this.panelViews) {
          for (let panelView of this._viewStack.children) {
            if (panelView.nodeName != "children") {
              panelView.__lastKnownBoundingRect = null;
              panelView.style.removeProperty("min-width");
              panelView.style.removeProperty("max-width");
            }
          }
          this.window.removeEventListener("keydown", this);
          this._panel.removeEventListener("mousemove", this);
          this._resetKeyNavigation();

          // Clear the main view size caches. The dimensions could be different
          // when the popup is opened again, e.g. through touch mode sizing.
          this._mainViewHeight = 0;
          this._mainViewWidth = 0;
          this._viewContainer.style.removeProperty("min-height");
          this._viewStack.style.removeProperty("max-height");
          this._viewContainer.style.removeProperty("min-width");
          this._viewContainer.style.removeProperty("max-width");
        }

        // Always try to layout the panel normally when reopening it. This is
        // also the layout that will be used in customize mode.
        let mainView = this._mainView;
        if (mainView && mainView.hasAttribute("blockinboxworkaround")) {
          mainView.style.removeProperty("height");
          mainView.removeAttribute("exceeding");
        }
        this._dispatchViewEvent(this.node, "PanelMultiViewHidden");
        break;
      }
    }
  }

  /**
   * Based on going up or down, select the previous or next focusable button
   * in the current view.
   *
   * @param {Object}  navMap   the navigation keyboard map object for the view
   * @param {Array}   buttons  an array of focusable buttons to select an item from.
   * @param {Boolean} isDown   whether we're going down (true) or up (false) in this view.
   *
   * @return {DOMNode} the button we selected.
   */
  _updateSelectedKeyNav(navMap, buttons, isDown) {
    let lastSelected = navMap.selected && navMap.selected.get();
    let newButton = null;
    let maxIdx = buttons.length - 1;
    if (lastSelected) {
      let buttonIndex = buttons.indexOf(lastSelected);
      if (buttonIndex != -1) {
        // Buttons may get selected whilst the panel is shown, so add an extra
        // check here.
        do {
          buttonIndex = buttonIndex + (isDown ? 1 : -1);
        } while (buttons[buttonIndex] && buttons[buttonIndex].disabled)
        if (isDown && buttonIndex > maxIdx)
          buttonIndex = 0;
        else if (!isDown && buttonIndex < 0)
          buttonIndex = maxIdx;
        newButton = buttons[buttonIndex];
      } else {
        // The previously selected item is no longer selectable. Find the next item:
        let allButtons = lastSelected.closest("panelview").getElementsByTagName("toolbarbutton");
        let maxAllButtonIdx = allButtons.length - 1;
        let allButtonIndex = allButtons.indexOf(lastSelected);
        while (allButtonIndex >= 0 && allButtonIndex <= maxAllButtonIdx) {
          allButtonIndex++;
          // Check if the next button is in the list of focusable buttons.
          buttonIndex = buttons.indexOf(allButtons[allButtonIndex]);
          if (buttonIndex != -1) {
            // If it is, just use that button if we were going down, or the previous one
            // otherwise. If this was the first button, newButton will end up undefined,
            // which is fine because we'll fall back to using the last button at the
            // bottom of this method.
            newButton = buttons[isDown ? buttonIndex : buttonIndex - 1];
            break;
          }
        }
      }
    }

    // If we couldn't find something, select the first or last item:
    if (!newButton) {
      newButton = buttons[isDown ? 0 : maxIdx];
    }
    navMap.selected = Cu.getWeakReference(newButton);
    return newButton;
  }

  /**
   * Allow for navigating subview buttons using the arrow keys and the Enter key.
   * The Up and Down keys can be used to navigate the list up and down and the
   * Enter, Right or Left - depending on the text direction - key can be used to
   * simulate a click on the currently selected button.
   * The Right or Left key - depending on the text direction - can be used to
   * navigate to the previous view, functioning as a shortcut for the view's
   * back button.
   * Thus, in LTR mode:
   *  - The Right key functions the same as the Enter key, simulating a click
   *  - The Left key triggers a navigation back to the previous view.
   *
   * @param {KeyEvent} event
   */
  _keyNavigation(event) {
    if (this._transitioning)
      return;

    let view = this._currentSubView;
    let navMap = this._keyNavigationMap.get(view);
    if (!navMap) {
      navMap = {};
      this._keyNavigationMap.set(view, navMap);
    }

    let buttons = navMap.buttons;
    if (!buttons || !buttons.length) {
      buttons = navMap.buttons = this._getNavigableElements(view);
      // Set the 'tabindex' attribute on the buttons to make sure they're focussable.
      for (let button of buttons) {
        if (!button.classList.contains("subviewbutton-back") &&
            !button.hasAttribute("tabindex")) {
          button.setAttribute("tabindex", 0);
        }
      }
    }
    if (!buttons.length)
      return;

    let stop = () => {
      event.stopPropagation();
      event.preventDefault();
    };

    let keyCode = event.code;
    switch (keyCode) {
      case "ArrowDown":
      case "ArrowUp": {
        stop();
        let isDown = (keyCode == "ArrowDown");
        let button = this._updateSelectedKeyNav(navMap, buttons, isDown);
        button.focus();
        break;
      }
      case "ArrowLeft":
      case "ArrowRight": {
        stop();
        let dir = this._dir;
        if ((dir == "ltr" && keyCode == "ArrowLeft") ||
            (dir == "rtl" && keyCode == "ArrowRight")) {
          if (this._canGoBack(view))
            this.goBack(view.backButton);
          break;
        }
        // If the current button is _not_ one that points to a subview, pressing
        // the arrow key shouldn't do anything.
        if (!navMap.selected || !navMap.selected.get() ||
            !navMap.selected.get().classList.contains("subviewbutton-nav")) {
          break;
        }
        // Fall-through...
      }
      case "Space":
      case "Enter": {
        let button = navMap.selected && navMap.selected.get();
        if (!button)
          break;
        stop();

        // Unfortunately, 'tabindex' doesn't execute the default action, so
        // we explicitly do this here.
        // We are sending a command event and then a click event.
        // This is done in order to mimic a "real" mouse click event.
        // The command event executes the action, then the click event closes the menu.
        button.doCommand();
        let clickEvent = new event.target.ownerGlobal.MouseEvent("click", {"bubbles": true});
        button.dispatchEvent(clickEvent);
        break;
      }
    }
  }

  /**
   * Clear all traces of keyboard navigation happening right now.
   *
   * @param {panelview} view View to reset the key navigation attributes of.
   *                         If no view is passed, all navigation attributes for
   *                         this panelmultiview are cleared.
   */
  _resetKeyNavigation(view) {
    let viewToBlur = view || this._currentSubView;
    let navMap = this._keyNavigationMap.get(viewToBlur);
    if (navMap && navMap.selected && navMap.selected.get()) {
      navMap.selected.get().blur();
    }

    // We clear the entire key navigation map ONLY if *no* view was passed in.
    // This happens e.g. when the popup is hidden completely, or the user moves
    // their mouse.
    // If a view is passed in, we just delete the map for that view. This happens
    // when going back from a view (which resets the map for that view only)
    if (view) {
      this._keyNavigationMap.delete(view);
    } else {
      this._keyNavigationMap.clear();
    }
  }

  /**
   * Retrieve the button elements from a view node that can be used for navigation
   * using the keyboard; enabled buttons and the back button, if visible.
   *
   * @param  {nsIDOMNode} view
   * @return {Array}
   */
  _getNavigableElements(view) {
    let buttons = Array.from(view.querySelectorAll(".subviewbutton:not([disabled])"));
    if (this._canGoBack(view))
      buttons.unshift(view.backButton);
    let dwu = this._dwu;
    return buttons.filter(button => {
      let bounds = dwu.getBoundsWithoutFlushing(button);
      return bounds.width > 0 && bounds.height > 0;
    });
  }

  /**
   * Focus the last selected element in the view, if any.
   *
   * @param {panelview} view the view in which to update keyboard focus.
   */
  _updateKeyboardFocus(view) {
    let navMap = this._keyNavigationMap.get(view);
    if (navMap && navMap.selected && navMap.selected.get()) {
      navMap.selected.get().focus();
    }
  }

  /**
   * If the main view or a subview contains wrapping elements, the attribute
   * "descriptionheightworkaround" should be set on the view to force all the
   * wrapping "description", "label" or "toolbarbutton" elements to a fixed
   * height. If the attribute is set and the visibility, contents, or width
   * of any of these elements changes, this function should be called to
   * refresh the calculated heights.
   *
   * This may trigger a synchronous layout.
   *
   * @param viewNode
   *        Indicates the node to scan for descendant elements. This is the main
   *        view if omitted.
   */
  descriptionHeightWorkaround(viewNode = this._mainView) {
    if (!viewNode || !viewNode.hasAttribute("descriptionheightworkaround")) {
      // This view does not require the workaround.
      return;
    }

    // We batch DOM changes together in order to reduce synchronous layouts.
    // First we reset any change we may have made previously. The first time
    // this is called, and in the best case scenario, this has no effect.
    let items = [];
    // Non-hidden <label> or <description> elements that also aren't empty
    // and also don't have a value attribute can be multiline (if their
    // text content is long enough).
    let isMultiline = ":not(:-moz-any([hidden],[value],:empty))";
    let selector = [
      "description" + isMultiline,
      "label" + isMultiline,
      "toolbarbutton[wrap]:not([hidden])",
    ].join(",");
    for (let element of viewNode.querySelectorAll(selector)) {
      // Ignore items in hidden containers.
      if (element.closest("[hidden]")) {
        continue;
      }
      // Take the label for toolbarbuttons; it only exists on those elements.
      element = element.labelElement || element;

      let bounds = element.getBoundingClientRect();
      let previous = this._multiLineElementsMap.get(element);
      // We don't need to (re-)apply the workaround for invisible elements or
      // on elements we've seen before and haven't changed in the meantime.
      if (!bounds.width || !bounds.height ||
          (previous && element.textContent == previous.textContent &&
                       bounds.width == previous.bounds.width)) {
        continue;
      }

      items.push({ element });
    }

    // Removing the 'height' property will only cause a layout flush in the next
    // loop below if it was set.
    for (let item of items) {
      item.element.style.removeProperty("height");
    }

    // We now read the computed style to store the height of any element that
    // may contain wrapping text.
    for (let item of items) {
      item.bounds = item.element.getBoundingClientRect();
    }

    // Now we can make all the necessary DOM changes at once.
    for (let { element, bounds } of items) {
      this._multiLineElementsMap.set(element, { bounds, textContent: element.textContent });
      element.style.height = bounds.height + "px";
    }
  }
}