gecko-dev/toolkit/modules/SubDialog.jsm
Molly Howell 119eaa0928 Bug 1710633 - Use the actual titlebar height to determine subdialog size. r=Mardak
It isn't immediately obvious that the hardcoded 30 pixel value this patch
replaces was meant to represent strictly the height of the dialog's title bar
(including border), but I do believe that is how it was intended, based on
these two things:

1) Using 0 instead of 30 when no titlebar is present at least appears to be the
   correct behavior, or if nothing else it's a reasonable enough behavior that
   it prevents anything looking broken.
2) 30 pixels is the exact height of the titlebar including border as it
   actually appeared on in-content preferences dialog boxes at the time the
   hardcoded value was originally introduced here in bug 1128237.

So, based on that, it looks like what's needed here is a value that is zero
when no titlebar exists, or if there is one, then its computed height including
border. As it turns out, we were already computing exactly that value for
another purpose, so this patch simply plugs that in here in place of the
hardcoded constant.

Differential Revision: https://phabricator.services.mozilla.com/D117374
2021-06-11 20:02:37 +00:00

1113 lines
35 KiB
JavaScript

/* - 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/. */
"use strict";
var EXPORTED_SYMBOLS = ["SubDialog", "SubDialogManager"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
/**
* The SubDialog resize callback.
* @callback SubDialog~resizeCallback
* @param {DOMNode} title - The title element of the dialog.
* @param {xul:browser} frame - The browser frame of the dialog.
*/
/**
* 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.
* @param {Object} dialogOptions - Dialog options object.
* @param {String[]} [dialogOptions.styleSheets] - An array of URLs to additional
* stylesheets to inject into the frame.
* @param {Boolean} [consumeOutsideClicks] - Whether to close the dialog when
* its background overlay is clicked.
* @param {SubDialog~resizeCallback} [resizeCallback] - Function to be called on
* dialog resize.
*/
function SubDialog({
template,
parentElement,
id,
dialogOptions: {
styleSheets = [],
consumeOutsideClicks = true,
resizeCallback,
} = {},
}) {
this._id = id;
this._injectedStyleSheets = this._injectedStyleSheets.concat(styleSheets);
this._consumeOutsideClicks = consumeOutsideClicks;
this._resizeCallback = resizeCallback;
this._overlay = template.cloneNode(true);
this._box = this._overlay.querySelector(".dialogBox");
this._titleBar = this._overlay.querySelector(".dialogTitleBar");
this._titleElement = this._overlay.querySelector(".dialogTitle");
this._closeButton = this._overlay.querySelector(".dialogClose");
this._frame = this._overlay.querySelector(".dialogFrame");
this._overlay.classList.add(`dialogOverlay-${id}`);
this._frame.setAttribute("name", `dialogFrame-${id}`);
this._frameCreated = new Promise(resolve => {
this._frame.addEventListener(
"load",
() => {
// We intentionally avoid handling or passing the event to the
// resolve method to avoid shutdown window leaks. See bug 1686743.
resolve();
},
{
once: true,
capture: 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://global/skin/in-content/common.css"],
_resizeObserver: null,
_template: null,
_id: null,
_titleElement: null,
_closeButton: null,
get frameContentWindow() {
return this._frame?.contentWindow;
},
get _window() {
return this._overlay?.ownerGlobal;
},
updateTitle(aEvent) {
if (aEvent.target != this._frame.contentDocument) {
return;
}
this._titleElement.textContent = this._frame.contentDocument.title;
},
injectXMLStylesheet(aStylesheetURL) {
const doc = this._frame.contentDocument;
if ([...doc.styleSheets].find(s => s.href === aStylesheetURL)) {
return;
}
let contentStylesheet = doc.createProcessingInstruction(
"xml-stylesheet",
'href="' + aStylesheetURL + '" type="text/css"'
);
doc.insertBefore(contentStylesheet, doc.documentElement);
},
async open(
aURL,
{ features, closingCallback, closedCallback, sizeTo } = {},
...aParams
) {
if (["available", "limitheight"].includes(sizeTo)) {
this._box.setAttribute("sizeto", sizeTo);
}
// Create a promise so consumers can tell when we're done setting up.
this._dialogReady = new Promise(resolve => {
this._resolveDialogReady = resolve;
});
this._frame._dialogReady = this._dialogReady;
// Assign close callbacks sync to ensure we can always callback even if the
// SubDialog is closed directly after opening.
let dialog = null;
if (closingCallback) {
this._closingCallback = (...args) => {
closingCallback.apply(dialog, args);
};
}
if (closedCallback) {
this._closedCallback = (...args) => {
closedCallback.apply(dialog, args);
};
}
// Wait until frame is ready to prevent browser crash in tests
await this._frameCreated;
if (!this._frame.contentWindow) {
// Given the binding constructor execution is asynchronous, and "load"
// event can be dispatched before the browser element is shown, the
// browser binding might not be constructed at this point. Forcibly
// construct the frame and construct the binding.
// FIXME: Remove this (bug 1437247)
this._frame.getBoundingClientRect();
}
// 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();
// If the parent is chrome we also need open the dialog as chrome, otherwise
// the openDialog call will fail.
let dialogFeatures = `resizable,dialog=no,centerscreen,chrome=${
this._window?.isChromeWindow ? "yes" : "no"
}`;
if (features) {
dialogFeatures = `${features},${dialogFeatures}`;
}
dialog = this._window.openDialog(
aURL,
`dialogFrame-${this._id}`,
dialogFeatures,
...aParams
);
this._closingEvent = null;
this._isClosing = false;
this._openedURL = aURL;
dialogFeatures = dialogFeatures.replace(/,/g, "&");
let featureParams = new URLSearchParams(dialogFeatures.toLowerCase());
this._box.setAttribute(
"resizable",
featureParams.has("resizable") &&
featureParams.get("resizable") != "no" &&
featureParams.get("resizable") != "0"
);
},
/**
* Close the dialog and mark it as aborted.
*/
abort() {
this._closingEvent = new CustomEvent("dialogclosing", {
bubbles: true,
detail: { dialog: this, abort: true },
});
this._frame.contentWindow.close();
// It's possible that we're aborting this dialog before we've had a
// chance to set up the contentWindow.close function override in
// _onContentLoaded. If so, call this.close() directly to clean things
// up. That'll be a no-op if the contentWindow.close override had been
// set up, since this.close is idempotent.
this.close(this._closingEvent);
},
close(aEvent = null) {
if (this._isClosing) {
return;
}
this._isClosing = true;
this._closingPromise = new Promise(resolve => {
this._resolveClosePromise = resolve;
});
if (this._closingCallback) {
try {
this._closingCallback.call(null, aEvent);
} catch (ex) {
Cu.reportError(ex);
}
this._closingCallback = null;
}
this._removeDialogEventListeners();
this._overlay.style.visibility = "";
// 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.parentNode.style.removeProperty("--inner-height");
let onClosed = () => {
this._openedURL = null;
this._resolveClosePromise();
if (this._closedCallback) {
try {
this._closedCallback.call(null, aEvent);
} catch (ex) {
Cu.reportError(ex);
}
this._closedCallback = null;
}
};
// Wait for the frame to unload before running the closed callback.
if (this._frame.contentWindow) {
this._frame.contentWindow.addEventListener("unload", onClosed, {
once: true,
});
} else {
onClosed();
}
this._overlay.dispatchEvent(
new CustomEvent("dialogclose", {
bubbles: true,
detail: { dialog: this },
})
);
// Defer removing the overlay so the frame content window can unload.
Services.tm.dispatchToMainThread(() => {
this._overlay.remove();
});
},
handleEvent(aEvent) {
switch (aEvent.type) {
case "click":
// Close the dialog if the user clicked the overlay background, just
// like when the user presses the ESC key (case "command" below).
if (aEvent.target !== this._overlay) {
break;
}
if (this._consumeOutsideClicks) {
this._frame.contentWindow.close();
break;
}
this._frame.focus();
break;
case "command":
this._frame.contentWindow.close();
break;
case "dialogclosing":
this._onDialogClosing(aEvent);
break;
case "DOMTitleChanged":
this.updateTitle(aEvent);
break;
case "DOMFrameContentLoaded":
this._onContentLoaded(aEvent);
break;
case "load":
this._onLoad(aEvent);
break;
case "unload":
this._onUnload(aEvent);
break;
case "keydown":
this._onKeyDown(aEvent);
break;
case "focus":
this._onParentWinFocus(aEvent);
break;
}
},
/* Private methods */
_onUnload(aEvent) {
if (
aEvent.target !== this._frame?.contentDocument ||
aEvent.target.location.href !== this._openedURL
) {
return;
}
this.abort();
},
_onContentLoaded(aEvent) {
if (
aEvent.target != this._frame ||
aEvent.target.contentWindow.location == "about:blank"
) {
return;
}
for (let styleSheetURL of this._injectedStyleSheets) {
this.injectXMLStylesheet(styleSheetURL);
}
let { contentDocument } = this._frame;
// Provide the ability for the dialog to know that it is being loaded "in-content".
for (let dialog of contentDocument.querySelectorAll("dialog")) {
dialog.setAttribute("subdialog", "true");
}
// Used by CSS to give the appropriate background colour in dark mode.
contentDocument.documentElement.setAttribute("dialogroot", "true");
this._frame.contentWindow.addEventListener("dialogclosing", this);
let oldResizeBy = this._frame.contentWindow.resizeBy;
this._frame.contentWindow.resizeBy = (resizeByWidth, resizeByHeight) => {
// Only handle resizeByHeight currently.
let frameHeight = this._overlay.parentNode.style.getPropertyValue(
"--inner-height"
);
if (frameHeight) {
frameHeight = parseFloat(frameHeight, 10);
} else {
frameHeight = this._frame.clientHeight;
}
let boxMinHeight = parseFloat(
this._window.getComputedStyle(this._box).minHeight,
10
);
this._box.style.minHeight = boxMinHeight + resizeByHeight + "px";
this._overlay.parentNode.style.setProperty(
"--inner-height",
frameHeight + resizeByHeight + "px"
);
oldResizeBy.call(
this._frame.contentWindow,
resizeByWidth,
resizeByHeight
);
};
// Make window.close calls work like dialog closing.
let oldClose = this._frame.contentWindow.close;
this._frame.contentWindow.close = () => {
var closingEvent = this._closingEvent;
// If this._closingEvent is set, the dialog is closed externally
// (dialog.js) and "dialogclosing" has already been dispatched.
if (!closingEvent) {
// If called without closing event, we need to create and dispatch it.
// This is the case for any external close calls not going through
// dialog.js.
closingEvent = new CustomEvent("dialogclosing", {
bubbles: true,
detail: { button: null },
});
this._frame.contentWindow.dispatchEvent(closingEvent);
} else if (this._closingEvent.detail?.abort) {
// If the dialog is aborted (SubDialog#abort) we need to dispatch the
// "dialogclosing" event ourselves.
this._frame.contentWindow.dispatchEvent(closingEvent);
}
this.close(closingEvent);
oldClose.call(this._frame.contentWindow);
};
// XXX: Hack to make focus during the dialog's load functions work. Make the element visible
// sooner in DOMContentLoaded but mostly invisible instead of changing visibility just before
// the dialog's load event.
// Note that this needs to inherit so that hideDialog() works as expected.
this._overlay.style.visibility = "inherit";
this._overlay.style.opacity = "0.01";
// Ensure the document gets an a11y role of dialog.
const a11yDoc = contentDocument.body || contentDocument.documentElement;
a11yDoc.setAttribute("role", "dialog");
Services.obs.notifyObservers(this._frame.contentWindow, "subdialog-loaded");
},
async _onLoad(aEvent) {
let target = aEvent.currentTarget;
if (target.contentWindow.location == "about:blank") {
return;
}
// In order to properly calculate the sizing of the subdialog, we need to
// ensure that all of the l10n is done.
if (target.contentDocument.l10n) {
await target.contentDocument.l10n.ready;
}
// Some subdialogs may want to perform additional, asynchronous steps during initializations.
//
// In that case, we expect them to define a Promise which will delay measuring
// until the promise is fulfilled.
if (target.contentDocument.mozSubdialogReady) {
await target.contentDocument.mozSubdialogReady;
}
await this.resizeDialog();
this._resolveDialogReady();
},
async resizeDialog() {
// Do this on load to wait for the CSS to load and apply before calculating the size.
let docEl = this._frame.contentDocument.documentElement;
// These are deduced from styles which we don't change, so it's safe to get them now:
let boxHorizontalBorder =
2 * parseFloat(this._window.getComputedStyle(this._box).borderLeftWidth);
let frameHorizontalMargin =
2 * parseFloat(this._window.getComputedStyle(this._frame).marginLeft);
// Then determine and set a bunch of width stuff:
let { scrollWidth } = docEl.ownerDocument.body || docEl;
let frameMinWidth = docEl.style.width || scrollWidth + "px";
let frameWidth = docEl.getAttribute("width")
? docEl.getAttribute("width") + "px"
: frameMinWidth;
if (this._box.getAttribute("sizeto") != "available") {
this._frame.style.width = frameWidth;
}
let boxMinWidth = `calc(${boxHorizontalBorder +
frameHorizontalMargin}px + ${frameMinWidth})`;
// Temporary fix to allow parent chrome to collapse properly to min width.
// See Bug 1658722.
if (this._window.isChromeWindow) {
boxMinWidth = `min(80vw, ${boxMinWidth})`;
}
this._box.style.minWidth = boxMinWidth;
this.resizeVertically();
this._overlay.dispatchEvent(
new CustomEvent("dialogopen", {
bubbles: true,
detail: { dialog: this },
})
);
this._overlay.style.visibility = "inherit";
this._overlay.style.opacity = ""; // XXX: focus hack continued from _onContentLoaded
if (this._box.getAttribute("resizable") == "true") {
this._onResize = this._onResize.bind(this);
this._resizeObserver = new this._window.MutationObserver(this._onResize);
this._resizeObserver.observe(this._box, { attributes: true });
}
this._trapFocus();
this._resizeCallback?.({
title: this._titleElement,
frame: this._frame,
});
},
resizeVertically() {
let docEl = this._frame.contentDocument.documentElement;
function getDocHeight() {
let { scrollHeight } = docEl.ownerDocument.body || docEl;
return docEl.style.height || scrollHeight + "px";
}
// If the title bar is disabled (not in the template),
// set its height to 0 for the calculation.
let titleBarHeight = 0;
if (this._titleBar) {
titleBarHeight =
this._titleBar.clientHeight +
parseFloat(
this._window.getComputedStyle(this._titleBar).borderBottomWidth
);
}
let boxVerticalBorder =
2 * parseFloat(this._window.getComputedStyle(this._box).borderTopWidth);
let frameVerticalMargin =
2 * parseFloat(this._window.getComputedStyle(this._frame).marginTop);
// The difference between the frame and box shouldn't change, either:
let boxRect = this._box.getBoundingClientRect();
let frameRect = this._frame.getBoundingClientRect();
let frameSizeDifference =
frameRect.top - boxRect.top + (boxRect.bottom - frameRect.bottom);
let contentPane =
this._frame.contentDocument.querySelector(".contentPane") ||
this._frame.contentDocument.querySelector("dialog");
let sizeTo = this._box.getAttribute("sizeto");
if (["available", "limitheight"].includes(sizeTo)) {
if (sizeTo == "limitheight") {
this._overlay.style.setProperty("--doc-height-px", getDocHeight());
contentPane?.classList.add("sizeDetermined");
} else {
// Inform the CSS of the toolbar height so the bottom padding can be
// correctly calculated.
this._box.style.setProperty("--box-top-px", `${boxRect.top}px`);
}
return;
}
// Now do the same but for the height. We need to do this afterwards because otherwise
// XUL assumes we'll optimize for height and gives us "wrong" values which then are no
// longer correct after we set the width:
let frameMinHeight = getDocHeight();
let frameHeight = docEl.getAttribute("height")
? docEl.getAttribute("height") + "px"
: frameMinHeight;
// Now check if the frame height we calculated is possible at this window size,
// accounting for titlebar, padding/border and some spacing.
let frameOverhead = frameSizeDifference + titleBarHeight;
let maxHeight = this._window.innerHeight - frameOverhead;
// Do this with a frame height in pixels...
let comparisonFrameHeight;
if (frameHeight.endsWith("em")) {
let fontSize = parseFloat(
this._window.getComputedStyle(this._frame).fontSize
);
comparisonFrameHeight = parseFloat(frameHeight, 10) * fontSize;
} else if (frameHeight.endsWith("px")) {
comparisonFrameHeight = parseFloat(frameHeight, 10);
} else {
Cu.reportError(
"This dialog (" +
this._frame.contentWindow.location.href +
") " +
"set a height in non-px-non-em units ('" +
frameHeight +
"'), " +
"which is likely to lead to bad sizing in in-content preferences. " +
"Please consider changing this."
);
comparisonFrameHeight = parseFloat(frameHeight);
}
if (comparisonFrameHeight > maxHeight) {
// If the height is bigger than that of the window, we should let the
// contents scroll. The class is set on the "dialog" element, unless a
// content pane exists, which is usually the case when the "window"
// element is used to implement the subdialog instead.
frameMinHeight = maxHeight + "px";
if (contentPane) {
// There are also instances where the subdialog is neither implemented
// using a content pane, nor a <dialog> (such as manageAddresses.xhtml)
// so make sure to check that we actually got a contentPane before we
// use it.
contentPane.classList.add("doScroll");
}
}
this._overlay.parentNode.style.setProperty("--inner-height", frameHeight);
this._frame.style.height = `min(
calc(100vh - ${frameOverhead}px),
var(--inner-height, ${frameHeight})
)`;
this._box.style.minHeight = `calc(
${boxVerticalBorder + titleBarHeight + frameVerticalMargin}px +
${frameMinHeight}
)`;
},
_onResize(mutations) {
let frame = this._frame;
// The width and height styles are needed for the initial
// layout of the frame, but afterward they need to be removed
// or their presence will restrict the contents of the <browser>
// from resizing to a smaller size.
frame.style.removeProperty("width");
frame.style.removeProperty("height");
let docEl = frame.contentDocument.documentElement;
let persistedAttributes = docEl.getAttribute("persist");
if (
!persistedAttributes ||
(!persistedAttributes.includes("width") &&
!persistedAttributes.includes("height"))
) {
return;
}
for (let mutation of mutations) {
if (mutation.attributeName == "width") {
docEl.setAttribute("width", docEl.scrollWidth);
} else if (mutation.attributeName == "height") {
docEl.setAttribute("height", docEl.scrollHeight);
}
}
},
_onDialogClosing(aEvent) {
this._frame.contentWindow.removeEventListener("dialogclosing", this);
this._closingEvent = aEvent;
},
_onKeyDown(aEvent) {
// Close on ESC key if target is SubDialog
// If we're in the parent window, we need to check if the SubDialogs
// frame is targeted, so we don't close the wrong dialog.
if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE && !aEvent.defaultPrevented) {
if (
(this._window.isChromeWindow && aEvent.currentTarget == this._box) ||
(!this._window.isChromeWindow && aEvent.currentTarget == this._window)
) {
// Prevent ESC on SubDialog from cancelling page load (Bug 1665339).
aEvent.preventDefault();
this._frame.contentWindow.close();
return;
}
}
if (
this._window.isChromeWindow ||
aEvent.keyCode != aEvent.DOM_VK_TAB ||
aEvent.ctrlKey ||
aEvent.altKey ||
aEvent.metaKey
) {
return;
}
let fm = Services.focus;
let isLastFocusableElement = el => {
// XXXgijs unfortunately there is no way to get the last focusable element without asking
// the focus manager to move focus to it.
let rv =
el ==
fm.moveFocus(this._frame.contentWindow, null, fm.MOVEFOCUS_LAST, 0);
fm.setFocus(el, 0);
return rv;
};
let forward = !aEvent.shiftKey;
// check if focus is leaving the frame (incl. the close button):
if (
(aEvent.target == this._closeButton && !forward) ||
(isLastFocusableElement(aEvent.originalTarget) && forward)
) {
aEvent.preventDefault();
aEvent.stopImmediatePropagation();
let parentWin = this._window.docShell.chromeEventHandler.ownerGlobal;
if (forward) {
fm.moveFocus(parentWin, null, fm.MOVEFOCUS_FIRST, fm.FLAG_BYKEY);
} else {
// Somehow, moving back 'past' the opening doc is not trivial. Cheat by doing it in 2 steps:
fm.moveFocus(this._window, null, fm.MOVEFOCUS_ROOT, fm.FLAG_BYKEY);
fm.moveFocus(parentWin, null, fm.MOVEFOCUS_BACKWARD, fm.FLAG_BYKEY);
}
}
},
_onParentWinFocus(aEvent) {
// Explicitly check for the focus target of |window| to avoid triggering this when the window
// is refocused
if (
this._closeButton &&
aEvent.target != this._closeButton &&
aEvent.target != this._window
) {
this._closeButton.focus();
}
},
/**
* Setup dialog event listeners.
* @param {Boolean} [includeLoad] - Whether to register load/unload listeners.
*/
_addDialogEventListeners(includeLoad = true) {
if (this._window.isChromeWindow) {
// Only register an event listener if we have a title to show.
if (this._titleBar) {
this._frame.addEventListener("DOMTitleChanged", this, true);
}
if (includeLoad) {
this._window.addEventListener("unload", this, true);
}
} else {
let chromeBrowser = this._window.docShell.chromeEventHandler;
if (includeLoad) {
// For content windows we listen for unload of the browser
chromeBrowser.addEventListener("unload", this, true);
}
if (this._titleBar) {
chromeBrowser.addEventListener("DOMTitleChanged", this, true);
}
}
// Make the close button work.
this._closeButton?.addEventListener("command", this);
if (includeLoad) {
// DOMFrameContentLoaded only fires on the top window
this._window.addEventListener("DOMFrameContentLoaded", this, true);
// Wait for the stylesheets injected during DOMContentLoaded to load before showing the dialog
// otherwise there is a flicker of the stylesheet applying.
this._frame.addEventListener("load", this, true);
}
// Ensure we get <esc> keypresses even if nothing in the subdialog is focusable
// (happens on OS X when only text inputs and lists are focusable, and
// the subdialog only has checkboxes/radiobuttons/buttons)
if (!this._window.isChromeWindow) {
this._window.addEventListener("keydown", this, true);
}
this._overlay.addEventListener("click", this, true);
},
/**
* Remove dialog event listeners.
* @param {Boolean} [includeLoad] - Whether to remove load/unload listeners.
*/
_removeDialogEventListeners(includeLoad = true) {
if (this._window.isChromeWindow) {
this._frame.removeEventListener("DOMTitleChanged", this, true);
if (includeLoad) {
this._window.removeEventListener("unload", this, true);
}
} else {
let chromeBrowser = this._window.docShell.chromeEventHandler;
if (includeLoad) {
chromeBrowser.removeEventListener("unload", this, true);
}
chromeBrowser.removeEventListener("DOMTitleChanged", this, true);
}
this._closeButton?.removeEventListener("command", this);
if (includeLoad) {
this._window.removeEventListener("DOMFrameContentLoaded", this, true);
this._frame.removeEventListener("load", this, true);
this._frame.contentWindow.removeEventListener("dialogclosing", this);
}
this._window.removeEventListener("keydown", this, true);
this._overlay.removeEventListener("click", this, true);
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
this._untrapFocus();
},
/**
* Focus the dialog content.
* If the embedded document defines a custom focus handler it will be called.
* Otherwise we will focus the first focusable element in the content window.
* @param {boolean} [isInitialFocus] - Whether the dialog is focused for the
* first time after opening.
*/
focus(isInitialFocus = false) {
// If the content window has its own focus logic, hand off the focus call.
let focusHandler = this._frame?.contentDocument?.subDialogSetDefaultFocus;
if (focusHandler) {
focusHandler(isInitialFocus);
return;
}
// Handle focus ourselves. Try to move the focus to the first element in
// the content window.
let fm = Services.focus;
// We're intentionally hiding the focus ring here for now per bug 1704882,
// but we aim to have a better fix that retains the focus ring for users
// that had brought up the dialog by keyboard in bug 1708261.
let focusedElement = fm.moveFocus(
this._frame.contentWindow,
null,
fm.MOVEFOCUS_FIRST,
fm.FLAG_NOSHOWRING
);
if (!focusedElement) {
// Ensure the focus is pulled out of the content document even if there's
// nothing focusable in the dialog.
this._frame.contentWindow.focus();
}
},
_trapFocus() {
// Attach a system event listener so the dialog can cancel keydown events.
// See Bug 1669990.
this._box.addEventListener("keydown", this, { mozSystemGroup: true });
this._closeButton?.addEventListener("keydown", this);
if (!this._window.isChromeWindow) {
this._window.addEventListener("focus", this, true);
}
},
_untrapFocus() {
this._box.removeEventListener("keydown", this, { mozSystemGroup: true });
this._closeButton?.removeEventListener("keydown", this);
this._window.removeEventListener("focus", this, true);
},
};
/**
* Manages multiple SubDialogs in a dialog stack element.
*/
class SubDialogManager {
/**
* @param {Object} options - Dialog manager options.
* @param {DOMNode} options.dialogStack - Container element for all dialogs
* this instance manages.
* @param {DOMNode} options.dialogTemplate - Element to use as template for
* constructing new dialogs.
* @param {Number} [options.orderType] - Whether dialogs should be ordered as
* a stack or a queue.
* @param {Boolean} [options.allowDuplicateDialogs] - Whether to allow opening
* duplicate dialogs (same URI) at the same time. If disabled, opening a
* dialog with the same URI as an existing dialog will be a no-op.
* @param {Object} options.dialogOptions - Options passed to every
* SubDialog instance.
* @see {@link SubDialog} for a list of dialog options.
*/
constructor({
dialogStack,
dialogTemplate,
orderType = SubDialogManager.ORDER_STACK,
allowDuplicateDialogs = false,
dialogOptions,
}) {
/**
* New dialogs are pushed to the end of the _dialogs array.
* Depending on the orderType either the last element (stack) or the first
* element (queue) in the array will be the top and visible.
* @type {SubDialog[]}
*/
this._dialogs = [];
this._dialogStack = dialogStack;
this._dialogTemplate = dialogTemplate;
this._topLevelPrevActiveElement = null;
this._orderType = orderType;
this._allowDuplicateDialogs = allowDuplicateDialogs;
this._dialogOptions = dialogOptions;
this._preloadDialog = new SubDialog({
template: this._dialogTemplate,
parentElement: this._dialogStack,
id: SubDialogManager._nextDialogID++,
dialogOptions: this._dialogOptions,
});
}
/**
* Get the dialog which is currently on top. This depends on whether the
* dialogs are in a stack or a queue.
*/
get _topDialog() {
if (!this._dialogs.length) {
return undefined;
}
if (this._orderType === SubDialogManager.ORDER_STACK) {
return this._dialogs[this._dialogs.length - 1];
}
return this._dialogs[0];
}
open(
aURL,
{
features,
closingCallback,
closedCallback,
allowDuplicateDialogs,
sizeTo,
} = {},
...aParams
) {
let allowDuplicates =
allowDuplicateDialogs != null
? allowDuplicateDialogs
: this._allowDuplicateDialogs;
// If we're already open/opening on this URL, do nothing.
if (
!allowDuplicates &&
this._dialogs.some(dialog => dialog._openedURL == aURL)
) {
return undefined;
}
let doc = this._dialogStack.ownerDocument;
// For dialog stacks, remember the last active element before opening the
// next dialog. This allows us to restore focus on dialog close.
if (
this._orderType === SubDialogManager.ORDER_STACK &&
this._dialogs.length
) {
this._topDialog._prevActiveElement = doc.activeElement;
}
if (!this._dialogs.length) {
// When opening the first dialog, show the dialog stack.
this._dialogStack.hidden = false;
this._dialogStack.classList.remove("temporarilyHidden");
this._topLevelPrevActiveElement = doc.activeElement;
}
this._dialogs.push(this._preloadDialog);
this._preloadDialog.open(
aURL,
{
features,
closingCallback,
closedCallback,
sizeTo,
},
...aParams
);
let openedDialog = this._preloadDialog;
this._preloadDialog = new SubDialog({
template: this._dialogTemplate,
parentElement: this._dialogStack,
id: SubDialogManager._nextDialogID++,
dialogOptions: this._dialogOptions,
});
if (this._dialogs.length == 1) {
this._ensureStackEventListeners();
}
return openedDialog;
}
close() {
this._topDialog.close();
}
/**
* Hides the dialog stack for a specific browser, without actually destroying
* frames for stuff within it.
*
* @param aBrowser - The browser associated with the tab dialog.
*/
hideDialog(aBrowser) {
aBrowser.removeAttribute("tabDialogShowing");
this._dialogStack.classList.add("temporarilyHidden");
}
/**
* Abort open dialogs.
* @param {function} [filterFn] - Function which should return true for
* dialogs that should be aborted and false for dialogs that should remain
* open. Defaults to aborting all dialogs.
*/
abortDialogs(filterFn = () => true) {
this._dialogs.filter(filterFn).forEach(dialog => dialog.abort());
}
get hasDialogs() {
if (!this._dialogs.length) {
return false;
}
return this._dialogs.some(dialog => !dialog._isClosing);
}
get dialogs() {
return [...this._dialogs];
}
focusTopDialog() {
this._topDialog?.focus();
}
handleEvent(aEvent) {
switch (aEvent.type) {
case "dialogopen": {
this._onDialogOpen(aEvent.detail.dialog);
break;
}
case "dialogclose": {
this._onDialogClose(aEvent.detail.dialog);
break;
}
}
}
_onDialogOpen(dialog) {
let lowerDialogs = [];
if (dialog == this._topDialog) {
dialog.focus(true);
} else {
// Opening dialog is not on top, hide it
lowerDialogs.push(dialog);
}
// For stack order, hide the previous top
if (
this._dialogs.length &&
this._orderType === SubDialogManager.ORDER_STACK
) {
let index = this._dialogs.indexOf(dialog);
if (index > 0) {
lowerDialogs.push(this._dialogs[index - 1]);
}
}
lowerDialogs.forEach(d => {
if (d._overlay.hasAttribute("topmost")) {
d._overlay.removeAttribute("topmost");
d._removeDialogEventListeners(false);
}
});
}
_onDialogClose(dialog) {
this._dialogs.splice(this._dialogs.indexOf(dialog), 1);
if (this._topDialog) {
// The prevActiveElement is only set for stacked dialogs
if (this._topDialog._prevActiveElement) {
this._topDialog._prevActiveElement.focus();
} else {
this._topDialog.focus(true);
}
this._topDialog._overlay.setAttribute("topmost", true);
this._topDialog._addDialogEventListeners(false);
this._dialogStack.hidden = false;
this._dialogStack.classList.remove("temporarilyHidden");
} else {
// We have closed the last dialog, do cleanup.
this._topLevelPrevActiveElement.focus();
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);
}
}
// Used for the SubDialogManager orderType option.
SubDialogManager.ORDER_STACK = 0;
SubDialogManager.ORDER_QUEUE = 1;
SubDialogManager._nextDialogID = 0;