1 /* - This Source Code Form is subject to the terms of the Mozilla Public
2 - License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 - You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
9 XPCOMUtils.defineLazyServiceGetter(
12 "@mozilla.org/widget/dragservice;1",
16 const HTML_NS = "http://www.w3.org/1999/xhtml";
19 * The SubDialog resize callback.
20 * @callback SubDialog~resizeCallback
21 * @param {DOMNode} title - The title element of the dialog.
22 * @param {xul:browser} frame - The browser frame of the dialog.
26 * SubDialog constructor creates a new subdialog from a template and appends
27 * it to the parentElement.
28 * @param {DOMNode} template - The template is copied to create a new dialog.
29 * @param {DOMNode} parentElement - New dialog is appended onto parentElement.
30 * @param {String} id - A unique identifier for the dialog.
31 * @param {Object} dialogOptions - Dialog options object.
32 * @param {String[]} [dialogOptions.styleSheets] - An array of URLs to additional
33 * stylesheets to inject into the frame.
34 * @param {Boolean} [consumeOutsideClicks] - Whether to close the dialog when
35 * its background overlay is clicked.
36 * @param {SubDialog~resizeCallback} [resizeCallback] - Function to be called on
39 export function SubDialog({
45 consumeOutsideClicks = true,
51 this._injectedStyleSheets = this._injectedStyleSheets.concat(styleSheets);
52 this._consumeOutsideClicks = consumeOutsideClicks;
53 this._resizeCallback = resizeCallback;
54 this._overlay = template.cloneNode(true);
55 this._box = this._overlay.querySelector(".dialogBox");
56 this._titleBar = this._overlay.querySelector(".dialogTitleBar");
57 this._titleElement = this._overlay.querySelector(".dialogTitle");
58 this._closeButton = this._overlay.querySelector(".dialogClose");
59 this._frame = this._overlay.querySelector(".dialogFrame");
61 this._overlay.classList.add(`dialogOverlay-${id}`);
62 this._frame.setAttribute("name", `dialogFrame-${id}`);
63 this._frameCreated = new Promise(resolve => {
64 this._frame.addEventListener(
67 // We intentionally avoid handling or passing the event to the
68 // resolve method to avoid shutdown window leaks. See bug 1686743.
78 parentElement.appendChild(this._overlay);
79 this._overlay.hidden = false;
82 SubDialog.prototype = {
83 _closingCallback: null,
91 _injectedStyleSheets: ["chrome://global/skin/in-content/common.css"],
92 _resizeObserver: null,
98 get frameContentWindow() {
99 return this._frame?.contentWindow;
103 return this._overlay?.ownerGlobal;
106 updateTitle(aEvent) {
107 if (aEvent.target != this._frame.contentDocument) {
110 this._titleElement.textContent = this._frame.contentDocument.title;
113 injectStylesheet(aStylesheetURL) {
114 const doc = this._frame.contentDocument;
115 if ([...doc.styleSheets].find(s => s.href === aStylesheetURL)) {
119 // Attempt to insert the stylesheet as a link element into the same place in
120 // the document as other link elements. It is almost certain that any
121 // document will already have a localization or other stylesheet link
123 let links = doc.getElementsByTagNameNS(HTML_NS, "link");
125 let stylesheetLink = doc.createElementNS(HTML_NS, "link");
126 stylesheetLink.setAttribute("rel", "stylesheet");
127 stylesheetLink.setAttribute("href", aStylesheetURL);
129 // Insert after the last found link element.
130 links[links.length - 1].after(stylesheetLink);
135 // In the odd case just insert at the top as a processing instruction.
136 let contentStylesheet = doc.createProcessingInstruction(
138 'href="' + aStylesheetURL + '" type="text/css"'
140 doc.insertBefore(contentStylesheet, doc.documentElement);
145 { features, closingCallback, closedCallback, sizeTo } = {},
148 if (["available", "limitheight"].includes(sizeTo)) {
149 this._box.setAttribute("sizeto", sizeTo);
152 // Create a promise so consumers can tell when we're done setting up.
153 this._dialogReady = new Promise(resolve => {
154 this._resolveDialogReady = resolve;
156 this._frame._dialogReady = this._dialogReady;
158 // Assign close callbacks sync to ensure we can always callback even if the
159 // SubDialog is closed directly after opening.
162 if (closingCallback) {
163 this._closingCallback = (...args) => {
164 closingCallback.apply(dialog, args);
167 if (closedCallback) {
168 this._closedCallback = (...args) => {
169 closedCallback.apply(dialog, args);
173 // Wait until frame is ready to prevent browser crash in tests
174 await this._frameCreated;
176 if (!this._frame.contentWindow) {
177 // Given the binding constructor execution is asynchronous, and "load"
178 // event can be dispatched before the browser element is shown, the
179 // browser binding might not be constructed at this point. Forcibly
180 // construct the frame and construct the binding.
181 // FIXME: Remove this (bug 1437247)
182 this._frame.getBoundingClientRect();
185 // If we're open on some (other) URL or we're closing, open when closing has finished.
186 if (this._openedURL || this._isClosing) {
187 if (!this._isClosing) {
190 let args = Array.from(arguments);
191 this._closingPromise.then(() => {
192 this.open.apply(this, args);
196 this._addDialogEventListeners();
198 // Ensure we end any pending drag sessions:
200 // The drag service getService call fails in puppeteer tests on Linux,
201 // so this is in a try...catch as it shouldn't stop us from opening the
202 // dialog. Bug 1806870 tracks fixing this.
203 if (lazy.dragService.getCurrentSession()) {
204 lazy.dragService.endDragSession(true);
210 // If the parent is chrome we also need open the dialog as chrome, otherwise
211 // the openDialog call will fail.
212 let dialogFeatures = `resizable,dialog=no,centerscreen,chrome=${
213 this._window?.isChromeWindow ? "yes" : "no"
216 dialogFeatures = `${features},${dialogFeatures}`;
219 dialog = this._window.openDialog(
221 `dialogFrame-${this._id}`,
226 this._closingEvent = null;
227 this._isClosing = false;
228 this._openedURL = aURL;
230 dialogFeatures = dialogFeatures.replace(/,/g, "&");
231 let featureParams = new URLSearchParams(dialogFeatures.toLowerCase());
232 this._box.setAttribute(
234 featureParams.has("resizable") &&
235 featureParams.get("resizable") != "no" &&
236 featureParams.get("resizable") != "0"
241 * Close the dialog and mark it as aborted.
244 this._closingEvent = new CustomEvent("dialogclosing", {
246 detail: { dialog: this, abort: true },
248 this._frame.contentWindow.close();
249 // It's possible that we're aborting this dialog before we've had a
250 // chance to set up the contentWindow.close function override in
251 // _onContentLoaded. If so, call this.close() directly to clean things
252 // up. That'll be a no-op if the contentWindow.close override had been
253 // set up, since this.close is idempotent.
254 this.close(this._closingEvent);
257 close(aEvent = null) {
258 if (this._isClosing) {
261 this._isClosing = true;
262 this._closingPromise = new Promise(resolve => {
263 this._resolveClosePromise = resolve;
266 if (this._closingCallback) {
268 this._closingCallback.call(null, aEvent);
272 this._closingCallback = null;
275 this._removeDialogEventListeners();
277 this._overlay.style.visibility = "";
278 // Clear the sizing inline styles.
279 this._frame.removeAttribute("style");
280 // Clear the sizing attributes
281 this._box.removeAttribute("width");
282 this._box.removeAttribute("height");
283 this._box.style.removeProperty("--box-max-height-requested");
284 this._box.style.removeProperty("--box-max-width-requested");
285 this._box.style.removeProperty("min-height");
286 this._box.style.removeProperty("min-width");
287 this._overlay.style.removeProperty("--subdialog-inner-height");
289 let onClosed = () => {
290 this._openedURL = null;
292 this._resolveClosePromise();
294 if (this._closedCallback) {
296 this._closedCallback.call(null, aEvent);
300 this._closedCallback = null;
304 // Wait for the frame to unload before running the closed callback.
305 if (this._frame.contentWindow) {
306 this._frame.contentWindow.addEventListener("unload", onClosed, {
313 this._overlay.dispatchEvent(
314 new CustomEvent("dialogclose", {
316 detail: { dialog: this },
320 // Defer removing the overlay so the frame content window can unload.
321 Services.tm.dispatchToMainThread(() => {
322 this._overlay.remove();
326 handleEvent(aEvent) {
327 switch (aEvent.type) {
329 // Close the dialog if the user clicked the overlay background, just
330 // like when the user presses the ESC key (case "command" below).
331 if (aEvent.target !== this._overlay) {
334 if (this._consumeOutsideClicks) {
335 this._frame.contentWindow.close();
341 this._frame.contentWindow.close();
343 case "dialogclosing":
344 this._onDialogClosing(aEvent);
346 case "DOMTitleChanged":
347 this.updateTitle(aEvent);
349 case "DOMFrameContentLoaded":
350 this._onContentLoaded(aEvent);
353 this._onLoad(aEvent);
356 this._onUnload(aEvent);
359 this._onKeyDown(aEvent);
362 this._onParentWinFocus(aEvent);
367 /* Private methods */
371 aEvent.target !== this._frame?.contentDocument ||
372 aEvent.target.location.href !== this._openedURL
379 _onContentLoaded(aEvent) {
381 aEvent.target != this._frame ||
382 aEvent.target.contentWindow.location == "about:blank"
387 for (let styleSheetURL of this._injectedStyleSheets) {
388 this.injectStylesheet(styleSheetURL);
391 let { contentDocument } = this._frame;
392 // Provide the ability for the dialog to know that it is loaded in a frame
393 // rather than as a top-level window.
394 for (let dialog of contentDocument.querySelectorAll("dialog")) {
395 dialog.setAttribute("subdialog", "true");
397 // Sub-dialogs loaded in a chrome window should use the system font size so
398 // that the user has a way to increase or decrease it via system settings.
399 // Sub-dialogs loaded in the content area, on the other hand, can be zoomed
401 if (this._window.isChromeWindow) {
402 contentDocument.documentElement.classList.add("system-font-size");
404 // Used by CSS to give the appropriate background colour in dark mode.
405 contentDocument.documentElement.setAttribute("dialogroot", "true");
407 this._frame.contentWindow.addEventListener("dialogclosing", this);
409 let oldResizeBy = this._frame.contentWindow.resizeBy;
410 this._frame.contentWindow.resizeBy = (resizeByWidth, resizeByHeight) => {
411 // Only handle resizeByHeight currently.
412 let frameHeight = this._overlay.style.getPropertyValue(
413 "--subdialog-inner-height"
416 frameHeight = parseFloat(frameHeight);
418 frameHeight = this._frame.clientHeight;
420 let boxMinHeight = parseFloat(
421 this._window.getComputedStyle(this._box).minHeight
424 this._box.style.minHeight = boxMinHeight + resizeByHeight + "px";
426 this._overlay.style.setProperty(
427 "--subdialog-inner-height",
428 frameHeight + resizeByHeight + "px"
432 this._frame.contentWindow,
438 // Make window.close calls work like dialog closing.
439 let oldClose = this._frame.contentWindow.close;
440 this._frame.contentWindow.close = () => {
441 var closingEvent = this._closingEvent;
442 // If this._closingEvent is set, the dialog is closed externally
443 // (dialog.js) and "dialogclosing" has already been dispatched.
445 // If called without closing event, we need to create and dispatch it.
446 // This is the case for any external close calls not going through
448 closingEvent = new CustomEvent("dialogclosing", {
450 detail: { button: null },
453 this._frame.contentWindow.dispatchEvent(closingEvent);
454 } else if (this._closingEvent.detail?.abort) {
455 // If the dialog is aborted (SubDialog#abort) we need to dispatch the
456 // "dialogclosing" event ourselves.
457 this._frame.contentWindow.dispatchEvent(closingEvent);
460 this.close(closingEvent);
461 oldClose.call(this._frame.contentWindow);
464 // XXX: Hack to make focus during the dialog's load functions work. Make the element visible
465 // sooner in DOMContentLoaded but mostly invisible instead of changing visibility just before
466 // the dialog's load event.
467 // Note that this needs to inherit so that hideDialog() works as expected.
468 this._overlay.style.visibility = "inherit";
469 this._overlay.style.opacity = "0.01";
471 // Ensure the document gets an a11y role of dialog.
472 const a11yDoc = contentDocument.body || contentDocument.documentElement;
473 a11yDoc.setAttribute("role", "dialog");
475 Services.obs.notifyObservers(this._frame.contentWindow, "subdialog-loaded");
478 async _onLoad(aEvent) {
479 let target = aEvent.currentTarget;
480 if (target.contentWindow.location == "about:blank") {
484 // In order to properly calculate the sizing of the subdialog, we need to
485 // ensure that all of the l10n is done.
486 if (target.contentDocument.l10n) {
487 await target.contentDocument.l10n.ready;
490 // Some subdialogs may want to perform additional, asynchronous steps during initializations.
492 // In that case, we expect them to define a Promise which will delay measuring
493 // until the promise is fulfilled.
494 if (target.contentDocument.mozSubdialogReady) {
495 await target.contentDocument.mozSubdialogReady;
498 await this.resizeDialog();
499 this._resolveDialogReady();
502 async resizeDialog() {
503 // Do this on load to wait for the CSS to load and apply before calculating the size.
504 let docEl = this._frame.contentDocument.documentElement;
506 // These are deduced from styles which we don't change, so it's safe to get them now:
507 let boxHorizontalBorder =
508 2 * parseFloat(this._window.getComputedStyle(this._box).borderLeftWidth);
509 let frameHorizontalMargin =
510 2 * parseFloat(this._window.getComputedStyle(this._frame).marginLeft);
512 // Then determine and set a bunch of width stuff:
513 let { scrollWidth } = docEl.ownerDocument.body || docEl;
514 // We need to convert em to px because an em value from the dialog window could
515 // translate to something else in the host window, as font sizes may vary.
517 this._emToPx(docEl.style.minWidth) ||
518 this._emToPx(docEl.style.width) ||
520 let frameWidth = docEl.getAttribute("width")
521 ? docEl.getAttribute("width") + "px"
522 : scrollWidth + "px";
524 this._box.getAttribute("sizeto") == "available" &&
527 this._box.style.setProperty(
528 "--box-max-width-requested",
529 this._emToPx(docEl.style.maxWidth)
533 if (this._box.getAttribute("sizeto") != "available") {
534 this._frame.style.width = frameWidth;
535 this._frame.style.minWidth = frameMinWidth;
538 let boxMinWidth = `calc(${
539 boxHorizontalBorder + frameHorizontalMargin
540 }px + ${frameMinWidth})`;
542 // Temporary fix to allow parent chrome to collapse properly to min width.
544 if (this._window.isChromeWindow) {
545 boxMinWidth = `min(80vw, ${boxMinWidth})`;
547 this._box.style.minWidth = boxMinWidth;
549 this.resizeVertically();
551 this._overlay.dispatchEvent(
552 new CustomEvent("dialogopen", {
554 detail: { dialog: this },
557 this._overlay.style.visibility = "inherit";
558 this._overlay.style.opacity = ""; // XXX: focus hack continued from _onContentLoaded
560 if (this._box.getAttribute("resizable") == "true") {
561 this._onResize = this._onResize.bind(this);
562 this._resizeObserver = new this._window.MutationObserver(this._onResize);
563 this._resizeObserver.observe(this._box, { attributes: true });
568 this._resizeCallback?.({
569 title: this._titleElement,
575 let docEl = this._frame.contentDocument.documentElement;
576 let getDocHeight = () => {
577 let { scrollHeight } = docEl.ownerDocument.body || docEl;
578 // We need to convert em to px because an em value from the dialog window could
579 // translate to something else in the host window, as font sizes may vary.
580 return this._emToPx(docEl.style.height) || scrollHeight + "px";
583 // If the title bar is disabled (not in the template),
584 // set its height to 0 for the calculation.
585 let titleBarHeight = 0;
586 if (this._titleBar) {
588 this._titleBar.clientHeight +
590 this._window.getComputedStyle(this._titleBar).borderBottomWidth
594 let boxVerticalBorder =
595 2 * parseFloat(this._window.getComputedStyle(this._box).borderTopWidth);
596 let frameVerticalMargin =
597 2 * parseFloat(this._window.getComputedStyle(this._frame).marginTop);
599 // The difference between the frame and box shouldn't change, either:
600 let boxRect = this._box.getBoundingClientRect();
601 let frameRect = this._frame.getBoundingClientRect();
602 let frameSizeDifference =
603 frameRect.top - boxRect.top + (boxRect.bottom - frameRect.bottom);
606 this._frame.contentDocument.querySelector(".contentPane") ||
607 this._frame.contentDocument.querySelector("dialog");
609 let sizeTo = this._box.getAttribute("sizeto");
610 if (["available", "limitheight"].includes(sizeTo)) {
611 if (sizeTo == "limitheight") {
612 this._overlay.style.setProperty("--doc-height-px", getDocHeight());
613 contentPane?.classList.add("sizeDetermined");
615 if (docEl.style.maxHeight) {
616 this._box.style.setProperty(
617 "--box-max-height-requested",
618 this._emToPx(docEl.style.maxHeight)
621 // Inform the CSS of the toolbar height so the bottom padding can be
622 // correctly calculated.
623 this._box.style.setProperty("--box-top-px", `${boxRect.top}px`);
628 // Now do the same but for the height. We need to do this afterwards because otherwise
629 // XUL assumes we'll optimize for height and gives us "wrong" values which then are no
630 // longer correct after we set the width:
631 let frameMinHeight = getDocHeight();
632 let frameHeight = docEl.getAttribute("height")
633 ? docEl.getAttribute("height") + "px"
636 // Now check if the frame height we calculated is possible at this window size,
637 // accounting for titlebar, padding/border and some spacing.
638 let frameOverhead = frameSizeDifference + titleBarHeight;
639 let maxHeight = this._window.innerHeight - frameOverhead;
640 // Do this with a frame height in pixels...
641 if (!frameHeight.endsWith("px")) {
644 this._frame.contentWindow.location.href,
645 ") set a height in non-px-non-em units ('",
648 "which is likely to lead to bad sizing in in-content preferences. " +
649 "Please consider changing this."
654 parseFloat(frameMinHeight) > maxHeight ||
655 parseFloat(frameHeight) > maxHeight
657 // If the height is bigger than that of the window, we should let the
658 // contents scroll. The class is set on the "dialog" element, unless a
659 // content pane exists, which is usually the case when the "window"
660 // element is used to implement the subdialog instead.
661 frameMinHeight = maxHeight + "px";
662 // There also instances where the subdialog is neither implemented using
663 // a content pane, nor a <dialog> (such as manageAddresses.xhtml)
664 // so make sure to check that we actually got a contentPane before we
666 contentPane?.classList.add("doScroll");
669 this._overlay.style.setProperty("--subdialog-inner-height", frameHeight);
670 this._frame.style.height = `min(
671 calc(100vh - ${frameOverhead}px),
672 var(--subdialog-inner-height, ${frameHeight})
674 this._box.style.minHeight = `calc(
675 ${boxVerticalBorder + titleBarHeight + frameVerticalMargin}px +
681 * Helper for converting em to px because an em value from the dialog window could
682 * translate to something else in the host window, as font sizes may vary.
684 * @param {String} val
685 * A CSS length value.
686 * @return {String} The converted CSS length value, or the original value if
687 * no conversion took place.
690 if (val && val.endsWith("em")) {
691 let { fontSize } = this.frameContentWindow.getComputedStyle(
692 this._frame.contentDocument.documentElement
694 return parseFloat(val) * parseFloat(fontSize) + "px";
699 _onResize(mutations) {
700 let frame = this._frame;
701 // The width and height styles are needed for the initial
702 // layout of the frame, but afterward they need to be removed
703 // or their presence will restrict the contents of the <browser>
704 // from resizing to a smaller size.
705 frame.style.removeProperty("width");
706 frame.style.removeProperty("height");
708 let docEl = frame.contentDocument.documentElement;
709 let persistedAttributes = docEl.getAttribute("persist");
711 !persistedAttributes ||
712 (!persistedAttributes.includes("width") &&
713 !persistedAttributes.includes("height"))
718 for (let mutation of mutations) {
719 if (mutation.attributeName == "width") {
720 docEl.setAttribute("width", docEl.scrollWidth);
721 } else if (mutation.attributeName == "height") {
722 docEl.setAttribute("height", docEl.scrollHeight);
727 _onDialogClosing(aEvent) {
728 this._frame.contentWindow.removeEventListener("dialogclosing", this);
729 this._closingEvent = aEvent;
733 // Close on ESC key if target is SubDialog
734 // If we're in the parent window, we need to check if the SubDialogs
735 // frame is targeted, so we don't close the wrong dialog.
736 if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE && !aEvent.defaultPrevented) {
738 (this._window.isChromeWindow && aEvent.currentTarget == this._box) ||
739 (!this._window.isChromeWindow && aEvent.currentTarget == this._window)
741 // Prevent ESC on SubDialog from cancelling page load (Bug 1665339).
742 aEvent.preventDefault();
743 this._frame.contentWindow.close();
749 this._window.isChromeWindow ||
750 aEvent.keyCode != aEvent.DOM_VK_TAB ||
758 let fm = Services.focus;
760 let isLastFocusableElement = el => {
761 // XXXgijs unfortunately there is no way to get the last focusable element without asking
762 // the focus manager to move focus to it.
765 fm.moveFocus(this._frame.contentWindow, null, fm.MOVEFOCUS_LAST, 0);
770 let forward = !aEvent.shiftKey;
771 // check if focus is leaving the frame (incl. the close button):
773 (aEvent.target == this._closeButton && !forward) ||
774 (isLastFocusableElement(aEvent.originalTarget) && forward)
776 aEvent.preventDefault();
777 aEvent.stopImmediatePropagation();
779 let parentWin = this._window.docShell.chromeEventHandler.ownerGlobal;
781 fm.moveFocus(parentWin, null, fm.MOVEFOCUS_FIRST, fm.FLAG_BYKEY);
783 // Somehow, moving back 'past' the opening doc is not trivial. Cheat by doing it in 2 steps:
784 fm.moveFocus(this._window, null, fm.MOVEFOCUS_ROOT, fm.FLAG_BYKEY);
785 fm.moveFocus(parentWin, null, fm.MOVEFOCUS_BACKWARD, fm.FLAG_BYKEY);
790 _onParentWinFocus(aEvent) {
791 // Explicitly check for the focus target of |window| to avoid triggering this when the window
795 aEvent.target != this._closeButton &&
796 aEvent.target != this._window
798 this._closeButton.focus();
803 * Setup dialog event listeners.
804 * @param {Boolean} [includeLoad] - Whether to register load/unload listeners.
806 _addDialogEventListeners(includeLoad = true) {
807 if (this._window.isChromeWindow) {
808 // Only register an event listener if we have a title to show.
809 if (this._titleBar) {
810 this._frame.addEventListener("DOMTitleChanged", this, true);
814 this._window.addEventListener("unload", this, true);
817 let chromeBrowser = this._window.docShell.chromeEventHandler;
820 // For content windows we listen for unload of the browser
821 chromeBrowser.addEventListener("unload", this, true);
824 if (this._titleBar) {
825 chromeBrowser.addEventListener("DOMTitleChanged", this, true);
829 // Make the close button work.
830 this._closeButton?.addEventListener("command", this);
833 // DOMFrameContentLoaded only fires on the top window
834 this._window.addEventListener("DOMFrameContentLoaded", this, true);
836 // Wait for the stylesheets injected during DOMContentLoaded to load before showing the dialog
837 // otherwise there is a flicker of the stylesheet applying.
838 this._frame.addEventListener("load", this, true);
841 // Ensure we get <esc> keypresses even if nothing in the subdialog is focusable
842 // (happens on OS X when only text inputs and lists are focusable, and
843 // the subdialog only has checkboxes/radiobuttons/buttons)
844 if (!this._window.isChromeWindow) {
845 this._window.addEventListener("keydown", this, true);
848 this._overlay.addEventListener("click", this, true);
852 * Remove dialog event listeners.
853 * @param {Boolean} [includeLoad] - Whether to remove load/unload listeners.
855 _removeDialogEventListeners(includeLoad = true) {
856 if (this._window.isChromeWindow) {
857 this._frame.removeEventListener("DOMTitleChanged", this, true);
860 this._window.removeEventListener("unload", this, true);
863 let chromeBrowser = this._window.docShell.chromeEventHandler;
865 chromeBrowser.removeEventListener("unload", this, true);
868 chromeBrowser.removeEventListener("DOMTitleChanged", this, true);
871 this._closeButton?.removeEventListener("command", this);
874 this._window.removeEventListener("DOMFrameContentLoaded", this, true);
875 this._frame.removeEventListener("load", this, true);
876 this._frame.contentWindow.removeEventListener("dialogclosing", this);
879 this._window.removeEventListener("keydown", this, true);
881 this._overlay.removeEventListener("click", this, true);
883 if (this._resizeObserver) {
884 this._resizeObserver.disconnect();
885 this._resizeObserver = null;
892 * Focus the dialog content.
893 * If the embedded document defines a custom focus handler it will be called.
894 * Otherwise we will focus the first focusable element in the content window.
895 * @param {boolean} [isInitialFocus] - Whether the dialog is focused for the
896 * first time after opening.
898 focus(isInitialFocus = false) {
899 // If the content window has its own focus logic, hand off the focus call.
900 let focusHandler = this._frame?.contentDocument?.subDialogSetDefaultFocus;
902 focusHandler(isInitialFocus);
905 // Handle focus ourselves. Try to move the focus to the first element in
906 // the content window.
907 let fm = Services.focus;
909 // We're intentionally hiding the focus ring here for now per bug 1704882,
910 // but we aim to have a better fix that retains the focus ring for users
911 // that had brought up the dialog by keyboard in bug 1708261.
912 let focusedElement = fm.moveFocus(
913 this._frame.contentWindow,
918 if (!focusedElement) {
919 // Ensure the focus is pulled out of the content document even if there's
920 // nothing focusable in the dialog.
926 // Attach a system event listener so the dialog can cancel keydown events.
928 this._box.addEventListener("keydown", this, { mozSystemGroup: true });
929 this._closeButton?.addEventListener("keydown", this);
931 if (!this._window.isChromeWindow) {
932 this._window.addEventListener("focus", this, true);
937 this._box.removeEventListener("keydown", this, { mozSystemGroup: true });
938 this._closeButton?.removeEventListener("keydown", this);
939 this._window.removeEventListener("focus", this, true);
944 * Manages multiple SubDialogs in a dialog stack element.
946 export class SubDialogManager {
948 * @param {Object} options - Dialog manager options.
949 * @param {DOMNode} options.dialogStack - Container element for all dialogs
950 * this instance manages.
951 * @param {DOMNode} options.dialogTemplate - Element to use as template for
952 * constructing new dialogs.
953 * @param {Number} [options.orderType] - Whether dialogs should be ordered as
954 * a stack or a queue.
955 * @param {Boolean} [options.allowDuplicateDialogs] - Whether to allow opening
956 * duplicate dialogs (same URI) at the same time. If disabled, opening a
957 * dialog with the same URI as an existing dialog will be a no-op.
958 * @param {Object} options.dialogOptions - Options passed to every
959 * SubDialog instance.
960 * @see {@link SubDialog} for a list of dialog options.
965 orderType = SubDialogManager.ORDER_STACK,
966 allowDuplicateDialogs = false,
970 * New dialogs are pushed to the end of the _dialogs array.
971 * Depending on the orderType either the last element (stack) or the first
972 * element (queue) in the array will be the top and visible.
973 * @type {SubDialog[]}
976 this._dialogStack = dialogStack;
977 this._dialogTemplate = dialogTemplate;
978 this._topLevelPrevActiveElement = null;
979 this._orderType = orderType;
980 this._allowDuplicateDialogs = allowDuplicateDialogs;
981 this._dialogOptions = dialogOptions;
983 this._preloadDialog = new SubDialog({
984 template: this._dialogTemplate,
985 parentElement: this._dialogStack,
986 id: SubDialogManager._nextDialogID++,
987 dialogOptions: this._dialogOptions,
992 * Get the dialog which is currently on top. This depends on whether the
993 * dialogs are in a stack or a queue.
996 if (!this._dialogs.length) {
999 if (this._orderType === SubDialogManager.ORDER_STACK) {
1000 return this._dialogs[this._dialogs.length - 1];
1002 return this._dialogs[0];
1011 allowDuplicateDialogs,
1017 let allowDuplicates =
1018 allowDuplicateDialogs != null
1019 ? allowDuplicateDialogs
1020 : this._allowDuplicateDialogs;
1021 // If we're already open/opening on this URL, do nothing.
1024 this._dialogs.some(dialog => dialog._openedURL == aURL)
1029 let doc = this._dialogStack.ownerDocument;
1031 // For dialog stacks, remember the last active element before opening the
1032 // next dialog. This allows us to restore focus on dialog close.
1034 this._orderType === SubDialogManager.ORDER_STACK &&
1035 this._dialogs.length
1037 this._topDialog._prevActiveElement = doc.activeElement;
1040 if (!this._dialogs.length) {
1041 // When opening the first dialog, show the dialog stack.
1042 this._dialogStack.hidden = false;
1043 this._dialogStack.classList.remove("temporarilyHidden");
1044 this._topLevelPrevActiveElement = doc.activeElement;
1047 // Consumers may pass this flag to make the dialog overlay background opaque,
1048 // effectively hiding the content behind it. For example,
1049 // this is used by the prompt code to prevent certain http authentication spoofing scenarios.
1051 this._preloadDialog._overlay.setAttribute("hideContent", true);
1053 this._dialogs.push(this._preloadDialog);
1054 this._preloadDialog.open(
1065 let openedDialog = this._preloadDialog;
1067 this._preloadDialog = new SubDialog({
1068 template: this._dialogTemplate,
1069 parentElement: this._dialogStack,
1070 id: SubDialogManager._nextDialogID++,
1071 dialogOptions: this._dialogOptions,
1074 if (this._dialogs.length == 1) {
1075 this._ensureStackEventListeners();
1078 return openedDialog;
1082 this._topDialog.close();
1086 * Hides the dialog stack for a specific browser, without actually destroying
1087 * frames for stuff within it.
1089 * @param aBrowser - The browser associated with the tab dialog.
1091 hideDialog(aBrowser) {
1092 aBrowser.removeAttribute("tabDialogShowing");
1093 this._dialogStack.classList.add("temporarilyHidden");
1097 * Abort open dialogs.
1098 * @param {function} [filterFn] - Function which should return true for
1099 * dialogs that should be aborted and false for dialogs that should remain
1100 * open. Defaults to aborting all dialogs.
1102 abortDialogs(filterFn = () => true) {
1103 this._dialogs.filter(filterFn).forEach(dialog => dialog.abort());
1107 if (!this._dialogs.length) {
1110 return this._dialogs.some(dialog => !dialog._isClosing);
1114 return [...this._dialogs];
1118 this._topDialog?.focus();
1121 handleEvent(aEvent) {
1122 switch (aEvent.type) {
1123 case "dialogopen": {
1124 this._onDialogOpen(aEvent.detail.dialog);
1127 case "dialogclose": {
1128 this._onDialogClose(aEvent.detail.dialog);
1134 _onDialogOpen(dialog) {
1135 let lowerDialogs = [];
1136 if (dialog == this._topDialog) {
1139 // Opening dialog is not on top, hide it
1140 lowerDialogs.push(dialog);
1143 // For stack order, hide the previous top
1145 this._dialogs.length &&
1146 this._orderType === SubDialogManager.ORDER_STACK
1148 let index = this._dialogs.indexOf(dialog);
1150 lowerDialogs.push(this._dialogs[index - 1]);
1154 lowerDialogs.forEach(d => {
1155 if (d._overlay.hasAttribute("topmost")) {
1156 d._overlay.removeAttribute("topmost");
1157 d._removeDialogEventListeners(false);
1162 _onDialogClose(dialog) {
1163 this._dialogs.splice(this._dialogs.indexOf(dialog), 1);
1165 if (this._topDialog) {
1166 // The prevActiveElement is only set for stacked dialogs
1167 if (this._topDialog._prevActiveElement) {
1168 this._topDialog._prevActiveElement.focus();
1170 this._topDialog.focus(true);
1172 this._topDialog._overlay.setAttribute("topmost", true);
1173 this._topDialog._addDialogEventListeners(false);
1174 this._dialogStack.hidden = false;
1175 this._dialogStack.classList.remove("temporarilyHidden");
1177 // We have closed the last dialog, do cleanup.
1178 this._topLevelPrevActiveElement.focus();
1179 this._dialogStack.hidden = true;
1180 this._removeStackEventListeners();
1184 _ensureStackEventListeners() {
1185 this._dialogStack.addEventListener("dialogopen", this);
1186 this._dialogStack.addEventListener("dialogclose", this);
1189 _removeStackEventListeners() {
1190 this._dialogStack.removeEventListener("dialogopen", this);
1191 this._dialogStack.removeEventListener("dialogclose", this);
1195 // Used for the SubDialogManager orderType option.
1196 SubDialogManager.ORDER_STACK = 0;
1197 SubDialogManager.ORDER_QUEUE = 1;
1199 SubDialogManager._nextDialogID = 0;