Bug 1340987 - (Part 2) Make Preference SubDialog stackable. r=MattN
authorScott Wu <scottcwwu@gmail.com>
Wed, 12 Apr 2017 16:13:50 +0800
changeset 410604 2eca7036eb36289051ca5847b45bad7df07c5a4f
parent 410603 e843dab00aa729cba50201b31011a8b50f4c4e29
child 410605 7860182ed49d0cfeb73ad00063663904262ea824
push id7391
push usermtabara@mozilla.com
push dateMon, 12 Jun 2017 13:08:53 +0000
treeherdermozilla-beta@2191d7f87e2e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1340987
milestone55.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1340987 - (Part 2) Make Preference SubDialog stackable. r=MattN MozReview-Commit-ID: EHVjC50s0bO
browser/components/preferences/in-content-new/preferences.xul
browser/components/preferences/in-content-new/subdialogs.js
browser/themes/shared/incontentprefs/preferences.inc.css
--- a/browser/components/preferences/in-content-new/preferences.xul
+++ b/browser/components/preferences/in-content-new/preferences.xul
@@ -212,28 +212,27 @@
 #include containers.xul
 #include advanced.xul
 #include applications.xul
 #include sync.xul
       </prefpane>
     </vbox>
   </hbox>
 
-    <vbox id="dialogOverlay" align="center" pack="center">
-      <groupbox id="dialogBox"
-                orient="vertical"
-                pack="end"
-                role="dialog"
-                aria-labelledby="dialogTitle">
-        <caption flex="1" align="center">
-          <label id="dialogTitle" flex="1"></label>
-          <button id="dialogClose"
-                  class="close-icon"
-                  aria-label="&preferencesCloseButton.label;"/>
-        </caption>
-        <browser id="dialogFrame"
-                 name="dialogFrame"
-                 autoscroll="false"
-                 disablehistory="true"/>
-      </groupbox>
-    </vbox>
+  <stack id="dialogStack" hidden="true"/>
+  <vbox id="dialogTemplate" class="dialogOverlay" align="center" pack="center" topmost="true" hidden="true">
+    <groupbox class="dialogBox"
+              orient="vertical"
+              pack="end"
+              role="dialog"
+              aria-labelledby="dialogTitle">
+      <caption flex="1" align="center">
+        <label class="dialogTitle" flex="1"></label>
+        <button class="dialogClose close-icon"
+                aria-label="&preferencesCloseButton.label;"/>
+      </caption>
+      <browser class="dialogFrame"
+               autoscroll="false"
+               disablehistory="true"/>
+    </groupbox>
+  </vbox>
   </stack>
 </page>
--- a/browser/components/preferences/in-content-new/subdialogs.js
+++ b/browser/components/preferences/in-content-new/subdialogs.js
@@ -2,74 +2,96 @@
    - 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/. */
 
 /* import-globals-from ../../../base/content/utilityOverlay.js */
 /* import-globals-from preferences.js */
 
 "use strict";
 
-var gSubDialog = {
+/**
+ * SubDialog constructor creates a new subdialog from a template and appends
+ * it to the parentElement.
+ * @param {DOMNode} template: The template is copied to create a new dialog.
+ * @param {DOMNode} parentElement: New dialog is appended onto parentElement.
+ * @param {String}  id: A unique identifier for the dialog.
+ */
+function SubDialog({template, parentElement, id}) {
+  this._id = id;
+
+  this._overlay = template.cloneNode(true);
+  this._frame = this._overlay.querySelector(".dialogFrame");
+  this._box = this._overlay.querySelector(".dialogBox");
+  this._closeButton = this._overlay.querySelector(".dialogClose");
+  this._titleElement = this._overlay.querySelector(".dialogTitle");
+
+  this._overlay.id = `dialogOverlay-${id}`;
+  this._frame.setAttribute("name", `dialogFrame-${id}`);
+  this._frameCreated = new Promise(resolve => {
+    this._frame.addEventListener("load", resolve, {once: true});
+  });
+
+  parentElement.appendChild(this._overlay);
+  this._overlay.hidden = false;
+}
+
+SubDialog.prototype = {
   _closingCallback: null,
   _closingEvent: null,
   _isClosing: false,
   _frame: null,
+  _frameCreated: null,
   _overlay: null,
   _box: null,
   _openedURL: null,
   _injectedStyleSheets: [
     "chrome://browser/skin/preferences/preferences.css",
     "chrome://global/skin/in-content/common.css",
     "chrome://browser/skin/preferences/in-content-new/preferences.css",
     "chrome://browser/skin/preferences/in-content-new/dialog.css",
   ],
   _resizeObserver: null,
-
-  init() {
-    this._frame = document.getElementById("dialogFrame");
-    this._overlay = document.getElementById("dialogOverlay");
-    this._box = document.getElementById("dialogBox");
-    this._closeButton = document.getElementById("dialogClose");
-  },
+  _template: null,
+  _id: null,
+  _titleElement: null,
+  _closeButton: null,
 
   updateTitle(aEvent) {
     if (aEvent.target != this._frame.contentDocument)
       return;
-    document.getElementById("dialogTitle").textContent = this._frame.contentDocument.title;
+    this._titleElement.textContent = this._frame.contentDocument.title;
   },
 
   injectXMLStylesheet(aStylesheetURL) {
     let contentStylesheet = this._frame.contentDocument.createProcessingInstruction(
       "xml-stylesheet",
       'href="' + aStylesheetURL + '" type="text/css"'
     );
     this._frame.contentDocument.insertBefore(contentStylesheet,
                                              this._frame.contentDocument.documentElement);
   },
 
-  open(aURL, aFeatures = null, aParams = null, aClosingCallback = null) {
-    // If we're already open/opening on this URL, do nothing.
-    if (this._openedURL == aURL && !this._isClosing) {
-      return;
-    }
+  async open(aURL, aFeatures = null, aParams = null, aClosingCallback = null) {
+    // Wait until frame is ready to prevent browser crash in tests
+    await this._frameCreated;
     // If we're open on some (other) URL or we're closing, open when closing has finished.
     if (this._openedURL || this._isClosing) {
       if (!this._isClosing) {
         this.close();
       }
       let args = Array.from(arguments);
       this._closingPromise.then(() => {
         this.open.apply(this, args);
       });
       return;
     }
     this._addDialogEventListeners();
 
     let features = (aFeatures ? aFeatures + "," : "") + "resizable,dialog=no,centerscreen";
-    let dialog = window.openDialog(aURL, "dialogFrame", features, aParams);
+    let dialog = window.openDialog(aURL, `dialogFrame-${this._id}`, features, aParams);
     if (aClosingCallback) {
       this._closingCallback = aClosingCallback.bind(dialog);
     }
 
     this._closingEvent = null;
     this._isClosing = false;
     this._openedURL = aURL;
 
@@ -104,16 +126,21 @@ var gSubDialog = {
     // Clear the sizing inline styles.
     this._frame.removeAttribute("style");
     // Clear the sizing attributes
     this._box.removeAttribute("width");
     this._box.removeAttribute("height");
     this._box.style.removeProperty("min-height");
     this._box.style.removeProperty("min-width");
 
+    this._overlay.dispatchEvent(new CustomEvent("dialogclose", {
+      bubbles: true,
+      detail: { dialog: this },
+    }));
+
     setTimeout(() => {
       // Unload the dialog after the event listeners run so that the load of about:blank isn't
       // cancelled by the ESC <key>.
       let onBlankLoad = e => {
         if (this._frame.contentWindow.location.href == "about:blank") {
           this._frame.removeEventListener("load", onBlankLoad);
           // We're now officially done closing, so update the state to reflect that.
           this._openedURL = null;
@@ -288,16 +315,20 @@ var gSubDialog = {
       }
     }
 
     this._frame.style.height = frameHeight;
     this._box.style.minHeight = "calc(" +
                                 (boxVerticalBorder + groupBoxTitleHeight + boxVerticalPadding) +
                                 "px + " + frameMinHeight + ")";
 
+    this._overlay.dispatchEvent(new CustomEvent("dialogopen", {
+      bubbles: true,
+      detail: { dialog: this },
+    }));
     this._overlay.style.visibility = "visible";
     this._overlay.style.opacity = ""; // XXX: focus hack continued from _onContentLoaded
 
     if (this._box.getAttribute("resizable") == "true") {
       this._resizeObserver = new MutationObserver(this._onResize);
       this._resizeObserver.observe(this._box, {attributes: true});
     }
 
@@ -444,8 +475,109 @@ var gSubDialog = {
 
   _getBrowser() {
     return window.QueryInterface(Ci.nsIInterfaceRequestor)
                  .getInterface(Ci.nsIWebNavigation)
                  .QueryInterface(Ci.nsIDocShell)
                  .chromeEventHandler;
   },
 };
+
+var gSubDialog = {
+  /**
+   * New dialogs are stacked on top of the existing ones, and they are pushed
+   * to the end of the _dialogs array.
+   * @type {Array}
+   */
+  _dialogs: [],
+  _dialogStack: null,
+  _dialogTemplate: null,
+  _nextDialogID: 0,
+  _preloadDialog: null,
+  get _topDialog() {
+    return this._dialogs.length > 0 ? this._dialogs[this._dialogs.length - 1] : undefined;
+  },
+
+  init() {
+    this._dialogStack = document.getElementById("dialogStack");
+    this._dialogTemplate = document.getElementById("dialogTemplate");
+    this._preloadDialog = new SubDialog({template: this._dialogTemplate,
+                                         parentElement: this._dialogStack,
+                                         id: this._nextDialogID++});
+  },
+
+  open(aURL, aFeatures = null, aParams = null, aClosingCallback = null) {
+    // If we're already open/opening on this URL, do nothing.
+    if (this._topDialog && this._topDialog._openedURL == aURL) {
+      return;
+    }
+
+    this._preloadDialog.open(aURL, aFeatures, aParams, aClosingCallback);
+    this._dialogs.push(this._preloadDialog);
+    this._preloadDialog = new SubDialog({template: this._dialogTemplate,
+                                         parentElement: this._dialogStack,
+                                         id: this._nextDialogID++});
+
+    if (this._dialogs.length == 1) {
+      this._dialogStack.hidden = false;
+      this._ensureStackEventListeners();
+    }
+  },
+
+  close() {
+    this._topDialog.close();
+  },
+
+  handleEvent(aEvent) {
+    switch (aEvent.type) {
+      case "dialogopen": {
+        this._onDialogOpen();
+        break;
+      }
+      case "dialogclose": {
+        this._onDialogClose(aEvent.detail.dialog);
+        break;
+      }
+    }
+  },
+
+  _onDialogOpen() {
+    let lowerDialog = this._dialogs.length > 1 ? this._dialogs[this._dialogs.length - 2] : undefined;
+    if (lowerDialog) {
+      lowerDialog._overlay.removeAttribute("topmost");
+      lowerDialog._removeDialogEventListeners();
+    }
+  },
+
+  _onDialogClose(dialog) {
+    let fm = Services.focus;
+    if (this._topDialog == dialog) {
+      // XXX: When a top-most dialog is closed, we reuse the closed dialog and
+      //      remove the preloadDialog. This is a temporary solution before we
+      //      rewrite all the test cases in Bug 1359023.
+      this._preloadDialog._overlay.remove();
+      this._preloadDialog = this._dialogs.pop();
+    } else {
+      dialog._overlay.remove();
+      this._dialogs.splice(this._dialogs.indexOf(dialog), 1);
+    }
+
+    if (this._topDialog) {
+      fm.moveFocus(this._topDialog._frame.contentWindow, null, fm.MOVEFOCUS_FIRST, fm.FLAG_BYKEY);
+      this._topDialog._overlay.setAttribute("topmost", true);
+      this._topDialog._addDialogEventListeners();
+    } else {
+      fm.moveFocus(window, null, fm.MOVEFOCUS_ROOT, fm.FLAG_BYKEY);
+      this._dialogStack.hidden = true;
+      this._removeStackEventListeners();
+    }
+  },
+
+  _ensureStackEventListeners() {
+    this._dialogStack.addEventListener("dialogopen", this);
+    this._dialogStack.addEventListener("dialogclose", this);
+  },
+
+  _removeStackEventListeners() {
+    this._dialogStack.removeEventListener("dialogopen", this);
+    this._dialogStack.removeEventListener("dialogclose", this);
+  },
+};
--- a/browser/themes/shared/incontentprefs/preferences.inc.css
+++ b/browser/themes/shared/incontentprefs/preferences.inc.css
@@ -289,70 +289,73 @@ description > html|a {
 #showUpdateHistory {
   margin-inline-start: 0;
 }
 
 /**
  * Dialog
  */
 
-#dialogOverlay {
-  background-color: rgba(0,0,0,0.5);
+.dialogOverlay {
   visibility: hidden;
 }
 
-#dialogBox {
+.dialogOverlay[topmost="true"] {
+  background-color: rgba(0,0,0,0.5);
+}
+
+.dialogBox {
   background-color: #fbfbfb;
   background-clip: content-box;
   color: #424e5a;
   font-size: 14px;
   /* `transparent` will use the dialogText color in high-contrast themes and
      when page colors are disabled */
   border: 1px solid transparent;
   border-radius: 3.5px;
   box-shadow: 0 2px 6px 0 rgba(0,0,0,0.3);
   display: -moz-box;
   margin: 0;
   padding: 0;
 }
 
-#dialogBox[resizable="true"] {
+.dialogBox[resizable="true"] {
   resize: both;
   overflow: hidden;
   min-height: 20em;
   min-width: 66ch;
 }
 
-#dialogBox > .groupbox-title {
+.dialogBox > .groupbox-title {
   padding: 3.5px 0;
   background-color: #F1F1F1;
   border-bottom: 1px solid #C1C1C1;
 }
 
-#dialogTitle {
+.dialogTitle {
   text-align: center;
   -moz-user-select: none;
 }
 
 .close-icon {
   background-color: transparent !important;
   border: none;
   box-shadow: none;
   padding: 0;
   height: auto;
   min-height: 16px;
   min-width: 0;
 }
 
-#dialogBox > .groupbox-body {
+.dialogBox > .groupbox-body {
   -moz-appearance: none;
   padding: 20px;
 }
 
-#dialogFrame {
+.dialogFrame {
   -moz-box-flex: 1;
   /* Default dialog dimensions */
   width: 66ch;
 }
 
 .largeDialogContainer.doScroll {
   overflow-y: auto;
   -moz-box-flex: 1;