diff options
Diffstat (limited to 'toolkit/content/widgets/dialog.js')
-rw-r--r-- | toolkit/content/widgets/dialog.js | 567 |
1 files changed, 567 insertions, 0 deletions
diff --git a/toolkit/content/widgets/dialog.js b/toolkit/content/widgets/dialog.js new file mode 100644 index 0000000000..42cf8dbe23 --- /dev/null +++ b/toolkit/content/widgets/dialog.js @@ -0,0 +1,567 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + class MozDialog extends MozXULElement { + constructor() { + super(); + + /** + * Gets populated by elements that are passed to document.l10n.setAttributes + * to localize the dialog buttons. Needed to properly size the dialog after + * the asynchronous translation. + */ + this._l10nButtons = []; + } + + static get observedAttributes() { + return super.observedAttributes.concat("subdialog"); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name == "subdialog") { + console.assert( + newValue, + `Turning off subdialog style is not supported` + ); + if (this.isConnectedAndReady && !oldValue && newValue) { + this.shadowRoot.appendChild( + MozXULElement.parseXULToFragment(this.inContentStyle) + ); + } + return; + } + super.attributeChangedCallback(name, oldValue, newValue); + } + + static get inheritedAttributes() { + return { + ".dialog-button-box": + "pack=buttonpack,align=buttonalign,dir=buttondir,orient=buttonorient", + "[dlgtype='accept']": "disabled=buttondisabledaccept", + }; + } + + get inContentStyle() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + `; + } + + get _markup() { + let buttons = AppConstants.XP_UNIX + ? ` + <hbox class="dialog-button-box"> + <button dlgtype="disclosure" hidden="true"/> + <button dlgtype="extra2" hidden="true"/> + <button dlgtype="extra1" hidden="true"/> + <spacer class="button-spacer" part="button-spacer" flex="1"/> + <button dlgtype="cancel"/> + <button dlgtype="accept"/> + </hbox>` + : ` + <hbox class="dialog-button-box" pack="end"> + <button dlgtype="extra2" hidden="true"/> + <spacer class="button-spacer" part="button-spacer" flex="1" hidden="true"/> + <button dlgtype="accept"/> + <button dlgtype="extra1" hidden="true"/> + <button dlgtype="cancel"/> + <button dlgtype="disclosure" hidden="true"/> + </hbox>`; + + return ` + <html:link rel="stylesheet" href="chrome://global/skin/button.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/dialog.css"/> + ${this.hasAttribute("subdialog") ? this.inContentStyle : ""} + <vbox class="box-inherit" part="content-box"> + <html:slot></html:slot> + </vbox> + ${buttons}`; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + if (this.hasConnected) { + return; + } + this.hasConnected = true; + this.attachShadow({ mode: "open" }); + + document.documentElement.setAttribute("role", "dialog"); + + this.shadowRoot.textContent = ""; + this.shadowRoot.appendChild( + MozXULElement.parseXULToFragment(this._markup) + ); + this.initializeAttributeInheritance(); + + this._configureButtons(this.buttons); + + window.moveToAlertPosition = this.moveToAlertPosition; + window.centerWindowOnScreen = this.centerWindowOnScreen; + + document.addEventListener( + "keypress", + event => { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + this._hitEnter(event); + } else if ( + event.keyCode == KeyEvent.DOM_VK_ESCAPE && + !event.defaultPrevented + ) { + this.cancelDialog(); + } + }, + { mozSystemGroup: true } + ); + + if (AppConstants.platform == "macosx") { + document.addEventListener( + "keypress", + event => { + if (event.key == "." && event.metaKey) { + this.cancelDialog(); + } + }, + true + ); + } else { + this.addEventListener("focus", this, true); + this.shadowRoot.addEventListener("focus", this, true); + } + + // listen for when window is closed via native close buttons + window.addEventListener("close", event => { + if (!this.cancelDialog()) { + event.preventDefault(); + } + }); + + if (this._l10nButtons.length) { + document.blockUnblockOnload(true); + this._translationReady = document.l10n.ready.then(async () => { + try { + await document.l10n.translateElements(this._l10nButtons); + } finally { + document.blockUnblockOnload(false); + } + }); + } + + // Call postLoadInit for things that we need to initialize after onload. + if (document.readyState == "complete") { + this._postLoadInit(); + } else { + window.addEventListener("load", event => this._postLoadInit()); + } + } + + set buttons(val) { + this._configureButtons(val); + } + + get buttons() { + return this.getAttribute("buttons"); + } + + set defaultButton(val) { + this._setDefaultButton(val); + } + + get defaultButton() { + if (this.hasAttribute("defaultButton")) { + return this.getAttribute("defaultButton"); + } + return "accept"; // default to the accept button + } + + get _strBundle() { + if (!this.__stringBundle) { + this.__stringBundle = Services.strings.createBundle( + "chrome://global/locale/dialog.properties" + ); + } + return this.__stringBundle; + } + + acceptDialog() { + return this._doButtonCommand("accept"); + } + + cancelDialog() { + return this._doButtonCommand("cancel"); + } + + getButton(aDlgType) { + return this._buttons[aDlgType]; + } + + get buttonBox() { + return this.shadowRoot.querySelector(".dialog-button-box"); + } + + // NOTE(emilio): This has to match AppWindow::IntrinsicallySizeShell, to + // prevent flickering, see bug 1799394. + _sizeToPreferredSize() { + const docEl = document.documentElement; + const prefWidth = (() => { + if (docEl.hasAttribute("width")) { + return parseInt(docEl.getAttribute("width")); + } + let prefWidthProp = docEl.getAttribute("prefwidth"); + if (prefWidthProp) { + let minWidth = parseFloat( + getComputedStyle(docEl).getPropertyValue(prefWidthProp) + ); + if (isFinite(minWidth)) { + return minWidth; + } + } + return 0; + })(); + window.sizeToContentConstrained({ prefWidth }); + } + + moveToAlertPosition() { + // hack. we need this so the window has something like its final size + if (window.outerWidth == 1) { + dump( + "Trying to position a sizeless window; caller should have called sizeToContent() or sizeTo(). See bug 75649.\n" + ); + this._sizeToPreferredSize(); + } + + if (opener) { + var xOffset = (opener.outerWidth - window.outerWidth) / 2; + var yOffset = opener.outerHeight / 5; + + var newX = opener.screenX + xOffset; + var newY = opener.screenY + yOffset; + } else { + newX = (screen.availWidth - window.outerWidth) / 2; + newY = (screen.availHeight - window.outerHeight) / 2; + } + + // ensure the window is fully onscreen (if smaller than the screen) + if (newX < screen.availLeft) { + newX = screen.availLeft + 20; + } + if (newX + window.outerWidth > screen.availLeft + screen.availWidth) { + newX = screen.availLeft + screen.availWidth - window.outerWidth - 20; + } + + if (newY < screen.availTop) { + newY = screen.availTop + 20; + } + if (newY + window.outerHeight > screen.availTop + screen.availHeight) { + newY = screen.availTop + screen.availHeight - window.outerHeight - 60; + } + + window.moveTo(newX, newY); + } + + centerWindowOnScreen() { + var xOffset = screen.availWidth / 2 - window.outerWidth / 2; + var yOffset = screen.availHeight / 2 - window.outerHeight / 2; + + xOffset = xOffset > 0 ? xOffset : 0; + yOffset = yOffset > 0 ? yOffset : 0; + window.moveTo(xOffset, yOffset); + } + + // Give focus to the first focusable element in the dialog + _setInitialFocusIfNeeded() { + let focusedElt = document.commandDispatcher.focusedElement; + if (focusedElt) { + return; + } + + const defaultButton = this.getButton(this.defaultButton); + Services.focus.moveFocus( + window, + null, + Services.focus.MOVEFOCUS_FORWARD, + Services.focus.FLAG_NOPARENTFRAME + ); + + focusedElt = document.commandDispatcher.focusedElement; + if (!focusedElt) { + return; // No focusable element? + } + + let firstFocusedElt = focusedElt; + while ( + focusedElt.localName == "tab" || + focusedElt.getAttribute("noinitialfocus") == "true" + ) { + Services.focus.moveFocus( + window, + focusedElt, + Services.focus.MOVEFOCUS_FORWARD, + Services.focus.FLAG_NOPARENTFRAME + ); + focusedElt = document.commandDispatcher.focusedElement; + if (focusedElt == firstFocusedElt) { + if (focusedElt.getAttribute("noinitialfocus") == "true") { + focusedElt.blur(); + } + // Didn't find anything else to focus, we're done. + return; + } + } + + if (firstFocusedElt.localName == "tab") { + if (focusedElt.hasAttribute("dlgtype")) { + // We don't want to focus on anonymous OK, Cancel, etc. buttons, + // so return focus to the tab itself + firstFocusedElt.focus(); + } + } else if ( + AppConstants.platform != "macosx" && + focusedElt.hasAttribute("dlgtype") && + focusedElt != defaultButton + ) { + defaultButton.focus(); + if (document.commandDispatcher.focusedElement != defaultButton) { + // If the default button is not focusable, then return focus to the + // initial element if possible, or blur otherwise. + if (firstFocusedElt.getAttribute("noinitialfocus") == "true") { + focusedElt.blur(); + } else { + firstFocusedElt.focus(); + } + } + } + } + + async _postLoadInit() { + this._setInitialFocusIfNeeded(); + if (this._translationReady) { + await this._translationReady; + } + + let finalStep = () => { + this._sizeToPreferredSize(); + this._snapCursorToDefaultButtonIfNeeded(); + }; + // As a hack to ensure Windows sizes the window correctly, + // _sizeToPreferredSize() needs to happen after + // AppWindow::OnChromeLoaded. That one is called right after the load + // event dispatch but within the same task. Using direct dispatch let's + // all this code run before the next task (which might be a task to + // paint the window). + // But, MacOS doesn't like resizing after window/dialog becoming visible. + // Linux seems to be able to handle both cases. + if (Services.appinfo.OS == "Darwin") { + finalStep(); + } else { + Services.tm.dispatchDirectTaskToCurrentThread(finalStep); + } + } + + // This snaps the cursor to the default button rect on windows, when + // SPI_GETSNAPTODEFBUTTON is set. + async _snapCursorToDefaultButtonIfNeeded() { + const defaultButton = this.getButton(this.defaultButton); + if (!defaultButton) { + return; + } + try { + // FIXME(emilio, bug 1797624): This setTimeout() ensures enough time + // has passed so that the dialog vertical margin has been set by the + // front-end. For subdialogs, cursor positioning should probably be + // done by the opener instead, once the dialog is positioned. + await new Promise(r => setTimeout(r, 0)); + await window.promiseDocumentFlushed(() => {}); + window.notifyDefaultButtonLoaded(defaultButton); + } catch (e) {} + } + + _configureButtons(aButtons) { + // by default, get all the anonymous button elements + var buttons = {}; + this._buttons = buttons; + + for (let type of ["accept", "cancel", "extra1", "extra2", "disclosure"]) { + buttons[type] = this.shadowRoot.querySelector(`[dlgtype="${type}"]`); + } + + // look for any overriding explicit button elements + var exBtns = this.getElementsByAttribute("dlgtype", "*"); + var dlgtype; + for (let i = 0; i < exBtns.length; ++i) { + dlgtype = exBtns[i].getAttribute("dlgtype"); + buttons[dlgtype].hidden = true; // hide the anonymous button + buttons[dlgtype] = exBtns[i]; + } + + // add the label and oncommand handler to each button + for (dlgtype in buttons) { + var button = buttons[dlgtype]; + button.addEventListener( + "command", + this._handleButtonCommand.bind(this), + true + ); + + // don't override custom labels with pre-defined labels on explicit buttons + if (!button.hasAttribute("label")) { + // dialog attributes override the default labels in dialog.properties + if (this.hasAttribute("buttonlabel" + dlgtype)) { + button.setAttribute( + "label", + this.getAttribute("buttonlabel" + dlgtype) + ); + if (this.hasAttribute("buttonaccesskey" + dlgtype)) { + button.setAttribute( + "accesskey", + this.getAttribute("buttonaccesskey" + dlgtype) + ); + } + } else if (this.hasAttribute("buttonid" + dlgtype)) { + document.l10n.setAttributes( + button, + this.getAttribute("buttonid" + dlgtype) + ); + this._l10nButtons.push(button); + } else if (dlgtype != "extra1" && dlgtype != "extra2") { + button.setAttribute( + "label", + this._strBundle.GetStringFromName("button-" + dlgtype) + ); + var accessKey = this._strBundle.GetStringFromName( + "accesskey-" + dlgtype + ); + if (accessKey) { + button.setAttribute("accesskey", accessKey); + } + } + } + } + + // ensure that hitting enter triggers the default button command + // eslint-disable-next-line no-self-assign + this.defaultButton = this.defaultButton; + + // if there is a special button configuration, use it + if (aButtons) { + // expect a comma delimited list of dlgtype values + var list = aButtons.split(","); + + // mark shown dlgtypes as true + var shown = { + accept: false, + cancel: false, + disclosure: false, + extra1: false, + extra2: false, + }; + for (let i = 0; i < list.length; ++i) { + shown[list[i].replace(/ /g, "")] = true; + } + + // hide/show the buttons we want + for (dlgtype in buttons) { + buttons[dlgtype].hidden = !shown[dlgtype]; + } + + // show the spacer on Windows only when the extra2 button is present + if (AppConstants.platform == "win") { + let spacer = this.shadowRoot.querySelector(".button-spacer"); + spacer.removeAttribute("hidden"); + spacer.setAttribute("flex", shown.extra2 ? "1" : "0"); + } + } + } + + _setDefaultButton(aNewDefault) { + // remove the default attribute from the previous default button, if any + var oldDefaultButton = this.getButton(this.defaultButton); + if (oldDefaultButton) { + oldDefaultButton.removeAttribute("default"); + } + + var newDefaultButton = this.getButton(aNewDefault); + if (newDefaultButton) { + this.setAttribute("defaultButton", aNewDefault); + newDefaultButton.setAttribute("default", "true"); + } else { + this.setAttribute("defaultButton", "none"); + if (aNewDefault != "none") { + dump( + "invalid new default button: " + aNewDefault + ", assuming: none\n" + ); + } + } + } + + _handleButtonCommand(aEvent) { + return this._doButtonCommand(aEvent.target.getAttribute("dlgtype")); + } + + _doButtonCommand(aDlgType) { + var button = this.getButton(aDlgType); + if (!button.disabled) { + var noCancel = this._fireButtonEvent(aDlgType); + if (noCancel) { + if (aDlgType == "accept" || aDlgType == "cancel") { + var closingEvent = new CustomEvent("dialogclosing", { + bubbles: true, + detail: { button: aDlgType }, + }); + this.dispatchEvent(closingEvent); + window.close(); + } + } + return noCancel; + } + return true; + } + + _fireButtonEvent(aDlgType) { + var event = document.createEvent("Events"); + event.initEvent("dialog" + aDlgType, true, true); + + // handle dom event handlers + return this.dispatchEvent(event); + } + + _hitEnter(evt) { + if (evt.defaultPrevented) { + return; + } + + var btn = this.getButton(this.defaultButton); + if (btn) { + this._doButtonCommand(this.defaultButton); + } + } + + on_focus(event) { + let btn = this.getButton(this.defaultButton); + if (btn) { + btn.setAttribute( + "default", + event.originalTarget == btn || + !( + event.originalTarget.localName == "button" || + event.originalTarget.localName == "toolbarbutton" + ) + ); + } + } + } + + customElements.define("dialog", MozDialog); +} |