diff --git a/accessible/tests/mochitest/events/test_mutation.html b/accessible/tests/mochitest/events/test_mutation.html index 48c23cfe8d8f..0eb15263ef0f 100644 --- a/accessible/tests/mochitest/events/test_mutation.html +++ b/accessible/tests/mochitest/events/test_mutation.html @@ -318,8 +318,8 @@ ]; this.invoke = function insertReferredElm_invoke() { - this.containerNode.innerHTML = - ""; + this.containerNode.unsafeSetInnerHTML( + ""); }; this.getID = function insertReferredElm_getID() { diff --git a/browser/base/content/browser-pageActions.js b/browser/base/content/browser-pageActions.js index 8e9361903638..444a5f122122 100644 --- a/browser/base/content/browser-pageActions.js +++ b/browser/base/content/browser-pageActions.js @@ -136,7 +136,6 @@ var BrowserPageActions = { if (action.subview) { buttonNode.classList.add("subviewbutton-nav"); panelViewNode = this._makePanelViewNodeForAction(action, false); - this.multiViewNode._panelViews = null; this.multiViewNode.appendChild(panelViewNode); } buttonNode.addEventListener("command", event => { @@ -236,6 +235,7 @@ var BrowserPageActions = { if (action.subview) { let multiViewNode = document.createElement("panelmultiview"); panelViewNode = this._makePanelViewNodeForAction(action, true); + multiViewNode.setAttribute("mainViewId", panelViewNode.id); multiViewNode.appendChild(panelViewNode); panelNode.appendChild(multiViewNode); } else if (action.wantsIframe) { @@ -291,11 +291,15 @@ var BrowserPageActions = { * * @param action (PageActions.Action, optional) * The action you want to anchor. + * @param event (DOM event, optional) + * This is used to display the feedback panel on the right node when + * the command can be invoked from both the main panel and another + * location, such as an activated action panel or a button. * @return (DOM node, nonnull) The node to which the action should be * anchored. */ panelAnchorNodeForAction(action, event) { - if (event && event.target.closest("panel")) { + if (event && event.target.closest("panel") == this.panelNode) { return this.mainButtonNode; } diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js index 8ab355b1d1ca..dea037baa16e 100755 --- a/browser/base/content/browser.js +++ b/browser/base/content/browser.js @@ -41,6 +41,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { NewTabUtils: "resource://gre/modules/NewTabUtils.jsm", PageActions: "resource:///modules/PageActions.jsm", PageThumbs: "resource://gre/modules/PageThumbs.jsm", + PanelView: "resource:///modules/PanelMultiView.jsm", PluralForm: "resource://gre/modules/PluralForm.jsm", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", ProcessHangMonitor: "resource:///modules/ProcessHangMonitor.jsm", @@ -7144,21 +7145,19 @@ var gIdentityHandler = { delete this._identityPopupMultiView; return this._identityPopupMultiView = document.getElementById("identity-popup-multiView"); }, + get _identityPopupMainView() { + delete this._identityPopupMainView; + return this._identityPopupMainView = document.getElementById("identity-popup-mainView"); + }, get _identityPopupContentHosts() { delete this._identityPopupContentHosts; - let selector = ".identity-popup-host"; - return this._identityPopupContentHosts = [ - ...this._identityPopupMultiView._mainView.querySelectorAll(selector), - ...document.querySelectorAll(selector) - ]; + return this._identityPopupContentHosts = + [...document.querySelectorAll(".identity-popup-host")]; }, get _identityPopupContentHostless() { delete this._identityPopupContentHostless; - let selector = ".identity-popup-hostless"; - return this._identityPopupContentHostless = [ - ...this._identityPopupMultiView._mainView.querySelectorAll(selector), - ...document.querySelectorAll(selector) - ]; + return this._identityPopupContentHostless = + [...document.querySelectorAll(".identity-popup-hostless")]; }, get _identityPopupContentOwner() { delete this._identityPopupContentOwner; @@ -7400,7 +7399,8 @@ var gIdentityHandler = { if (this._identityPopup.state == "open") { this.updateSitePermissions(); - this._identityPopupMultiView.descriptionHeightWorkaround(); + PanelView.forNode(this._identityPopupMainView) + .descriptionHeightWorkaround(); } }, @@ -8056,7 +8056,8 @@ var gIdentityHandler = { SitePermissions.remove(gBrowser.currentURI, aPermission.id, browser); this._permissionReloadHint.removeAttribute("hidden"); - this._identityPopupMultiView.descriptionHeightWorkaround(); + PanelView.forNode(this._identityPopupMainView) + .descriptionHeightWorkaround(); // Set telemetry values for clearing a permission let histogram = Services.telemetry.getKeyedHistogramById("WEB_PERMISSION_CLEARED"); diff --git a/browser/base/content/test/performance/browser_appmenu_reflows.js b/browser/base/content/test/performance/browser_appmenu_reflows.js index 602838c04ac9..ab9b2c31fc98 100644 --- a/browser/base/content/test/performance/browser_appmenu_reflows.js +++ b/browser/base/content/test/performance/browser_appmenu_reflows.js @@ -58,6 +58,7 @@ const EXPECTED_APPMENU_SUBVIEW_REFLOWS = [ { stack: [ "descriptionHeightWorkaround@resource:///modules/PanelMultiView.jsm", + "set current@resource:///modules/PanelMultiView.jsm", "hideAllViewsExcept@resource:///modules/PanelMultiView.jsm", ], diff --git a/browser/base/content/test/permissions/browser_reservedkey.js b/browser/base/content/test/permissions/browser_reservedkey.js index d932f7743188..f78441541121 100644 --- a/browser/base/content/test/permissions/browser_reservedkey.js +++ b/browser/base/content/test/permissions/browser_reservedkey.js @@ -10,7 +10,7 @@ add_task(async function test_reserved_shortcuts() { `; let container = document.createElement("box"); - container.innerHTML = keyset; + container.unsafeSetInnerHTML(keyset); document.documentElement.appendChild(container); /* eslint-enable no-unsanitized/property */ diff --git a/browser/base/content/test/urlbar/browser_page_action_menu.js b/browser/base/content/test/urlbar/browser_page_action_menu.js index 7e00a51bd04f..8c565a0f1727 100644 --- a/browser/base/content/test/urlbar/browser_page_action_menu.js +++ b/browser/base/content/test/urlbar/browser_page_action_menu.js @@ -131,6 +131,11 @@ add_task(async function copyURLFromPanel() { // does not appear on about:blank for example.) let url = "http://example.com/"; await BrowserTestUtils.withNewTab(url, async () => { + // Add action to URL bar. + let action = PageActions._builtInActions.find(a => a.id == "copyURL"); + action.pinnedToUrlbar = true; + registerCleanupFunction(() => action.pinnedToUrlbar = false); + // Open the panel and click Copy URL. await promisePageActionPanelOpen(); Assert.ok(true, "page action panel opened"); @@ -147,6 +152,8 @@ add_task(async function copyURLFromPanel() { Assert.equal(feedbackPanel.anchorNode.id, "pageActionButton", "Feedback menu should be anchored on the main Page Action button"); let feedbackHiddenPromise = promisePanelHidden("pageActionFeedback"); await feedbackHiddenPromise; + + action.pinnedToUrlbar = false; }); }); @@ -170,6 +177,8 @@ add_task(async function copyURLFromURLBar() { Assert.equal(panel.anchorNode.id, "pageAction-urlbar-copyURL", "Feedback menu should be anchored on the main URL bar button"); let feedbackHiddenPromise = promisePanelHidden("pageActionFeedback"); await feedbackHiddenPromise; + + action.pinnedToUrlbar = false; }); }); diff --git a/browser/components/customizableui/CustomizableWidgets.jsm b/browser/components/customizableui/CustomizableWidgets.jsm index 7a36754a22a4..1eda181536e5 100644 --- a/browser/components/customizableui/CustomizableWidgets.jsm +++ b/browser/components/customizableui/CustomizableWidgets.jsm @@ -14,6 +14,7 @@ Cu.import("resource://gre/modules/AppConstants.jsm"); XPCOMUtils.defineLazyModuleGetters(this, { BrowserUITelemetry: "resource:///modules/BrowserUITelemetry.jsm", + PanelView: "resource:///modules/PanelMultiView.jsm", PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", PlacesUIUtils: "resource:///modules/PlacesUIUtils.jsm", RecentlyClosedTabsAndWindowsMenuUtils: "resource:///modules/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm", @@ -366,8 +367,8 @@ const CustomizableWidgets = [ } } this._tabsList.appendChild(fragment); - let panelView = this._tabsList.closest("panelview"); - panelView.panelMultiView.descriptionHeightWorkaround(panelView); + PanelView.forNode(this._tabsList.closest("panelview")) + .descriptionHeightWorkaround(); }).catch(err => { Cu.reportError(err); }).then(() => { diff --git a/browser/components/customizableui/PanelMultiView.jsm b/browser/components/customizableui/PanelMultiView.jsm index 99e1a4966e7e..55475f1a2841 100644 --- a/browser/components/customizableui/PanelMultiView.jsm +++ b/browser/components/customizableui/PanelMultiView.jsm @@ -2,9 +2,49 @@ * 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 element should contain a element. Views are + * declared using elements that are usually children of the main + * 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 nodes move during navigation: + * + * In this 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 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"]; +this.EXPORTED_SYMBOLS = [ + "PanelMultiView", + "PanelView", +]; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; @@ -23,136 +63,37 @@ const TRANSITION_PHASES = Object.freeze({ END: 4 }); -/** - * Simple implementation of the sliding window pattern; panels are added to a - * linked list, in-order, and the currently shown panel is remembered using a - * marker. The marker shifts as navigation between panels is continued, where - * the panel at index 0 is always the starting point: - * ┌────┬────┬────┬────┐ - * │▓▓▓▓│ │ │ │ Start - * └────┴────┴────┴────┘ - * ┌────┬────┬────┬────┐ - * │ │▓▓▓▓│ │ │ Forward - * └────┴────┴────┴────┘ - * ┌────┬────┬────┬────┐ - * │ │ │▓▓▓▓│ │ Forward - * └────┴────┴────┴────┘ - * ┌────┬────┬────┬────┐ - * │ │▓▓▓▓│ │ │ Back - * └────┴────┴────┴────┘ - */ -class SlidingPanelViews extends Array { - constructor() { - super(); - this._marker = 0; - } - - /** - * Get the index that points to the currently selected view. - * - * @return {Number} - */ - get current() { - return this._marker; - } - - /** - * Setter for the current index, which changes the order of elements and - * updates the internal marker for the currently selected view. - * We're manipulating the array directly to have it reflect the order of - * navigation, instead of continuously growing the array with the next selected - * view to keep memory usage within reasonable proportions. With this method, - * the data structure grows no larger than the number of panels inside the - * panelMultiView. - * - * @param {Number} index Index of the item to move to the current position. - * @return {Number} The new marker index. - */ - set current(index) { - if (index == this._marker) { - // Never change a winning team. - return index; - } - if (index == -1 || index > (this.length - 1)) { - throw new Error(`SlidingPanelViews :: index ${index} out of bounds`); - } - - let view = this.splice(index, 1)[0]; - if (this._marker > index) { - // Correct the current marker if the view-to-select was removed somewhere - // before it. - --this._marker; - } - // Then add the view-to-select right after the currently selected view. - this.splice(++this._marker, 0, view); - return this._marker; - } - - /** - * Getter for the currently selected view node. - * - * @return {panelview} - */ - get currentView() { - return this[this._marker]; - } - - /** - * Setter for the currently selected view node. - * - * @param {panelview} view - * @return {Number} Index of the currently selected view. - */ - set currentView(view) { - if (!view) - return this.current; - // This will throw an error if the view could not be found. - return this.current = this.indexOf(view); - } - - /** - * Getter for the previous view, which is always positioned one position after - * the current view. - * - * @return {panelview} - */ - get previousView() { - return this[this._marker + 1]; - } - - /** - * Going back is an explicit action on the data structure, moving the marker - * one step back. - * - * @return {Array} A list of two items: the newly selected view and the previous one. - */ - back() { - if (this._marker > 0) - --this._marker; - return [this.currentView, this.previousView]; - } - - /** - * Reset the data structure to its original construct, removing all references - * to view nodes. - */ - clear() { - this._marker = 0; - this.splice(0, this.length); - } -} +let gNodeToObjectMap = new WeakMap(); +let gMultiLineElementsMap = new WeakMap(); /** - * This is the implementation of the panelUI.xml XBL binding, moved to this - * module, to make it easier to fork the logic for the newer photon structure. - * Goals are: - * 1. to make it easier to programmatically extend the list of panels, - * 2. allow for navigation between panels multiple levels deep and - * 3. maintain the pre-photon structure with as little effort possible. + * Allows associating an object to a node lazily using a weak map. * - * @type {PanelMultiView} + * Classes deriving from this one may be easily converted to Custom Elements, + * although they would lose the ability of being associated lazily. */ -this.PanelMultiView = class { +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; } @@ -161,22 +102,49 @@ this.PanelMultiView = class { 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 elements by the panelUI.xml binding. + */ +this.PanelMultiView = class extends this.AssociatedToNode { get _panel() { return this.node.parentNode; } - get showingSubView() { - return this._showingSubView; - } get _mainViewId() { return this.node.getAttribute("mainViewId"); } - set _mainViewId(val) { - this.node.setAttribute("mainViewId", val); - return val; - } get _mainView() { - return this._mainViewId ? this.document.getElementById(this._mainViewId) : null; + return this.document.getElementById(this._mainViewId); } get _transitioning() { @@ -200,20 +168,6 @@ this.PanelMultiView = class { return this.node.hasAttribute("ephemeral"); } - get panelViews() { - if (this._panelViews) - return this._panelViews; - - this._panelViews = new SlidingPanelViews(); - this._panelViews.push(...this.node.getElementsByTagName("panelview")); - return this._panelViews; - } - get _dwu() { - if (this.__dwu) - return this.__dwu; - return this.__dwu = this.window.QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIDOMWindowUtils); - } get _screenManager() { if (this.__screenManager) return this.__screenManager; @@ -226,13 +180,13 @@ this.PanelMultiView = class { * dispatched. */ get current() { - return this._viewShowing || this._currentSubView; + return this.node && (this._viewShowing || this._currentSubView); } get _currentSubView() { - return this.panelViews.currentView; - } - set _currentSubView(panel) { - this.panelViews.currentView = panel; + // 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 @@ -241,27 +195,15 @@ this.PanelMultiView = class { get currentShowPromise() { return this._currentShowPromise || Promise.resolve(); } - get _keyNavigationMap() { - if (!this.__keyNavigationMap) - this.__keyNavigationMap = new Map(); - return this.__keyNavigationMap; - } - get _multiLineElementsMap() { - if (!this.__multiLineElementsMap) - this.__multiLineElementsMap = new WeakMap(); - return this.__multiLineElementsMap; - } - constructor(xulNode, testMode = false) { - this.node = xulNode; - // If `testMode` is `true`, the consumer is only interested in accessing the - // methods of this instance. (E.g. in unit tests.) - if (testMode) - return; - - this._currentSubView = this._subViewObserver = null; + connect() { + this.knownViews = new Set(Array.from( + this.node.getElementsByTagName("panelview"), + node => PanelView.forNode(node))); + this.openViews = []; this._mainViewHeight = 0; - this.__transitioning = this._ignoreMutations = this._showingSubView = false; + this.__transitioning = false; + this.showingSubView = false; const {document, window} = this; @@ -285,29 +227,17 @@ this.PanelMultiView = class { // Set CSS-determined attributes now to prevent a layout flush when we do // it when transitioning between panels. this._dir = cs.direction; - this.setMainView(this.panelViews.currentView); this.showMainView(); - this._showingSubView = false; - // Proxy these public properties and methods, as used elsewhere by various // parts of the browser, to this instance. - ["_mainView", "ignoreMutations", "showingSubView", - "_panelViews"].forEach(property => { - Object.defineProperty(this.node, property, { - enumerable: true, - get: () => this[property], - set: (val) => this[property] = val - }); - }); - ["goBack", "descriptionHeightWorkaround", "setMainView", "showMainView", - "showSubView"].forEach(method => { + ["goBack", "showMainView", "showSubView"].forEach(method => { Object.defineProperty(this.node, method, { enumerable: true, value: (...args) => this[method](...args) }); }); - ["current", "currentShowPromise"].forEach(property => { + ["current", "currentShowPromise", "showingSubView"].forEach(property => { Object.defineProperty(this.node, property, { enumerable: true, get: () => this[property] @@ -331,7 +261,6 @@ this.PanelMultiView = class { } this._moveOutKids(this._viewStack); - this.panelViews.clear(); this._panel.removeEventListener("mousemove", this); this._panel.removeEventListener("popupshowing", this); this._panel.removeEventListener("popuppositioned", this); @@ -362,87 +291,20 @@ this.PanelMultiView = class { } } - _placeSubView(viewNode) { - this._viewStack.appendChild(viewNode); - if (!this.panelViews.includes(viewNode)) - this.panelViews.push(viewNode); - } - - _setHeader(viewNode, titleText) { - // If the header already exists, update or remove it as requested. - let header = viewNode.firstChild; - if (header && header.classList.contains("panel-header")) { - if (titleText) { - header.querySelector("label").setAttribute("value", titleText); - } else { - header.remove(); - } - return; - } - - // The header doesn't exist, only create it if needed. - if (!titleText) { - 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. - viewNode.panelMultiView.goBack(); - backButton.blur(); - }); - - let label = this.document.createElement("label"); - label.setAttribute("value", titleText); - - header.append(backButton, label); - viewNode.prepend(header); - } - goBack() { - let [current, previous] = this.panelViews.back(); - return this.showSubView(current, null, previous); - } - - /** - * Checks whether it is possible to navigate backwards currently. Returns - * false if this is the panelmultiview's mainview, true otherwise. - * - * @param {panelview} view View to check, defaults to the currently active view. - * @return {Boolean} - */ - _canGoBack(view = this._currentSubView) { - return view.id != this._mainViewId; - } - - setMainView(aNewMainView) { - if (!aNewMainView) + if (this.openViews.length < 2) { + // This may be called by keyboard navigation or external code when only + // the main view is open. return; + } - if (this._mainView) { - this._mainView.removeAttribute("mainview"); - } - this._mainViewId = aNewMainView.id; - aNewMainView.setAttribute("mainview", "true"); - // If the new main view is not yet in the zeroth position, make sure it's - // inserted there. - if (aNewMainView.parentNode != this._viewStack && - this._viewStack.firstChild != aNewMainView) { - this._viewStack.insertBefore(aNewMainView, this._viewStack.firstChild); - } + let previous = this.openViews.pop().node; + let current = this._currentSubView; + this.showSubView(current, null, previous); } showMainView() { - if (!this._mainViewId) + if (!this.node || !this._mainViewId) return Promise.resolve(); return this.showSubView(this._mainView); @@ -452,31 +314,27 @@ this.PanelMultiView = class { * Ensures that all the panelviews, that are currently part of this instance, * are hidden, except one specifically. * - * @param {panelview} [theOne] The panelview DOM node to ensure is visible. - * Optional. + * @param {panelview} [nextPanelView] + * The PanelView object to ensure is visible. Optional. */ - hideAllViewsExcept(theOne = null) { - for (let panelview of this._panelViews) { + hideAllViewsExcept(nextPanelView = null) { + for (let panelView of this.knownViews) { // When the panelview was already reparented, don't interfere any more. - if (panelview == theOne || !this.node || panelview.panelMultiView != this.node) + if (panelView == nextPanelView || !this.node || panelView.node.panelMultiView != this.node) continue; - if (panelview.hasAttribute("current")) - this._dispatchViewEvent(panelview, "ViewHiding"); - panelview.removeAttribute("current"); + panelView.current = false; } this._viewShowing = null; - if (!this.node || !theOne) + if (!this.node || !nextPanelView) return; - this._currentSubView = theOne; - if (!theOne.hasAttribute("current")) { - theOne.setAttribute("current", true); - this.descriptionHeightWorkaround(theOne); - this._dispatchViewEvent(theOne, "ViewShown"); - } - this._showingSubView = theOne.id != this._mainViewId; + if (!this.openViews.includes(nextPanelView)) + this.openViews.push(nextPanelView); + + nextPanelView.current = true; + this.showingSubView = nextPanelView.node.id != this._mainViewId; } showSubView(aViewId, aAnchor, aPreviousView) { @@ -486,20 +344,23 @@ this.PanelMultiView = class { if (!viewNode) { viewNode = this.document.getElementById(aViewId); if (viewNode) { - this._placeSubView(viewNode); + this._viewStack.appendChild(viewNode); } else { throw new Error(`Subview ${aViewId} doesn't exist!`); } } else if (viewNode.parentNode == this._panelViewCache) { - this._placeSubView(viewNode); + this._viewStack.appendChild(viewNode); } + let nextPanelView = PanelView.forNode(viewNode); + this.knownViews.add(nextPanelView); + viewNode.panelMultiView = this.node; let reverse = !!aPreviousView; if (!reverse) { - this._setHeader(viewNode, viewNode.getAttribute("title") || - (aAnchor && aAnchor.getAttribute("label"))); + nextPanelView.headerText = viewNode.getAttribute("title") || + (aAnchor && aAnchor.getAttribute("label")); } let previousViewNode = aPreviousView || this._currentSubView; @@ -509,17 +370,13 @@ this.PanelMultiView = class { let playTransition = (!!previousViewNode && !showingSameView && this._panel.state == "open"); let isMainView = viewNode.id == this._mainViewId; - let dwu = this._dwu; let previousRect = previousViewNode.__lastKnownBoundingRect = - dwu.getBoundsWithoutFlushing(previousViewNode); + 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; - let top = dwu.getBoundsWithoutFlushing(previousViewNode.firstChild || previousViewNode).top; - let bottom = dwu.getBoundsWithoutFlushing(previousViewNode.lastChild || previousViewNode).bottom; - this._viewVerticalPadding = previousRect.height - (bottom - top); } if (!this._mainViewHeight) { this._mainViewHeight = previousRect.height; @@ -530,10 +387,7 @@ this.PanelMultiView = class { // Because the 'mainview' attribute may be out-of-sync, due to view node // reparenting in combination with ephemeral PanelMultiView instances, // this is the best place to correct it (just before showing). - if (isMainView) - viewNode.setAttribute("mainview", true); - else - viewNode.removeAttribute("mainview"); + nextPanelView.mainview = isMainView; if (aAnchor) { viewNode.classList.add("PanelUI-subView"); @@ -551,7 +405,7 @@ this.PanelMultiView = class { this.blockers.add(promise); } }; - let cancel = this._dispatchViewEvent(viewNode, "ViewShowing", aAnchor, detail); + let cancel = nextPanelView.dispatchCustomEvent("ViewShowing", detail, true); if (detail.blockers.size) { try { let results = await Promise.all(detail.blockers); @@ -573,9 +427,9 @@ this.PanelMultiView = class { await this._cleanupTransitionPhase(); if (playTransition) { await this._transitionViews(previousViewNode, viewNode, reverse, previousRect, aAnchor); - this._updateKeyboardFocus(viewNode); + nextPanelView.focusSelectedElement(); } else { - this.hideAllViewsExcept(viewNode); + this.hideAllViewsExcept(nextPanelView); } })().catch(e => Cu.reportError(e)); return this._currentShowPromise; @@ -608,6 +462,8 @@ this.PanelMultiView = class { const {window, document} = this; + let nextPanelView = PanelView.forNode(viewNode); + if (this._autoResizeWorkaroundTimer) window.clearTimeout(this._autoResizeWorkaroundTimer); @@ -657,7 +513,7 @@ this.PanelMultiView = class { // Now that the subview is visible, we can check the height of the // description elements it contains. - this.descriptionHeightWorkaround(viewNode); + nextPanelView.descriptionHeightWorkaround(); viewRect = await BrowserUtils.promiseLayoutFlushed(this.document, "layout", () => { return this._dwu.getBoundsWithoutFlushing(viewNode); @@ -755,13 +611,16 @@ this.PanelMultiView = class { 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(viewNode); + this.hideAllViewsExcept(nextPanelView); previousViewNode.removeAttribute("in-transition"); viewNode.removeAttribute("in-transition"); if (reverse) - this._resetKeyNavigation(previousViewNode); + prevPanelView.clearNavigation(); if (anchor) anchor.removeAttribute("open"); @@ -807,35 +666,6 @@ this.PanelMultiView = class { } } - /** - * Helper method to emit an event on a panelview, whilst also making sure that - * the correct method is called on CustomizableWidget instances. - * - * @param {panelview} viewNode Target of the event to dispatch. - * @param {String} eventName Name of the event to dispatch. - * @param {DOMNode} [anchor] Node where the panel is anchored to. Optional. - * @param {Object} [detail] Event detail object. Optional. - * @return {Boolean} `true` if the event was canceled by an event handler, `false` - * otherwise. - */ - _dispatchViewEvent(viewNode, eventName, anchor, detail) { - let cancel = false; - if (eventName != "PanelMultiViewHidden") { - // Don't need to do this for PanelMultiViewHidden event - CustomizableUI.ensureSubviewListeners(viewNode); - } - - let evt = new this.window.CustomEvent(eventName, { - detail, - bubbles: true, - cancelable: eventName == "ViewShowing" - }); - viewNode.dispatchEvent(evt); - if (!cancel) - cancel = evt.defaultPrevented; - return cancel; - } - _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 @@ -882,14 +712,17 @@ this.PanelMultiView = class { } switch (aEvent.type) { case "keydown": - this._keyNavigation(aEvent); + if (!this._transitioning) { + PanelView.forNode(this._currentSubView) + .keyNavigation(aEvent, this._dir); + } break; case "mousemove": - this._resetKeyNavigation(); + this.openViews.forEach(panelView => panelView.clearNavigation()); break; case "popupshowing": { this.node.setAttribute("panelopen", "true"); - if (this.panelViews && !this.node.hasAttribute("disablekeynav")) { + if (!this.node.hasAttribute("disablekeynav")) { this.window.addEventListener("keydown", this); this._panel.addEventListener("mousemove", this); } @@ -915,7 +748,7 @@ this.PanelMultiView = class { case "popupshown": // Now that the main view is visible, we can check the height of the // description elements it contains. - this.descriptionHeightWorkaround(); + PanelView.forNode(this._mainView).descriptionHeightWorkaround(); break; case "popuphidden": { // WebExtensions consumers can hide the popup from viewshowing, or @@ -933,7 +766,8 @@ this.PanelMultiView = class { } this.window.removeEventListener("keydown", this); this._panel.removeEventListener("mousemove", this); - this._resetKeyNavigation(); + 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. @@ -944,24 +778,208 @@ this.PanelMultiView = class { this._viewContainer.style.removeProperty("min-width"); this._viewContainer.style.removeProperty("max-width"); - this._dispatchViewEvent(this.node, "PanelMultiViewHidden"); + this.dispatchCustomEvent("PanelMultiViewHidden"); break; } } } +}; + +/** + * This is associated to 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 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"); + } + } /** - * Based on going up or down, select the previous or next focusable button - * in the current view. + * 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. * - * @param {Object} navMap the navigation keyboard map object for the view - * @param {Array} buttons an array of focusable buttons to select an item from. - * @param {Boolean} isDown whether we're going down (true) or up (false) in this view. + * 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