Bug 1340987 - (Part 2) Make Preference SubDialog stackable. r=MattN draft
authorScott Wu <scottcwwu@gmail.com>
Wed, 12 Apr 2017 16:13:50 +0800
changeset 588996 9c5da231133291449ffd1869056a18c32e3a89a2
parent 588995 90623590a69caa54fde1d4df03a05922b8cf82be
child 588997 321243e3a5b5d923bf97f38e2fe095864c6d910f
push id62215
push userbmo:scwwu@mozilla.com
push dateMon, 05 Jun 2017 11:59:07 +0000
reviewersMattN
bugs1340987
milestone55.0a1
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;