Bug 1857998 [wpt PR 42432] - [css-nesting-ident] Enable relaxed syntax, a=testonly
[gecko.git] / toolkit / modules / SubDialog.sys.mjs
blob534415b94d2511aa2f77fcf5c60cf3c059c17d0c
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 (!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();
183     }
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) {
188         this.close();
189       }
190       let args = Array.from(arguments);
191       this._closingPromise.then(() => {
192         this.open.apply(this, args);
193       });
194       return;
195     }
196     this._addDialogEventListeners();
198     // Ensure we end any pending drag sessions:
199     try {
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);
205       }
206     } catch (ex) {
207       console.error(ex);
208     }
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"
214     }`;
215     if (features) {
216       dialogFeatures = `${features},${dialogFeatures}`;
217     }
219     dialog = this._window.openDialog(
220       aURL,
221       `dialogFrame-${this._id}`,
222       dialogFeatures,
223       ...aParams
224     );
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(
233       "resizable",
234       featureParams.has("resizable") &&
235         featureParams.get("resizable") != "no" &&
236         featureParams.get("resizable") != "0"
237     );
238   },
240   /**
241    * Close the dialog and mark it as aborted.
242    */
243   abort() {
244     this._closingEvent = new CustomEvent("dialogclosing", {
245       bubbles: true,
246       detail: { dialog: this, abort: true },
247     });
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);
255   },
257   close(aEvent = null) {
258     if (this._isClosing) {
259       return;
260     }
261     this._isClosing = true;
262     this._closingPromise = new Promise(resolve => {
263       this._resolveClosePromise = resolve;
264     });
266     if (this._closingCallback) {
267       try {
268         this._closingCallback.call(null, aEvent);
269       } catch (ex) {
270         console.error(ex);
271       }
272       this._closingCallback = null;
273     }
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) {
295         try {
296           this._closedCallback.call(null, aEvent);
297         } catch (ex) {
298           console.error(ex);
299         }
300         this._closedCallback = null;
301       }
302     };
304     // Wait for the frame to unload before running the closed callback.
305     if (this._frame.contentWindow) {
306       this._frame.contentWindow.addEventListener("unload", onClosed, {
307         once: true,
308       });
309     } else {
310       onClosed();
311     }
313     this._overlay.dispatchEvent(
314       new CustomEvent("dialogclose", {
315         bubbles: true,
316         detail: { dialog: this },
317       })
318     );
320     // Defer removing the overlay so the frame content window can unload.
321     Services.tm.dispatchToMainThread(() => {
322       this._overlay.remove();
323     });
324   },
326   handleEvent(aEvent) {
327     switch (aEvent.type) {
328       case "click":
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) {
332           break;
333         }
334         if (this._consumeOutsideClicks) {
335           this._frame.contentWindow.close();
336           break;
337         }
338         this._frame.focus();
339         break;
340       case "command":
341         this._frame.contentWindow.close();
342         break;
343       case "dialogclosing":
344         this._onDialogClosing(aEvent);
345         break;
346       case "DOMTitleChanged":
347         this.updateTitle(aEvent);
348         break;
349       case "DOMFrameContentLoaded":
350         this._onContentLoaded(aEvent);
351         break;
352       case "load":
353         this._onLoad(aEvent);
354         break;
355       case "unload":
356         this._onUnload(aEvent);
357         break;
358       case "keydown":
359         this._onKeyDown(aEvent);
360         break;
361       case "focus":
362         this._onParentWinFocus(aEvent);
363         break;
364     }
365   },
367   /* Private methods */
369   _onUnload(aEvent) {
370     if (
371       aEvent.target !== this._frame?.contentDocument ||
372       aEvent.target.location.href !== this._openedURL
373     ) {
374       return;
375     }
376     this.abort();
377   },
379   _onContentLoaded(aEvent) {
380     if (
381       aEvent.target != this._frame ||
382       aEvent.target.contentWindow.location == "about:blank"
383     ) {
384       return;
385     }
387     for (let styleSheetURL of this._injectedStyleSheets) {
388       this.injectStylesheet(styleSheetURL);
389     }
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");
396     }
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
400     // like web content.
401     if (this._window.isChromeWindow) {
402       contentDocument.documentElement.classList.add("system-font-size");
403     }
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"
414       );
415       if (frameHeight) {
416         frameHeight = parseFloat(frameHeight);
417       } else {
418         frameHeight = this._frame.clientHeight;
419       }
420       let boxMinHeight = parseFloat(
421         this._window.getComputedStyle(this._box).minHeight
422       );
424       this._box.style.minHeight = boxMinHeight + resizeByHeight + "px";
426       this._overlay.style.setProperty(
427         "--subdialog-inner-height",
428         frameHeight + resizeByHeight + "px"
429       );
431       oldResizeBy.call(
432         this._frame.contentWindow,
433         resizeByWidth,
434         resizeByHeight
435       );
436     };
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.
444       if (!closingEvent) {
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
447         // dialog.js.
448         closingEvent = new CustomEvent("dialogclosing", {
449           bubbles: true,
450           detail: { button: null },
451         });
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);
458       }
460       this.close(closingEvent);
461       oldClose.call(this._frame.contentWindow);
462     };
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");
476   },
478   async _onLoad(aEvent) {
479     let target = aEvent.currentTarget;
480     if (target.contentWindow.location == "about:blank") {
481       return;
482     }
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;
488     }
490     // Some subdialogs may want to perform additional, asynchronous steps during initializations.
491     //
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;
496     }
498     await this.resizeDialog();
499     this._resolveDialogReady();
500   },
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.
516     let frameMinWidth =
517       this._emToPx(docEl.style.minWidth) ||
518       this._emToPx(docEl.style.width) ||
519       scrollWidth + "px";
520     let frameWidth = docEl.getAttribute("width")
521       ? docEl.getAttribute("width") + "px"
522       : scrollWidth + "px";
523     if (
524       this._box.getAttribute("sizeto") == "available" &&
525       docEl.style.maxWidth
526     ) {
527       this._box.style.setProperty(
528         "--box-max-width-requested",
529         this._emToPx(docEl.style.maxWidth)
530       );
531     }
533     if (this._box.getAttribute("sizeto") != "available") {
534       this._frame.style.width = frameWidth;
535       this._frame.style.minWidth = frameMinWidth;
536     }
538     let boxMinWidth = `calc(${
539       boxHorizontalBorder + frameHorizontalMargin
540     }px + ${frameMinWidth})`;
542     // Temporary fix to allow parent chrome to collapse properly to min width.
543     // See Bug 1658722.
544     if (this._window.isChromeWindow) {
545       boxMinWidth = `min(80vw, ${boxMinWidth})`;
546     }
547     this._box.style.minWidth = boxMinWidth;
549     this.resizeVertically();
551     this._overlay.dispatchEvent(
552       new CustomEvent("dialogopen", {
553         bubbles: true,
554         detail: { dialog: this },
555       })
556     );
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 });
564     }
566     this._trapFocus();
568     this._resizeCallback?.({
569       title: this._titleElement,
570       frame: this._frame,
571     });
572   },
574   resizeVertically() {
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";
581     };
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) {
587       titleBarHeight =
588         this._titleBar.clientHeight +
589         parseFloat(
590           this._window.getComputedStyle(this._titleBar).borderBottomWidth
591         );
592     }
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);
605     let contentPane =
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");
614       } else {
615         if (docEl.style.maxHeight) {
616           this._box.style.setProperty(
617             "--box-max-height-requested",
618             this._emToPx(docEl.style.maxHeight)
619           );
620         }
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`);
624       }
625       return;
626     }
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"
634       : frameMinHeight;
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")) {
642       console.error(
643         "This dialog (",
644         this._frame.contentWindow.location.href,
645         ") set a height in non-px-non-em units ('",
646         frameHeight,
647         "'), " +
648           "which is likely to lead to bad sizing in in-content preferences. " +
649           "Please consider changing this."
650       );
651     }
653     if (
654       parseFloat(frameMinHeight) > maxHeight ||
655       parseFloat(frameHeight) > maxHeight
656     ) {
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
665       // use it.
666       contentPane?.classList.add("doScroll");
667     }
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})
673     )`;
674     this._box.style.minHeight = `calc(
675       ${boxVerticalBorder + titleBarHeight + frameVerticalMargin}px +
676       ${frameMinHeight}
677     )`;
678   },
680   /**
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.
683    *
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.
688    */
689   _emToPx(val) {
690     if (val && val.endsWith("em")) {
691       let { fontSize } = this.frameContentWindow.getComputedStyle(
692         this._frame.contentDocument.documentElement
693       );
694       return parseFloat(val) * parseFloat(fontSize) + "px";
695     }
696     return val;
697   },
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");
710     if (
711       !persistedAttributes ||
712       (!persistedAttributes.includes("width") &&
713         !persistedAttributes.includes("height"))
714     ) {
715       return;
716     }
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);
723       }
724     }
725   },
727   _onDialogClosing(aEvent) {
728     this._frame.contentWindow.removeEventListener("dialogclosing", this);
729     this._closingEvent = aEvent;
730   },
732   _onKeyDown(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) {
737       if (
738         (this._window.isChromeWindow && aEvent.currentTarget == this._box) ||
739         (!this._window.isChromeWindow && aEvent.currentTarget == this._window)
740       ) {
741         // Prevent ESC on SubDialog from cancelling page load (Bug 1665339).
742         aEvent.preventDefault();
743         this._frame.contentWindow.close();
744         return;
745       }
746     }
748     if (
749       this._window.isChromeWindow ||
750       aEvent.keyCode != aEvent.DOM_VK_TAB ||
751       aEvent.ctrlKey ||
752       aEvent.altKey ||
753       aEvent.metaKey
754     ) {
755       return;
756     }
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.
763       let rv =
764         el ==
765         fm.moveFocus(this._frame.contentWindow, null, fm.MOVEFOCUS_LAST, 0);
766       fm.setFocus(el, 0);
767       return rv;
768     };
770     let forward = !aEvent.shiftKey;
771     // check if focus is leaving the frame (incl. the close button):
772     if (
773       (aEvent.target == this._closeButton && !forward) ||
774       (isLastFocusableElement(aEvent.originalTarget) && forward)
775     ) {
776       aEvent.preventDefault();
777       aEvent.stopImmediatePropagation();
779       let parentWin = this._window.docShell.chromeEventHandler.ownerGlobal;
780       if (forward) {
781         fm.moveFocus(parentWin, null, fm.MOVEFOCUS_FIRST, fm.FLAG_BYKEY);
782       } else {
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);
786       }
787     }
788   },
790   _onParentWinFocus(aEvent) {
791     // Explicitly check for the focus target of |window| to avoid triggering this when the window
792     // is refocused
793     if (
794       this._closeButton &&
795       aEvent.target != this._closeButton &&
796       aEvent.target != this._window
797     ) {
798       this._closeButton.focus();
799     }
800   },
802   /**
803    * Setup dialog event listeners.
804    * @param {Boolean} [includeLoad] - Whether to register load/unload listeners.
805    */
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);
811       }
813       if (includeLoad) {
814         this._window.addEventListener("unload", this, true);
815       }
816     } else {
817       let chromeBrowser = this._window.docShell.chromeEventHandler;
819       if (includeLoad) {
820         // For content windows we listen for unload of the browser
821         chromeBrowser.addEventListener("unload", this, true);
822       }
824       if (this._titleBar) {
825         chromeBrowser.addEventListener("DOMTitleChanged", this, true);
826       }
827     }
829     // Make the close button work.
830     this._closeButton?.addEventListener("command", this);
832     if (includeLoad) {
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);
839     }
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);
846     }
848     this._overlay.addEventListener("click", this, true);
849   },
851   /**
852    * Remove dialog event listeners.
853    * @param {Boolean} [includeLoad] - Whether to remove load/unload listeners.
854    */
855   _removeDialogEventListeners(includeLoad = true) {
856     if (this._window.isChromeWindow) {
857       this._frame.removeEventListener("DOMTitleChanged", this, true);
859       if (includeLoad) {
860         this._window.removeEventListener("unload", this, true);
861       }
862     } else {
863       let chromeBrowser = this._window.docShell.chromeEventHandler;
864       if (includeLoad) {
865         chromeBrowser.removeEventListener("unload", this, true);
866       }
868       chromeBrowser.removeEventListener("DOMTitleChanged", this, true);
869     }
871     this._closeButton?.removeEventListener("command", this);
873     if (includeLoad) {
874       this._window.removeEventListener("DOMFrameContentLoaded", this, true);
875       this._frame.removeEventListener("load", this, true);
876       this._frame.contentWindow.removeEventListener("dialogclosing", this);
877     }
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;
886     }
888     this._untrapFocus();
889   },
891   /**
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.
897    */
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;
901     if (focusHandler) {
902       focusHandler(isInitialFocus);
903       return;
904     }
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,
914       null,
915       fm.MOVEFOCUS_FIRST,
916       fm.FLAG_NOSHOWRING
917     );
918     if (!focusedElement) {
919       // Ensure the focus is pulled out of the content document even if there's
920       // nothing focusable in the dialog.
921       this._frame.focus();
922     }
923   },
925   _trapFocus() {
926     // Attach a system event listener so the dialog can cancel keydown events.
927     // See Bug 1669990.
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);
933     }
934   },
936   _untrapFocus() {
937     this._box.removeEventListener("keydown", this, { mozSystemGroup: true });
938     this._closeButton?.removeEventListener("keydown", this);
939     this._window.removeEventListener("focus", this, true);
940   },
944  * Manages multiple SubDialogs in a dialog stack element.
945  */
946 export class SubDialogManager {
947   /**
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.
961    */
962   constructor({
963     dialogStack,
964     dialogTemplate,
965     orderType = SubDialogManager.ORDER_STACK,
966     allowDuplicateDialogs = false,
967     dialogOptions,
968   }) {
969     /**
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[]}
974      */
975     this._dialogs = [];
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,
988     });
989   }
991   /**
992    * Get the dialog which is currently on top. This depends on whether the
993    * dialogs are in a stack or a queue.
994    */
995   get _topDialog() {
996     if (!this._dialogs.length) {
997       return undefined;
998     }
999     if (this._orderType === SubDialogManager.ORDER_STACK) {
1000       return this._dialogs[this._dialogs.length - 1];
1001     }
1002     return this._dialogs[0];
1003   }
1005   open(
1006     aURL,
1007     {
1008       features,
1009       closingCallback,
1010       closedCallback,
1011       allowDuplicateDialogs,
1012       sizeTo,
1013       hideContent,
1014     } = {},
1015     ...aParams
1016   ) {
1017     let allowDuplicates =
1018       allowDuplicateDialogs != null
1019         ? allowDuplicateDialogs
1020         : this._allowDuplicateDialogs;
1021     // If we're already open/opening on this URL, do nothing.
1022     if (
1023       !allowDuplicates &&
1024       this._dialogs.some(dialog => dialog._openedURL == aURL)
1025     ) {
1026       return undefined;
1027     }
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.
1033     if (
1034       this._orderType === SubDialogManager.ORDER_STACK &&
1035       this._dialogs.length
1036     ) {
1037       this._topDialog._prevActiveElement = doc.activeElement;
1038     }
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;
1045     }
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.
1050     if (hideContent) {
1051       this._preloadDialog._overlay.setAttribute("hideContent", true);
1052     }
1053     this._dialogs.push(this._preloadDialog);
1054     this._preloadDialog.open(
1055       aURL,
1056       {
1057         features,
1058         closingCallback,
1059         closedCallback,
1060         sizeTo,
1061       },
1062       ...aParams
1063     );
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,
1072     });
1074     if (this._dialogs.length == 1) {
1075       this._ensureStackEventListeners();
1076     }
1078     return openedDialog;
1079   }
1081   close() {
1082     this._topDialog.close();
1083   }
1085   /**
1086    * Hides the dialog stack for a specific browser, without actually destroying
1087    * frames for stuff within it.
1088    *
1089    * @param aBrowser - The browser associated with the tab dialog.
1090    */
1091   hideDialog(aBrowser) {
1092     aBrowser.removeAttribute("tabDialogShowing");
1093     this._dialogStack.classList.add("temporarilyHidden");
1094   }
1096   /**
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.
1101    */
1102   abortDialogs(filterFn = () => true) {
1103     this._dialogs.filter(filterFn).forEach(dialog => dialog.abort());
1104   }
1106   get hasDialogs() {
1107     if (!this._dialogs.length) {
1108       return false;
1109     }
1110     return this._dialogs.some(dialog => !dialog._isClosing);
1111   }
1113   get dialogs() {
1114     return [...this._dialogs];
1115   }
1117   focusTopDialog() {
1118     this._topDialog?.focus();
1119   }
1121   handleEvent(aEvent) {
1122     switch (aEvent.type) {
1123       case "dialogopen": {
1124         this._onDialogOpen(aEvent.detail.dialog);
1125         break;
1126       }
1127       case "dialogclose": {
1128         this._onDialogClose(aEvent.detail.dialog);
1129         break;
1130       }
1131     }
1132   }
1134   _onDialogOpen(dialog) {
1135     let lowerDialogs = [];
1136     if (dialog == this._topDialog) {
1137       dialog.focus(true);
1138     } else {
1139       // Opening dialog is not on top, hide it
1140       lowerDialogs.push(dialog);
1141     }
1143     // For stack order, hide the previous top
1144     if (
1145       this._dialogs.length &&
1146       this._orderType === SubDialogManager.ORDER_STACK
1147     ) {
1148       let index = this._dialogs.indexOf(dialog);
1149       if (index > 0) {
1150         lowerDialogs.push(this._dialogs[index - 1]);
1151       }
1152     }
1154     lowerDialogs.forEach(d => {
1155       if (d._overlay.hasAttribute("topmost")) {
1156         d._overlay.removeAttribute("topmost");
1157         d._removeDialogEventListeners(false);
1158       }
1159     });
1160   }
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();
1169       } else {
1170         this._topDialog.focus(true);
1171       }
1172       this._topDialog._overlay.setAttribute("topmost", true);
1173       this._topDialog._addDialogEventListeners(false);
1174       this._dialogStack.hidden = false;
1175       this._dialogStack.classList.remove("temporarilyHidden");
1176     } else {
1177       // We have closed the last dialog, do cleanup.
1178       this._topLevelPrevActiveElement.focus();
1179       this._dialogStack.hidden = true;
1180       this._removeStackEventListeners();
1181     }
1182   }
1184   _ensureStackEventListeners() {
1185     this._dialogStack.addEventListener("dialogopen", this);
1186     this._dialogStack.addEventListener("dialogclose", this);
1187   }
1189   _removeStackEventListeners() {
1190     this._dialogStack.removeEventListener("dialogopen", this);
1191     this._dialogStack.removeEventListener("dialogclose", this);
1192   }
1195 // Used for the SubDialogManager orderType option.
1196 SubDialogManager.ORDER_STACK = 0;
1197 SubDialogManager.ORDER_QUEUE = 1;
1199 SubDialogManager._nextDialogID = 0;