summaryrefslogtreecommitdiffstats
path: root/browser/components/customizableui/PanelMultiView.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/customizableui/PanelMultiView.sys.mjs')
-rw-r--r--browser/components/customizableui/PanelMultiView.sys.mjs1875
1 files changed, 1875 insertions, 0 deletions
diff --git a/browser/components/customizableui/PanelMultiView.sys.mjs b/browser/components/customizableui/PanelMultiView.sys.mjs
new file mode 100644
index 0000000000..c5443f1b1d
--- /dev/null
+++ b/browser/components/customizableui/PanelMultiView.sys.mjs
@@ -0,0 +1,1875 @@
+/* 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 panel should be opened asynchronously using the openPopup static method
+ * on the PanelMultiView object. This will display the view specified using the
+ * mainViewId attribute on the contained <panelmultiview> element.
+ *
+ * Specific subviews can slide in using the showSubView method, and backwards
+ * navigation can be done using the goBack method or through a button in the
+ * subview headers.
+ *
+ * The process of displaying the main view or a new subview requires multiple
+ * steps to be completed, hence at any given time the <panelview> element may
+ * be in different states:
+ *
+ * -- Open or closed
+ *
+ * All the <panelview> elements start "closed", meaning that they are not
+ * associated to a <panelmultiview> element and can be located anywhere in
+ * the document. When the openPopup or showSubView methods are called, the
+ * relevant view becomes "open" and the <panelview> element may be moved to
+ * ensure it is a descendant of the <panelmultiview> element.
+ *
+ * The "ViewShowing" event is fired at this point, when the view is not
+ * visible yet. The event is allowed to cancel the operation, in which case
+ * the view is closed immediately.
+ *
+ * Closing the view does not move the node back to its original position.
+ *
+ * -- Visible or invisible
+ *
+ * This indicates whether the view is visible in the document from a layout
+ * perspective, regardless of whether it is currently scrolled into view. In
+ * fact, all subviews are already visible before they start sliding in.
+ *
+ * Before scrolling into view, a view may become visible but be placed in a
+ * special off-screen area of the document where layout and measurements can
+ * take place asyncronously.
+ *
+ * When navigating forward, an open view may become invisible but stay open
+ * after sliding out of view. The last known size of these views is still
+ * taken into account for determining the overall panel size.
+ *
+ * When navigating backwards, an open subview will first become invisible and
+ * then will be closed.
+ *
+ * -- Active or inactive
+ *
+ * This indicates whether the view is fully scrolled into the visible area
+ * and ready to receive mouse and keyboard events. An active view is always
+ * visible, but a visible view may be inactive. For example, during a scroll
+ * transition, both views will be inactive.
+ *
+ * When a view becomes active, the ViewShown event is fired synchronously,
+ * and the showSubView and goBack methods can be called for navigation.
+ *
+ * For the main view of the panel, the ViewShown event is dispatched during
+ * the "popupshown" event, which means that other "popupshown" handlers may
+ * be called before the view is active. Thus, code that needs to perform
+ * further navigation automatically should either use the ViewShown event or
+ * wait for an event loop tick, like BrowserTestUtils.waitForEvent does.
+ *
+ * -- Navigating with the keyboard
+ *
+ * An open view may keep state related to keyboard navigation, even if it is
+ * invisible. When a view is closed, keyboard navigation state is cleared.
+ *
+ * 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
+ * │ │ │
+ * └───┴───┴── Open views
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+});
+
+/**
+ * Safety timeout after which asynchronous events will be canceled if any of the
+ * registered blockers does not return.
+ */
+const BLOCKERS_TIMEOUT_MS = 10000;
+
+const TRANSITION_PHASES = Object.freeze({
+ START: 1,
+ PREPARE: 2,
+ TRANSITION: 3,
+});
+
+let gNodeToObjectMap = new WeakMap();
+let gWindowsWithUnloadHandler = new WeakSet();
+
+/**
+ * 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.
+ */
+var AssociatedToNode = class {
+ constructor(node) {
+ /**
+ * Node associated to this object.
+ */
+ this.node = node;
+
+ /**
+ * This promise is resolved when the current set of blockers set by event
+ * handlers have all been processed.
+ */
+ this._blockersPromise = Promise.resolve();
+ }
+
+ /**
+ * 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;
+ }
+
+ _getBoundsWithoutFlushing(element) {
+ return this.window.windowUtils.getBoundsWithoutFlushing(element);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Dispatches a custom event on this element and waits for any blocking
+ * promises registered using the "addBlocker" function on the details object.
+ * If this function is called again, the event is only dispatched after all
+ * the previously registered blockers have returned.
+ *
+ * The event can be canceled either by resolving any blocking promise to the
+ * boolean value "false" or by calling preventDefault on the event. Rejections
+ * and exceptions will be reported and will cancel the event.
+ *
+ * Blocking should be used sporadically because it slows down the interface.
+ * Also, non-reentrancy is not strictly guaranteed because a safety timeout of
+ * BLOCKERS_TIMEOUT_MS is implemented, after which the event will be canceled.
+ * This helps to prevent deadlocks if any of the event handlers does not
+ * resolve a blocker promise.
+ *
+ * @note Since there is no use case for dispatching different asynchronous
+ * events in parallel for the same element, this function will also wait
+ * for previous blockers when the event name is different.
+ *
+ * @param eventName
+ * Name of the custom event to dispatch.
+ *
+ * @resolves True if the event was canceled by a handler, false otherwise.
+ */
+ async dispatchAsyncEvent(eventName) {
+ // Wait for all the previous blockers before dispatching the event.
+ let blockersPromise = this._blockersPromise.catch(() => {});
+ return (this._blockersPromise = blockersPromise.then(async () => {
+ let blockers = new Set();
+ let cancel = this.dispatchCustomEvent(
+ eventName,
+ {
+ addBlocker(promise) {
+ // Any exception in the blocker will cancel the operation.
+ blockers.add(
+ promise.catch(ex => {
+ console.error(ex);
+ return true;
+ })
+ );
+ },
+ },
+ true
+ );
+ if (blockers.size) {
+ let timeoutPromise = new Promise((resolve, reject) => {
+ this.window.setTimeout(reject, BLOCKERS_TIMEOUT_MS);
+ });
+ try {
+ let results = await Promise.race([
+ Promise.all(blockers),
+ timeoutPromise,
+ ]);
+ cancel = cancel || results.some(result => result === false);
+ } catch (ex) {
+ console.error(
+ new Error(`One of the blockers for ${eventName} timed out.`)
+ );
+ return true;
+ }
+ }
+ return cancel;
+ }));
+ }
+};
+
+/**
+ * This is associated to <panelmultiview> elements.
+ */
+export var PanelMultiView = class extends AssociatedToNode {
+ /**
+ * Tries to open the specified <panel> and displays the main view specified
+ * with the "mainViewId" attribute on the <panelmultiview> node it contains.
+ *
+ * If the panel does not contain a <panelmultiview>, it is opened directly.
+ * This allows consumers like page actions to accept different panel types.
+ *
+ * @see The non-static openPopup method for details.
+ */
+ static async openPopup(panelNode, ...args) {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ return this.forNode(panelMultiViewNode).openPopup(...args);
+ }
+ panelNode.openPopup(...args);
+ return true;
+ }
+
+ /**
+ * Closes the specified <panel> which contains a <panelmultiview> node.
+ *
+ * If the panel does not contain a <panelmultiview>, it is closed directly.
+ * This allows consumers like page actions to accept different panel types.
+ *
+ * @param {DOMNode} panelNode The <panel> node.
+ * @param {Boolean} [animate] Whether to show a fade animation. Optional.
+ *
+ * @see The non-static hidePopup method for details.
+ */
+ static hidePopup(panelNode, animate = false) {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ this.forNode(panelMultiViewNode).hidePopup(animate);
+ } else {
+ panelNode.hidePopup(animate);
+ }
+ }
+
+ /**
+ * Removes the specified <panel> from the document, ensuring that any
+ * <panelmultiview> node it contains is destroyed properly.
+ *
+ * If the viewCacheId attribute is present on the <panelmultiview> element,
+ * imported subviews will be moved out again to the element it specifies, so
+ * that the panel element can be removed safely.
+ *
+ * If the panel does not contain a <panelmultiview>, it is removed directly.
+ * This allows consumers like page actions to accept different panel types.
+ */
+ static removePopup(panelNode) {
+ try {
+ let panelMultiViewNode = panelNode.querySelector("panelmultiview");
+ if (panelMultiViewNode) {
+ let panelMultiView = this.forNode(panelMultiViewNode);
+ panelMultiView._moveOutKids();
+ panelMultiView.disconnect();
+ }
+ } finally {
+ // Make sure to remove the panel element even if disconnecting fails.
+ panelNode.remove();
+ }
+ }
+ /**
+ * Returns the element with the given id.
+ * For nodes that are lazily loaded and not yet in the DOM, the node should
+ * be retrieved from the view cache template.
+ */
+ static getViewNode(doc, id) {
+ let viewCacheTemplate = doc.getElementById("appMenu-viewCache");
+
+ return (
+ doc.getElementById(id) ||
+ viewCacheTemplate?.content.querySelector("#" + id)
+ );
+ }
+
+ /**
+ * Ensures that when the specified window is closed all the <panelmultiview>
+ * node it contains are destroyed properly.
+ */
+ static ensureUnloadHandlerRegistered(window) {
+ if (gWindowsWithUnloadHandler.has(window)) {
+ return;
+ }
+
+ window.addEventListener(
+ "unload",
+ () => {
+ for (let panelMultiViewNode of window.document.querySelectorAll(
+ "panelmultiview"
+ )) {
+ this.forNode(panelMultiViewNode).disconnect();
+ }
+ },
+ { once: true }
+ );
+
+ gWindowsWithUnloadHandler.add(window);
+ }
+
+ get _panel() {
+ return this.node.parentNode;
+ }
+
+ set _transitioning(val) {
+ if (val) {
+ this.node.setAttribute("transitioning", "true");
+ } else {
+ this.node.removeAttribute("transitioning");
+ }
+ }
+
+ get _screenManager() {
+ if (this.__screenManager) {
+ return this.__screenManager;
+ }
+ return (this.__screenManager = Cc[
+ "@mozilla.org/gfx/screenmanager;1"
+ ].getService(Ci.nsIScreenManager));
+ }
+
+ constructor(node) {
+ super(node);
+ this._openPopupPromise = Promise.resolve(false);
+ }
+
+ connect() {
+ this.connected = true;
+
+ PanelMultiView.ensureUnloadHandlerRegistered(this.window);
+
+ let viewContainer = (this._viewContainer =
+ this.document.createXULElement("box"));
+ viewContainer.classList.add("panel-viewcontainer");
+
+ let viewStack = (this._viewStack = this.document.createXULElement("box"));
+ viewStack.classList.add("panel-viewstack");
+ viewContainer.append(viewStack);
+
+ let offscreenViewContainer = this.document.createXULElement("box");
+ offscreenViewContainer.classList.add("panel-viewcontainer", "offscreen");
+
+ let offscreenViewStack = (this._offscreenViewStack =
+ this.document.createXULElement("box"));
+ offscreenViewStack.classList.add("panel-viewstack");
+ offscreenViewContainer.append(offscreenViewStack);
+
+ this.node.prepend(offscreenViewContainer);
+ this.node.prepend(viewContainer);
+
+ this.openViews = [];
+
+ this._panel.addEventListener("popupshowing", this);
+ this._panel.addEventListener("popuppositioned", this);
+ this._panel.addEventListener("popuphidden", this);
+ this._panel.addEventListener("popupshown", this);
+
+ // Proxy these public properties and methods, as used elsewhere by various
+ // parts of the browser, to this instance.
+ ["goBack", "showSubView"].forEach(method => {
+ Object.defineProperty(this.node, method, {
+ enumerable: true,
+ value: (...args) => this[method](...args),
+ });
+ });
+ }
+
+ disconnect() {
+ // Guard against re-entrancy.
+ if (!this.node || !this.connected) {
+ return;
+ }
+
+ 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.document.documentElement.removeEventListener("keydown", this, true);
+ this.node =
+ this._openPopupPromise =
+ this._openPopupCancelCallback =
+ this._viewContainer =
+ this._viewStack =
+ this._transitionDetails =
+ null;
+ }
+
+ /**
+ * Tries to open the panel associated with this PanelMultiView, and displays
+ * the main view specified with the "mainViewId" attribute.
+ *
+ * The hidePopup method can be called while the operation is in progress to
+ * prevent the panel from being displayed. View events may also cancel the
+ * operation, so there is no guarantee that the panel will become visible.
+ *
+ * The "popuphidden" event will be fired either when the operation is canceled
+ * or when the popup is closed later. This event can be used for example to
+ * reset the "open" state of the anchor or tear down temporary panels.
+ *
+ * If this method is called again before the panel is shown, the result
+ * depends on the operation currently in progress. If the operation was not
+ * canceled, the panel is opened using the arguments from the previous call,
+ * and this call is ignored. If the operation was canceled, it will be
+ * retried again using the arguments from this call.
+ *
+ * It's not necessary for the <panelmultiview> binding to be connected when
+ * this method is called, but the containing panel must have its display
+ * turned on, for example it shouldn't have the "hidden" attribute.
+ *
+ * @param anchor
+ * The node to anchor the popup to.
+ * @param options
+ * Either options to use or a string position. This is forwarded to
+ * the openPopup method of the panel.
+ * @param args
+ * Additional arguments to be forwarded to the openPopup method of the
+ * panel.
+ *
+ * @resolves With true as soon as the request to display the panel has been
+ * sent, or with false if the operation was canceled. The state of
+ * the panel at this point is not guaranteed. It may be still
+ * showing, completely shown, or completely hidden.
+ * @rejects If an exception is thrown at any point in the process before the
+ * request to display the panel is sent.
+ */
+ async openPopup(anchor, options, ...args) {
+ // Set up the function that allows hidePopup or a second call to showPopup
+ // to cancel the specific panel opening operation that we're starting below.
+ // This function must be synchronous, meaning we can't use Promise.race,
+ // because hidePopup wants to dispatch the "popuphidden" event synchronously
+ // even if the panel has not been opened yet.
+ let canCancel = true;
+ let cancelCallback = (this._openPopupCancelCallback = () => {
+ // If the cancel callback is called and the panel hasn't been prepared
+ // yet, cancel showing it. Setting canCancel to false will prevent the
+ // popup from opening. If the panel has opened by the time the cancel
+ // callback is called, canCancel will be false already, and we will not
+ // fire the "popuphidden" event.
+ if (canCancel && this.node) {
+ canCancel = false;
+ this.dispatchCustomEvent("popuphidden");
+ }
+ if (cancelCallback == this._openPopupCancelCallback) {
+ // If still current, let go of the cancel callback since it will capture
+ // the entire scope and tie it to the main window.
+ delete this._openPopupCancelCallback;
+ }
+ });
+
+ // Create a promise that is resolved with the result of the last call to
+ // this method, where errors indicate that the panel was not opened.
+ let openPopupPromise = this._openPopupPromise.catch(() => {
+ return false;
+ });
+
+ // Make the preparation done before showing the panel non-reentrant. The
+ // promise created here will be resolved only after the panel preparation is
+ // completed, even if a cancellation request is received in the meantime.
+ return (this._openPopupPromise = openPopupPromise.then(async wasShown => {
+ // The panel may have been destroyed in the meantime.
+ if (!this.node) {
+ return false;
+ }
+ // If the panel has been already opened there is nothing more to do. We
+ // check the actual state of the panel rather than setting some state in
+ // our handler of the "popuphidden" event because this has a lower chance
+ // of locking indefinitely if events aren't raised in the expected order.
+ if (wasShown && ["open", "showing"].includes(this._panel.state)) {
+ if (cancelCallback == this._openPopupCancelCallback) {
+ // If still current, let go of the cancel callback since it will
+ // capture the entire scope and tie it to the main window.
+ delete this._openPopupCancelCallback;
+ }
+ return true;
+ }
+ try {
+ if (!this.connected) {
+ this.connect();
+ }
+ // Allow any of the ViewShowing handlers to prevent showing the main view.
+ if (!(await this._showMainView())) {
+ cancelCallback();
+ }
+ } catch (ex) {
+ cancelCallback();
+ throw ex;
+ }
+ // If a cancellation request was received there is nothing more to do.
+ if (!canCancel || !this.node) {
+ return false;
+ }
+ // We have to set canCancel to false before opening the popup because the
+ // hidePopup method of PanelMultiView can be re-entered by event handlers.
+ // If the openPopup call fails, however, we still have to dispatch the
+ // "popuphidden" event even if canCancel was set to false.
+ try {
+ canCancel = false;
+ this._panel.openPopup(anchor, options, ...args);
+ if (cancelCallback == this._openPopupCancelCallback) {
+ // If still current, let go of the cancel callback since it will
+ // capture the entire scope and tie it to the main window.
+ delete this._openPopupCancelCallback;
+ }
+ // Set an attribute on the popup to let consumers style popup elements -
+ // for example, the anchor arrow is styled to match the color of the header
+ // in the Protections Panel main view.
+ this._panel.setAttribute("mainviewshowing", true);
+
+ // On Windows, if another popup is hiding while we call openPopup, the
+ // call won't fail but the popup won't open. In this case, we have to
+ // dispatch an artificial "popuphidden" event to reset our state.
+ if (this._panel.state == "closed" && this.openViews.length) {
+ this.dispatchCustomEvent("popuphidden");
+ return false;
+ }
+
+ if (
+ options &&
+ typeof options == "object" &&
+ options.triggerEvent &&
+ (options.triggerEvent.type == "keypress" ||
+ options.triggerEvent?.inputSource ==
+ MouseEvent.MOZ_SOURCE_KEYBOARD) &&
+ this.openViews.length
+ ) {
+ // This was opened via the keyboard, so focus the first item.
+ this.openViews[0].focusWhenActive = true;
+ }
+
+ return true;
+ } catch (ex) {
+ this.dispatchCustomEvent("popuphidden");
+ throw ex;
+ }
+ }));
+ }
+
+ /**
+ * Closes the panel associated with this PanelMultiView.
+ *
+ * If the openPopup method was called but the panel has not been displayed
+ * yet, the operation is canceled and the panel will not be displayed, but the
+ * "popuphidden" event is fired synchronously anyways.
+ *
+ * This means that by the time this method returns all the operations handled
+ * by the "popuphidden" event are completed, for example resetting the "open"
+ * state of the anchor, and the panel is already invisible.
+ *
+ * @note The value of animate could be changed to true by default, in both
+ * this and the static method above. (see bug 1769813)
+ *
+ * @param {Boolean} [animate] Whether to show a fade animation. Optional.
+ *
+ */
+ hidePopup(animate = false) {
+ if (!this.node || !this.connected) {
+ return;
+ }
+
+ // If we have already reached the _panel.openPopup call in the openPopup
+ // method, we can call hidePopup. Otherwise, we have to cancel the latest
+ // request to open the panel, which will have no effect if the request has
+ // been canceled already.
+ if (["open", "showing"].includes(this._panel.state)) {
+ this._panel.hidePopup(animate);
+ } else {
+ this._openPopupCancelCallback?.();
+ }
+
+ // We close all the views synchronously, so that they are ready to be opened
+ // in other PanelMultiView instances. The "popuphidden" handler may also
+ // call this function, but the second time openViews will be empty.
+ this.closeAllViews();
+ }
+
+ /**
+ * Move any child subviews into the element defined by "viewCacheId" to make
+ * sure they will not be removed together with the <panelmultiview> element.
+ */
+ _moveOutKids() {
+ // this.node may have been set to null by a call to disconnect().
+ let viewCacheId = this.node?.getAttribute("viewCacheId");
+ if (!viewCacheId) {
+ return;
+ }
+
+ // Node.children and Node.children is live to DOM changes like the
+ // ones we're about to do, so iterate over a static copy:
+ let subviews = Array.from(this._viewStack.children);
+ let viewCache = this.document.getElementById("appMenu-viewCache");
+ for (let subview of subviews) {
+ viewCache.appendChild(subview);
+ }
+ }
+
+ /**
+ * Slides in the specified view as a subview.
+ *
+ * @param viewIdOrNode
+ * DOM element or string ID of the <panelview> to display.
+ * @param anchor
+ * DOM element that triggered the subview, which will be highlighted
+ * and whose "label" attribute will be used for the title of the
+ * subview when a "title" attribute is not specified.
+ */
+ showSubView(viewIdOrNode, anchor) {
+ this._showSubView(viewIdOrNode, anchor).catch(console.error);
+ }
+ async _showSubView(viewIdOrNode, anchor) {
+ let viewNode =
+ typeof viewIdOrNode == "string"
+ ? PanelMultiView.getViewNode(this.document, viewIdOrNode)
+ : viewIdOrNode;
+ if (!viewNode) {
+ console.error(new Error(`Subview ${viewIdOrNode} doesn't exist.`));
+ return;
+ }
+
+ if (!this.openViews.length) {
+ console.error(new Error(`Cannot show a subview in a closed panel.`));
+ return;
+ }
+
+ let prevPanelView = this.openViews[this.openViews.length - 1];
+ let nextPanelView = PanelView.forNode(viewNode);
+ if (this.openViews.includes(nextPanelView)) {
+ console.error(new Error(`Subview ${viewNode.id} is already open.`));
+ return;
+ }
+
+ // Do not re-enter the process if navigation is already in progress. Since
+ // there is only one active view at any given time, we can do this check
+ // safely, even considering that during the navigation process the actual
+ // view to which prevPanelView refers will change.
+ if (!prevPanelView.active) {
+ return;
+ }
+ // If prevPanelView._doingKeyboardActivation is true, it will be reset to
+ // false synchronously. Therefore, we must capture it before we use any
+ // "await" statements.
+ let doingKeyboardActivation = prevPanelView._doingKeyboardActivation;
+ // Marking the view that is about to scrolled out of the visible area as
+ // inactive will prevent re-entrancy and also disable keyboard navigation.
+ // From this point onwards, "await" statements can be used safely.
+ prevPanelView.active = false;
+
+ // Provide visual feedback while navigation is in progress, starting before
+ // the transition starts and ending when the previous view is invisible.
+ if (anchor) {
+ anchor.setAttribute("open", "true");
+ }
+ try {
+ // If the ViewShowing event cancels the operation we have to re-enable
+ // keyboard navigation, but this must be avoided if the panel was closed.
+ if (!(await this._openView(nextPanelView))) {
+ if (prevPanelView.isOpenIn(this)) {
+ // We don't raise a ViewShown event because nothing actually changed.
+ // Technically we should use a different state flag just because there
+ // is code that could check the "active" property to determine whether
+ // to wait for a ViewShown event later, but this only happens in
+ // regression tests and is less likely to be a technique used in
+ // production code, where use of ViewShown is less common.
+ prevPanelView.active = true;
+ }
+ return;
+ }
+
+ prevPanelView.captureKnownSize();
+
+ // The main view of a panel can be a subview in another one. Make sure to
+ // reset all the properties that may be set on a subview.
+ nextPanelView.mainview = false;
+ // The header may change based on how the subview was opened.
+ nextPanelView.headerText =
+ viewNode.getAttribute("title") ||
+ (anchor && anchor.getAttribute("label"));
+ // The constrained width of subviews may also vary between panels.
+ nextPanelView.minMaxWidth = prevPanelView.knownWidth;
+ let lockPanelVertical =
+ this.openViews[0].node.getAttribute("lockpanelvertical") == "true";
+ nextPanelView.minMaxHeight = lockPanelVertical
+ ? prevPanelView.knownHeight
+ : 0;
+
+ if (anchor) {
+ viewNode.classList.add("PanelUI-subView");
+ }
+
+ await this._transitionViews(prevPanelView.node, viewNode, false);
+ } finally {
+ if (anchor) {
+ anchor.removeAttribute("open");
+ }
+ }
+
+ nextPanelView.focusWhenActive = doingKeyboardActivation;
+ this._activateView(nextPanelView);
+ }
+
+ /**
+ * Navigates backwards by sliding out the most recent subview.
+ */
+ goBack() {
+ this._goBack().catch(console.error);
+ }
+ async _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 prevPanelView = this.openViews[this.openViews.length - 1];
+ let nextPanelView = this.openViews[this.openViews.length - 2];
+
+ // Like in the showSubView method, do not re-enter navigation while it is
+ // in progress, and make the view inactive immediately. From this point
+ // onwards, "await" statements can be used safely.
+ if (!prevPanelView.active) {
+ return;
+ }
+ prevPanelView.active = false;
+
+ prevPanelView.captureKnownSize();
+ await this._transitionViews(prevPanelView.node, nextPanelView.node, true);
+
+ this._closeLatestView();
+
+ this._activateView(nextPanelView);
+ }
+
+ /**
+ * Prepares the main view before showing the panel.
+ */
+ async _showMainView() {
+ let nextPanelView = PanelView.forNode(
+ PanelMultiView.getViewNode(
+ this.document,
+ this.node.getAttribute("mainViewId")
+ )
+ );
+
+ // If the view is already open in another panel, close the panel first.
+ let oldPanelMultiViewNode = nextPanelView.node.panelMultiView;
+ if (oldPanelMultiViewNode) {
+ PanelMultiView.forNode(oldPanelMultiViewNode).hidePopup();
+ // Wait for a layout flush after hiding the popup, otherwise the view may
+ // not be displayed correctly for some time after the new panel is opened.
+ // This is filed as bug 1441015.
+ await this.window.promiseDocumentFlushed(() => {});
+ }
+
+ if (!(await this._openView(nextPanelView))) {
+ return false;
+ }
+
+ // The main view of a panel can be a subview in another one. Make sure to
+ // reset all the properties that may be set on a subview.
+ nextPanelView.mainview = true;
+ nextPanelView.headerText = "";
+ nextPanelView.minMaxWidth = 0;
+ nextPanelView.minMaxHeight = 0;
+
+ // Ensure the view will be visible once the panel is opened.
+ nextPanelView.visible = true;
+
+ return true;
+ }
+
+ /**
+ * Opens the specified PanelView and dispatches the ViewShowing event, which
+ * can be used to populate the subview or cancel the operation.
+ *
+ * This also clears all the attributes and styles that may be left by a
+ * transition that was interrupted.
+ *
+ * @resolves With true if the view was opened, false otherwise.
+ */
+ async _openView(panelView) {
+ if (panelView.node.parentNode != this._viewStack) {
+ this._viewStack.appendChild(panelView.node);
+ }
+
+ panelView.node.panelMultiView = this.node;
+ this.openViews.push(panelView);
+
+ // Panels could contain out-pf-process <browser> elements, that need to be
+ // supported with a remote attribute on the panel in order to display properly.
+ // See bug https://bugzilla.mozilla.org/show_bug.cgi?id=1365660
+ if (panelView.node.getAttribute("remote") == "true") {
+ this._panel.setAttribute("remote", "true");
+ }
+
+ let canceled = await panelView.dispatchAsyncEvent("ViewShowing");
+
+ // The panel can be hidden while we are processing the ViewShowing event.
+ // This results in all the views being closed synchronously, and at this
+ // point the ViewHiding event has already been dispatched for all of them.
+ if (!this.openViews.length) {
+ return false;
+ }
+
+ // Check if the event requested cancellation but the panel is still open.
+ if (canceled) {
+ // Handlers for ViewShowing can't know if a different handler requested
+ // cancellation, so this will dispatch a ViewHiding event to give a chance
+ // to clean up.
+ this._closeLatestView();
+ return false;
+ }
+
+ // Clean up all the attributes and styles related to transitions. We do this
+ // here rather than when the view is closed because we are likely to make
+ // other DOM modifications soon, which isn't the case when closing.
+ let { style } = panelView.node;
+ style.removeProperty("outline");
+ style.removeProperty("width");
+
+ return true;
+ }
+
+ /**
+ * Activates the specified view and raises the ViewShown event, unless the
+ * view was closed in the meantime.
+ */
+ _activateView(panelView) {
+ if (panelView.isOpenIn(this)) {
+ panelView.active = true;
+ if (panelView.focusWhenActive) {
+ panelView.focusFirstNavigableElement(false, true);
+ panelView.focusWhenActive = false;
+ }
+ panelView.dispatchCustomEvent("ViewShown");
+ }
+ }
+
+ /**
+ * Closes the most recent PanelView and raises the ViewHiding event.
+ *
+ * @note The ViewHiding event is not cancelable and should probably be renamed
+ * to ViewHidden or ViewClosed instead, see bug 1438507.
+ */
+ _closeLatestView() {
+ let panelView = this.openViews.pop();
+ panelView.clearNavigation();
+ panelView.dispatchCustomEvent("ViewHiding");
+ panelView.node.panelMultiView = null;
+ // Views become invisible synchronously when they are closed, and they won't
+ // become visible again until they are opened. When this is called at the
+ // end of backwards navigation, the view is already invisible.
+ panelView.visible = false;
+ }
+
+ /**
+ * Closes all the views that are currently open.
+ */
+ closeAllViews() {
+ // Raise ViewHiding events for open views in reverse order.
+ while (this.openViews.length) {
+ this._closeLatestView();
+ }
+ }
+
+ /**
+ * 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 displayed, but
+ * is about to be transitioned away. This
+ * must be already inactive at this point.
+ * @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.
+ */
+ async _transitionViews(previousViewNode, viewNode, reverse) {
+ const { window } = this;
+
+ let nextPanelView = PanelView.forNode(viewNode);
+ let prevPanelView = PanelView.forNode(previousViewNode);
+
+ let details = (this._transitionDetails = {
+ phase: TRANSITION_PHASES.START,
+ });
+
+ // Set the viewContainer dimensions to make sure only the current view is
+ // visible.
+ let olderView = reverse ? nextPanelView : prevPanelView;
+ this._viewContainer.style.minHeight = olderView.knownHeight + "px";
+ this._viewContainer.style.height = prevPanelView.knownHeight + "px";
+ this._viewContainer.style.width = prevPanelView.knownWidth + "px";
+ // Lock the dimensions of the window that hosts the popup panel.
+ let rect = this._getBoundsWithoutFlushing(this._panel);
+ this._panel.style.width = rect.width + "px";
+ this._panel.style.height = rect.height + "px";
+
+ let viewRect;
+ if (reverse) {
+ // Use the cached size when going back to a previous view, but not when
+ // reopening a subview, because its contents may have changed.
+ viewRect = {
+ width: nextPanelView.knownWidth,
+ height: nextPanelView.knownHeight,
+ };
+ nextPanelView.visible = true;
+ } else if (viewNode.customRectGetter) {
+ // We use a customRectGetter for WebExtensions panels, because they need
+ // to query the size from an embedded browser. The presence of this
+ // getter also provides an indication that the view node shouldn't be
+ // moved around, otherwise the state of the browser would get disrupted.
+ let width = prevPanelView.knownWidth;
+ let height = prevPanelView.knownHeight;
+ viewRect = Object.assign({ height, width }, viewNode.customRectGetter());
+ nextPanelView.visible = true;
+ // Until the header is visible, it has 0 height.
+ // Wait for layout before measuring it
+ let header = viewNode.firstElementChild;
+ if (header && header.classList.contains("panel-header")) {
+ viewRect.height += await window.promiseDocumentFlushed(() => {
+ return this._getBoundsWithoutFlushing(header).height;
+ });
+ }
+ // Bail out if the panel was closed in the meantime.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+ } else {
+ this._offscreenViewStack.style.minHeight = olderView.knownHeight + "px";
+ this._offscreenViewStack.appendChild(viewNode);
+ nextPanelView.visible = true;
+
+ viewRect = await window.promiseDocumentFlushed(() => {
+ return this._getBoundsWithoutFlushing(viewNode);
+ });
+ // Bail out if the panel was closed in the meantime.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+
+ // Place back the view after all the other views that are already open in
+ // order for the transition to work as expected.
+ 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.window.RTL_UI && !reverse) || (!this.window.RTL_UI && reverse);
+ let deltaX = prevPanelView.knownWidth;
+ 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 that all the elements are in place for the start of the transition,
+ // give the layout code a chance to set the initial values.
+ await window.promiseDocumentFlushed(() => {});
+ // Bail out if the panel was closed in the meantime.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+
+ // Now set the viewContainer dimensions to that of the new view, which
+ // kicks of the height animation.
+ this._viewContainer.style.height = viewRect.height + "px";
+ this._viewContainer.style.width = viewRect.width + "px";
+ this._panel.style.removeProperty("width");
+ this._panel.style.removeProperty("height");
+ // We're setting the width property to prevent flickering during the
+ // sliding animation with smaller views.
+ viewNode.style.width = viewRect.width + "px";
+
+ // Kick off the transition!
+ details.phase = TRANSITION_PHASES.TRANSITION;
+
+ // If we're going to show the main view, we can remove the
+ // min-height property on the view container. It's also time
+ // to set the mainviewshowing attribute on the popup.
+ if (viewNode.getAttribute("mainview")) {
+ this._viewContainer.style.removeProperty("min-height");
+ this._panel.setAttribute("mainviewshowing", true);
+ } else {
+ this._panel.removeAttribute("mainviewshowing");
+ }
+
+ // Avoid transforming element if the user has prefers-reduced-motion set
+ if (
+ this.window.matchMedia("(prefers-reduced-motion: no-preference)").matches
+ ) {
+ 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();
+ })
+ );
+ });
+ }
+
+ // Bail out if the panel was closed during the transition.
+ if (!nextPanelView.isOpenIn(this)) {
+ return;
+ }
+ prevPanelView.visible = false;
+
+ // This will complete the operation by removing any transition properties.
+ nextPanelView.node.style.removeProperty("width");
+ deepestNode.style.removeProperty("outline");
+ this._cleanupTransitionPhase();
+ // Ensure the newly-visible view has been through a layout flush before we
+ // attempt to focus anything in it.
+ // See https://firefox-source-docs.mozilla.org/performance/bestpractices.html#detecting-and-avoiding-synchronous-reflow
+ // for more information.
+ await this.window.promiseDocumentFlushed(() => {});
+ nextPanelView.focusSelectedElement();
+ }
+
+ /**
+ * 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.
+ */
+ _cleanupTransitionPhase() {
+ if (!this._transitionDetails) {
+ return;
+ }
+
+ let { phase, resolve, listener, cancelListener } = this._transitionDetails;
+ this._transitionDetails = null;
+
+ if (phase >= TRANSITION_PHASES.START) {
+ this._panel.removeAttribute("width");
+ this._panel.removeAttribute("height");
+ this._viewContainer.style.removeProperty("height");
+ this._viewContainer.style.removeProperty("width");
+ }
+ if (phase >= TRANSITION_PHASES.PREPARE) {
+ this._transitioning = false;
+ this._viewStack.style.removeProperty("margin-inline-start");
+ this._viewStack.style.removeProperty("transition");
+ }
+ if (phase >= TRANSITION_PHASES.TRANSITION) {
+ this._viewStack.style.removeProperty("transform");
+ if (listener) {
+ this._viewContainer.removeEventListener("transitionend", listener);
+ }
+ if (cancelListener) {
+ this._viewContainer.removeEventListener(
+ "transitioncancel",
+ cancelListener
+ );
+ }
+ if (resolve) {
+ resolve();
+ }
+ }
+ }
+
+ _calculateMaxHeight(aEvent) {
+ // 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 anchor = this._panel.anchorNode;
+ let anchorRect = anchor.getBoundingClientRect();
+ let screen = anchor.screen;
+
+ // GetAvailRect returns screen-device pixels, which we can convert to CSS
+ // pixels here.
+ 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 (aEvent.alignmentPosition.startsWith("before_")) {
+ maxHeight = anchor.screenY - cssAvailTop;
+ } else {
+ let anchorScreenBottom = anchor.screenY + anchorRect.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) {
+ // Only process actual popup events from the panel or events we generate
+ // ourselves, but not from menus being shown from within the panel.
+ if (
+ aEvent.type.startsWith("popup") &&
+ aEvent.target != this._panel &&
+ aEvent.target != this.node
+ ) {
+ return;
+ }
+ switch (aEvent.type) {
+ case "keydown":
+ // Since we start listening for the "keydown" event when the popup is
+ // already showing and stop listening when the panel is hidden, we
+ // always have at least one view open.
+ let currentView = this.openViews[this.openViews.length - 1];
+ currentView.keyNavigation(aEvent);
+ break;
+ case "mousemove":
+ this.openViews.forEach(panelView => {
+ if (!panelView.ignoreMouseMove) {
+ panelView.clearNavigation();
+ }
+ });
+ break;
+ case "popupshowing": {
+ this._viewContainer.setAttribute("panelopen", "true");
+ if (!this.node.hasAttribute("disablekeynav")) {
+ // We add the keydown handler on the root so that it handles key
+ // presses when a panel appears but doesn't get focus, as happens
+ // when a button to open a panel is clicked with the mouse.
+ // However, this means the listener is on an ancestor of the panel,
+ // which means that handlers such as ToolbarKeyboardNavigator are
+ // deeper in the tree. Therefore, this must be a capturing listener
+ // so we get the event first.
+ this.document.documentElement.addEventListener("keydown", this, true);
+ this._panel.addEventListener("mousemove", this);
+ }
+ break;
+ }
+ case "popuppositioned": {
+ if (this._panel.state == "showing") {
+ let maxHeight = this._calculateMaxHeight(aEvent);
+ this._viewStack.style.maxHeight = maxHeight + "px";
+ this._offscreenViewStack.style.maxHeight = maxHeight + "px";
+ }
+ break;
+ }
+ case "popupshown":
+ // The main view is always open and visible when the panel is first
+ // shown, so we can check the height of the description elements it
+ // contains and notify consumers using the ViewShown event. In order to
+ // minimize flicker we need to allow synchronous reflows, and we still
+ // make sure the ViewShown event is dispatched synchronously.
+ let mainPanelView = this.openViews[0];
+ this._activateView(mainPanelView);
+ break;
+ case "popuphidden": {
+ // WebExtensions consumers can hide the popup from viewshowing, or
+ // mid-transition, which disrupts our state:
+ this._transitioning = false;
+ this._viewContainer.removeAttribute("panelopen");
+ this._cleanupTransitionPhase();
+ this.document.documentElement.removeEventListener(
+ "keydown",
+ this,
+ true
+ );
+ this._panel.removeEventListener("mousemove", this);
+ this.closeAllViews();
+
+ // Clear the main view size caches. The dimensions could be different
+ // when the popup is opened again, e.g. through touch mode sizing.
+ this._viewContainer.style.removeProperty("min-height");
+ this._viewStack.style.removeProperty("max-height");
+ this._viewContainer.style.removeProperty("width");
+ this._viewContainer.style.removeProperty("height");
+
+ this.dispatchCustomEvent("PanelMultiViewHidden");
+ break;
+ }
+ }
+ }
+};
+
+/**
+ * This is associated to <panelview> elements.
+ */
+export var PanelView = class extends AssociatedToNode {
+ constructor(node) {
+ super(node);
+
+ /**
+ * Indicates whether the view is active. When this is false, consumers can
+ * wait for the ViewShown event to know when the view becomes active.
+ */
+ this.active = false;
+
+ /**
+ * Specifies whether the view should be focused when active. When this
+ * is true, the first navigable element in the view will be focused
+ * when the view becomes active. This should be set to true when the view
+ * is activated from the keyboard. It will be set to false once the view
+ * is active.
+ */
+ this.focusWhenActive = false;
+ }
+
+ /**
+ * Indicates whether the view is open in the specified PanelMultiView object.
+ */
+ isOpenIn(panelMultiView) {
+ return this.node.panelMultiView == panelMultiView.node;
+ }
+
+ /**
+ * 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");
+ }
+ }
+
+ /**
+ * Determines whether the view is visible. Setting this to false also resets
+ * the "active" property.
+ */
+ set visible(value) {
+ if (value) {
+ this.node.setAttribute("visible", true);
+ } else {
+ this.node.removeAttribute("visible");
+ this.active = false;
+ this.focusWhenActive = false;
+ }
+ }
+
+ /**
+ * Constrains the width of this view using the "min-width" and "max-width"
+ * styles. Setting this to zero removes the constraints.
+ */
+ set minMaxWidth(value) {
+ let style = this.node.style;
+ if (value) {
+ style.minWidth = style.maxWidth = value + "px";
+ } else {
+ style.removeProperty("min-width");
+ style.removeProperty("max-width");
+ }
+ }
+
+ /**
+ * Constrains the height of this view using the "min-height" and "max-height"
+ * styles. Setting this to zero removes the constraints.
+ */
+ set minMaxHeight(value) {
+ let style = this.node.style;
+ if (value) {
+ style.minHeight = style.maxHeight = value + "px";
+ } else {
+ style.removeProperty("min-height");
+ style.removeProperty("max-height");
+ }
+ }
+
+ /**
+ * Adds a header with the given title, or removes it if the title is empty.
+ */
+ set headerText(value) {
+ let ensureHeaderSeparator = headerNode => {
+ if (headerNode.nextSibling.tagName != "toolbarseparator") {
+ let separator = this.document.createXULElement("toolbarseparator");
+ this.node.insertBefore(separator, headerNode.nextSibling);
+ }
+ };
+
+ // If the header already exists, update or remove it as requested.
+ let header = this.node.querySelector(".panel-header");
+ if (header) {
+ let headerInfoButton = header.querySelector(".panel-info-button");
+ let headerBackButton = header.querySelector(".subviewbutton-back");
+ if (headerBackButton && this.node.getAttribute("mainview")) {
+ // A back button should not appear in a mainview.
+ // This codepath can be reached if a user enters a panelview in
+ // the overflow panel, and then unpins it back to the toolbar.
+ headerBackButton.remove();
+ }
+ if (!this.node.getAttribute("mainview")) {
+ if (value) {
+ if (headerInfoButton && !headerBackButton) {
+ // If we're not in a mainview and an info button is present,
+ // that means the panel header is a custom one and a back
+ // button should be added, if not already present.
+ header.prepend(this.createHeaderBackButton());
+ }
+ // Set the header title based on the value given.
+ header.querySelector(".panel-header > h1 > span").textContent = value;
+ ensureHeaderSeparator(header);
+ } else {
+ if (header.nextSibling.tagName == "toolbarseparator") {
+ header.nextSibling.remove();
+ }
+ header.remove();
+ }
+ return;
+ } else if (!this.node.getAttribute("showheader")) {
+ if (header.nextSibling.tagName == "toolbarseparator") {
+ header.nextSibling.remove();
+ }
+ header.remove();
+ }
+ }
+
+ // The header doesn't exist, only create it if needed.
+ if (!value) {
+ return;
+ }
+
+ header = this.document.createXULElement("box");
+ header.classList.add("panel-header");
+
+ let backButton = this.createHeaderBackButton();
+ let h1 = this.document.createElement("h1");
+ let span = this.document.createElement("span");
+ span.textContent = value;
+ h1.appendChild(span);
+
+ header.append(backButton, h1);
+ this.node.prepend(header);
+
+ ensureHeaderSeparator(header);
+ }
+
+ /**
+ * Creates and returns a panel header back toolbarbutton.
+ */
+ createHeaderBackButton() {
+ let backButton = this.document.createXULElement("toolbarbutton");
+ backButton.className =
+ "subviewbutton subviewbutton-iconic subviewbutton-back";
+ backButton.setAttribute("closemenu", "none");
+ backButton.setAttribute("tabindex", "0");
+ backButton.setAttribute(
+ "aria-label",
+ lazy.gBundle.GetStringFromName("panel.back")
+ );
+ backButton.addEventListener("command", () => {
+ // The panelmultiview element may change if the view is reused.
+ this.node.panelMultiView.goBack();
+ backButton.blur();
+ });
+ return backButton;
+ }
+
+ /**
+ * Also make sure that the correct method is called on CustomizableWidget.
+ */
+ dispatchCustomEvent(...args) {
+ lazy.CustomizableUI.ensureSubviewListeners(this.node);
+ return super.dispatchCustomEvent(...args);
+ }
+
+ /**
+ * Populates the "knownWidth" and "knownHeight" properties with the current
+ * dimensions of the view. These may be zero if the view is invisible.
+ *
+ * These values are relevant during transitions and are retained for backwards
+ * navigation if the view is still open but is invisible.
+ */
+ captureKnownSize() {
+ let rect = this._getBoundsWithoutFlushing(this.node);
+ this.knownWidth = rect.width;
+ this.knownHeight = rect.height;
+ }
+
+ /**
+ * Determine whether an element can only be navigated to with tab/shift+tab,
+ * not the arrow keys.
+ */
+ _isNavigableWithTabOnly(element) {
+ let tag = element.localName;
+ return (
+ tag == "menulist" ||
+ tag == "radiogroup" ||
+ tag == "input" ||
+ tag == "textarea" ||
+ // Allow tab to reach embedded documents.
+ tag == "browser" ||
+ tag == "iframe" ||
+ // This is currently needed for the unified extensions panel to allow
+ // users to use up/down arrow to more quickly move between the extension
+ // items. See Bug 1784118
+ element.dataset?.navigableWithTabOnly === "true"
+ );
+ }
+
+ /**
+ * Make a TreeWalker for keyboard navigation.
+ *
+ * @param {Boolean} arrowKey If `true`, elements only navigable with tab are
+ * excluded.
+ */
+ _makeNavigableTreeWalker(arrowKey) {
+ let filter = node => {
+ if (node.disabled) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ let bounds = this._getBoundsWithoutFlushing(node);
+ if (bounds.width == 0 || bounds.height == 0) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ let isNavigableWithTabOnly = this._isNavigableWithTabOnly(node);
+ // Early return when the node is navigable with tab only and we are using
+ // arrow keys so that nodes like button, toolbarbutton, checkbox, etc.
+ // can also be marked as "navigable with tab only", otherwise the next
+ // condition will unconditionally make them focusable.
+ if (arrowKey && isNavigableWithTabOnly) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ let localName = node.localName.toLowerCase();
+ if (
+ localName == "button" ||
+ localName == "toolbarbutton" ||
+ localName == "checkbox" ||
+ localName == "a" ||
+ localName == "moz-toggle" ||
+ node.classList.contains("text-link") ||
+ (!arrowKey && isNavigableWithTabOnly)
+ ) {
+ // Set the tabindex attribute to make sure the node is focusable.
+ // Don't do this for browser and iframe elements because this breaks
+ // tabbing behavior. They're already focusable anyway.
+ if (
+ localName != "browser" &&
+ localName != "iframe" &&
+ !node.hasAttribute("tabindex")
+ ) {
+ node.setAttribute("tabindex", "-1");
+ }
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_SKIP;
+ };
+ return this.document.createTreeWalker(
+ this.node,
+ NodeFilter.SHOW_ELEMENT,
+ filter
+ );
+ }
+
+ /**
+ * Get a TreeWalker which finds elements navigable with tab/shift+tab.
+ */
+ get _tabNavigableWalker() {
+ if (!this.__tabNavigableWalker) {
+ this.__tabNavigableWalker = this._makeNavigableTreeWalker(false);
+ }
+ return this.__tabNavigableWalker;
+ }
+
+ /**
+ * Get a TreeWalker which finds elements navigable with up/down arrow keys.
+ */
+ get _arrowNavigableWalker() {
+ if (!this.__arrowNavigableWalker) {
+ this.__arrowNavigableWalker = this._makeNavigableTreeWalker(true);
+ }
+ return this.__arrowNavigableWalker;
+ }
+
+ /**
+ * 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.
+ */
+ get selectedElement() {
+ return this._selectedElement && this._selectedElement.get();
+ }
+ set selectedElement(value) {
+ if (!value) {
+ delete this._selectedElement;
+ } else {
+ this._selectedElement = Cu.getWeakReference(value);
+ }
+ }
+
+ /**
+ * Focuses and moves keyboard selection to the first navigable element.
+ * This is a no-op if there are no navigable elements.
+ *
+ * @param {Boolean} homeKey `true` if this is for the home key.
+ * @param {Boolean} skipBack `true` if the Back button should be skipped.
+ */
+ focusFirstNavigableElement(homeKey = false, skipBack = false) {
+ // The home key is conceptually similar to the up/down arrow keys.
+ let walker = homeKey
+ ? this._arrowNavigableWalker
+ : this._tabNavigableWalker;
+ walker.currentNode = walker.root;
+ this.selectedElement = walker.firstChild();
+ if (
+ skipBack &&
+ walker.currentNode &&
+ walker.currentNode.classList.contains("subviewbutton-back") &&
+ walker.nextNode()
+ ) {
+ this.selectedElement = walker.currentNode;
+ }
+ this.focusSelectedElement(/* byKey */ true);
+ }
+
+ /**
+ * Focuses and moves keyboard selection to the last navigable element.
+ * This is a no-op if there are no navigable elements.
+ *
+ * @param {Boolean} endKey `true` if this is for the end key.
+ */
+ focusLastNavigableElement(endKey = false) {
+ // The end key is conceptually similar to the up/down arrow keys.
+ let walker = endKey ? this._arrowNavigableWalker : this._tabNavigableWalker;
+ walker.currentNode = walker.root;
+ this.selectedElement = walker.lastChild();
+ this.focusSelectedElement(/* byKey */ true);
+ }
+
+ /**
+ * Based on going up or down, select the previous or next focusable element.
+ *
+ * @param {Boolean} isDown whether we're going down (true) or up (false).
+ * @param {Boolean} arrowKey `true` if this is for the up/down arrow keys.
+ *
+ * @return {DOMNode} the element we selected.
+ */
+ moveSelection(isDown, arrowKey = false) {
+ let walker = arrowKey
+ ? this._arrowNavigableWalker
+ : this._tabNavigableWalker;
+ let oldSel = this.selectedElement;
+ let newSel;
+ if (oldSel) {
+ walker.currentNode = oldSel;
+ newSel = isDown ? walker.nextNode() : walker.previousNode();
+ }
+ // If we couldn't find something, select the first or last item:
+ if (!newSel) {
+ walker.currentNode = walker.root;
+ newSel = isDown ? walker.firstChild() : walker.lastChild();
+ }
+ this.selectedElement = newSel;
+ return newSel;
+ }
+
+ /**
+ * 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.
+ *
+ * Key navigation is only enabled while the view is active, meaning that this
+ * method will return early if it is invoked during a sliding transition.
+ *
+ * @param {KeyEvent} event
+ */
+ keyNavigation(event) {
+ if (!this.active) {
+ return;
+ }
+
+ let focus = this.document.activeElement;
+ // Make sure the focus is actually inside the panel. (It might not be if
+ // the panel was opened with the mouse.) If it isn't, we don't care
+ // about it for our purposes.
+ // We use Node.compareDocumentPosition because Node.contains doesn't
+ // behave as expected for anonymous content; e.g. the input inside a
+ // textbox.
+ if (
+ focus &&
+ !(
+ this.node.compareDocumentPosition(focus) &
+ Node.DOCUMENT_POSITION_CONTAINED_BY
+ )
+ ) {
+ focus = null;
+ }
+
+ // Some panels contain embedded documents. We can't manage
+ // keyboard navigation within those.
+ if (focus && (focus.tagName == "browser" || focus.tagName == "iframe")) {
+ return;
+ }
+
+ let stop = () => {
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ // If the focused element is only navigable with tab, it wants the arrow
+ // keys, etc. We shouldn't handle any keys except tab and shift+tab.
+ // We make a function for this for performance reasons: we only want to
+ // check this for keys we potentially care about, not *all* keys.
+ let tabOnly = () => {
+ // We use the real focus rather than this.selectedElement because focus
+ // might have been moved without keyboard navigation (e.g. mouse click)
+ // and this.selectedElement is only updated for keyboard navigation.
+ return focus && this._isNavigableWithTabOnly(focus);
+ };
+
+ // If a context menu is open, we must let it handle all keys.
+ // Normally, this just happens, but because we have a capturing root
+ // element keydown listener, our listener takes precedence.
+ // Again, we only want to do this check on demand for performance.
+ let isContextMenuOpen = () => {
+ if (!focus) {
+ return false;
+ }
+ let contextNode = focus.closest("[context]");
+ if (!contextNode) {
+ return false;
+ }
+ let context = contextNode.getAttribute("context");
+ if (!context) {
+ return false;
+ }
+ let popup = this.document.getElementById(context);
+ return popup && popup.state == "open";
+ };
+
+ this.ignoreMouseMove = false;
+
+ let keyCode = event.code;
+ switch (keyCode) {
+ case "ArrowDown":
+ case "ArrowUp":
+ if (tabOnly()) {
+ break;
+ }
+ // Fall-through...
+ case "Tab": {
+ if (
+ isContextMenuOpen() ||
+ // Tab in an open menulist should close it.
+ (focus && focus.localName == "menulist" && focus.open)
+ ) {
+ break;
+ }
+ stop();
+ let isDown =
+ keyCode == "ArrowDown" || (keyCode == "Tab" && !event.shiftKey);
+ let button = this.moveSelection(isDown, keyCode != "Tab");
+ Services.focus.setFocus(button, Services.focus.FLAG_BYKEY);
+ break;
+ }
+ case "Home":
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ this.focusFirstNavigableElement(true);
+ break;
+ case "End":
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ this.focusLastNavigableElement(true);
+ break;
+ case "ArrowLeft":
+ case "ArrowRight": {
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ stop();
+ if (
+ (!this.window.RTL_UI && keyCode == "ArrowLeft") ||
+ (this.window.RTL_UI && 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 "NumpadEnter":
+ case "Enter": {
+ if (tabOnly() || isContextMenuOpen()) {
+ break;
+ }
+ let button = this.selectedElement;
+ if (!button || button?.localName == "moz-toggle") {
+ break;
+ }
+ stop();
+
+ this._doingKeyboardActivation = true;
+ const details = {
+ bubbles: true,
+ ctrlKey: event.ctrlKey,
+ altKey: event.altKey,
+ shiftKey: event.shiftKey,
+ metaKey: event.metaKey,
+ };
+ let dispEvent = new event.target.ownerGlobal.MouseEvent(
+ "mousedown",
+ details
+ );
+ button.dispatchEvent(dispEvent);
+ // This event will trigger a command event too.
+ dispEvent = new event.target.ownerGlobal.MouseEvent("click", details);
+ button.dispatchEvent(dispEvent);
+ this._doingKeyboardActivation = false;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Focus the last selected element in the view, if any.
+ *
+ * @param byKey {Boolean} whether focus was moved by the user pressing a key.
+ * Needed to ensure we show focus styles in the right cases.
+ */
+ focusSelectedElement(byKey = false) {
+ let selected = this.selectedElement;
+ if (selected) {
+ let flag = byKey ? Services.focus.FLAG_BYKEY : 0;
+ Services.focus.setFocus(selected, flag);
+ }
+ }
+
+ /**
+ * Clear all traces of keyboard navigation happening right now.
+ */
+ clearNavigation() {
+ let selected = this.selectedElement;
+ if (selected) {
+ selected.blur();
+ this.selectedElement = null;
+ }
+ }
+};