fune/browser/components/customizableui/PanelMultiView.jsm
Paolo Amadini 0d7dab5bac Bug 1432016 - Part 4 - Move keyboard navigation to the PanelView class. r=Gijs
This allows removing the separate keyboard navigation map.

MozReview-Commit-ID: 2N0wflAvg7Y

--HG--
extra : rebase_source : 63b9ae6e4db0de1957cb011c69134894d311b703
2018-01-22 14:42:24 +00:00

1132 lines
42 KiB
JavaScript

/* 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/. */
/**
* Allows a popup panel to host multiple subviews. The main view shown when the
* panel is opened may slide out to display a subview, which in turn may lead to
* other subviews in a cascade menu pattern.
*
* The <panel> element should contain a <panelmultiview> element. Views are
* declared using <panelview> elements that are usually children of the main
* <panelmultiview> element, although they don't need to be, as views can also
* be imported into the panel from other panels or popup sets.
*
* The main view can be declared using the mainViewId attribute, and specific
* subviews can slide in using the showSubView method. Backwards navigation can
* be done using the goBack method or through a button in the subview headers.
*
* This diagram shows how <panelview> nodes move during navigation:
*
* In this <panelmultiview> In other panels Action
* ┌───┬───┬───┐ ┌───┬───┐
* │(A)│ B │ C │ │ D │ E │ Open panel
* └───┴───┴───┘ └───┴───┘
* ┌───┬───┬───┐ ┌───┬───┐
* │ A │(C)│ B │ │ D │ E │ Show subview C
* └───┴───┴───┘ └───┴───┘
* ┌───┬───┬───┬───┐ ┌───┐
* │ A │ C │(D)│ B │ │ E │ Show subview D
* └───┴───┴───┴───┘ └───┘
* ┌───┬───┬───┬───┐ ┌───┐
* │ A │(C)│ D │ B │ │ E │ Go back
* └───┴───┴───┴───┘ └───┘
* │
* └── Currently visible view
*
* If the <panelmultiview> element is "ephemeral", imported subviews will be
* moved out again to the element specified by the viewCacheId attribute, so
* that the panel element can be removed safely.
*/
"use strict";
this.EXPORTED_SYMBOLS = [
"PanelMultiView",
"PanelView",
];
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
});
let gNodeToObjectMap = new WeakMap();
let gMultiLineElementsMap = new WeakMap();
/**
* Allows associating an object to a node lazily using a weak map.
*
* Classes deriving from this one may be easily converted to Custom Elements,
* although they would lose the ability of being associated lazily.
*/
this.AssociatedToNode = class {
constructor(node) {
/**
* Node associated to this object.
*/
this.node = node;
}
/**
* Retrieves the instance associated with the given node, constructing a new
* one if necessary. When the last reference to the node is released, the
* object instance will be garbage collected as well.
*/
static forNode(node) {
let associatedToNode = gNodeToObjectMap.get(node);
if (!associatedToNode) {
associatedToNode = new this(node);
gNodeToObjectMap.set(node, associatedToNode);
}
return associatedToNode;
}
get document() {
return this.node.ownerDocument;
}
get window() {
return this.node.ownerGlobal;
}
/**
* nsIDOMWindowUtils for the window of this node.
*/
get _dwu() {
if (this.__dwu)
return this.__dwu;
return this.__dwu = this.window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils);
}
/**
* Dispatches a custom event on this element.
*
* @param {String} eventName Name of the event to dispatch.
* @param {Object} [detail] Event detail object. Optional.
* @param {Boolean} cancelable If the event can be canceled.
* @return {Boolean} `true` if the event was canceled by an event handler, `false`
* otherwise.
*/
dispatchCustomEvent(eventName, detail, cancelable = false) {
let event = new this.window.CustomEvent(eventName, {
detail,
bubbles: true,
cancelable,
});
this.node.dispatchEvent(event);
return event.defaultPrevented;
}
};
/**
* This is associated to <panelmultiview> elements by the panelUI.xml binding.
*/
this.PanelMultiView = class extends this.AssociatedToNode {
get _panel() {
return this.node.parentNode;
}
get _mainViewId() {
return this.node.getAttribute("mainViewId");
}
get _mainView() {
return this.document.getElementById(this._mainViewId);
}
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 _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.node && (this._viewShowing || this._currentSubView);
}
get _currentSubView() {
// Peek the top of the stack, but fall back to the main view if the list of
// opened views is currently empty.
let panelView = this.openViews[this.openViews.length - 1];
return (panelView && panelView.node) || this._mainView;
}
/**
* @return {Promise} showSubView() returns a promise, which is kept here for
* random access.
*/
get currentShowPromise() {
return this._currentShowPromise || Promise.resolve();
}
connect() {
this.knownViews = new Set(Array.from(
this.node.getElementsByTagName("panelview"),
node => PanelView.forNode(node)));
this.openViews = [];
this._mainViewHeight = 0;
this.__transitioning = false;
this.showingSubView = false;
const {document, window} = this;
this._viewContainer =
document.getAnonymousElementByAttribute(this.node, "anonid", "viewContainer");
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("popuppositioned", this);
this._panel.addEventListener("popuphidden", this);
this._panel.addEventListener("popupshown", this);
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.showMainView();
// Proxy these public properties and methods, as used elsewhere by various
// parts of the browser, to this instance.
["goBack", "showMainView", "showSubView"].forEach(method => {
Object.defineProperty(this.node, method, {
enumerable: true,
value: (...args) => this[method](...args)
});
});
["current", "currentShowPromise", "showingSubView"].forEach(property => {
Object.defineProperty(this.node, property, {
enumerable: true,
get: () => this[property]
});
});
}
destructor() {
// Guard against re-entrancy.
if (!this.node)
return;
this._cleanupTransitionPhase();
if (this._ephemeral)
this.hideAllViewsExcept(null);
let mainView = this._mainView;
if (mainView) {
if (this._panelViewCache)
this._panelViewCache.appendChild(mainView);
mainView.removeAttribute("mainview");
}
this._moveOutKids(this._viewStack);
this._panel.removeEventListener("mousemove", this);
this._panel.removeEventListener("popupshowing", this);
this._panel.removeEventListener("popuppositioned", this);
this._panel.removeEventListener("popupshown", this);
this._panel.removeEventListener("popuphidden", this);
this.window.removeEventListener("keydown", this);
this.node = this._viewContainer = 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);
}
}
goBack() {
if (this.openViews.length < 2) {
// This may be called by keyboard navigation or external code when only
// the main view is open.
return;
}
let previous = this.openViews.pop().node;
let current = this._currentSubView;
this.showSubView(current, null, previous);
}
showMainView() {
if (!this.node || !this._mainViewId)
return Promise.resolve();
return this.showSubView(this._mainView);
}
/**
* Ensures that all the panelviews, that are currently part of this instance,
* are hidden, except one specifically.
*
* @param {panelview} [nextPanelView]
* The PanelView object to ensure is visible. Optional.
*/
hideAllViewsExcept(nextPanelView = null) {
for (let panelView of this.knownViews) {
// When the panelview was already reparented, don't interfere any more.
if (panelView == nextPanelView || !this.node || panelView.node.panelMultiView != this.node)
continue;
panelView.current = false;
}
this._viewShowing = null;
if (!this.node || !nextPanelView)
return;
if (!this.openViews.includes(nextPanelView))
this.openViews.push(nextPanelView);
nextPanelView.current = true;
this.showingSubView = nextPanelView.node.id != this._mainViewId;
}
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._viewStack.appendChild(viewNode);
} else {
throw new Error(`Subview ${aViewId} doesn't exist!`);
}
} else if (viewNode.parentNode == this._panelViewCache) {
this._viewStack.appendChild(viewNode);
}
let nextPanelView = PanelView.forNode(viewNode);
this.knownViews.add(nextPanelView);
viewNode.panelMultiView = this.node;
let reverse = !!aPreviousView;
if (!reverse) {
nextPanelView.headerText = viewNode.getAttribute("title") ||
(aAnchor && aAnchor.getAttribute("label"));
}
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 previousRect = previousViewNode.__lastKnownBoundingRect =
this._dwu.getBoundsWithoutFlushing(previousViewNode);
// 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;
}
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).
nextPanelView.mainview = isMainView;
if (aAnchor) {
viewNode.classList.add("PanelUI-subView");
}
if (!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 = nextPanelView.dispatchCustomEvent("ViewShowing", detail, true);
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 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);
nextPanelView.focusSelectedElement();
} else {
this.hideAllViewsExcept(nextPanelView);
}
})().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;
let nextPanelView = PanelView.forNode(viewNode);
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 (reverse && viewNode.__lastKnownBoundingRect) {
// Use the cached size when going back to a previous view, but not when
// reopening a subview, because its contents may have changed.
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.firstChild;
if (header && header.classList.contains("panel-header")) {
viewRect.height += this._dwu.getBoundsWithoutFlushing(header).height;
}
viewNode.setAttribute("in-transition", true);
} else {
let oldSibling = viewNode.nextSibling || null;
this._offscreenViewStack.style.minHeight =
this._viewContainer.style.height;
this._offscreenViewStack.appendChild(viewNode);
viewNode.setAttribute("in-transition", true);
// Now that the subview is visible, we can check the height of the
// description elements it contains.
nextPanelView.descriptionHeightWorkaround();
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._offscreenViewStack.style.removeProperty("min-height");
}
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";
// Use an outline instead of a border so that the size is not affected.
deepestNode.style.outline = "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();
});
this._viewContainer.addEventListener("transitioncancel", details.cancelListener = ev => {
if (ev.target != this._viewStack)
return;
this._viewContainer.removeEventListener("transitioncancel", details.cancelListener);
delete details.cancelListener;
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`.
*
* @param {Object} details Dictionary object containing details of the transition
* that should be cleaned up after. Defaults to the most
* recent details.
*/
async _cleanupTransitionPhase(details = this._transitionDetails) {
if (!details || !this.node)
return;
let {phase, previousViewNode, viewNode, reverse, resolve, listener, cancelListener, anchor} = details;
if (details == this._transitionDetails)
this._transitionDetails = null;
let nextPanelView = PanelView.forNode(viewNode);
let prevPanelView = PanelView.forNode(previousViewNode);
// Do the things we _always_ need to do whenever the transition ends or is
// interrupted.
this.hideAllViewsExcept(nextPanelView);
previousViewNode.removeAttribute("in-transition");
viewNode.removeAttribute("in-transition");
if (reverse)
prevPanelView.clearNavigation();
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("outline");
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 (cancelListener)
this._viewContainer.removeEventListener("transitioncancel", cancelListener);
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");
}
}
_calculateMaxHeight() {
// While 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;
return maxHeight;
}
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 "keydown":
if (!this._transitioning) {
PanelView.forNode(this._currentSubView)
.keyNavigation(aEvent, this._dir);
}
break;
case "mousemove":
this.openViews.forEach(panelView => panelView.clearNavigation());
break;
case "popupshowing": {
this.node.setAttribute("panelopen", "true");
if (!this.node.hasAttribute("disablekeynav")) {
this.window.addEventListener("keydown", this);
this._panel.addEventListener("mousemove", this);
}
break;
}
case "popuppositioned": {
// When autoPosition is true, the popup window manager would attempt to re-position
// the panel as subviews are opened and it changes size. The resulting popoppositioned
// events triggers the binding's arrow position adjustment - and its reflow.
// This is not needed here, as we calculated and set maxHeight so it is known
// to fit the screen while open.
// autoPosition gets reset after each popuppositioned event, and when the
// popup closes, so we must set it back to false each time.
this._panel.autoPosition = false;
if (this._panel.state == "showing") {
let maxHeight = this._calculateMaxHeight();
this._viewStack.style.maxHeight = maxHeight + "px";
this._offscreenViewStack.style.maxHeight = maxHeight + "px";
}
break;
}
case "popupshown":
// Now that the main view is visible, we can check the height of the
// description elements it contains.
PanelView.forNode(this._mainView).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();
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.openViews.forEach(panelView => panelView.clearNavigation());
this.openViews = [];
// 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");
this.dispatchCustomEvent("PanelMultiViewHidden");
break;
}
}
}
};
/**
* This is associated to <panelview> elements.
*/
this.PanelView = class extends this.AssociatedToNode {
/**
* The "mainview" attribute is set before the panel is opened when this view
* is displayed as the main view, and is removed before the <panelview> is
* displayed as a subview. The same view element can be displayed as a main
* view and as a subview at different times.
*/
set mainview(value) {
if (value) {
this.node.setAttribute("mainview", true);
} else {
this.node.removeAttribute("mainview");
}
}
set current(value) {
if (value) {
if (!this.node.hasAttribute("current")) {
this.node.setAttribute("current", true);
this.descriptionHeightWorkaround();
this.dispatchCustomEvent("ViewShown");
}
} else if (this.node.hasAttribute("current")) {
this.dispatchCustomEvent("ViewHiding");
this.node.removeAttribute("current");
}
}
/**
* Adds a header with the given title, or removes it if the title is empty.
*/
set headerText(value) {
// If the header already exists, update or remove it as requested.
let header = this.node.firstChild;
if (header && header.classList.contains("panel-header")) {
if (value) {
header.querySelector("label").setAttribute("value", value);
} else {
header.remove();
}
return;
}
// The header doesn't exist, only create it if needed.
if (!value) {
return;
}
header = this.document.createElement("box");
header.classList.add("panel-header");
let backButton = this.document.createElement("toolbarbutton");
backButton.className =
"subviewbutton subviewbutton-iconic subviewbutton-back";
backButton.setAttribute("closemenu", "none");
backButton.setAttribute("tabindex", "0");
backButton.setAttribute("tooltip",
this.node.getAttribute("data-subviewbutton-tooltip"));
backButton.addEventListener("command", () => {
// The panelmultiview element may change if the view is reused.
this.node.panelMultiView.goBack();
backButton.blur();
});
let label = this.document.createElement("label");
label.setAttribute("value", value);
header.append(backButton, label);
this.node.prepend(header);
}
/**
* Also make sure that the correct method is called on CustomizableWidget.
*/
dispatchCustomEvent(...args) {
CustomizableUI.ensureSubviewListeners(this.node);
return super.dispatchCustomEvent(...args);
}
/**
* 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.
*/
descriptionHeightWorkaround() {
if (!this.node.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 this.node.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 = gMultiLineElementsMap.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) {
gMultiLineElementsMap.set(element, { bounds, textContent: element.textContent });
element.style.height = bounds.height + "px";
}
}
/**
* Retrieves the button elements that can be used for navigation using the
* keyboard, that is all enabled buttons including the back button if visible.
*
* @return {Array}
*/
getNavigableElements() {
let buttons = Array.from(this.node.querySelectorAll(".subviewbutton:not([disabled])"));
let dwu = this._dwu;
return buttons.filter(button => {
let bounds = dwu.getBoundsWithoutFlushing(button);
return bounds.width > 0 && bounds.height > 0;
});
}
/**
* Element that is currently selected with the keyboard, or null if no element
* is selected. Since the reference is held weakly, it can become null or
* undefined at any time.
*
* The element is usually, but not necessarily, in the "buttons" property
* which in turn is initialized from the getNavigableElements list.
*/
get selectedElement() {
return this._selectedElement && this._selectedElement.get();
}
set selectedElement(value) {
if (!value) {
delete this._selectedElement;
} else {
this._selectedElement = Cu.getWeakReference(value);
}
}
/**
* Based on going up or down, select the previous or next focusable button.
*
* @param {Boolean} isDown whether we're going down (true) or up (false).
*
* @return {DOMNode} the button we selected.
*/
moveSelection(isDown) {
let buttons = this.buttons;
let lastSelected = this.selectedElement;
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];
}
this.selectedElement = 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
* @param {String} dir
* Direction for arrow navigation, either "ltr" or "rtl".
*/
keyNavigation(event, dir) {
let buttons = this.buttons;
if (!buttons || !buttons.length) {
buttons = this.buttons = this.getNavigableElements();
// 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":
case "Tab": {
stop();
let isDown = (keyCode == "ArrowDown") ||
(keyCode == "Tab" && !event.shiftKey);
let button = this.moveSelection(isDown);
button.focus();
break;
}
case "ArrowLeft":
case "ArrowRight": {
stop();
if ((dir == "ltr" && keyCode == "ArrowLeft") ||
(dir == "rtl" && keyCode == "ArrowRight")) {
this.node.panelMultiView.goBack();
break;
}
// If the current button is _not_ one that points to a subview, pressing
// the arrow key shouldn't do anything.
let button = this.selectedElement;
if (!button || !button.classList.contains("subviewbutton-nav")) {
break;
}
// Fall-through...
}
case "Space":
case "Enter": {
let button = this.selectedElement;
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;
}
}
}
/**
* Focus the last selected element in the view, if any.
*/
focusSelectedElement() {
let selected = this.selectedElement;
if (selected) {
selected.focus();
}
}
/**
* Clear all traces of keyboard navigation happening right now.
*/
clearNavigation() {
delete this.buttons;
let selected = this.selectedElement;
if (selected) {
selected.blur();
this.selectedElement = null;
}
}
};