no bug - Bumping Firefox l10n changesets r=release a=l10n-bump DONTBUILD CLOSED TREE
[gecko.git] / toolkit / content / widgets / dialog.js
blob52eb2168f8ba7083a1f499c570369f486432d8cf
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
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 // This is loaded into all XUL windows. Wrap in a block to prevent
8 // leaking to window scope.
10   const { AppConstants } = ChromeUtils.importESModule(
11     "resource://gre/modules/AppConstants.sys.mjs"
12   );
14   class MozDialog extends MozXULElement {
15     constructor() {
16       super();
17     }
19     static get observedAttributes() {
20       return super.observedAttributes.concat("subdialog");
21     }
23     attributeChangedCallback(name, oldValue, newValue) {
24       if (name == "subdialog") {
25         console.assert(
26           newValue,
27           `Turning off subdialog style is not supported`
28         );
29         if (this.isConnectedAndReady && !oldValue && newValue) {
30           this.shadowRoot.appendChild(
31             MozXULElement.parseXULToFragment(this.inContentStyle)
32           );
33         }
34         return;
35       }
36       super.attributeChangedCallback(name, oldValue, newValue);
37     }
39     static get inheritedAttributes() {
40       return {
41         ".dialog-button-box":
42           "pack=buttonpack,align=buttonalign,dir=buttondir,orient=buttonorient",
43         "[dlgtype='accept']": "disabled=buttondisabledaccept",
44       };
45     }
47     get inContentStyle() {
48       return `
49       <html:link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
50     `;
51     }
53     get _markup() {
54       let buttons = AppConstants.XP_UNIX
55         ? `
56       <hbox class="dialog-button-box">
57         <button dlgtype="disclosure" hidden="true"/>
58         <button dlgtype="extra2" hidden="true"/>
59         <button dlgtype="extra1" hidden="true"/>
60         <spacer class="button-spacer" part="button-spacer" flex="1"/>
61         <button dlgtype="cancel"/>
62         <button dlgtype="accept"/>
63       </hbox>`
64         : `
65       <hbox class="dialog-button-box" pack="end">
66         <button dlgtype="extra2" hidden="true"/>
67         <spacer class="button-spacer" part="button-spacer" flex="1" hidden="true"/>
68         <button dlgtype="accept"/>
69         <button dlgtype="extra1" hidden="true"/>
70         <button dlgtype="cancel"/>
71         <button dlgtype="disclosure" hidden="true"/>
72       </hbox>`;
74       return `
75       <html:link rel="stylesheet" href="chrome://global/skin/button.css"/>
76       <html:link rel="stylesheet" href="chrome://global/skin/dialog.css"/>
77       ${this.hasAttribute("subdialog") ? this.inContentStyle : ""}
78       <vbox class="box-inherit" part="content-box">
79         <html:slot></html:slot>
80       </vbox>
81       ${buttons}`;
82     }
84     connectedCallback() {
85       if (this.delayConnectedCallback()) {
86         return;
87       }
88       if (this.hasConnected) {
89         return;
90       }
91       this.hasConnected = true;
92       this.attachShadow({ mode: "open" });
94       document.documentElement.setAttribute("role", "dialog");
95       document.l10n?.connectRoot(this.shadowRoot);
97       this.shadowRoot.textContent = "";
98       this.shadowRoot.appendChild(
99         MozXULElement.parseXULToFragment(this._markup)
100       );
101       this.initializeAttributeInheritance();
103       this._configureButtons(this.buttons);
105       window.moveToAlertPosition = this.moveToAlertPosition;
106       window.centerWindowOnScreen = this.centerWindowOnScreen;
108       document.addEventListener(
109         "keypress",
110         event => {
111           if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
112             this._hitEnter(event);
113           } else if (
114             event.keyCode == KeyEvent.DOM_VK_ESCAPE &&
115             !event.defaultPrevented
116           ) {
117             this.cancelDialog();
118           }
119         },
120         { mozSystemGroup: true }
121       );
123       if (AppConstants.platform == "macosx") {
124         document.addEventListener(
125           "keypress",
126           event => {
127             if (event.key == "." && event.metaKey) {
128               this.cancelDialog();
129             }
130           },
131           true
132         );
133       } else {
134         this.addEventListener("focus", this, true);
135         this.shadowRoot.addEventListener("focus", this, true);
136       }
138       // listen for when window is closed via native close buttons
139       window.addEventListener("close", event => {
140         if (!this.cancelDialog()) {
141           event.preventDefault();
142         }
143       });
145       // Call postLoadInit for things that we need to initialize after onload.
146       if (document.readyState == "complete") {
147         this._postLoadInit();
148       } else {
149         window.addEventListener("load", event => this._postLoadInit());
150       }
151     }
153     set buttons(val) {
154       this._configureButtons(val);
155     }
157     get buttons() {
158       return this.getAttribute("buttons");
159     }
161     set defaultButton(val) {
162       this._setDefaultButton(val);
163     }
165     get defaultButton() {
166       if (this.hasAttribute("defaultButton")) {
167         return this.getAttribute("defaultButton");
168       }
169       return "accept"; // default to the accept button
170     }
172     get _strBundle() {
173       if (!this.__stringBundle) {
174         this.__stringBundle = Services.strings.createBundle(
175           "chrome://global/locale/dialog.properties"
176         );
177       }
178       return this.__stringBundle;
179     }
181     acceptDialog() {
182       return this._doButtonCommand("accept");
183     }
185     cancelDialog() {
186       return this._doButtonCommand("cancel");
187     }
189     getButton(aDlgType) {
190       return this._buttons[aDlgType];
191     }
193     get buttonBox() {
194       return this.shadowRoot.querySelector(".dialog-button-box");
195     }
197     // NOTE(emilio): This has to match AppWindow::IntrinsicallySizeShell, to
198     // prevent flickering, see bug 1799394.
199     _sizeToPreferredSize() {
200       const docEl = document.documentElement;
201       const prefWidth = (() => {
202         if (docEl.hasAttribute("width")) {
203           return parseInt(docEl.getAttribute("width"));
204         }
205         let prefWidthProp = docEl.getAttribute("prefwidth");
206         if (prefWidthProp) {
207           let minWidth = parseFloat(
208             getComputedStyle(docEl).getPropertyValue(prefWidthProp)
209           );
210           if (isFinite(minWidth)) {
211             return minWidth;
212           }
213         }
214         return 0;
215       })();
216       window.sizeToContentConstrained({ prefWidth });
217     }
219     moveToAlertPosition() {
220       // hack. we need this so the window has something like its final size
221       if (window.outerWidth == 1) {
222         dump(
223           "Trying to position a sizeless window; caller should have called sizeToContent() or sizeTo(). See bug 75649.\n"
224         );
225         this._sizeToPreferredSize();
226       }
228       if (opener) {
229         var xOffset = (opener.outerWidth - window.outerWidth) / 2;
230         var yOffset = opener.outerHeight / 5;
232         var newX = opener.screenX + xOffset;
233         var newY = opener.screenY + yOffset;
234       } else {
235         newX = (screen.availWidth - window.outerWidth) / 2;
236         newY = (screen.availHeight - window.outerHeight) / 2;
237       }
239       // ensure the window is fully onscreen (if smaller than the screen)
240       if (newX < screen.availLeft) {
241         newX = screen.availLeft + 20;
242       }
243       if (newX + window.outerWidth > screen.availLeft + screen.availWidth) {
244         newX = screen.availLeft + screen.availWidth - window.outerWidth - 20;
245       }
247       if (newY < screen.availTop) {
248         newY = screen.availTop + 20;
249       }
250       if (newY + window.outerHeight > screen.availTop + screen.availHeight) {
251         newY = screen.availTop + screen.availHeight - window.outerHeight - 60;
252       }
254       window.moveTo(newX, newY);
255     }
257     centerWindowOnScreen() {
258       var xOffset = screen.availWidth / 2 - window.outerWidth / 2;
259       var yOffset = screen.availHeight / 2 - window.outerHeight / 2;
261       xOffset = xOffset > 0 ? xOffset : 0;
262       yOffset = yOffset > 0 ? yOffset : 0;
263       window.moveTo(xOffset, yOffset);
264     }
266     // Give focus to the first focusable element in the dialog
267     _setInitialFocusIfNeeded() {
268       let focusedElt = document.commandDispatcher.focusedElement;
269       if (focusedElt) {
270         return;
271       }
273       const defaultButton = this.getButton(this.defaultButton);
274       Services.focus.moveFocus(
275         window,
276         null,
277         Services.focus.MOVEFOCUS_FORWARD,
278         Services.focus.FLAG_NOPARENTFRAME
279       );
281       focusedElt = document.commandDispatcher.focusedElement;
282       if (!focusedElt) {
283         return; // No focusable element?
284       }
286       let firstFocusedElt = focusedElt;
287       while (
288         focusedElt.localName == "tab" ||
289         focusedElt.getAttribute("noinitialfocus") == "true"
290       ) {
291         Services.focus.moveFocus(
292           window,
293           focusedElt,
294           Services.focus.MOVEFOCUS_FORWARD,
295           Services.focus.FLAG_NOPARENTFRAME
296         );
297         focusedElt = document.commandDispatcher.focusedElement;
298         if (focusedElt == firstFocusedElt) {
299           if (focusedElt.getAttribute("noinitialfocus") == "true") {
300             focusedElt.blur();
301           }
302           // Didn't find anything else to focus, we're done.
303           return;
304         }
305       }
307       if (firstFocusedElt.localName == "tab") {
308         if (focusedElt.hasAttribute("dlgtype")) {
309           // We don't want to focus on anonymous OK, Cancel, etc. buttons,
310           // so return focus to the tab itself
311           firstFocusedElt.focus();
312         }
313       } else if (
314         AppConstants.platform != "macosx" &&
315         focusedElt.hasAttribute("dlgtype") &&
316         focusedElt != defaultButton
317       ) {
318         defaultButton.focus();
319         if (document.commandDispatcher.focusedElement != defaultButton) {
320           // If the default button is not focusable, then return focus to the
321           // initial element if possible, or blur otherwise.
322           if (firstFocusedElt.getAttribute("noinitialfocus") == "true") {
323             focusedElt.blur();
324           } else {
325             firstFocusedElt.focus();
326           }
327         }
328       }
329     }
331     async _postLoadInit() {
332       this._setInitialFocusIfNeeded();
333       let finalStep = () => {
334         this._sizeToPreferredSize();
335         this._snapCursorToDefaultButtonIfNeeded();
336       };
337       // As a hack to ensure Windows sizes the window correctly,
338       // _sizeToPreferredSize() needs to happen after
339       // AppWindow::OnChromeLoaded. That one is called right after the load
340       // event dispatch but within the same task. Using direct dispatch let's
341       // all this code run before the next task (which might be a task to
342       // paint the window).
343       // But, MacOS doesn't like resizing after window/dialog becoming visible.
344       // Linux seems to be able to handle both cases.
345       if (Services.appinfo.OS == "Darwin") {
346         finalStep();
347       } else {
348         Services.tm.dispatchDirectTaskToCurrentThread(finalStep);
349       }
350     }
352     // This snaps the cursor to the default button rect on windows, when
353     // SPI_GETSNAPTODEFBUTTON is set.
354     async _snapCursorToDefaultButtonIfNeeded() {
355       const defaultButton = this.getButton(this.defaultButton);
356       if (!defaultButton) {
357         return;
358       }
359       try {
360         // FIXME(emilio, bug 1797624): This setTimeout() ensures enough time
361         // has passed so that the dialog vertical margin has been set by the
362         // front-end. For subdialogs, cursor positioning should probably be
363         // done by the opener instead, once the dialog is positioned.
364         await new Promise(r => setTimeout(r, 0));
365         await window.promiseDocumentFlushed(() => {});
366         window.notifyDefaultButtonLoaded(defaultButton);
367       } catch (e) {}
368     }
370     _configureButtons(aButtons) {
371       // by default, get all the anonymous button elements
372       var buttons = {};
373       this._buttons = buttons;
375       for (let type of ["accept", "cancel", "extra1", "extra2", "disclosure"]) {
376         buttons[type] = this.shadowRoot.querySelector(`[dlgtype="${type}"]`);
377       }
379       // look for any overriding explicit button elements
380       var exBtns = this.getElementsByAttribute("dlgtype", "*");
381       var dlgtype;
382       for (let i = 0; i < exBtns.length; ++i) {
383         dlgtype = exBtns[i].getAttribute("dlgtype");
384         buttons[dlgtype].hidden = true; // hide the anonymous button
385         buttons[dlgtype] = exBtns[i];
386       }
388       // add the label and oncommand handler to each button
389       for (dlgtype in buttons) {
390         var button = buttons[dlgtype];
391         button.addEventListener(
392           "command",
393           this._handleButtonCommand.bind(this),
394           true
395         );
397         // don't override custom labels with pre-defined labels on explicit buttons
398         if (!button.hasAttribute("label")) {
399           // dialog attributes override the default labels in dialog.properties
400           if (this.hasAttribute("buttonlabel" + dlgtype)) {
401             button.setAttribute(
402               "label",
403               this.getAttribute("buttonlabel" + dlgtype)
404             );
405             if (this.hasAttribute("buttonaccesskey" + dlgtype)) {
406               button.setAttribute(
407                 "accesskey",
408                 this.getAttribute("buttonaccesskey" + dlgtype)
409               );
410             }
411           } else if (this.hasAttribute("buttonid" + dlgtype)) {
412             document.l10n.setAttributes(
413               button,
414               this.getAttribute("buttonid" + dlgtype)
415             );
416           } else if (dlgtype != "extra1" && dlgtype != "extra2") {
417             button.setAttribute(
418               "label",
419               this._strBundle.GetStringFromName("button-" + dlgtype)
420             );
421             var accessKey = this._strBundle.GetStringFromName(
422               "accesskey-" + dlgtype
423             );
424             if (accessKey) {
425               button.setAttribute("accesskey", accessKey);
426             }
427           }
428         }
429       }
431       // ensure that hitting enter triggers the default button command
432       // eslint-disable-next-line no-self-assign
433       this.defaultButton = this.defaultButton;
435       // if there is a special button configuration, use it
436       if (aButtons) {
437         // expect a comma delimited list of dlgtype values
438         var list = aButtons.split(",");
440         // mark shown dlgtypes as true
441         var shown = {
442           accept: false,
443           cancel: false,
444           disclosure: false,
445           extra1: false,
446           extra2: false,
447         };
448         for (let i = 0; i < list.length; ++i) {
449           shown[list[i].replace(/ /g, "")] = true;
450         }
452         // hide/show the buttons we want
453         for (dlgtype in buttons) {
454           buttons[dlgtype].hidden = !shown[dlgtype];
455         }
457         // show the spacer on Windows only when the extra2 button is present
458         if (AppConstants.platform == "win") {
459           let spacer = this.shadowRoot.querySelector(".button-spacer");
460           spacer.removeAttribute("hidden");
461           spacer.setAttribute("flex", shown.extra2 ? "1" : "0");
462         }
463       }
464     }
466     _setDefaultButton(aNewDefault) {
467       // remove the default attribute from the previous default button, if any
468       var oldDefaultButton = this.getButton(this.defaultButton);
469       if (oldDefaultButton) {
470         oldDefaultButton.removeAttribute("default");
471       }
473       var newDefaultButton = this.getButton(aNewDefault);
474       if (newDefaultButton) {
475         this.setAttribute("defaultButton", aNewDefault);
476         newDefaultButton.setAttribute("default", "true");
477       } else {
478         this.setAttribute("defaultButton", "none");
479         if (aNewDefault != "none") {
480           dump(
481             "invalid new default button: " + aNewDefault + ", assuming: none\n"
482           );
483         }
484       }
485     }
487     _handleButtonCommand(aEvent) {
488       return this._doButtonCommand(aEvent.target.getAttribute("dlgtype"));
489     }
491     _doButtonCommand(aDlgType) {
492       var button = this.getButton(aDlgType);
493       if (!button.disabled) {
494         var noCancel = this._fireButtonEvent(aDlgType);
495         if (noCancel) {
496           if (aDlgType == "accept" || aDlgType == "cancel") {
497             var closingEvent = new CustomEvent("dialogclosing", {
498               bubbles: true,
499               detail: { button: aDlgType },
500             });
501             this.dispatchEvent(closingEvent);
502             window.close();
503           }
504         }
505         return noCancel;
506       }
507       return true;
508     }
510     _fireButtonEvent(aDlgType) {
511       var event = document.createEvent("Events");
512       event.initEvent("dialog" + aDlgType, true, true);
514       // handle dom event handlers
515       return this.dispatchEvent(event);
516     }
518     _hitEnter(evt) {
519       if (evt.defaultPrevented) {
520         return;
521       }
523       var btn = this.getButton(this.defaultButton);
524       if (btn) {
525         this._doButtonCommand(this.defaultButton);
526       }
527     }
529     on_focus(event) {
530       let btn = this.getButton(this.defaultButton);
531       if (btn) {
532         btn.setAttribute(
533           "default",
534           event.originalTarget == btn ||
535             !(
536               event.originalTarget.localName == "button" ||
537               event.originalTarget.localName == "toolbarbutton"
538             )
539         );
540       }
541     }
542   }
544   customElements.define("dialog", MozDialog);