Bug 1435871 - Implement a basic tab-modal dialog container for Payment Request. r=jaws
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Sat, 20 Oct 2018 03:39:32 +0000
changeset 500732 a1db0baedde03d00e96ddd577b8d3672f115a726
parent 500731 24a03346eaf5e5306a1c4d983e30c5102629560c
child 500733 d0f1450799b56b9960ad6df2da432a5fd5849417
push id1864
push userffxbld-merge
push dateMon, 03 Dec 2018 15:51:40 +0000
treeherdermozilla-release@f040763d99ad [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1435871
milestone64.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 1435871 - Implement a basic tab-modal dialog container for Payment Request. r=jaws Differential Revision: https://phabricator.services.mozilla.com/D7934
browser/base/content/tabbrowser.js
browser/components/payments/content/paymentDialogWrapper.js
browser/components/payments/content/paymentDialogWrapper.xul
browser/components/payments/paymentUIService.js
browser/components/payments/res/containers/payment-dialog.js
browser/components/payments/res/paymentRequest.js
browser/components/payments/test/PaymentTestUtils.jsm
browser/components/payments/test/browser/browser.ini
browser/components/payments/test/browser/browser_dropdowns.js
browser/components/payments/test/browser/browser_show_dialog.js
browser/components/payments/test/browser/head.js
toolkit/components/prompts/content/tabprompts.css
toolkit/themes/osx/global/tabprompts.css
toolkit/themes/windows/global/tabprompts.css
--- a/browser/base/content/tabbrowser.js
+++ b/browser/base/content/tabbrowser.js
@@ -1089,18 +1089,23 @@ window._gBrowser = {
       return;
 
     let newBrowser = this.getBrowserForTab(newTab);
 
     // If there's a tabmodal prompt showing, focus it.
     if (newBrowser.hasAttribute("tabmodalPromptShowing")) {
       let prompts = newBrowser.parentNode.getElementsByTagNameNS(this._XUL_NS, "tabmodalprompt");
       let prompt = prompts[prompts.length - 1];
-      prompt.Dialog.setDefaultFocus();
-      return;
+      // @tabmodalPromptShowing is also set for other tab modal prompts
+      // (e.g. the Payment Request dialog) so there may not be a <tabmodalprompt>.
+      // Bug 1492814 will implement this for the Payment Request dialog.
+      if (prompt) {
+        prompt.Dialog.setDefaultFocus();
+        return;
+      }
     }
 
     // Focus the location bar if it was previously focused for that tab.
     // In full screen mode, only bother making the location bar visible
     // if the tab is a blank one.
     if (newBrowser._urlbarFocused && gURLBar) {
       // Explicitly close the popup if the URL bar retains focus
       gURLBar.closePopup();
--- a/browser/components/payments/content/paymentDialogWrapper.js
+++ b/browser/components/payments/content/paymentDialogWrapper.js
@@ -7,16 +7,19 @@
  * own scope.
  */
 
 "use strict";
 
 const paymentSrv = Cc["@mozilla.org/dom/payments/payment-request-service;1"]
                      .getService(Ci.nsIPaymentRequestService);
 
+const paymentUISrv = Cc["@mozilla.org/dom/payments/payment-ui-service;1"]
+                     .getService(Ci.nsIPaymentUIService);
+
 ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 ChromeUtils.defineModuleGetter(this, "MasterPassword",
                                "resource://formautofill/MasterPassword.jsm");
 ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
                                "resource://gre/modules/PrivateBrowsingUtils.jsm");
@@ -212,17 +215,17 @@ var paymentDialogWrapper = {
     this.mm.addMessageListener("paymentContentToChrome", this);
     this.mm.loadFrameScript("chrome://payments/content/paymentDialogFrameScript.js", true);
     // Until we have bug 1446164 and bug 1407418 we use form autofill's temporary
     // shim for data-localization* attributes.
     this.mm.loadFrameScript("chrome://formautofill/content/l10n.js", true);
     if (AppConstants.platform == "win") {
       this.frame.setAttribute("selectmenulist", "ContentSelectDropdown-windows");
     }
-    this.frame.loadURI("resource://payments/paymentRequest.xhtml");
+    this.frame.setAttribute("src", "resource://payments/paymentRequest.xhtml");
 
     this.temporaryStore = {
       addresses: new TempCollection("addresses"),
       creditCards: new TempCollection("creditCards"),
     };
   },
 
   createShowResponse({
@@ -448,17 +451,17 @@ var paymentDialogWrapper = {
     }
     return obj;
   },
 
   async initializeFrame() {
     Services.obs.addObserver(this, "formautofill-storage-changed", true);
 
     let requestSerialized = this._serializeRequest(this.request);
-    let chromeWindow = Services.wm.getMostRecentWindow("navigator:browser");
+    let chromeWindow = window.frameElement.ownerGlobal;
     let isPrivate = PrivateBrowsingUtils.isWindowPrivate(chromeWindow);
 
     let [savedAddresses, savedBasicCards] =
       await Promise.all([this.fetchSavedAddresses(), this.fetchSavedPaymentCards()]);
 
     this.sendMessageToContent("showPaymentRequest", {
       request: requestSerialized,
       savedAddresses,
@@ -470,38 +473,38 @@ var paymentDialogWrapper = {
   },
 
   debugFrame() {
     // To avoid self-XSS-type attacks, ensure that Browser Chrome debugging is enabled.
     if (!Services.prefs.getBoolPref("devtools.chrome.enabled", false)) {
       Cu.reportError("devtools.chrome.enabled must be enabled to debug the frame");
       return;
     }
-    let chromeWindow = Services.wm.getMostRecentWindow(null);
     let {
       gDevToolsBrowser,
     } = ChromeUtils.import("resource://devtools/client/framework/gDevTools.jsm", {});
     gDevToolsBrowser.openContentProcessToolbox({
-      selectedBrowser: chromeWindow.document.getElementById("paymentRequestFrame").frameLoader,
+      selectedBrowser: document.getElementById("paymentRequestFrame").frameLoader,
     });
   },
 
   onOpenPreferences() {
     let prefsURL = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
     prefsURL.data = "about:preferences#privacy-form-autofill";
     Services.ww.openWindow(null, AppConstants.BROWSER_CHROME_URL, "_blank", "chrome,all,dialog=no",
                            prefsURL);
   },
 
   onPaymentCancel() {
     const showResponse = this.createShowResponse({
       acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_REJECTED,
     });
+
     paymentSrv.respondPayment(showResponse);
-    window.close();
+    paymentUISrv.closePayment(this.request.requestId);
   },
 
   async onPay({
     selectedPayerAddressGUID: payerGUID,
     selectedPaymentCardGUID: paymentCardGUID,
     selectedPaymentCardSecurityCode: cardSecurityCode,
     selectedShippingAddressGUID: shippingGUID,
   }) {
@@ -580,17 +583,17 @@ var paymentDialogWrapper = {
     // the dialog before the onChangeShippingOption is called, thus
     // deleting the request and making the requestId invalid. Unclear
     // why we aren't seeing the same issue with onChangeShippingAddress.
     paymentSrv.changeShippingOption(this.request.requestId, optionID);
   },
 
   onCloseDialogMessage() {
     // The PR is complete(), just close the dialog
-    window.close();
+    paymentUISrv.closePayment(this.request.requestId);
   },
 
   async onUpdateAutofillRecord(collectionName, record, guid, messageID) {
     let responseMessage = {
       guid,
       messageID,
       stateChange: {},
     };
@@ -693,24 +696,33 @@ var paymentDialogWrapper = {
       case "openPreferences": {
         this.onOpenPreferences();
         break;
       }
       case "paymentCancel": {
         this.onPaymentCancel();
         break;
       }
+      case "paymentDialogReady": {
+        window.dispatchEvent(new Event("tabmodaldialogready", {
+          bubbles: true,
+        }));
+        break;
+      }
       case "pay": {
         this.onPay(data);
         break;
       }
       case "updateAutofillRecord": {
         this.onUpdateAutofillRecord(data.collectionName, data.record, data.guid, data.messageID);
         break;
       }
+      default: {
+        throw new Error(`paymentDialogWrapper: Unexpected messageType: ${messageType}`);
+      }
     }
   },
 };
 
 if ("document" in this) {
   // Running in a browser, not a unit test
   let frame = document.getElementById("paymentRequestFrame");
   let requestId = (new URLSearchParams(window.location.search)).get("requestId");
--- a/browser/components/payments/content/paymentDialogWrapper.xul
+++ b/browser/components/payments/content/paymentDialogWrapper.xul
@@ -33,12 +33,12 @@
   </popupset>
 
   <browser type="content"
            id="paymentRequestFrame"
            disablehistory="true"
            nodefaultsrc="true"
            remote="true"
            selectmenulist="ContentSelectDropdown"
-           style="height:500px;width:600px"
+           style="height:100vh;width:100vw"
            transparent="true"></browser>
   <script type="application/javascript" src="chrome://payments/content/paymentDialogWrapper.js"></script>
 </window>
--- a/browser/components/payments/paymentUIService.js
+++ b/browser/components/payments/paymentUIService.js
@@ -10,18 +10,22 @@
  * PaymentUIService is started by the DOM code lazily.
  *
  * For now the UI is shown in a native dialog but that is likely to change.
  * Tests should try to avoid relying on that implementation detail.
  */
 
 "use strict";
 
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
-ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(this, "BrowserWindowTracker",
+                               "resource:///modules/BrowserWindowTracker.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this,
                                    "paymentSrv",
                                    "@mozilla.org/dom/payments/payment-request-service;1",
                                    "nsIPaymentRequestService");
 
 function PaymentUIService() {
   this.wrappedJSObject = this;
@@ -40,20 +44,54 @@ PaymentUIService.prototype = {
   QueryInterface: ChromeUtils.generateQI([Ci.nsIPaymentUIService]),
   DIALOG_URL: "chrome://payments/content/paymentDialogWrapper.xul",
   REQUEST_ID_PREFIX: "paymentRequest-",
 
   // nsIPaymentUIService implementation:
 
   showPayment(requestId) {
     this.log.debug("showPayment:", requestId);
-    let chromeWindow = Services.wm.getMostRecentWindow("navigator:browser");
-    chromeWindow.openDialog(`${this.DIALOG_URL}?requestId=${requestId}`,
-                            `${this.REQUEST_ID_PREFIX}${requestId}`,
-                            "modal,dialog,centerscreen,resizable=no");
+    let request = paymentSrv.getPaymentRequestById(requestId);
+    let merchantBrowser = this.findBrowserByTabId(request.tabId);
+    let chromeWindow = merchantBrowser.ownerGlobal;
+    let {gBrowser} = chromeWindow;
+    let browserContainer = gBrowser.getBrowserContainer(merchantBrowser);
+    let container = chromeWindow.document.createElementNS(XHTML_NS, "div");
+    container.dataset.requestId = requestId;
+    container.classList.add("paymentDialogContainer");
+    container.hidden = true;
+    let paymentsBrowser = chromeWindow.document.createElementNS(XHTML_NS, "iframe");
+    paymentsBrowser.classList.add("paymentDialogContainerFrame");
+    paymentsBrowser.setAttribute("type", "content");
+    paymentsBrowser.setAttribute("remote", "true");
+    paymentsBrowser.setAttribute("src", `${this.DIALOG_URL}?requestId=${requestId}`);
+    // append the frame to start the loading
+    container.appendChild(paymentsBrowser);
+    browserContainer.prepend(container);
+
+    // Only show the frame and change the UI when the dialog is ready to show.
+    paymentsBrowser.addEventListener("tabmodaldialogready", function readyToShow() {
+      if (!container) {
+        // The dialog was closed by the DOM code before it was ready to be shown.
+        return;
+      }
+      container.hidden = false;
+
+      // Prevent focusing or interacting with the <browser>.
+      merchantBrowser.setAttribute("tabmodalPromptShowing", "true");
+
+      // Darken the merchant content area.
+      let tabModalBackground = chromeWindow.document.createElement("box");
+      tabModalBackground.classList.add("tabModalBackground", "paymentDialogBackground");
+      // Insert the same way as <tabmodalprompt>.
+      merchantBrowser.parentNode.insertBefore(tabModalBackground,
+                                              merchantBrowser.nextElementSibling);
+    }, {
+      once: true,
+    });
   },
 
   abortPayment(requestId) {
     this.log.debug("abortPayment:", requestId);
     let abortResponse = Cc["@mozilla.org/dom/payments/payment-abort-action-response;1"]
                           .createInstance(Ci.nsIPaymentAbortActionResponse);
     let found = this.closeDialog(requestId);
 
@@ -76,76 +114,107 @@ PaymentUIService.prototype = {
     switch (completeStatus) {
       case "fail":
       case "timeout":
         break;
       default:
         closed = this.closeDialog(requestId);
         break;
     }
+
+    let dialogContainer;
+    if (!closed) {
+      // We need to call findDialog before we respond below as getPaymentRequestById
+      // may fail due to the request being removed upon completion.
+      dialogContainer = this.findDialog(requestId).dialogContainer;
+      if (!dialogContainer) {
+        this.log.error("completePayment: no dialog found");
+        return;
+      }
+    }
+
     let responseCode = closed ?
         Ci.nsIPaymentActionResponse.COMPLETE_SUCCEEDED :
         Ci.nsIPaymentActionResponse.COMPLETE_FAILED;
     let completeResponse = Cc["@mozilla.org/dom/payments/payment-complete-action-response;1"]
                              .createInstance(Ci.nsIPaymentCompleteActionResponse);
     completeResponse.init(requestId, responseCode);
     paymentSrv.respondPayment(completeResponse.QueryInterface(Ci.nsIPaymentActionResponse));
 
     if (!closed) {
-      let dialog = this.findDialog(requestId);
-      if (!dialog) {
-        this.log.error("completePayment: no dialog found");
-        return;
-      }
-      dialog.paymentDialogWrapper.updateRequest();
+      dialogContainer.querySelector("iframe").contentWindow.paymentDialogWrapper.updateRequest();
     }
   },
 
   updatePayment(requestId) {
-    let dialog = this.findDialog(requestId);
+    let {dialogContainer} = this.findDialog(requestId);
     this.log.debug("updatePayment:", requestId);
-    if (!dialog) {
+    if (!dialogContainer) {
       this.log.error("updatePayment: no dialog found");
       return;
     }
-    dialog.paymentDialogWrapper.updateRequest();
+    dialogContainer.querySelector("iframe").contentWindow.paymentDialogWrapper.updateRequest();
   },
 
   closePayment(requestId) {
     this.closeDialog(requestId);
   },
 
   // other helper methods
 
   /**
    * @param {string} requestId - Payment Request ID of the dialog to close.
    * @returns {boolean} whether the specified dialog was closed.
    */
   closeDialog(requestId) {
-    let win = this.findDialog(requestId);
-    if (!win) {
+    let {
+      browser,
+      dialogContainer,
+    } = this.findDialog(requestId);
+    if (!dialogContainer) {
       return false;
     }
-    this.log.debug(`closing: ${win.name}`);
-    win.close();
+    this.log.debug(`closing: ${requestId}`);
+    dialogContainer.remove();
+    if (!dialogContainer.hidden) {
+      // If the container is no longer hidden then the background was added after
+      // `tabmodaldialogready` so remove it.
+      browser.parentElement.querySelector(".paymentDialogBackground").remove();
+
+      if (!browser.tabModalPromptBox || browser.tabModalPromptBox.listPrompts().length == 0) {
+        browser.removeAttribute("tabmodalPromptShowing");
+      }
+    }
     return true;
   },
 
   findDialog(requestId) {
-    for (let win of Services.wm.getEnumerator(null)) {
-      if (win.name == `${this.REQUEST_ID_PREFIX}${requestId}`) {
-        return win;
+    for (let win of BrowserWindowTracker.orderedWindows) {
+      for (let dialogContainer of win.document.querySelectorAll(".paymentDialogContainer")) {
+        if (dialogContainer.dataset.requestId == requestId) {
+          return {
+            dialogContainer,
+            browser: dialogContainer.parentElement.querySelector("browser"),
+          };
+        }
+      }
+    }
+    return {};
+  },
+
+  findBrowserByTabId(tabId) {
+    for (let win of BrowserWindowTracker.orderedWindows) {
+      for (let browser of win.gBrowser.browsers) {
+        if (!browser.frameLoader || !browser.frameLoader.tabParent) {
+          continue;
+        }
+        if (browser.frameLoader.tabParent.tabId == tabId) {
+          return browser;
+        }
       }
     }
 
+    this.log.error("findBrowserByTabId: No browser found for tabId:", tabId);
     return null;
   },
-
-  requestIdForWindow(window) {
-    let windowName = window.name;
-
-    return windowName.startsWith(this.REQUEST_ID_PREFIX) ?
-      windowName.replace(this.REQUEST_ID_PREFIX, "") : // returns suffix, which is the requestId
-      null;
-  },
 };
 
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([PaymentUIService]);
--- a/browser/components/payments/res/containers/payment-dialog.js
+++ b/browser/components/payments/res/containers/payment-dialog.js
@@ -179,17 +179,17 @@ export default class PaymentDialog exten
 
   /**
    * Set some state from the privileged parent process.
    * Other elements that need to set state should use their own `this.requestStore.setState`
    * method provided by the `PaymentStateSubscriberMixin`.
    *
    * @param {object} state - See `PaymentsStore.setState`
    */
-  setStateFromParent(state) {
+  async setStateFromParent(state) {
     let oldAddresses = paymentRequest.getAddresses(this.requestStore.getState());
     if (state.request) {
       state = this._updateCompleteStatus(state);
     }
     this.requestStore.setState(state);
 
     // Check if any foreign-key constraints were invalidated.
     state = this.requestStore.getState();
--- a/browser/components/payments/res/paymentRequest.js
+++ b/browser/components/payments/res/paymentRequest.js
@@ -104,17 +104,16 @@ var paymentRequest = {
         break;
       }
     }
   },
 
   onPaymentRequestLoad() {
     log.debug("onPaymentRequestLoad");
     window.addEventListener("unload", this, {once: true});
-    this.sendMessageToChrome("paymentDialogReady");
 
     // Automatically show the debugging console if loaded with a truthy `debug` query parameter.
     if (new URLSearchParams(location.search).get("debug")) {
       this.toggleDebuggingConsole();
     }
   },
 
   async onShowPaymentRequest(detail) {
@@ -167,17 +166,19 @@ var paymentRequest = {
         id: "basic-card-page",
         onboardingWizard: true,
       };
       state["basic-card-page"] = {
         selectedStateKey: "selectedPaymentCard",
       };
     }
 
-    paymentDialog.setStateFromParent(state);
+    await paymentDialog.setStateFromParent(state);
+
+    this.sendMessageToChrome("paymentDialogReady");
   },
 
   openPreferences() {
     this.sendMessageToChrome("openPreferences");
   },
 
   cancel() {
     this.sendMessageToChrome("paymentCancel");
--- a/browser/components/payments/test/PaymentTestUtils.jsm
+++ b/browser/components/payments/test/PaymentTestUtils.jsm
@@ -107,25 +107,29 @@ var PaymentTestUtils = {
 
     /**
      * Create a new payment request cached as `rq` and then show it.
      *
      * @param {Object} args
      * @param {PaymentMethodData[]} methodData
      * @param {PaymentDetailsInit} details
      * @param {PaymentOptions} options
+     * @returns {Object}
      */
     createAndShowRequest: ({methodData, details, options}) => {
       const rq = new content.PaymentRequest(Cu.cloneInto(methodData, content), details, options);
       content.rq = rq; // assign it so we can retrieve it later
 
       const handle = content.windowUtils.setHandlingUserInput(true);
       content.showPromise = rq.show();
 
       handle.destruct();
+      return {
+        requestId: rq.id,
+      };
     },
   },
 
   DialogContentTasks: {
     getShippingOptions: () => {
       let picker = content.document.querySelector("shipping-option-picker");
       let popupBox = Cu.waiveXrays(picker).dropdown.popupBox;
       let selectedOptionIndex = popupBox.selectedIndex;
--- a/browser/components/payments/test/browser/browser.ini
+++ b/browser/components/payments/test/browser/browser.ini
@@ -5,17 +5,17 @@ prefs =
   dom.payments.request.enabled=true
 skip-if = !e10s # Bug 1365964 - Payment Request isn't implemented for non-e10s
 support-files =
   blank_page.html
 
 [browser_address_edit.js]
 skip-if = verify && debug && os == 'mac'
 [browser_card_edit.js]
-skip-if = os == 'linux' && debug # bug 1465673
+skip-if = (verify && debug && os == 'mac') || (os == 'linux' && debug) # bug 1465673
 [browser_change_shipping.js]
 [browser_dropdowns.js]
 [browser_host_name.js]
 [browser_onboarding_wizard.js]
 [browser_openPreferences.js]
 [browser_payment_completion.js]
 [browser_profile_storage.js]
 [browser_request_serialization.js]
--- a/browser/components/payments/test/browser/browser_dropdowns.js
+++ b/browser/components/payments/test/browser/browser_dropdowns.js
@@ -40,14 +40,16 @@ add_task(async function test_dropdown() 
 
     let event = await popupshownPromise;
     let expectedPopupID = "ContentSelectDropdown";
     if (AppConstants.platform == "win") {
       expectedPopupID = "ContentSelectDropdown-windows";
     }
     is(event.target.parentElement.id, expectedPopupID, "Checked menulist of opened popup");
 
+    event.target.hidePopup(true);
+
     info("clicking cancel");
     spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
 
     await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
   });
 });
--- a/browser/components/payments/test/browser/browser_show_dialog.js
+++ b/browser/components/payments/test/browser/browser_show_dialog.js
@@ -246,8 +246,80 @@ add_task(async function test_supportedNe
     await spawnPaymentDialogTask(frame, async () => {
       ok(!content.document.getElementById("pay").disabled, "pay button should not be disabled");
     });
 
     spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
     await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
   });
 });
+
+add_task(async function test_tab_modal() {
+  await BrowserTestUtils.withNewTab({
+    gBrowser,
+    url: BLANK_PAGE_URL,
+  }, async browser => {
+    let {win, frame} = await setupPaymentDialog(browser, {
+      methodData,
+      details,
+      merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+    });
+
+    await TestUtils.waitForCondition(() => {
+      return !document.querySelector(".paymentDialogContainer").hidden;
+    }, "Waiting for container to be visible after the dialog's ready");
+
+    ok(!EventUtils.isHidden(win.frameElement), "Frame should be visible");
+
+    let {
+      bottom: toolboxBottom,
+    } = document.getElementById("navigator-toolbox").getBoundingClientRect();
+
+    let {x, y} = win.frameElement.getBoundingClientRect();
+    ok(y > 0, "Frame should have y > 0");
+    // Inset by 10px since the corner point doesn't return the frame due to the
+    // border-radius.
+    is(document.elementFromPoint(x + 10, y + 10), win.frameElement,
+       "Check .paymentDialogContainerFrame is visible");
+
+    info("Click to the left of the dialog over the content area");
+    isnot(document.elementFromPoint(x - 10, y + 50), browser,
+          "Check clicks on the merchant content area don't go to the browser");
+    is(document.elementFromPoint(x - 10, y + 50),
+       document.querySelector(".paymentDialogBackground"),
+       "Check clicks on the merchant content area go to the payment dialog background");
+
+    ok(y < toolboxBottom - 2, "Dialog should overlap the toolbox by at least 2px");
+
+    ok(browser.hasAttribute("tabmodalPromptShowing"), "Check browser has @tabmodalPromptShowing");
+
+    await BrowserTestUtils.withNewTab({
+      gBrowser,
+      url: BLANK_PAGE_URL,
+    }, async newBrowser => {
+      let {
+        x: x2,
+        y: y2,
+      } = win.frameElement.getBoundingClientRect();
+      is(x2, x, "Check x-coordinate is the same");
+      is(y2, y, "Check y-coordinate is the same");
+      isnot(document.elementFromPoint(x + 10, y + 10), win.frameElement,
+            "Check .paymentDialogContainerFrame is hidden");
+      ok(!newBrowser.hasAttribute("tabmodalPromptShowing"),
+         "Check second browser doesn't have @tabmodalPromptShowing");
+    });
+
+    let {
+      x: x3,
+      y: y3,
+    } = win.frameElement.getBoundingClientRect();
+    is(x3, x, "Check x-coordinate is the same again");
+    is(y3, y, "Check y-coordinate is the same again");
+    is(document.elementFromPoint(x + 10, y + 10), win.frameElement,
+       "Check .paymentDialogContainerFrame is visible again");
+
+    spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+    await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
+
+    await BrowserTestUtils.waitForCondition(() => !browser.hasAttribute("tabmodalPromptShowing"),
+                                            "Check @tabmodalPromptShowing was removed");
+  });
+});
--- a/browser/components/payments/test/browser/head.js
+++ b/browser/components/payments/test/browser/head.js
@@ -12,43 +12,48 @@ const BLANK_PAGE_PATH = "/browser/browse
 const BLANK_PAGE_URL = "https://example.com" + BLANK_PAGE_PATH;
 const RESPONSE_TIMEOUT_PREF = "dom.payments.response.timeout";
 const SAVE_CREDITCARD_DEFAULT_PREF = "dom.payments.defaults.saveCreditCard";
 const SAVE_ADDRESS_DEFAULT_PREF = "dom.payments.defaults.saveAddress";
 
 const paymentSrv = Cc["@mozilla.org/dom/payments/payment-request-service;1"]
                      .getService(Ci.nsIPaymentRequestService);
 const paymentUISrv = Cc["@mozilla.org/dom/payments/payment-ui-service;1"]
-                     .getService().wrappedJSObject;
+                     .getService(Ci.nsIPaymentUIService).wrappedJSObject;
 const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm", {});
 const {formAutofillStorage} = ChromeUtils.import(
   "resource://formautofill/FormAutofillStorage.jsm", {});
 const {PaymentTestUtils: PTU} = ChromeUtils.import(
   "resource://testing-common/PaymentTestUtils.jsm", {});
+ChromeUtils.import("resource:///modules/BrowserWindowTracker.jsm");
 ChromeUtils.import("resource://gre/modules/CreditCard.jsm");
 
 function getPaymentRequests() {
   return Array.from(paymentSrv.enumerate());
 }
 
 /**
  * Return the container (e.g. dialog or overlay) that the payment request contents are shown in.
- * This abstracts away the details of the widget used so that this can more earily transition from a
- * dialog to another kind of overlay.
- * Consumers shouldn't rely on a dialog window being returned.
+ * This abstracts away the details of the widget used so that this can more easily transition to
+ * another kind of dialog/overlay.
+ * @param {string} requestId
  * @returns {Promise}
  */
-async function getPaymentWidget() {
-  let win;
-  await BrowserTestUtils.waitForCondition(() => {
-    win = Services.wm.getMostRecentWindow(null);
-    return win.name.startsWith(paymentUISrv.REQUEST_ID_PREFIX);
-  }, "payment dialog should be the most recent");
-
-  return win;
+async function getPaymentWidget(requestId) {
+  return BrowserTestUtils.waitForCondition(() => {
+    let {dialogContainer} = paymentUISrv.findDialog(requestId);
+    if (!dialogContainer) {
+      return false;
+    }
+    let browserIFrame = dialogContainer.querySelector("iframe");
+    if (!browserIFrame) {
+      return false;
+    }
+    return browserIFrame.contentWindow;
+  }, "payment dialog should be opened");
 }
 
 async function getPaymentFrame(widget) {
   return widget.document.getElementById("paymentRequestFrame");
 }
 
 function waitForMessageFromWidget(messageType, widget = null) {
   info("waitForMessageFromWidget: " + messageType);
@@ -232,29 +237,28 @@ function checkPaymentMethodDetailsMatche
  * @param {Object} options.methodData
  * @param {Object} options.details
  * @param {Object} options.options
  * @param {Function} options.merchantTaskFn
  * @returns {Object} References to the window, requestId, and frame
  */
 async function setupPaymentDialog(browser, {methodData, details, options, merchantTaskFn}) {
   let dialogReadyPromise = waitForWidgetReady();
-  await ContentTask.spawn(browser,
-                          {
-                            methodData,
-                            details,
-                            options,
-                          },
-                          merchantTaskFn);
+  let {requestId} = await ContentTask.spawn(browser,
+                                            {
+                                              methodData,
+                                              details,
+                                              options,
+                                            },
+                                            merchantTaskFn);
+  ok(requestId, "requestId should be defined");
 
   // get a reference to the UI dialog and the requestId
-  let [win] = await Promise.all([getPaymentWidget(), dialogReadyPromise]);
+  let [win] = await Promise.all([getPaymentWidget(requestId), dialogReadyPromise]);
   ok(win, "Got payment widget");
-  let requestId = paymentUISrv.requestIdForWindow(win);
-  ok(requestId, "requestId should be defined");
   is(win.closed, false, "dialog should not be closed");
 
   let frame = await getPaymentFrame(win);
   ok(frame, "Got payment frame");
 
   await dialogReadyPromise;
   info("dialog ready");
 
@@ -654,17 +658,17 @@ async function fillInCardForm(frame, aCa
  * but is missing from content task scope.
  * You should call this method only once per <browser> tag
  *
  * @param {xul:browser} browser
  *        Reference to the browser in which we load content task
  */
 /* eslint-enable valid-jsdoc */
 async function injectEventUtilsInContentTask(browser) {
-  await ContentTask.spawn(browser, {}, async function() {
+  await spawnPaymentDialogTask(browser, async function injectEventUtils() {
     if ("EventUtils" in this) {
       return;
     }
 
     const EventUtils = this.EventUtils = {};
 
     EventUtils.window = {};
     EventUtils.parent = EventUtils.window;
--- a/toolkit/components/prompts/content/tabprompts.css
+++ b/toolkit/components/prompts/content/tabprompts.css
@@ -1,13 +1,14 @@
 /* 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/. */
 
 /* Tab Modal Prompt boxes */
+.tabModalBackground,
 tabmodalprompt {
   width: 100%;
   height: 100%;
   -moz-box-pack: center;
   -moz-box-orient: vertical;
 }
 
 .tabmodalprompt-mainContainer {
@@ -28,8 +29,39 @@ tabmodalprompt {
   cursor: text !important;
   white-space: pre-wrap;
   unicode-bidi: plaintext;
 }
 
 tabmodalprompt label[value=""] {
   visibility: collapse;
 }
+
+/* Tab-Modal Payment Request widget */
+.paymentDialogContainer:not([hidden]) {
+  /* Center the .paymentDialogContainerFrame horizontally with flexbox. */
+  display: flex;
+  flex-direction: column;
+  position: absolute;
+}
+
+.paymentDialogContainerFrame {
+  align-self: center;
+  box-sizing: border-box;
+  height: 500px;
+  /* By setting `left` & `right` to `auto` with `position: absolute`, the
+   * horizontal position from the `align-self: center` is used.
+   * See https://developer.mozilla.org/en-US/docs/Web/CSS/right#Values. */
+  left: auto;
+  /* Shrink the height for small browser window sizes so the dialog footer
+     remains visible.
+     Ideally this would be 100vh minus the #navigator-toolbox height. */
+  max-height: 75vh;
+  /* Leave a 16px border on each side when the normal dialog width can't fit in
+   * the browser window. This ensure that the dialog still looks like a dialog
+   * (with content showing beside) instead of a full-width overlay. */
+  max-width: calc(100vw - 16px - 16px);
+  position: absolute;
+  right: auto;
+  /* Vertically overlap the browser chrome. */
+  top: -3px;
+  width: 600px;
+}
--- a/toolkit/themes/osx/global/tabprompts.css
+++ b/toolkit/themes/osx/global/tabprompts.css
@@ -1,19 +1,24 @@
 /* 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/. */
 
 /* Tab Modal Prompt boxes */
+.tabModalBackground,
 tabmodalprompt {
   background-color: hsla(0,0%,10%,.5);
+}
+
+tabmodalprompt {
   font-family: sans-serif; /* use content font not system UI font */
   font-size: 110%;
 }
 
+.paymentDialogContainerFrame,
 .tabmodalprompt-mainContainer {
   color: black;
   background-color: hsla(0,0%,100%,.95);
   background-clip: padding-box;
   border-radius: 2px;
   border: 1px solid hsla(0,0%,0%,.5);
 }
 
--- a/toolkit/themes/windows/global/tabprompts.css
+++ b/toolkit/themes/windows/global/tabprompts.css
@@ -1,18 +1,23 @@
 /* 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/. */
 
 /* Tab Modal Prompt boxes */
+.tabModalBackground,
 tabmodalprompt {
   background-color: hsla(0,0%,10%,.5);
+}
+
+tabmodalprompt {
   font-family: sans-serif; /* use content font not system UI font */
 }
 
+.paymentDialogContainerFrame,
 .tabmodalprompt-mainContainer {
   color: -moz-fieldText;
   background-color: -moz-field;
   border-radius: 2px;
   border: 1px solid threeDDarkShadow;
 }
 
 .tabmodalprompt-topContainer {