Bug 1888590 - Mark some subtests on trusted-types-event-handlers.html as failing...
[gecko.git] / toolkit / modules / SubDialog.sys.mjs
blob20c4199dd317e0cbe7f7c284052c70d740aa2268
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";
7 let lazy = {};
9 XPCOMUtils.defineLazyServiceGetter(
10   lazy,
11   "dragService",
12   "@mozilla.org/widget/dragservice;1",
13   "nsIDragService"
16 const HTML_NS = "http://www.w3.org/1999/xhtml";
18 /**
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.
23  */
25 /**
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
37  * dialog resize.
38  */
39 export function SubDialog({
40   template,
41   parentElement,
42   id,
43   dialogOptions: {
44     styleSheets = [],
45     consumeOutsideClicks = true,
46     resizeCallback,
47   } = {},
48 }) {
49   this._id = id;
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(
65       "load",
66       () => {
67         // We intentionally avoid handling or passing the event to the
68         // resolve method to avoid shutdown window leaks. See bug 1686743.
69         resolve();
70       },
71       {
72         once: true,
73         capture: true,
74       }
75     );
76   });
78   parentElement.appendChild(this._overlay);
79   this._overlay.hidden = false;
82 SubDialog.prototype = {
83   _closingCallback: null,
84   _closingEvent: null,
85   _isClosing: false,
86   _frame: null,
87   _frameCreated: null,
88   _overlay: null,
89   _box: null,
90   _openedURL: null,
91   _injectedStyleSheets: ["chrome://global/skin/in-content/common.css"],
92   _resizeObserver: null,
93   _template: null,
94   _id: null,
95   _titleElement: null,
96   _closeButton: null,
98   get frameContentWindow() {
99     return this._frame?.contentWindow;
100   },
102   get _window() {
103     return this._overlay?.ownerGlobal;
104   },
106   updateTitle(aEvent) {
107     if (aEvent.target != this._frame.contentDocument) {
108       return;
109     }
110     this._titleElement.textContent = this._frame.contentDocument.title;
111   },
113   injectStylesheet(aStylesheetURL) {
114     const doc = this._frame.contentDocument;
115     if ([...doc.styleSheets].find(s => s.href === aStylesheetURL)) {
116       return;
117     }
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
122     // present.
123     let links = doc.getElementsByTagNameNS(HTML_NS, "link");
124     if (links.length) {
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);
132       return;
133     }
135     // In the odd case just insert at the top as a processing instruction.
136     let contentStylesheet = doc.createProcessingInstruction(
137       "xml-stylesheet",
138       'href="' + aStylesheetURL + '" type="text/css"'
139     );
140     doc.insertBefore(contentStylesheet, doc.documentElement);
141   },
143   async open(
144     aURL,
145     { features, closingCallback, closedCallback, sizeTo } = {},
146     ...aParams
147   ) {
148     if (["available", "limitheight"].includes(sizeTo)) {
149       this._box.setAttribute("sizeto", sizeTo);
150     }
152     // Create a promise so consumers can tell when we're done setting up.
153     this._dialogReady = new Promise(resolve => {
154       this._resolveDialogReady = resolve;
155     });
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.
160     let dialog = null;
162     if (closingCallback) {
163       this._closingCallback = (...args) => {
164         closingCallback.apply(dialog, args);
165       };
166     }
167     if (closedCallback) {
168       this._closedCallback = (...args) => {
169         closedCallback.apply(dialog, args);
170       };
171     }
173     // Wait until frame is ready to prevent browser crash in tests
174     await this._frameCreated;
176     // If we're closing now that we've waited for the dialog to load, abort.
177     if (this._isClosing) {
178       return;
179     }
180     this._addDialogEventListeners();
182     // Ensure we end any pending drag sessions:
183     try {
184       // The drag service getService call fails in puppeteer tests on Linux,
185       // so this is in a try...catch as it shouldn't stop us from opening the
186       // dialog. Bug 1806870 tracks fixing this.
187       if (lazy.dragService.getCurrentSession()) {
188         lazy.dragService.endDragSession(true);
189       }
190     } catch (ex) {
191       console.error(ex);
192     }
194     // If the parent is chrome we also need open the dialog as chrome, otherwise
195     // the openDialog call will fail.
196     let dialogFeatures = `resizable,dialog=no,centerscreen,chrome=${
197       this._window?.isChromeWindow ? "yes" : "no"
198     }`;
199     if (features) {
200       dialogFeatures = `${features},${dialogFeatures}`;
201     }
203     dialog = this._window.openDialog(
204       aURL,
205       `dialogFrame-${this._id}`,
206       dialogFeatures,
207       ...aParams
208     );
210     this._closingEvent = null;
211     this._isClosing = false;
212     this._openedURL = aURL;
214     dialogFeatures = dialogFeatures.replace(/,/g, "&");
215     let featureParams = new URLSearchParams(dialogFeatures.toLowerCase());
216     this._box.setAttribute(
217       "resizable",
218       featureParams.has("resizable") &&
219         featureParams.get("resizable") != "no" &&
220         featureParams.get("resizable") != "0"
221     );
222   },
224   /**
225    * Close the dialog and mark it as aborted.
226    */
227   abort() {
228     this._closingEvent = new CustomEvent("dialogclosing", {
229       bubbles: true,
230       detail: { dialog: this, abort: true },
231     });
232     this._frame.contentWindow?.close();
233     // It's possible that we're aborting this dialog before we've had a
234     // chance to set up the contentWindow.close function override in
235     // _onContentLoaded. If so, call this.close() directly to clean things
236     // up. That'll be a no-op if the contentWindow.close override had been
237     // set up, since this.close is idempotent.
238     this.close(this._closingEvent);
239   },
241   close(aEvent = null) {
242     if (this._isClosing) {
243       return;
244     }
245     this._isClosing = true;
246     this._closingPromise = new Promise(resolve => {
247       this._resolveClosePromise = resolve;
248     });
250     if (this._closingCallback) {
251       try {
252         this._closingCallback.call(null, aEvent);
253       } catch (ex) {
254         console.error(ex);
255       }
256       this._closingCallback = null;
257     }
259     this._removeDialogEventListeners();
261     this._overlay.style.visibility = "";
262     // Clear the sizing inline styles.
263     this._frame.removeAttribute("style");
264     // Clear the sizing attributes
265     this._box.removeAttribute("width");
266     this._box.removeAttribute("height");
267     this._box.style.removeProperty("--box-max-height-requested");
268     this._box.style.removeProperty("--box-max-width-requested");
269     this._box.style.removeProperty("min-height");
270     this._box.style.removeProperty("min-width");
271     this._overlay.style.removeProperty("--subdialog-inner-height");
273     let onClosed = () => {
274       this._openedURL = null;
276       this._resolveClosePromise();
278       if (this._closedCallback) {
279         try {
280           this._closedCallback.call(null, aEvent);
281         } catch (ex) {
282           console.error(ex);
283         }
284         this._closedCallback = null;
285       }
286     };
288     // Wait for the frame to unload before running the closed callback.
289     if (this._frame.contentWindow) {
290       this._frame.contentWindow.addEventListener("unload", onClosed, {
291         once: true,
292       });
293     } else {
294       onClosed();
295     }
297     this._overlay.dispatchEvent(
298       new CustomEvent("dialogclose", {
299         bubbles: true,
300         detail: { dialog: this },
301       })
302     );
304     // Defer removing the overlay so the frame content window can unload.
305     Services.tm.dispatchToMainThread(() => {
306       this._overlay.remove();
307     });
308   },
310   handleEvent(aEvent) {
311     switch (aEvent.type) {
312       case "click":
313         // Close the dialog if the user clicked the overlay background, just
314         // like when the user presses the ESC key (case "command" below).
315         if (aEvent.target !== this._overlay) {
316           break;
317         }
318         if (this._consumeOutsideClicks) {
319           this._frame.contentWindow.close();
320           break;
321         }
322         this._frame.focus();
323         break;
324       case "command":
325         this._frame.contentWindow.close();
326         break;
327       case "dialogclosing":
328         this._onDialogClosing(aEvent);
329         break;
330       case "DOMTitleChanged":
331         this.updateTitle(aEvent);
332         break;
333       case "DOMFrameContentLoaded":
334         this._onContentLoaded(aEvent);
335         break;
336       case "load":
337         this._onLoad(aEvent);
338         break;
339       case "unload":
340         this._onUnload(aEvent);
341         break;
342       case "keydown":
343         this._onKeyDown(aEvent);
344         break;
345       case "focus":
346         this._onParentWinFocus(aEvent);
347         break;
348     }
349   },
351   /* Private methods */
353   _onUnload(aEvent) {
354     if (
355       aEvent.target !== this._frame?.contentDocument ||
356       aEvent.target.location.href !== this._openedURL
357     ) {
358       return;
359     }
360     this.abort();
361   },
363   _onContentLoaded(aEvent) {
364     if (
365       aEvent.target != this._frame ||
366       aEvent.target.contentWindow.location == "about:blank"
367     ) {
368       return;
369     }
371     for (let styleSheetURL of this._injectedStyleSheets) {
372       this.injectStylesheet(styleSheetURL);
373     }
375     let { contentDocument } = this._frame;
376     // Provide the ability for the dialog to know that it is loaded in a frame
377     // rather than as a top-level window.
378     for (let dialog of contentDocument.querySelectorAll("dialog")) {
379       dialog.setAttribute("subdialog", "true");
380     }
381     // Sub-dialogs loaded in a chrome window should use the system font size so
382     // that the user has a way to increase or decrease it via system settings.
383     // Sub-dialogs loaded in the content area, on the other hand, can be zoomed
384     // like web content.
385     if (this._window.isChromeWindow) {
386       contentDocument.documentElement.classList.add("system-font-size");
387     }
388     // Used by CSS to give the appropriate background colour in dark mode.
389     contentDocument.documentElement.setAttribute("dialogroot", "true");
391     this._frame.contentWindow.addEventListener("dialogclosing", this);
393     let oldResizeBy = this._frame.contentWindow.resizeBy;
394     this._frame.contentWindow.resizeBy = (resizeByWidth, resizeByHeight) => {
395       // Only handle resizeByHeight currently.
396       let frameHeight = this._overlay.style.getPropertyValue(
397         "--subdialog-inner-height"
398       );
399       if (frameHeight) {
400         frameHeight = parseFloat(frameHeight);
401       } else {
402         frameHeight = this._frame.clientHeight;
403       }
404       let boxMinHeight = parseFloat(
405         this._window.getComputedStyle(this._box).minHeight
406       );
408       this._box.style.minHeight = boxMinHeight + resizeByHeight + "px";
410       this._overlay.style.setProperty(
411         "--subdialog-inner-height",
412         frameHeight + resizeByHeight + "px"
413       );
415       oldResizeBy.call(
416         this._frame.contentWindow,
417         resizeByWidth,
418         resizeByHeight
419       );
420     };
422     // Defining resizeDialog on the contentWindow object to resize dialogs when prompted
423     this._frame.contentWindow.resizeDialog = () => {
424       return this.resizeDialog();
425     };
427     // Make window.close calls work like dialog closing.
428     let oldClose = this._frame.contentWindow.close;
429     this._frame.contentWindow.close = () => {
430       var closingEvent = this._closingEvent;
431       // If this._closingEvent is set, the dialog is closed externally
432       // (dialog.js) and "dialogclosing" has already been dispatched.
433       if (!closingEvent) {
434         // If called without closing event, we need to create and dispatch it.
435         // This is the case for any external close calls not going through
436         // dialog.js.
437         closingEvent = new CustomEvent("dialogclosing", {
438           bubbles: true,
439           detail: { button: null },
440         });
442         this._frame.contentWindow.dispatchEvent(closingEvent);
443       } else if (this._closingEvent.detail?.abort) {
444         // If the dialog is aborted (SubDialog#abort) we need to dispatch the
445         // "dialogclosing" event ourselves.
446         this._frame.contentWindow.dispatchEvent(closingEvent);
447       }
449       this.close(closingEvent);
450       oldClose.call(this._frame.contentWindow);
451     };
453     // XXX: Hack to make focus during the dialog's load functions work. Make the element visible
454     // sooner in DOMContentLoaded but mostly invisible instead of changing visibility just before
455     // the dialog's load event.
456     // Note that this needs to inherit so that hideDialog() works as expected.
457     this._overlay.style.visibility = "inherit";
458     this._overlay.style.opacity = "0.01";
460     // Ensure the document gets an a11y role of dialog.
461     const a11yDoc = contentDocument.body || contentDocument.documentElement;
462     a11yDoc.setAttribute("role", "dialog");
464     Services.obs.notifyObservers(this._frame.contentWindow, "subdialog-loaded");
465   },
467   async _onLoad(aEvent) {
468     let target = aEvent.currentTarget;
469     if (target.contentWindow.location == "about:blank") {
470       return;
471     }
473     // In order to properly calculate the sizing of the subdialog, we need to
474     // ensure that all of the l10n is done.
475     if (target.contentDocument.l10n) {
476       await target.contentDocument.l10n.ready;
477     }
479     // Some subdialogs may want to perform additional, asynchronous steps during initializations.
480     //
481     // In that case, we expect them to define a Promise which will delay measuring
482     // until the promise is fulfilled.
483     if (target.contentDocument.mozSubdialogReady) {
484       await target.contentDocument.mozSubdialogReady;
485     }
487     await this.resizeDialog();
488     this._resolveDialogReady();
489   },
491   async resizeDialog() {
492     // Do this on load to wait for the CSS to load and apply before calculating the size.
493     let docEl = this._frame.contentDocument.documentElement;
495     // These are deduced from styles which we don't change, so it's safe to get them now:
496     let boxHorizontalBorder =
497       2 * parseFloat(this._window.getComputedStyle(this._box).borderLeftWidth);
498     let frameHorizontalMargin =
499       2 * parseFloat(this._window.getComputedStyle(this._frame).marginLeft);
501     // Then determine and set a bunch of width stuff:
502     let { scrollWidth } = docEl.ownerDocument.body || docEl;
503     // We need to convert em to px because an em value from the dialog window could
504     // translate to something else in the host window, as font sizes may vary.
505     let frameMinWidth =
506       this._emToPx(docEl.style.minWidth) ||
507       this._emToPx(docEl.style.width) ||
508       scrollWidth + "px";
509     let frameWidth = docEl.getAttribute("width")
510       ? docEl.getAttribute("width") + "px"
511       : scrollWidth + "px";
512     if (
513       this._box.getAttribute("sizeto") == "available" &&
514       docEl.style.maxWidth
515     ) {
516       this._box.style.setProperty(
517         "--box-max-width-requested",
518         this._emToPx(docEl.style.maxWidth)
519       );
520     }
522     if (this._box.getAttribute("sizeto") != "available") {
523       this._frame.style.width = frameWidth;
524       this._frame.style.minWidth = frameMinWidth;
525     }
527     let boxMinWidth = `calc(${
528       boxHorizontalBorder + frameHorizontalMargin
529     }px + ${frameMinWidth})`;
531     // Temporary fix to allow parent chrome to collapse properly to min width.
532     // See Bug 1658722.
533     if (this._window.isChromeWindow) {
534       boxMinWidth = `min(80vw, ${boxMinWidth})`;
535     }
536     this._box.style.minWidth = boxMinWidth;
538     this.resizeVertically();
540     this._overlay.dispatchEvent(
541       new CustomEvent("dialogopen", {
542         bubbles: true,
543         detail: { dialog: this },
544       })
545     );
546     this._overlay.style.visibility = "inherit";
547     this._overlay.style.opacity = ""; // XXX: focus hack continued from _onContentLoaded
549     if (this._box.getAttribute("resizable") == "true") {
550       this._onResize = this._onResize.bind(this);
551       this._resizeObserver = new this._window.MutationObserver(this._onResize);
552       this._resizeObserver.observe(this._box, { attributes: true });
553     }
555     this._trapFocus();
557     this._resizeCallback?.({
558       title: this._titleElement,
559       frame: this._frame,
560     });
561   },
563   resizeVertically() {
564     let docEl = this._frame.contentDocument.documentElement;
565     let getDocHeight = () => {
566       let { scrollHeight } = docEl.ownerDocument.body || docEl;
567       // We need to convert em to px because an em value from the dialog window could
568       // translate to something else in the host window, as font sizes may vary.
569       return this._emToPx(docEl.style.height) || scrollHeight + "px";
570     };
572     // If the title bar is disabled (not in the template),
573     // set its height to 0 for the calculation.
574     let titleBarHeight = 0;
575     if (this._titleBar) {
576       titleBarHeight =
577         this._titleBar.clientHeight +
578         parseFloat(
579           this._window.getComputedStyle(this._titleBar).borderBottomWidth
580         );
581     }
583     let boxVerticalBorder =
584       2 * parseFloat(this._window.getComputedStyle(this._box).borderTopWidth);
585     let frameVerticalMargin =
586       2 * parseFloat(this._window.getComputedStyle(this._frame).marginTop);
588     // The difference between the frame and box shouldn't change, either:
589     let boxRect = this._box.getBoundingClientRect();
590     let frameRect = this._frame.getBoundingClientRect();
591     let frameSizeDifference =
592       frameRect.top - boxRect.top + (boxRect.bottom - frameRect.bottom);
594     let contentPane =
595       this._frame.contentDocument.querySelector(".contentPane") ||
596       this._frame.contentDocument.querySelector("dialog");
598     let sizeTo = this._box.getAttribute("sizeto");
599     if (["available", "limitheight"].includes(sizeTo)) {
600       if (sizeTo == "limitheight") {
601         this._overlay.style.setProperty("--doc-height-px", getDocHeight());
602         contentPane?.classList.add("sizeDetermined");
603       } else {
604         if (docEl.style.maxHeight) {
605           this._box.style.setProperty(
606             "--box-max-height-requested",
607             this._emToPx(docEl.style.maxHeight)
608           );
609         }
610         // Inform the CSS of the toolbar height so the bottom padding can be
611         // correctly calculated.
612         this._box.style.setProperty("--box-top-px", `${boxRect.top}px`);
613       }
614       return;
615     }
617     // Now do the same but for the height. We need to do this afterwards because otherwise
618     // XUL assumes we'll optimize for height and gives us "wrong" values which then are no
619     // longer correct after we set the width:
620     let frameMinHeight = getDocHeight();
621     let frameHeight = docEl.getAttribute("height")
622       ? docEl.getAttribute("height") + "px"
623       : frameMinHeight;
625     // Now check if the frame height we calculated is possible at this window size,
626     // accounting for titlebar, padding/border and some spacing.
627     let frameOverhead = frameSizeDifference + titleBarHeight;
628     let maxHeight = this._window.innerHeight - frameOverhead;
629     // Do this with a frame height in pixels...
630     if (!frameHeight.endsWith("px")) {
631       console.error(
632         "This dialog (",
633         this._frame.contentWindow.location.href,
634         ") set a height in non-px-non-em units ('",
635         frameHeight,
636         "'), " +
637           "which is likely to lead to bad sizing in in-content preferences. " +
638           "Please consider changing this."
639       );
640     }
642     if (
643       parseFloat(frameMinHeight) > maxHeight ||
644       parseFloat(frameHeight) > maxHeight
645     ) {
646       // If the height is bigger than that of the window, we should let the
647       // contents scroll. The class is set on the "dialog" element, unless a
648       // content pane exists, which is usually the case when the "window"
649       // element is used to implement the subdialog instead.
650       frameMinHeight = maxHeight + "px";
651       // There also instances where the subdialog is neither implemented using
652       // a content pane, nor a <dialog> (such as manageAddresses.xhtml)
653       // so make sure to check that we actually got a contentPane before we
654       // use it.
655       contentPane?.classList.add("doScroll");
656     }
658     this._overlay.style.setProperty("--subdialog-inner-height", frameHeight);
659     this._frame.style.height = `min(
660       calc(100vh - ${frameOverhead}px),
661       var(--subdialog-inner-height, ${frameHeight})
662     )`;
663     this._box.style.minHeight = `calc(
664       ${boxVerticalBorder + titleBarHeight + frameVerticalMargin}px +
665       ${frameMinHeight}
666     )`;
667   },
669   /**
670    * Helper for converting em to px because an em value from the dialog window could
671    * translate to something else in the host window, as font sizes may vary.
672    *
673    * @param {String} val
674    *                 A CSS length value.
675    * @return {String} The converted CSS length value, or the original value if
676    *                  no conversion took place.
677    */
678   _emToPx(val) {
679     if (val && val.endsWith("em")) {
680       let { fontSize } = this.frameContentWindow.getComputedStyle(
681         this._frame.contentDocument.documentElement
682       );
683       return parseFloat(val) * parseFloat(fontSize) + "px";
684     }
685     return val;
686   },
688   _onResize(mutations) {
689     let frame = this._frame;
690     // The width and height styles are needed for the initial
691     // layout of the frame, but afterward they need to be removed
692     // or their presence will restrict the contents of the <browser>
693     // from resizing to a smaller size.
694     frame.style.removeProperty("width");
695     frame.style.removeProperty("height");
697     let docEl = frame.contentDocument.documentElement;
698     let persistedAttributes = docEl.getAttribute("persist");
699     if (
700       !persistedAttributes ||
701       (!persistedAttributes.includes("width") &&
702         !persistedAttributes.includes("height"))
703     ) {
704       return;
705     }
707     for (let mutation of mutations) {
708       if (mutation.attributeName == "width") {
709         docEl.setAttribute("width", docEl.scrollWidth);
710       } else if (mutation.attributeName == "height") {
711         docEl.setAttribute("height", docEl.scrollHeight);
712       }
713     }
714   },
716   _onDialogClosing(aEvent) {
717     this._frame.contentWindow.removeEventListener("dialogclosing", this);
718     this._closingEvent = aEvent;
719   },
721   _onKeyDown(aEvent) {
722     // Close on ESC key if target is SubDialog
723     // If we're in the parent window, we need to check if the SubDialogs
724     // frame is targeted, so we don't close the wrong dialog.
725     if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE && !aEvent.defaultPrevented) {
726       if (
727         (this._window.isChromeWindow && aEvent.currentTarget == this._box) ||
728         (!this._window.isChromeWindow && aEvent.currentTarget == this._window)
729       ) {
730         // Prevent ESC on SubDialog from cancelling page load (Bug 1665339).
731         aEvent.preventDefault();
732         this._frame.contentWindow.close();
733         return;
734       }
735     }
737     if (
738       this._window.isChromeWindow ||
739       aEvent.keyCode != aEvent.DOM_VK_TAB ||
740       aEvent.ctrlKey ||
741       aEvent.altKey ||
742       aEvent.metaKey
743     ) {
744       return;
745     }
747     let fm = Services.focus;
749     let isLastFocusableElement = el => {
750       // XXXgijs unfortunately there is no way to get the last focusable element without asking
751       // the focus manager to move focus to it.
752       let rv =
753         el ==
754         fm.moveFocus(this._frame.contentWindow, null, fm.MOVEFOCUS_LAST, 0);
755       fm.setFocus(el, 0);
756       return rv;
757     };
759     let forward = !aEvent.shiftKey;
760     // check if focus is leaving the frame (incl. the close button):
761     if (
762       (aEvent.target == this._closeButton && !forward) ||
763       (isLastFocusableElement(aEvent.originalTarget) && forward)
764     ) {
765       aEvent.preventDefault();
766       aEvent.stopImmediatePropagation();
768       let parentWin = this._window.docShell.chromeEventHandler.ownerGlobal;
769       if (forward) {
770         fm.moveFocus(parentWin, null, fm.MOVEFOCUS_FIRST, fm.FLAG_BYKEY);
771       } else {
772         // Somehow, moving back 'past' the opening doc is not trivial. Cheat by doing it in 2 steps:
773         fm.moveFocus(this._window, null, fm.MOVEFOCUS_ROOT, fm.FLAG_BYKEY);
774         fm.moveFocus(parentWin, null, fm.MOVEFOCUS_BACKWARD, fm.FLAG_BYKEY);
775       }
776     }
777   },
779   _onParentWinFocus(aEvent) {
780     // Explicitly check for the focus target of |window| to avoid triggering this when the window
781     // is refocused
782     if (
783       this._closeButton &&
784       aEvent.target != this._closeButton &&
785       aEvent.target != this._window
786     ) {
787       this._closeButton.focus();
788     }
789   },
791   /**
792    * Setup dialog event listeners.
793    * @param {Boolean} [includeLoad] - Whether to register load/unload listeners.
794    */
795   _addDialogEventListeners(includeLoad = true) {
796     if (this._window.isChromeWindow) {
797       // Only register an event listener if we have a title to show.
798       if (this._titleBar) {
799         this._frame.addEventListener("DOMTitleChanged", this, true);
800       }
802       if (includeLoad) {
803         this._window.addEventListener("unload", this, true);
804       }
805     } else {
806       let chromeBrowser = this._window.docShell.chromeEventHandler;
808       if (includeLoad) {
809         // For content windows we listen for unload of the browser
810         chromeBrowser.addEventListener("unload", this, true);
811       }
813       if (this._titleBar) {
814         chromeBrowser.addEventListener("DOMTitleChanged", this, true);
815       }
816     }
818     // Make the close button work.
819     this._closeButton?.addEventListener("command", this);
821     if (includeLoad) {
822       // DOMFrameContentLoaded only fires on the top window
823       this._window.addEventListener("DOMFrameContentLoaded", this, true);
825       // Wait for the stylesheets injected during DOMContentLoaded to load before showing the dialog
826       // otherwise there is a flicker of the stylesheet applying.
827       this._frame.addEventListener("load", this, true);
828     }
830     // Ensure we get <esc> keypresses even if nothing in the subdialog is focusable
831     // (happens on OS X when only text inputs and lists are focusable, and
832     //  the subdialog only has checkboxes/radiobuttons/buttons)
833     if (!this._window.isChromeWindow) {
834       this._window.addEventListener("keydown", this, true);
835     }
837     this._overlay.addEventListener("click", this, true);
838   },
840   /**
841    * Remove dialog event listeners.
842    * @param {Boolean} [includeLoad] - Whether to remove load/unload listeners.
843    */
844   _removeDialogEventListeners(includeLoad = true) {
845     if (this._window.isChromeWindow) {
846       this._frame.removeEventListener("DOMTitleChanged", this, true);
848       if (includeLoad) {
849         this._window.removeEventListener("unload", this, true);
850       }
851     } else {
852       let chromeBrowser = this._window.docShell.chromeEventHandler;
853       if (includeLoad) {
854         chromeBrowser.removeEventListener("unload", this, true);
855       }
857       chromeBrowser.removeEventListener("DOMTitleChanged", this, true);
858     }
860     this._closeButton?.removeEventListener("command", this);
862     if (includeLoad) {
863       this._window.removeEventListener("DOMFrameContentLoaded", this, true);
864       this._frame.removeEventListener("load", this, true);
865       this._frame.contentWindow.removeEventListener("dialogclosing", this);
866     }
868     this._window.removeEventListener("keydown", this, true);
870     this._overlay.removeEventListener("click", this, true);
872     if (this._resizeObserver) {
873       this._resizeObserver.disconnect();
874       this._resizeObserver = null;
875     }
877     this._untrapFocus();
878   },
880   /**
881    * Focus the dialog content.
882    * If the embedded document defines a custom focus handler it will be called.
883    * Otherwise we will focus the first focusable element in the content window.
884    * @param {boolean} [isInitialFocus] - Whether the dialog is focused for the
885    * first time after opening.
886    */
887   focus(isInitialFocus = false) {
888     // If the content window has its own focus logic, hand off the focus call.
889     let focusHandler = this._frame?.contentDocument?.subDialogSetDefaultFocus;
890     if (focusHandler) {
891       focusHandler(isInitialFocus);
892       return;
893     }
894     // Handle focus ourselves. Try to move the focus to the first element in
895     // the content window.
896     let fm = Services.focus;
898     // We're intentionally hiding the focus ring here for now per bug 1704882,
899     // but we aim to have a better fix that retains the focus ring for users
900     // that had brought up the dialog by keyboard in bug 1708261.
901     let focusedElement = fm.moveFocus(
902       this._frame.contentWindow,
903       null,
904       fm.MOVEFOCUS_FIRST,
905       fm.FLAG_NOSHOWRING
906     );
907     if (!focusedElement) {
908       // Ensure the focus is pulled out of the content document even if there's
909       // nothing focusable in the dialog.
910       this._frame.focus();
911     }
912   },
914   _trapFocus() {
915     // Attach a system event listener so the dialog can cancel keydown events.
916     // See Bug 1669990.
917     this._box.addEventListener("keydown", this, { mozSystemGroup: true });
918     this._closeButton?.addEventListener("keydown", this);
920     if (!this._window.isChromeWindow) {
921       this._window.addEventListener("focus", this, true);
922     }
923   },
925   _untrapFocus() {
926     this._box.removeEventListener("keydown", this, { mozSystemGroup: true });
927     this._closeButton?.removeEventListener("keydown", this);
928     this._window.removeEventListener("focus", this, true);
929   },
933  * Manages multiple SubDialogs in a dialog stack element.
934  */
935 export class SubDialogManager {
936   /**
937    * @param {Object} options - Dialog manager options.
938    * @param {DOMNode} options.dialogStack - Container element for all dialogs
939    * this instance manages.
940    * @param {DOMNode} options.dialogTemplate - Element to use as template for
941    * constructing new dialogs.
942    * @param {Number} [options.orderType] - Whether dialogs should be ordered as
943    * a stack or a queue.
944    * @param {Boolean} [options.allowDuplicateDialogs] - Whether to allow opening
945    * duplicate dialogs (same URI) at the same time. If disabled, opening a
946    * dialog with the same URI as an existing dialog will be a no-op.
947    * @param {Object} options.dialogOptions - Options passed to every
948    * SubDialog instance.
949    * @see {@link SubDialog} for a list of dialog options.
950    */
951   constructor({
952     dialogStack,
953     dialogTemplate,
954     orderType = SubDialogManager.ORDER_STACK,
955     allowDuplicateDialogs = false,
956     dialogOptions,
957   }) {
958     /**
959      * New dialogs are pushed to the end of the _dialogs array.
960      * Depending on the orderType either the last element (stack) or the first
961      * element (queue) in the array will be the top and visible.
962      * @type {SubDialog[]}
963      */
964     this._dialogs = [];
965     this._dialogStack = dialogStack;
966     this._dialogTemplate = dialogTemplate;
967     this._topLevelPrevActiveElement = null;
968     this._orderType = orderType;
969     this._allowDuplicateDialogs = allowDuplicateDialogs;
970     this._dialogOptions = dialogOptions;
972     this._preloadDialog = new SubDialog({
973       template: this._dialogTemplate,
974       parentElement: this._dialogStack,
975       id: SubDialogManager._nextDialogID++,
976       dialogOptions: this._dialogOptions,
977     });
978   }
980   /**
981    * Get the dialog which is currently on top. This depends on whether the
982    * dialogs are in a stack or a queue.
983    */
984   get _topDialog() {
985     if (!this._dialogs.length) {
986       return undefined;
987     }
988     if (this._orderType === SubDialogManager.ORDER_STACK) {
989       return this._dialogs[this._dialogs.length - 1];
990     }
991     return this._dialogs[0];
992   }
994   open(
995     aURL,
996     {
997       features,
998       closingCallback,
999       closedCallback,
1000       allowDuplicateDialogs,
1001       sizeTo,
1002       hideContent,
1003     } = {},
1004     ...aParams
1005   ) {
1006     let allowDuplicates =
1007       allowDuplicateDialogs != null
1008         ? allowDuplicateDialogs
1009         : this._allowDuplicateDialogs;
1010     // If we're already open/opening on this URL, do nothing.
1011     if (
1012       !allowDuplicates &&
1013       this._dialogs.some(dialog => dialog._openedURL == aURL)
1014     ) {
1015       return undefined;
1016     }
1018     let doc = this._dialogStack.ownerDocument;
1020     // For dialog stacks, remember the last active element before opening the
1021     // next dialog. This allows us to restore focus on dialog close.
1022     if (
1023       this._orderType === SubDialogManager.ORDER_STACK &&
1024       this._dialogs.length
1025     ) {
1026       this._topDialog._prevActiveElement = doc.activeElement;
1027     }
1029     if (!this._dialogs.length) {
1030       // When opening the first dialog, show the dialog stack.
1031       this._dialogStack.hidden = false;
1032       this._dialogStack.classList.remove("temporarilyHidden");
1033       this._topLevelPrevActiveElement = doc.activeElement;
1034     }
1036     // Consumers may pass this flag to make the dialog overlay background opaque,
1037     // effectively hiding the content behind it. For example,
1038     // this is used by the prompt code to prevent certain http authentication spoofing scenarios.
1039     if (hideContent) {
1040       this._preloadDialog._overlay.setAttribute("hideContent", true);
1041     }
1042     this._dialogs.push(this._preloadDialog);
1043     this._preloadDialog.open(
1044       aURL,
1045       {
1046         features,
1047         closingCallback,
1048         closedCallback,
1049         sizeTo,
1050       },
1051       ...aParams
1052     );
1054     let openedDialog = this._preloadDialog;
1056     this._preloadDialog = new SubDialog({
1057       template: this._dialogTemplate,
1058       parentElement: this._dialogStack,
1059       id: SubDialogManager._nextDialogID++,
1060       dialogOptions: this._dialogOptions,
1061     });
1063     if (this._dialogs.length == 1) {
1064       this._ensureStackEventListeners();
1065     }
1067     return openedDialog;
1068   }
1070   close() {
1071     this._topDialog.close();
1072   }
1074   /**
1075    * Hides the dialog stack for a specific browser, without actually destroying
1076    * frames for stuff within it.
1077    *
1078    * @param aBrowser - The browser associated with the tab dialog.
1079    */
1080   hideDialog(aBrowser) {
1081     aBrowser.removeAttribute("tabDialogShowing");
1082     this._dialogStack.classList.add("temporarilyHidden");
1083   }
1085   /**
1086    * Abort open dialogs.
1087    * @param {function} [filterFn] - Function which should return true for
1088    * dialogs that should be aborted and false for dialogs that should remain
1089    * open. Defaults to aborting all dialogs.
1090    */
1091   abortDialogs(filterFn = () => true) {
1092     this._dialogs.filter(filterFn).forEach(dialog => dialog.abort());
1093   }
1095   get hasDialogs() {
1096     if (!this._dialogs.length) {
1097       return false;
1098     }
1099     return this._dialogs.some(dialog => !dialog._isClosing);
1100   }
1102   get dialogs() {
1103     return [...this._dialogs];
1104   }
1106   focusTopDialog() {
1107     this._topDialog?.focus();
1108   }
1110   handleEvent(aEvent) {
1111     switch (aEvent.type) {
1112       case "dialogopen": {
1113         this._onDialogOpen(aEvent.detail.dialog);
1114         break;
1115       }
1116       case "dialogclose": {
1117         this._onDialogClose(aEvent.detail.dialog);
1118         break;
1119       }
1120     }
1121   }
1123   _onDialogOpen(dialog) {
1124     let lowerDialogs = [];
1125     if (dialog == this._topDialog) {
1126       dialog.focus(true);
1127     } else {
1128       // Opening dialog is not on top, hide it
1129       lowerDialogs.push(dialog);
1130     }
1132     // For stack order, hide the previous top
1133     if (
1134       this._dialogs.length &&
1135       this._orderType === SubDialogManager.ORDER_STACK
1136     ) {
1137       let index = this._dialogs.indexOf(dialog);
1138       if (index > 0) {
1139         lowerDialogs.push(this._dialogs[index - 1]);
1140       }
1141     }
1143     lowerDialogs.forEach(d => {
1144       if (d._overlay.hasAttribute("topmost")) {
1145         d._overlay.removeAttribute("topmost");
1146         d._removeDialogEventListeners(false);
1147       }
1148     });
1149   }
1151   _onDialogClose(dialog) {
1152     this._dialogs.splice(this._dialogs.indexOf(dialog), 1);
1154     if (this._topDialog) {
1155       // The prevActiveElement is only set for stacked dialogs
1156       if (this._topDialog._prevActiveElement) {
1157         this._topDialog._prevActiveElement.focus();
1158       } else {
1159         this._topDialog.focus(true);
1160       }
1161       this._topDialog._overlay.setAttribute("topmost", true);
1162       this._topDialog._addDialogEventListeners(false);
1163       this._dialogStack.hidden = false;
1164       this._dialogStack.classList.remove("temporarilyHidden");
1165     } else {
1166       // We have closed the last dialog, do cleanup.
1167       this._topLevelPrevActiveElement.focus();
1168       this._dialogStack.hidden = true;
1169       this._removeStackEventListeners();
1170     }
1171   }
1173   _ensureStackEventListeners() {
1174     this._dialogStack.addEventListener("dialogopen", this);
1175     this._dialogStack.addEventListener("dialogclose", this);
1176   }
1178   _removeStackEventListeners() {
1179     this._dialogStack.removeEventListener("dialogopen", this);
1180     this._dialogStack.removeEventListener("dialogclose", this);
1181   }
1184 // Used for the SubDialogManager orderType option.
1185 SubDialogManager.ORDER_STACK = 0;
1186 SubDialogManager.ORDER_QUEUE = 1;
1188 SubDialogManager._nextDialogID = 0;