diff options
Diffstat (limited to 'toolkit/content')
544 files changed, 106424 insertions, 0 deletions
diff --git a/toolkit/content/.eslintrc.js b/toolkit/content/.eslintrc.js new file mode 100644 index 0000000000..b56cafb08b --- /dev/null +++ b/toolkit/content/.eslintrc.js @@ -0,0 +1,24 @@ +/* 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"; + +module.exports = { + overrides: [ + { + files: "./**/*.?(m)js", + excludedFiles: "aboutwebrtc/**", + env: { + "mozilla/browser-window": true, + }, + }, + ], + plugins: ["mozilla"], + + rules: { + // XXX Bug 1358949 - This should be reduced down - probably to 20 or to + // be removed & synced with the mozilla/recommended value. + complexity: ["error", 48], + }, +}; diff --git a/toolkit/content/TopLevelVideoDocument.js b/toolkit/content/TopLevelVideoDocument.js new file mode 100644 index 0000000000..4505a93d18 --- /dev/null +++ b/toolkit/content/TopLevelVideoDocument.js @@ -0,0 +1,69 @@ +/* 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"; + +// Hide our variables from the web content, even though the spec allows them +// (and the DOM) to be accessible (see bug 1474832) +{ + // <video> is used for top-level audio documents as well + let videoElement = document.getElementsByTagName("video")[0]; + + let setFocusToVideoElement = function (e) { + // We don't want to retarget focus if it goes to the controls in + // the video element. Because they're anonymous content, the target + // will be the video element in that case. Avoid calling .focus() + // for those events: + if (e && e.target == videoElement) { + return; + } + videoElement.focus(); + }; + + // Redirect focus to the video element whenever the document receives + // focus. + document.addEventListener("focus", setFocusToVideoElement, true); + + // Focus on the video in the newly created document. + setFocusToVideoElement(); + + // Opt out of moving focus away if the DOM tree changes (from add-on or web content) + let observer = new MutationObserver(() => { + observer.disconnect(); + document.removeEventListener("focus", setFocusToVideoElement, true); + }); + observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); + + // Handle fullscreen mode + document.addEventListener("keypress", ev => { + // Maximize the standalone video when pressing F11, + // but ignore audio elements + if ( + ev.key == "F11" && + videoElement.videoWidth != 0 && + videoElement.videoHeight != 0 + ) { + // If we're in browser fullscreen mode, it means the user pressed F11 + // while browser chrome or another tab had focus. + // Don't break leaving that mode, so do nothing here. + if (window.fullScreen) { + return; + } + + // If we're not in browser fullscreen mode, prevent entering into that, + // so we don't end up there after pressing Esc. + ev.preventDefault(); + ev.stopPropagation(); + + if (!document.fullscreenElement) { + videoElement.requestFullscreen(); + } else { + document.exitFullscreen(); + } + } + }); +} diff --git a/toolkit/content/about.js b/toolkit/content/about.js new file mode 100644 index 0000000000..469f77b8d5 --- /dev/null +++ b/toolkit/content/about.js @@ -0,0 +1,30 @@ +/* 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/. */ + +// get release notes and vendor URL from prefs +var releaseNotesURL = Services.urlFormatter.formatURLPref( + "app.releaseNotesURL" +); +if (releaseNotesURL != "about:blank") { + var relnotes = document.getElementById("releaseNotesURL"); + relnotes.setAttribute("href", releaseNotesURL); + relnotes.parentNode.removeAttribute("hidden"); +} + +var vendorURL = Services.urlFormatter.formatURLPref("app.vendorURL"); +if (vendorURL != "about:blank") { + var vendor = document.getElementById("vendorURL"); + vendor.setAttribute("href", vendorURL); +} + +// insert the version of the XUL application (!= XULRunner platform version) +var versionNum = Services.appinfo.version; +var version = document.getElementById("version"); +version.textContent += " " + versionNum; + +// append user agent +var ua = navigator.userAgent; +if (ua) { + document.getElementById("buildID").textContent += " " + ua; +} diff --git a/toolkit/content/aboutAbout.html b/toolkit/content/aboutAbout.html new file mode 100644 index 0000000000..ab955bc27b --- /dev/null +++ b/toolkit/content/aboutAbout.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> + +<!-- 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/. --> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta charset="utf-8" /> + <meta name="color-scheme" content="light dark" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <title data-l10n-id="about-about-title"></title> + <link + rel="stylesheet" + href="chrome://global/skin/in-content/info-pages.css" + /> + <link rel="localization" href="toolkit/about/aboutAbout.ftl" /> + <link + rel="icon" + type="image/png" + href="chrome://branding/content/icon32.png" + /> + <script src="chrome://global/content/aboutAbout.js"></script> + </head> + + <body> + <div class="container"> + <h1 data-l10n-id="about-about-title"></h1> + <p><em data-l10n-id="about-about-note"></em></p> + <ul id="abouts" class="columns"></ul> + </div> + </body> +</html> diff --git a/toolkit/content/aboutAbout.js b/toolkit/content/aboutAbout.js new file mode 100644 index 0000000000..6ee2fe96ed --- /dev/null +++ b/toolkit/content/aboutAbout.js @@ -0,0 +1,24 @@ +/* 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/. */ + +const { AboutPagesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/AboutPagesUtils.sys.mjs" +); + +var gContainer; +window.onload = function () { + gContainer = document.getElementById("abouts"); + AboutPagesUtils.visibleAboutUrls.forEach(createProtocolListing); +}; + +function createProtocolListing(aUrl) { + var li = document.createElement("li"); + var link = document.createElement("a"); + var text = document.createTextNode(aUrl); + + link.href = aUrl; + link.appendChild(text); + li.appendChild(link); + gContainer.appendChild(li); +} diff --git a/toolkit/content/aboutGlean.css b/toolkit/content/aboutGlean.css new file mode 100644 index 0000000000..09417f433d --- /dev/null +++ b/toolkit/content/aboutGlean.css @@ -0,0 +1,11 @@ +/* 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/. */ + +@import url("chrome://global/skin/in-content/common.css"); + +#tag-pings:invalid + label > span { + font-weight: var(--font-weight-bold); + text-decoration: underline; + color: var(--text-color-error); +} diff --git a/toolkit/content/aboutGlean.html b/toolkit/content/aboutGlean.html new file mode 100644 index 0000000000..3f7855ef99 --- /dev/null +++ b/toolkit/content/aboutGlean.html @@ -0,0 +1,82 @@ +<!-- 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/. --> + +<!DOCTYPE html> + +<html> + <head> + <meta http-equiv="Content-Security-Policy" content="default-src chrome:; object-src 'none'"/> + <meta charset="utf-8"> + <meta name="color-scheme" content="light dark"> + <title data-l10n-id="about-glean-page-title2"></title> + <link rel="stylesheet" href="chrome://global/content/aboutGlean.css"/> + + <script src="chrome://global/content/aboutGlean.js"></script> + <link rel="localization" href="branding/brand.ftl"/> + <link rel="localization" href="toolkit/about/aboutGlean.ftl"/> + </head> + + <body> + <section class="main-content"> + <div id="description"> + <h2 data-l10n-id="about-glean-header"></h2> + <p data-l10n-id="about-glean-interface-description"> + <a data-l10n-name="glean-sdk-doc-link" href="https://mozilla.github.io/glean/book/index.html"></a> + <a data-l10n-name="fog-link" href="https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/instrumentation_tests.html"></a> + </p> + <p id="upload-status" data-l10n-id="about-glean-upload-enabled"></p> + <p> + <span id="about-glean-prefs-and-defines" data-l10n-id="about-glean-prefs-and-defines"> + <a data-l10n-name="fog-prefs-and-defines-doc-link" href="https://firefox-source-docs.mozilla.org/toolkit/components/glean/dev/preferences.html"></a> + </span> + <ul> + <li data-l10n-id="about-glean-data-upload" data-l10n-args='{"data-upload-pref-value": "unknown"}'></li> + <li data-l10n-id="about-glean-local-port" data-l10n-args='{"local-port-pref-value": "unknown"}'></li> + <li data-l10n-id="about-glean-glean-android" data-l10n-args='{"glean-android-define-value": "unknown"}'></li> + <li data-l10n-id="about-glean-moz-official" data-l10n-args='{"moz-official-define-value": "unknown"}'></li> + </ul> + </p> + <h2 data-l10n-id="about-glean-about-testing-header"></h2> + <p data-l10n-id="about-glean-manual-testing"> + <a data-l10n-name="fog-instrumentation-test-doc-link" href="https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/instrumentation_tests.html"></a> + <a data-l10n-name="glean-sdk-doc-link" href="https://mozilla.github.io/glean/book/index.html"></a> + </p> + <ol> + <li> + <input type="text" name="tag-pings" id="tag-pings" pattern="[a-zA-Z0-9\-]{1,20}"> + <label for="tag-pings" data-l10n-id="about-glean-label-for-tag-pings-with-requirements"></label> + </li> + <li> + <select name="ping-names" id="ping-names"></select> + <label for="ping-names" data-l10n-id="about-glean-label-for-ping-names"> + <a data-l10n-name="custom-ping-link" href="https://mozilla.github.io/glean/book/user/pings/custom.html"></a> + </label> + </li> + <li> + <input type="checkbox" name="log-pings" id="log-pings"> + <label for="log-pings" data-l10n-id="about-glean-label-for-log-pings"> + <a data-l10n-name="enable-logging-link" href="http://firefox-source-docs.mozilla.org/toolkit/components/glean/dev/testing.html#logging"></a> + </label> + </li> + <li> + <button id="controls-submit" data-l10n-id="controls-button-label-verbose"></button> + <label for="controls-submit" data-l10n-id="about-glean-label-for-controls-submit" data-l10n-args='{"debug-tag": "unknown"}'> + <span id="submit-tag-span"></span> + </label> + </li> + <li data-l10n-id="about-glean-li-for-visit-gdpv"> + <a data-l10n-name="gdpv-tagged-pings-link" href="https://debug-ping-preview.firebaseapp.com/pings/"></a> + </li> + </ol> + <p data-l10n-id="about-glean-adhoc-explanation2"></p> + <p data-l10n-id="about-glean-adhoc-note"></p> + <h2 data-l10n-id="about-glean-about-data-header"></h2> + <p data-l10n-id="about-glean-about-data-explanation"> + <a data-l10n-name="glean-dictionary-link" href="https://dictionary.telemetry.mozilla.org/"></a> + </p> + </div> + </section> + </body> + +</html> diff --git a/toolkit/content/aboutGlean.js b/toolkit/content/aboutGlean.js new file mode 100644 index 0000000000..44339c80d1 --- /dev/null +++ b/toolkit/content/aboutGlean.js @@ -0,0 +1,151 @@ +/* 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"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +function updatePrefsAndDefines() { + let upload = Services.prefs.getBoolPref( + "datareporting.healthreport.uploadEnabled" + ); + document.l10n.setAttributes( + document.querySelector("[data-l10n-id='about-glean-data-upload']"), + "about-glean-data-upload", + { + "data-upload-pref-value": upload, + } + ); + let port = Services.prefs.getIntPref("telemetry.fog.test.localhost_port"); + document.l10n.setAttributes( + document.querySelector("[data-l10n-id='about-glean-local-port']"), + "about-glean-local-port", + { + "local-port-pref-value": port, + } + ); + document.l10n.setAttributes( + document.querySelector("[data-l10n-id='about-glean-glean-android']"), + "about-glean-glean-android", + { "glean-android-define-value": AppConstants.MOZ_GLEAN_ANDROID } + ); + document.l10n.setAttributes( + document.querySelector("[data-l10n-id='about-glean-moz-official']"), + "about-glean-moz-official", + { "moz-official-define-value": AppConstants.MOZILLA_OFFICIAL } + ); + + // Knowing what we know, and copying logic from viaduct_uploader.rs, + // (which is documented in Preferences and Defines), + // tell the fine user whether and why upload is disabled. + let uploadMessageEl = document.getElementById("upload-status"); + let uploadL10nId = "about-glean-upload-enabled"; + if (!upload) { + uploadL10nId = "about-glean-upload-disabled"; + } else if (port < 0 || (port == 0 && !AppConstants.MOZILLA_OFFICIAL)) { + uploadL10nId = "about-glean-upload-fake-enabled"; + // This message has a link to the Glean Debug Ping Viewer in it. + // We must add the anchor element now so that Fluent can match it. + let a = document.createElement("a"); + a.href = "https://debug-ping-preview.firebaseapp.com/"; + a.setAttribute("data-l10n-name", "glean-debug-ping-viewer"); + uploadMessageEl.appendChild(a); + } else if (port > 0) { + uploadL10nId = "about-glean-upload-enabled-local"; + } + document.l10n.setAttributes(uploadMessageEl, uploadL10nId); +} + +function camelToKebab(str) { + let out = ""; + for (let i = 0; i < str.length; i++) { + let c = str.charAt(i); + if (c == c.toUpperCase()) { + out += "-"; + c = c.toLowerCase(); + } + out += c; + } + return out; +} + +// I'm consciously omitting "deletion-request" until someone can come up with +// a use-case for sending it via about:glean. +const GLEAN_BUILTIN_PINGS = ["metrics", "events", "baseline"]; +const NO_PING = "(don't submit any ping)"; +function refillPingNames() { + let select = document.getElementById("ping-names"); + let pings = GLEAN_BUILTIN_PINGS.slice().concat(Object.keys(GleanPings)); + + pings.forEach(ping => { + let option = document.createElement("option"); + option.textContent = camelToKebab(ping); + select.appendChild(option); + }); + let option = document.createElement("option"); + document.l10n.setAttributes(option, "about-glean-no-ping-label"); + option.value = NO_PING; + select.appendChild(option); +} + +// If there's been a previous tag, use it. +// If not, be _slightly_ clever and derive a default one from the profile dir. +function fillDebugTag() { + const DEBUG_TAG_PREF = "telemetry.fog.aboutGlean.debugTag"; + let debugTag; + if (Services.prefs.prefHasUserValue(DEBUG_TAG_PREF)) { + debugTag = Services.prefs.getStringPref(DEBUG_TAG_PREF); + } else { + const debugTagPrefix = "about-glean-"; + const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path; + let charSum = Array.from(profileDir).reduce( + (prev, cur) => prev + cur.charCodeAt(0), + 0 + ); + + debugTag = debugTagPrefix + (charSum % 1000); + } + + let tagInput = document.getElementById("tag-pings"); + tagInput.value = debugTag; + const updateDebugTagValues = () => { + document.l10n.setAttributes( + document.querySelector( + "[data-l10n-id='about-glean-label-for-controls-submit']" + ), + "about-glean-label-for-controls-submit", + { "debug-tag": tagInput.value } + ); + const GDPV_ROOT = "https://debug-ping-preview.firebaseapp.com/pings/"; + let gdpvLink = document.querySelector( + "[data-l10n-name='gdpv-tagged-pings-link']" + ); + gdpvLink.href = GDPV_ROOT + tagInput.value; + }; + tagInput.addEventListener("change", () => { + Services.prefs.setStringPref(DEBUG_TAG_PREF, tagInput.value); + updateDebugTagValues(); + }); + updateDebugTagValues(); +} + +function onLoad() { + updatePrefsAndDefines(); + refillPingNames(); + fillDebugTag(); + document.getElementById("controls-submit").addEventListener("click", () => { + let tag = document.getElementById("tag-pings").value; + let log = document.getElementById("log-pings").checked; + let ping = document.getElementById("ping-names").value; + Services.fog.setLogPings(log); + Services.fog.setTagPings(tag); + if (ping != NO_PING) { + Services.fog.sendPing(ping); + } + }); +} + +window.addEventListener("load", onLoad); diff --git a/toolkit/content/aboutLogging.html b/toolkit/content/aboutLogging.html new file mode 100644 index 0000000000..95b3513052 --- /dev/null +++ b/toolkit/content/aboutLogging.html @@ -0,0 +1,171 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="about-logging-title"></title> + <link rel="stylesheet" href="chrome://global/skin/aboutLogging.css" /> + <script src="chrome://global/content/aboutLogging.js"></script> + <link rel="localization" href="toolkit/about/aboutLogging.ftl" /> + <link rel="localization" href="toolkit/branding/brandings.ftl" /> + </head> + + <body id="body"> + <main class="main-content"> + <h1 id="title" data-l10n-id="about-logging-page-title"></h1> + <section> + <div hidden id="error" class="page-subsection info-box"> + <div class="info-box-label" data-l10n-id="about-logging-error"></div> + <div id="error-description"></div> + <div data-l10n-id="about-logging-configuration-url-ignored"></div> + </div> + <div + hidden + id="some-elements-unavailable" + class="page-subsection info-box" + > + <div class="info-box-label" data-l10n-id="about-logging-info"></div> + <div data-l10n-id="about-logging-some-elements-disabled"></div> + </div> + </section> + <div class="button-row"> + <button + id="toggle-logging-button" + data-l10n-id="about-logging-start-logging" + ></button> + </div> + <section id="log-module-selection"> + <h2 data-l10n-id="about-logging-log-modules-selection"></h2> + <div class="page-subsection"> + <label + for="current-log-modules" + data-l10n-id="about-logging-currently-enabled-log-modules" + ></label> + <div id="current-log-modules"></div> + <div + id="no-log-modules" + data-l10n-id="about-logging-no-log-modules" + ></div> + </div> + <form id="log-modules-form" class="page-subsection"> + <label + for="log-modules" + data-l10n-id="about-logging-new-log-modules" + ></label> + <input type="text" name="log-modules" id="log-modules" value="" /> + <div class="button-row"> + <button + type="submit" + id="set-log-modules-button" + data-l10n-id="about-logging-set-log-modules" + ></button> + </div> + </form> + <div id="preset-selector-section" class="page-subsection"> + <label + for="logging-preset-dropdown" + data-l10n-id="about-logging-logging-preset-selector-text" + ></label> + <select + name="logging-preset-dropdown" + id="logging-preset-dropdown" + ></select> + <div id="logging-preset-description"></div> + </div> + </section> + <section id="logging-output"> + <div> + <span + hidden + id="buttons-disabled" + data-l10n-id="about-logging-buttons-disabled" + ></span> + </div> + <h2 data-l10n-id="about-logging-logging-output-selection"></h2> + <div id="logging-output-profiler" class="form-entry"> + <input + type="radio" + id="radio-logging-profiler" + name="logging-output" + value="profiler" + checked + /> + <label + for="radio-logging-profiler" + data-l10n-id="about-logging-logging-to-profiler" + ></label> + </div> + <div id="profiler-configuration" class="form-entry"> + <input type="checkbox" id="with-profiler-stacks-checkbox" /><label + for="with-profiler-stacks-checkbox" + data-l10n-id="about-logging-with-profiler-stacks-checkbox" + ></label> + </div> + <div id="logging-output-file" class="form-entry"> + <input + type="radio" + id="radio-logging-file" + name="logging-output" + value="file" + /> + <label + for="radio-logging-file" + data-l10n-id="about-logging-logging-to-file" + ></label> + <div> + <span + hidden + id="buttons-disabled" + data-l10n-id="about-logging-buttons-disabled" + ></span> + </div> + </div> + <form id="log-file-configuration" class="page-subsection"> + <div> + <span data-l10n-id="about-logging-current-log-file"></span> + <span id="current-log-file"></span> + <span + id="no-log-file" + data-l10n-id="about-logging-no-log-file" + ></span> + </div> + <div> + <label + for="log-file" + data-l10n-id="about-logging-new-log-file" + ></label> + <input type="text" name="log-file" id="log-file" /> + <div class="button-row"> + <button + type="button" + id="open-log-file-button" + data-l10n-id="about-logging-open-log-file-dir" + ></button> + <button + type="submit" + id="set-log-file-button" + data-l10n-id="about-logging-set-log-file" + ></button> + </div> + </div> + </form> + <div class="page-subsection"> + <p id="log-tutorial" data-l10n-id="about-logging-log-tutorial"> + <a + data-l10n-name="logging" + href="https://firefox-source-docs.mozilla.org/networking/http/logging.html" + ></a> + </p> + </div> + </section> + </main> + </body> +</html> diff --git a/toolkit/content/aboutLogging.js b/toolkit/content/aboutLogging.js new file mode 100644 index 0000000000..5aaf0f9ecc --- /dev/null +++ b/toolkit/content/aboutLogging.js @@ -0,0 +1,776 @@ +/* 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"; +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const gDashboard = Cc["@mozilla.org/network/dashboard;1"].getService( + Ci.nsIDashboard +); +const gDirServ = Cc["@mozilla.org/file/directory_service;1"].getService( + Ci.nsIDirectoryServiceProvider +); + +const { ProfilerMenuButton } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/popup/menu-button.sys.mjs" +); +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +ChromeUtils.defineLazyGetter(this, "ProfilerPopupBackground", function () { + return ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ); +}); + +const $ = document.querySelector.bind(document); +const $$ = document.querySelectorAll.bind(document); + +function fileEnvVarPresent() { + return Services.env.get("MOZ_LOG_FILE") || Services.env.get("NSPR_LOG_FILE"); +} + +function moduleEnvVarPresent() { + return Services.env.get("MOZ_LOG") || Services.env.get("NSPR_LOG"); +} + +/** + * All the information associated with a logging presets: + * - `modules` is the list of log modules and option, the same that would have + * been set as a MOZ_LOG environment variable + * - l10nIds.label and l10nIds.description are the Ids of the strings that + * appear in the dropdown selector, and a one-liner describing the purpose of + * a particular logging preset + * - profilerPreset is the name of a Firefox Profiler preset [1]. In general, + * the profiler preset will have the correct set of threads for a particular + * logging preset, so that all logging statements are recorded in the profile + * as markers. + * + * [1]: The keys of the `presets` object defined in + * https://searchfox.org/mozilla-central/source/devtools/client/performance-new/shared/background.jsm.js + */ + +const gOsSpecificLoggingPresets = (() => { + // Microsoft Windows + if (navigator.platform.startsWith("Win")) { + return { + windows: { + modules: + "timestamp,sync,Widget:5,BaseWidget:5,WindowsEvent:4,TaskbarConcealer:5,FileDialog:5", + l10nIds: { + label: "about-logging-preset-windows-label", + description: "about-logging-preset-windows-description", + }, + }, + }; + } + + return {}; +})(); + +const gLoggingPresets = { + networking: { + modules: + "timestamp,sync,nsHttp:5,cache2:5,nsSocketTransport:5,nsHostResolver:5", + l10nIds: { + label: "about-logging-preset-networking-label", + description: "about-logging-preset-networking-description", + }, + profilerPreset: "networking", + }, + cookie: { + modules: "timestamp,sync,nsHttp:5,cache2:5,cookie:5", + l10nIds: { + label: "about-logging-preset-networking-cookie-label", + description: "about-logging-preset-networking-cookie-description", + }, + }, + websocket: { + modules: + "timestamp,sync,nsHttp:5,nsWebSocket:5,nsSocketTransport:5,nsHostResolver:5", + l10nIds: { + label: "about-logging-preset-networking-websocket-label", + description: "about-logging-preset-networking-websocket-description", + }, + }, + http3: { + modules: + "timestamp,sync,nsHttp:5,nsSocketTransport:5,nsHostResolver:5,neqo_http3::*:5,neqo_transport::*:5", + l10nIds: { + label: "about-logging-preset-networking-http3-label", + description: "about-logging-preset-networking-http3-description", + }, + }, + "http3-upload-speed": { + modules: "timestamp,neqo_transport::*:3", + l10nIds: { + label: "about-logging-preset-networking-http3-upload-speed-label", + description: + "about-logging-preset-networking-http3-upload-speed-description", + }, + }, + "media-playback": { + modules: + "HTMLMediaElement:4,HTMLMediaElementEvents:4,cubeb:5,PlatformDecoderModule:5,AudioSink:5,AudioSinkWrapper:5,MediaDecoderStateMachine:4,MediaDecoder:4,MediaFormatReader:5,GMP:5", + l10nIds: { + label: "about-logging-preset-media-playback-label", + description: "about-logging-preset-media-playback-description", + }, + profilerPreset: "media", + }, + webrtc: { + modules: + "jsep:5,sdp:5,signaling:5,mtransport:5,RTCRtpReceiver:5,RTCRtpSender:5,RTCDMTFSender:5,VideoFrameConverter:5,WebrtcTCPSocket:5,CamerasChild:5,CamerasParent:5,VideoEngine:5,ShmemPool:5,TabShare:5,MediaChild:5,MediaParent:5,MediaManager:5,MediaTrackGraph:5,cubeb:5,MediaStream:5,MediaStreamTrack:5,DriftCompensator:5,ForwardInputTrack:5,MediaRecorder:5,MediaEncoder:5,TrackEncoder:5,VP8TrackEncoder:5,Muxer:5,GetUserMedia:5,MediaPipeline:5,PeerConnectionImpl:5,WebAudioAPI:5,webrtc_trace:5,RTCRtpTransceiver:5,ForwardedInputTrack:5,HTMLMediaElement:5,HTMLMediaElementEvents:5", + l10nIds: { + label: "about-logging-preset-webrtc-label", + description: "about-logging-preset-webrtc-description", + }, + profilerPreset: "media", + }, + webgpu: { + modules: + "wgpu_core::*:5,wgpu_hal::*:5,wgpu_types::*:5,naga::*:5,wgpu_bindings::*:5,WebGPU:5", + l10nIds: { + label: "about-logging-preset-webgpu-label", + description: "about-logging-preset-webgpu-description", + }, + }, + gfx: { + modules: + "webrender::*:5,webrender_bindings::*:5,webrender_types::*:5,gfx2d:5,WebRenderBridgeParent:5,DcompSurface:5,apz.displayport:5,layout:5,dl.content:5,dl.parent:5,nsRefreshDriver:5,fontlist:5,fontinit:5,textrun:5,textrunui:5,textperf:5", + l10nIds: { + label: "about-logging-preset-gfx-label", + description: "about-logging-preset-gfx-description", + }, + // The graphics profiler preset enables the threads we want but loses the screenshots. + // We could add an extra preset for that if we miss it. + profilerPreset: "graphics", + }, + ...gOsSpecificLoggingPresets, + custom: { + modules: "", + l10nIds: { + label: "about-logging-preset-custom-label", + description: "about-logging-preset-custom-description", + }, + }, +}; + +const gLoggingSettings = { + // Possible values: "profiler" and "file". + loggingOutputType: "profiler", + running: false, + // If non-null, the profiler preset to use. If null, the preset selected in + // the dropdown is going to be used. It is also possible to use a "custom" + // preset and an explicit list of modules. + loggingPreset: null, + // If non-null, the profiler preset to use. If a logging preset is being used, + // and this is null, the profiler preset associated to the logging preset is + // going to be used. Otherwise, a generic profiler preset is going to be used + // ("firefox-platform"). + profilerPreset: null, + // If non-null, the threads that will be recorded by the Firefox Profiler. If + // null, the threads from the profiler presets are going to be used. + profilerThreads: null, + // If non-null, stack traces will be recorded for MOZ_LOG profiler markers. + // This is set only when coming from the URL, not when the user changes the UI. + profilerStacks: null, +}; + +// When the profiler has been started, this holds the promise the +// Services.profiler.StartProfiler returns, to ensure the profiler has +// effectively started. +let gProfilerPromise = null; + +// Used in tests +function presets() { + return gLoggingPresets; +} + +// Used in tests +function settings() { + return gLoggingSettings; +} + +// Used in tests +function profilerPromise() { + return gProfilerPromise; +} + +function populatePresets() { + let dropdown = $("#logging-preset-dropdown"); + for (let presetName in gLoggingPresets) { + let preset = gLoggingPresets[presetName]; + let option = document.createElement("option"); + document.l10n.setAttributes(option, preset.l10nIds.label); + option.value = presetName; + dropdown.appendChild(option); + if (option.value === gLoggingSettings.loggingPreset) { + option.setAttribute("selected", true); + } + } + + function setPresetAndDescription(preset) { + document.l10n.setAttributes( + $("#logging-preset-description"), + gLoggingPresets[preset].l10nIds.description + ); + gLoggingSettings.loggingPreset = preset; + } + + dropdown.onchange = function () { + // When switching to custom, leave the existing module list, to allow + // editing. + if (dropdown.value != "custom") { + $("#log-modules").value = gLoggingPresets[dropdown.value].modules; + } + setPresetAndDescription(dropdown.value); + setLogModules(); + Services.prefs.setCharPref("logging.config.preset", dropdown.value); + }; + + $("#log-modules").value = gLoggingPresets[dropdown.value].modules; + setPresetAndDescription(dropdown.value); + // When changing the list switch to custom. + $("#log-modules").oninput = e => { + dropdown.value = "custom"; + }; +} + +function updateLoggingOutputType(profilerOutputType) { + gLoggingSettings.loggingOutputType = profilerOutputType; + Services.prefs.setCharPref("logging.config.output_type", profilerOutputType); + $(`input[type=radio][value=${profilerOutputType}]`).checked = true; + + switch (profilerOutputType) { + case "profiler": + if (!gLoggingSettings.profilerStacks) { + // If this value is set from the URL, do not allow to change it. + $("#with-profiler-stacks-checkbox").disabled = false; + } + // hide options related to file output for clarity + $("#log-file-configuration").hidden = true; + break; + case "file": + $("#with-profiler-stacks-checkbox").disabled = true; + $("#log-file-configuration").hidden = false; + $("#no-log-file").hidden = !!$("#current-log-file").innerText.length; + break; + } +} + +function displayErrorMessage(error) { + var err = $("#error"); + err.hidden = false; + + var errorDescription = $("#error-description"); + document.l10n.setAttributes(errorDescription, error.l10nId, { + k: error.key, + v: error.value, + }); +} + +class ParseError extends Error { + constructor(l10nId, key, value) { + super(name); + this.l10nId = l10nId; + this.key = key; + this.value = value; + } + name = "ParseError"; + l10nId; + key; + value; +} + +function parseURL() { + let options = new URL(document.location.href).searchParams; + + if (!options) { + return; + } + + let modulesOverriden = null, + outputTypeOverriden = null, + loggingPresetOverriden = null, + threadsOverriden = null, + profilerPresetOverriden = null, + profilerStacksOverriden = null; + try { + for (let [k, v] of options) { + switch (k) { + case "modules": + case "module": + modulesOverriden = v; + break; + case "output": + case "output-type": + if (v !== "profiler" && v !== "file") { + throw new ParseError("about-logging-invalid-output", k, v); + } + outputTypeOverriden = v; + break; + case "preset": + case "logging-preset": + if (!Object.keys(gLoggingPresets).includes(v)) { + throw new ParseError("about-logging-unknown-logging-preset", k, v); + } + loggingPresetOverriden = v; + break; + case "threads": + case "thread": + threadsOverriden = v; + break; + case "profiler-preset": + if (!Object.keys(ProfilerPopupBackground.presets).includes(v)) { + throw new Error(["about-logging-unknown-profiler-preset", k, v]); + } + profilerPresetOverriden = v; + break; + case "profilerstacks": + profilerStacksOverriden = true; + break; + default: + throw new ParseError("about-logging-unknown-option", k, v); + } + } + } catch (e) { + displayErrorMessage(e); + return; + } + + // Detect combinations that don't make sense + if ( + (profilerPresetOverriden || threadsOverriden) && + outputTypeOverriden == "file" + ) { + displayErrorMessage( + new ParseError("about-logging-file-and-profiler-override") + ); + return; + } + + // Configuration is deemed at least somewhat valid, override each setting in + // turn + let someElementsDisabled = false; + + if (modulesOverriden || loggingPresetOverriden) { + // Don't allow changing those if set by the URL + let logModules = $("#log-modules"); + var dropdown = $("#logging-preset-dropdown"); + if (loggingPresetOverriden) { + dropdown.value = loggingPresetOverriden; + dropdown.onchange(); + } + if (modulesOverriden) { + logModules.value = modulesOverriden; + dropdown.value = "custom"; + dropdown.onchange(); + dropdown.disabled = true; + someElementsDisabled = true; + } + logModules.disabled = true; + $("#set-log-modules-button").disabled = true; + $("#logging-preset-dropdown").disabled = true; + someElementsDisabled = true; + setLogModules(); + updateLogModules(); + } + if (outputTypeOverriden) { + $$("input[type=radio]").forEach(e => (e.disabled = true)); + someElementsDisabled = true; + updateLoggingOutputType(outputTypeOverriden); + } + if (profilerStacksOverriden) { + const checkbox = $("#with-profiler-stacks-checkbox"); + checkbox.disabled = true; + someElementsDisabled = true; + Services.prefs.setBoolPref("logging.config.profilerstacks", true); + gLoggingSettings.profilerStacks = true; + } + + if (loggingPresetOverriden) { + gLoggingSettings.loggingPreset = loggingPresetOverriden; + } + if (profilerPresetOverriden) { + gLoggingSettings.profilerPreset = profilerPresetOverriden; + } + if (threadsOverriden) { + gLoggingSettings.profilerThreads = threadsOverriden; + } + + $("#some-elements-unavailable").hidden = !someElementsDisabled; +} + +let gInited = false; +function init() { + if (gInited) { + return; + } + gInited = true; + gDashboard.enableLogging = true; + + populatePresets(); + parseURL(); + + $("#log-file-configuration").addEventListener("submit", e => { + e.preventDefault(); + setLogFile(); + }); + + $("#log-modules-form").addEventListener("submit", e => { + e.preventDefault(); + setLogModules(); + }); + + let toggleLoggingButton = $("#toggle-logging-button"); + toggleLoggingButton.addEventListener("click", startStopLogging); + + $$("input[type=radio]").forEach(radio => { + radio.onchange = e => { + updateLoggingOutputType(e.target.value); + }; + }); + + $("#with-profiler-stacks-checkbox").addEventListener("change", e => { + Services.prefs.setBoolPref( + "logging.config.profilerstacks", + e.target.checked + ); + updateLogModules(); + }); + + let loggingOutputType = Services.prefs.getCharPref( + "logging.config.output_type", + "profiler" + ); + if (loggingOutputType.length) { + updateLoggingOutputType(loggingOutputType); + } + + $("#with-profiler-stacks-checkbox").checked = Services.prefs.getBoolPref( + "logging.config.profilerstacks", + false + ); + + try { + let loggingPreset = Services.prefs.getCharPref("logging.config.preset"); + gLoggingSettings.loggingPreset = loggingPreset; + } catch {} + + try { + let running = Services.prefs.getBoolPref("logging.config.running"); + gLoggingSettings.running = running; + document.l10n.setAttributes( + $("#toggle-logging-button"), + `about-logging-${gLoggingSettings.running ? "stop" : "start"}-logging` + ); + } catch {} + + try { + let file = gDirServ.getFile("TmpD", {}); + file.append("log.txt"); + $("#log-file").value = file.path; + } catch (e) { + console.error(e); + } + + // Update the value of the log file. + updateLogFile(); + + // Update the active log modules + updateLogModules(); + + // If we can't set the file and the modules at runtime, + // the start and stop buttons wouldn't really do anything. + if ( + ($("#set-log-file-button").disabled || + $("#set-log-modules-button").disabled) && + moduleEnvVarPresent() + ) { + $("#buttons-disabled").hidden = false; + toggleLoggingButton.disabled = true; + } +} + +function updateLogFile(file) { + let logPath = ""; + + // Try to get the environment variable for the log file + logPath = + Services.env.get("MOZ_LOG_FILE") || Services.env.get("NSPR_LOG_FILE"); + let currentLogFile = $("#current-log-file"); + let setLogFileButton = $("#set-log-file-button"); + + // If the log file was set from an env var, we disable the ability to set it + // at runtime. + if (logPath.length) { + currentLogFile.innerText = logPath; + setLogFileButton.disabled = true; + } else if (gDashboard.getLogPath() != ".moz_log") { + // There may be a value set by a pref. + currentLogFile.innerText = gDashboard.getLogPath(); + } else if (file !== undefined) { + currentLogFile.innerText = file; + } else { + try { + let file = gDirServ.getFile("TmpD", {}); + file.append("log.txt"); + $("#log-file").value = file.path; + } catch (e) { + console.error(e); + } + // Fall back to the temp dir + currentLogFile.innerText = $("#log-file").value; + } + + let openLogFileButton = $("#open-log-file-button"); + openLogFileButton.disabled = true; + + if (currentLogFile.innerText.length) { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(currentLogFile.innerText); + + if (file.exists()) { + openLogFileButton.disabled = false; + openLogFileButton.onclick = function (e) { + file.reveal(); + }; + } + } + $("#no-log-file").hidden = !!currentLogFile.innerText.length; + $("#current-log-file").hidden = !currentLogFile.innerText.length; +} + +function updateLogModules() { + // Try to get the environment variable for the log file + let logModules = + Services.env.get("MOZ_LOG") || + Services.env.get("MOZ_LOG_MODULES") || + Services.env.get("NSPR_LOG_MODULES"); + let currentLogModules = $("#current-log-modules"); + let setLogModulesButton = $("#set-log-modules-button"); + if (logModules.length) { + currentLogModules.innerText = logModules; + // If the log modules are set by an environment variable at startup, do not + // allow changing them throught a pref. It would be difficult to figure out + // which ones are enabled and which ones are not. The user probably knows + // what he they are doing. + setLogModulesButton.disabled = true; + } else { + let activeLogModules = []; + let children = Services.prefs.getBranch("logging.").getChildList(""); + + for (let pref of children) { + if (pref.startsWith("config.")) { + continue; + } + + try { + let value = Services.prefs.getIntPref(`logging.${pref}`); + activeLogModules.push(`${pref}:${value}`); + } catch (e) { + console.error(e); + } + } + + if (activeLogModules.length) { + // Add some options only if some modules are present. + if (Services.prefs.getBoolPref("logging.config.add_timestamp", false)) { + activeLogModules.push("timestamp"); + } + if (Services.prefs.getBoolPref("logging.config.sync", false)) { + activeLogModules.push("sync"); + } + if (Services.prefs.getBoolPref("logging.config.profilerstacks", false)) { + activeLogModules.push("profilerstacks"); + } + } + + if (activeLogModules.length !== 0) { + currentLogModules.innerText = activeLogModules.join(","); + currentLogModules.hidden = false; + $("#no-log-modules").hidden = true; + } else { + currentLogModules.innerText = ""; + currentLogModules.hidden = true; + $("#no-log-modules").hidden = false; + } + } +} + +function setLogFile() { + let setLogButton = $("#set-log-file-button"); + if (setLogButton.disabled) { + // There's no point trying since it wouldn't work anyway. + return; + } + let logFile = $("#log-file").value.trim(); + Services.prefs.setCharPref("logging.config.LOG_FILE", logFile); + updateLogFile(logFile); +} + +function clearLogModules() { + // Turn off all the modules. + let children = Services.prefs.getBranch("logging.").getChildList(""); + for (let pref of children) { + if (!pref.startsWith("config.")) { + Services.prefs.clearUserPref(`logging.${pref}`); + } + } + Services.prefs.clearUserPref("logging.config.add_timestamp"); + Services.prefs.clearUserPref("logging.config.sync"); + updateLogModules(); +} + +function setLogModules() { + if (moduleEnvVarPresent()) { + // The modules were set via env var, so we shouldn't try to change them. + return; + } + + let modules = $("#log-modules").value.trim(); + + // Clear previously set log modules. + clearLogModules(); + + if (modules.length !== 0) { + let logModules = modules.split(","); + for (let module of logModules) { + if (module == "timestamp") { + Services.prefs.setBoolPref("logging.config.add_timestamp", true); + } else if (module == "rotate") { + // XXX: rotate is not yet supported. + } else if (module == "append") { + // XXX: append is not yet supported. + } else if (module == "sync") { + Services.prefs.setBoolPref("logging.config.sync", true); + } else if (module == "profilerstacks") { + Services.prefs.setBoolPref("logging.config.profilerstacks", true); + } else { + let lastColon = module.lastIndexOf(":"); + let key = module.slice(0, lastColon); + let value = parseInt(module.slice(lastColon + 1), 10); + Services.prefs.setIntPref(`logging.${key}`, value); + } + } + } + + updateLogModules(); +} + +function isLogging() { + try { + return Services.prefs.getBoolPref("logging.config.running"); + } catch { + return false; + } +} + +function startStopLogging() { + if (isLogging()) { + document.l10n.setAttributes( + $("#toggle-logging-button"), + "about-logging-start-logging" + ); + stopLogging(); + } else { + document.l10n.setAttributes( + $("#toggle-logging-button"), + "about-logging-stop-logging" + ); + startLogging(); + } +} + +function startLogging() { + setLogModules(); + if (gLoggingSettings.loggingOutputType === "profiler") { + const pageContext = "aboutlogging"; + const supportedFeatures = Services.profiler.GetFeatures(); + if (gLoggingSettings.loggingPreset != "custom") { + // Change the preset before starting the profiler, so that the + // underlying profiler code picks up the right configuration. + const profilerPreset = + gLoggingPresets[gLoggingSettings.loggingPreset].profilerPreset; + ProfilerPopupBackground.changePreset( + "aboutlogging", + profilerPreset, + supportedFeatures + ); + } else { + // a baseline set of threads, and possibly others, overriden by the URL + ProfilerPopupBackground.changePreset( + "aboutlogging", + "firefox-platform", + supportedFeatures + ); + } + let { entries, interval, features, threads, duration } = + ProfilerPopupBackground.getRecordingSettings( + pageContext, + Services.profiler.GetFeatures() + ); + + if (gLoggingSettings.profilerThreads) { + threads.push(...gLoggingSettings.profilerThreads.split(",")); + // Small hack: if cubeb is being logged, it's almost always necessary (and + // never harmful) to enable audio callback tracing, otherwise, no log + // statements will be recorded from real-time threads. + if (gLoggingSettings.profilerThreads.includes("cubeb")) { + features.push("audiocallbacktracing"); + } + } + const win = Services.wm.getMostRecentWindow("navigator:browser"); + const windowid = win?.gBrowser?.selectedBrowser?.browsingContext?.browserId; + + // Force displaying the profiler button in the navbar if not preset, so + // that there is a visual indication profiling is in progress. + if (!ProfilerMenuButton.isInNavbar()) { + // Ensure the widget is enabled. + Services.prefs.setBoolPref( + "devtools.performance.popup.feature-flag", + true + ); + // Enable the profiler menu button. + ProfilerMenuButton.addToNavbar(); + // Dispatch the change event manually, so that the shortcuts will also be + // added. + CustomizableUI.dispatchToolboxEvent("customizationchange"); + } + + gProfilerPromise = Services.profiler.StartProfiler( + entries, + interval, + features, + threads, + windowid, + duration + ); + } else { + setLogFile(); + } + Services.prefs.setBoolPref("logging.config.running", true); +} + +async function stopLogging() { + if (gLoggingSettings.loggingOutputType === "profiler") { + await ProfilerPopupBackground.captureProfile("aboutlogging"); + } else { + Services.prefs.clearUserPref("logging.config.LOG_FILE"); + updateLogFile(); + } + Services.prefs.setBoolPref("logging.config.running", false); + clearLogModules(); +} + +// We use the pageshow event instead of onload. This is needed because sometimes +// the page is loaded via session-restore/bfcache. In such cases we need to call +// init() to keep the page behaviour consistent with the ticked checkboxes. +// Mostly the issue is with the autorefresh checkbox. +window.addEventListener("pageshow", function () { + init(); +}); diff --git a/toolkit/content/aboutMozilla.css b/toolkit/content/aboutMozilla.css new file mode 100644 index 0000000000..6babca7f26 --- /dev/null +++ b/toolkit/content/aboutMozilla.css @@ -0,0 +1,35 @@ +/* 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/. */ + +html { + background: maroon radial-gradient( circle, #a01010 0%, #800000 80%) center center / cover no-repeat; + color: white; + font-style: italic; + text-rendering: optimizeLegibility; + min-height: 100%; +} + +#moztext { + margin-top: 15%; + font-size: 1.1em; + font-family: serif; + text-align: center; + line-height: 1.5; +} + +#from { + font-size: 1.95em; + font-family: serif; + text-align: end; +} + +em { + font-size: 1.3em; + line-height: 0; +} + +a { + text-decoration: none; + color: white; +} diff --git a/toolkit/content/aboutNetError.html b/toolkit/content/aboutNetError.html new file mode 100644 index 0000000000..965038ad45 --- /dev/null +++ b/toolkit/content/aboutNetError.html @@ -0,0 +1,250 @@ +<!DOCTYPE html> +<!-- 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/. --> +<html data-l10n-sync="true"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="neterror-page-title"></title> + <link + rel="stylesheet" + href="chrome://global/skin/aboutNetError.css" + type="text/css" + media="all" + /> + <link rel="icon" id="favicon" /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/neterror/certError.ftl" /> + <link rel="localization" href="toolkit/neterror/netError.ftl" /> + </head> + <body> + <div class="container"> + <div id="text-container"> + <!-- Error Title --> + <div class="title"> + <h1 class="title-text"></h1> + </div> + + <!-- Short Description --> + <p id="errorShortDesc"></p> + <p id="errorShortDesc2"></p> + + <div id="errorWhatToDo" hidden=""> + <p + id="errorWhatToDoTitle" + data-l10n-id="certerror-what-can-you-do-about-it-title" + ></p> + <p id="badStsCertExplanation" hidden=""></p> + <p id="errorWhatToDoText"></p> + </div> + + <!-- Long Description --> + <div id="errorLongDesc"></div> + + <div id="trrOnlyContainer" hidden=""> + <p id="trrOnlyMessage"></p> + <div class="trr-message-container"> + <span id="trrOnlyDescription"></span> + <p id="trrLearnMoreContainer" hidden=""> + <a + id="trrOnlylearnMoreLink" + target="_blank" + rel="noopener noreferrer" + data-l10n-id="neterror-learn-more-link" + ></a> + </p> + </div> + <p data-l10n-id="neterror-dns-not-found-trr-third-party-warning2"></p> + </div> + + <div id="nativeFallbackContainer" hidden=""> + <p id="nativeFallbackMessage"></p> + <div class="trr-message-container"> + <span id="nativeFallbackDescription"></span> + <p id="nativeFallbackLearnMoreContainer" hidden=""> + <a + id="nativeFallbackLearnMoreLink" + target="_blank" + rel="noopener noreferrer" + data-l10n-id="neterror-learn-more-link" + ></a> + </p> + </div> + <p data-l10n-id="neterror-dns-not-found-trr-third-party-warning2"></p> + </div> + + <p id="tlsVersionNotice" hidden=""></p> + + <p id="learnMoreContainer" hidden=""> + <a + id="learnMoreLink" + target="_blank" + rel="noopener noreferrer" + data-telemetry-id="learn_more_link" + data-l10n-id="neterror-learn-more-link" + ></a> + </p> + + <div id="openInNewWindowContainer" class="button-container" hidden=""> + <p> + <a + id="openInNewWindowButton" + target="_blank" + rel="noopener noreferrer" + > + <button + class="primary" + data-l10n-id="open-in-new-window-for-csp-or-xfo-error" + ></button + ></a> + </p> + </div> + + <!-- UI for option to report certificate errors to Mozilla. Removed on + init for other error types .--> + <div id="prefChangeContainer" class="button-container" hidden=""> + <p data-l10n-id="neterror-pref-reset"></p> + <button + id="prefResetButton" + class="primary" + data-l10n-id="neterror-pref-reset-button" + ></button> + </div> + + <div + id="certErrorAndCaptivePortalButtonContainer" + class="button-container" + hidden="" + > + <button + id="returnButton" + class="primary" + data-telemetry-id="return_button_top" + data-l10n-id="neterror-return-to-previous-page-recommended-button" + ></button> + <button + id="openPortalLoginPageButton" + class="primary" + data-l10n-id="neterror-open-portal-login-page-button" + hidden="" + ></button> + <button + id="certErrorTryAgainButton" + class="primary try-again" + data-l10n-id="neterror-try-again-button" + hidden="" + ></button> + <button + id="advancedButton" + data-telemetry-id="advanced_button" + data-l10n-id="neterror-advanced-button" + ></button> + </div> + </div> + + <div id="netErrorButtonContainer" class="button-container" hidden=""> + <button + id="neterrorTryAgainButton" + class="primary try-again" + data-l10n-id="neterror-try-again-button" + data-telemetry-id="try_again_button" + ></button> + <button + id="trrExceptionButton" + data-l10n-id="neterror-add-exception-button" + data-telemetry-id="add_exception_button" + hidden="" + ></button> + <button + id="trrSettingsButton" + data-l10n-id="neterror-settings-button" + data-telemetry-id="settings_button" + hidden="" + ></button> + <button + id="nativeFallbackContinueThisTimeButton" + data-l10n-id="neterror-trr-continue-this-time" + data-telemetry-id="continue_button" + hidden="" + ></button> + <button + id="nativeFallbackIgnoreButton" + data-l10n-id="neterror-disable-native-feedback-warning" + data-telemetry-id="disable_warning" + hidden="" + ></button> + </div> + + <div class="advanced-panel-container"> + <div id="badCertAdvancedPanel" class="advanced-panel" hidden=""> + <p id="badCertTechnicalInfo"></p> + <a + id="viewCertificate" + href="javascript:void(0)" + data-l10n-id="neterror-view-certificate-link" + ></a> + <div id="advancedPanelButtonContainer" class="button-container"> + <button + id="advancedPanelReturnButton" + class="primary" + data-telemetry-id="return_button_adv" + data-l10n-id="neterror-return-to-previous-page-recommended-button" + ></button> + <button + id="advancedPanelTryAgainButton" + class="primary try-again" + data-l10n-id="neterror-try-again-button" + hidden="" + ></button> + <button + id="exceptionDialogButton" + data-telemetry-id="exception_button" + data-l10n-id="neterror-override-exception-button" + ></button> + </div> + </div> + + <div id="blockingErrorReporting" class="advanced-panel" hidden=""> + <p class="toggle-container-with-text"> + <input + type="checkbox" + id="automaticallyReportBlockingInFuture" + role="checkbox" + /> + <label + for="automaticallyReportBlockingInFuture" + data-l10n-id="neterror-error-reporting-automatic" + ></label> + </p> + </div> + + <div + id="certificateErrorDebugInformation" + class="advanced-panel" + hidden="" + > + <button + id="copyToClipboardTop" + data-telemetry-id="clipboard_button_top" + data-l10n-id="neterror-copy-to-clipboard-button" + ></button> + <div id="certificateErrorText"></div> + <button + id="copyToClipboardBottom" + data-telemetry-id="clipboard_button_bot" + data-l10n-id="neterror-copy-to-clipboard-button" + ></button> + </div> + </div> + </div> + <script src="chrome://global/content/neterror/aboutNetErrorCodes.js"></script> + <script + type="module" + src="chrome://global/content/aboutNetError.mjs" + ></script> + </body> +</html> diff --git a/toolkit/content/aboutNetError.mjs b/toolkit/content/aboutNetError.mjs new file mode 100644 index 0000000000..83f40fc479 --- /dev/null +++ b/toolkit/content/aboutNetError.mjs @@ -0,0 +1,1559 @@ +/* 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/. */ + +/* eslint-env mozilla/remote-page */ +/* eslint-disable import/no-unassigned-import */ + +import { + parse, + pemToDER, +} from "chrome://global/content/certviewer/certDecoder.mjs"; + +const formatter = new Intl.DateTimeFormat(); + +const HOST_NAME = getHostName(); + +function getHostName() { + try { + return new URL(RPMGetInnerMostURI(document.location.href)).hostname; + } catch (error) { + console.error("Could not parse URL", error); + } + return ""; +} + +// Used to check if we have a specific localized message for an error. +const KNOWN_ERROR_TITLE_IDS = new Set([ + // Error titles: + "connectionFailure-title", + "deniedPortAccess-title", + "dnsNotFound-title", + "dns-not-found-trr-only-title2", + "fileNotFound-title", + "fileAccessDenied-title", + "generic-title", + "captivePortal-title", + "malformedURI-title", + "netInterrupt-title", + "notCached-title", + "netOffline-title", + "contentEncodingError-title", + "unsafeContentType-title", + "netReset-title", + "netTimeout-title", + "unknownProtocolFound-title", + "proxyConnectFailure-title", + "proxyResolveFailure-title", + "redirectLoop-title", + "unknownSocketType-title", + "nssFailure2-title", + "csp-xfo-error-title", + "corruptedContentError-title", + "sslv3Used-title", + "inadequateSecurityError-title", + "blockedByPolicy-title", + "clockSkewError-title", + "networkProtocolError-title", + "nssBadCert-title", + "nssBadCert-sts-title", + "certerror-mitm-title", +]); + +/* The error message IDs from nsserror.ftl get processed into + * aboutNetErrorCodes.js which is loaded before we are: */ +/* global KNOWN_ERROR_MESSAGE_IDS */ +const ERROR_MESSAGES_FTL = "toolkit/neterror/nsserrors.ftl"; + +// The following parameters are parsed from the error URL: +// e - the error code +// s - custom CSS class to allow alternate styling/favicons +// d - error description +// captive - "true" to indicate we're behind a captive portal. +// Any other value is ignored. + +// Note that this file uses document.documentURI to get +// the URL (with the format from above). This is because +// document.location.href gets the current URI off the docshell, +// which is the URL displayed in the location bar, i.e. +// the URI that the user attempted to load. + +let searchParams = new URLSearchParams(document.documentURI.split("?")[1]); + +let gErrorCode = searchParams.get("e"); +let gIsCertError = gErrorCode == "nssBadCert"; +let gHasSts = gIsCertError && getCSSClass() === "badStsCert"; + +// If the location of the favicon changes, FAVICON_CERTERRORPAGE_URL and/or +// FAVICON_ERRORPAGE_URL in toolkit/components/places/nsFaviconService.idl +// should also be updated. +document.getElementById("favicon").href = + gIsCertError || gErrorCode == "nssFailure2" + ? "chrome://global/skin/icons/warning.svg" + : "chrome://global/skin/icons/info.svg"; + +function getCSSClass() { + return searchParams.get("s"); +} + +function getDescription() { + return searchParams.get("d"); +} + +function isCaptive() { + return searchParams.get("captive") == "true"; +} + +/** + * We don't actually know what the MitM is called (since we don't + * maintain a list), so we'll try and display the common name of the + * root issuer to the user. In the worst case they are as clueless as + * before, in the best case this gives them an actionable hint. + * This may be revised in the future. + */ +function getMitmName(failedCertInfo) { + return failedCertInfo.issuerCommonName; +} + +function retryThis(buttonEl) { + RPMSendAsyncMessage("Browser:EnableOnlineMode"); + buttonEl.disabled = true; +} + +function showPrefChangeContainer() { + const panel = document.getElementById("prefChangeContainer"); + panel.hidden = false; + document.getElementById("netErrorButtonContainer").hidden = true; + document + .getElementById("prefResetButton") + .addEventListener("click", function resetPreferences() { + RPMSendAsyncMessage("Browser:ResetSSLPreferences"); + }); + setFocus("#prefResetButton", "beforeend"); +} + +function toggleCertErrorDebugInfoVisibility(shouldShow) { + let debugInfo = document.getElementById("certificateErrorDebugInformation"); + let copyButton = document.getElementById("copyToClipboardTop"); + + if (shouldShow === undefined) { + shouldShow = debugInfo.hidden; + } + debugInfo.hidden = !shouldShow; + if (shouldShow) { + copyButton.scrollIntoView({ block: "start", behavior: "smooth" }); + copyButton.focus(); + } +} + +function setupAdvancedButton() { + // Get the hostname and add it to the panel + var panel = document.getElementById("badCertAdvancedPanel"); + + // Register click handler for the weakCryptoAdvancedPanel + document + .getElementById("advancedButton") + .addEventListener("click", togglePanelVisibility); + + function togglePanelVisibility() { + if (panel.hidden) { + // Reveal + revealAdvancedPanelSlowlyAsync(); + + // send event to trigger telemetry ping + document.dispatchEvent( + new CustomEvent("AboutNetErrorUIExpanded", { bubbles: true }) + ); + } else { + // Hide + panel.hidden = true; + } + } + + if (getCSSClass() == "expertBadCert") { + revealAdvancedPanelSlowlyAsync(); + } +} + +async function revealAdvancedPanelSlowlyAsync() { + const badCertAdvancedPanel = document.getElementById("badCertAdvancedPanel"); + const exceptionDialogButton = document.getElementById( + "exceptionDialogButton" + ); + + // Toggling the advanced panel must ensure that the debugging + // information panel is hidden as well, since it's opened by the + // error code link in the advanced panel. + toggleCertErrorDebugInfoVisibility(false); + + // Reveal, but disabled (and grayed-out) for 3.0s. + badCertAdvancedPanel.hidden = false; + exceptionDialogButton.disabled = true; + + // - + + if (exceptionDialogButton.resetReveal) { + exceptionDialogButton.resetReveal(); // Reset if previous is pending. + } + let wasReset = false; + exceptionDialogButton.resetReveal = () => { + wasReset = true; + }; + + // Wait for 10 frames to ensure that the warning text is rendered + // and gets all the way to the screen for the user to read it. + // This is only ~0.160s at 60Hz, so it's not too much extra time that we're + // taking to ensure that we're caught up with rendering, on top of the + // (by default) whole second(s) we're going to wait based on the + // security.dialog_enable_delay pref. + // The catching-up to rendering is the important part, not the + // N-frame-delay here. + for (let i = 0; i < 10; i++) { + await new Promise(requestAnimationFrame); + } + + // Wait another Nms (default: 1000) for the user to be very sure. (Sorry speed readers!) + const securityDelayMs = RPMGetIntPref("security.dialog_enable_delay", 1000); + await new Promise(go => setTimeout(go, securityDelayMs)); + + if (wasReset) { + return; + } + + // Enable and un-gray-out. + exceptionDialogButton.disabled = false; +} + +function disallowCertOverridesIfNeeded() { + // Disallow overrides if this is a Strict-Transport-Security + // host and the cert is bad (STS Spec section 7.3) or if the + // certerror is in a frame (bug 633691). + if (gHasSts || window != top) { + document.getElementById("exceptionDialogButton").hidden = true; + } + if (gHasSts) { + const stsExplanation = document.getElementById("badStsCertExplanation"); + document.l10n.setAttributes( + stsExplanation, + "certerror-what-should-i-do-bad-sts-cert-explanation", + { hostname: HOST_NAME } + ); + stsExplanation.hidden = false; + + document.l10n.setAttributes( + document.getElementById("returnButton"), + "neterror-return-to-previous-page-button" + ); + document.l10n.setAttributes( + document.getElementById("advancedPanelReturnButton"), + "neterror-return-to-previous-page-button" + ); + } +} + +function recordTRREventTelemetry( + warningPageType, + trrMode, + trrDomain, + skipReason +) { + RPMRecordTelemetryEvent( + "security.doh.neterror", + "load", + "dohwarning", + warningPageType, + { + mode: trrMode, + provider_key: trrDomain, + skip_reason: skipReason, + } + ); + + const netErrorButtonDiv = document.getElementById("netErrorButtonContainer"); + const buttons = netErrorButtonDiv.querySelectorAll("button"); + for (let b of buttons) { + b.addEventListener("click", function (e) { + let target = e.originalTarget; + let telemetryId = target.dataset.telemetryId; + RPMRecordTelemetryEvent( + "security.doh.neterror", + "click", + telemetryId, + warningPageType, + { + mode: trrMode, + provider_key: trrDomain, + skip_reason: skipReason, + } + ); + }); + } +} + +function initPage() { + // We show an offline support page in case of a system-wide error, + // when a user cannot connect to the internet and access the SUMO website. + // For example, clock error, which causes certerrors across the web or + // a security software conflict where the user is unable to connect + // to the internet. + // The URL that prompts us to show an offline support page should have the following + // format: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/supportPageSlug", + // so we can extract the support page slug. + let baseURL = RPMGetFormatURLPref("app.support.baseURL"); + if (document.location.href.startsWith(baseURL)) { + let supportPageSlug = document.location.pathname.split("/").pop(); + RPMSendAsyncMessage("DisplayOfflineSupportPage", { + supportPageSlug, + }); + } + + const className = getCSSClass(); + if (className) { + document.body.classList.add(className); + } + + const isTRROnlyFailure = gErrorCode == "dnsNotFound" && RPMIsTRROnlyFailure(); + + let isNativeFallbackWarning = false; + if (RPMGetBoolPref("network.trr.display_fallback_warning")) { + isNativeFallbackWarning = + gErrorCode == "dnsNotFound" && RPMIsNativeFallbackFailure(); + } + + const docTitle = document.querySelector("title"); + const bodyTitle = document.querySelector(".title-text"); + const shortDesc = document.getElementById("errorShortDesc"); + + if (gIsCertError) { + const isStsError = window !== window.top || gHasSts; + const errArgs = { hostname: HOST_NAME }; + if (isCaptive()) { + document.l10n.setAttributes( + docTitle, + "neterror-captive-portal-page-title" + ); + document.l10n.setAttributes(bodyTitle, "captivePortal-title"); + document.l10n.setAttributes( + shortDesc, + "neterror-captive-portal", + errArgs + ); + initPageCaptivePortal(); + } else { + if (isStsError) { + document.l10n.setAttributes(docTitle, "certerror-sts-page-title"); + document.l10n.setAttributes(bodyTitle, "nssBadCert-sts-title"); + document.l10n.setAttributes(shortDesc, "certerror-sts-intro", errArgs); + } else { + document.l10n.setAttributes(docTitle, "certerror-page-title"); + document.l10n.setAttributes(bodyTitle, "nssBadCert-title"); + document.l10n.setAttributes(shortDesc, "certerror-intro", errArgs); + } + initPageCertError(); + } + + initCertErrorPageActions(); + setTechnicalDetailsOnCertError(); + return; + } + + document.body.classList.add("neterror"); + + let longDesc = document.getElementById("errorLongDesc"); + const tryAgain = document.getElementById("netErrorButtonContainer"); + tryAgain.hidden = false; + const learnMore = document.getElementById("learnMoreContainer"); + const learnMoreLink = document.getElementById("learnMoreLink"); + learnMoreLink.setAttribute("href", baseURL + "connection-not-secure"); + + let pageTitleId = "neterror-page-title"; + let bodyTitleId = gErrorCode + "-title"; + + switch (gErrorCode) { + case "blockedByPolicy": + pageTitleId = "neterror-blocked-by-policy-page-title"; + document.body.classList.add("blocked"); + + // Remove the "Try again" button from pages that don't need it. + // For pages blocked by policy, trying again won't help. + tryAgain.hidden = true; + break; + + case "cspBlocked": + case "xfoBlocked": { + bodyTitleId = "csp-xfo-error-title"; + + // Remove the "Try again" button for XFO and CSP violations, + // since it's almost certainly useless. (Bug 553180) + tryAgain.hidden = true; + + // Adding a button for opening websites blocked for CSP and XFO violations + // in a new window. (Bug 1461195) + document.getElementById("errorShortDesc").hidden = true; + + document.l10n.setAttributes(longDesc, "csp-xfo-blocked-long-desc", { + hostname: HOST_NAME, + }); + longDesc = null; + + document.getElementById("openInNewWindowContainer").hidden = false; + + const openInNewWindowButton = document.getElementById( + "openInNewWindowButton" + ); + openInNewWindowButton.href = document.location.href; + + // Add a learn more link + learnMore.hidden = false; + learnMoreLink.setAttribute("href", baseURL + "xframe-neterror-page"); + + setupBlockingReportingUI(); + break; + } + + case "dnsNotFound": + pageTitleId = "neterror-dns-not-found-title"; + if (!isTRROnlyFailure) { + RPMCheckAlternateHostAvailable(); + } + + break; + case "inadequateSecurityError": + // Remove the "Try again" button from pages that don't need it. + // For HTTP/2 inadequate security, trying again won't help. + tryAgain.hidden = true; + break; + + case "malformedURI": + pageTitleId = "neterror-malformed-uri-page-title"; + // Remove the "Try again" button from pages that don't need it. + tryAgain.hidden = true; + break; + + // Pinning errors are of type nssFailure2 + case "nssFailure2": { + learnMore.hidden = false; + + const errorCode = document.getNetErrorInfo().errorCodeString; + switch (errorCode) { + case "SSL_ERROR_UNSUPPORTED_VERSION": + case "SSL_ERROR_PROTOCOL_VERSION_ALERT": { + const tlsNotice = document.getElementById("tlsVersionNotice"); + tlsNotice.hidden = false; + document.l10n.setAttributes(tlsNotice, "cert-error-old-tls-version"); + } + // fallthrough + + case "interrupted": // This happens with subresources that are above the max tls + case "SSL_ERROR_NO_CIPHERS_SUPPORTED": + case "SSL_ERROR_NO_CYPHER_OVERLAP": + case "SSL_ERROR_SSL_DISABLED": + RPMAddMessageListener("HasChangedCertPrefs", msg => { + if (msg.data.hasChangedCertPrefs) { + // Configuration overrides might have caused this; offer to reset. + showPrefChangeContainer(); + } + }); + RPMSendAsyncMessage("GetChangedCertPrefs"); + } + + break; + } + + case "sslv3Used": + learnMore.hidden = false; + document.body.className = "certerror"; + break; + } + + if (!KNOWN_ERROR_TITLE_IDS.has(bodyTitleId)) { + console.error("No strings exist for error:", gErrorCode); + bodyTitleId = "generic-title"; + } + + // The TRR errors may present options that direct users to settings only available on Firefox Desktop + if (RPMIsFirefox()) { + if (isTRROnlyFailure) { + document.body.className = "certerror"; // Shows warning icon + pageTitleId = "dns-not-found-trr-only-title2"; + document.l10n.setAttributes(docTitle, pageTitleId); + bodyTitleId = "dns-not-found-trr-only-title2"; + document.l10n.setAttributes(bodyTitle, bodyTitleId); + + shortDesc.textContent = ""; + let skipReason = RPMGetTRRSkipReason(); + + // enable buttons + let trrExceptionButton = document.getElementById("trrExceptionButton"); + trrExceptionButton.addEventListener("click", () => { + RPMSendQuery("Browser:AddTRRExcludedDomain", { + hostname: HOST_NAME, + }).then(msg => { + retryThis(trrExceptionButton); + }); + }); + + let isTrrServerError = true; + if (RPMIsSiteSpecificTRRError()) { + // Only show the exclude button if the failure is specific to this + // domain. If the TRR server is inaccessible we don't want to allow + // the user to add an exception just for this domain. + trrExceptionButton.hidden = false; + isTrrServerError = false; + } + let trrSettingsButton = document.getElementById("trrSettingsButton"); + trrSettingsButton.addEventListener("click", () => { + RPMSendAsyncMessage("OpenTRRPreferences"); + }); + trrSettingsButton.hidden = false; + let message = document.getElementById("trrOnlyMessage"); + document.l10n.setAttributes( + message, + "neterror-dns-not-found-trr-only-reason2", + { + hostname: HOST_NAME, + } + ); + + let descriptionTag = "neterror-dns-not-found-trr-unknown-problem"; + let args = { trrDomain: RPMGetTRRDomain() }; + if ( + skipReason == "TRR_FAILED" || + skipReason == "TRR_CHANNEL_DNS_FAIL" || + skipReason == "TRR_UNKNOWN_CHANNEL_FAILURE" || + skipReason == "TRR_NET_REFUSED" || + skipReason == "TRR_NET_INTERRUPT" || + skipReason == "TRR_NET_INADEQ_SEQURITY" + ) { + descriptionTag = "neterror-dns-not-found-trr-only-could-not-connect"; + } else if (skipReason == "TRR_TIMEOUT") { + descriptionTag = "neterror-dns-not-found-trr-only-timeout"; + } else if ( + skipReason == "TRR_IS_OFFLINE" || + skipReason == "TRR_NO_CONNECTIVITY" + ) { + descriptionTag = "neterror-dns-not-found-trr-offline"; + } else if ( + skipReason == "TRR_NO_ANSWERS" || + skipReason == "TRR_NXDOMAIN" || + skipReason == "TRR_RCODE_FAIL" + ) { + descriptionTag = "neterror-dns-not-found-trr-unknown-host2"; + } else if ( + skipReason == "TRR_DECODE_FAILED" || + skipReason == "TRR_SERVER_RESPONSE_ERR" + ) { + descriptionTag = "neterror-dns-not-found-trr-server-problem"; + } else if (skipReason == "TRR_BAD_URL") { + descriptionTag = "neterror-dns-not-found-bad-trr-url"; + } + + let trrMode = RPMGetIntPref("network.trr.mode").toString(); + recordTRREventTelemetry( + "TRROnlyFailure", + trrMode, + args.trrDomain, + skipReason + ); + + let description = document.getElementById("trrOnlyDescription"); + document.l10n.setAttributes(description, descriptionTag, args); + + const trrLearnMoreContainer = document.getElementById( + "trrLearnMoreContainer" + ); + trrLearnMoreContainer.hidden = false; + let trrOnlyLearnMoreLink = document.getElementById( + "trrOnlylearnMoreLink" + ); + if (isTrrServerError) { + // Go to DoH settings page + trrOnlyLearnMoreLink.href = "about:preferences#privacy-doh"; + trrOnlyLearnMoreLink.addEventListener("click", event => { + event.preventDefault(); + RPMSendAsyncMessage("OpenTRRPreferences"); + RPMRecordTelemetryEvent( + "security.doh.neterror", + "click", + "settings_button", + "TRROnlyFailure", + { + mode: trrMode, + provider_key: args.trrDomain, + skip_reason: skipReason, + } + ); + }); + } else { + // This will be replaced at a later point with a link to an offline support page + // https://bugzilla.mozilla.org/show_bug.cgi?id=1806257 + trrOnlyLearnMoreLink.href = + RPMGetFormatURLPref("network.trr_ui.skip_reason_learn_more_url") + + skipReason.toLowerCase().replaceAll("_", "-"); + } + + let div = document.getElementById("trrOnlyContainer"); + div.hidden = false; + + return; + } else if (isNativeFallbackWarning) { + showNativeFallbackWarning(); + return; + } + } + + document.l10n.setAttributes(docTitle, pageTitleId); + document.l10n.setAttributes(bodyTitle, bodyTitleId); + + shortDesc.textContent = getDescription(); + setFocus("#netErrorButtonContainer > .try-again"); + + if (longDesc) { + const parts = getNetErrorDescParts(); + setNetErrorMessageFromParts(longDesc, parts); + } + + setNetErrorMessageFromCode(); +} + +function showNativeFallbackWarning() { + const docTitle = document.querySelector("title"); + const bodyTitle = document.querySelector(".title-text"); + const shortDesc = document.getElementById("errorShortDesc"); + + let pageTitleId = "neterror-page-title"; + let bodyTitleId = gErrorCode + "-title"; + + document.body.className = "certerror"; // Shows warning icon + pageTitleId = "dns-not-found-native-fallback-title2"; + document.l10n.setAttributes(docTitle, pageTitleId); + + bodyTitleId = "dns-not-found-native-fallback-title2"; + document.l10n.setAttributes(bodyTitle, bodyTitleId); + + shortDesc.textContent = ""; + let nativeFallbackIgnoreButton = document.getElementById( + "nativeFallbackIgnoreButton" + ); + nativeFallbackIgnoreButton.addEventListener("click", () => { + RPMSetPref("network.trr.display_fallback_warning", false); + retryThis(nativeFallbackIgnoreButton); + }); + + let continueThisTimeButton = document.getElementById( + "nativeFallbackContinueThisTimeButton" + ); + continueThisTimeButton.addEventListener("click", () => { + RPMSetTRRDisabledLoadFlags(); + document.location.reload(); + }); + continueThisTimeButton.hidden = false; + + nativeFallbackIgnoreButton.hidden = false; + let message = document.getElementById("nativeFallbackMessage"); + document.l10n.setAttributes( + message, + "neterror-dns-not-found-native-fallback-reason2", + { + hostname: HOST_NAME, + } + ); + let skipReason = RPMGetTRRSkipReason(); + let descriptionTag = "neterror-dns-not-found-trr-unknown-problem"; + let args = { trrDomain: RPMGetTRRDomain() }; + + if (skipReason.includes("HEURISTIC_TRIPPED")) { + descriptionTag = "neterror-dns-not-found-native-fallback-heuristic"; + } else if (skipReason == "TRR_NOT_CONFIRMED") { + descriptionTag = "neterror-dns-not-found-native-fallback-not-confirmed2"; + } + + let description = document.getElementById("nativeFallbackDescription"); + document.l10n.setAttributes(description, descriptionTag, args); + + let learnMoreContainer = document.getElementById( + "nativeFallbackLearnMoreContainer" + ); + learnMoreContainer.hidden = false; + + let learnMoreLink = document.getElementById("nativeFallbackLearnMoreLink"); + learnMoreLink.href = + RPMGetFormatURLPref("network.trr_ui.skip_reason_learn_more_url") + + skipReason.toLowerCase().replaceAll("_", "-"); + + let div = document.getElementById("nativeFallbackContainer"); + div.hidden = false; + + recordTRREventTelemetry( + "NativeFallbackWarning", + RPMGetIntPref("network.trr.mode").toString(), + args.trrDomain, + skipReason + ); +} +/** + * Builds HTML elements from `parts` and appends them to `parentElement`. + * + * @param {HTMLElement} parentElement + * @param {Array<["li" | "p" | "span", string, Record<string, string> | undefined]>} parts + */ +function setNetErrorMessageFromParts(parentElement, parts) { + let list = null; + + for (let [tag, l10nId, l10nArgs] of parts) { + const elem = document.createElement(tag); + elem.dataset.l10nId = l10nId; + if (l10nArgs) { + elem.dataset.l10nArgs = JSON.stringify(l10nArgs); + } + + if (tag === "li") { + if (!list) { + list = document.createElement("ul"); + parentElement.appendChild(list); + } + list.appendChild(elem); + } else { + if (list) { + list = null; + } + parentElement.appendChild(elem); + } + } +} + +/** + * Returns an array of tuples determining the parts of an error message: + * - HTML tag name + * - l10n id + * - l10n args (optional) + * + * @returns { Array<["li" | "p" | "span", string, Record<string, string> | undefined]> } + */ +function getNetErrorDescParts() { + switch (gErrorCode) { + case "connectionFailure": + case "netInterrupt": + case "netReset": + case "netTimeout": + return [ + ["li", "neterror-load-error-try-again"], + ["li", "neterror-load-error-connection"], + ["li", "neterror-load-error-firewall"], + ]; + + case "blockedByPolicy": + case "deniedPortAccess": + case "malformedURI": + return []; + + case "captivePortal": + return [["p", ""]]; + case "contentEncodingError": + return [["li", "neterror-content-encoding-error"]]; + case "corruptedContentErrorv2": + return [ + ["p", "neterror-corrupted-content-intro"], + ["li", "neterror-corrupted-content-contact-website"], + ]; + case "dnsNotFound": + return [ + ["span", "neterror-dns-not-found-hint-header"], + ["li", "neterror-dns-not-found-hint-try-again"], + ["li", "neterror-dns-not-found-hint-check-network"], + ["li", "neterror-dns-not-found-hint-firewall"], + ]; + case "fileAccessDenied": + return [["li", "neterror-access-denied"]]; + case "fileNotFound": + return [ + ["li", "neterror-file-not-found-filename"], + ["li", "neterror-file-not-found-moved"], + ]; + case "inadequateSecurityError": + return [ + ["p", "neterror-inadequate-security-intro", { hostname: HOST_NAME }], + ["p", "neterror-inadequate-security-code"], + ]; + case "mitm": { + const failedCertInfo = document.getFailedCertSecurityInfo(); + const errArgs = { + hostname: HOST_NAME, + mitm: getMitmName(failedCertInfo), + }; + return [["span", "certerror-mitm", errArgs]]; + } + case "netOffline": + return [["li", "neterror-net-offline"]]; + case "networkProtocolError": + return [ + ["p", "neterror-network-protocol-error-intro"], + ["li", "neterror-network-protocol-error-contact-website"], + ]; + case "notCached": + return [ + ["p", "neterror-not-cached-intro"], + ["li", "neterror-not-cached-sensitive"], + ["li", "neterror-not-cached-try-again"], + ]; + case "nssFailure2": + return [ + ["li", "neterror-nss-failure-not-verified"], + ["li", "neterror-nss-failure-contact-website"], + ]; + case "proxyConnectFailure": + return [ + ["li", "neterror-proxy-connect-failure-settings"], + ["li", "neterror-proxy-connect-failure-contact-admin"], + ]; + case "proxyResolveFailure": + return [ + ["li", "neterror-proxy-resolve-failure-settings"], + ["li", "neterror-proxy-resolve-failure-connection"], + ["li", "neterror-proxy-resolve-failure-firewall"], + ]; + case "redirectLoop": + return [["li", "neterror-redirect-loop"]]; + case "sslv3Used": + return [["span", "neterror-sslv3-used"]]; + case "unknownProtocolFound": + return [["li", "neterror-unknown-protocol"]]; + case "unknownSocketType": + return [ + ["li", "neterror-unknown-socket-type-psm-installed"], + ["li", "neterror-unknown-socket-type-server-config"], + ]; + case "unsafeContentType": + return [["li", "neterror-unsafe-content-type"]]; + + default: + return [["p", "neterror-generic-error"]]; + } +} + +function setNetErrorMessageFromCode() { + let errorCode; + try { + errorCode = document.getNetErrorInfo().errorCodeString; + } catch (ex) { + // We don't have a securityInfo when this is for example a DNS error. + return; + } + + let errorMessage; + if (errorCode) { + const l10nId = errorCode.replace(/_/g, "-").toLowerCase(); + if (KNOWN_ERROR_MESSAGE_IDS.has(l10nId)) { + const l10n = new Localization([ERROR_MESSAGES_FTL], true); + errorMessage = l10n.formatValueSync(l10nId); + } + + const shortDesc2 = document.getElementById("errorShortDesc2"); + document.l10n.setAttributes(shortDesc2, "cert-error-code-prefix", { + error: errorCode, + }); + } else { + console.warn("This error page has no error code in its security info"); + } + + let hostname = HOST_NAME; + const { port } = document.location; + if (port && port != 443) { + hostname += ":" + port; + } + + const shortDesc = document.getElementById("errorShortDesc"); + document.l10n.setAttributes(shortDesc, "cert-error-ssl-connection-error", { + errorMessage: errorMessage ?? errorCode ?? "", + hostname, + }); +} + +function setupBlockingReportingUI() { + let checkbox = document.getElementById("automaticallyReportBlockingInFuture"); + + let reportingAutomatic = RPMGetBoolPref( + "security.xfocsp.errorReporting.automatic" + ); + checkbox.checked = !!reportingAutomatic; + + checkbox.addEventListener("change", function ({ target: { checked } }) { + RPMSetPref("security.xfocsp.errorReporting.automatic", checked); + + // If we're enabling reports, send a report for this failure. + if (checked) { + reportBlockingError(); + } + }); + + let reportingEnabled = RPMGetBoolPref( + "security.xfocsp.errorReporting.enabled" + ); + + if (reportingEnabled) { + // Display blocking error reporting UI for XFO error and CSP error. + document.getElementById("blockingErrorReporting").hidden = false; + + if (reportingAutomatic) { + reportBlockingError(); + } + } +} + +function reportBlockingError() { + // We only report if we are in a frame. + if (window === window.top) { + return; + } + + let err = gErrorCode; + // Ensure we only deal with XFO and CSP here. + if (!["xfoBlocked", "cspBlocked"].includes(err)) { + return; + } + + let xfo_header = RPMGetHttpResponseHeader("X-Frame-Options"); + let csp_header = RPMGetHttpResponseHeader("Content-Security-Policy"); + + // Extract the 'CSP: frame-ancestors' from the CSP header. + let reg = /(?:^|\s)frame-ancestors\s([^;]*)[$]*/i; + let match = reg.exec(csp_header); + csp_header = match ? match[1] : ""; + + // If it's the csp error page without the CSP: frame-ancestors, this means + // this error page is not triggered by CSP: frame-ancestors. So, we bail out + // early. + if (err === "cspBlocked" && !csp_header) { + return; + } + + let xfoAndCspInfo = { + error_type: err === "xfoBlocked" ? "xfo" : "csp", + xfo_header, + csp_header, + }; + + // Trimming the tail colon symbol. + let scheme = document.location.protocol.slice(0, -1); + + RPMSendAsyncMessage("ReportBlockingError", { + scheme, + host: document.location.host, + port: parseInt(document.location.port) || -1, + path: document.location.pathname, + xfoAndCspInfo, + }); +} + +function initPageCaptivePortal() { + document.body.className = "captiveportal"; + document.getElementById("returnButton").hidden = true; + const openButton = document.getElementById("openPortalLoginPageButton"); + openButton.hidden = false; + openButton.addEventListener("click", () => { + RPMSendAsyncMessage("Browser:OpenCaptivePortalPage"); + }); + + setFocus("#openPortalLoginPageButton"); + setupAdvancedButton(); + disallowCertOverridesIfNeeded(); + + // When the portal is freed, an event is sent by the parent process + // that we can pick up and attempt to reload the original page. + RPMAddMessageListener("AboutNetErrorCaptivePortalFreed", () => { + document.location.reload(); + }); +} + +function initPageCertError() { + document.body.classList.add("certerror"); + + setFocus("#returnButton"); + setupAdvancedButton(); + disallowCertOverridesIfNeeded(); + + const hideAddExceptionButton = RPMGetBoolPref( + "security.certerror.hideAddException", + false + ); + if (hideAddExceptionButton) { + document.getElementById("exceptionDialogButton").hidden = true; + } + + const els = document.querySelectorAll("[data-telemetry-id]"); + for (let el of els) { + el.addEventListener("click", recordClickTelemetry); + } + + const failedCertInfo = document.getFailedCertSecurityInfo(); + // Truncate the error code to avoid going over the allowed + // string size limit for telemetry events. + const errorCode = failedCertInfo.errorCodeString.substring(0, 40); + RPMRecordTelemetryEvent( + "security.ui.certerror", + "load", + "aboutcerterror", + errorCode, + { + has_sts: gHasSts.toString(), + is_frame: (window.parent != window).toString(), + } + ); + + setCertErrorDetails(); +} + +function recordClickTelemetry(e) { + let target = e.originalTarget; + let telemetryId = target.dataset.telemetryId; + let failedCertInfo = document.getFailedCertSecurityInfo(); + // Truncate the error code to avoid going over the allowed + // string size limit for telemetry events. + let errorCode = failedCertInfo.errorCodeString.substring(0, 40); + RPMRecordTelemetryEvent( + "security.ui.certerror", + "click", + telemetryId, + errorCode, + { + has_sts: gHasSts.toString(), + is_frame: (window.parent != window).toString(), + } + ); +} + +function initCertErrorPageActions() { + document.getElementById( + "certErrorAndCaptivePortalButtonContainer" + ).hidden = false; + document + .getElementById("returnButton") + .addEventListener("click", onReturnButtonClick); + document + .getElementById("advancedPanelReturnButton") + .addEventListener("click", onReturnButtonClick); + document + .getElementById("copyToClipboardTop") + .addEventListener("click", copyPEMToClipboard); + document + .getElementById("copyToClipboardBottom") + .addEventListener("click", copyPEMToClipboard); + document + .getElementById("exceptionDialogButton") + .addEventListener("click", addCertException); +} + +function addCertException() { + const isPermanent = + !RPMIsWindowPrivate() && + RPMGetBoolPref("security.certerrors.permanentOverride"); + document.addCertException(!isPermanent).then( + () => { + location.reload(); + }, + err => {} + ); +} + +function onReturnButtonClick(e) { + RPMSendAsyncMessage("Browser:SSLErrorGoBack"); +} + +function copyPEMToClipboard(e) { + const errorText = document.getElementById("certificateErrorText"); + navigator.clipboard.writeText(errorText.textContent); +} + +async function getFailedCertificatesAsPEMString() { + let locationUrl = document.location.href; + let failedCertInfo = document.getFailedCertSecurityInfo(); + let errorMessage = failedCertInfo.errorMessage; + let hasHSTS = failedCertInfo.hasHSTS.toString(); + let hasHPKP = failedCertInfo.hasHPKP.toString(); + let [hstsLabel, hpkpLabel, failedChainLabel] = + await document.l10n.formatValues([ + { id: "cert-error-details-hsts-label", args: { hasHSTS } }, + { id: "cert-error-details-key-pinning-label", args: { hasHPKP } }, + { id: "cert-error-details-cert-chain-label" }, + ]); + + let certStrings = failedCertInfo.certChainStrings; + let failedChainCertificates = ""; + for (let der64 of certStrings) { + let wrapped = der64.replace(/(\S{64}(?!$))/g, "$1\r\n"); + failedChainCertificates += + "-----BEGIN CERTIFICATE-----\r\n" + + wrapped + + "\r\n-----END CERTIFICATE-----\r\n"; + } + + let details = + locationUrl + + "\r\n\r\n" + + errorMessage + + "\r\n\r\n" + + hstsLabel + + "\r\n" + + hpkpLabel + + "\r\n\r\n" + + failedChainLabel + + "\r\n\r\n" + + failedChainCertificates; + return details; +} + +function setCertErrorDetails() { + // Check if the connection is being man-in-the-middled. When the parent + // detects an intercepted connection, the page may be reloaded with a new + // error code (MOZILLA_PKIX_ERROR_MITM_DETECTED). + const failedCertInfo = document.getFailedCertSecurityInfo(); + const mitmPrimingEnabled = RPMGetBoolPref( + "security.certerrors.mitm.priming.enabled" + ); + if ( + mitmPrimingEnabled && + failedCertInfo.errorCodeString == "SEC_ERROR_UNKNOWN_ISSUER" && + // Only do this check for top-level failures. + window.parent == window + ) { + RPMSendAsyncMessage("Browser:PrimeMitm"); + } + + document.body.setAttribute("code", failedCertInfo.errorCodeString); + + const learnMore = document.getElementById("learnMoreContainer"); + learnMore.hidden = false; + const learnMoreLink = document.getElementById("learnMoreLink"); + const baseURL = RPMGetFormatURLPref("app.support.baseURL"); + learnMoreLink.href = baseURL + "connection-not-secure"; + + const bodyTitle = document.querySelector(".title-text"); + const shortDesc = document.getElementById("errorShortDesc"); + const shortDesc2 = document.getElementById("errorShortDesc2"); + + let whatToDoParts = null; + + switch (failedCertInfo.errorCodeString) { + case "SSL_ERROR_BAD_CERT_DOMAIN": + whatToDoParts = [ + ["p", "certerror-bad-cert-domain-what-can-you-do-about-it"], + ]; + break; + + case "SEC_ERROR_OCSP_INVALID_SIGNING_CERT": // FIXME - this would have thrown? + break; + + case "SEC_ERROR_UNKNOWN_ISSUER": + whatToDoParts = [ + ["p", "certerror-unknown-issuer-what-can-you-do-about-it-website"], + [ + "p", + "certerror-unknown-issuer-what-can-you-do-about-it-contact-admin", + ], + ]; + break; + + // This error code currently only exists for the Symantec distrust + // in Firefox 63, so we add copy explaining that to the user. + // In case of future distrusts of that scale we might need to add + // additional parameters that allow us to identify the affected party + // without replicating the complex logic from certverifier code. + case "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED": { + document.l10n.setAttributes( + shortDesc2, + "cert-error-symantec-distrust-description", + { hostname: HOST_NAME } + ); + + // FIXME - this does nothing + const adminDesc = document.createElement("p"); + document.l10n.setAttributes( + adminDesc, + "cert-error-symantec-distrust-admin" + ); + + learnMoreLink.href = baseURL + "symantec-warning"; + break; + } + + case "MOZILLA_PKIX_ERROR_MITM_DETECTED": { + const autoEnabledEnterpriseRoots = RPMGetBoolPref( + "security.enterprise_roots.auto-enabled", + false + ); + if (mitmPrimingEnabled && autoEnabledEnterpriseRoots) { + RPMSendAsyncMessage("Browser:ResetEnterpriseRootsPref"); + } + + learnMoreLink.href = baseURL + "security-error"; + + document.l10n.setAttributes(bodyTitle, "certerror-mitm-title"); + + document.l10n.setAttributes(shortDesc, "certerror-mitm", { + hostname: HOST_NAME, + mitm: getMitmName(failedCertInfo), + }); + + const id3 = gHasSts + ? "certerror-mitm-what-can-you-do-about-it-attack-sts" + : "certerror-mitm-what-can-you-do-about-it-attack"; + whatToDoParts = [ + ["li", "certerror-mitm-what-can-you-do-about-it-antivirus"], + ["li", "certerror-mitm-what-can-you-do-about-it-corporate"], + ["li", id3, { mitm: getMitmName(failedCertInfo) }], + ]; + break; + } + + case "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT": + learnMoreLink.href = baseURL + "security-error"; + break; + + // In case the certificate expired we make sure the system clock + // matches the remote-settings service (blocklist via Kinto) ping time + // and is not before the build date. + case "SEC_ERROR_EXPIRED_CERTIFICATE": + case "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE": + case "MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE": + case "MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE": { + learnMoreLink.href = baseURL + "time-errors"; + + // We check against the remote-settings server time first if available, because that allows us + // to give the user an approximation of what the correct time is. + const difference = RPMGetIntPref( + "services.settings.clock_skew_seconds", + 0 + ); + const lastFetched = + RPMGetIntPref("services.settings.last_update_seconds", 0) * 1000; + + // This is set to true later if the user's system clock is at fault for this error. + let clockSkew = false; + + const now = Date.now(); + const certRange = { + notBefore: failedCertInfo.certValidityRangeNotBefore, + notAfter: failedCertInfo.certValidityRangeNotAfter, + }; + const approximateDate = now - difference * 1000; + // If the difference is more than a day, we last fetched the date in the last 5 days, + // and adjusting the date per the interval would make the cert valid, warn the user: + if ( + Math.abs(difference) > 60 * 60 * 24 && + now - lastFetched <= 60 * 60 * 24 * 5 * 1000 && + certRange.notBefore < approximateDate && + certRange.notAfter > approximateDate + ) { + clockSkew = true; + // If there is no clock skew with Kinto servers, check against the build date. + // (The Kinto ping could have happened when the time was still right, or not at all) + } else { + const appBuildID = RPMGetAppBuildID(); + const year = parseInt(appBuildID.substr(0, 4), 10); + const month = parseInt(appBuildID.substr(4, 2), 10) - 1; + const day = parseInt(appBuildID.substr(6, 2), 10); + + const buildDate = new Date(year, month, day); + + // We don't check the notBefore of the cert with the build date, + // as it is of course almost certain that it is now later than the build date, + // so we shouldn't exclude the possibility that the cert has become valid + // since the build date. + if (buildDate > now && new Date(certRange.notAfter) > buildDate) { + clockSkew = true; + } + } + + if (clockSkew) { + document.body.classList.add("clockSkewError"); + document.l10n.setAttributes(bodyTitle, "clockSkewError-title"); + document.l10n.setAttributes(shortDesc, "neterror-clock-skew-error", { + hostname: HOST_NAME, + now, + }); + document.getElementById("returnButton").hidden = true; + document.getElementById("certErrorTryAgainButton").hidden = false; + document.getElementById("advancedButton").hidden = true; + + document.getElementById("advancedPanelReturnButton").hidden = true; + document.getElementById("advancedPanelTryAgainButton").hidden = false; + document.getElementById("exceptionDialogButton").hidden = true; + break; + } + + document.l10n.setAttributes(shortDesc, "certerror-expired-cert-intro", { + hostname: HOST_NAME, + }); + + // The secondary description mentions expired certificates explicitly + // and should only be shown if the certificate has actually expired + // instead of being not yet valid. + if (failedCertInfo.errorCodeString == "SEC_ERROR_EXPIRED_CERTIFICATE") { + const sd2Id = gHasSts + ? "certerror-expired-cert-sts-second-para" + : "certerror-expired-cert-second-para"; + document.l10n.setAttributes(shortDesc2, sd2Id); + if ( + Math.abs(difference) <= 60 * 60 * 24 && + now - lastFetched <= 60 * 60 * 24 * 5 * 1000 + ) { + whatToDoParts = [ + ["p", "certerror-bad-cert-domain-what-can-you-do-about-it"], + ]; + } + } + + whatToDoParts ??= [ + [ + "p", + "certerror-expired-cert-what-can-you-do-about-it-clock", + { hostname: HOST_NAME, now }, + ], + [ + "p", + "certerror-expired-cert-what-can-you-do-about-it-contact-website", + ], + ]; + break; + } + } + + if (whatToDoParts) { + setNetErrorMessageFromParts( + document.getElementById("errorWhatToDoText"), + whatToDoParts + ); + document.getElementById("errorWhatToDo").hidden = false; + } +} + +async function getSubjectAltNames(failedCertInfo) { + const serverCertBase64 = failedCertInfo.certChainStrings[0]; + const parsed = await parse(pemToDER(serverCertBase64)); + const subjectAltNamesExtension = parsed.ext.san; + const subjectAltNames = []; + if (subjectAltNamesExtension) { + for (let [key, value] of subjectAltNamesExtension.altNames) { + if (key === "DNS Name" && value.length) { + subjectAltNames.push(value); + } + } + } + return subjectAltNames; +} + +// The optional argument is only here for testing purposes. +function setTechnicalDetailsOnCertError( + failedCertInfo = document.getFailedCertSecurityInfo() +) { + let technicalInfo = document.getElementById("badCertTechnicalInfo"); + technicalInfo.textContent = ""; + + function addLabel(l10nId, args = null, attrs = null) { + let elem = document.createElement("label"); + technicalInfo.appendChild(elem); + + let newLines = document.createTextNode("\n \n"); + technicalInfo.appendChild(newLines); + + if (attrs) { + let link = document.createElement("a"); + for (let [attr, value] of Object.entries(attrs)) { + link.setAttribute(attr, value); + } + elem.appendChild(link); + } + + document.l10n.setAttributes(elem, l10nId, args); + } + + function addErrorCodeLink() { + addLabel( + "cert-error-code-prefix-link", + { error: failedCertInfo.errorCodeString }, + { + title: failedCertInfo.errorCodeString, + id: "errorCode", + "data-l10n-name": "error-code-link", + "data-telemetry-id": "error_code_link", + href: "#certificateErrorDebugInformation", + } + ); + + // We're attaching the event listener to the parent element and not on + // the errorCodeLink itself because event listeners cannot be attached + // to fluent DOM overlays. + technicalInfo.addEventListener("click", event => { + if (event.target.id === "errorCode") { + event.preventDefault(); + toggleCertErrorDebugInfoVisibility(); + recordClickTelemetry(event); + } + }); + } + + let hostname = HOST_NAME; + const { port } = document.location; + if (port && port != 443) { + hostname += ":" + port; + } + + switch (failedCertInfo.overridableErrorCategory) { + case "trust-error": + switch (failedCertInfo.errorCodeString) { + case "MOZILLA_PKIX_ERROR_MITM_DETECTED": + addLabel("cert-error-mitm-intro"); + addLabel("cert-error-mitm-mozilla"); + addLabel("cert-error-mitm-connection"); + break; + case "SEC_ERROR_UNKNOWN_ISSUER": + addLabel("cert-error-trust-unknown-issuer-intro"); + addLabel("cert-error-trust-unknown-issuer", { hostname }); + break; + case "SEC_ERROR_CA_CERT_INVALID": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-cert-invalid"); + break; + case "SEC_ERROR_UNTRUSTED_ISSUER": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-untrusted-issuer"); + break; + case "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-signature-algorithm-disabled"); + break; + case "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-expired-issuer"); + break; + case "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-self-signed"); + break; + case "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-symantec"); + break; + default: + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-untrusted-default"); + } + addErrorCodeLink(); + break; + + case "expired-or-not-yet-valid": { + const notBefore = failedCertInfo.validNotBefore; + const notAfter = failedCertInfo.validNotAfter; + if (notBefore && Date.now() < notAfter) { + addLabel("cert-error-not-yet-valid-now", { + hostname, + "not-before-local-time": formatter.format(new Date(notBefore)), + }); + } else { + addLabel("cert-error-expired-now", { + hostname, + "not-after-local-time": formatter.format(new Date(notAfter)), + }); + } + addErrorCodeLink(); + break; + } + + case "domain-mismatch": + getSubjectAltNames(failedCertInfo).then(subjectAltNames => { + if (!subjectAltNames.length) { + addLabel("cert-error-domain-mismatch", { hostname }); + } else if (subjectAltNames.length > 1) { + const names = subjectAltNames.join(", "); + addLabel("cert-error-domain-mismatch-multiple", { + hostname, + "subject-alt-names": names, + }); + } else { + const altName = subjectAltNames[0]; + + // If the alt name is a wildcard domain ("*.example.com") + // let's use "www" instead. "*.example.com" isn't going to + // get anyone anywhere useful. bug 432491 + const okHost = altName.replace(/^\*\./, "www."); + + // Let's check if we want to make this a link. + const showLink = + /* case #1: + * example.com uses an invalid security certificate. + * + * The certificate is only valid for www.example.com + * + * Make sure to include the "." ahead of thisHost so that a + * MitM attack on paypal.com doesn't hyperlink to "notpaypal.com" + * + * We'd normally just use a RegExp here except that we lack a + * library function to escape them properly (bug 248062), and + * domain names are famous for having '.' characters in them, + * which would allow spurious and possibly hostile matches. + */ + okHost.endsWith("." + HOST_NAME) || + /* case #2: + * browser.garage.maemo.org uses an invalid security certificate. + * + * The certificate is only valid for garage.maemo.org + */ + HOST_NAME.endsWith("." + okHost); + + const l10nArgs = { hostname, "alt-name": altName }; + if (showLink) { + // Set the link if we want it. + const proto = document.location.protocol + "//"; + addLabel("cert-error-domain-mismatch-single", l10nArgs, { + href: proto + okHost, + "data-l10n-name": "domain-mismatch-link", + id: "cert_domain_link", + }); + + // If we set a link, meaning there's something helpful for + // the user here, expand the section by default + if (getCSSClass() != "expertBadCert") { + revealAdvancedPanelSlowlyAsync(); + } + } else { + addLabel("cert-error-domain-mismatch-single-nolink", l10nArgs); + } + } + addErrorCodeLink(); + }); + break; + } + + getFailedCertificatesAsPEMString().then(pemString => { + const errorText = document.getElementById("certificateErrorText"); + errorText.textContent = pemString; + }); +} + +/* Only focus if we're the toplevel frame; otherwise we + don't want to call attention to ourselves! +*/ +function setFocus(selector, position = "afterbegin") { + if (window.top == window) { + var button = document.querySelector(selector); + button.parentNode.insertAdjacentElement(position, button); + // It's possible setFocus was called via the DOMContentLoaded event + // handler and that the button has no frame. Things without a frame cannot + // be focused. We use a requestAnimationFrame to queue up the focus to occur + // once the button has its frame. + requestAnimationFrame(() => { + button.focus({ focusVisible: false }); + }); + } +} + +for (let button of document.querySelectorAll(".try-again")) { + button.addEventListener("click", function () { + retryThis(this); + }); +} + +initPage(); + +// Dispatch this event so tests can detect that we finished loading the error page. +document.dispatchEvent(new CustomEvent("AboutNetErrorLoad", { bubbles: true })); diff --git a/toolkit/content/aboutNetworking.html b/toolkit/content/aboutNetworking.html new file mode 100644 index 0000000000..40a543c5ab --- /dev/null +++ b/toolkit/content/aboutNetworking.html @@ -0,0 +1,307 @@ +<!-- 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/. --> + +<!DOCTYPE html> + +<html> + <head> + <meta charset="utf-8" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="about-networking-title"></title> + <link rel="stylesheet" href="chrome://global/skin/aboutNetworking.css" /> + <script src="chrome://global/content/aboutNetworking.js"></script> + <link rel="localization" href="toolkit/about/aboutNetworking.ftl" /> + </head> + <body id="body"> + <div id="categories"> + <div class="category category-no-icon" selected="true" id="category-http"> + <span class="category-name" data-l10n-id="about-networking-http"></span> + </div> + <div class="category category-no-icon" id="category-sockets"> + <span + class="category-name" + data-l10n-id="about-networking-sockets" + ></span> + </div> + <div class="category category-no-icon" id="category-dns"> + <span class="category-name" data-l10n-id="about-networking-dns"></span> + </div> + <div class="category category-no-icon" id="category-websockets"> + <span + class="category-name" + data-l10n-id="about-networking-websockets" + ></span> + </div> + <hr /> + <div class="category category-no-icon" id="category-dnslookuptool"> + <span + class="category-name" + data-l10n-id="about-networking-dns-lookup" + ></span> + </div> + <div class="category category-no-icon" id="category-logging"> + <span + class="category-name" + data-l10n-id="about-networking-logging" + ></span> + </div> + <div class="category category-no-icon" id="category-rcwn"> + <span class="category-name" data-l10n-id="about-networking-rcwn"></span> + </div> + <div class="category category-no-icon" id="category-networkid"> + <span + class="category-name" + data-l10n-id="about-networking-networkid" + ></span> + </div> + </div> + <div class="main-content"> + <div class="header"> + <h1 + id="sectionTitle" + class="header-name" + data-l10n-id="about-networking-http" + ></h1> + <div id="refreshDiv"> + <button + id="refreshButton" + data-l10n-id="about-networking-refresh" + ></button> + <label class="toggle-container-with-text"> + <input + id="autorefcheck" + type="checkbox" + name="Autorefresh" + role="checkbox" + /> + <span data-l10n-id="about-networking-auto-refresh"></span> + </label> + </div> + </div> + + <div id="http" class="tab active"> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-hostname"></th> + <th data-l10n-id="about-networking-port"></th> + <th data-l10n-id="about-networking-http-version"></th> + <th data-l10n-id="about-networking-ssl"></th> + <th data-l10n-id="about-networking-active"></th> + <th data-l10n-id="about-networking-idle"></th> + </tr> + </thead> + <tbody id="http_content"></tbody> + </table> + </div> + + <div id="sockets" class="tab" hidden="true"> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-host"></th> + <th data-l10n-id="about-networking-port"></th> + <th data-l10n-id="about-networking-type"></th> + <th data-l10n-id="about-networking-active"></th> + <th data-l10n-id="about-networking-sent"></th> + <th data-l10n-id="about-networking-received"></th> + </tr> + </thead> + <tbody id="sockets_content"></tbody> + </table> + </div> + + <div id="dns" class="tab" hidden="true"> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-dns-suffix"></th> + </tr> + </thead> + <tbody id="dns_suffix_content"></tbody> + </table> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-dns-trr-url"></th> + <th data-l10n-id="about-networking-dns-trr-mode"></th> + </tr> + </thead> + <tbody id="dns_trr_url"></tbody> + </table> + <br /><br /> + <button + id="clearDNSCache" + data-l10n-id="about-networking-dns-clear-cache-button" + ></button> + <br /><br /> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-hostname"></th> + <th data-l10n-id="about-networking-family"></th> + <th data-l10n-id="about-networking-trr"></th> + <th data-l10n-id="about-networking-addresses"></th> + <th data-l10n-id="about-networking-expires"></th> + <th data-l10n-id="about-networking-originAttributesSuffix"></th> + <th data-l10n-id="about-networking-flags"></th> + </tr> + </thead> + <tbody id="dns_content"></tbody> + </table> + </div> + + <div id="websockets" class="tab" hidden="true"> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-hostname"></th> + <th data-l10n-id="about-networking-ssl"></th> + <th data-l10n-id="about-networking-messages-sent"></th> + <th data-l10n-id="about-networking-messages-received"></th> + <th data-l10n-id="about-networking-bytes-sent"></th> + <th data-l10n-id="about-networking-bytes-received"></th> + </tr> + </thead> + <tbody id="websockets_content"></tbody> + </table> + </div> + + <div id="dnslookuptool" class="tab" hidden="true"> + <label data-l10n-id="about-networking-dns-domain"></label> + <input type="text" name="host" id="host" /> + <button + id="dnsLookupButton" + data-l10n-id="about-networking-dns-lookup-button" + ></button> + <hr /> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-dns-lookup-table-column"></th> + </tr> + </thead> + <tbody id="dnslookuptool_content"></tbody> + </table> + <hr /> + <table> + <thead> + <tr> + <th + data-l10n-id="about-networking-dns-https-rr-lookup-table-column" + ></th> + </tr> + </thead> + <tbody id="https_rr_content"></tbody> + </table> + </div> + + <div id="rcwn" class="tab" hidden="true"> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-rcwn-status"></th> + <th data-l10n-id="about-networking-total-network-requests"></th> + <th data-l10n-id="about-networking-rcwn-cache-won-count"></th> + <th data-l10n-id="about-networking-rcwn-net-won-count"></th> + </tr> + </thead> + <tbody id="rcwn_content"> + <tr> + <td id="rcwn_status"></td> + <td id="total_req_count"></td> + <td id="rcwn_cache_won_count"></td> + <td id="rcwn_cache_net_count"></td> + </tr> + </tbody> + </table> + + <br /><br /> + + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-rcwn-operation"></th> + <th data-l10n-id="about-networking-rcwn-avg-short"></th> + <th data-l10n-id="about-networking-rcwn-avg-long"></th> + <th data-l10n-id="about-networking-rcwn-std-dev-long"></th> + </tr> + </thead> + <tbody id="cacheperf_content"> + <tr> + <td data-l10n-id="about-networking-rcwn-perf-open"></td> + <td id="rcwn_perfstats_open_avgShort"></td> + <td id="rcwn_perfstats_open_avgLong"></td> + <td id="rcwn_perfstats_open_stddevLong"></td> + </tr> + <tr> + <td data-l10n-id="about-networking-rcwn-perf-read"></td> + <td id="rcwn_perfstats_read_avgShort"></td> + <td id="rcwn_perfstats_read_avgLong"></td> + <td id="rcwn_perfstats_read_stddevLong"></td> + </tr> + <tr> + <td data-l10n-id="about-networking-rcwn-perf-write"></td> + <td id="rcwn_perfstats_write_avgShort"></td> + <td id="rcwn_perfstats_write_avgLong"></td> + <td id="rcwn_perfstats_write_stddevLong"></td> + </tr> + <tr> + <td data-l10n-id="about-networking-rcwn-perf-entry-open"></td> + <td id="rcwn_perfstats_entryopen_avgShort"></td> + <td id="rcwn_perfstats_entryopen_avgLong"></td> + <td id="rcwn_perfstats_entryopen_stddevLong"></td> + </tr> + </tbody> + </table> + + <br /><br /> + + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-rcwn-cache-slow"></th> + <th data-l10n-id="about-networking-rcwn-cache-not-slow"></th> + </tr> + </thead> + <tbody> + <tr> + <td id="rcwn_cache_slow"></td> + <td id="rcwn_cache_not_slow"></td> + </tr> + </tbody> + </table> + </div> + + <div id="logging" class="tab" hidden="true"> + <span data-l10n-id="about-networking-moved-about-logging"> + <a data-l10n-name="about-logging-url" href="about:logging"></a> + </span> + </div> + + <div id="networkid" class="tab" hidden="true"> + <table> + <thead> + <tr> + <th data-l10n-id="about-networking-networkid-is-up"></th> + <th data-l10n-id="about-networking-networkid-status-known"></th> + <th data-l10n-id="about-networking-networkid-id"></th> + </tr> + </thead> + <tbody id="networkid_content"> + <tr> + <td id="networkid_isUp"></td> + <td id="networkid_statusKnown"></td> + <td id="networkid_id"></td> + </tr> + </tbody> + </table> + </div> + </div> + </body> +</html> diff --git a/toolkit/content/aboutNetworking.js b/toolkit/content/aboutNetworking.js new file mode 100644 index 0000000000..d33d0cc88b --- /dev/null +++ b/toolkit/content/aboutNetworking.js @@ -0,0 +1,418 @@ +/* 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"; + +const FileUtils = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +).FileUtils; +const gDashboard = Cc["@mozilla.org/network/dashboard;1"].getService( + Ci.nsIDashboard +); +const gDirServ = Cc["@mozilla.org/file/directory_service;1"].getService( + Ci.nsIDirectoryServiceProvider +); +const gNetLinkSvc = + Cc["@mozilla.org/network/network-link-service;1"] && + Cc["@mozilla.org/network/network-link-service;1"].getService( + Ci.nsINetworkLinkService + ); + +const gRequestNetworkingData = { + http: gDashboard.requestHttpConnections, + sockets: gDashboard.requestSockets, + dns: gDashboard.requestDNSInfo, + websockets: gDashboard.requestWebsocketConnections, + dnslookuptool: () => {}, + rcwn: gDashboard.requestRcwnStats, + networkid: displayNetworkID, +}; +const gDashboardCallbacks = { + http: displayHttp, + sockets: displaySockets, + dns: displayDns, + websockets: displayWebsockets, + rcwn: displayRcwnStats, +}; + +const REFRESH_INTERVAL_MS = 3000; + +function col(element) { + let col = document.createElement("td"); + let content = document.createTextNode(element); + col.appendChild(content); + return col; +} + +function displayHttp(data) { + let cont = document.getElementById("http_content"); + let parent = cont.parentNode; + let new_cont = document.createElement("tbody"); + new_cont.setAttribute("id", "http_content"); + + for (let i = 0; i < data.connections.length; i++) { + let row = document.createElement("tr"); + row.appendChild(col(data.connections[i].host)); + row.appendChild(col(data.connections[i].port)); + row.appendChild(col(data.connections[i].httpVersion)); + row.appendChild(col(data.connections[i].ssl)); + row.appendChild(col(data.connections[i].active.length)); + row.appendChild(col(data.connections[i].idle.length)); + new_cont.appendChild(row); + } + + parent.replaceChild(new_cont, cont); +} + +function displaySockets(data) { + let cont = document.getElementById("sockets_content"); + let parent = cont.parentNode; + let new_cont = document.createElement("tbody"); + new_cont.setAttribute("id", "sockets_content"); + + for (let i = 0; i < data.sockets.length; i++) { + let row = document.createElement("tr"); + row.appendChild(col(data.sockets[i].host)); + row.appendChild(col(data.sockets[i].port)); + row.appendChild(col(data.sockets[i].type)); + row.appendChild(col(data.sockets[i].active)); + row.appendChild(col(data.sockets[i].sent)); + row.appendChild(col(data.sockets[i].received)); + new_cont.appendChild(row); + } + + parent.replaceChild(new_cont, cont); +} + +function displayDns(data) { + let suffixContent = document.getElementById("dns_suffix_content"); + let suffixParent = suffixContent.parentNode; + let suffixes = []; + try { + suffixes = gNetLinkSvc.dnsSuffixList; // May throw + } catch (e) {} + let suffix_tbody = document.createElement("tbody"); + suffix_tbody.id = "dns_suffix_content"; + for (let suffix of suffixes) { + let row = document.createElement("tr"); + row.appendChild(col(suffix)); + suffix_tbody.appendChild(row); + } + suffixParent.replaceChild(suffix_tbody, suffixContent); + + let trr_url_tbody = document.createElement("tbody"); + trr_url_tbody.id = "dns_trr_url"; + let trr_url = document.createElement("tr"); + trr_url.appendChild(col(Services.dns.currentTrrURI)); + trr_url.appendChild(col(Services.dns.currentTrrMode)); + trr_url_tbody.appendChild(trr_url); + let prevURL = document.getElementById("dns_trr_url"); + prevURL.parentNode.replaceChild(trr_url_tbody, prevURL); + + let cont = document.getElementById("dns_content"); + let parent = cont.parentNode; + let new_cont = document.createElement("tbody"); + new_cont.setAttribute("id", "dns_content"); + + for (let i = 0; i < data.entries.length; i++) { + let row = document.createElement("tr"); + row.appendChild(col(data.entries[i].hostname)); + row.appendChild(col(data.entries[i].family)); + row.appendChild(col(data.entries[i].trr)); + let column = document.createElement("td"); + + for (let j = 0; j < data.entries[i].hostaddr.length; j++) { + column.appendChild(document.createTextNode(data.entries[i].hostaddr[j])); + column.appendChild(document.createElement("br")); + } + + row.appendChild(column); + row.appendChild(col(data.entries[i].expiration)); + row.appendChild(col(data.entries[i].originAttributesSuffix)); + row.appendChild(col(data.entries[i].flags)); + new_cont.appendChild(row); + } + + parent.replaceChild(new_cont, cont); +} + +function displayWebsockets(data) { + let cont = document.getElementById("websockets_content"); + let parent = cont.parentNode; + let new_cont = document.createElement("tbody"); + new_cont.setAttribute("id", "websockets_content"); + + for (let i = 0; i < data.websockets.length; i++) { + let row = document.createElement("tr"); + row.appendChild(col(data.websockets[i].hostport)); + row.appendChild(col(data.websockets[i].encrypted)); + row.appendChild(col(data.websockets[i].msgsent)); + row.appendChild(col(data.websockets[i].msgreceived)); + row.appendChild(col(data.websockets[i].sentsize)); + row.appendChild(col(data.websockets[i].receivedsize)); + new_cont.appendChild(row); + } + + parent.replaceChild(new_cont, cont); +} + +function displayRcwnStats(data) { + let status = Services.prefs.getBoolPref("network.http.rcwn.enabled"); + let linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN; + try { + linkType = gNetLinkSvc.linkType; + } catch (e) {} + if ( + !( + linkType == Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN || + linkType == Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET || + linkType == Ci.nsINetworkLinkService.LINK_TYPE_USB || + linkType == Ci.nsINetworkLinkService.LINK_TYPE_WIFI + ) + ) { + status = false; + } + + let cacheWon = data.rcwnCacheWonCount; + let netWon = data.rcwnNetWonCount; + let total = data.totalNetworkRequests; + let cacheSlow = data.cacheSlowCount; + let cacheNotSlow = data.cacheNotSlowCount; + + document.getElementById("rcwn_status").innerText = status; + document.getElementById("total_req_count").innerText = total; + document.getElementById("rcwn_cache_won_count").innerText = cacheWon; + document.getElementById("rcwn_cache_net_count").innerText = netWon; + document.getElementById("rcwn_cache_slow").innerText = cacheSlow; + document.getElementById("rcwn_cache_not_slow").innerText = cacheNotSlow; + + // Keep in sync with CachePerfStats::EDataType in CacheFileUtils.h + const perfStatTypes = ["open", "read", "write", "entryopen"]; + + const perfStatFieldNames = ["avgShort", "avgLong", "stddevLong"]; + + for (let typeIndex in perfStatTypes) { + for (let statFieldIndex in perfStatFieldNames) { + document.getElementById( + "rcwn_perfstats_" + + perfStatTypes[typeIndex] + + "_" + + perfStatFieldNames[statFieldIndex] + ).innerText = + data.perfStats[typeIndex][perfStatFieldNames[statFieldIndex]]; + } + } +} + +function displayNetworkID() { + try { + let linkIsUp = gNetLinkSvc.isLinkUp; + let linkStatusKnown = gNetLinkSvc.linkStatusKnown; + let networkID = gNetLinkSvc.networkID; + + document.getElementById("networkid_isUp").innerText = linkIsUp; + document.getElementById("networkid_statusKnown").innerText = + linkStatusKnown; + document.getElementById("networkid_id").innerText = networkID; + } catch (e) { + document.getElementById("networkid_isUp").innerText = "<unknown>"; + document.getElementById("networkid_statusKnown").innerText = "<unknown>"; + document.getElementById("networkid_id").innerText = "<unknown>"; + } +} + +function requestAllNetworkingData() { + for (let id in gRequestNetworkingData) { + requestNetworkingDataForTab(id); + } +} + +function requestNetworkingDataForTab(id) { + gRequestNetworkingData[id](gDashboardCallbacks[id]); +} + +let gInited = false; +function init() { + if (gInited) { + return; + } + gInited = true; + + requestAllNetworkingData(); + + let autoRefresh = document.getElementById("autorefcheck"); + if (autoRefresh.checked) { + setAutoRefreshInterval(autoRefresh); + } + + autoRefresh.addEventListener("click", function () { + let refrButton = document.getElementById("refreshButton"); + if (this.checked) { + setAutoRefreshInterval(this); + refrButton.disabled = "disabled"; + } else { + clearInterval(this.interval); + refrButton.disabled = null; + } + }); + + let refr = document.getElementById("refreshButton"); + refr.addEventListener("click", requestAllNetworkingData); + if (document.getElementById("autorefcheck").checked) { + refr.disabled = "disabled"; + } + + // Event delegation on #categories element + let menu = document.getElementById("categories"); + menu.addEventListener("click", function click(e) { + if (e.target && e.target.parentNode == menu) { + show(e.target); + } + }); + + let dnsLookupButton = document.getElementById("dnsLookupButton"); + dnsLookupButton.addEventListener("click", function () { + doLookup(); + }); + + let clearDNSCache = document.getElementById("clearDNSCache"); + clearDNSCache.addEventListener("click", function () { + Services.dns.clearCache(true); + }); + + if (location.hash) { + let sectionButton = document.getElementById( + "category-" + location.hash.substring(1) + ); + if (sectionButton) { + sectionButton.click(); + } + } +} + +function show(button) { + let current_tab = document.querySelector(".active"); + let category = button.getAttribute("id").substring("category-".length); + let content = document.getElementById(category); + if (current_tab == content) { + return; + } + current_tab.classList.remove("active"); + current_tab.hidden = true; + content.classList.add("active"); + content.hidden = false; + + let current_button = document.querySelector("[selected=true]"); + current_button.removeAttribute("selected"); + button.setAttribute("selected", "true"); + + let autoRefresh = document.getElementById("autorefcheck"); + if (autoRefresh.checked) { + clearInterval(autoRefresh.interval); + setAutoRefreshInterval(autoRefresh); + } + + let title = document.getElementById("sectionTitle"); + title.textContent = button.children[0].textContent; + location.hash = category; +} + +function setAutoRefreshInterval(checkBox) { + let active_tab = document.querySelector(".active"); + checkBox.interval = setInterval(function () { + requestNetworkingDataForTab(active_tab.id); + }, REFRESH_INTERVAL_MS); +} + +// We use the pageshow event instead of onload. This is needed because sometimes +// the page is loaded via session-restore/bfcache. In such cases we need to call +// init() to keep the page behaviour consistent with the ticked checkboxes. +// Mostly the issue is with the autorefresh checkbox. +window.addEventListener("pageshow", function () { + init(); +}); + +function doLookup() { + let host = document.getElementById("host").value; + if (host) { + try { + gDashboard.requestDNSLookup(host, displayDNSLookup); + } catch (e) {} + try { + gDashboard.requestDNSHTTPSRRLookup(host, displayHTTPSRRLookup); + } catch (e) {} + } +} + +function displayDNSLookup(data) { + let cont = document.getElementById("dnslookuptool_content"); + let parent = cont.parentNode; + let new_cont = document.createElement("tbody"); + new_cont.setAttribute("id", "dnslookuptool_content"); + + if (data.answer) { + for (let address of data.address) { + let row = document.createElement("tr"); + row.appendChild(col(address)); + new_cont.appendChild(row); + } + } else { + new_cont.appendChild(col(data.error)); + } + + parent.replaceChild(new_cont, cont); +} + +function displayHTTPSRRLookup(data) { + let cont = document.getElementById("https_rr_content"); + let parent = cont.parentNode; + let new_cont = document.createElement("tbody"); + new_cont.setAttribute("id", "https_rr_content"); + + if (data.answer) { + for (let record of data.records) { + let row = document.createElement("tr"); + let alpn = record.alpn ? `alpn="${record.alpn.alpn}" ` : ""; + let noDefaultAlpn = record.noDefaultAlpn ? "noDefaultAlpn " : ""; + let port = record.port ? `port="${record.port.port}" ` : ""; + let echConfig = record.echConfig + ? `echConfig="${record.echConfig.echConfig}" ` + : ""; + let ODoHConfig = record.ODoHConfig + ? `odoh="${record.ODoHConfig.ODoHConfig}" ` + : ""; + let ipv4hint = ""; + let ipv6hint = ""; + if (record.ipv4Hint) { + let ipv4Str = ""; + for (let addr of record.ipv4Hint.address) { + ipv4Str += `${addr}, `; + } + // Remove ", " at the end. + ipv4Str = ipv4Str.slice(0, -2); + ipv4hint = `ipv4hint="${ipv4Str}" `; + } + if (record.ipv6Hint) { + let ipv6Str = ""; + for (let addr of record.ipv6Hint.address) { + ipv6Str += `${addr}, `; + } + // Remove ", " at the end. + ipv6Str = ipv6Str.slice(0, -2); + ipv6hint = `ipv6hint="${ipv6Str}" `; + } + + let str = `${record.priority} ${record.targetName} `; + str += `(${alpn}${noDefaultAlpn}${port}`; + str += `${ipv4hint}${echConfig}${ipv6hint}`; + str += `${ODoHConfig})`; + row.appendChild(col(str)); + new_cont.appendChild(row); + } + } else { + new_cont.appendChild(col(data.error)); + } + + parent.replaceChild(new_cont, cont); +} diff --git a/toolkit/content/aboutProfiles.js b/toolkit/content/aboutProfiles.js new file mode 100644 index 0000000000..15c0419a11 --- /dev/null +++ b/toolkit/content/aboutProfiles.js @@ -0,0 +1,397 @@ +/* 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"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "ProfileService", + "@mozilla.org/toolkit/profile-service;1", + "nsIToolkitProfileService" +); + +async function flush() { + try { + ProfileService.flush(); + rebuildProfileList(); + } catch (e) { + let [title, msg, button] = await document.l10n.formatValues([ + { id: "profiles-flush-fail-title" }, + { + id: + e.result == Cr.NS_ERROR_DATABASE_CHANGED + ? "profiles-flush-conflict" + : "profiles-flush-failed", + }, + { id: "profiles-flush-restart-button" }, + ]); + + const PS = Ci.nsIPromptService; + let result = Services.prompt.confirmEx( + window, + title, + msg, + PS.BUTTON_POS_0 * PS.BUTTON_TITLE_CANCEL + + PS.BUTTON_POS_1 * PS.BUTTON_TITLE_IS_STRING, + null, + button, + null, + null, + {} + ); + if (result == 1) { + restart(false); + } + } +} + +function rebuildProfileList() { + let parent = document.getElementById("profiles"); + while (parent.firstChild) { + parent.firstChild.remove(); + } + + let defaultProfile; + try { + defaultProfile = ProfileService.defaultProfile; + } catch (e) {} + + let currentProfile = ProfileService.currentProfile; + + for (let profile of ProfileService.profiles) { + let isCurrentProfile = profile == currentProfile; + let isInUse = isCurrentProfile; + if (!isInUse) { + try { + let lock = profile.lock({}); + lock.unlock(); + } catch (e) { + if ( + e.result != Cr.NS_ERROR_FILE_NOT_DIRECTORY && + e.result != Cr.NS_ERROR_FILE_NOT_FOUND + ) { + isInUse = true; + } + } + } + display({ + profile, + isDefault: profile == defaultProfile, + isCurrentProfile, + isInUse, + }); + } +} + +function display(profileData) { + let parent = document.getElementById("profiles"); + + let div = document.createElement("div"); + parent.appendChild(div); + + let name = document.createElement("h2"); + + div.appendChild(name); + document.l10n.setAttributes(name, "profiles-name", { + name: profileData.profile.name, + }); + + if (profileData.isCurrentProfile) { + let currentProfile = document.createElement("h3"); + document.l10n.setAttributes(currentProfile, "profiles-current-profile"); + div.appendChild(currentProfile); + } else if (profileData.isInUse) { + let currentProfile = document.createElement("h3"); + document.l10n.setAttributes(currentProfile, "profiles-in-use-profile"); + div.appendChild(currentProfile); + } + + let table = document.createElement("table"); + div.appendChild(table); + + let tbody = document.createElement("tbody"); + table.appendChild(tbody); + + function createItem(title, value, dir = false) { + let tr = document.createElement("tr"); + tbody.appendChild(tr); + + let th = document.createElement("th"); + th.setAttribute("class", "column"); + document.l10n.setAttributes(th, title); + tr.appendChild(th); + + let td = document.createElement("td"); + tr.appendChild(td); + + if (dir) { + td.appendChild(document.createTextNode(value.path)); + + if (value.exists()) { + let button = document.createElement("button"); + button.setAttribute("class", "opendir"); + document.l10n.setAttributes(button, "profiles-opendir"); + + td.appendChild(button); + + button.addEventListener("click", function (e) { + value.reveal(); + }); + } + } else { + document.l10n.setAttributes(td, value); + } + } + + createItem( + "profiles-is-default", + profileData.isDefault ? "profiles-yes" : "profiles-no" + ); + + createItem("profiles-rootdir", profileData.profile.rootDir, true); + + if (profileData.profile.localDir.path != profileData.profile.rootDir.path) { + createItem("profiles-localdir", profileData.profile.localDir, true); + } + + let renameButton = document.createElement("button"); + document.l10n.setAttributes(renameButton, "profiles-rename"); + renameButton.onclick = function () { + renameProfile(profileData.profile); + }; + div.appendChild(renameButton); + + if (!profileData.isInUse) { + let removeButton = document.createElement("button"); + document.l10n.setAttributes(removeButton, "profiles-remove"); + removeButton.onclick = function () { + removeProfile(profileData.profile); + }; + + div.appendChild(removeButton); + } + + if (!profileData.isDefault) { + let defaultButton = document.createElement("button"); + document.l10n.setAttributes(defaultButton, "profiles-set-as-default"); + defaultButton.onclick = function () { + defaultProfile(profileData.profile); + }; + div.appendChild(defaultButton); + } + + if (!profileData.isInUse) { + let runButton = document.createElement("button"); + document.l10n.setAttributes(runButton, "profiles-launch-profile"); + runButton.onclick = function () { + openProfile(profileData.profile); + }; + div.appendChild(runButton); + } + + let sep = document.createElement("hr"); + div.appendChild(sep); +} + +// This is called from the createProfileWizard.xhtml dialog. +function CreateProfile(profile) { + // The wizard created a profile, just make it the default. + defaultProfile(profile); +} + +function createProfileWizard() { + // This should be rewritten in HTML eventually. + window.browsingContext.topChromeWindow.openDialog( + "chrome://mozapps/content/profile/createProfileWizard.xhtml", + "", + "centerscreen,chrome,modal,titlebar", + ProfileService, + { CreateProfile } + ); +} + +async function renameProfile(profile) { + let newName = { value: profile.name }; + let [title, msg] = await document.l10n.formatValues([ + { id: "profiles-rename-profile-title" }, + { id: "profiles-rename-profile", args: { name: profile.name } }, + ]); + + if (Services.prompt.prompt(window, title, msg, newName, null, { value: 0 })) { + newName = newName.value; + + if (newName == profile.name) { + return; + } + + try { + profile.name = newName; + } catch (e) { + let [title, msg] = await document.l10n.formatValues([ + { id: "profiles-invalid-profile-name-title" }, + { id: "profiles-invalid-profile-name", args: { name: newName } }, + ]); + + Services.prompt.alert(window, title, msg); + return; + } + + flush(); + } +} + +async function removeProfile(profile) { + let deleteFiles = false; + + if (profile.rootDir.exists()) { + let [title, msg, dontDeleteStr, deleteStr] = + await document.l10n.formatValues([ + { id: "profiles-delete-profile-title" }, + { + id: "profiles-delete-profile-confirm", + args: { dir: profile.rootDir.path }, + }, + { id: "profiles-dont-delete-files" }, + { id: "profiles-delete-files" }, + ]); + let buttonPressed = Services.prompt.confirmEx( + window, + title, + msg, + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 + + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_2, + dontDeleteStr, + null, + deleteStr, + null, + { value: 0 } + ); + if (buttonPressed == 1) { + return; + } + + if (buttonPressed == 2) { + deleteFiles = true; + } + } + + // If we are deleting the default profile we must choose a different one. + let isDefault = false; + try { + isDefault = ProfileService.defaultProfile == profile; + } catch (e) {} + + if (isDefault) { + for (let p of ProfileService.profiles) { + if (profile == p) { + continue; + } + + if (isDefault) { + try { + ProfileService.defaultProfile = p; + } catch (e) { + // This can happen on dev-edition if a non-default profile is in use. + // In such a case the next time that dev-edition is started it will + // find no default profile and just create a new one. + } + } + + break; + } + } + + try { + profile.removeInBackground(deleteFiles); + } catch (e) { + let [title, msg] = await document.l10n.formatValues([ + { id: "profiles-delete-profile-failed-title" }, + { id: "profiles-delete-profile-failed-message" }, + ]); + + Services.prompt.alert(window, title, msg); + return; + } + + flush(); +} + +async function defaultProfile(profile) { + try { + ProfileService.defaultProfile = profile; + flush(); + } catch (e) { + // This can happen on dev-edition. + let [title, msg] = await document.l10n.formatValues([ + { id: "profiles-cannot-set-as-default-title" }, + { id: "profiles-cannot-set-as-default-message" }, + ]); + + Services.prompt.alert(window, title, msg); + } +} + +function openProfile(profile) { + Services.startup.createInstanceWithProfile(profile); +} + +function restart(safeMode) { + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + + if (cancelQuit.data) { + return; + } + + let flags = Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart; + + if (safeMode) { + Services.startup.restartInSafeMode(flags); + } else { + Services.startup.quit(flags); + } +} + +window.addEventListener( + "DOMContentLoaded", + function () { + let createButton = document.getElementById("create-button"); + createButton.addEventListener("click", createProfileWizard); + + let restartSafeModeButton = document.getElementById( + "restart-in-safe-mode-button" + ); + if (!Services.policies || Services.policies.isAllowed("safeMode")) { + restartSafeModeButton.addEventListener("click", () => { + restart(true); + }); + } else { + restartSafeModeButton.setAttribute("disabled", "true"); + } + + let restartNormalModeButton = document.getElementById("restart-button"); + restartNormalModeButton.addEventListener("click", () => { + restart(false); + }); + + if (ProfileService.isListOutdated) { + document.getElementById("owned").hidden = true; + } else { + document.getElementById("conflict").hidden = true; + rebuildProfileList(); + } + }, + { once: true } +); diff --git a/toolkit/content/aboutProfiles.xhtml b/toolkit/content/aboutProfiles.xhtml new file mode 100644 index 0000000000..3ce9e58062 --- /dev/null +++ b/toolkit/content/aboutProfiles.xhtml @@ -0,0 +1,64 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<!DOCTYPE html> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="profiles-title"></title> + <link + rel="icon" + type="image/png" + id="favicon" + href="chrome://branding/content/icon32.png" + /> + <link + rel="stylesheet" + href="chrome://mozapps/skin/aboutProfiles.css" + type="text/css" + /> + <script src="chrome://global/content/aboutProfiles.js" /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/about/aboutProfiles.ftl" /> + </head> + <body id="body" class="wide-container"> + <h1 data-l10n-id="profiles-title"></h1> + + <div id="conflict"> + <p data-l10n-id="profiles-conflict" /> + </div> + + <div class="header-flex"> + <div + class="page-subtitle content-flex" + data-l10n-id="profiles-subtitle" + ></div> + <div class="action-box"> + <h3 data-l10n-id="profiles-restart-title"></h3> + <button + id="restart-in-safe-mode-button" + data-l10n-id="profiles-restart-in-safe-mode" + ></button> + <button + id="restart-button" + data-l10n-id="profiles-restart-normal" + ></button> + </div> + </div> + + <div id="owned"> + <div> + <button id="create-button" data-l10n-id="profiles-create"></button> + </div> + + <div id="profiles" class="tab"></div> + </div> + </body> +</html> diff --git a/toolkit/content/aboutRights-unbranded.xhtml b/toolkit/content/aboutRights-unbranded.xhtml new file mode 100644 index 0000000000..240b1195fa --- /dev/null +++ b/toolkit/content/aboutRights-unbranded.xhtml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +%htmlDTD; ]> + +<!-- 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/. --> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="rights-title"></title> + <link + rel="stylesheet" + href="chrome://global/skin/in-content/info-pages.css" + type="text/css" + /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/about/aboutRights.ftl" /> + </head> + + <body id="your-rights" class="aboutPageWideContainer"> + <div class="container"> + <h1 data-l10n-id="rights-title"></h1> + + <p data-l10n-id="rights-intro"></p> + + <ul> + <li data-l10n-id="rights-intro-point-1"> + <a + href="http://www.mozilla.org/MPL/" + data-l10n-name="mozilla-public-license-link" + ></a> + </li> + <!-- Point 2 discusses Mozilla trademarks, and isn't needed when the build is unbranded. + - Point 4 discusses privacy policy, unbranded builds get a placeholder (for the vendor to replace) + - Point 5 discusses web service terms, unbranded builds gets a placeholder (for the vendor to replace) --> + <li data-l10n-id="rights-intro-point-4-unbranded"></li> + <li data-l10n-id="rights-intro-point-5-unbranded"> + <a + href="about:rights#webservices" + id="showWebServices" + data-l10n-name="mozilla-website-services-link" + ></a> + </li> + </ul> + + <div id="webservices-container"> + <a name="webservices" /> + <h3 data-l10n-id="rights-webservices-header"></h3> + + <p data-l10n-id="rights-webservices-unbranded"></p> + + <ol> + <!-- Terms only apply to official builds, unbranded builds get a placeholder. --> + <li data-l10n-id="rights-webservices-term-unbranded"></li> + </ol> + </div> + </div> + </body> + <script src="chrome://global/content/aboutRights.js" /> +</html> diff --git a/toolkit/content/aboutRights.js b/toolkit/content/aboutRights.js new file mode 100644 index 0000000000..1defd21978 --- /dev/null +++ b/toolkit/content/aboutRights.js @@ -0,0 +1,42 @@ +/* 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/. */ + +var servicesDiv = document.getElementById("webservices-container"); +servicesDiv.style.display = "none"; + +function showServices() { + servicesDiv.style.display = ""; +} + +// Fluent replaces the children of the element being overlayed which prevents us +// from putting an event handler directly on the children. +let rightsIntro = + document.querySelector("[data-l10n-id=rights-intro-point-5]") || + document.querySelector("[data-l10n-id=rights-intro-point-5-unbranded]"); +rightsIntro.addEventListener("click", event => { + if (event.target.id == "showWebServices") { + showServices(); + } +}); + +var disablingServicesDiv = document.getElementById( + "disabling-webservices-container" +); + +function showDisablingServices() { + disablingServicesDiv.style.display = ""; +} + +if (disablingServicesDiv != null) { + disablingServicesDiv.style.display = "none"; + // Same issue here with Fluent replacing the children affecting the event listeners. + let rightsWebServices = document.querySelector( + "[data-l10n-id=rights-webservices]" + ); + rightsWebServices.addEventListener("click", event => { + if (event.target.id == "showDisablingWebServices") { + showDisablingServices(); + } + }); +} diff --git a/toolkit/content/aboutRights.xhtml b/toolkit/content/aboutRights.xhtml new file mode 100644 index 0000000000..324b97f452 --- /dev/null +++ b/toolkit/content/aboutRights.xhtml @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +%htmlDTD; ]> + +<!-- 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/. --> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="rights-title"></title> + <link + rel="stylesheet" + href="chrome://global/skin/in-content/info-pages.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://global/skin/aboutRights.css" + type="text/css" + /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/about/aboutRights.ftl" /> + </head> + + <body id="your-rights"> + <div class="container"> + <div class="rights-header"> + <div> + <h1 data-l10n-id="rights-title"></h1> + + <p data-l10n-id="rights-intro"></p> + </div> + </div> + + <ul> + <li data-l10n-id="rights-intro-point-1"> + <a + href="http://www.mozilla.org/MPL/" + data-l10n-name="mozilla-public-license-link" + ></a> + </li> + <!-- Point 2 discusses Mozilla trademarks, and isn't needed when the build is unbranded. + - Point 4 discusses privacy policy, unbranded builds get a placeholder (for the vendor to replace) + - Point 5 discusses web service terms, unbranded builds gets a placeholder (for the vendor to replace) --> + <li data-l10n-id="rights-intro-point-2"> + <a + href="http://www.mozilla.org/foundation/trademarks/policy.html" + data-l10n-name="mozilla-trademarks-link" + ></a> + </li> + <li data-l10n-id="rights-intro-point-3"></li> + <li data-l10n-id="rights-intro-point-4"> + <a + href="https://www.mozilla.org/legal/privacy/firefox.html" + data-l10n-name="mozilla-privacy-policy-link" + ></a> + </li> + <li data-l10n-id="rights-intro-point-5"> + <a + href="about:rights#webservices" + id="showWebServices" + data-l10n-name="mozilla-service-terms-link" + ></a> + </li> + <li data-l10n-id="rights-intro-point-6"></li> + </ul> + + <div id="webservices-container"> + <a name="webservices" /> + <h3 data-l10n-id="rights-webservices-header"></h3> + + <p data-l10n-id="rights-webservices"> + <a + href="about:rights#disabling-webservices" + id="showDisablingWebServices" + data-l10n-name="mozilla-disable-service-link" + ></a> + </p> + + <div id="disabling-webservices-container" style="margin-left: 40px"> + <a name="disabling-webservices" /> + <p data-l10n-id="rights-safebrowsing"></p> + <ul> + <li data-l10n-id="rights-safebrowsing-term-1"></li> + <li data-l10n-id="rights-safebrowsing-term-2"></li> + <li data-l10n-id="rights-safebrowsing-term-3"></li> + <li data-l10n-id="rights-safebrowsing-term-4"></li> + </ul> + + <p data-l10n-id="rights-locationawarebrowsing"></p> + <ul> + <li data-l10n-id="rights-locationawarebrowsing-term-1"></li> + <li data-l10n-id="rights-locationawarebrowsing-term-2"></li> + <li data-l10n-id="rights-locationawarebrowsing-term-3"></li> + <li data-l10n-id="rights-locationawarebrowsing-term-4"></li> + </ul> + </div> + + <ol> + <!-- Terms only apply to official builds, unbranded builds get a placeholder. --> + <li data-l10n-id="rights-webservices-term-1"></li> + <li data-l10n-id="rights-webservices-term-2"></li> + <li data-l10n-id="rights-webservices-term-3"></li> + <li data-l10n-id="rights-webservices-term-4"></li> + <li data-l10n-id="rights-webservices-term-5"></li> + <li data-l10n-id="rights-webservices-term-6"></li> + <li data-l10n-id="rights-webservices-term-7"></li> + </ol> + </div> + </div> + </body> + <script src="chrome://global/content/aboutRights.js" /> +</html> diff --git a/toolkit/content/aboutServiceWorkers.js b/toolkit/content/aboutServiceWorkers.js new file mode 100644 index 0000000000..f57753239c --- /dev/null +++ b/toolkit/content/aboutServiceWorkers.js @@ -0,0 +1,173 @@ +/* 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"; + +var gSWM; +var gSWCount = 0; + +function init() { + let enabled = Services.prefs.getBoolPref("dom.serviceWorkers.enabled"); + if (!enabled) { + let div = document.getElementById("warning_not_enabled"); + div.classList.add("active"); + return; + } + + gSWM = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + if (!gSWM) { + dump( + "AboutServiceWorkers: Failed to get the ServiceWorkerManager service!\n" + ); + return; + } + + let data = gSWM.getAllRegistrations(); + if (!data) { + dump("AboutServiceWorkers: Failed to retrieve the registrations.\n"); + return; + } + + let length = data.length; + if (!length) { + let div = document.getElementById("warning_no_serviceworkers"); + div.classList.add("active"); + return; + } + + let ps = undefined; + try { + ps = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService); + } catch (e) { + dump("Could not acquire PushService\n"); + } + + for (let i = 0; i < length; ++i) { + let info = data.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (!info) { + dump( + "AboutServiceWorkers: Invalid nsIServiceWorkerRegistrationInfo interface.\n" + ); + continue; + } + + display(info, ps); + } +} + +async function display(info, pushService) { + let parent = document.getElementById("serviceworkers"); + + let div = document.createElement("div"); + parent.appendChild(div); + + let title = document.createElement("h2"); + document.l10n.setAttributes(title, "origin-title", { + originTitle: info.principal.origin, + }); + div.appendChild(title); + + let list = document.createElement("ul"); + div.appendChild(list); + + function createItem(l10nId, value, makeLink) { + let item = document.createElement("li"); + list.appendChild(item); + let bold = document.createElement("strong"); + bold.setAttribute("data-l10n-name", "item-label"); + item.appendChild(bold); + // Falsey values like "" are still valid values, so check exactly against + // undefined for the cases where the caller did not provide any value. + if (value === undefined) { + document.l10n.setAttributes(item, l10nId); + } else if (makeLink) { + let link = document.createElement("a"); + link.setAttribute("target", "_blank"); + link.setAttribute("data-l10n-name", "link"); + link.setAttribute("href", value); + item.appendChild(link); + document.l10n.setAttributes(item, l10nId, { url: value }); + } else { + document.l10n.setAttributes(item, l10nId, { name: value }); + } + return item; + } + + createItem("scope", info.scope); + createItem("script-spec", info.scriptSpec, true); + let currentWorkerURL = info.activeWorker ? info.activeWorker.scriptSpec : ""; + createItem("current-worker-url", currentWorkerURL, true); + let activeCacheName = info.activeWorker ? info.activeWorker.cacheName : ""; + createItem("active-cache-name", activeCacheName); + let waitingCacheName = info.waitingWorker ? info.waitingWorker.cacheName : ""; + createItem("waiting-cache-name", waitingCacheName); + + let pushItem = createItem("push-end-point-waiting"); + if (pushService) { + pushService.getSubscription( + info.scope, + info.principal, + (status, pushRecord) => { + if (Components.isSuccessCode(status)) { + document.l10n.setAttributes(pushItem, "push-end-point-result", { + name: JSON.stringify(pushRecord), + }); + } else { + dump("about:serviceworkers - retrieving push registration failed\n"); + } + } + ); + } + + let unregisterButton = document.createElement("button"); + document.l10n.setAttributes(unregisterButton, "unregister-button"); + div.appendChild(unregisterButton); + + let loadingMessage = document.createElement("span"); + document.l10n.setAttributes(loadingMessage, "waiting"); + loadingMessage.classList.add("inactive"); + div.appendChild(loadingMessage); + + unregisterButton.onclick = function () { + let cb = { + unregisterSucceeded() { + parent.removeChild(div); + + if (!--gSWCount) { + let div = document.getElementById("warning_no_serviceworkers"); + div.classList.add("active"); + } + }, + + async unregisterFailed() { + let [alertMsg] = await document.l10n.formatValues([ + { id: "unregister-error" }, + ]); + alert(alertMsg); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIServiceWorkerUnregisterCallback", + ]), + }; + + loadingMessage.classList.remove("inactive"); + gSWM.propagateUnregister(info.principal, cb, info.scope); + }; + + let sep = document.createElement("hr"); + div.appendChild(sep); + + ++gSWCount; +} + +window.addEventListener( + "DOMContentLoaded", + function () { + init(); + }, + { once: true } +); diff --git a/toolkit/content/aboutServiceWorkers.xhtml b/toolkit/content/aboutServiceWorkers.xhtml new file mode 100644 index 0000000000..ef64b9070b --- /dev/null +++ b/toolkit/content/aboutServiceWorkers.xhtml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +%htmlDTD; ]> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="about-service-workers-title"></title> + <link + rel="stylesheet" + href="chrome://global/skin/in-content/info-pages.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://mozapps/skin/aboutServiceWorkers.css" + type="text/css" + /> + <link rel="localization" href="toolkit/about/aboutServiceWorkers.ftl" /> + <link rel="localization" href="branding/brand.ftl" /> + <script src="chrome://global/content/aboutServiceWorkers.js" /> + </head> + <body class="wide-container"> + <div id="warning_not_enabled" class="warningBackground"> + <div + class="warningMessage" + data-l10n-id="about-service-workers-warning-not-enabled" + ></div> + </div> + <div id="warning_no_serviceworkers" class="warningBackground"> + <div + class="warningMessage" + data-l10n-id="about-service-workers-warning-no-service-workers" + ></div> + </div> + <div id="serviceworkers" class="tab active"> + <h1 data-l10n-id="about-service-workers-main-title"></h1> + </div> + </body> +</html> diff --git a/toolkit/content/aboutSupport.js b/toolkit/content/aboutSupport.js new file mode 100644 index 0000000000..f668fd671f --- /dev/null +++ b/toolkit/content/aboutSupport.js @@ -0,0 +1,2051 @@ +/* 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"; + +const { Troubleshoot } = ChromeUtils.importESModule( + "resource://gre/modules/Troubleshoot.sys.mjs" +); +const { ResetProfile } = ChromeUtils.importESModule( + "resource://gre/modules/ResetProfile.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs", + ProcessType: "resource://gre/modules/ProcessType.sys.mjs", +}); + +window.addEventListener("load", function onload(event) { + try { + window.removeEventListener("load", onload); + Troubleshoot.snapshot().then(async snapshot => { + for (let prop in snapshotFormatters) { + try { + await snapshotFormatters[prop](snapshot[prop]); + } catch (e) { + console.error( + "stack of snapshot error for about:support: ", + e, + ": ", + e.stack + ); + } + } + if (location.hash) { + scrollToSection(); + } + }, console.error); + populateActionBox(); + setupEventListeners(); + + if (Services.sysinfo.getProperty("isPackagedApp")) { + $("update-dir-row").hidden = true; + $("update-history-row").hidden = true; + } + } catch (e) { + console.error("stack of load error for about:support: ", e, ": ", e.stack); + } +}); + +function prefsTable(data) { + return sortedArrayFromObject(data).map(function ([name, value]) { + return $.new("tr", [ + $.new("td", name, "pref-name"), + // Very long preference values can cause users problems when they + // copy and paste them into some text editors. Long values generally + // aren't useful anyway, so truncate them to a reasonable length. + $.new("td", String(value).substr(0, 120), "pref-value"), + ]); + }); +} + +// Fluent uses lisp-case IDs so this converts +// the SentenceCase info IDs to lisp-case. +const FLUENT_IDENT_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +function toFluentID(str) { + if (!FLUENT_IDENT_REGEX.test(str)) { + return null; + } + return str + .toString() + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .toLowerCase(); +} + +// Each property in this object corresponds to a property in Troubleshoot.sys.mjs's +// snapshot data. Each function is passed its property's corresponding data, +// and it's the function's job to update the page with it. +var snapshotFormatters = { + async application(data) { + $("application-box").textContent = data.name; + $("useragent-box").textContent = data.userAgent; + $("os-box").textContent = data.osVersion; + if (data.osTheme) { + $("os-theme-box").textContent = data.osTheme; + } else { + $("os-theme-row").hidden = true; + } + if (AppConstants.platform == "macosx") { + $("rosetta-box").textContent = data.rosetta; + } + if (AppConstants.platform == "win") { + const translatedList = await Promise.all( + data.pointingDevices.map(deviceName => { + return document.l10n.formatValue(deviceName); + }) + ); + + const formatter = new Intl.ListFormat(); + + $("pointing-devices-box").textContent = formatter.format(translatedList); + } + $("binary-box").textContent = Services.dirsvc.get( + "XREExeF", + Ci.nsIFile + ).path; + $("supportLink").href = data.supportURL; + let version = AppConstants.MOZ_APP_VERSION_DISPLAY; + if (data.vendor) { + version += " (" + data.vendor + ")"; + } + $("version-box").textContent = version; + $("buildid-box").textContent = data.buildID; + $("distributionid-box").textContent = data.distributionID; + if (data.updateChannel) { + $("updatechannel-box").textContent = data.updateChannel; + } + if (AppConstants.MOZ_UPDATER && AppConstants.platform != "android") { + $("update-dir-box").textContent = Services.dirsvc.get( + "UpdRootD", + Ci.nsIFile + ).path; + } + $("profile-dir-box").textContent = Services.dirsvc.get( + "ProfD", + Ci.nsIFile + ).path; + + try { + let launcherStatusTextId = "launcher-process-status-unknown"; + switch (data.launcherProcessState) { + case 0: + case 1: + case 2: + launcherStatusTextId = + "launcher-process-status-" + data.launcherProcessState; + break; + } + + document.l10n.setAttributes( + $("launcher-process-box"), + launcherStatusTextId + ); + } catch (e) {} + + const STATUS_STRINGS = { + experimentControl: "fission-status-experiment-control", + experimentTreatment: "fission-status-experiment-treatment", + disabledByE10sEnv: "fission-status-disabled-by-e10s-env", + enabledByEnv: "fission-status-enabled-by-env", + disabledByEnv: "fission-status-disabled-by-env", + enabledByDefault: "fission-status-enabled-by-default", + disabledByDefault: "fission-status-disabled-by-default", + enabledByUserPref: "fission-status-enabled-by-user-pref", + disabledByUserPref: "fission-status-disabled-by-user-pref", + disabledByE10sOther: "fission-status-disabled-by-e10s-other", + enabledByRollout: "fission-status-enabled-by-rollout", + }; + + let statusTextId = STATUS_STRINGS[data.fissionDecisionStatus]; + + document.l10n.setAttributes( + $("multiprocess-box-process-count"), + "multi-process-windows", + { + remoteWindows: data.numRemoteWindows, + totalWindows: data.numTotalWindows, + } + ); + document.l10n.setAttributes( + $("fission-box-process-count"), + "fission-windows", + { + fissionWindows: data.numFissionWindows, + totalWindows: data.numTotalWindows, + } + ); + document.l10n.setAttributes($("fission-box-status"), statusTextId); + + if (Services.policies) { + let policiesStrId = ""; + let aboutPolicies = "about:policies"; + switch (data.policiesStatus) { + case Services.policies.INACTIVE: + policiesStrId = "policies-inactive"; + break; + + case Services.policies.ACTIVE: + policiesStrId = "policies-active"; + aboutPolicies += "#active"; + break; + + default: + policiesStrId = "policies-error"; + aboutPolicies += "#errors"; + break; + } + + if (data.policiesStatus != Services.policies.INACTIVE) { + let activePolicies = $.new("a", null, null, { + href: aboutPolicies, + }); + document.l10n.setAttributes(activePolicies, policiesStrId); + $("policies-status").appendChild(activePolicies); + } else { + document.l10n.setAttributes($("policies-status"), policiesStrId); + } + } else { + $("policies-status-row").hidden = true; + } + + let keyLocationServiceGoogleFound = data.keyLocationServiceGoogleFound + ? "found" + : "missing"; + document.l10n.setAttributes( + $("key-location-service-google-box"), + keyLocationServiceGoogleFound + ); + + let keySafebrowsingGoogleFound = data.keySafebrowsingGoogleFound + ? "found" + : "missing"; + document.l10n.setAttributes( + $("key-safebrowsing-google-box"), + keySafebrowsingGoogleFound + ); + + let keyMozillaFound = data.keyMozillaFound ? "found" : "missing"; + document.l10n.setAttributes($("key-mozilla-box"), keyMozillaFound); + + $("safemode-box").textContent = data.safeMode; + + const formatHumanReadableBytes = (elem, bytes) => { + let size = DownloadUtils.convertByteUnits(bytes); + document.l10n.setAttributes(elem, "app-basics-data-size", { + value: size[0], + unit: size[1], + }); + }; + + formatHumanReadableBytes($("memory-size-box"), data.memorySizeBytes); + formatHumanReadableBytes($("disk-available-box"), data.diskAvailableBytes); + }, + + async legacyUserStylesheets(legacyUserStylesheets) { + $("legacyUserStylesheets-enabled").textContent = + legacyUserStylesheets.active; + $("legacyUserStylesheets-types").textContent = + new Intl.ListFormat(undefined, { style: "short", type: "unit" }).format( + legacyUserStylesheets.types + ) || + document.l10n.setAttributes( + $("legacyUserStylesheets-types"), + "legacy-user-stylesheets-no-stylesheets-found" + ); + }, + + crashes(data) { + if (!AppConstants.MOZ_CRASHREPORTER) { + return; + } + + let daysRange = Troubleshoot.kMaxCrashAge / (24 * 60 * 60 * 1000); + document.l10n.setAttributes($("crashes"), "report-crash-for-days", { + days: daysRange, + }); + let reportURL; + try { + reportURL = Services.prefs.getCharPref("breakpad.reportURL"); + // Ignore any non http/https urls + if (!/^https?:/i.test(reportURL)) { + reportURL = null; + } + } catch (e) {} + if (!reportURL) { + $("crashes-noConfig").style.display = "block"; + $("crashes-noConfig").classList.remove("no-copy"); + return; + } + $("crashes-allReports").style.display = "block"; + + if (data.pending > 0) { + document.l10n.setAttributes( + $("crashes-allReportsWithPending"), + "pending-reports", + { reports: data.pending } + ); + } + + let dateNow = new Date(); + $.append( + $("crashes-tbody"), + data.submitted.map(function (crash) { + let date = new Date(crash.date); + let timePassed = dateNow - date; + let formattedDateStrId; + let formattedDateStrArgs; + if (timePassed >= 24 * 60 * 60 * 1000) { + let daysPassed = Math.round(timePassed / (24 * 60 * 60 * 1000)); + formattedDateStrId = "crashes-time-days"; + formattedDateStrArgs = { days: daysPassed }; + } else if (timePassed >= 60 * 60 * 1000) { + let hoursPassed = Math.round(timePassed / (60 * 60 * 1000)); + formattedDateStrId = "crashes-time-hours"; + formattedDateStrArgs = { hours: hoursPassed }; + } else { + let minutesPassed = Math.max(Math.round(timePassed / (60 * 1000)), 1); + formattedDateStrId = "crashes-time-minutes"; + formattedDateStrArgs = { minutes: minutesPassed }; + } + return $.new("tr", [ + $.new("td", [ + $.new("a", crash.id, null, { href: reportURL + crash.id }), + ]), + $.new("td", null, null, { + "data-l10n-id": formattedDateStrId, + "data-l10n-args": formattedDateStrArgs, + }), + ]); + }) + ); + }, + + addons(data) { + $.append( + $("addons-tbody"), + data.map(function (addon) { + return $.new("tr", [ + $.new("td", addon.name), + $.new("td", addon.type), + $.new("td", addon.version), + $.new("td", addon.isActive), + $.new("td", addon.id), + ]); + }) + ); + }, + + securitySoftware(data) { + if (AppConstants.platform !== "win") { + $("security-software").hidden = true; + $("security-software-table").hidden = true; + return; + } + + $("security-software-antivirus").textContent = data.registeredAntiVirus; + $("security-software-antispyware").textContent = data.registeredAntiSpyware; + $("security-software-firewall").textContent = data.registeredFirewall; + }, + + features(data) { + $.append( + $("features-tbody"), + data.map(function (feature) { + return $.new("tr", [ + $.new("td", feature.name), + $.new("td", feature.version), + $.new("td", feature.id), + ]); + }) + ); + }, + + async processes(data) { + async function buildEntry(name, value) { + const fluentName = ProcessType.fluentNameFromProcessTypeString(name); + let entryName = (await document.l10n.formatValue(fluentName)) || name; + $("processes-tbody").appendChild( + $.new("tr", [$.new("td", entryName), $.new("td", value)]) + ); + } + + let remoteProcessesCount = Object.values(data.remoteTypes).reduce( + (a, b) => a + b, + 0 + ); + document.querySelector("#remoteprocesses-row a").textContent = + remoteProcessesCount; + + // Display the regular "web" process type first in the list, + // and with special formatting. + if (data.remoteTypes.web) { + await buildEntry( + "web", + `${data.remoteTypes.web} / ${data.maxWebContentProcesses}` + ); + delete data.remoteTypes.web; + } + + for (let remoteProcessType in data.remoteTypes) { + await buildEntry(remoteProcessType, data.remoteTypes[remoteProcessType]); + } + }, + + async experimentalFeatures(data) { + if (!data) { + return; + } + let titleL10nIds = data.map(([titleL10nId]) => titleL10nId); + let titleL10nObjects = await document.l10n.formatMessages(titleL10nIds); + if (titleL10nObjects.length != data.length) { + throw Error("Missing localized title strings in experimental features"); + } + for (let i = 0; i < titleL10nObjects.length; i++) { + let localizedTitle = titleL10nObjects[i].attributes.find( + a => a.name == "label" + ).value; + data[i] = [localizedTitle, data[i][1], data[i][2]]; + } + + $.append( + $("experimental-features-tbody"), + data.map(function ([title, pref, value]) { + return $.new("tr", [ + $.new("td", `${title} (${pref})`, "pref-name"), + $.new("td", value, "pref-value"), + ]); + }) + ); + }, + + environmentVariables(data) { + if (!data) { + return; + } + $.append( + $("environment-variables-tbody"), + Object.entries(data).map(([name, value]) => { + return $.new("tr", [ + $.new("td", name, "pref-name"), + $.new("td", value, "pref-value"), + ]); + }) + ); + }, + + modifiedPreferences(data) { + $.append($("prefs-tbody"), prefsTable(data)); + }, + + lockedPreferences(data) { + $.append($("locked-prefs-tbody"), prefsTable(data)); + }, + + places(data) { + if (!AppConstants.MOZ_PLACES) { + return; + } + const statsBody = $("place-database-stats-tbody"); + $.append( + statsBody, + data.map(function (entry) { + return $.new("tr", [ + $.new("td", entry.entity), + $.new("td", entry.count), + $.new("td", entry.sizeBytes / 1024), + $.new("td", entry.sizePerc), + $.new("td", entry.efficiencyPerc), + $.new("td", entry.sequentialityPerc), + ]); + }) + ); + statsBody.style.display = "none"; + $("place-database-stats-toggle").addEventListener( + "click", + function (event) { + if (statsBody.style.display === "none") { + document.l10n.setAttributes( + event.target, + "place-database-stats-hide" + ); + statsBody.style.display = ""; + } else { + document.l10n.setAttributes( + event.target, + "place-database-stats-show" + ); + statsBody.style.display = "none"; + } + } + ); + }, + + printingPreferences(data) { + if (AppConstants.platform == "android") { + return; + } + const tbody = $("support-printing-prefs-tbody"); + $.append(tbody, prefsTable(data)); + $("support-printing-clear-settings-button").addEventListener( + "click", + function () { + for (let name in data) { + Services.prefs.clearUserPref(name); + } + tbody.textContent = ""; + } + ); + }, + + async graphics(data) { + function localizedMsg(msg) { + if (typeof msg == "object" && msg.key) { + return document.l10n.formatValue(msg.key, msg.args); + } + let msgId = toFluentID(msg); + if (msgId) { + return document.l10n.formatValue(msgId); + } + return ""; + } + + // Read APZ info out of data.info, stripping it out in the process. + let apzInfo = []; + let formatApzInfo = function (info) { + let out = []; + for (let type of [ + "Wheel", + "Touch", + "Drag", + "Keyboard", + "Autoscroll", + "Zooming", + ]) { + let key = "Apz" + type + "Input"; + + if (!(key in info)) { + continue; + } + + delete info[key]; + + out.push(toFluentID(type.toLowerCase() + "Enabled")); + } + + return out; + }; + + // Create a <tr> element with key and value columns. + // + // @key Text in the key column. Localized automatically, unless starts with "#". + // @value Fluent ID for text in the value column, or array of children. + function buildRow(key, value) { + let title = key[0] == "#" ? key.substr(1) : key; + let keyStrId = toFluentID(key); + let valueStrId = Array.isArray(value) ? null : toFluentID(value); + let td = $.new("td", value); + td.style["white-space"] = "pre-wrap"; + if (valueStrId) { + document.l10n.setAttributes(td, valueStrId); + } + + let th = $.new("th", title, "column"); + if (!key.startsWith("#")) { + document.l10n.setAttributes(th, keyStrId); + } + return $.new("tr", [th, td]); + } + + // @where The name in "graphics-<name>-tbody", of the element to append to. + // @trs Array of row elements. + function addRows(where, trs) { + $.append($("graphics-" + where + "-tbody"), trs); + } + + // Build and append a row. + // + // @where The name in "graphics-<name>-tbody", of the element to append to. + function addRow(where, key, value) { + addRows(where, [buildRow(key, value)]); + } + if ("info" in data) { + apzInfo = formatApzInfo(data.info); + + let trs = sortedArrayFromObject(data.info).map(function ([prop, val]) { + let td = $.new("td", String(val)); + td.style["word-break"] = "break-all"; + return $.new("tr", [$.new("th", prop, "column"), td]); + }); + addRows("diagnostics", trs); + + delete data.info; + } + + let windowUtils = window.windowUtils; + let gpuProcessPid = windowUtils.gpuProcessPid; + + if (gpuProcessPid != -1) { + let gpuProcessKillButton = null; + if (AppConstants.NIGHTLY_BUILD || AppConstants.MOZ_DEV_EDITION) { + gpuProcessKillButton = $.new("button"); + + gpuProcessKillButton.addEventListener("click", function () { + windowUtils.terminateGPUProcess(); + }); + + document.l10n.setAttributes( + gpuProcessKillButton, + "gpu-process-kill-button" + ); + } + + addRow("diagnostics", "gpu-process-pid", [new Text(gpuProcessPid)]); + if (gpuProcessKillButton) { + addRow("diagnostics", "gpu-process", [gpuProcessKillButton]); + } + } + + if ( + (AppConstants.NIGHTLY_BUILD || AppConstants.MOZ_DEV_EDITION) && + AppConstants.platform != "macosx" + ) { + let gpuDeviceResetButton = $.new("button"); + + gpuDeviceResetButton.addEventListener("click", function () { + windowUtils.triggerDeviceReset(); + }); + + document.l10n.setAttributes( + gpuDeviceResetButton, + "gpu-device-reset-button" + ); + addRow("diagnostics", "gpu-device-reset", [gpuDeviceResetButton]); + } + + // graphics-failures-tbody tbody + if ("failures" in data) { + // If indices is there, it should be the same length as failures, + // (see Troubleshoot.sys.mjs) but we check anyway: + if ("indices" in data && data.failures.length == data.indices.length) { + let combined = []; + for (let i = 0; i < data.failures.length; i++) { + let assembled = assembleFromGraphicsFailure(i, data); + combined.push(assembled); + } + combined.sort(function (a, b) { + if (a.index < b.index) { + return -1; + } + if (a.index > b.index) { + return 1; + } + return 0; + }); + $.append( + $("graphics-failures-tbody"), + combined.map(function (val) { + return $.new("tr", [ + $.new("th", val.header, "column"), + $.new("td", val.message), + ]); + }) + ); + delete data.indices; + } else { + $.append($("graphics-failures-tbody"), [ + $.new("tr", [ + $.new("th", "LogFailure", "column"), + $.new( + "td", + data.failures.map(function (val) { + return $.new("p", val); + }) + ), + ]), + ]); + } + delete data.failures; + } else { + $("graphics-failures-tbody").style.display = "none"; + } + + // Add a new row to the table, and take the key (or keys) out of data. + // + // @where Table section to add to. + // @key Data key to use. + // @colKey The localization key to use, if different from key. + async function addRowFromKey(where, key, colKey) { + if (!(key in data)) { + return; + } + colKey = colKey || key; + + let value; + let messageKey = key + "Message"; + if (messageKey in data) { + value = await localizedMsg(data[messageKey]); + delete data[messageKey]; + } else { + value = data[key]; + } + delete data[key]; + + if (value) { + addRow(where, colKey, [new Text(value)]); + } + } + + // graphics-features-tbody + let devicePixelRatios = data.graphicsDevicePixelRatios; + addRow("features", "graphicsDevicePixelRatios", [ + new Text(devicePixelRatios), + ]); + + let compositor = ""; + if (data.windowLayerManagerRemote) { + compositor = data.windowLayerManagerType; + } else { + let noOMTCString = await document.l10n.formatValue("main-thread-no-omtc"); + compositor = "BasicLayers (" + noOMTCString + ")"; + } + addRow("features", "compositing", [new Text(compositor)]); + addRow("features", "supportFontDetermination", [ + new Text(data.supportFontDetermination), + ]); + delete data.windowLayerManagerRemote; + delete data.windowLayerManagerType; + delete data.numTotalWindows; + delete data.numAcceleratedWindows; + delete data.numAcceleratedWindowsMessage; + delete data.graphicsDevicePixelRatios; + + addRow( + "features", + "asyncPanZoom", + apzInfo.length + ? [ + new Text( + ( + await document.l10n.formatValues( + apzInfo.map(id => { + return { id }; + }) + ) + ).join("; ") + ), + ] + : "apz-none" + ); + let featureKeys = [ + "webgl1WSIInfo", + "webgl1Renderer", + "webgl1Version", + "webgl1DriverExtensions", + "webgl1Extensions", + "webgl2WSIInfo", + "webgl2Renderer", + "webgl2Version", + "webgl2DriverExtensions", + "webgl2Extensions", + ["supportsHardwareH264", "hardware-h264"], + ["direct2DEnabled", "#Direct2D"], + ["windowProtocol", "graphics-window-protocol"], + ["desktopEnvironment", "graphics-desktop-environment"], + "targetFrameRate", + ]; + for (let feature of featureKeys) { + if (Array.isArray(feature)) { + await addRowFromKey("features", feature[0], feature[1]); + continue; + } + await addRowFromKey("features", feature); + } + + featureKeys = ["webgpuDefaultAdapter", "webgpuFallbackAdapter"]; + for (let feature of featureKeys) { + const obj = data[feature]; + if (obj) { + const str = JSON.stringify(obj, null, " "); + await addRow("features", feature, [new Text(str)]); + delete data[feature]; + } + } + + if ("directWriteEnabled" in data) { + let message = data.directWriteEnabled; + if ("directWriteVersion" in data) { + message += " (" + data.directWriteVersion + ")"; + } + await addRow("features", "#DirectWrite", [new Text(message)]); + delete data.directWriteEnabled; + delete data.directWriteVersion; + } + + // Adapter tbodies. + let adapterKeys = [ + ["adapterDescription", "gpu-description"], + ["adapterVendorID", "gpu-vendor-id"], + ["adapterDeviceID", "gpu-device-id"], + ["driverVendor", "gpu-driver-vendor"], + ["driverVersion", "gpu-driver-version"], + ["driverDate", "gpu-driver-date"], + ["adapterDrivers", "gpu-drivers"], + ["adapterSubsysID", "gpu-subsys-id"], + ["adapterRAM", "gpu-ram"], + ]; + + function showGpu(id, suffix) { + function get(prop) { + return data[prop + suffix]; + } + + let trs = []; + for (let [prop, key] of adapterKeys) { + let value = get(prop); + if (value === undefined || value === "") { + continue; + } + trs.push(buildRow(key, [new Text(value)])); + } + + if (!trs.length) { + $("graphics-" + id + "-tbody").style.display = "none"; + return; + } + + let active = "yes"; + if ("isGPU2Active" in data && (suffix == "2") != data.isGPU2Active) { + active = "no"; + } + + addRow(id, "gpu-active", active); + addRows(id, trs); + } + showGpu("gpu-1", ""); + showGpu("gpu-2", "2"); + + // Remove adapter keys. + for (let [prop /* key */] of adapterKeys) { + delete data[prop]; + delete data[prop + "2"]; + } + delete data.isGPU2Active; + + let featureLog = data.featureLog; + delete data.featureLog; + + if (featureLog.features.length) { + for (let feature of featureLog.features) { + let trs = []; + for (let entry of feature.log) { + let bugNumber; + if (entry.hasOwnProperty("failureId")) { + // This is a failure ID. See nsIGfxInfo.idl. + let m = /BUG_(\d+)/.exec(entry.failureId); + if (m) { + bugNumber = m[1]; + } + } + + let failureIdSpan = $.new("span", ""); + if (bugNumber) { + let bugHref = $.new("a"); + bugHref.href = + "https://bugzilla.mozilla.org/show_bug.cgi?id=" + bugNumber; + bugHref.setAttribute("data-l10n-name", "bug-link"); + failureIdSpan.append(bugHref); + document.l10n.setAttributes( + failureIdSpan, + "support-blocklisted-bug", + { + bugNumber, + } + ); + } else if ( + entry.hasOwnProperty("failureId") && + entry.failureId.length + ) { + document.l10n.setAttributes(failureIdSpan, "unknown-failure", { + failureCode: entry.failureId, + }); + } + + let messageSpan = $.new("span", ""); + if (entry.hasOwnProperty("message") && entry.message.length) { + messageSpan.innerText = entry.message; + } + + let typeCol = $.new("td", entry.type); + let statusCol = $.new("td", entry.status); + let messageCol = $.new("td", ""); + let failureIdCol = $.new("td", ""); + typeCol.style.width = "10%"; + statusCol.style.width = "10%"; + messageCol.style.width = "30%"; + messageCol.appendChild(messageSpan); + failureIdCol.style.width = "50%"; + failureIdCol.appendChild(failureIdSpan); + + trs.push($.new("tr", [typeCol, statusCol, messageCol, failureIdCol])); + } + addRow("decisions", "#" + feature.name, [$.new("table", trs)]); + } + } else { + $("graphics-decisions-tbody").style.display = "none"; + } + + if (featureLog.fallbacks.length) { + for (let fallback of featureLog.fallbacks) { + addRow("workarounds", "#" + fallback.name, [ + new Text(fallback.message), + ]); + } + } else { + $("graphics-workarounds-tbody").style.display = "none"; + } + + let crashGuards = data.crashGuards; + delete data.crashGuards; + + if (crashGuards.length) { + for (let guard of crashGuards) { + let resetButton = $.new("button"); + let onClickReset = function () { + Services.prefs.setIntPref(guard.prefName, 0); + resetButton.removeEventListener("click", onClickReset); + resetButton.disabled = true; + }; + + document.l10n.setAttributes(resetButton, "reset-on-next-restart"); + resetButton.addEventListener("click", onClickReset); + + addRow("crashguards", guard.type + "CrashGuard", [resetButton]); + } + } else { + $("graphics-crashguards-tbody").style.display = "none"; + } + + // Now that we're done, grab any remaining keys in data and drop them into + // the diagnostics section. + for (let key in data) { + let value = data[key]; + addRow("diagnostics", key, [new Text(value)]); + } + }, + + async media(data) { + function insertBasicInfo(key, value) { + function createRow(key, value) { + let th = $.new("th", null, "column"); + document.l10n.setAttributes(th, key); + let td = $.new("td", value); + td.style["white-space"] = "pre-wrap"; + td.colSpan = 8; + return $.new("tr", [th, td]); + } + $.append($("media-info-tbody"), [createRow(key, value)]); + } + + function createDeviceInfoRow(device) { + let deviceInfo = Ci.nsIAudioDeviceInfo; + + let states = {}; + states[deviceInfo.STATE_DISABLED] = "Disabled"; + states[deviceInfo.STATE_UNPLUGGED] = "Unplugged"; + states[deviceInfo.STATE_ENABLED] = "Enabled"; + + let preferreds = {}; + preferreds[deviceInfo.PREF_NONE] = "None"; + preferreds[deviceInfo.PREF_MULTIMEDIA] = "Multimedia"; + preferreds[deviceInfo.PREF_VOICE] = "Voice"; + preferreds[deviceInfo.PREF_NOTIFICATION] = "Notification"; + preferreds[deviceInfo.PREF_ALL] = "All"; + + let formats = {}; + formats[deviceInfo.FMT_S16LE] = "S16LE"; + formats[deviceInfo.FMT_S16BE] = "S16BE"; + formats[deviceInfo.FMT_F32LE] = "F32LE"; + formats[deviceInfo.FMT_F32BE] = "F32BE"; + + function toPreferredString(preferred) { + if (preferred == deviceInfo.PREF_NONE) { + return preferreds[deviceInfo.PREF_NONE]; + } else if (preferred & deviceInfo.PREF_ALL) { + return preferreds[deviceInfo.PREF_ALL]; + } + let str = ""; + for (let pref of [ + deviceInfo.PREF_MULTIMEDIA, + deviceInfo.PREF_VOICE, + deviceInfo.PREF_NOTIFICATION, + ]) { + if (preferred & pref) { + str += " " + preferreds[pref]; + } + } + return str; + } + + function toFromatString(dev) { + let str = "default: " + formats[dev.defaultFormat] + ", support:"; + for (let fmt of [ + deviceInfo.FMT_S16LE, + deviceInfo.FMT_S16BE, + deviceInfo.FMT_F32LE, + deviceInfo.FMT_F32BE, + ]) { + if (dev.supportedFormat & fmt) { + str += " " + formats[fmt]; + } + } + return str; + } + + function toRateString(dev) { + return ( + "default: " + + dev.defaultRate + + ", support: " + + dev.minRate + + " - " + + dev.maxRate + ); + } + + function toLatencyString(dev) { + return dev.minLatency + " - " + dev.maxLatency; + } + + return $.new("tr", [ + $.new("td", device.name), + $.new("td", device.groupId), + $.new("td", device.vendor), + $.new("td", states[device.state]), + $.new("td", toPreferredString(device.preferred)), + $.new("td", toFromatString(device)), + $.new("td", device.maxChannels), + $.new("td", toRateString(device)), + $.new("td", toLatencyString(device)), + ]); + } + + function insertDeviceInfo(side, devices) { + let rows = []; + for (let dev of devices) { + rows.push(createDeviceInfoRow(dev)); + } + $.append($("media-" + side + "-devices-tbody"), rows); + } + + function insertEnumerateDatabase() { + if ( + !Services.prefs.getBoolPref("media.mediacapabilities.from-database") + ) { + $("media-capabilities-tbody").style.display = "none"; + return; + } + let button = $("enumerate-database-button"); + if (button) { + button.addEventListener("click", function (event) { + let { KeyValueService } = ChromeUtils.importESModule( + "resource://gre/modules/kvstore.sys.mjs" + ); + let currProfDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + currProfDir.append("mediacapabilities"); + let path = currProfDir.path; + + function enumerateDatabase(name) { + KeyValueService.getOrCreate(path, name) + .then(database => { + return database.enumerate(); + }) + .then(enumerator => { + var logs = []; + logs.push(`${name}:`); + while (enumerator.hasMoreElements()) { + const { key, value } = enumerator.getNext(); + logs.push(`${key}: ${value}`); + } + $("enumerate-database-result").textContent += + logs.join("\n") + "\n"; + }) + .catch(err => { + $("enumerate-database-result").textContent += `${name}:\n`; + }); + } + + $("enumerate-database-result").style.display = "block"; + $("enumerate-database-result").classList.remove("no-copy"); + $("enumerate-database-result").textContent = ""; + + enumerateDatabase("video/av1"); + enumerateDatabase("video/vp8"); + enumerateDatabase("video/vp9"); + enumerateDatabase("video/avc"); + enumerateDatabase("video/theora"); + }); + } + } + + function roundtripAudioLatency() { + insertBasicInfo("roundtrip-latency", "..."); + window.windowUtils + .defaultDevicesRoundTripLatency() + .then(latency => { + var latencyString = `${(latency[0] * 1000).toFixed(2)}ms (${( + latency[1] * 1000 + ).toFixed(2)})`; + data.defaultDevicesRoundTripLatency = latencyString; + document.querySelector( + 'th[data-l10n-id="roundtrip-latency"]' + ).nextSibling.textContent = latencyString; + }) + .catch(e => {}); + } + + function createCDMInfoRow(cdmInfo) { + function findElementInArray(array, name) { + const rv = array.find(element => element.includes(name)); + return rv ? rv.split("=")[1] : "Unknown"; + } + + function getAudioRobustness(array) { + return findElementInArray(array, "audio-robustness"); + } + + function getVideoRobustness(array) { + return findElementInArray(array, "video-robustness"); + } + + function getSupportedCodecs(array) { + const mp4Content = findElementInArray(array, "MP4"); + const webContent = findElementInArray(array, "WEBM"); + + const mp4DecodingAndDecryptingCodecs = mp4Content + .match(/decoding-and-decrypting:\[([^\]]*)\]/)[1] + .split(","); + const webmDecodingAndDecryptingCodecs = webContent + .match(/decoding-and-decrypting:\[([^\]]*)\]/)[1] + .split(","); + + const mp4DecryptingOnlyCodecs = mp4Content + .match(/decrypting-only:\[([^\]]*)\]/)[1] + .split(","); + const webmDecryptingOnlyCodecs = webContent + .match(/decrypting-only:\[([^\]]*)\]/)[1] + .split(","); + + // Combine and get unique codecs for decoding-and-decrypting (always) + // and decrypting-only (only set when it's not empty) + let rv = {}; + rv.decodingAndDecrypting = [ + ...new Set( + [ + ...mp4DecodingAndDecryptingCodecs, + ...webmDecodingAndDecryptingCodecs, + ].filter(Boolean) + ), + ]; + let temp = [ + ...new Set( + [...mp4DecryptingOnlyCodecs, ...webmDecryptingOnlyCodecs].filter( + Boolean + ) + ), + ]; + if (temp.length) { + rv.decryptingOnly = temp; + } + return rv; + } + + function getCapabilities(array) { + let capabilities = {}; + capabilities.persistent = findElementInArray(array, "persistent"); + capabilities.distinctive = findElementInArray(array, "distinctive"); + capabilities.sessionType = findElementInArray(array, "sessionType"); + capabilities.scheme = findElementInArray(array, "scheme"); + capabilities.codec = getSupportedCodecs(array); + return JSON.stringify(capabilities); + } + + const rvArray = cdmInfo.capabilities.split(" "); + return $.new("tr", [ + $.new("td", cdmInfo.keySystemName), + $.new("td", getVideoRobustness(rvArray)), + $.new("td", getAudioRobustness(rvArray)), + $.new("td", getCapabilities(rvArray)), + $.new("td", cdmInfo.clearlead ? "Yes" : "No"), + $.new("td", cdmInfo.isHDCP22Compatible ? "Yes" : "No"), + ]); + } + + async function insertContentDecryptionModuleInfo() { + let rows = []; + // Retrieve information from GMPCDM + let cdmInfo = + await ChromeUtils.getGMPContentDecryptionModuleInformation(); + for (let info of cdmInfo) { + rows.push(createCDMInfoRow(info)); + } + // Retrieve information from WMFCDM, only works when MOZ_WMF_CDM is true + if (ChromeUtils.getWMFContentDecryptionModuleInformation !== undefined) { + cdmInfo = await ChromeUtils.getWMFContentDecryptionModuleInformation(); + for (let info of cdmInfo) { + rows.push(createCDMInfoRow(info)); + } + } + $.append($("media-content-decryption-modules-tbody"), rows); + } + + // Basic information + insertBasicInfo("audio-backend", data.currentAudioBackend); + insertBasicInfo("max-audio-channels", data.currentMaxAudioChannels); + insertBasicInfo("sample-rate", data.currentPreferredSampleRate); + + if (AppConstants.platform == "macosx") { + var micStatus = {}; + let permission = Cc["@mozilla.org/ospermissionrequest;1"].getService( + Ci.nsIOSPermissionRequest + ); + permission.getAudioCapturePermissionState(micStatus); + if (micStatus.value == permission.PERMISSION_STATE_AUTHORIZED) { + roundtripAudioLatency(); + } + } else { + roundtripAudioLatency(); + } + + // Output devices information + insertDeviceInfo("output", data.audioOutputDevices); + + // Input devices information + insertDeviceInfo("input", data.audioInputDevices); + + // Media Capabilitites + insertEnumerateDatabase(); + + // Create codec support matrix if possible + let supportInfo = null; + if (data.codecSupportInfo.length) { + const [ + supportText, + unsupportedText, + codecNameHeaderText, + codecSWDecodeText, + codecHWDecodeText, + lackOfExtensionText, + ] = await document.l10n.formatValues([ + "media-codec-support-supported", + "media-codec-support-unsupported", + "media-codec-support-codec-name", + "media-codec-support-sw-decoding", + "media-codec-support-hw-decoding", + "media-codec-support-lack-of-extension", + ]); + + function formatCodecRowHeader(a, b, c) { + let h1 = $.new("th", a); + let h2 = $.new("th", b); + let h3 = $.new("th", c); + h1.classList.add("codec-table-name"); + h2.classList.add("codec-table-sw"); + h3.classList.add("codec-table-hw"); + return $.new("tr", [h1, h2, h3]); + } + + function formatCodecRow(codec, sw, hw) { + let swCell = $.new("td", sw ? supportText : unsupportedText); + let hwCell = $.new("td", hw ? supportText : unsupportedText); + if (sw) { + swCell.classList.add("supported"); + } else { + swCell.classList.add("unsupported"); + } + if (hw) { + hwCell.classList.add("supported"); + } else { + hwCell.classList.add("unsupported"); + } + return $.new("tr", [$.new("td", codec), swCell, hwCell]); + } + + function formatCodecRowForLackOfExtension(codec, sw) { + let swCell = $.new("td", sw ? supportText : unsupportedText); + // Link to AV1 extension on MS store. + let hwCell = $.new("td", [ + $.new("a", lackOfExtensionText, null, { + href: "ms-windows-store://pdp/?ProductId=9MVZQVXJBQ9V", + }), + ]); + if (sw) { + swCell.classList.add("supported"); + } else { + swCell.classList.add("unsupported"); + } + hwCell.classList.add("lack-of-extension"); + return $.new("tr", [$.new("td", codec), swCell, hwCell]); + } + + // Parse codec support string and create dictionary containing + // SW/HW support information for each codec found + let codecs = {}; + for (const codec_string of data.codecSupportInfo.split("\n")) { + const s = codec_string.split(" "); + const codec_name = s[0]; + const codec_support = s.slice(1); + + if (!(codec_name in codecs)) { + codecs[codec_name] = { + name: codec_name, + sw: false, + hw: false, + lackOfExtension: false, + }; + } + + if (codec_support.includes("SW")) { + codecs[codec_name].sw = true; + } + if (codec_support.includes("HW")) { + codecs[codec_name].hw = true; + } + if (codec_support.includes("LACK_OF_EXTENSION")) { + codecs[codec_name].lackOfExtension = true; + } + } + + // Create row in support table for each codec + let codecSupportRows = []; + for (const c in codecs) { + if (!codecs.hasOwnProperty(c)) { + continue; + } + if (codecs[c].lackOfExtension) { + codecSupportRows.push( + formatCodecRowForLackOfExtension(codecs[c].name, codecs[c].sw) + ); + } else { + codecSupportRows.push( + formatCodecRow(codecs[c].name, codecs[c].sw, codecs[c].hw) + ); + } + } + + let codecSupportTable = $.new("table", [ + formatCodecRowHeader( + codecNameHeaderText, + codecSWDecodeText, + codecHWDecodeText + ), + $.new("tbody", codecSupportRows), + ]); + codecSupportTable.id = "codec-table"; + supportInfo = [codecSupportTable]; + } else { + // Don't have access to codec support information + supportInfo = await document.l10n.formatValue( + "media-codec-support-error" + ); + } + if (["win", "macosx", "linux", "android"].includes(AppConstants.platform)) { + insertBasicInfo("media-codec-support-info", supportInfo); + } + + // CDM info + insertContentDecryptionModuleInfo(); + }, + + remoteAgent(data) { + if (!AppConstants.ENABLE_WEBDRIVER) { + return; + } + $("remote-debugging-accepting-connections").textContent = data.running; + $("remote-debugging-url").textContent = data.url; + }, + + accessibility(data) { + $("a11y-activated").textContent = data.isActive; + $("a11y-force-disabled").textContent = data.forceDisabled || 0; + + let a11yInstantiator = $("a11y-instantiator"); + if (a11yInstantiator) { + a11yInstantiator.textContent = data.instantiator; + } + }, + + startupCache(data) { + $("startup-cache-disk-cache-path").textContent = data.DiskCachePath; + $("startup-cache-ignore-disk-cache").textContent = data.IgnoreDiskCache; + $("startup-cache-found-disk-cache-on-init").textContent = + data.FoundDiskCacheOnInit; + $("startup-cache-wrote-to-disk-cache").textContent = data.WroteToDiskCache; + }, + + libraryVersions(data) { + let trs = [ + $.new("tr", [ + $.new("th", ""), + $.new("th", null, null, { "data-l10n-id": "min-lib-versions" }), + $.new("th", null, null, { "data-l10n-id": "loaded-lib-versions" }), + ]), + ]; + sortedArrayFromObject(data).forEach(function ([name, val]) { + trs.push( + $.new("tr", [ + $.new("td", name), + $.new("td", val.minVersion), + $.new("td", val.version), + ]) + ); + }); + $.append($("libversions-tbody"), trs); + }, + + userJS(data) { + if (!data.exists) { + return; + } + let userJSFile = Services.dirsvc.get("PrefD", Ci.nsIFile); + userJSFile.append("user.js"); + $("prefs-user-js-link").href = Services.io.newFileURI(userJSFile).spec; + $("prefs-user-js-section").style.display = ""; + // Clear the no-copy class + $("prefs-user-js-section").className = ""; + }, + + sandbox(data) { + if (!AppConstants.MOZ_SANDBOX) { + return; + } + + let tbody = $("sandbox-tbody"); + for (let key in data) { + // Simplify the display a little in the common case. + if ( + key === "hasPrivilegedUserNamespaces" && + data[key] === data.hasUserNamespaces + ) { + continue; + } + if (key === "syscallLog") { + // Not in this table. + continue; + } + let keyStrId = toFluentID(key); + let th = $.new("th", null, "column"); + document.l10n.setAttributes(th, keyStrId); + tbody.appendChild($.new("tr", [th, $.new("td", data[key])])); + } + + if ("syscallLog" in data) { + let syscallBody = $("sandbox-syscalls-tbody"); + let argsHead = $("sandbox-syscalls-argshead"); + for (let syscall of data.syscallLog) { + if (argsHead.colSpan < syscall.args.length) { + argsHead.colSpan = syscall.args.length; + } + let procTypeStrId = toFluentID(syscall.procType); + let cells = [ + $.new("td", syscall.index, "integer"), + $.new("td", syscall.msecAgo / 1000), + $.new("td", syscall.pid, "integer"), + $.new("td", syscall.tid, "integer"), + $.new("td", null, null, { + "data-l10n-id": "sandbox-proc-type-" + procTypeStrId, + }), + $.new("td", syscall.syscall, "integer"), + ]; + for (let arg of syscall.args) { + cells.push($.new("td", arg, "integer")); + } + syscallBody.appendChild($.new("tr", cells)); + } + } + }, + + intl(data) { + $("intl-locale-requested").textContent = JSON.stringify( + data.localeService.requested + ); + $("intl-locale-available").textContent = JSON.stringify( + data.localeService.available + ); + $("intl-locale-supported").textContent = JSON.stringify( + data.localeService.supported + ); + $("intl-locale-regionalprefs").textContent = JSON.stringify( + data.localeService.regionalPrefs + ); + $("intl-locale-default").textContent = JSON.stringify( + data.localeService.defaultLocale + ); + + $("intl-osprefs-systemlocales").textContent = JSON.stringify( + data.osPrefs.systemLocales + ); + $("intl-osprefs-regionalprefs").textContent = JSON.stringify( + data.osPrefs.regionalPrefsLocales + ); + }, + + normandy(data) { + if (!data) { + return; + } + + const { + prefStudies, + addonStudies, + prefRollouts, + nimbusExperiments, + nimbusRollouts, + } = data; + $.append( + $("remote-features-tbody"), + prefRollouts.map(({ slug, state }) => + $.new("tr", [ + $.new("td", [document.createTextNode(slug)]), + $.new("td", [document.createTextNode(state)]), + ]) + ) + ); + + $.append( + $("remote-features-tbody"), + nimbusRollouts.map(({ userFacingName, branch }) => + $.new("tr", [ + $.new("td", [document.createTextNode(userFacingName)]), + $.new("td", [document.createTextNode(`(${branch.slug})`)]), + ]) + ) + ); + $.append( + $("remote-experiments-tbody"), + [addonStudies, prefStudies, nimbusExperiments] + .flat() + .map(({ userFacingName, branch }) => + $.new("tr", [ + $.new("td", [document.createTextNode(userFacingName)]), + $.new("td", [document.createTextNode(branch?.slug || branch)]), + ]) + ) + ); + }, +}; + +var $ = document.getElementById.bind(document); + +$.new = function $_new(tag, textContentOrChildren, className, attributes) { + let elt = document.createElement(tag); + if (className) { + elt.className = className; + } + if (attributes) { + if (attributes["data-l10n-id"]) { + let args = attributes.hasOwnProperty("data-l10n-args") + ? attributes["data-l10n-args"] + : undefined; + document.l10n.setAttributes(elt, attributes["data-l10n-id"], args); + delete attributes["data-l10n-id"]; + if (args) { + delete attributes["data-l10n-args"]; + } + } + + for (let attrName in attributes) { + elt.setAttribute(attrName, attributes[attrName]); + } + } + if (Array.isArray(textContentOrChildren)) { + this.append(elt, textContentOrChildren); + } else if (!attributes || !attributes["data-l10n-id"]) { + elt.textContent = String(textContentOrChildren); + } + return elt; +}; + +$.append = function $_append(parent, children) { + children.forEach(c => parent.appendChild(c)); +}; + +function assembleFromGraphicsFailure(i, data) { + // Only cover the cases we have today; for example, we do not have + // log failures that assert and we assume the log level is 1/error. + let message = data.failures[i]; + let index = data.indices[i]; + let what = ""; + if (message.search(/\[GFX1-\]: \(LF\)/) == 0) { + // Non-asserting log failure - the message is substring(14) + what = "LogFailure"; + message = message.substring(14); + } else if (message.search(/\[GFX1-\]: /) == 0) { + // Non-asserting - the message is substring(9) + what = "Error"; + message = message.substring(9); + } else if (message.search(/\[GFX1\]: /) == 0) { + // Asserting - the message is substring(8) + what = "Assert"; + message = message.substring(8); + } + let assembled = { + index, + header: "(#" + index + ") " + what, + message, + }; + return assembled; +} + +function sortedArrayFromObject(obj) { + let tuples = []; + for (let prop in obj) { + tuples.push([prop, obj[prop]]); + } + tuples.sort(([prop1, v1], [prop2, v2]) => prop1.localeCompare(prop2)); + return tuples; +} + +function copyRawDataToClipboard(button) { + if (button) { + button.disabled = true; + } + Troubleshoot.snapshot().then( + async snapshot => { + if (button) { + button.disabled = false; + } + let str = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + str.data = JSON.stringify(snapshot, undefined, 2); + let transferable = Cc[ + "@mozilla.org/widget/transferable;1" + ].createInstance(Ci.nsITransferable); + transferable.init(getLoadContext()); + transferable.addDataFlavor("text/plain"); + transferable.setTransferData("text/plain", str); + Services.clipboard.setData( + transferable, + null, + Ci.nsIClipboard.kGlobalClipboard + ); + }, + err => { + if (button) { + button.disabled = false; + } + console.error(err); + } + ); +} + +function getLoadContext() { + return window.docShell.QueryInterface(Ci.nsILoadContext); +} + +async function copyContentsToClipboard() { + // Get the HTML and text representations for the important part of the page. + let contentsDiv = $("contents").cloneNode(true); + // Remove the items we don't want to copy from the clone: + contentsDiv.querySelectorAll(".no-copy, [hidden]").forEach(n => n.remove()); + let dataHtml = contentsDiv.innerHTML; + let dataText = createTextForElement(contentsDiv); + + // We can't use plain strings, we have to use nsSupportsString. + let supportsStringClass = Cc["@mozilla.org/supports-string;1"]; + let ssHtml = supportsStringClass.createInstance(Ci.nsISupportsString); + let ssText = supportsStringClass.createInstance(Ci.nsISupportsString); + + let transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + transferable.init(getLoadContext()); + + // Add the HTML flavor. + transferable.addDataFlavor("text/html"); + ssHtml.data = dataHtml; + transferable.setTransferData("text/html", ssHtml); + + // Add the plain text flavor. + transferable.addDataFlavor("text/plain"); + ssText.data = dataText; + transferable.setTransferData("text/plain", ssText); + + // Store the data into the clipboard. + Services.clipboard.setData( + transferable, + null, + Services.clipboard.kGlobalClipboard + ); +} + +// Return the plain text representation of an element. Do a little bit +// of pretty-printing to make it human-readable. +function createTextForElement(elem) { + let serializer = new Serializer(); + let text = serializer.serialize(elem); + + // Actual CR/LF pairs are needed for some Windows text editors. + if (AppConstants.platform == "win") { + text = text.replace(/\n/g, "\r\n"); + } + + return text; +} + +function Serializer() {} + +Serializer.prototype = { + serialize(rootElem) { + this._lines = []; + this._startNewLine(); + this._serializeElement(rootElem); + this._startNewLine(); + return this._lines.join("\n").trim() + "\n"; + }, + + // The current line is always the line that writing will start at next. When + // an element is serialized, the current line is updated to be the line at + // which the next element should be written. + get _currentLine() { + return this._lines.length ? this._lines[this._lines.length - 1] : null; + }, + + set _currentLine(val) { + this._lines[this._lines.length - 1] = val; + }, + + _serializeElement(elem) { + // table + if (elem.localName == "table") { + this._serializeTable(elem); + return; + } + + // all other elements + + let hasText = false; + for (let child of elem.childNodes) { + if (child.nodeType == Node.TEXT_NODE) { + let text = this._nodeText(child); + this._appendText(text); + hasText = hasText || !!text.trim(); + } else if (child.nodeType == Node.ELEMENT_NODE) { + this._serializeElement(child); + } + } + + // For headings, draw a "line" underneath them so they stand out. + let isHeader = /^h[0-9]+$/.test(elem.localName); + if (isHeader) { + let headerText = (this._currentLine || "").trim(); + if (headerText) { + this._startNewLine(); + this._appendText("-".repeat(headerText.length)); + } + } + + // Add a blank line underneath elements but only if they contain text. + if (hasText && (isHeader || "p" == elem.localName)) { + this._startNewLine(); + this._startNewLine(); + } + }, + + _startNewLine(lines) { + let currLine = this._currentLine; + if (currLine) { + // The current line is not empty. Trim it. + this._currentLine = currLine.trim(); + if (!this._currentLine) { + // The current line became empty. Discard it. + this._lines.pop(); + } + } + this._lines.push(""); + }, + + _appendText(text, lines) { + this._currentLine += text; + }, + + _isHiddenSubHeading(th) { + return th.parentNode.parentNode.style.display == "none"; + }, + + _serializeTable(table) { + // Collect the table's column headings if in fact there are any. First + // check thead. If there's no thead, check the first tr. + let colHeadings = {}; + let tableHeadingElem = table.querySelector("thead"); + if (!tableHeadingElem) { + tableHeadingElem = table.querySelector("tr"); + } + if (tableHeadingElem) { + let tableHeadingCols = tableHeadingElem.querySelectorAll("th,td"); + // If there's a contiguous run of th's in the children starting from the + // rightmost child, then consider them to be column headings. + for (let i = tableHeadingCols.length - 1; i >= 0; i--) { + let col = tableHeadingCols[i]; + if (col.localName != "th" || col.classList.contains("title-column")) { + break; + } + colHeadings[i] = this._nodeText(col).trim(); + } + } + let hasColHeadings = !!Object.keys(colHeadings).length; + if (!hasColHeadings) { + tableHeadingElem = null; + } + + let trs = table.querySelectorAll("table > tr, tbody > tr"); + let startRow = + tableHeadingElem && tableHeadingElem.localName == "tr" ? 1 : 0; + + if (startRow >= trs.length) { + // The table's empty. + return; + } + + if (hasColHeadings) { + // Use column headings. Print each tr as a multi-line chunk like: + // Heading 1: Column 1 value + // Heading 2: Column 2 value + for (let i = startRow; i < trs.length; i++) { + let children = trs[i].querySelectorAll("td"); + for (let j = 0; j < children.length; j++) { + let text = ""; + if (colHeadings[j]) { + text += colHeadings[j] + ": "; + } + text += this._nodeText(children[j]).trim(); + this._appendText(text); + this._startNewLine(); + } + this._startNewLine(); + } + return; + } + + // Don't use column headings. Assume the table has only two columns and + // print each tr in a single line like: + // Column 1 value: Column 2 value + for (let i = startRow; i < trs.length; i++) { + let children = trs[i].querySelectorAll("th,td"); + let rowHeading = this._nodeText(children[0]).trim(); + if (children[0].classList.contains("title-column")) { + if (!this._isHiddenSubHeading(children[0])) { + this._appendText(rowHeading); + } + } else if (children.length == 1) { + // This is a single-cell row. + this._appendText(rowHeading); + } else { + let childTables = trs[i].querySelectorAll("table"); + if (childTables.length) { + // If we have child tables, don't use nodeText - its trs are already + // queued up from querySelectorAll earlier. + this._appendText(rowHeading + ": "); + } else { + this._appendText(rowHeading + ": "); + for (let k = 1; k < children.length; k++) { + let l = this._nodeText(children[k]).trim(); + if (l == "") { + continue; + } + if (k < children.length - 1) { + l += ", "; + } + this._appendText(l); + } + } + } + this._startNewLine(); + } + this._startNewLine(); + }, + + _nodeText(node) { + return node.textContent.replace(/\s+/g, " "); + }, +}; + +function openProfileDirectory() { + // Get the profile directory. + let currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile); + let profileDir = currProfD.path; + + // Show the profile directory. + let nsLocalFile = Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" + ); + new nsLocalFile(profileDir).reveal(); +} + +/** + * Profile reset is only supported for the default profile if the appropriate migrator exists. + */ +function populateActionBox() { + if (ResetProfile.resetSupported()) { + $("reset-box").style.display = "block"; + } + if (!Services.appinfo.inSafeMode && AppConstants.platform !== "android") { + $("safe-mode-box").style.display = "block"; + + if (Services.policies && !Services.policies.isAllowed("safeMode")) { + $("restart-in-safe-mode-button").setAttribute("disabled", "true"); + } + } +} + +// Prompt user to restart the browser in safe mode +function safeModeRestart() { + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + + if (!cancelQuit.data) { + Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + } +} +/** + * Set up event listeners for buttons. + */ +function setupEventListeners() { + let button = $("reset-box-button"); + if (button) { + button.addEventListener("click", function (event) { + ResetProfile.openConfirmationDialog(window); + }); + } + button = $("clear-startup-cache-button"); + if (button) { + button.addEventListener("click", async function (event) { + const [promptTitle, promptBody, restartButtonLabel] = + await document.l10n.formatValues([ + { id: "startup-cache-dialog-title2" }, + { id: "startup-cache-dialog-body2" }, + { id: "restart-button-label" }, + ]); + const buttonFlags = + Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL + + Services.prompt.BUTTON_POS_0_DEFAULT; + const result = Services.prompt.confirmEx( + window.docShell.chromeEventHandler.ownerGlobal, + promptTitle, + promptBody, + buttonFlags, + restartButtonLabel, + null, + null, + null, + {} + ); + if (result !== 0) { + return; + } + Services.appinfo.invalidateCachesOnRestart(); + Services.startup.quit( + Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit + ); + }); + } + button = $("restart-in-safe-mode-button"); + if (button) { + button.addEventListener("click", function (event) { + if ( + Services.obs + .enumerateObservers("restart-in-safe-mode") + .hasMoreElements() + ) { + Services.obs.notifyObservers( + window.docShell.chromeEventHandler.ownerGlobal, + "restart-in-safe-mode" + ); + } else { + safeModeRestart(); + } + }); + } + if (AppConstants.MOZ_UPDATER) { + button = $("update-dir-button"); + if (button) { + button.addEventListener("click", function (event) { + // Get the update directory. + let updateDir = Services.dirsvc.get("UpdRootD", Ci.nsIFile); + if (!updateDir.exists()) { + updateDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + let updateDirPath = updateDir.path; + // Show the update directory. + let nsLocalFile = Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" + ); + new nsLocalFile(updateDirPath).reveal(); + }); + } + button = $("show-update-history-button"); + if (button) { + button.addEventListener("click", function (event) { + window.browsingContext.topChromeWindow.openDialog( + "chrome://mozapps/content/update/history.xhtml", + "Update:History", + "centerscreen,resizable=no,titlebar,modal" + ); + }); + } + } + button = $("verify-place-integrity-button"); + if (button) { + button.addEventListener("click", function (event) { + PlacesDBUtils.checkAndFixDatabase().then(tasksStatusMap => { + let logs = []; + for (let [key, value] of tasksStatusMap) { + logs.push(`> Task: ${key}`); + let prefix = value.succeeded ? "+ " : "- "; + logs = logs.concat(value.logs.map(m => `${prefix}${m}`)); + } + $("verify-place-result").style.display = "block"; + $("verify-place-result").classList.remove("no-copy"); + $("verify-place-result").textContent = logs.join("\n"); + }); + }); + } + + $("copy-raw-data-to-clipboard").addEventListener("click", function (event) { + copyRawDataToClipboard(this); + }); + $("copy-to-clipboard").addEventListener("click", function (event) { + copyContentsToClipboard(); + }); + $("profile-dir-button").addEventListener("click", function (event) { + openProfileDirectory(); + }); +} + +/** + * Scroll to section specified by location.hash + */ +function scrollToSection() { + const id = location.hash.substr(1); + const elem = $(id); + + if (elem) { + elem.scrollIntoView(); + } +} diff --git a/toolkit/content/aboutSupport.xhtml b/toolkit/content/aboutSupport.xhtml new file mode 100644 index 0000000000..d3de7d0019 --- /dev/null +++ b/toolkit/content/aboutSupport.xhtml @@ -0,0 +1,909 @@ +<?xml version="1.0" encoding="UTF-8"?> + +# 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/. + +<!DOCTYPE html [ + <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> %htmlDTD; +]> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Security-Policy" content="default-src chrome:; object-src 'none'" /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="page-title"/> + + <link rel="icon" type="image/png" id="favicon" + href="chrome://branding/content/icon32.png"/> + <link rel="stylesheet" href="chrome://global/skin/aboutSupport.css" + type="text/css"/> + + <script src="chrome://global/content/aboutSupport.js"/> + <link rel="localization" href="branding/brand.ftl"/> + <link rel="localization" href="toolkit/about/aboutSupport.ftl"/> + <link rel="localization" href="toolkit/global/resetProfile.ftl"/> + <link rel="localization" href="toolkit/global/processTypes.ftl"/> +#ifndef ANDROID + <link rel="localization" href="toolkit/featuregates/features.ftl"/> +#endif + </head> + + <body class="wide-container"> + <h1 data-l10n-id="page-title"/> + <div class="header-flex"> + <div class="content-flex"> + <div class="page-subtitle" data-l10n-id="page-subtitle"> + <a id="supportLink" data-l10n-name="support-link"></a> + </div> + <div> + <button id="copy-raw-data-to-clipboard" data-l10n-id="copy-raw-data-to-clipboard-label"/> + <button id="copy-to-clipboard" data-l10n-id="copy-text-to-clipboard-label"/> + </div> + </div> + +#ifndef ANDROID + <div class="action-box"> + <div id="reset-box"> + <h3 data-l10n-id="refresh-profile"/> + <button id="reset-box-button" data-l10n-id="refresh-profile-button"/> + </div> + <div id="safe-mode-box"> + <h3 data-l10n-id="troubleshoot-mode-title"/> + <button id="restart-in-safe-mode-button" data-l10n-id="restart-in-troubleshoot-mode-label"/> + </div> + <div id="clear-startup-cache-box"> + <h3 data-l10n-id="clear-startup-cache-title"/> + <button id="clear-startup-cache-button" data-l10n-id="clear-startup-cache-label"/> + </div> + </div> +#endif + </div> + <div id="contents"> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="app-basics" data-l10n-id="app-basics-title"/> + + <table> + <tbody> + <tr> + <th class="column" data-l10n-id="app-basics-name"/> + + <td id="application-box"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-version"/> + + <td id="version-box"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-build-id"/> + <td id="buildid-box"></td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-distribution-id"/> + <td id="distributionid-box"></td> + </tr> + +#ifndef ANDROID +#ifdef MOZ_UPDATER + <tr id="update-dir-row" class="no-copy"> + <th class="column" data-l10n-id="app-basics-update-dir"/> + + <td> + <button id="update-dir-button" data-l10n-id="show-dir-label"/> + <span id="update-dir-box" dir="ltr"> + </span> + </td> + </tr> + + <tr id="update-history-row" class="no-copy"> + <th class="column" data-l10n-id="app-basics-update-history"/> + + <td> + <button id="show-update-history-button" data-l10n-id="app-basics-show-update-history"/> + </td> + </tr> +#endif +#endif + +#ifdef MOZ_UPDATER + <tr> + <th class="column" data-l10n-id="app-basics-update-channel"/> + <td id="updatechannel-box"></td> + </tr> +#endif + + <tr> + <th class="column" data-l10n-id="app-basics-user-agent"/> + + <td id="useragent-box"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-os"/> + + <td id="os-box"> + </td> + </tr> + + <tr id="os-theme-row"> + <th class="column" data-l10n-id="app-basics-os-theme"/> + + <td id="os-theme-box"> + </td> + </tr> + +#ifdef XP_MACOSX + <tr> + <th class="column" data-l10n-id="app-basics-rosetta"/> + + <td id="rosetta-box"> + </td> + </tr> +#endif + + <tr class="no-copy"> + <th class="column" data-l10n-id="app-basics-binary"/> + + <td id="binary-box" dir="ltr"> + </td> + </tr> + + <tr id="profile-row" class="no-copy"> + <th class="column" data-l10n-id="app-basics-profile-dir"/> + + <td> + <button id="profile-dir-button" data-l10n-id="show-dir-label"/> + <span id="profile-dir-box" dir="ltr"> + </span> + </td> + </tr> + + <tr class="no-copy"> + <th class="column" data-l10n-id="app-basics-build-config"/> + + <td> + <a href="about:buildconfig">about:buildconfig</a> + </td> + </tr> + + <tr class="no-copy"> + <th class="column" data-l10n-id="app-basics-memory-use"/> + + <td> + <a href="about:memory">about:memory</a> + </td> + </tr> + + <tr class="no-copy"> + <th class="column" data-l10n-id="app-basics-performance"/> + + <td> + <a href="about:processes">about:processes</a> + </td> + </tr> + + <tr class="no-copy"> + <th class="column" data-l10n-id="app-basics-service-workers"/> + + <td> + <a href="about:serviceworkers">about:serviceworkers</a> + </td> + </tr> + +#if defined(XP_WIN) + <tr class="no-copy"> + <th class="column" data-l10n-id="app-basics-third-party"/> + + <td> + <a href="about:third-party">about:third-party</a> + </td> + </tr> +#endif + +#if defined(XP_WIN) && defined(MOZ_LAUNCHER_PROCESS) + <tr> + <th class="column" data-l10n-id="app-basics-launcher-process-status"/> + + <td id="launcher-process-box"> + </td> + </tr> +#endif + + <tr> + <th class="column" data-l10n-id="app-basics-multi-process-support"/> + + <td id="multiprocess-box"> + <span id="multiprocess-box-process-count"/> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-fission-support"/> + + <td id="fission-box"> + <span id="fission-box-process-count"/> + <span id="fission-box-status"/> + </td> + </tr> + + <tr id="remoteprocesses-row"> + <th class="column" data-l10n-id="app-basics-remote-processes-count"/> + + <td> + <a href="#remote-processes"></a> + </td> + </tr> + + <tr id="policies-status-row"> + <th class="column" data-l10n-id="app-basics-enterprise-policies"/> + + <td id="policies-status"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-location-service-key-google"/> + + <td id="key-location-service-google-box"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-safebrowsing-key-google"/> + + <td id="key-safebrowsing-google-box"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-key-mozilla"/> + + <td id="key-mozilla-box"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-safe-mode"/> + + <td id="safemode-box"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-memory-size"/> + + <td id="memory-size-box"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="app-basics-disk-available"/> + + <td id="disk-available-box"> + </td> + </tr> + +#ifndef ANDROID + <tr class="no-copy"> + <th class="column" data-l10n-id="app-basics-profiles"/> + + <td> + <a href="about:profiles">about:profiles</a> + </td> + </tr> +#endif + +#if defined(XP_WIN) + <tr> + <th class="column" data-l10n-id="app-basics-pointing-devices"/> + + <td id="pointing-devices-box"> + </td> + </tr> +#endif + + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> +#ifdef MOZ_CRASHREPORTER + + <h2 class="major-section" id="crashes" data-l10n-id="crashes-title"/> + + <table id="crashes-table"> + <thead> + <tr> + <th data-l10n-id="crashes-id"/> + <th data-l10n-id="crashes-send-date"/> + </tr> + </thead> + <tbody id="crashes-tbody"> + </tbody> + </table> + <p id="crashes-allReports" class="hidden no-copy"> + <a href="about:crashes" id="crashes-allReportsWithPending" + class="block" data-l10n-id="crashes-all-reports"/> + </p> + <p id="crashes-noConfig" class="hidden no-copy" data-l10n-id="crashes-no-config"/> + +#endif + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="features" data-l10n-id="features-title"/> + + <table id="features-table"> + <thead> + <tr> + <th data-l10n-id="features-name"/> + <th data-l10n-id="features-version"/> + <th data-l10n-id="features-id"/> + </tr> + </thead> + <tbody id="features-tbody"> + </tbody> + </table> + +#ifdef MOZ_NORMANDY + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="remote-features" data-l10n-id="support-remote-features-title"/> + + <table class="prefs-table"> + <thead class="no-copy"> + <th data-l10n-id="support-remote-features-name"/> + <th data-l10n-id="support-remote-features-status"/> + </thead> + + <tbody id="remote-features-tbody"> + </tbody> + </table> + +#endif + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" data-l10n-id="processes-title" id="remote-processes"/> + + <table id="remote-processes-table"> + <thead> + <tr> + <th data-l10n-id="processes-type"/> + <th data-l10n-id="processes-count"/> + </tr> + </thead> + <tbody id="processes-tbody"> + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="addons" data-l10n-id="support-addons-title"/> + + <table> + <thead> + <tr> + <th data-l10n-id="support-addons-name"/> + <th data-l10n-id="support-addons-type"/> + <th data-l10n-id="support-addons-version"/> + <th data-l10n-id="support-addons-enabled"/> + <th data-l10n-id="support-addons-id"/> + </tr> + </thead> + <tbody id="addons-tbody"> + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="security-software" data-l10n-id="security-software-title"/> + + <table id="security-software-table"> + <thead> + <tr> + <th data-l10n-id="security-software-type"/> + <th data-l10n-id="security-software-name"/> + </tr> + </thead> + <tbody> + <tr> + <th class="column" data-l10n-id="security-software-antivirus"/> + + <td id="security-software-antivirus"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="security-software-antispyware"/> + + <td id="security-software-antispyware"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="security-software-firewall"/> + + <td id="security-software-firewall"> + </td> + </tr> + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="legacyUserStylesheets" data-l10n-id="legacy-user-stylesheets-title"/> + + <table> + <tbody id="legacyUserStylesheets-info-tbody"> + <tr> + <th class="column" data-l10n-id="legacy-user-stylesheets-enabled"/> + + <td id="legacyUserStylesheets-enabled"> + </td> + </tr> + + <tr> + <th class="column" data-l10n-id="legacy-user-stylesheets-stylesheet-types"/> + + <td id="legacyUserStylesheets-types"> + </td> + </tr> + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="graphics" data-l10n-id="graphics-title"/> + + <table> + <tbody id="graphics-features-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="graphics-features-title"/> + </tr> + </tbody> + + <tbody id="graphics-gpu-1-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="graphics-gpu1-title"/> + </tr> + </tbody> + + <tbody id="graphics-gpu-2-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="graphics-gpu2-title"/> + </tr> + </tbody> + + <tbody id="graphics-diagnostics-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="graphics-diagnostics-title"/> + </tr> + </tbody> + + <tbody id="graphics-decisions-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="graphics-decision-log-title"/> + </tr> + </tbody> + + <tbody id="graphics-crashguards-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="graphics-crash-guards-title"/> + </tr> + </tbody> + + <tbody id="graphics-workarounds-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="graphics-workarounds-title"/> + </tr> + </tbody> + + <tbody id="graphics-failures-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="graphics-failure-log-title"/> + </tr> + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="media" data-l10n-id="media-title"/> + <table> + <tbody id="media-info-tbody"> + </tbody> + + <tbody id="media-output-devices-tbody"> + <tr> + <th colspan="9" class="title-column" data-l10n-id="media-output-devices-title"/> + </tr> + <tr> + <th data-l10n-id="media-device-name"/> + <th data-l10n-id="media-device-group"/> + <th data-l10n-id="media-device-vendor"/> + <th data-l10n-id="media-device-state"/> + <th data-l10n-id="media-device-preferred"/> + <th data-l10n-id="media-device-format"/> + <th data-l10n-id="media-device-channels"/> + <th data-l10n-id="media-device-rate"/> + <th data-l10n-id="media-device-latency"/> + </tr> + </tbody> + + <tbody id="media-input-devices-tbody"> + <tr> + <th colspan="9" class="title-column" data-l10n-id="media-input-devices-title"/> + </tr> + <tr> + <th data-l10n-id="media-device-name"/> + <th data-l10n-id="media-device-group"/> + <th data-l10n-id="media-device-vendor"/> + <th data-l10n-id="media-device-state"/> + <th data-l10n-id="media-device-preferred"/> + <th data-l10n-id="media-device-format"/> + <th data-l10n-id="media-device-channels"/> + <th data-l10n-id="media-device-rate"/> + <th data-l10n-id="media-device-latency"/> + </tr> + </tbody> + + <tbody id="media-capabilities-tbody"> + <tr> + <th colspan="9" class="title-column" data-l10n-id="media-capabilities-title"/> + </tr> + <tr> + <td colspan="9"> + <button id="enumerate-database-button" data-l10n-id="media-capabilities-enumerate"/> + <pre id="enumerate-database-result" class="hidden no-copy"></pre> + </td> + </tr> + </tbody> + + <tbody id="media-content-decryption-modules-tbody"> + <tr> + <th colspan="6" class="title-column" data-l10n-id="media-content-decryption-modules-title"/> + </tr> + <tr> + <th data-l10n-id="media-key-system-name"/> + <th data-l10n-id="media-video-robustness"/> + <th data-l10n-id="media-audio-robustness"/> + <th data-l10n-id="media-cdm-capabilities"/> + <th data-l10n-id="media-cdm-clear-lead"/> + <th data-l10n-id="media-hdcp-22-compatible"/> + </tr> + </tbody> + + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="environment-variables" data-l10n-id="environment-variables-title"/> + + <table class="prefs-table"> + <thead class="no-copy"> + <th class="name" data-l10n-id="environment-variables-name"/> + + <th class="value" data-l10n-id="environment-variables-value"/> + </thead> + + <tbody id="environment-variables-tbody"> + </tbody> + </table> + + +#ifndef ANDROID + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="experimental-features" data-l10n-id="experimental-features-title"/> + + <table class="prefs-table"> + <thead class="no-copy"> + <th class="name" data-l10n-id="experimental-features-name"/> + + <th class="value" data-l10n-id="experimental-features-value"/> + </thead> + + <tbody id="experimental-features-tbody"> + </tbody> + </table> + +#endif +#ifdef MOZ_NORMANDY + <!-- - - - - - - - - - - - - - - - - - - - - --> + <h2 class="major-section" id="remote-experiments" data-l10n-id="support-remote-experiments-title"/> + + <table class="prefs-table"> + <thead class="no-copy"> + <th data-l10n-id="support-remote-experiments-name"/> + <th data-l10n-id="support-remote-experiments-branch"/> + </thead> + + <tbody id="remote-experiments-tbody"> + </tbody> + </table> + + <section id="about-studies-section" class="no-copy"> + <p data-l10n-id="support-remote-experiments-see-about-studies"> + <a data-l10n-name="support-about-studies-link" href="about:studies"></a> + </p> + </section> + +#endif + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="modified-key-prefs" data-l10n-id="modified-key-prefs-title"/> + + <table class="prefs-table"> + <thead class="no-copy"> + <th class="name" data-l10n-id="modified-prefs-name"/> + + <th class="value" data-l10n-id="modified-prefs-value"/> + </thead> + + <tbody id="prefs-tbody"> + </tbody> + </table> + + <section id="prefs-user-js-section" class="hidden no-copy"> + <h3 data-l10n-id="user-js-title"/> + <p data-l10n-id="user-js-description"> + <a id="prefs-user-js-link" data-l10n-name="user-js-link"></a> + </p> + </section> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="locked-key-prefs" data-l10n-id="locked-key-prefs-title"/> + + <table class="prefs-table"> + <thead class="no-copy"> + <th class="name" data-l10n-id="locked-prefs-name"/> + + <th class="value" data-l10n-id="locked-prefs-value"/> + </thead> + + <tbody id="locked-prefs-tbody"> + </tbody> + </table> + +#ifndef ANDROID + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="place-database" data-l10n-id="place-database-title"/> + + <table> + <tbody> + <tr class="no-copy"> + <th class="column" data-l10n-id="place-database-integrity"/> + + <td colspan="5"> + <button id="verify-place-integrity-button" data-l10n-id="place-database-verify-integrity"/> + <pre id="verify-place-result" class="hidden no-copy"></pre> + </td> + </tr> + <tr class="no-copy"> + <th class="column" data-l10n-id="place-database-stats"/> + + <td colspan="5"> + <button id="place-database-stats-toggle" data-l10n-id="place-database-stats-show"/> + </td> + </tr> + </tbody> + <tbody id="place-database-stats-tbody"> + <tr> + <th data-l10n-id="place-database-stats-entity"/> + <th data-l10n-id="place-database-stats-count"/> + <th data-l10n-id="place-database-stats-size-kib"/> + <th data-l10n-id="place-database-stats-size-perc"/> + <th data-l10n-id="place-database-stats-efficiency-perc"/> + <th data-l10n-id="place-database-stats-sequentiality-perc"/> + </tr> + </tbody> + </table> +#endif + + <!-- - - - - - - - - - - - - - - - - - - - - --> + <h2 class="major-section" id="a11y" data-l10n-id="a11y-title"/> + + <table> + <tbody> + <tr> + <th class="column" data-l10n-id="a11y-activated"/> + + <td id="a11y-activated"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="a11y-force-disabled"/> + + <td id="a11y-force-disabled"> + </td> + </tr> +#if defined(XP_WIN) + <tr> + <th class="column" data-l10n-id="a11y-instantiator"/> + + <td id="a11y-instantiator"> + </td> + </tr> +#endif + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + <h2 class="major-section" id="library-version" data-l10n-id="library-version-title"/> + + <table> + <tbody id="libversions-tbody"> + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + +#if defined(MOZ_SANDBOX) + <h2 class="major-section" id="sandbox" data-l10n-id="sandbox-title"/> + + <table> + <tbody id="sandbox-tbody"> + </tbody> + </table> + +#if defined(XP_LINUX) + <h4 data-l10n-id="sandbox-sys-call-log-title"/> + <table> + <thead> + <tr> + <th data-l10n-id="sandbox-sys-call-index"/> + <th data-l10n-id="sandbox-sys-call-age"/> + <th data-l10n-id="sandbox-sys-call-pid"/> + <th data-l10n-id="sandbox-sys-call-tid"/> + <th data-l10n-id="sandbox-sys-call-proc-type"/> + <th data-l10n-id="sandbox-sys-call-number"/> + <th id="sandbox-syscalls-argshead" data-l10n-id="sandbox-sys-call-args"/> + </tr> + </thead> + <tbody id="sandbox-syscalls-tbody"> + </tbody> + </table> +#endif +#endif + + <h2 class="major-section" id="startup-cache" data-l10n-id="startup-cache-title"/> + + <table> + <tbody> + <tr> + <th class="column" data-l10n-id="startup-cache-disk-cache-path"/> + + <td id="startup-cache-disk-cache-path"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="startup-cache-ignore-disk-cache"/> + + <td id="startup-cache-ignore-disk-cache"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="startup-cache-found-disk-cache-on-init"/> + + <td id="startup-cache-found-disk-cache-on-init"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="startup-cache-wrote-to-disk-cache"/> + + <td id="startup-cache-wrote-to-disk-cache"> + </td> + </tr> + </tbody> + </table> + + <h2 class="major-section" id="intl" data-l10n-id="intl-title"/> + + <table> + <tbody id="intl-localeservice-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="intl-app-title"/> + </tr> + <tr> + <th class="column" data-l10n-id="intl-locales-requested"/> + <td id="intl-locale-requested"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="intl-locales-available"/> + <td id="intl-locale-available"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="intl-locales-supported"/> + <td id="intl-locale-supported"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="intl-regional-prefs"/> + <td id="intl-locale-regionalprefs"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="intl-locales-default"/> + <td id="intl-locale-default"> + </td> + </tr> + </tbody> + <tbody id="intl-ospreferences-tbody"> + <tr> + <th colspan="2" class="title-column" data-l10n-id="intl-os-title"/> + </tr> + <tr> + <th class="column" data-l10n-id="intl-os-prefs-system-locales"/> + <td id="intl-osprefs-systemlocales"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="intl-regional-prefs"/> + <td id="intl-osprefs-regionalprefs"> + </td> + </tr> + </tbody> + </table> + + <!-- - - - - - - - - - - - - - - - - - - - - --> + +#if defined(ENABLE_WEBDRIVER) + <h2 class="major-section" id="remote-debugging" data-l10n-id="remote-debugging-title"/> + + <table> + <tbody> + <tr> + <th class="column" data-l10n-id="remote-debugging-accepting-connections"/> + <td id="remote-debugging-accepting-connections"></td> + </tr> + <tr> + <th class="column" data-l10n-id="remote-debugging-url"/> + <td id="remote-debugging-url"></td> + </tr> + </tbody> + </table> +#endif + +#ifndef ANDROID + <!-- - - - - - - - - - - - - - - - - - - - - --> + + <h2 class="major-section" id="printing" data-l10n-id="support-printing-title"/> + + <table> + <tr class="no-copy"> + <th class="column" data-l10n-id="support-printing-troubleshoot"/> + <td> + <button id="support-printing-clear-settings-button" data-l10n-id="support-printing-clear-settings-button"/> + </td> + </tr> + </table> + + <h3 data-l10n-id="support-printing-modified-settings"/> + + <table class="prefs-table"> + <thead class="no-copy"> + <th class="name" data-l10n-id="support-printing-prefs-name"/> + + <th class="value" data-l10n-id="support-printing-prefs-value"/> + </thead> + + <tbody id="support-printing-prefs-tbody"> + </tbody> + </table> +#endif + + </div> + + </body> + +</html> diff --git a/toolkit/content/aboutTelemetry.css b/toolkit/content/aboutTelemetry.css new file mode 100644 index 0000000000..1022d3bf01 --- /dev/null +++ b/toolkit/content/aboutTelemetry.css @@ -0,0 +1,340 @@ +/* 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/. */ + +@import url("chrome://global/skin/in-content/common.css"); + +body { + display: flex; +} + +/* This is needed to make the sidebar not hide the last button but breaks printing by hiding overflowing content */ +@media not print { + html, body { + height: 100%; + } +} + +#categories { + padding-top: 0; + overflow-y: auto; + margin-bottom: 42px; + user-select: none; +} + +.main-content.search > section > *:not(.data) { + display: none; +} + +.main-content { + flex: 1; + line-height: 1.6; +} + +#home-section { + font-size: 18px; +} + +#category-raw { + background-color: var(--in-content-page-background); + position: absolute; + bottom: 0; + inset-inline-start: 0; +} + +.heading { + display: flex; + flex-direction: column; + font-size: 17px; + font-weight: 600; + pointer-events: none; + padding: 12px 8px; +} + +.header { + display: flex; +} + +.header select { + margin-inline-start: 4px; +} + +#sectionTitle { + flex-grow: 1; +} + +#sectionFilters { + display: flex; + align-items: center; + margin-inline-start: 5px; +} + +#stores { + padding-block: 5px; + padding-inline-start: 5px; +} + +#ping-type { + flex-grow: 1; + text-align: center; + pointer-events: all; + cursor: pointer; +} + +#older-ping, +#newer-ping, +#ping-date { + pointer-events: all; + user-select: none; + cursor: pointer; + text-align: center; +} + +.dropdown { + background-image: url(chrome://global/skin/icons/arrow-down.svg); + background-position: right 8px center; + background-repeat: no-repeat; + -moz-context-properties: fill; + fill: currentColor; +} + +.dropdown:dir(rtl) { + background-position-x: left 8px; +} + +#controls { + display: flex; + margin-top: 4px; + justify-content: space-between; +} + +.category:not(.has-data) { + display: none; +} + +.category { + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 42px; +} + +#categories > .category.category-no-icon { + margin-inline-start: 0; + margin-inline-end: 0; + width: auto; +} + +.category-name { + padding: 13px 0; +} + +.category-subsection { + color: var(--in-content-text-color); + padding: 8px 0; + padding-inline-start: 16px; + display: none; +} + +.category-subsection.selected { + color: inherit; +} + +.category-subsection::first-letter { + text-transform: uppercase; +} + +.category.selected > .category-subsection { + display: block; +} + +.category-name { + pointer-events: none; +} + +section:not(.active) { + display: none; +} + +#ping-explanation > span { + cursor: pointer; + border-bottom-width: 2px; + border-bottom-style: solid; +} + +#no-search-results { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + flex-direction: column; +} + +#no-search-results-text { + font-size: 17px; + margin-bottom: 2em; +} + +.hidden { + display: none !important; +} + +#ping-picker { + min-width: 300px; + position: fixed; + z-index: 2; + top: 32px; + border-radius: 2px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.25); + display: flex; + padding: 24px; + flex-direction: column; + background-color: var(--in-content-box-background); + border: 1px solid var(--in-content-box-border-color); + margin: 12px 0; + inset-inline-start: 12px; +} + +#ping-picker .title { + margin: 4px 0; +} + +#ping-source-picker { + margin-inline-start: 5px; + margin-bottom: 10px; +} + +#ping-source-archive-container.disabled { + opacity: 0.5; +} + +.stack-title { + font-size: medium; + font-weight: bold; + text-decoration: underline; +} + +#histograms { + overflow: hidden; +} + +.histogram { + float: inline-start; + white-space: nowrap; + padding: 10px; + position: relative; /* required for position:absolute of the contained .copy-node */ + padding-block: 12px; + padding-inline: 20px; + border: 1px solid var(--in-content-box-border-color); + background-color: var(--in-content-box-background); + border-radius: 2px; + margin-bottom: 24px; + margin-inline-end: 24px; + min-height: 17.5em; +} + +.histogram-title { + text-overflow: ellipsis; + width: 100%; + white-space: nowrap; + overflow: hidden; + font-size: 17px +} + +.histogram-stats { + font-size: 13px; +} + +.keyed-histogram { + white-space: nowrap; + position: relative; /* required for position:absolute of the contained .copy-node */ + overflow: hidden; + margin-bottom: 1em; +} + +.keyed-scalar, +.sub-section { + margin-bottom: 1em; +} + +.keyed-title { + text-overflow: ellipsis; + margin: 12px 0; + font-size: 17px; + white-space: nowrap; +} + +.bar { + font-size: 17px; + width: 2em; + margin: 2px; + text-align: center; + float: inline-start; + font-family: monospace; +} + +.bar-inner { + background-color: var(--in-content-accent-color); + border: 1px solid rgba(0,0,0,0.1); + border-radius: 2px; +} + +.bar:nth-child(even) .long-label { + margin-bottom: 1em; +} + +th, td, table { + text-align: start; + word-break: break-all; + border-collapse: collapse; +} + +table { + table-layout: fixed; + width: 100%; + font-size: 15px; +} + +td { + padding-bottom: 0.25em; + border-bottom: 1px solid var(--in-content-border-color); +} + +tr:not(:first-child):hover { + background-color: rgba(0, 0, 0, 0.05); +} + +th { + font-size: 13px; + white-space: nowrap; + padding: 0.5em 0; +} + +caption { + text-align: start; + font-size: 22px; + margin-block: 0.5em; + margin-inline: 0; +} + +.copy-node { + visibility: hidden; + position: absolute; + bottom: 1px; + inset-inline-end: 1px; +} + +.histogram:hover .copy-node { + visibility: visible; +} + +#raw-ping-data { + font-size: 15px; +} + +.clearfix { + clear: both; +} diff --git a/toolkit/content/aboutTelemetry.js b/toolkit/content/aboutTelemetry.js new file mode 100644 index 0000000000..b25c2687d9 --- /dev/null +++ b/toolkit/content/aboutTelemetry.js @@ -0,0 +1,2618 @@ +/* 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"; + +const { BrowserUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BrowserUtils.sys.mjs" +); +const { TelemetryTimestamps } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryTimestamps.sys.mjs" +); +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { TelemetryArchive } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryArchive.sys.mjs" +); +const { TelemetrySend } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetrySend.sys.mjs" +); + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const Telemetry = Services.telemetry; + +// Maximum height of a histogram bar (in em for html, in chars for text) +const MAX_BAR_HEIGHT = 8; +const MAX_BAR_CHARS = 25; +const PREF_TELEMETRY_SERVER_OWNER = "toolkit.telemetry.server_owner"; +const PREF_TELEMETRY_ENABLED = "toolkit.telemetry.enabled"; +const PREF_DEBUG_SLOW_SQL = "toolkit.telemetry.debugSlowSql"; +const PREF_SYMBOL_SERVER_URI = "profiler.symbolicationUrl"; +const DEFAULT_SYMBOL_SERVER_URI = + "https://symbolication.services.mozilla.com/symbolicate/v4"; +const PREF_FHR_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled"; + +// ms idle before applying the filter (allow uninterrupted typing) +const FILTER_IDLE_TIMEOUT = 500; + +const isWindows = Services.appinfo.OS == "WINNT"; +const EOL = isWindows ? "\r\n" : "\n"; + +// This is the ping object currently displayed in the page. +var gPingData = null; + +// Cached value of document's RTL mode +var documentRTLMode = ""; + +/** + * Helper function for determining whether the document direction is RTL. + * Caches result of check on first invocation. + */ +function isRTL() { + if (!documentRTLMode) { + documentRTLMode = window.getComputedStyle(document.body).direction; + } + return documentRTLMode == "rtl"; +} + +function isFlatArray(obj) { + if (!Array.isArray(obj)) { + return false; + } + return !obj.some(e => typeof e == "object"); +} + +/** + * This is a helper function for explodeObject. + */ +function flattenObject(obj, map, path, array) { + for (let k of Object.keys(obj)) { + let newPath = [...path, array ? "[" + k + "]" : k]; + let v = obj[k]; + if (!v || typeof v != "object") { + map.set(newPath.join("."), v); + } else if (isFlatArray(v)) { + map.set(newPath.join("."), "[" + v.join(", ") + "]"); + } else { + flattenObject(v, map, newPath, Array.isArray(v)); + } + } +} + +/** + * This turns a JSON object into a "flat" stringified form. + * + * For an object like {a: "1", b: {c: "2", d: "3"}} it returns a Map of the + * form Map(["a","1"], ["b.c", "2"], ["b.d", "3"]). + */ +function explodeObject(obj) { + let map = new Map(); + flattenObject(obj, map, []); + return map; +} + +function filterObject(obj, filterOut) { + let ret = {}; + for (let k of Object.keys(obj)) { + if (!filterOut.includes(k)) { + ret[k] = obj[k]; + } + } + return ret; +} + +/** + * This turns a JSON object into a "flat" stringified form, separated into top-level sections. + * + * For an object like: + * { + * a: {b: "1"}, + * c: {d: "2", e: {f: "3"}} + * } + * it returns a Map of the form: + * Map([ + * ["a", Map(["b","1"])], + * ["c", Map([["d", "2"], ["e.f", "3"]])] + * ]) + */ +function sectionalizeObject(obj) { + let map = new Map(); + for (let k of Object.keys(obj)) { + map.set(k, explodeObject(obj[k])); + } + return map; +} + +/** + * Obtain the main DOMWindow for the current context. + */ +function getMainWindow() { + return window.browsingContext.topChromeWindow; +} + +/** + * Obtain the DOMWindow that can open a preferences pane. + * + * This is essentially "get the browser chrome window" with the added check + * that the supposed browser chrome window is capable of opening a preferences + * pane. + * + * This may return null if we can't find the browser chrome window. + */ +function getMainWindowWithPreferencesPane() { + let mainWindow = getMainWindow(); + if (mainWindow && "openPreferences" in mainWindow) { + return mainWindow; + } + return null; +} + +/** + * Remove all child nodes of a document node. + */ +function removeAllChildNodes(node) { + while (node.hasChildNodes()) { + node.removeChild(node.lastChild); + } +} + +var Settings = { + attachObservers() { + let elements = document.getElementsByClassName("change-data-choices-link"); + for (let el of elements) { + el.parentElement.addEventListener("click", function (event) { + if (event.target.localName === "a") { + if (AppConstants.platform == "android") { + var { EventDispatcher } = ChromeUtils.importESModule( + "resource://gre/modules/Messaging.sys.mjs" + ); + EventDispatcher.instance.sendRequest({ + type: "Settings:Show", + resource: "preferences_privacy", + }); + } else { + // Show the data choices preferences on desktop. + let mainWindow = getMainWindowWithPreferencesPane(); + mainWindow.openPreferences("privacy-reports"); + } + } + }); + } + }, + + /** + * Updates the button & text at the top of the page to reflect Telemetry state. + */ + render() { + let settingsExplanation = document.getElementById("settings-explanation"); + let extendedEnabled = Services.telemetry.canRecordExtended; + + let channel = extendedEnabled ? "prerelease" : "release"; + let uploadcase = TelemetrySend.sendingEnabled() ? "enabled" : "disabled"; + + document.l10n.setAttributes( + settingsExplanation, + "about-telemetry-settings-explanation", + { channel, uploadcase } + ); + + this.attachObservers(); + }, +}; + +var PingPicker = { + viewCurrentPingData: null, + _archivedPings: null, + TYPE_ALL: "all", + + attachObservers() { + let pingSourceElements = document.getElementsByName("choose-ping-source"); + for (let el of pingSourceElements) { + el.addEventListener("change", () => this.onPingSourceChanged()); + } + + let displays = document.getElementsByName("choose-ping-display"); + for (let el of displays) { + el.addEventListener("change", () => this.onPingDisplayChanged()); + } + + document + .getElementById("show-subsession-data") + .addEventListener("change", () => { + this._updateCurrentPingData(); + }); + + document.getElementById("choose-ping-id").addEventListener("change", () => { + this._updateArchivedPingData(); + }); + document + .getElementById("choose-ping-type") + .addEventListener("change", () => { + this.filterDisplayedPings(); + }); + + document + .getElementById("newer-ping") + .addEventListener("click", () => this._movePingIndex(-1)); + document + .getElementById("older-ping") + .addEventListener("click", () => this._movePingIndex(1)); + + let pingPickerNeedHide = false; + let pingPicker = document.getElementById("ping-picker"); + pingPicker.addEventListener( + "mouseenter", + () => (pingPickerNeedHide = false) + ); + pingPicker.addEventListener( + "mouseleave", + () => (pingPickerNeedHide = true) + ); + document.addEventListener("click", ev => { + if (pingPickerNeedHide) { + pingPicker.classList.add("hidden"); + } + }); + document + .getElementById("stores") + .addEventListener("change", () => displayPingData(gPingData)); + Array.from(document.querySelectorAll(".change-ping")).forEach(el => { + el.addEventListener("click", event => { + if (!pingPicker.classList.contains("hidden")) { + pingPicker.classList.add("hidden"); + } else { + pingPicker.classList.remove("hidden"); + event.stopPropagation(); + } + }); + }); + }, + + onPingSourceChanged() { + this.update(); + }, + + onPingDisplayChanged() { + this.update(); + }, + + render() { + // Display the type and controls if the ping is not current + let pingDate = document.getElementById("ping-date"); + let pingType = document.getElementById("ping-type"); + let controls = document.getElementById("controls"); + let pingExplanation = document.getElementById("ping-explanation"); + + if (!this.viewCurrentPingData) { + let pingName = this._getSelectedPingName(); + // Change sidebar heading text. + pingDate.textContent = pingName; + pingDate.setAttribute("title", pingName); + let pingTypeText = this._getSelectedPingType(); + controls.classList.remove("hidden"); + pingType.textContent = pingTypeText; + document.l10n.setAttributes( + pingExplanation, + "about-telemetry-ping-details", + { timestamp: pingTypeText, name: pingName } + ); + } else { + // Change sidebar heading text. + controls.classList.add("hidden"); + document.l10n.setAttributes( + pingType, + "about-telemetry-current-data-sidebar" + ); + // Change home page text. + document.l10n.setAttributes( + pingExplanation, + "about-telemetry-data-details-current" + ); + } + + GenericSubsection.deleteAllSubSections(); + }, + + async update() { + let viewCurrent = document.getElementById("ping-source-current").checked; + let currentChanged = viewCurrent !== this.viewCurrentPingData; + this.viewCurrentPingData = viewCurrent; + + // If we have no archived pings, disable the ping archive selection. + // This can happen on new profiles or if the ping archive is disabled. + let archivedPingList = await TelemetryArchive.promiseArchivedPingList(); + let sourceArchived = document.getElementById("ping-source-archive"); + let sourceArchivedContainer = document.getElementById( + "ping-source-archive-container" + ); + let archivedDisabled = !archivedPingList.length; + sourceArchived.disabled = archivedDisabled; + sourceArchivedContainer.classList.toggle("disabled", archivedDisabled); + + if (currentChanged) { + if (this.viewCurrentPingData) { + document.getElementById("current-ping-picker").hidden = false; + document.getElementById("archived-ping-picker").hidden = true; + this._updateCurrentPingData(); + } else { + document.getElementById("current-ping-picker").hidden = true; + await this._updateArchivedPingList(archivedPingList); + document.getElementById("archived-ping-picker").hidden = false; + } + } + }, + + _updateCurrentPingData() { + const subsession = document.getElementById("show-subsession-data").checked; + let ping = TelemetryController.getCurrentPingData(subsession); + if (!ping) { + return; + } + + let stores = Telemetry.getAllStores(); + let getData = { + histograms: Telemetry.getSnapshotForHistograms, + keyedHistograms: Telemetry.getSnapshotForKeyedHistograms, + scalars: Telemetry.getSnapshotForScalars, + keyedScalars: Telemetry.getSnapshotForKeyedScalars, + }; + + let data = {}; + for (const [name, fn] of Object.entries(getData)) { + for (const store of stores) { + if (!data[store]) { + data[store] = {}; + } + let measurement = fn(store, /* clear */ false, /* filterTest */ true); + let processes = Object.keys(measurement); + + for (const process of processes) { + if (!data[store][process]) { + data[store][process] = {}; + } + + data[store][process][name] = measurement[process]; + } + } + } + ping.payload.stores = data; + + // Delete the unused data from the payload of the current ping. + // It's included in the above `stores` attribute. + for (const data of Object.values(ping.payload.processes)) { + delete data.scalars; + delete data.keyedScalars; + delete data.histograms; + delete data.keyedHistograms; + } + delete ping.payload.histograms; + delete ping.payload.keyedHistograms; + + // augment ping payload with event telemetry + let eventSnapshot = Telemetry.snapshotEvents( + Telemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + for (let process of Object.keys(eventSnapshot)) { + if (process in ping.payload.processes) { + ping.payload.processes[process].events = eventSnapshot[process].filter( + e => !e[1].startsWith("telemetry.test") + ); + } + } + + displayPingData(ping, true); + }, + + _updateArchivedPingData() { + let id = this._getSelectedPingId(); + let res = Promise.resolve(); + if (id) { + res = TelemetryArchive.promiseArchivedPingById(id).then(ping => + displayPingData(ping, true) + ); + } + return res; + }, + + async _updateArchivedPingList(pingList) { + // The archived ping list is sorted in ascending timestamp order, + // but descending is more practical for the operations we do here. + pingList.reverse(); + this._archivedPings = pingList; + // Render the archive data. + this._renderPingList(); + // Update the displayed ping. + await this._updateArchivedPingData(); + }, + + _renderPingList() { + let pingSelector = document.getElementById("choose-ping-id"); + Array.from(pingSelector.children).forEach(child => + removeAllChildNodes(child) + ); + + let pingTypes = new Set(); + pingTypes.add(this.TYPE_ALL); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + for (let p of this._archivedPings) { + pingTypes.add(p.type); + const pingDate = new Date(p.timestampCreated); + const datetimeText = new Services.intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "medium", + }).format(pingDate); + const pingName = `${datetimeText}, ${p.type}`; + + let option = document.createElement("option"); + let content = document.createTextNode(pingName); + option.appendChild(content); + option.setAttribute("value", p.id); + option.dataset.type = p.type; + option.dataset.date = datetimeText; + + pingDate.setHours(0, 0, 0, 0); + if (pingDate.getTime() === today.getTime()) { + pingSelector.children[0].appendChild(option); + } else if (pingDate.getTime() === yesterday.getTime()) { + pingSelector.children[1].appendChild(option); + } else { + pingSelector.children[2].appendChild(option); + } + } + this._renderPingTypes(pingTypes); + }, + + _renderPingTypes(pingTypes) { + let pingTypeSelector = document.getElementById("choose-ping-type"); + removeAllChildNodes(pingTypeSelector); + pingTypes.forEach(type => { + let option = document.createElement("option"); + option.appendChild(document.createTextNode(type)); + option.setAttribute("value", type); + pingTypeSelector.appendChild(option); + }); + }, + + _movePingIndex(offset) { + if (this.viewCurrentPingData) { + return; + } + let typeSelector = document.getElementById("choose-ping-type"); + let type = typeSelector.selectedOptions.item(0).value; + + let id = this._getSelectedPingId(); + let index = this._archivedPings.findIndex(p => p.id == id); + let newIndex = Math.min( + Math.max(0, index + offset), + this._archivedPings.length - 1 + ); + + let pingList; + if (offset > 0) { + pingList = this._archivedPings.slice(newIndex); + } else { + pingList = this._archivedPings.slice(0, newIndex); + pingList.reverse(); + } + + let ping = pingList.find(p => { + return type == this.TYPE_ALL || p.type == type; + }); + + if (ping) { + this.selectPing(ping); + this._updateArchivedPingData(); + } + }, + + selectPing(ping) { + let pingSelector = document.getElementById("choose-ping-id"); + // Use some() to break if we find the ping. + Array.from(pingSelector.children).some(group => { + return Array.from(group.children).some(option => { + if (option.value == ping.id) { + option.selected = true; + return true; + } + return false; + }); + }); + }, + + filterDisplayedPings() { + let pingSelector = document.getElementById("choose-ping-id"); + let typeSelector = document.getElementById("choose-ping-type"); + let type = typeSelector.selectedOptions.item(0).value; + let first = true; + Array.from(pingSelector.children).forEach(group => { + Array.from(group.children).forEach(option => { + if (first && option.dataset.type == type) { + option.selected = true; + first = false; + } + option.hidden = type != this.TYPE_ALL && option.dataset.type != type; + // Arrow keys should only iterate over visible options + option.disabled = option.hidden; + }); + }); + this._updateArchivedPingData(); + }, + + _getSelectedPingName() { + let pingSelector = document.getElementById("choose-ping-id"); + let selected = pingSelector.selectedOptions.item(0); + return selected.dataset.date; + }, + + _getSelectedPingType() { + let pingSelector = document.getElementById("choose-ping-id"); + let selected = pingSelector.selectedOptions.item(0); + return selected.dataset.type; + }, + + _getSelectedPingId() { + let pingSelector = document.getElementById("choose-ping-id"); + let selected = pingSelector.selectedOptions.item(0); + return selected.getAttribute("value"); + }, + + _showRawPingData() { + show(document.getElementById("category-raw")); + }, + + _showStructuredPingData() { + show(document.getElementById("category-home")); + }, +}; + +var GeneralData = { + /** + * Renders the general data + */ + render(aPing) { + setHasData("general-data-section", true); + let generalDataSection = document.getElementById("general-data"); + removeAllChildNodes(generalDataSection); + + const headings = [ + "about-telemetry-names-header", + "about-telemetry-values-header", + ]; + + // The payload & environment parts are handled by other renderers. + let ignoreSections = ["payload", "environment"]; + let data = explodeObject(filterObject(aPing, ignoreSections)); + + const table = GenericTable.render(data, headings); + generalDataSection.appendChild(table); + }, +}; + +var EnvironmentData = { + /** + * Renders the environment data + */ + render(ping) { + let dataDiv = document.getElementById("environment-data"); + removeAllChildNodes(dataDiv); + const hasData = !!ping.environment; + setHasData("environment-data-section", hasData); + if (!hasData) { + return; + } + + let ignore = ["addons"]; + let env = filterObject(ping.environment, ignore); + let sections = sectionalizeObject(env); + GenericSubsection.render(sections, dataDiv, "environment-data-section"); + + // We use specialized rendering here to make the addon and plugin listings + // more readable. + this.createAddonSection(dataDiv, ping); + }, + + renderAddonsObject(addonObj, addonSection, sectionTitle) { + let table = document.createElement("table"); + table.setAttribute("id", sectionTitle); + this.appendAddonSubsectionTitle(sectionTitle, table); + + for (let id of Object.keys(addonObj)) { + let addon = addonObj[id]; + this.appendHeadingName(table, addon.name || id); + this.appendAddonID(table, id); + let data = explodeObject(addon); + + for (let [key, value] of data) { + this.appendRow(table, key, value); + } + } + + addonSection.appendChild(table); + }, + + renderKeyValueObject(addonObj, addonSection, sectionTitle) { + let data = explodeObject(addonObj); + let table = GenericTable.render(data); + table.setAttribute("class", sectionTitle); + this.appendAddonSubsectionTitle(sectionTitle, table); + addonSection.appendChild(table); + }, + + appendAddonID(table, addonID) { + this.appendRow(table, "id", addonID); + }, + + appendHeadingName(table, name) { + let headings = document.createElement("tr"); + this.appendColumn(headings, "th", name); + headings.cells[0].colSpan = 2; + table.appendChild(headings); + }, + + appendAddonSubsectionTitle(section, table) { + let caption = document.createElement("caption"); + caption.appendChild(document.createTextNode(section)); + table.appendChild(caption); + }, + + createAddonSection(dataDiv, ping) { + if (!ping || !("environment" in ping) || !("addons" in ping.environment)) { + return; + } + let addonSection = document.createElement("div"); + addonSection.setAttribute("class", "subsection-data subdata"); + let addons = ping.environment.addons; + this.renderAddonsObject(addons.activeAddons, addonSection, "activeAddons"); + this.renderKeyValueObject(addons.theme, addonSection, "theme"); + this.renderAddonsObject( + addons.activeGMPlugins, + addonSection, + "activeGMPlugins" + ); + + let hasAddonData = !!Object.keys(ping.environment.addons).length; + let s = GenericSubsection.renderSubsectionHeader( + "addons", + hasAddonData, + "environment-data-section" + ); + s.appendChild(addonSection); + dataDiv.appendChild(s); + }, + + appendRow(table, id, value) { + let row = document.createElement("tr"); + row.id = id; + this.appendColumn(row, "td", id); + this.appendColumn(row, "td", value); + table.appendChild(row); + }, + /** + * Helper function for appending a column to the data table. + * + * @param aRowElement Parent row element + * @param aColType Column's tag name + * @param aColText Column contents + */ + appendColumn(aRowElement, aColType, aColText) { + let colElement = document.createElement(aColType); + let colTextElement = document.createTextNode(aColText); + colElement.appendChild(colTextElement); + aRowElement.appendChild(colElement); + }, +}; + +var SlowSQL = { + /** + * Render slow SQL statistics + */ + render: function SlowSQL_render(aPing) { + // We can add the debug SQL data to the current ping later. + // However, we need to be careful to never send that debug data + // out due to privacy concerns. + // We want to show the actual ping data for archived pings, + // so skip this there. + + let debugSlowSql = + PingPicker.viewCurrentPingData && + Preferences.get(PREF_DEBUG_SLOW_SQL, false); + let slowSql = debugSlowSql ? Telemetry.debugSlowSQL : aPing.payload.slowSQL; + if (!slowSql) { + setHasData("slow-sql-section", false); + return; + } + + let { mainThread, otherThreads } = debugSlowSql + ? Telemetry.debugSlowSQL + : aPing.payload.slowSQL; + + let mainThreadCount = Object.keys(mainThread).length; + let otherThreadCount = Object.keys(otherThreads).length; + if (mainThreadCount == 0 && otherThreadCount == 0) { + setHasData("slow-sql-section", false); + return; + } + + setHasData("slow-sql-section", true); + if (debugSlowSql) { + document.getElementById("sql-warning").hidden = false; + } + + let slowSqlDiv = document.getElementById("slow-sql-tables"); + removeAllChildNodes(slowSqlDiv); + + // Main thread + if (mainThreadCount > 0) { + let table = document.createElement("table"); + this.renderTableHeader(table, "main"); + this.renderTable(table, mainThread); + slowSqlDiv.appendChild(table); + } + + // Other threads + if (otherThreadCount > 0) { + let table = document.createElement("table"); + this.renderTableHeader(table, "other"); + this.renderTable(table, otherThreads); + slowSqlDiv.appendChild(table); + } + }, + + /** + * Creates a header row for a Slow SQL table + * Tabs & newlines added to cells to make it easier to copy-paste. + * + * @param aTable Parent table element + * @param aTitle Table's title + */ + renderTableHeader: function SlowSQL_renderTableHeader(aTable, threadType) { + let caption = document.createElement("caption"); + if (threadType == "main") { + document.l10n.setAttributes(caption, "about-telemetry-slow-sql-main"); + } + + if (threadType == "other") { + document.l10n.setAttributes(caption, "about-telemetry-slow-sql-other"); + } + aTable.appendChild(caption); + + let headings = document.createElement("tr"); + document.l10n.setAttributes( + this.appendColumn(headings, "th"), + "about-telemetry-slow-sql-hits" + ); + document.l10n.setAttributes( + this.appendColumn(headings, "th"), + "about-telemetry-slow-sql-average" + ); + document.l10n.setAttributes( + this.appendColumn(headings, "th"), + "about-telemetry-slow-sql-statement" + ); + aTable.appendChild(headings); + }, + + /** + * Fills out the table body + * Tabs & newlines added to cells to make it easier to copy-paste. + * + * @param aTable Parent table element + * @param aSql SQL stats object + */ + renderTable: function SlowSQL_renderTable(aTable, aSql) { + for (let [sql, [hitCount, totalTime]] of Object.entries(aSql)) { + let averageTime = totalTime / hitCount; + + let sqlRow = document.createElement("tr"); + + this.appendColumn(sqlRow, "td", hitCount + "\t"); + this.appendColumn(sqlRow, "td", averageTime.toFixed(0) + "\t"); + this.appendColumn(sqlRow, "td", sql + "\n"); + + aTable.appendChild(sqlRow); + } + }, + + /** + * Helper function for appending a column to a Slow SQL table. + * + * @param aRowElement Parent row element + * @param aColType Column's tag name + * @param aColText Column contents + */ + appendColumn: function SlowSQL_appendColumn( + aRowElement, + aColType, + aColText = "" + ) { + let colElement = document.createElement(aColType); + if (aColText) { + let colTextElement = document.createTextNode(aColText); + colElement.appendChild(colTextElement); + } + aRowElement.appendChild(colElement); + return colElement; + }, +}; + +var StackRenderer = { + /** + * Outputs the memory map associated with this hang report + * + * @param aDiv Output div + */ + renderMemoryMap: async function StackRenderer_renderMemoryMap( + aDiv, + memoryMap + ) { + let memoryMapTitleElement = document.createElement("span"); + document.l10n.setAttributes( + memoryMapTitleElement, + "about-telemetry-memory-map-title" + ); + aDiv.appendChild(memoryMapTitleElement); + aDiv.appendChild(document.createElement("br")); + + for (let currentModule of memoryMap) { + aDiv.appendChild(document.createTextNode(currentModule.join(" "))); + aDiv.appendChild(document.createElement("br")); + } + + aDiv.appendChild(document.createElement("br")); + }, + + /** + * Outputs the raw PCs from the hang's stack + * + * @param aDiv Output div + * @param aStack Array of PCs from the hang stack + */ + renderStack: function StackRenderer_renderStack(aDiv, aStack) { + let stackTitleElement = document.createElement("span"); + document.l10n.setAttributes( + stackTitleElement, + "about-telemetry-stack-title" + ); + aDiv.appendChild(stackTitleElement); + let stackText = " " + aStack.join(" "); + aDiv.appendChild(document.createTextNode(stackText)); + + aDiv.appendChild(document.createElement("br")); + aDiv.appendChild(document.createElement("br")); + }, + renderStacks: function StackRenderer_renderStacks( + aPrefix, + aStacks, + aMemoryMap, + aRenderHeader + ) { + let div = document.getElementById(aPrefix); + removeAllChildNodes(div); + + let fetchE = document.getElementById(aPrefix + "-fetch-symbols"); + if (fetchE) { + fetchE.hidden = false; + } + let hideE = document.getElementById(aPrefix + "-hide-symbols"); + if (hideE) { + hideE.hidden = true; + } + + if (!aStacks.length) { + return; + } + + setHasData(aPrefix + "-section", true); + + this.renderMemoryMap(div, aMemoryMap); + + for (let i = 0; i < aStacks.length; ++i) { + let stack = aStacks[i]; + aRenderHeader(i); + this.renderStack(div, stack); + } + }, + + /** + * Renders the title of the stack: e.g. "Late Write #1" or + * "Hang Report #1 (6 seconds)". + * + * @param aDivId The id of the div to append the header to. + * @param aL10nId The l10n id of the message to use for the title. + * @param aL10nArgs The l10n args for the provided message id. + */ + renderHeader: function StackRenderer_renderHeader( + aDivId, + aL10nId, + aL10nArgs + ) { + let div = document.getElementById(aDivId); + + let titleElement = document.createElement("span"); + titleElement.className = "stack-title"; + + document.l10n.setAttributes(titleElement, aL10nId, aL10nArgs); + + div.appendChild(titleElement); + div.appendChild(document.createElement("br")); + }, +}; + +var RawPayloadData = { + /** + * Renders the raw pyaload. + */ + render(aPing) { + setHasData("raw-payload-section", true); + let pre = document.getElementById("raw-payload-data"); + pre.textContent = JSON.stringify(aPing.payload, null, 2); + }, + + attachObservers() { + document + .getElementById("payload-json-viewer") + .addEventListener("click", e => { + openJsonInFirefoxJsonViewer(JSON.stringify(gPingData.payload, null, 2)); + }); + }, +}; + +function SymbolicationRequest( + aPrefix, + aRenderHeader, + aMemoryMap, + aStacks, + aDurations = null +) { + this.prefix = aPrefix; + this.renderHeader = aRenderHeader; + this.memoryMap = aMemoryMap; + this.stacks = aStacks; + this.durations = aDurations; +} +/** + * A callback for onreadystatechange. It replaces the numeric stack with + * the symbolicated one returned by the symbolication server. + */ +SymbolicationRequest.prototype.handleSymbolResponse = + async function SymbolicationRequest_handleSymbolResponse() { + if (this.symbolRequest.readyState != 4) { + return; + } + + let fetchElement = document.getElementById(this.prefix + "-fetch-symbols"); + fetchElement.hidden = true; + let hideElement = document.getElementById(this.prefix + "-hide-symbols"); + hideElement.hidden = false; + let div = document.getElementById(this.prefix); + removeAllChildNodes(div); + let errorMessage = await document.l10n.formatValue( + "about-telemetry-error-fetching-symbols" + ); + + if (this.symbolRequest.status != 200) { + div.appendChild(document.createTextNode(errorMessage)); + return; + } + + let jsonResponse = {}; + try { + jsonResponse = JSON.parse(this.symbolRequest.responseText); + } catch (e) { + div.appendChild(document.createTextNode(errorMessage)); + return; + } + + for (let i = 0; i < jsonResponse.length; ++i) { + let stack = jsonResponse[i]; + this.renderHeader(i, this.durations); + + for (let symbol of stack) { + div.appendChild(document.createTextNode(symbol)); + div.appendChild(document.createElement("br")); + } + div.appendChild(document.createElement("br")); + } + }; +/** + * Send a request to the symbolication server to symbolicate this stack. + */ +SymbolicationRequest.prototype.fetchSymbols = + function SymbolicationRequest_fetchSymbols() { + let symbolServerURI = Preferences.get( + PREF_SYMBOL_SERVER_URI, + DEFAULT_SYMBOL_SERVER_URI + ); + let request = { + memoryMap: this.memoryMap, + stacks: this.stacks, + version: 3, + }; + let requestJSON = JSON.stringify(request); + + this.symbolRequest = new XMLHttpRequest(); + this.symbolRequest.open("POST", symbolServerURI, true); + this.symbolRequest.setRequestHeader("Content-type", "application/json"); + this.symbolRequest.setRequestHeader("Content-length", requestJSON.length); + this.symbolRequest.setRequestHeader("Connection", "close"); + this.symbolRequest.onreadystatechange = + this.handleSymbolResponse.bind(this); + this.symbolRequest.send(requestJSON); + }; + +var Histogram = { + /** + * Renders a single Telemetry histogram + * + * @param aParent Parent element + * @param aName Histogram name + * @param aHgram Histogram information + * @param aOptions Object with render options + * * exponential: bars follow logarithmic scale + */ + render: function Histogram_render(aParent, aName, aHgram, aOptions) { + let options = aOptions || {}; + let hgram = this.processHistogram(aHgram, aName); + + let outerDiv = document.createElement("div"); + outerDiv.className = "histogram"; + outerDiv.id = aName; + + let divTitle = document.createElement("div"); + divTitle.classList.add("histogram-title"); + divTitle.appendChild(document.createTextNode(aName)); + outerDiv.appendChild(divTitle); + + let divStats = document.createElement("div"); + divStats.classList.add("histogram-stats"); + + let histogramStatsArgs = { + sampleCount: hgram.sample_count, + prettyAverage: hgram.pretty_average, + sum: hgram.sum, + }; + + document.l10n.setAttributes( + divStats, + "about-telemetry-histogram-stats", + histogramStatsArgs + ); + + if (isRTL()) { + hgram.values.reverse(); + } + + let textData = this.renderValues(outerDiv, hgram, options); + + // The 'Copy' button contains the textual data, copied to clipboard on click + let copyButton = document.createElement("button"); + copyButton.className = "copy-node"; + document.l10n.setAttributes(copyButton, "about-telemetry-histogram-copy"); + + copyButton.addEventListener("click", async function () { + let divStatsString = await document.l10n.formatValue( + "about-telemetry-histogram-stats", + histogramStatsArgs + ); + copyButton.histogramText = + aName + EOL + divStatsString + EOL + EOL + textData; + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(this.histogramText); + }); + outerDiv.appendChild(copyButton); + + aParent.appendChild(outerDiv); + return outerDiv; + }, + + processHistogram(aHgram, aName) { + const values = Object.keys(aHgram.values).map(k => aHgram.values[k]); + if (!values.length) { + // If we have no values collected for this histogram, just return + // zero values so we still render it. + return { + values: [], + pretty_average: 0, + max: 0, + sample_count: 0, + sum: 0, + }; + } + + const sample_count = values.reduceRight((a, b) => a + b); + const average = Math.round((aHgram.sum * 10) / sample_count) / 10; + const max_value = Math.max(...values); + + const labelledValues = Object.keys(aHgram.values).map(k => [ + Number(k), + aHgram.values[k], + ]); + + let result = { + values: labelledValues, + pretty_average: average, + max: max_value, + sample_count, + sum: aHgram.sum, + }; + + return result; + }, + + /** + * Return a non-negative, logarithmic representation of a non-negative number. + * e.g. 0 => 0, 1 => 1, 10 => 2, 100 => 3 + * + * @param aNumber Non-negative number + */ + getLogValue(aNumber) { + return Math.max(0, Math.log10(aNumber) + 1); + }, + + /** + * Create histogram HTML bars, also returns a textual representation + * Both aMaxValue and aSumValues must be positive. + * Values are assumed to use 0 as baseline. + * + * @param aDiv Outer parent div + * @param aHgram The histogram data + * @param aOptions Object with render options (@see #render) + */ + renderValues: function Histogram_renderValues(aDiv, aHgram, aOptions) { + let text = ""; + // If the last label is not the longest string, alignment will break a little + let labelPadTo = 0; + if (aHgram.values.length) { + labelPadTo = String(aHgram.values[aHgram.values.length - 1][0]).length; + } + let maxBarValue = aOptions.exponential + ? this.getLogValue(aHgram.max) + : aHgram.max; + + for (let [label, value] of aHgram.values) { + label = String(label); + let barValue = aOptions.exponential ? this.getLogValue(value) : value; + + // Create a text representation: <right-aligned-label> |<bar-of-#><value> <percentage> + text += + EOL + + " ".repeat(Math.max(0, labelPadTo - label.length)) + + label + // Right-aligned label + " |" + + "#".repeat(Math.round((MAX_BAR_CHARS * barValue) / maxBarValue)) + // Bar + " " + + value + // Value + " " + + Math.round((100 * value) / aHgram.sample_count) + + "%"; // Percentage + + // Construct the HTML labels + bars + let belowEm = + Math.round(MAX_BAR_HEIGHT * (barValue / maxBarValue) * 10) / 10; + let aboveEm = MAX_BAR_HEIGHT - belowEm; + + let barDiv = document.createElement("div"); + barDiv.className = "bar"; + barDiv.style.paddingTop = aboveEm + "em"; + + // Add value label or an nbsp if no value + barDiv.appendChild(document.createTextNode(value ? value : "\u00A0")); + + // Create the blue bar + let bar = document.createElement("div"); + bar.className = "bar-inner"; + bar.style.height = belowEm + "em"; + barDiv.appendChild(bar); + + // Add a special class to move the text down to prevent text overlap + if (label.length > 3) { + bar.classList.add("long-label"); + } + // Add bucket label + barDiv.appendChild(document.createTextNode(label)); + + aDiv.appendChild(barDiv); + } + + return text.substr(EOL.length); // Trim the EOL before the first line + }, +}; + +var Search = { + HASH_SEARCH: "search=", + + // A list of ids of sections that do not support search. + blacklist: ["late-writes-section", "raw-payload-section"], + + // Pass if: all non-empty array items match (case-sensitive) + isPassText(subject, filter) { + for (let item of filter) { + if (item.length && !subject.includes(item)) { + return false; // mismatch and not a spurious space + } + } + return true; + }, + + isPassRegex(subject, filter) { + return filter.test(subject); + }, + + chooseFilter(filterText) { + let filter = filterText.toString(); + // Setup normalized filter string (trimmed, lower cased and split on spaces if not RegEx) + let isPassFunc; // filter function, set once, then applied to all elements + filter = filter.trim(); + if (filter[0] != "/") { + // Plain text: case insensitive, AND if multi-string + isPassFunc = this.isPassText; + filter = filter.toLowerCase().split(" "); + } else { + isPassFunc = this.isPassRegex; + var r = filter.match(/^\/(.*)\/(i?)$/); + try { + filter = RegExp(r[1], r[2]); + } catch (e) { + // Incomplete or bad RegExp - always no match + isPassFunc = function () { + return false; + }; + } + } + return [isPassFunc, filter]; + }, + + filterTextRows(table, filterText) { + let [isPassFunc, filter] = this.chooseFilter(filterText); + let allElementHidden = true; + + let needLowerCase = isPassFunc === this.isPassText; + let elements = table.rows; + for (let element of elements) { + if (element.firstChild.nodeName == "th") { + continue; + } + for (let cell of element.children) { + let subject = needLowerCase + ? cell.textContent.toLowerCase() + : cell.textContent; + element.hidden = !isPassFunc(subject, filter); + if (!element.hidden) { + if (allElementHidden) { + allElementHidden = false; + } + // Don't need to check the rest of this row. + break; + } + } + } + // Unhide the first row: + if (!allElementHidden) { + table.rows[0].hidden = false; + } + return allElementHidden; + }, + + filterElements(elements, filterText) { + let [isPassFunc, filter] = this.chooseFilter(filterText); + let allElementHidden = true; + + let needLowerCase = isPassFunc === this.isPassText; + for (let element of elements) { + let subject = needLowerCase ? element.id.toLowerCase() : element.id; + element.hidden = !isPassFunc(subject, filter); + if (allElementHidden && !element.hidden) { + allElementHidden = false; + } + } + return allElementHidden; + }, + + filterKeyedElements(keyedElements, filterText) { + let [isPassFunc, filter] = this.chooseFilter(filterText); + let allElementsHidden = true; + + let needLowerCase = isPassFunc === this.isPassText; + keyedElements.forEach(keyedElement => { + let subject = needLowerCase + ? keyedElement.key.id.toLowerCase() + : keyedElement.key.id; + if (!isPassFunc(subject, filter)) { + // If the keyedHistogram's name is not matched + let allKeyedElementsHidden = true; + for (let element of keyedElement.datas) { + let subject = needLowerCase ? element.id.toLowerCase() : element.id; + let match = isPassFunc(subject, filter); + element.hidden = !match; + if (match) { + allKeyedElementsHidden = false; + } + } + if (allElementsHidden && !allKeyedElementsHidden) { + allElementsHidden = false; + } + keyedElement.key.hidden = allKeyedElementsHidden; + } else { + // If the keyedHistogram's name is matched + allElementsHidden = false; + keyedElement.key.hidden = false; + for (let element of keyedElement.datas) { + element.hidden = false; + } + } + }); + return allElementsHidden; + }, + + searchHandler(e) { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + } + this.idleTimeout = setTimeout( + () => Search.search(e.target.value), + FILTER_IDLE_TIMEOUT + ); + }, + + search(text, sectionParam = null) { + let section = sectionParam; + if (!section) { + let sectionId = document + .querySelector(".category.selected") + .getAttribute("value"); + section = document.getElementById(sectionId); + } + if (Search.blacklist.includes(section.id)) { + return false; + } + let noSearchResults = true; + // In the home section, we search all other sections: + if (section.id === "home-section") { + return this.homeSearch(text); + } + + if (section.id === "histograms-section") { + let histograms = section.getElementsByClassName("histogram"); + noSearchResults = this.filterElements(histograms, text); + } else if (section.id === "keyed-histograms-section") { + let keyedElements = []; + let keyedHistograms = section.getElementsByClassName("keyed-histogram"); + for (let key of keyedHistograms) { + let datas = key.getElementsByClassName("histogram"); + keyedElements.push({ key, datas }); + } + noSearchResults = this.filterKeyedElements(keyedElements, text); + } else if (section.id === "keyed-scalars-section") { + let keyedElements = []; + let keyedScalars = section.getElementsByClassName("keyed-scalar"); + for (let key of keyedScalars) { + let datas = key.querySelector("table").rows; + keyedElements.push({ key, datas }); + } + noSearchResults = this.filterKeyedElements(keyedElements, text); + } else if (section.matches(".text-search")) { + let tables = section.querySelectorAll("table"); + for (let table of tables) { + // If we unhide anything, flip noSearchResults to + // false so we don't show the "no results" bits. + if (!this.filterTextRows(table, text)) { + noSearchResults = false; + } + } + } else if (section.querySelector(".sub-section")) { + let keyedSubSections = []; + let subsections = section.querySelectorAll(".sub-section"); + for (let section of subsections) { + let datas = section.querySelector("table").rows; + keyedSubSections.push({ key: section, datas }); + } + noSearchResults = this.filterKeyedElements(keyedSubSections, text); + } else { + let tables = section.querySelectorAll("table"); + for (let table of tables) { + noSearchResults = this.filterElements(table.rows, text); + if (table.caption) { + table.caption.hidden = noSearchResults; + } + } + } + + changeUrlSearch(text); + + if (!sectionParam) { + // If we are not searching in all section. + this.updateNoResults(text, noSearchResults); + } + return noSearchResults; + }, + + updateNoResults(text, noSearchResults) { + document + .getElementById("no-search-results") + .classList.toggle("hidden", !noSearchResults); + if (noSearchResults) { + let section = document.querySelector(".category.selected > span"); + let searchResultsText = document.getElementById("no-search-results-text"); + if (section.parentElement.id === "category-home") { + document.l10n.setAttributes( + searchResultsText, + "about-telemetry-no-search-results-all", + { searchTerms: text } + ); + } else { + let sectionName = section.textContent.trim(); + text === "" + ? document.l10n.setAttributes( + searchResultsText, + "about-telemetry-no-data-to-display", + { sectionName } + ) + : document.l10n.setAttributes( + searchResultsText, + "about-telemetry-no-search-results", + { sectionName, currentSearchText: text } + ); + } + } + }, + + resetHome() { + document.getElementById("main").classList.remove("search"); + document.getElementById("no-search-results").classList.add("hidden"); + adjustHeaderState(); + Array.from(document.querySelectorAll("section")).forEach(section => { + section.classList.toggle("active", section.id == "home-section"); + }); + }, + + homeSearch(text) { + changeUrlSearch(text); + removeSearchSectionTitles(); + if (text === "") { + this.resetHome(); + return; + } + document.getElementById("main").classList.add("search"); + adjustHeaderState(text); + let noSearchResults = true; + Array.from(document.querySelectorAll("section")).forEach(section => { + if (section.id == "home-section" || section.id == "raw-payload-section") { + section.classList.remove("active"); + return; + } + section.classList.add("active"); + let sectionHidden = this.search(text, section); + if (!sectionHidden) { + let sectionTitle = document.querySelector( + `.category[value="${section.id}"] .category-name` + ).textContent; + let sectionDataDiv = document.querySelector( + `#${section.id}.has-data.active .data` + ); + let titleDiv = document.createElement("h1"); + titleDiv.classList.add("data", "search-section-title"); + titleDiv.textContent = sectionTitle; + section.insertBefore(titleDiv, sectionDataDiv); + noSearchResults = false; + } else { + // Hide all subsections if the section is hidden + let subsections = section.querySelectorAll(".sub-section"); + for (let subsection of subsections) { + subsection.hidden = true; + } + } + }); + this.updateNoResults(text, noSearchResults); + }, +}; + +/* + * Helper function to render JS objects with white space between top level elements + * so that they look better in the browser + * @param aObject JavaScript object or array to render + * @return String + */ +function RenderObject(aObject) { + let output = ""; + if (Array.isArray(aObject)) { + if (!aObject.length) { + return "[]"; + } + output = "[" + JSON.stringify(aObject[0]); + for (let i = 1; i < aObject.length; i++) { + output += ", " + JSON.stringify(aObject[i]); + } + return output + "]"; + } + let keys = Object.keys(aObject); + if (!keys.length) { + return "{}"; + } + output = '{"' + keys[0] + '":\u00A0' + JSON.stringify(aObject[keys[0]]); + for (let i = 1; i < keys.length; i++) { + output += ', "' + keys[i] + '":\u00A0' + JSON.stringify(aObject[keys[i]]); + } + return output + "}"; +} + +var GenericSubsection = { + addSubSectionToSidebar(id, title) { + let category = document.querySelector("#categories > [value=" + id + "]"); + category.classList.add("has-subsection"); + let subCategory = document.createElement("div"); + subCategory.classList.add("category-subsection"); + subCategory.setAttribute("value", id + "-" + title); + subCategory.addEventListener("click", ev => { + let section = ev.target; + showSubSection(section); + }); + subCategory.appendChild(document.createTextNode(title)); + category.appendChild(subCategory); + }, + + render(data, dataDiv, sectionID) { + for (let [title, sectionData] of data) { + let hasData = sectionData.size > 0; + let s = this.renderSubsectionHeader(title, hasData, sectionID); + s.appendChild(this.renderSubsectionData(title, sectionData)); + dataDiv.appendChild(s); + } + }, + + renderSubsectionHeader(title, hasData, sectionID) { + this.addSubSectionToSidebar(sectionID, title); + let section = document.createElement("div"); + section.setAttribute("id", sectionID + "-" + title); + section.classList.add("sub-section"); + if (hasData) { + section.classList.add("has-subdata"); + } + return section; + }, + + renderSubsectionData(title, data) { + // Create data container + let dataDiv = document.createElement("div"); + dataDiv.setAttribute("class", "subsection-data subdata"); + // Instanciate the data + let table = GenericTable.render(data); + let caption = document.createElement("caption"); + caption.textContent = title; + table.appendChild(caption); + dataDiv.appendChild(table); + + return dataDiv; + }, + + deleteAllSubSections() { + let subsections = document.querySelectorAll(".category-subsection"); + subsections.forEach(el => { + el.parentElement.removeChild(el); + }); + }, +}; + +var GenericTable = { + // Returns a table with key and value headers + defaultHeadings() { + return ["about-telemetry-keys-header", "about-telemetry-values-header"]; + }, + + /** + * Returns a n-column table. + * @param rows An array of arrays, each containing data to render + * for one row. + * @param headings The column header strings. + */ + render(rows, headings = this.defaultHeadings()) { + let table = document.createElement("table"); + this.renderHeader(table, headings); + this.renderBody(table, rows); + return table; + }, + + /** + * Create the table header. + * Tabs & newlines added to cells to make it easier to copy-paste. + * + * @param table Table element + * @param headings Array of column header strings. + */ + renderHeader(table, headings) { + let headerRow = document.createElement("tr"); + table.appendChild(headerRow); + + for (let i = 0; i < headings.length; ++i) { + let column = document.createElement("th"); + document.l10n.setAttributes(column, headings[i]); + headerRow.appendChild(column); + } + }, + + /** + * Create the table body + * Tabs & newlines added to cells to make it easier to copy-paste. + * + * @param table Table element + * @param rows An array of arrays, each containing data to render + * for one row. + */ + renderBody(table, rows) { + for (let row of rows) { + row = row.map(value => { + // use .valueOf() to unbox Number, String, etc. objects + if ( + value && + typeof value == "object" && + typeof value.valueOf() == "object" + ) { + return RenderObject(value); + } + return value; + }); + + let newRow = document.createElement("tr"); + newRow.id = row[0]; + table.appendChild(newRow); + + for (let i = 0; i < row.length; ++i) { + let suffix = i == row.length - 1 ? "\n" : "\t"; + let field = document.createElement("td"); + field.appendChild(document.createTextNode(row[i] + suffix)); + newRow.appendChild(field); + } + } + }, +}; + +var KeyedHistogram = { + render(parent, id, keyedHistogram) { + let outerDiv = document.createElement("div"); + outerDiv.className = "keyed-histogram"; + outerDiv.id = id; + + let divTitle = document.createElement("div"); + divTitle.classList.add("keyed-title"); + divTitle.appendChild(document.createTextNode(id)); + outerDiv.appendChild(divTitle); + + for (let [name, hgram] of Object.entries(keyedHistogram)) { + Histogram.render(outerDiv, name, hgram); + } + + parent.appendChild(outerDiv); + return outerDiv; + }, +}; + +var AddonDetails = { + /** + * Render the addon details section as a series of headers followed by key/value tables + * @param aPing A ping object to render the data from. + */ + render(aPing) { + let addonSection = document.getElementById("addon-details"); + removeAllChildNodes(addonSection); + let addonDetails = aPing.payload.addonDetails; + const hasData = addonDetails && !!Object.keys(addonDetails).length; + setHasData("addon-details-section", hasData); + if (!hasData) { + return; + } + + for (let provider in addonDetails) { + let providerSection = document.createElement("caption"); + document.l10n.setAttributes( + providerSection, + "about-telemetry-addon-provider", + { addonProvider: provider } + ); + let headingStrings = [ + "about-telemetry-addon-table-id", + "about-telemetry-addon-table-details", + ]; + let table = GenericTable.render( + explodeObject(addonDetails[provider]), + headingStrings + ); + table.appendChild(providerSection); + addonSection.appendChild(table); + } + }, +}; + +class Section { + static renderContent(data, process, div, section) { + if (data && Object.keys(data).length) { + let s = GenericSubsection.renderSubsectionHeader(process, true, section); + let heading = document.createElement("h2"); + document.l10n.setAttributes(heading, "about-telemetry-process", { + process, + }); + s.appendChild(heading); + + this.renderData(data, s); + + div.appendChild(s); + let separator = document.createElement("div"); + separator.classList.add("clearfix"); + div.appendChild(separator); + } + } + + /** + * Make parent process the first one, content process the second + * then sort processes alphabetically + */ + static processesComparator(a, b) { + if (a === "parent" || (a === "content" && b !== "parent")) { + return -1; + } else if (b === "parent" || b === "content") { + return 1; + } else if (a < b) { + return -1; + } else if (a > b) { + return 1; + } + return 0; + } + + /** + * Render sections + */ + static renderSection(divName, section, aPayload) { + let div = document.getElementById(divName); + removeAllChildNodes(div); + + let data = {}; + let hasData = false; + let selectedStore = getSelectedStore(); + + let payload = aPayload.stores; + + let isCurrentPayload = !!payload; + + // Sort processes + let sortedProcesses = isCurrentPayload + ? Object.keys(payload[selectedStore]).sort(this.processesComparator) + : Object.keys(aPayload.processes).sort(this.processesComparator); + + // Render content by process + for (const process of sortedProcesses) { + data = isCurrentPayload + ? this.dataFiltering(payload, selectedStore, process) + : this.archivePingDataFiltering(aPayload, process); + hasData = hasData || !ObjectUtils.isEmpty(data); + this.renderContent(data, process, div, section, this.renderData); + } + setHasData(section, hasData); + } +} + +class Scalars extends Section { + /** + * Return data from the current ping + */ + static dataFiltering(payload, selectedStore, process) { + return payload[selectedStore][process].scalars; + } + + /** + * Return data from an archived ping + */ + static archivePingDataFiltering(payload, process) { + return payload.processes[process].scalars; + } + + static renderData(data, div) { + const scalarsHeadings = [ + "about-telemetry-names-header", + "about-telemetry-values-header", + ]; + let scalarsTable = GenericTable.render( + explodeObject(data), + scalarsHeadings + ); + div.appendChild(scalarsTable); + } + + /** + * Render the scalar data - if present - from the payload in a simple key-value table. + * @param aPayload A payload object to render the data from. + */ + static render(aPayload) { + const divName = "scalars"; + const section = "scalars-section"; + this.renderSection(divName, section, aPayload); + } +} + +class KeyedScalars extends Section { + /** + * Return data from the current ping + */ + static dataFiltering(payload, selectedStore, process) { + return payload[selectedStore][process].keyedScalars; + } + + /** + * Return data from an archived ping + */ + static archivePingDataFiltering(payload, process) { + return payload.processes[process].keyedScalars; + } + + static renderData(data, div) { + const scalarsHeadings = [ + "about-telemetry-names-header", + "about-telemetry-values-header", + ]; + for (let scalarId in data) { + // Add the name of the scalar. + let container = document.createElement("div"); + container.classList.add("keyed-scalar"); + container.id = scalarId; + let scalarNameSection = document.createElement("p"); + scalarNameSection.classList.add("keyed-title"); + scalarNameSection.appendChild(document.createTextNode(scalarId)); + container.appendChild(scalarNameSection); + // Populate the section with the key-value pairs from the scalar. + const table = GenericTable.render( + explodeObject(data[scalarId]), + scalarsHeadings + ); + container.appendChild(table); + div.appendChild(container); + } + } + + /** + * Render the keyed scalar data - if present - from the payload in a simple key-value table. + * @param aPayload A payload object to render the data from. + */ + static render(aPayload) { + const divName = "keyed-scalars"; + const section = "keyed-scalars-section"; + this.renderSection(divName, section, aPayload); + } +} + +var Events = { + /** + * Render the event data - if present - from the payload in a simple table. + * @param aPayload A payload object to render the data from. + */ + render(aPayload) { + let eventsDiv = document.getElementById("events"); + removeAllChildNodes(eventsDiv); + const headings = [ + "about-telemetry-time-stamp-header", + "about-telemetry-category-header", + "about-telemetry-method-header", + "about-telemetry-object-header", + "about-telemetry-values-header", + "about-telemetry-extra-header", + ]; + let payload = aPayload.processes; + let hasData = false; + if (payload) { + for (const process of Object.keys(aPayload.processes)) { + let data = aPayload.processes[process].events; + if (data && Object.keys(data).length) { + hasData = true; + let s = GenericSubsection.renderSubsectionHeader( + process, + true, + "events-section" + ); + let heading = document.createElement("h2"); + heading.textContent = process; + s.appendChild(heading); + const table = GenericTable.render(data, headings); + s.appendChild(table); + eventsDiv.appendChild(s); + let separator = document.createElement("div"); + separator.classList.add("clearfix"); + eventsDiv.appendChild(separator); + } + } + } else { + // handle archived ping + for (const process of Object.keys(aPayload.events)) { + let data = process; + if (data && Object.keys(data).length) { + hasData = true; + let s = GenericSubsection.renderSubsectionHeader( + process, + true, + "events-section" + ); + let heading = document.createElement("h2"); + heading.textContent = process; + s.appendChild(heading); + const table = GenericTable.render(data, headings); + eventsDiv.appendChild(table); + let separator = document.createElement("div"); + separator.classList.add("clearfix"); + eventsDiv.appendChild(separator); + } + } + } + setHasData("events-section", hasData); + }, +}; + +/** + * Helper function for showing either the toggle element or "No data collected" message for a section + * + * @param aSectionID ID of the section element that needs to be changed + * @param aHasData true (default) indicates that toggle should be displayed + */ +function setHasData(aSectionID, aHasData) { + let sectionElement = document.getElementById(aSectionID); + sectionElement.classList[aHasData ? "add" : "remove"]("has-data"); + + // Display or Hide the section in the sidebar + let sectionCategory = document.querySelector( + ".category[value=" + aSectionID + "]" + ); + sectionCategory.classList[aHasData ? "add" : "remove"]("has-data"); +} + +/** + * Sets l10n attributes based on the Telemetry Server Owner pref. + */ +function setupServerOwnerBranding() { + let serverOwner = Preferences.get(PREF_TELEMETRY_SERVER_OWNER, "Mozilla"); + const elements = [ + [document.getElementById("page-subtitle"), "about-telemetry-page-subtitle"], + ]; + for (const [elt, l10nName] of elements) { + document.l10n.setAttributes(elt, l10nName, { + telemetryServerOwner: serverOwner, + }); + } +} + +/** + * Display the store selector if we are on one + * of the whitelisted sections + */ +function displayStoresSelector(selectedSection) { + let whitelist = [ + "scalars-section", + "keyed-scalars-section", + "histograms-section", + "keyed-histograms-section", + ]; + let stores = document.getElementById("stores"); + stores.hidden = !whitelist.includes(selectedSection); + let storesLabel = document.getElementById("storesLabel"); + storesLabel.hidden = !whitelist.includes(selectedSection); +} + +function refreshSearch() { + removeSearchSectionTitles(); + let selectedSection = document + .querySelector(".category.selected") + .getAttribute("value"); + let search = document.getElementById("search"); + if (!Search.blacklist.includes(selectedSection)) { + Search.search(search.value); + } +} + +function adjustSearchState() { + removeSearchSectionTitles(); + let selectedSection = document + .querySelector(".category.selected") + .getAttribute("value"); + let search = document.getElementById("search"); + search.value = ""; + search.hidden = Search.blacklist.includes(selectedSection); + document.getElementById("no-search-results").classList.add("hidden"); + Search.search(""); // reinitialize search state. +} + +function removeSearchSectionTitles() { + for (let sectionTitleDiv of Array.from( + document.getElementsByClassName("search-section-title") + )) { + sectionTitleDiv.remove(); + } +} + +function adjustSection() { + let selectedCategory = document.querySelector(".category.selected"); + if (!selectedCategory.classList.contains("has-data")) { + PingPicker._showStructuredPingData(); + } +} + +function adjustHeaderState(title = null) { + let selected = document.querySelector(".category.selected .category-name"); + let selectedTitle = selected.textContent.trim(); + let sectionTitle = document.getElementById("sectionTitle"); + if (title !== null) { + document.l10n.setAttributes( + sectionTitle, + "about-telemetry-results-for-search", + { searchTerms: title } + ); + } else { + sectionTitle.textContent = selectedTitle; + } + let search = document.getElementById("search"); + if (selected.parentElement.id === "category-home") { + document.l10n.setAttributes( + search, + "about-telemetry-filter-all-placeholder" + ); + } else { + document.l10n.setAttributes(search, "about-telemetry-filter-placeholder", { + selectedTitle, + }); + } +} + +/** + * Change the url according to the current section displayed + * e.g about:telemetry#general-data + */ +function changeUrlPath(selectedSection, subSection) { + if (subSection) { + let hash = window.location.hash.split("_")[0] + "_" + selectedSection; + window.location.hash = hash; + } else { + window.location.hash = selectedSection.replace("-section", "-tab"); + } +} + +/** + * Change the url according to the current search text + */ +function changeUrlSearch(searchText) { + let currentHash = window.location.hash; + let hashWithoutSearch = currentHash.split(Search.HASH_SEARCH)[0]; + let hash = ""; + + if (!currentHash && !searchText) { + return; + } + if (!currentHash.includes(Search.HASH_SEARCH) && hashWithoutSearch) { + hashWithoutSearch += "_"; + } + if (searchText) { + hash = + hashWithoutSearch + Search.HASH_SEARCH + searchText.replace(/ /g, "+"); + } else if (hashWithoutSearch) { + hash = hashWithoutSearch.slice(0, hashWithoutSearch.length - 1); + } + + window.location.hash = hash; +} + +/** + * Change the section displayed + */ +function show(selected) { + let selectedValue = selected.getAttribute("value"); + if (selectedValue === "raw-json-viewer") { + openJsonInFirefoxJsonViewer(JSON.stringify(gPingData, null, 2)); + return; + } + + let selected_section = document.getElementById(selectedValue); + let subsections = selected_section.querySelectorAll(".sub-section"); + if (selected.classList.contains("has-subsection")) { + for (let subsection of selected.children) { + subsection.classList.remove("selected"); + } + } + if (subsections) { + for (let subsection of subsections) { + subsection.hidden = false; + } + } + + let current_button = document.querySelector(".category.selected"); + if (current_button == selected) { + return; + } + current_button.classList.remove("selected"); + selected.classList.add("selected"); + + document.querySelectorAll("section").forEach(section => { + section.classList.remove("active"); + }); + selected_section.classList.add("active"); + + adjustHeaderState(); + displayStoresSelector(selectedValue); + adjustSearchState(); + changeUrlPath(selectedValue); +} + +function showSubSection(selected) { + if (!selected) { + return; + } + let current_selection = document.querySelector( + ".category-subsection.selected" + ); + if (current_selection) { + current_selection.classList.remove("selected"); + } + selected.classList.add("selected"); + + let section = document.getElementById(selected.getAttribute("value")); + section.parentElement.childNodes.forEach(element => { + element.hidden = true; + }); + section.hidden = false; + + let title = + selected.parentElement.querySelector(".category-name").textContent; + let subsection = selected.textContent; + document.getElementById("sectionTitle").textContent = + title + " - " + subsection; + changeUrlPath(subsection, true); +} + +/** + * Initializes load/unload, pref change and mouse-click listeners + */ +function setupListeners() { + Settings.attachObservers(); + PingPicker.attachObservers(); + RawPayloadData.attachObservers(); + + let menu = document.getElementById("categories"); + menu.addEventListener("click", e => { + if (e.target && e.target.parentNode == menu) { + show(e.target); + } + }); + + let search = document.getElementById("search"); + search.addEventListener("input", Search.searchHandler); + + document + .getElementById("late-writes-fetch-symbols") + .addEventListener("click", function () { + if (!gPingData) { + return; + } + + let lateWrites = gPingData.payload.lateWrites; + let req = new SymbolicationRequest( + "late-writes", + LateWritesSingleton.renderHeader, + lateWrites.memoryMap, + lateWrites.stacks + ); + req.fetchSymbols(); + }); + + document + .getElementById("late-writes-hide-symbols") + .addEventListener("click", function () { + if (!gPingData) { + return; + } + + LateWritesSingleton.renderLateWrites(gPingData.payload.lateWrites); + }); +} + +// Restores the sections states +function urlSectionRestore(hash) { + if (hash) { + let section = hash.replace("-tab", "-section"); + let subsection = section.split("_")[1]; + section = section.split("_")[0]; + let category = document.querySelector(".category[value=" + section + "]"); + if (category) { + show(category); + if (subsection) { + let selector = + ".category-subsection[value=" + section + "-" + subsection + "]"; + let subcategory = document.querySelector(selector); + showSubSection(subcategory); + } + } + } +} + +// Restore sections states and search terms +function urlStateRestore() { + let hash = window.location.hash; + let searchQuery = ""; + if (hash) { + hash = hash.slice(1); + if (hash.includes(Search.HASH_SEARCH)) { + searchQuery = hash.split(Search.HASH_SEARCH)[1].replace(/[+]/g, " "); + hash = hash.split(Search.HASH_SEARCH)[0]; + } + urlSectionRestore(hash); + } + if (searchQuery) { + let search = document.getElementById("search"); + search.value = searchQuery; + } +} + +function openJsonInFirefoxJsonViewer(json) { + json = unescape(encodeURIComponent(json)); + try { + window.open("data:application/json;base64," + btoa(json)); + } catch (e) { + show(document.querySelector(".category[value=raw-payload-section]")); + } +} + +function onLoad() { + window.removeEventListener("load", onLoad); + // Set the text in the page header and elsewhere that needs the server owner. + setupServerOwnerBranding(); + + // Set up event listeners + setupListeners(); + + // Render settings. + Settings.render(); + + adjustHeaderState(); + + urlStateRestore(); + + // Update ping data when async Telemetry init is finished. + Telemetry.asyncFetchTelemetryData(async () => { + await PingPicker.update(); + }); +} + +var LateWritesSingleton = { + renderHeader: function LateWritesSingleton_renderHeader(aIndex) { + StackRenderer.renderHeader( + "late-writes", + "about-telemetry-late-writes-title", + { lateWriteCount: aIndex + 1 } + ); + }, + + renderLateWrites: function LateWritesSingleton_renderLateWrites(lateWrites) { + let hasData = !!( + lateWrites && + lateWrites.stacks && + lateWrites.stacks.length + ); + setHasData("late-writes-section", hasData); + if (!hasData) { + return; + } + + let stacks = lateWrites.stacks; + let memoryMap = lateWrites.memoryMap; + StackRenderer.renderStacks( + "late-writes", + stacks, + memoryMap, + LateWritesSingleton.renderHeader + ); + }, +}; + +class HistogramSection extends Section { + /** + * Return data from the current ping + */ + static dataFiltering(payload, selectedStore, process) { + return payload[selectedStore][process].histograms; + } + + /** + * Return data from an archived ping + */ + static archivePingDataFiltering(payload, process) { + if (process === "parent") { + return payload.histograms; + } + return payload.processes[process].histograms; + } + + static renderData(data, div) { + for (let [hName, hgram] of Object.entries(data)) { + Histogram.render(div, hName, hgram, { unpacked: true }); + } + } + + static render(aPayload) { + const divName = "histograms"; + const section = "histograms-section"; + this.renderSection(divName, section, aPayload); + } +} + +class KeyedHistogramSection extends Section { + /** + * Return data from the current ping + */ + static dataFiltering(payload, selectedStore, process) { + return payload[selectedStore][process].keyedHistograms; + } + + /** + * Return data from an archived ping + */ + static archivePingDataFiltering(payload, process) { + if (process === "parent") { + return payload.keyedHistograms; + } + return payload.processes[process].keyedHistograms; + } + + static renderData(data, div) { + for (let [id, keyed] of Object.entries(data)) { + KeyedHistogram.render(div, id, keyed, { unpacked: true }); + } + } + + static render(aPayload) { + const divName = "keyed-histograms"; + const section = "keyed-histograms-section"; + this.renderSection(divName, section, aPayload); + } +} + +var SessionInformation = { + render(aPayload) { + let infoSection = document.getElementById("session-info"); + removeAllChildNodes(infoSection); + + let hasData = !!Object.keys(aPayload.info).length; + setHasData("session-info-section", hasData); + + if (hasData) { + const table = GenericTable.render(explodeObject(aPayload.info)); + infoSection.appendChild(table); + } + }, +}; + +var SimpleMeasurements = { + render(aPayload) { + let simpleSection = document.getElementById("simple-measurements"); + removeAllChildNodes(simpleSection); + + let simpleMeasurements = this.sortStartupMilestones( + aPayload.simpleMeasurements + ); + let hasData = !!Object.keys(simpleMeasurements).length; + setHasData("simple-measurements-section", hasData); + + if (hasData) { + const table = GenericTable.render(explodeObject(simpleMeasurements)); + simpleSection.appendChild(table); + } + }, + + /** + * Helper function for sorting the startup milestones in the Simple Measurements + * section into temporal order. + * + * @param aSimpleMeasurements Telemetry ping's "Simple Measurements" data + * @return Sorted measurements + */ + sortStartupMilestones(aSimpleMeasurements) { + const telemetryTimestamps = TelemetryTimestamps.get(); + let startupEvents = Services.startup.getStartupInfo(); + delete startupEvents.process; + + function keyIsMilestone(k) { + return k in startupEvents || k in telemetryTimestamps; + } + + let sortedKeys = Object.keys(aSimpleMeasurements); + + // Sort the measurements, with startup milestones at the front + ordered by time + sortedKeys.sort(function keyCompare(keyA, keyB) { + let isKeyAMilestone = keyIsMilestone(keyA); + let isKeyBMilestone = keyIsMilestone(keyB); + + // First order by startup vs non-startup measurement + if (isKeyAMilestone && !isKeyBMilestone) { + return -1; + } + if (!isKeyAMilestone && isKeyBMilestone) { + return 1; + } + // Don't change order of non-startup measurements + if (!isKeyAMilestone && !isKeyBMilestone) { + return 0; + } + + // If both keys are startup measurements, order them by value + return aSimpleMeasurements[keyA] - aSimpleMeasurements[keyB]; + }); + + // Insert measurements into a result object in sort-order + let result = {}; + for (let key of sortedKeys) { + result[key] = aSimpleMeasurements[key]; + } + + return result; + }, +}; + +/** + * Render stores options + */ +function renderStoreList(payload) { + let storeSelect = document.getElementById("stores"); + let storesLabel = document.getElementById("storesLabel"); + removeAllChildNodes(storeSelect); + + if (!("stores" in payload)) { + storeSelect.classList.add("hidden"); + storesLabel.classList.add("hidden"); + return; + } + + storeSelect.classList.remove("hidden"); + storesLabel.classList.remove("hidden"); + storeSelect.disabled = false; + + for (let store of Object.keys(payload.stores)) { + let option = document.createElement("option"); + option.appendChild(document.createTextNode(store)); + option.setAttribute("value", store); + // Select main store by default + if (store === "main") { + option.selected = true; + } + storeSelect.appendChild(option); + } +} + +/** + * Return the selected store + */ +function getSelectedStore() { + let storeSelect = document.getElementById("stores"); + let storeSelectedOption = storeSelect.selectedOptions.item(0); + let selectedStore = + storeSelectedOption !== null + ? storeSelectedOption.getAttribute("value") + : undefined; + return selectedStore; +} + +function togglePingSections(isMainPing) { + // We always show the sections that are "common" to all pings. + let commonSections = new Set([ + "heading", + "home-section", + "general-data-section", + "environment-data-section", + "raw-json-viewer", + ]); + + let elements = document.querySelectorAll(".category"); + for (let section of elements) { + if (commonSections.has(section.getAttribute("value"))) { + continue; + } + // Only show the raw payload for non main ping. + if (section.getAttribute("value") == "raw-payload-section") { + section.classList.toggle("has-data", !isMainPing); + } else { + section.classList.toggle("has-data", isMainPing); + } + } +} + +function displayPingData(ping, updatePayloadList = false) { + gPingData = ping; + try { + PingPicker.render(); + displayRichPingData(ping, updatePayloadList); + adjustSection(); + refreshSearch(); + } catch (err) { + console.log(err); + PingPicker._showRawPingData(); + } +} + +function displayRichPingData(ping, updatePayloadList) { + // Update the payload list and store lists + if (updatePayloadList) { + renderStoreList(ping.payload); + } + + // Show general data. + GeneralData.render(ping); + + // Show environment data. + EnvironmentData.render(ping); + + RawPayloadData.render(ping); + + // We have special rendering code for the payloads from "main" and "event" pings. + // For any other pings we just render the raw JSON payload. + let isMainPing = ping.type == "main" || ping.type == "saved-session"; + let isEventPing = ping.type == "event"; + togglePingSections(isMainPing); + + if (isEventPing) { + // Copy the payload, so we don't modify the raw representation + // Ensure we always have at least the parent process. + let payload = { processes: { parent: {} } }; + for (let process of Object.keys(ping.payload.events)) { + payload.processes[process] = { + events: ping.payload.events[process], + }; + } + + // We transformed the actual payload, let's reload the store list if necessary. + if (updatePayloadList) { + renderStoreList(payload); + } + + // Show event data. + Events.render(payload); + return; + } + + if (!isMainPing) { + return; + } + + // Show slow SQL stats + SlowSQL.render(ping); + + // Render Addon details. + AddonDetails.render(ping); + + let payload = ping.payload; + // Show basic session info gathered + SessionInformation.render(payload); + + // Show scalar data. + Scalars.render(payload); + KeyedScalars.render(payload); + + // Show histogram data + HistogramSection.render(payload); + + // Show keyed histogram data + KeyedHistogramSection.render(payload); + + // Show event data. + Events.render(payload); + + LateWritesSingleton.renderLateWrites(payload.lateWrites); + + // Show simple measurements + SimpleMeasurements.render(payload); +} + +window.addEventListener("load", onLoad); diff --git a/toolkit/content/aboutTelemetry.xhtml b/toolkit/content/aboutTelemetry.xhtml new file mode 100644 index 0000000000..aae16e4dd0 --- /dev/null +++ b/toolkit/content/aboutTelemetry.xhtml @@ -0,0 +1,347 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<!-- 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/. --> + +<!DOCTYPE html> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome: resource:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="about-telemetry-page-title"></title> + <link + rel="stylesheet" + href="chrome://global/content/aboutTelemetry.css" + type="text/css" + /> + + <script src="chrome://global/content/aboutTelemetry.js" /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/about/aboutTelemetry.ftl" /> + </head> + + <body id="body"> + <div id="categories"> + <div class="heading"> + <span id="ping-type" class="change-ping dropdown"></span> + <div id="controls" hidden="true"> + <span + id="older-ping" + data-l10n-id="about-telemetry-previous-ping" + ></span> + <span id="ping-date" class="change-ping"></span> + <span id="newer-ping" data-l10n-id="about-telemetry-next-ping"></span> + </div> + </div> + <div + id="category-home" + class="category category-no-icon has-data selected" + value="home-section" + > + <span + class="category-name" + data-l10n-id="about-telemetry-home-section" + ></span> + </div> + <div class="category category-no-icon" value="general-data-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-general-data-section" + ></span> + </div> + <div class="category category-no-icon" value="environment-data-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-environment-data-section" + ></span> + </div> + <div class="category category-no-icon" value="session-info-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-session-info-section" + ></span> + </div> + <div class="category category-no-icon" value="scalars-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-scalar-section" + ></span> + </div> + <div class="category category-no-icon" value="keyed-scalars-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-keyed-scalar-section" + ></span> + </div> + <div class="category category-no-icon" value="histograms-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-histograms-section" + ></span> + </div> + <div class="category category-no-icon" value="keyed-histograms-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-keyed-histogram-section" + ></span> + </div> + <div class="category category-no-icon" value="events-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-events-section" + ></span> + </div> + <div + class="category category-no-icon" + value="simple-measurements-section" + > + <span + class="category-name" + data-l10n-id="about-telemetry-simple-measurements-section" + ></span> + </div> + <div class="category category-no-icon" value="slow-sql-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-slow-sql-section" + ></span> + </div> + <div class="category category-no-icon" value="addon-details-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-addon-details-section" + ></span> + </div> + <div class="category category-no-icon" value="late-writes-section"> + <span + class="category-name" + data-l10n-id="about-telemetry-late-writes-section" + ></span> + </div> + <div + class="category category-no-icon has-data" + value="raw-payload-section" + > + <span + class="category-name" + data-l10n-id="about-telemetry-raw-payload-section" + ></span> + </div> + <div + id="category-raw" + class="category category-no-icon has-data" + value="raw-json-viewer" + > + <span class="category-name" data-l10n-id="about-telemetry-raw"></span> + </div> + </div> + + <div id="main" class="main-content"> + <div id="ping-picker" class="hidden"> + <div id="ping-source-picker"> + <h4 + class="title" + data-l10n-id="about-telemetry-ping-data-source" + ></h4> + <label class="radio-container-with-text"> + <input + type="radio" + id="ping-source-current" + name="choose-ping-source" + value="current" + checked="checked" + /> + <span data-l10n-id="about-telemetry-show-current-data"></span> + </label> + <label + id="ping-source-archive-container" + class="radio-container-with-text" + > + <input + type="radio" + id="ping-source-archive" + name="choose-ping-source" + value="archive" + /> + <span data-l10n-id="about-telemetry-show-archived-ping-data"></span> + </label> + </div> + <label id="current-ping-picker" class="toggle-container-with-text"> + <input id="show-subsession-data" type="checkbox" checked="checked" /> + <span data-l10n-id="about-telemetry-show-subsession-data"></span> + </label> + <div id="archived-ping-picker"> + <h4 class="title" data-l10n-id="about-telemetry-choose-ping"></h4> + <div> + <h4 + class="title" + data-l10n-id="about-telemetry-archive-ping-type" + ></h4> + <select id="choose-ping-type"></select> + </div> + <div> + <h4 + class="title" + data-l10n-id="about-telemetry-archive-ping-header" + ></h4> + <select id="choose-ping-id"> + <optgroup + data-l10n-id="about-telemetry-option-group-today" + ></optgroup> + <optgroup + data-l10n-id="about-telemetry-option-group-yesterday" + ></optgroup> + <optgroup + data-l10n-id="about-telemetry-option-group-older" + ></optgroup> + </select> + </div> + </div> + </div> + + <div class="header"> + <h1 + id="sectionTitle" + class="header-name" + data-l10n-id="about-telemetry-page-title" + /> + <div id="sectionFilters"> + <label + id="storesLabel" + for="stores" + hidden="true" + data-l10n-id="about-telemetry-current-store" + /> + <select id="stores" hidden="true"></select> + <input type="text" id="search" placeholder="" /> + </div> + </div> + + <div id="no-search-results" hidden="true" class="hidden"> + <span id="no-search-results-text"></span> + </div> + + <section id="home-section" class="active"> + <p id="page-subtitle"></p> + <p id="settings-explanation"> + <a + id="uploadLink" + data-l10n-name="upload-link" + class="change-data-choices-link" + href="#" + ></a> + </p> + <p id="ping-explanation"> + <a + id="pingLink" + data-l10n-name="ping-link" + href="https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/concepts/pings.html" + ></a> + </p> + <p data-l10n-id="about-telemetry-more-information"></p> + <ul> + <li data-l10n-id="about-telemetry-firefox-data-doc"> + <a + id="dataDocLink" + data-l10n-name="data-doc-link" + href="https://docs.telemetry.mozilla.org/" + ></a> + </li> + <li data-l10n-id="about-telemetry-telemetry-client-doc"> + <a + id="clientDocLink" + data-l10n-name="client-doc-link" + href="https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/index.html" + ></a> + </li> + <li data-l10n-id="about-telemetry-telemetry-dashboard"> + <a + id="dashboardLink" + data-l10n-name="dashboard-link" + href="https://telemetry.mozilla.org/" + ></a> + </li> + <li data-l10n-id="about-telemetry-telemetry-probe-dictionary"> + <a + id="probeDictionaryLink" + data-l10n-name="probe-dictionary-link" + href="https://probes.telemetry.mozilla.org/" + ></a> + </li> + </ul> + </section> + + <section id="raw-payload-section"> + <button + id="payload-json-viewer" + data-l10n-id="about-telemetry-show-in-Firefox-json-viewer" + ></button> + <pre id="raw-payload-data"></pre> + </section> + + <section id="general-data-section"> + <div id="general-data" class="data"></div> + </section> + + <section id="environment-data-section"> + <div id="environment-data" class="data"></div> + </section> + + <section id="session-info-section"> + <div id="session-info" class="data"></div> + </section> + + <section id="scalars-section"> + <div id="scalars" class="data"></div> + </section> + + <section id="keyed-scalars-section"> + <div id="keyed-scalars" class="data"></div> + </section> + + <section id="histograms-section"> + <div id="histograms" class="data"></div> + </section> + + <section id="keyed-histograms-section"> + <div id="keyed-histograms" class="data"></div> + </section> + + <section id="events-section" class="text-search"> + <div id="events" class="data"></div> + </section> + + <section id="simple-measurements-section"> + <div id="simple-measurements" class="data"></div> + </section> + + <section id="slow-sql-section"> + <p id="sql-warning" data-l10n-id="about-telemetry-full-sql-warning"></p> + <div id="slow-sql-tables" class="data"></div> + </section> + + <section id="late-writes-section"> + <a + id="late-writes-fetch-symbols" + href="" + data-l10n-id="about-telemetry-fetch-stack-symbols" + ></a> + <a + id="late-writes-hide-symbols" + href="" + data-l10n-id="about-telemetry-hide-stack-symbols" + ></a> + <div id="late-writes" class="data"></div> + </section> + + <section id="addon-details-section"> + <div id="addon-details" class="data"></div> + </section> + </div> + </body> +</html> diff --git a/toolkit/content/aboutUrlClassifier.css b/toolkit/content/aboutUrlClassifier.css new file mode 100644 index 0000000000..d9fbe62a7b --- /dev/null +++ b/toolkit/content/aboutUrlClassifier.css @@ -0,0 +1,30 @@ +/* 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/. */ + +@import url("chrome://global/skin/in-content/info-pages.css"); + +.major-section { + margin-block: 2em 1em; + font-size: large; + text-align: start; + font-weight: bold; +} + +#provider-table > tbody > tr > td:last-child, +#cache-table > tbody > tr > td:last-child { + text-align: center; +} + +#debug-table, #cache-table { + margin-top: 20px; +} + +.options > .toggle-container-with-text { + display: inline-flex; +} + +button { + margin-inline: 0 8px; + padding: 3px; +} diff --git a/toolkit/content/aboutUrlClassifier.js b/toolkit/content/aboutUrlClassifier.js new file mode 100644 index 0000000000..14b34c7e89 --- /dev/null +++ b/toolkit/content/aboutUrlClassifier.js @@ -0,0 +1,719 @@ +/* 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/. */ + +const UPDATE_BEGIN = "safebrowsing-update-begin"; +const UPDATE_FINISH = "safebrowsing-update-finished"; +const JSLOG_PREF = "browser.safebrowsing.debug"; + +window.onunload = function () { + Search.uninit(); + Provider.uninit(); + Cache.uninit(); + Debug.uninit(); +}; + +window.onload = function () { + Search.init(); + Provider.init(); + Cache.init(); + Debug.init(); +}; + +/* + * Search + */ +var Search = { + init() { + let classifier = Cc["@mozilla.org/url-classifier/dbservice;1"].getService( + Ci.nsIURIClassifier + ); + let featureNames = classifier.getFeatureNames(); + + let fragment = document.createDocumentFragment(); + featureNames.forEach(featureName => { + let container = document.createElement("label"); + container.className = "toggle-container-with-text"; + fragment.appendChild(container); + + let checkbox = document.createElement("input"); + checkbox.id = "feature_" + featureName; + checkbox.type = "checkbox"; + checkbox.checked = true; + container.appendChild(checkbox); + + let span = document.createElement("span"); + container.appendChild(span); + + let text = document.createTextNode(featureName); + span.appendChild(text); + }); + + let list = document.getElementById("search-features"); + list.appendChild(fragment); + + let btn = document.getElementById("search-button"); + btn.addEventListener("click", this.search); + + this.hideError(); + this.hideResults(); + }, + + uninit() { + let list = document.getElementById("search-features"); + while (list.firstChild) { + list.firstChild.remove(); + } + + let btn = document.getElementById("search-button"); + btn.removeEventListener("click", this.search); + }, + + search() { + Search.hideError(); + Search.hideResults(); + + let input = document.getElementById("search-input").value; + + let uri; + try { + uri = Services.io.newURI(input); + if (!uri) { + Search.reportError("url-classifier-search-error-invalid-url"); + return; + } + } catch (ex) { + Search.reportError("url-classifier-search-error-invalid-url"); + return; + } + + let classifier = Cc["@mozilla.org/url-classifier/dbservice;1"].getService( + Ci.nsIURIClassifier + ); + + let featureNames = classifier.getFeatureNames(); + let features = []; + featureNames.forEach(featureName => { + if (document.getElementById("feature_" + featureName).checked) { + let feature = classifier.getFeatureByName(featureName); + if (feature) { + features.push(feature); + } + } + }); + + if (!features.length) { + Search.reportError("url-classifier-search-error-no-features"); + return; + } + + let listType = + document.getElementById("search-listtype").value == 0 + ? Ci.nsIUrlClassifierFeature.blocklist + : Ci.nsIUrlClassifierFeature.entitylist; + classifier.asyncClassifyLocalWithFeatures(uri, features, listType, list => + Search.showResults(list) + ); + + Search.hideError(); + }, + + hideError() { + let errorMessage = document.getElementById("search-error-message"); + errorMessage.style.display = "none"; + }, + + reportError(msg) { + let errorMessage = document.getElementById("search-error-message"); + document.l10n.setAttributes(errorMessage, msg); + errorMessage.style.display = ""; + }, + + hideResults() { + let resultTitle = document.getElementById("result-title"); + resultTitle.style.display = "none"; + + let resultTable = document.getElementById("result-table"); + resultTable.style.display = "none"; + }, + + showResults(results) { + let fragment = document.createDocumentFragment(); + results.forEach(result => { + let tr = document.createElement("tr"); + fragment.appendChild(tr); + + let th = document.createElement("th"); + tr.appendChild(th); + th.appendChild(document.createTextNode(result.feature.name)); + + let td = document.createElement("td"); + tr.appendChild(td); + + let featureName = document.createElement("div"); + document.l10n.setAttributes( + featureName, + "url-classifier-search-result-uri", + { uri: result.uri.spec } + ); + td.appendChild(featureName); + + let list = document.createElement("div"); + document.l10n.setAttributes(list, "url-classifier-search-result-list", { + list: result.list, + }); + td.appendChild(list); + }); + + let resultTable = document.getElementById("result-table"); + while (resultTable.firstChild) { + resultTable.firstChild.remove(); + } + + resultTable.appendChild(fragment); + resultTable.style.display = ""; + + let resultTitle = document.getElementById("result-title"); + resultTitle.style.display = ""; + }, +}; + +/* + * Provider + */ +var Provider = { + providers: null, + + updatingProvider: "", + + init() { + this.providers = new Set(); + let branch = Services.prefs.getBranch("browser.safebrowsing.provider."); + let children = branch.getChildList(""); + for (let child of children) { + let provider = child.split(".")[0]; + if (this.isActiveProvider(provider)) { + this.providers.add(provider); + } + } + + this.register(); + this.render(); + this.refresh(); + }, + + uninit() { + Services.obs.removeObserver(this.onBeginUpdate, UPDATE_BEGIN); + Services.obs.removeObserver(this.onFinishUpdate, UPDATE_FINISH); + }, + + onBeginUpdate(aSubject, aTopic, aData) { + this.updatingProvider = aData; + let p = this.updatingProvider; + + // Disable update button for the provider while we are doing update. + document.getElementById("update-" + p).disabled = true; + + let elem = document.getElementById(p + "-col-lastupdateresult"); + document.l10n.setAttributes(elem, "url-classifier-updating"); + }, + + onFinishUpdate(aSubject, aTopic, aData) { + let p = this.updatingProvider; + this.updatingProvider = ""; + + // It is possible that we get update-finished event only because + // about::url-classifier is opened after update-begin event is fired. + if (p === "") { + this.refresh(); + return; + } + + this.refresh([p]); + + document.getElementById("update-" + p).disabled = false; + + let elem = document.getElementById(p + "-col-lastupdateresult"); + if (aData.startsWith("success")) { + document.l10n.setAttributes(elem, "url-classifier-success"); + } else if (aData.startsWith("update error")) { + document.l10n.setAttributes(elem, "url-classifier-update-error", { + error: aData.split(": ")[1], + }); + } else if (aData.startsWith("download error")) { + document.l10n.setAttributes(elem, "url-classifier-download-error", { + error: aData.split(": ")[1], + }); + } else { + elem.childNodes[0].nodeValue = aData; + } + }, + + register() { + // Handle begin update + this.onBeginUpdate = this.onBeginUpdate.bind(this); + Services.obs.addObserver(this.onBeginUpdate, UPDATE_BEGIN); + + // Handle finish update + this.onFinishUpdate = this.onFinishUpdate.bind(this); + Services.obs.addObserver(this.onFinishUpdate, UPDATE_FINISH); + }, + + // This should only be called once because we assume number of providers + // won't change. + render() { + let tbody = document.getElementById("provider-table-body"); + + for (let provider of this.providers) { + let tr = document.createElement("tr"); + let cols = document.getElementById("provider-head-row").childNodes; + for (let column of cols) { + if (!column.id) { + continue; + } + let td = document.createElement("td"); + td.id = provider + "-" + column.id; + + if (column.id === "col-update") { + let btn = document.createElement("button"); + btn.id = "update-" + provider; + btn.addEventListener("click", () => { + this.update(provider); + }); + + document.l10n.setAttributes(btn, "url-classifier-trigger-update"); + td.appendChild(btn); + } else if (column.id === "col-lastupdateresult") { + document.l10n.setAttributes(td, "url-classifier-not-available"); + } else { + td.appendChild(document.createTextNode("")); + } + tr.appendChild(td); + } + tbody.appendChild(tr); + } + }, + + refresh(listProviders = this.providers) { + for (let provider of listProviders) { + let values = {}; + values["col-provider"] = provider; + + let pref = + "browser.safebrowsing.provider." + provider + ".lastupdatetime"; + let lut = Services.prefs.getCharPref(pref, ""); + values["col-lastupdatetime"] = lut ? new Date(lut * 1) : null; + + pref = "browser.safebrowsing.provider." + provider + ".nextupdatetime"; + let nut = Services.prefs.getCharPref(pref, ""); + values["col-nextupdatetime"] = nut ? new Date(nut * 1) : null; + + let listmanager = Cc[ + "@mozilla.org/url-classifier/listmanager;1" + ].getService(Ci.nsIUrlListManager); + let bot = listmanager.getBackOffTime(provider); + values["col-backofftime"] = bot ? new Date(bot * 1) : null; + + for (let key of Object.keys(values)) { + let elem = document.getElementById(provider + "-" + key); + if (values[key]) { + elem.removeAttribute("data-l10n-id"); + elem.childNodes[0].nodeValue = values[key]; + } else { + document.l10n.setAttributes(elem, "url-classifier-not-available"); + } + } + } + }, + + // Call update for the provider. + update(provider) { + let listmanager = Cc[ + "@mozilla.org/url-classifier/listmanager;1" + ].getService(Ci.nsIUrlListManager); + + let pref = "browser.safebrowsing.provider." + provider + ".lists"; + let tables = Services.prefs.getCharPref(pref, ""); + + if (!listmanager.forceUpdates(tables)) { + // This may because of back-off algorithm. + let elem = document.getElementById(provider + "-col-lastupdateresult"); + document.l10n.setAttributes(elem, "url-classifier-cannot-update"); + } + }, + + // if we can find any table registered an updateURL in the listmanager, + // the provider is active. This is used to filter out google v2 provider + // without changing the preference. + isActiveProvider(provider) { + let listmanager = Cc[ + "@mozilla.org/url-classifier/listmanager;1" + ].getService(Ci.nsIUrlListManager); + + let pref = "browser.safebrowsing.provider." + provider + ".lists"; + let tables = Services.prefs.getCharPref(pref, "").split(","); + + for (let i = 0; i < tables.length; i++) { + let updateUrl = listmanager.getUpdateUrl(tables[i]); + if (updateUrl) { + return true; + } + } + + return false; + }, +}; + +/* + * Cache + */ +var Cache = { + // Tables that show cahe entries. + showCacheEnties: null, + + init() { + this.showCacheEnties = new Set(); + + this.register(); + this.render(); + }, + + uninit() { + Services.obs.removeObserver(this.refresh, UPDATE_FINISH); + }, + + register() { + this.refresh = this.refresh.bind(this); + Services.obs.addObserver(this.refresh, UPDATE_FINISH); + }, + + render() { + this.createCacheEntries(); + + let refreshBtn = document.getElementById("refresh-cache-btn"); + refreshBtn.addEventListener("click", () => { + this.refresh(); + }); + + let clearBtn = document.getElementById("clear-cache-btn"); + clearBtn.addEventListener("click", () => { + let dbservice = Cc["@mozilla.org/url-classifier/dbservice;1"].getService( + Ci.nsIUrlClassifierDBService + ); + dbservice.clearCache(); + // Since clearCache is async call, we just simply assume it will be + // updated in 100 milli-seconds. + setTimeout(() => { + this.refresh(); + }, 100); + }); + }, + + refresh() { + this.clearCacheEntries(); + this.createCacheEntries(); + }, + + clearCacheEntries() { + let ctbody = document.getElementById("cache-table-body"); + while (ctbody.firstChild) { + ctbody.firstChild.remove(); + } + + let cetbody = document.getElementById("cache-entries-table-body"); + while (cetbody.firstChild) { + cetbody.firstChild.remove(); + } + }, + + createCacheEntries() { + function createRow(tds, body, cols) { + let tr = document.createElement("tr"); + tds.forEach(function (v, i, a) { + let td = document.createElement("td"); + if (i == 0 && tds.length != cols) { + td.setAttribute("colspan", cols - tds.length + 1); + } + + if (typeof v === "object") { + if (v.l10n) { + document.l10n.setAttributes(td, v.l10n); + } else { + td.removeAttribute("data-l10n-id"); + td.appendChild(v); + } + } else { + td.removeAttribute("data-l10n-id"); + td.textContent = v; + } + + tr.appendChild(td); + }); + body.appendChild(tr); + } + + let dbservice = Cc["@mozilla.org/url-classifier/dbservice;1"].getService( + Ci.nsIUrlClassifierInfo + ); + + for (let provider of Provider.providers) { + let pref = "browser.safebrowsing.provider." + provider + ".lists"; + let tables = Services.prefs.getCharPref(pref, "").split(","); + + for (let table of tables) { + dbservice.getCacheInfo(table, { + onGetCacheComplete: aCache => { + let entries = aCache.entries; + if (entries.length === 0) { + this.showCacheEnties.delete(table); + return; + } + + let positiveCacheCount = 0; + for (let i = 0; i < entries.length; i++) { + let entry = entries.queryElementAt( + i, + Ci.nsIUrlClassifierCacheEntry + ); + let matches = entry.matches; + positiveCacheCount += matches.length; + + // If we don't have to show cache entries for this table then just + // skip the following code. + if (!this.showCacheEnties.has(table)) { + continue; + } + + let tds = [ + table, + entry.prefix, + new Date(entry.expiry * 1000).toString(), + ]; + let j = 0; + do { + if (matches.length >= 1) { + let match = matches.queryElementAt( + j, + Ci.nsIUrlClassifierPositiveCacheEntry + ); + let list = [ + match.fullhash, + new Date(match.expiry * 1000).toString(), + ]; + tds = tds.concat(list); + } else { + tds = tds.concat([ + { l10n: "url-classifier-not-available" }, + { l10n: "url-classifier-not-available" }, + ]); + } + createRow( + tds, + document.getElementById("cache-entries-table-body"), + 5 + ); + j++; + tds = [""]; + } while (j < matches.length); + } + + // Create cache information entries. + let chk = document.createElement("input"); + chk.type = "checkbox"; + chk.checked = this.showCacheEnties.has(table); + chk.addEventListener("click", () => { + if (chk.checked) { + this.showCacheEnties.add(table); + } else { + this.showCacheEnties.delete(table); + } + this.refresh(); + }); + + let tds = [table, entries.length, positiveCacheCount, chk]; + createRow( + tds, + document.getElementById("cache-table-body"), + tds.length + ); + }, + }); + } + } + + let entries_div = document.getElementById("cache-entries"); + entries_div.style.display = + this.showCacheEnties.size == 0 ? "none" : "block"; + }, +}; + +/* + * Debug + */ +var Debug = { + // url-classifier NSPR Log modules. + modules: [ + "UrlClassifierDbService", + "nsChannelClassifier", + "UrlClassifier", + "UrlClassifierProtocolParser", + "UrlClassifierStreamUpdater", + "UrlClassifierPrefixSet", + "ApplicationReputation", + ], + + init() { + this.register(); + this.render(); + this.refresh(); + }, + + uninit() { + Services.prefs.removeObserver(JSLOG_PREF, this.refreshJSDebug); + }, + + register() { + this.refreshJSDebug = this.refreshJSDebug.bind(this); + Services.prefs.addObserver(JSLOG_PREF, this.refreshJSDebug); + }, + + render() { + // This function update the log module text field if we click + // safebrowsing log module check box. + function logModuleUpdate(module) { + let txt = document.getElementById("log-modules"); + let chk = document.getElementById("chk-" + module); + + let dst = chk.checked ? "," + module + ":5" : ""; + let re = new RegExp(",?" + module + ":[0-9]"); + + let str = txt.value.replace(re, dst); + if (chk.checked) { + str = txt.value === str ? str + dst : str; + } + txt.value = str.replace(/^,/, ""); + } + + let setLog = document.getElementById("set-log-modules"); + setLog.addEventListener("click", this.nsprlog); + + let setLogFile = document.getElementById("set-log-file"); + setLogFile.addEventListener("click", this.logfile); + + let setJSLog = document.getElementById("js-log"); + setJSLog.addEventListener("click", this.jslog); + + let modules = document.getElementById("log-modules"); + let sbModules = document.getElementById("sb-log-modules"); + for (let module of this.modules) { + let container = document.createElement("label"); + container.className = "toggle-container-with-text"; + sbModules.appendChild(container); + + let chk = document.createElement("input"); + chk.id = "chk-" + module; + chk.type = "checkbox"; + chk.checked = true; + chk.addEventListener("click", () => { + logModuleUpdate(module); + }); + container.appendChild(chk, modules); + + let span = document.createElement("span"); + span.appendChild(document.createTextNode(module)); + container.appendChild(span, modules); + } + + this.modules.map(logModuleUpdate); + + let file = Services.dirsvc.get("TmpD", Ci.nsIFile); + file.append("safebrowsing.log"); + + let logFile = document.getElementById("log-file"); + logFile.value = file.path; + + let curLog = document.getElementById("cur-log-modules"); + curLog.childNodes[0].nodeValue = ""; + + let curLogFile = document.getElementById("cur-log-file"); + curLogFile.childNodes[0].nodeValue = ""; + }, + + refresh() { + this.refreshJSDebug(); + + // Disable configure log modules if log modules are already set + // by environment variable. + + let logModules = + Services.env.get("MOZ_LOG") || + Services.env.get("MOZ_LOG_MODULES") || + Services.env.get("NSPR_LOG_MODULES"); + + if (logModules.length) { + document.getElementById("set-log-modules").disabled = true; + for (let module of this.modules) { + document.getElementById("chk-" + module).disabled = true; + } + + let curLogModules = document.getElementById("cur-log-modules"); + curLogModules.childNodes[0].nodeValue = logModules; + } + + // Disable set log file if log file is already set + // by environment variable. + let logFile = + Services.env.get("MOZ_LOG_FILE") || Services.env.get("NSPR_LOG_FILE"); + if (logFile.length) { + document.getElementById("set-log-file").disabled = true; + document.getElementById("log-file").value = logFile; + } + }, + + refreshJSDebug() { + let enabled = Services.prefs.getBoolPref(JSLOG_PREF, false); + + let jsChk = document.getElementById("js-log"); + jsChk.checked = enabled; + + let curJSLog = document.getElementById("cur-js-log"); + if (enabled) { + document.l10n.setAttributes(curJSLog, "url-classifier-enabled"); + } else { + document.l10n.setAttributes(curJSLog, "url-classifier-disabled"); + } + }, + + jslog() { + let enabled = Services.prefs.getBoolPref(JSLOG_PREF, false); + Services.prefs.setBoolPref(JSLOG_PREF, !enabled); + }, + + nsprlog() { + // Turn off debugging for all the modules. + let children = Services.prefs.getBranch("logging.").getChildList(""); + for (let pref of children) { + if (!pref.startsWith("config.")) { + Services.prefs.clearUserPref(`logging.${pref}`); + } + } + + let value = document.getElementById("log-modules").value; + let logModules = value.split(","); + for (let module of logModules) { + let [key, value] = module.split(":"); + Services.prefs.setIntPref(`logging.${key}`, parseInt(value, 10)); + } + + let curLogModules = document.getElementById("cur-log-modules"); + curLogModules.childNodes[0].nodeValue = value; + }, + + logfile() { + let logFile = document.getElementById("log-file").value.trim(); + Services.prefs.setCharPref("logging.config.LOG_FILE", logFile); + + let curLogFile = document.getElementById("cur-log-file"); + curLogFile.childNodes[0].nodeValue = logFile; + }, +}; diff --git a/toolkit/content/aboutUrlClassifier.xhtml b/toolkit/content/aboutUrlClassifier.xhtml new file mode 100644 index 0000000000..333ef429ed --- /dev/null +++ b/toolkit/content/aboutUrlClassifier.xhtml @@ -0,0 +1,163 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<!-- 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/. --> + +<!DOCTYPE html [ +<!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> %htmlDTD; +]> + +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta http-equiv="Content-Security-Policy" content="default-src chrome:; object-src 'none'" /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="url-classifier-title"></title> + <link rel="stylesheet" href="chrome://global/content/aboutUrlClassifier.css" type="text/css"/> + <link rel="localization" href="toolkit/about/url-classifier.ftl"/> + <script src="chrome://global/content/aboutUrlClassifier.js"></script> +</head> + +<body class="wide-container"> + <h1 data-l10n-id="url-classifier-title"></h1> + <div id="search"> + <h2 class="major-section" data-l10n-id="url-classifier-search-title"></h2> + <div class="options"> + <table id="search-table"> + <tbody> + <tr> + <th class="column" data-l10n-id="url-classifier-search-input"></th> + <td> + <input id="search-input" type="text" value=""/> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="url-classifier-search-listType"></th> + <td> + <select id="search-listtype"> + <option value="0">Blocklist</option> + <option value="1">Entitylist</option> + </select> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="url-classifier-search-features"></th> + <td id="search-features"></td> + </tr> + <tr> + <th></th> + <td> + <button id="search-button" data-l10n-id="url-classifier-search-btn"></button> + </td> + </tr> + </tbody> + </table> + <p id="search-error-message"></p> + <h2 class="major-section" id="result-title" data-l10n-id="url-classifier-search-result-title"></h2> + <table id="result-table"> + <tr> + <td> + <input id="search-input" type="text" value=""/> + </td> + </tr> + </table> + </div> + </div> + <div id="provider"> + <h2 class="major-section" data-l10n-id="url-classifier-provider-title"></h2> + <table id="provider-table"> + <thead> + <tr id="provider-head-row"> + <th id="col-provider" data-l10n-id="url-classifier-provider"></th> + <th id="col-lastupdatetime" data-l10n-id="url-classifier-provider-last-update-time"></th> + <th id="col-nextupdatetime" data-l10n-id="url-classifier-provider-next-update-time"></th> + <th id="col-backofftime" data-l10n-id="url-classifier-provider-back-off-time"></th> + <th id="col-lastupdateresult" data-l10n-id="url-classifier-provider-last-update-status"></th> + <th id="col-update" data-l10n-id="url-classifier-provider-update-btn"></th> + </tr> + </thead> + <tbody id="provider-table-body"> + <!-- data is generated in javascript --> + </tbody> + </table> + </div> + <div id="cache"> + <h2 class="major-section" data-l10n-id="url-classifier-cache-title"></h2> + <div id="cache-modules" class="options"> + <button id="refresh-cache-btn" data-l10n-id="url-classifier-cache-refresh-btn"></button> + <button id="clear-cache-btn" data-l10n-id="url-classifier-cache-clear-btn"></button> + <br></br> + </div> + <table id="cache-table"> + <thead> + <tr id="cache-head-row"> + <th id="col-tablename" data-l10n-id="url-classifier-cache-table-name"></th> + <th id="col-negativeentries" data-l10n-id="url-classifier-cache-ncache-entries"></th> + <th id="col-positiveentries" data-l10n-id="url-classifier-cache-pcache-entries"></th> + <th id="col-showentries" data-l10n-id="url-classifier-cache-show-entries"></th> + </tr> + </thead> + <tbody id="cache-table-body"> + <!-- data is generated in javascript --> + </tbody> + </table> + <br></br> + </div> + <div id="cache-entries"> + <h2 class="major-section" data-l10n-id="url-classifier-cache-entries"></h2> + <table id="cache-entries-table"> + <thead> + <tr id="cache-entries-row"> + <th id="col-table" data-l10n-id="url-classifier-cache-table-name"></th> + <th id="col-prefix" data-l10n-id="url-classifier-cache-prefix"></th> + <th id="col-n-expire" data-l10n-id="url-classifier-cache-ncache-expiry"></th> + <th id="col-fullhash" data-l10n-id="url-classifier-cache-fullhash"></th> + <th id="col-p-expire" data-l10n-id="url-classifier-cache-pcache-expiry"></th> + </tr> + </thead> + <tbody id="cache-entries-table-body"> + <!-- data is generated in javascript --> + </tbody> + </table> + </div> + <div id="debug"> + <h2 class="major-section" data-l10n-id="url-classifier-debug-title"></h2> + <div id="debug-modules" class="options"> + <input id="log-modules" type="text" value=""/> + <button id="set-log-modules" data-l10n-id="url-classifier-debug-module-btn"></button> + <br></br> + <input id="log-file" type="text" value=""/> + <button id="set-log-file" data-l10n-id="url-classifier-debug-file-btn"></button> + <br></br> + <label class="toggle-container-with-text"> + <input id="js-log" type="checkbox"/> + <span data-l10n-id="url-classifier-debug-js-log-chk"></span> + </label> + </div> + <table id="debug-table"> + <tbody> + <tr> + <th class="column" data-l10n-id="url-classifier-debug-sb-modules"></th> + <td id="sb-log-modules"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="url-classifier-debug-modules"></th> + <td id="cur-log-modules"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="url-classifier-debug-sbjs-modules"></th> + <td id="cur-js-log"> + </td> + </tr> + <tr> + <th class="column" data-l10n-id="url-classifier-debug-file"></th> + <td id="cur-log-file"> + </td> + </tr> + </tbody> + </table> + </div> +</body> +</html> diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.css b/toolkit/content/aboutwebrtc/aboutWebrtc.css new file mode 100644 index 0000000000..9d87aded81 --- /dev/null +++ b/toolkit/content/aboutwebrtc/aboutWebrtc.css @@ -0,0 +1,268 @@ +/* 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/. */ + +body { + margin: 8px; +} + +table { + font-family: monospace; + border: 1px solid var(--in-content-border-color); + border-spacing: 0; + margin-block: 1em; +} + +.controls { + font-size: 1.1em; + display: inline-block; + margin: 0 0.5em; +} + +.control { + margin: 0.5em 0; +} + +.message > p { + margin: 4px; +} + +.log p, +.prefs p { + font-family: monospace; + padding-inline-start: 2em; + text-indent: -2em; + margin-block: 2px; +} + +#content > div, +#mediactx > div { + padding: 1em 2em; + margin: 1em 0; + border: 1px solid var(--in-content-box-border-color); + border-radius: 10px; + background-color: var(--in-content-box-background); +} + +.autorefresh { + font-size: var(--font-size-small); + margin-inline-end: 0.5em; +} + +.section-heading { + display: flex; + align-items: center; + + > h3, + > h4 { + margin-inline-end: 1em; + } + + > .fold-trigger { + margin-inline-end: 1em; + } + + > button { + margin-inline: 1em; + } +} + +.fold-target { + border-inline-start: 1px solid var(--in-content-border-color); + padding-inline-start: 1em; + + .section-body > & { + display: block; + } +} + +.peer-connection > h3 { + background-color: var(--in-content-box-info-background); + padding: 4px; +} + +h3 > span { + margin-inline-end: 0.5em; +} + +.peer-connection > button { + margin-inline-start: 0; +} + +.peer-connection table { + width: 100%; + text-align: center; +} + +.peer-connection table th { + font-weight: bold; +} + +.peer-connection table th, +.peer-connection table td { + padding: 0.4em; + border: 1px solid var(--in-content-border-color); +} + +.peer-connection table tr:nth-child(odd) { + background-color: var(--in-content-box-background-odd); +} + +.peer-connection table caption { + text-align: start; +} + +.peer-connection table.raw-candidate { + text-align: match-parent; +} + +.bottom-border td { + border-bottom: 2px solid currentColor; +} + +.peer-connection-config div { + margin-inline: 1em; + padding: 4px; + border: 1px solid var(--in-content-border-color); +} + +.peer-connection-config div:nth-child(odd) { + background-color: var(--in-content-box-background-odd); +} + +/* The pale colour scheme is taken from: + https://personal.sron.nl/~pault/#sec:qualitative */ +.ice-trickled { + background-color: #cceeff; /* pale cyan */ +} +.ice-succeeded { + background-color: #ccddaa; /* pale green */ +} +.ice-failed { + background-color: #ffcccc; /* pale red */ +} +.ice-cancelled { + background-color: #eeeebb; /* pale yellow */ +} +.ice-trickled, +.ice-succeeded, +.ice-failed, +.ice-cancelled { + color: black +} + +.info-label { + font-weight: bold; +} + +.info-body, +.stat-label { + padding-inline-start: 0.5em; +} + +.section-ctrl { + margin: 1em 1.5em; +} + +div.fold-trigger { + color: var(--blue-60); + cursor: pointer; +} + +@media screen { + .fold-closed { + display: none !important; + } +} + +@media print { + .no-print { + display: none !important; + } +} + +.tab-pane { + display: none; +} + +.active-tab-pane { + border: 1px solid var(--in-content-border-color); + display: block; +} + +.tab-button { + color: var(--in-content-button-text-color); +} + +.active-tab-button { + color: var(--in-content-button-text-color-active); + background: var(--in-content-button-background-active); +} + +.sdp-history { + display: flex; + height: 400px; +} + +.sdp-history-link { + text-decoration: underline; +} + +.sdp-history h5 { + background-color: var(--in-content-box-info-background); +} + +.sdp-history div { + border: 1px solid var(--in-content-border-color); + padding: 1em; + width: 50%; + overflow: scroll; +} + +.line-graph { + border: 1px solid var(--in-content-border-color); + margin-inline: 1px; + padding: 1px; +} + +.svg-graph { + border: 1px solid var(--in-content-border-color); + margin-inline: 1px; +} + +.copy-button-base { + padding-inline-end: 0.25em; +} + +.copy-button { + visibility: hidden; +} + +.prefList > li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.pathDisplay { + margin-inline-end: 1em; +} + +.subsection-heading > h4 > span { + margin-inline-end: 0.5em; +} + +.prefList > li:hover > .copy-button, +.subsection-heading:hover > h4 > .copy-button { + visibility: visible; +} + +.copy-button-fade-out { + opacity: 0; + transition: opacity 0.5s; +} + +.copy-button-fade-in { + opacity: 1; + transition: opacity 0.5s; +} diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.html b/toolkit/content/aboutwebrtc/aboutWebrtc.html new file mode 100644 index 0000000000..67a202f17f --- /dev/null +++ b/toolkit/content/aboutwebrtc/aboutWebrtc.html @@ -0,0 +1,35 @@ +<!DOCTYPE html> + +<!-- 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/. --> + +<html> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta charset="utf-8" /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="about-webrtc-document-title"></title> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + type="text/css" + media="all" + href="chrome://global/content/aboutwebrtc/aboutWebrtc.css" + /> + <script + src="chrome://global/content/aboutwebrtc/aboutWebrtc.mjs" + defer="defer" + type="module" + ></script> + <link rel="localization" href="toolkit/about/aboutWebrtc.ftl" /> + </head> + <body id="body"> + <div id="content"></div> + <div id="mediactx"></div> + <div id="controls" class="no-print"></div> + </body> +</html> diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.mjs b/toolkit/content/aboutwebrtc/aboutWebrtc.mjs new file mode 100644 index 0000000000..3c41a4aa66 --- /dev/null +++ b/toolkit/content/aboutwebrtc/aboutWebrtc.mjs @@ -0,0 +1,1957 @@ +/* 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/. */ + +import { GraphImpl } from "chrome://global/content/aboutwebrtc/graph.mjs"; +import { GraphDb } from "chrome://global/content/aboutwebrtc/graphdb.mjs"; +import { Disclosure } from "chrome://global/content/aboutwebrtc/disclosure.mjs"; +import { ConfigurationList } from "chrome://global/content/aboutwebrtc/configurationList.mjs"; +import { CopyButton } from "./copyButton.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +function makeFilePickerService() { + const fpContractID = "@mozilla.org/filepicker;1"; + const fpIID = Ci.nsIFilePicker; + return Cc[fpContractID].createInstance(fpIID); +} + +const WGI = WebrtcGlobalInformation; + +const LOGFILE_NAME_DEFAULT = "aboutWebrtc.html"; + +class Renderer { + // Long function names preserved until code can be uniformly moved to new names + renderElement(eleName, options, l10n_id, l10n_args) { + let elem = Object.assign(document.createElement(eleName), options); + if (l10n_id) { + document.l10n.setAttributes(elem, l10n_id, l10n_args); + } + return elem; + } + elem() { + return this.renderElement(...arguments); + } + text(eleName, textContent, options) { + return this.renderElement(eleName, { textContent, ...options }); + } + renderElements(eleName, options, list) { + const element = renderElement(eleName, options); + element.append(...list); + return element; + } + elems() { + return this.renderElements(...arguments); + } +} + +// Proxies a Renderer instance to provide some meta programming methods to make +// adding elements more readable, e.g. elemRenderer.elem_h4(...) instead of +// elemRenderer.elem("h4", ...). +const elemRenderer = new Proxy(new Renderer(), { + get(target, prop, receiver) { + // Function prefixes to proxy. + const proxied = { + elem_: (...args) => target.elem(...args), + elems_: (...args) => target.elems(...args), + text_: (...args) => target.text(...args), + }; + for (let [prefix, func] of Object.entries(proxied)) { + if (prop.startsWith(prefix) && prop.length > prefix.length) { + return (...args) => func(prop.substring(prefix.length), ...args); + } + } + // Pass non-matches to the base object + return Reflect.get(...arguments); + }, +}); + +let graphData = []; +let mostRecentReports = {}; +let sdpHistories = []; +let historyTsMemoForPcid = {}; +let sdpHistoryTsMemoForPcid = {}; + +function clearStatsHistory() { + graphData = []; + mostRecentReports = {}; + sdpHistories = []; + historyTsMemoForPcid = {}; + sdpHistoryTsMemoForPcid = {}; +} + +function appendReportToHistory(report) { + appendSdpHistory(report); + mostRecentReports[report.pcid] = report; + if (graphData[report.pcid] === undefined) { + graphData[report.pcid] ??= new GraphDb(report); + } else { + graphData[report.pcid].insertReportData(report); + } +} + +function appendSdpHistory({ pcid, sdpHistory: newHistory }) { + sdpHistories[pcid] ??= []; + let storedHistory = sdpHistories[pcid]; + newHistory.forEach(entry => { + const { timestamp } = entry; + if (!storedHistory.length || storedHistory.at(-1).timestamp < timestamp) { + storedHistory.push(entry); + sdpHistoryTsMemoForPcid[pcid] = timestamp; + } + }); +} + +function recentStats() { + return Object.values(mostRecentReports); +} + +// Returns the sdpHistory for a given stats report +function getSdpHistory({ pcid, timestamp: a }) { + sdpHistories[pcid] ??= []; + return sdpHistories[pcid].filter(({ timestamp: b }) => a >= b); +} + +function appendStats(allStats) { + allStats.forEach(appendReportToHistory); +} + +function getAndUpdateStatsTsMemoForPcid(pcid) { + historyTsMemoForPcid[pcid] = mostRecentReports[pcid]?.timestamp; + return historyTsMemoForPcid[pcid] || null; +} + +function getSdpTsMemoForPcid(pcid) { + return sdpHistoryTsMemoForPcid[pcid] || null; +} + +const REQUEST_FULL_REFRESH = true; +const REQUEST_UPDATE_ONLY_REFRESH = false; + +async function getStats(requestFullRefresh) { + if ( + requestFullRefresh || + !Services.prefs.getBoolPref("media.aboutwebrtc.hist.enabled") + ) { + // Upon clearing the history we need to get all the stats to rebuild what + // will become the skeleton of the page.hg wip + const { reports } = await new Promise(r => WGI.getAllStats(r)); + appendStats(reports); + return reports.sort((a, b) => b.timestamp - a.timestamp); + } + const pcids = await new Promise(r => WGI.getStatsHistoryPcIds(r)); + await Promise.all( + [...pcids].map(pcid => + new Promise(r => + WGI.getStatsHistorySince( + r, + pcid, + getAndUpdateStatsTsMemoForPcid(pcid), + getSdpTsMemoForPcid(pcid) + ) + ).then(r => { + appendStats(r.reports); + r.sdpHistories.forEach(hist => appendSdpHistory(hist)); + }) + ) + ); + let recent = recentStats(); + return recent.sort((a, b) => b.timestamp - a.timestamp); +} + +const renderElement = (eleName, options, l10n_id, l10n_args) => + elemRenderer.elem(eleName, options, l10n_id, l10n_args); + +const renderText = (eleName, textContent, options) => + elemRenderer.text(eleName, textContent, options); + +const renderElements = (eleName, options, list) => + elemRenderer.elems(eleName, options, list); + +// Button control classes + +class Control { + label = null; + message = null; + messageArgs = null; + messageHeader = null; + + render() { + this.ctrl = renderElement( + "button", + { onclick: () => this.onClick() }, + this.label + ); + this.msg = renderElement("p"); + this.update(); + return [this.ctrl, this.msg]; + } + + update() { + document.l10n.setAttributes(this.ctrl, this.label); + this.msg.textContent = ""; + if (this.message) { + this.msg.append( + renderElement( + "span", + { + className: "info-label", + }, + this.messageHeader + ), + renderElement( + "span", + { + className: "info-body", + }, + this.message, + this.messageArgs + ) + ); + } + } +} + +class SavePage extends Control { + constructor() { + super(); + this.messageHeader = "about-webrtc-save-page-label"; + this.label = "about-webrtc-save-page-label"; + } + + async onClick() { + FoldEffect.expandAll(); + let [dialogTitle] = await document.l10n.formatValues([ + { id: "about-webrtc-save-page-dialog-title" }, + ]); + let FilePicker = makeFilePickerService(); + const lazyFileUtils = lazy.FileUtils; + FilePicker.init(window, dialogTitle, FilePicker.modeSave); + FilePicker.defaultString = LOGFILE_NAME_DEFAULT; + const rv = await new Promise(r => FilePicker.open(r)); + if (rv != FilePicker.returnOK && rv != FilePicker.returnReplace) { + return; + } + const fout = lazyFileUtils.openAtomicFileOutputStream( + FilePicker.file, + lazyFileUtils.MODE_WRONLY | lazyFileUtils.MODE_CREATE + ); + const content = document.querySelector("#content"); + const noPrintList = [...content.querySelectorAll(".no-print")]; + for (const node of noPrintList) { + node.style.setProperty("display", "none"); + } + try { + fout.write(content.outerHTML, content.outerHTML.length); + } finally { + lazyFileUtils.closeAtomicFileOutputStream(fout); + for (const node of noPrintList) { + node.style.removeProperty("display"); + } + } + this.message = "about-webrtc-save-page-complete-msg"; + this.messageArgs = { path: FilePicker.file.path }; + this.update(); + } +} + +class EnableLogging extends Control { + constructor() { + super(); + this.label = "about-webrtc-enable-logging-label"; + this.message = null; + } + + onClick() { + this.update(); + window.open("about:logging?preset=webrtc"); + } +} + +class AecLogging extends Control { + constructor() { + super(); + this.messageHeader = "about-webrtc-aec-logging-msg-label"; + + if (WGI.aecDebug) { + this.setState(true); + } else { + this.label = "about-webrtc-aec-logging-off-state-label"; + this.message = null; + } + } + + setState(state) { + this.label = state + ? "about-webrtc-aec-logging-on-state-label" + : "about-webrtc-aec-logging-off-state-label"; + try { + if (!state) { + const file = WGI.aecDebugLogDir; + this.message = "about-webrtc-aec-logging-toggled-off-state-msg"; + this.messageArgs = { path: file }; + } else { + this.message = "about-webrtc-aec-logging-toggled-on-state-msg"; + } + } catch (e) { + this.message = null; + } + } + + onClick() { + if (Services.env.get("MOZ_DISABLE_CONTENT_SANDBOX") != "1") { + this.message = "about-webrtc-aec-logging-unavailable-sandbox"; + } else { + this.setState((WGI.aecDebug = !WGI.aecDebug)); + } + this.update(); + } +} + +class ShowTab extends Control { + constructor(browserId) { + super(); + this.label = "about-webrtc-show-tab-label"; + this.message = null; + this.browserId = browserId; + } + + onClick() { + const globalBrowser = + window.ownerGlobal.browsingContext.topChromeWindow.gBrowser; + for (const tab of globalBrowser.visibleTabs) { + if (tab.linkedBrowser && tab.linkedBrowser.browserId == this.browserId) { + globalBrowser.selectedTab = tab; + return; + } + } + this.ctrl.disabled = true; + } +} + +(async () => { + // Setup. Retrieve reports & log while page loads. + + const primarySections = []; + let peerConnections = renderElement("div"); + let connectionLog = renderElement("div"); + let userModifiedConfigView = renderElement("div"); + + const content = document.querySelector("#content"); + content.append(peerConnections, connectionLog, userModifiedConfigView); + await new Promise(r => (window.onload = r)); + { + const ctrl = renderElement("div", { className: "control" }); + const msg = renderElement("div", { className: "message" }); + const add = ([control, message]) => { + ctrl.appendChild(control); + msg.appendChild(message); + }; + add(new SavePage().render()); + add(new EnableLogging().render()); + add(new AecLogging().render()); + + const ctrls = document.querySelector("#controls"); + ctrls.append(renderElements("div", { className: "controls" }, [ctrl, msg])); + + const mediactx = document.querySelector("#mediactx"); + const mediaCtxSection = await renderMediaCtx(elemRenderer); + primarySections.push(mediaCtxSection); + mediactx.append(mediaCtxSection.view()); + } + + // This does not handle the auto-refresh, only the manual refreshes needed + // for certain user actions, and the initial population of the data + async function refresh() { + const pcSection = await renderPeerConnectionSection(); + primarySections.push(pcSection); + const pcDiv = pcSection.view(); + const connectionLogSection = await renderConnectionLog(); + primarySections.push(connectionLogSection); + const logDiv = connectionLogSection.view(); + + // Replace previous info + peerConnections.replaceWith(pcDiv); + connectionLog.replaceWith(logDiv); + const userModifiedConfigSection = await renderUserPrefSection(); + primarySections.push(userModifiedConfigSection); + userModifiedConfigView.replaceWith(userModifiedConfigSection.view()); + peerConnections = pcDiv; + connectionLog = logDiv; + } + refresh(); + + const INTERVAL_MS = 250; + const HALF_INTERVAL_MS = INTERVAL_MS / 2; + // This handles autorefresh and forced refresh, not initial document loading + async function autorefresh() { + const startTime = performance.now(); + await Promise.all(primarySections.map(s => s.autoUpdate())); + const elapsed = performance.now() - startTime; + // Using half the refresh interval as + const timeout = + elapsed > HALF_INTERVAL_MS ? INTERVAL_MS : INTERVAL_MS - elapsed; + return timeout; + } + let timeout = INTERVAL_MS; + while (true) { + timeout = await autorefresh(); + await new Promise(r => setTimeout(r, timeout)); + } +})(); + +const peerConnectionAutoRefreshState = { + /** @type HTMLInputElement */ + primaryCheckbox: undefined, + /** @type [HTMLInputElement] */ + secondaryCheckboxes: [], + + secondaryClicked() { + const { checkedBoxes, uncheckedBoxes } = this.secondaryCheckboxes + .filter(cb => !cb.hidden) + .reduce( + (sums, { checked }) => { + if (checked) { + sums.checkedBoxes += 1; + } else { + sums.uncheckedBoxes += 1; + } + return sums; + }, + { + checkedBoxes: 0, + uncheckedBoxes: 0, + } + ); + // Stay checked unless all secondary boxes are unchecked + this.primaryCheckbox.checked = checkedBoxes > 0; + // Display an indeterminate state when there are both checked and unchecked boxes + this.primaryCheckbox.indeterminate = checkedBoxes && uncheckedBoxes; + }, + primaryClicked() { + for (const cb of this.secondaryCheckboxes.filter(c => !c.hidden)) { + cb.checked = this.primaryCheckbox.checked; + } + this.primaryCheckbox.indeterminate = false; + }, +}; + +function renderCopyTextToClipboardButton(rndr, id, l10n_id, getTextFn) { + return rndr.elem_button( + { + id: `copytextbutton-${id}`, + onclick() { + navigator.clipboard.writeText(getTextFn()); + }, + }, + l10n_id + ); +} + +async function renderPeerConnectionSection() { + // Render pcs and log + let reports = await getStats(); + let needsFullUpdate = REQUEST_UPDATE_ONLY_REFRESH; + reports.sort((a, b) => a.browserId - b.browserId); + + // Used by the renderTransportStats function to calculate stat deltas + const hist = {}; + + // Adding a pcid to this list will cause the stats for that list to be refreshed + // on the next update interval. This is useful for one time refreshes like the + // "Refresh" button. The list is cleared at the end of each refresh interval. + const forceRefreshList = []; + + const openPeerConnectionReports = reports.filter(r => !r.closed); + const closedPeerConnectionReports = reports.filter(r => r.closed); + const closedPCSection = document.createElement("div"); + if (closedPeerConnectionReports.length) { + const closedPeerConnectionDisclosure = renderFoldableSection( + closedPCSection, + { + showMsg: "about-webrtc-closed-peerconnection-disclosure-show-msg", + hideMsg: "about-webrtc-closed-peerconnection-disclosure-hide-msg", + startsCollapsed: [...openPeerConnectionReports].size, + } + ); + closedPCSection.append(closedPeerConnectionDisclosure); + closedPeerConnectionDisclosure.append( + ...closedPeerConnectionReports.map(r => + renderPeerConnection(r, () => forceRefreshList.push(r.pcid)) + ) + ); + } + + const primarySection = await PrimarySection.make({ + headingL10nId: "about-webrtc-peerconnections-section-heading", + disclosureShowL10nId: "about-webrtc-peerconnections-section-show-msg", + disclosureHideL10nId: "about-webrtc-peerconnections-section-hide-msg", + autoRefreshPref: "media.aboutwebrtc.auto_refresh.peerconnection_section", + renderFn: async () => { + const body = document.createElement("div"); + body.append( + ...openPeerConnectionReports.map(r => + renderPeerConnection(r, () => forceRefreshList.push(r.pcid)) + ), + closedPCSection + ); + return body; + }, + // Creates the filling for the disclosure + updateFn: async section => { + let statsReports = await getStats(needsFullUpdate); + needsFullUpdate = REQUEST_UPDATE_ONLY_REFRESH; + + async function translate(element) { + const frag = document.createDocumentFragment(); + frag.append(element); + await document.l10n.translateFragment(frag); + return frag; + } + + const translateSection = async (report, id, renderFunc) => { + const element = document.getElementById(`${id}: ${report.pcid}`); + const result = + element && (await translate(renderFunc(elemRenderer, report, hist))); + return { element, translated: result }; + }; + + const sections = ( + await Promise.all( + // Add filter to check the refreshEnabledPcids + statsReports + .filter( + ({ pcid }) => + document.getElementById(`autorefresh-${pcid}`)?.checked || + forceRefreshList.includes(pcid) + ) + .flatMap(report => [ + translateSection( + report, + "pc-heading", + renderPeerConnectionHeading + ), + translateSection(report, "ice-stats", renderICEStats), + translateSection( + report, + "ice-raw-stats-fold", + renderRawICEStatsFold + ), + translateSection(report, "rtp-stats", renderRTPStats), + translateSection(report, "sdp-stats", renderSDPStats), + translateSection(report, "bandwidth-stats", renderBandwidthStats), + translateSection(report, "frame-stats", renderFrameRateStats), + ]) + ) + ).filter(({ element }) => element); + document.l10n.pauseObserving(); + for (const { element, translated } of sections) { + element.replaceWith(translated); + } + document.l10n.resumeObserving(); + while (forceRefreshList.length) { + forceRefreshList.pop(); + } + }, + // Updates the contents. + headerElementsFn: async () => { + const clearStatsButton = document.createElement("button"); + Object.assign(clearStatsButton, { + className: "no-print", + onclick: async () => { + WGI.clearAllStats(); + clearStatsHistory(); + needsFullUpdate = REQUEST_FULL_REFRESH; + primarySection.updateFn(); + }, + }); + document.l10n.setAttributes(clearStatsButton, "about-webrtc-stats-clear"); + return [clearStatsButton]; + }, + }); + peerConnectionAutoRefreshState.primaryCheckbox = primarySection.autorefresh; + let originalOnChange = primarySection.autorefresh.onchange; + primarySection.autorefresh.onchange = () => { + originalOnChange(); + peerConnectionAutoRefreshState.primaryClicked(); + }; + return primarySection; +} + +function renderSubsectionHeading(l10n_id, copyFunc) { + const heading = document.createElement("div"); + heading.className = "subsection-heading"; + const h4 = document.createElement("h4"); + const text = document.createElement("span"); + document.l10n.setAttributes(text, l10n_id); + h4.appendChild(text); + if (copyFunc != undefined) { + const copyButton = new CopyButton(copyFunc); + h4.appendChild(copyButton.element); + } + heading.appendChild(h4); + return heading; +} + +function renderPeerConnection(report, forceRefreshFn) { + const rndr = elemRenderer; + const { pcid, configuration } = report; + const pcStats = report.peerConnectionStats[0]; + + const pcDiv = renderElement("div", { className: "peer-connection" }); + pcDiv.append(renderPeerConnectionTools(rndr, report, forceRefreshFn)); + { + const section = renderFoldableSection(pcDiv); + section.append( + renderElements("div", {}, [ + renderElement( + "span", + { + className: "info-label", + }, + "about-webrtc-peerconnection-id-label" + ), + renderText("span", pcid, { className: "info-body" }), + rndr.elems_p({}, [ + rndr.elem_span( + { className: "info-label" }, + "about-webrtc-data-channels-opened-label" + ), + rndr.text_span(pcStats.dataChannelsOpened, { + className: "info-body", + }), + ]), + rndr.elems_p({}, [ + rndr.elem_span( + { className: "info-label" }, + "about-webrtc-data-channels-closed-label" + ), + rndr.text_span(pcStats.dataChannelsClosed, { + className: "info-body", + }), + ]), + renderConfiguration(rndr, configuration), + ]), + renderRTPStats(rndr, report), + renderICEStats(rndr, report), + renderRawICEStats(rndr, report), + renderSDPStats(rndr, report), + renderBandwidthStats(rndr, report), + renderFrameRateStats(rndr, report) + ); + pcDiv.append(section); + } + return pcDiv; +} + +function renderPeerConnectionMediaSummary(rndr, report) { + // Takes a codecId value and returns a corresponding codec stats object + const getCodecById = aId => report.codecStats.find(({ id }) => id == aId); + + // Find all the codecs used by send streams + const sendCodecs = new Set( + [...report.outboundRtpStreamStats] + .filter(({ codecId }) => codecId) + .map(({ codecId }) => getCodecById(codecId).mimeType) + .sort() + ); + + // Find all the codecs used by receive streams + const recvCodecs = new Set( + [...report.inboundRtpStreamStats] + .filter(({ codecId }) => codecId) + .map(({ codecId }) => getCodecById(codecId).mimeType) + .sort() + ); + + // Take all the codecs that appear in both the send and receive codec lists + const sendRecvCodecs = new Set( + [...sendCodecs, ...recvCodecs].filter( + c => sendCodecs.has(c) && recvCodecs.has(c) + ) + ); + + // Remove the common codecs from the send and receive codec lists. + // sendCodecs will now contain send only codecs + // receiveCodecs will now contain receive only codecs + sendRecvCodecs.forEach(c => { + sendCodecs.delete(c); + recvCodecs.delete(c); + }); + + const formatter = new Intl.ListFormat("en", { + style: "short", + type: "conjunction", + }); + + // Create a label with the codecs common to send and receive streams + const sendRecvSpan = sendRecvCodecs.size + ? [ + rndr.elem_span({}, "about-webrtc-short-send-receive-direction", { + codecs: formatter.format(sendRecvCodecs), + }), + ] + : []; + + // Do the same for send only codecs + const sendSpan = sendCodecs.size + ? [ + rndr.elem_span({}, "about-webrtc-short-send-direction", { + codecs: formatter.format(sendCodecs), + }), + ] + : []; + + // Do the same for receive only codecs + const recvSpan = recvCodecs.size + ? [ + rndr.elem_span({}, "about-webrtc-short-receive-direction", { + codecs: formatter.format(recvCodecs), + }), + ] + : []; + + return [...sendRecvSpan, ...sendSpan, ...recvSpan]; +} + +function renderPeerConnectionHeading(rndr, report) { + const { pcid, timestamp, closed: isClosed, browserId } = report; + const id = pcid.match(/id=(\S+)/)[1]; + const url = pcid.match(/url=([^)]+)/)[1]; + const now = new Date(timestamp); + return isClosed + ? rndr.elems_div( + { + id: `pc-heading: ${pcid}`, + class: "pc-heading", + }, + [ + rndr.elems_h3({}, [ + rndr.elem_span({}, "about-webrtc-connection-closed", { + "browser-id": browserId, + id, + url, + now, + }), + ...renderPeerConnectionMediaSummary(rndr, report), + ]), + ] + ) + : rndr.elems_div( + { + id: `pc-heading: ${pcid}`, + class: "pc-heading", + }, + [ + rndr.elems_h3({}, [ + rndr.elem_span({}, "about-webrtc-connection-open", { + "browser-id": browserId, + id, + url, + now, + }), + ...renderPeerConnectionMediaSummary(rndr, report), + ]), + ] + ); +} + +function renderPeerConnectionTools(rndr, report, forceRefreshFn) { + const { pcid, browserId } = report; + const id = pcid.match(/id=(\S+)/)[1]; + const copyHistButton = !Services.prefs.getBoolPref( + "media.aboutwebrtc.hist.enabled" + ) + ? [] + : [ + rndr.elem_button( + { + id: `copytextbutton-hist-${id}`, + onclick() { + WGI.getStatsHistorySince( + hist => + navigator.clipboard.writeText(JSON.stringify(hist, null, 2)), + pcid + ); + }, + }, + "about-webrtc-copy-report-history-button" + ), + ]; + const autorefreshButton = rndr.elem_input({ + id: `autorefresh-${pcid}`, + className: "autorefresh", + type: "checkbox", + hidden: report.closed, + checked: Services.prefs.getBoolPref( + "media.aboutwebrtc.auto_refresh.peerconnection_section" + ), + onchange: () => peerConnectionAutoRefreshState.secondaryClicked(), + }); + peerConnectionAutoRefreshState.secondaryCheckboxes.push(autorefreshButton); + const forceRefreshButton = rndr.elem_button( + { + id: `force-refresh-pc-${id}`, + onclick() { + forceRefreshFn(); + }, + }, + "about-webrtc-force-refresh-button" + ); + const autorefreshLabel = rndr.elem_label( + { + className: "autorefresh", + hidden: autorefreshButton.hidden, + }, + "about-webrtc-auto-refresh-label" + ); + return renderElements("div", { id: "pc-tools: " + pcid }, [ + renderPeerConnectionHeading(rndr, report), + new ShowTab(browserId).render()[0], + renderCopyTextToClipboardButton( + rndr, + report.pcid, + "about-webrtc-copy-report-button", + () => JSON.stringify({ ...report }, null, 2) + ), + ...copyHistButton, + forceRefreshButton, + autorefreshButton, + autorefreshLabel, + ]); +} + +const trimNewlines = sdp => sdp.replaceAll("\r\n", "\n"); + +const tabElementProps = (element, elemSubId, pcid) => ({ + className: + elemSubId != "answer" + ? `tab-${element}` + : `tab-${element} active-tab-${element}`, + id: `tab_${element}_${elemSubId}_${pcid}`, +}); + +const renderSDPTab = (rndr, sdp, props) => + rndr.elems("div", props, [rndr.text("pre", trimNewlines(sdp))]); + +const renderSDPHistoryTab = (rndr, hist, props) => { + // All SDP in sequential order. Add onclick handler to scroll the associated + // SDP into view below. + let first = Math.min(...hist.map(({ timestamp }) => timestamp)); + const parts = hist.map(({ isLocal, timestamp, sdp, errors: errs }) => { + let errorsSubSect = () => [ + rndr.elem_h5({}, "about-webrtc-sdp-parsing-errors-heading"), + ...errs.map(({ lineNumber: n, error: e }) => rndr.text_br(`${n}: ${e}`)), + ]; + + let sdpSection = [ + rndr.elem_h5({}, "about-webrtc-sdp-set-timestamp", { + timestamp, + "relative-timestamp": timestamp - first, + }), + ...(errs && errs.length ? errorsSubSect() : []), + rndr.text_pre(trimNewlines(sdp)), + ]; + + return { + link: rndr.elems_div({}, [ + rndr.elem_h5( + { + className: "sdp-history-link", + onclick: () => sdpSection[0].scrollIntoView(), + }, + isLocal + ? "about-webrtc-sdp-set-at-timestamp-local" + : "about-webrtc-sdp-set-at-timestamp-remote", + { timestamp } + ), + ]), + ...(isLocal ? { local: sdpSection } : { remote: sdpSection }), + }; + }); + + return rndr.elems_div(props, [ + // Render the links + rndr.elems_div( + {}, + parts.map(({ link }) => link) + ), + rndr.elems_div({ className: "sdp-history" }, [ + // Render the SDP into separate columns for local and remote. + rndr.elems_div({}, [ + rndr.elem_h4({}, "about-webrtc-local-sdp-heading"), + ...parts.filter(({ local }) => local).flatMap(({ local }) => local), + ]), + rndr.elems_div({}, [ + rndr.elem_h4({}, "about-webrtc-remote-sdp-heading"), + ...parts.filter(({ remote }) => remote).flatMap(({ remote }) => remote), + ]), + ]), + ]); +}; + +function renderSDPStats(rndr, { offerer, pcid, timestamp }) { + // Get the most recent (as of timestamp) local and remote SDPs from the + // history + const sdpEntries = getSdpHistory({ pcid, timestamp }); + const localSdp = sdpEntries.findLast(({ isLocal }) => isLocal)?.sdp || ""; + const remoteSdp = sdpEntries.findLast(({ isLocal }) => !isLocal)?.sdp || ""; + + const sdps = offerer + ? { offer: localSdp, answer: remoteSdp } + : { offer: remoteSdp, answer: localSdp }; + + const sdpLabels = offerer + ? { offer: "local", answer: "remote" } + : { offer: "remote", answer: "local" }; + + sdpLabels.l10n = { + offer: offerer + ? "about-webrtc-local-sdp-heading-offer" + : "about-webrtc-remote-sdp-heading-offer", + answer: offerer + ? "about-webrtc-remote-sdp-heading-answer" + : "about-webrtc-local-sdp-heading-answer", + history: "about-webrtc-sdp-history-heading", + }; + + const tabPaneProps = elemSubId => tabElementProps("pane", elemSubId, pcid); + + const panes = { + answer: renderSDPTab(rndr, sdps.answer, tabPaneProps("answer")), + offer: renderSDPTab(rndr, sdps.offer, tabPaneProps("offer")), + history: renderSDPHistoryTab( + rndr, + getSdpHistory({ pcid, timestamp }), + tabPaneProps("history") + ), + }; + + // Creates the properties and l10n label for tab buttons + const tabButtonProps = (elemSubId, pane) => [ + { + ...tabElementProps("button", elemSubId, pcid), + onclick({ currentTarget: t }) { + const flipPane = c => c.classList.toggle("active-tab-pane", c == pane); + Object.values(panes).forEach(flipPane); + const selButton = c => c.classList.toggle("active-tab-button", c == t); + [...t.parentElement.children].forEach(selButton); + }, + }, + sdpLabels.l10n[elemSubId], + ]; + + const sdpDiv = renderSubsectionHeading("about-webrtc-sdp-heading", () => + JSON.stringify( + { + offer: { + side: sdpLabels.offer, + sdp: sdps.offer.split("\r\n"), + }, + answer: { + side: sdpLabels.answer, + sdp: sdps.answer.split("\r\n"), + }, + }, + null, + 2 + ) + ); + const outer = document.createElement("div", { id: "sdp-stats" + pcid }); + outer.appendChild(sdpDiv); + let foldSection = renderFoldableSection(outer, { + showMsg: "about-webrtc-show-msg-sdp", + hideMsg: "about-webrtc-hide-msg-sdp", + }); + foldSection.append( + rndr.elems_div({ className: "tab-buttons" }, [ + ...Object.entries(panes).map(([elemSubId, pane]) => + rndr.elem_button(...tabButtonProps(elemSubId, pane)) + ), + ...Object.values(panes), + ]) + ); + outer.append(foldSection); + return outer; +} + +function renderBandwidthStats(rndr, report) { + const statsDiv = renderElement("div", { + id: "bandwidth-stats: " + report.pcid, + }); + const table = renderSimpleTable( + "", + [ + "about-webrtc-track-identifier", + "about-webrtc-send-bandwidth-bytes-sec", + "about-webrtc-receive-bandwidth-bytes-sec", + "about-webrtc-max-padding-bytes-sec", + "about-webrtc-pacer-delay-ms", + "about-webrtc-round-trip-time-ms", + ], + report.bandwidthEstimations.map(stat => [ + stat.trackIdentifier, + stat.sendBandwidthBps, + stat.receiveBandwidthBps, + stat.maxPaddingBps, + stat.pacerDelayMs, + stat.rttMs, + ]) + ); + statsDiv.append( + renderElement("h4", {}, "about-webrtc-bandwidth-stats-heading"), + table + ); + return statsDiv; +} + +function renderFrameRateStats(rndr, report) { + const statsDiv = renderElement("div", { id: "frame-stats: " + report.pcid }); + report.videoFrameHistories.forEach(hist => { + const stats = hist.entries.map(stat => { + stat.elapsed = stat.lastFrameTimestamp - stat.firstFrameTimestamp; + if (stat.elapsed < 1) { + stat.elapsed = "0.00"; + } + stat.elapsed = (stat.elapsed / 1_000).toFixed(3); + if (stat.elapsed && stat.consecutiveFrames) { + stat.avgFramerate = (stat.consecutiveFrames / stat.elapsed).toFixed(2); + } else { + stat.avgFramerate = "0.00"; + } + return stat; + }); + + const table = renderSimpleTable( + "", + [ + "about-webrtc-width-px", + "about-webrtc-height-px", + "about-webrtc-consecutive-frames", + "about-webrtc-time-elapsed", + "about-webrtc-estimated-framerate", + "about-webrtc-rotation-degrees", + "about-webrtc-first-frame-timestamp", + "about-webrtc-last-frame-timestamp", + "about-webrtc-local-receive-ssrc", + "about-webrtc-remote-send-ssrc", + ], + stats.map(stat => + [ + stat.width, + stat.height, + stat.consecutiveFrames, + stat.elapsed, + stat.avgFramerate, + stat.rotationAngle, + stat.firstFrameTimestamp, + stat.lastFrameTimestamp, + stat.localSsrc, + stat.remoteSsrc || "?", + ].map(entry => (Object.is(entry, undefined) ? "<<undefined>>" : entry)) + ) + ); + + statsDiv.append( + renderElement("h4", {}, "about-webrtc-frame-stats-heading", { + "track-identifier": hist.trackIdentifier, + }), + table + ); + }); + + return statsDiv; +} + +function renderRTPStats(rndr, report, hist) { + const rtpStats = [ + ...(report.inboundRtpStreamStats || []), + ...(report.outboundRtpStreamStats || []), + ]; + const remoteRtpStats = [ + ...(report.remoteInboundRtpStreamStats || []), + ...(report.remoteOutboundRtpStreamStats || []), + ]; + + // Generate an id-to-streamStat index for each remote streamStat. This will + // be used next to link the remote to its local side. + const remoteRtpStatsMap = {}; + for (const stat of remoteRtpStats) { + remoteRtpStatsMap[stat.id] = stat; + } + + // If a streamStat has a remoteId attribute, create a remoteRtpStats + // attribute that references the remote streamStat entry directly. + // That is, the index generated above is merged into the returned list. + for (const stat of rtpStats.filter(s => "remoteId" in s)) { + stat.remoteRtpStats = remoteRtpStatsMap[stat.remoteId]; + } + for (const stat of rtpStats.filter(s => "codecId" in s)) { + stat.codecStat = report.codecStats.find(({ id }) => id == stat.codecId); + } + const graphsByStat = stat => + (graphData[report.pcid]?.getGraphDataById(stat.id) || []).map(gd => { + // For some (remote) graphs data comes in slowly. + // Those graphs can be larger to show trends. + const histSecs = gd.getConfig().histSecs; + const width = (histSecs > 30 ? histSecs / 3 : 15) * 20; + const height = 100; + const graph = new GraphImpl(width, height); + graph.startTime = () => stat.timestamp - histSecs * 1000; + graph.stopTime = () => stat.timestamp; + if (gd.subKey == "packetsLost") { + const oldMaxColor = graph.maxColor; + graph.maxColor = data => (data.value == 0 ? "red" : oldMaxColor(data)); + } + // Get a bit more history for averages (20%) + const dataSet = gd.getDataSetSince( + graph.startTime() - histSecs * 0.2 * 1000 + ); + return graph.drawSparseValues(dataSet, gd.subKey, gd.getConfig()); + }); + // Render stats set + return renderElements( + "div", + { id: "rtp-stats: " + report.pcid, className: "rtp-stats" }, + [ + renderSubsectionHeading("about-webrtc-rtp-stats-heading", () => + JSON.stringify([...rtpStats, ...remoteRtpStats], null, 2) + ), + ...rtpStats.map(stat => { + const { ssrc, remoteId, remoteRtpStats: rtcpStats } = stat; + const remoteGraphs = rtcpStats + ? [ + rndr.elems_div({}, [ + rndr.text_h6(rtcpStats.type), + ...graphsByStat(rtcpStats), + ]), + ] + : []; + const mime = stat?.codecStat?.mimeType?.concat(" - ") || ""; + const div = renderElements("div", {}, [ + rndr.text_h5(`${mime}SSRC ${ssrc}`), + rndr.elems_div({}, [rndr.text_h6(stat.type), ...graphsByStat(stat)]), + ...remoteGraphs, + renderCodecStats(stat), + renderTransportStats(stat, true, hist), + ]); + if (remoteId && rtcpStats) { + div.append(renderTransportStats(rtcpStats, false)); + } + return div; + }), + ] + ); +} + +function renderCodecStats({ + codecStat, + framesEncoded, + framesDecoded, + framesDropped, + discardedPackets, + packetsReceived, +}) { + let elements = []; + + if (codecStat) { + elements.push( + renderText("span", `${codecStat.payloadType} ${codecStat.mimeType}`, {}) + ); + if (framesEncoded !== undefined || framesDecoded !== undefined) { + elements.push( + renderElement( + "span", + { className: "stat-label" }, + "about-webrtc-frames", + { + frames: framesEncoded || framesDecoded || 0, + } + ) + ); + } + if (codecStat.channels !== undefined) { + elements.push( + renderElement( + "span", + { className: "stat-label" }, + "about-webrtc-channels", + { + channels: codecStat.channels, + } + ) + ); + } + elements.push( + renderText( + "span", + ` ${codecStat.clockRate} ${codecStat.sdpFmtpLine || ""}`, + {} + ) + ); + } + if (framesDropped !== undefined) { + elements.push( + renderElement( + "span", + { className: "stat-label" }, + "about-webrtc-dropped-frames-label" + ) + ); + elements.push(renderText("span", ` ${framesDropped}`, {})); + } + if (discardedPackets !== undefined) { + elements.push( + renderElement( + "span", + { className: "stat-label" }, + "about-webrtc-discarded-packets-label" + ) + ); + elements.push(renderText("span", ` ${discardedPackets}`, {})); + } + if (elements.length) { + if (packetsReceived !== undefined) { + elements.unshift( + renderElement("span", {}, "about-webrtc-decoder-label"), + renderText("span", ": ") + ); + } else { + elements.unshift( + renderElement("span", {}, "about-webrtc-encoder-label"), + renderText("span", ": ") + ); + } + } + return renderElements("div", {}, elements); +} + +function renderTransportStats( + { + id, + timestamp, + type, + packetsReceived, + bytesReceived, + packetsLost, + jitter, + roundTripTime, + packetsSent, + bytesSent, + }, + local, + hist +) { + if (hist) { + if (hist[id] === undefined) { + hist[id] = {}; + } + } + + const estimateKBps = (curTimestamp, lastTimestamp, bytes, lastBytes) => { + if (!curTimestamp || !lastTimestamp || !bytes || !lastBytes) { + return "0.0"; + } + const elapsedTime = curTimestamp - lastTimestamp; + if (elapsedTime <= 0) { + return "0.0"; + } + return ((bytes - lastBytes) / elapsedTime).toFixed(1); + }; + + let elements = []; + + if (local) { + elements.push( + renderElement("span", {}, "about-webrtc-type-local"), + renderText("span", ": ") + ); + } else { + elements.push( + renderElement("span", {}, "about-webrtc-type-remote"), + renderText("span", ": ") + ); + } + + const time = new Date(timestamp).toTimeString(); + elements.push(renderText("span", `${time} ${type}`)); + + if (packetsReceived) { + elements.push( + renderElement( + "span", + { className: "stat-label" }, + "about-webrtc-received-label", + { + packets: packetsReceived, + } + ) + ); + + if (bytesReceived) { + let s = ` (${(bytesReceived / 1024).toFixed(2)} Kb`; + if (local && hist) { + s += ` , ${estimateKBps( + timestamp, + hist[id].lastTimestamp, + bytesReceived, + hist[id].lastBytesReceived + )} KBps`; + } + s += ")"; + elements.push(renderText("span", s)); + } + + elements.push( + renderElement( + "span", + { className: "stat-label" }, + "about-webrtc-lost-label", + { + packets: packetsLost, + } + ) + ); + elements.push( + renderElement( + "span", + { className: "stat-label" }, + "about-webrtc-jitter-label", + { + jitter, + } + ) + ); + + if (roundTripTime !== undefined) { + elements.push(renderText("span", ` RTT: ${roundTripTime * 1000} ms`)); + } + } else if (packetsSent) { + elements.push( + renderElement( + "span", + { className: "stat-label" }, + "about-webrtc-sent-label", + { + packets: packetsSent, + } + ) + ); + if (bytesSent) { + let s = ` (${(bytesSent / 1024).toFixed(2)} Kb`; + if (local && hist) { + s += `, ${estimateKBps( + timestamp, + hist[id].lastTimestamp, + bytesSent, + hist[id].lastBytesSent + )} KBps`; + } + s += ")"; + elements.push(renderText("span", s)); + } + } + + // Update history + if (hist) { + hist[id].lastBytesReceived = bytesReceived; + hist[id].lastBytesSent = bytesSent; + hist[id].lastTimestamp = timestamp; + } + + return renderElements("div", {}, elements); +} + +function renderRawIceTable(caption, candidates) { + const table = renderSimpleTable( + "", + [caption], + [...new Set(candidates.sort())].filter(i => i).map(i => [i]) + ); + table.className = "raw-candidate"; + return table; +} + +function renderConfiguration(rndr, c) { + const provided = "about-webrtc-configuration-element-provided"; + const notProvided = "about-webrtc-configuration-element-not-provided"; + + // Create the text for a configuration field + const cfg = (obj, key) => [ + renderElement("br"), + `${key}: `, + key in obj ? obj[key] : renderElement("i", {}, notProvided), + ]; + + // Create the text for a fooProvided configuration field + const pro = (obj, key) => [ + renderElement("br"), + `${key}(`, + renderElement("i", {}, provided), + `/`, + renderElement("i", {}, notProvided), + `): `, + renderElement("i", {}, obj[`${key}Provided`] ? provided : notProvided), + ]; + + const confDiv = rndr.elem_div({ display: "contents" }); + let disclosure = renderFoldableSection(confDiv, { + showMsg: "about-webrtc-pc-configuration-show-msg", + hideMsg: "about-webrtc-pc-configuration-hide-msg", + }); + disclosure.append( + rndr.elems_div({ classList: "peer-connection-config" }, [ + "RTCConfiguration", + ...cfg(c, "bundlePolicy"), + ...cfg(c, "iceTransportPolicy"), + ...pro(c, "peerIdentity"), + ...cfg(c, "sdpSemantics"), + renderElement("br"), + "iceServers: ", + ...(!c.iceServers + ? [renderElement("i", {}, notProvided)] + : c.iceServers.map(i => + renderElements("div", {}, [ + `urls: ${JSON.stringify(i.urls)}`, + ...pro(i, "credential"), + ...pro(i, "userName"), + ]) + )), + ]) + ); + confDiv.append(disclosure); + return confDiv; +} + +function renderICEStats(rndr, report) { + const iceDiv = renderElements("div", { id: "ice-stats: " + report.pcid }, [ + renderSubsectionHeading("about-webrtc-ice-stats-heading", () => + JSON.stringify( + [...report.iceCandidateStats, ...report.iceCandidatePairStats], + null, + 2 + ) + ), + ]); + + // Render ICECandidate table + { + const caption = renderElement( + "caption", + { className: "no-print" }, + "about-webrtc-trickle-caption-msg" + ); + + // Generate ICE stats + const stats = []; + { + // Create an index based on candidate ID for each element in the + // iceCandidateStats array. + const candidates = {}; + for (const candidate of report.iceCandidateStats) { + candidates[candidate.id] = candidate; + } + + // a method to see if a given candidate id is in the array of tickled + // candidates. + const isTrickled = candidateId => + report.trickledIceCandidateStats.some(({ id }) => id == candidateId); + + // A component may have a remote or local candidate address or both. + // Combine those with both; these will be the peer candidates. + const matched = {}; + + for (const { + localCandidateId, + remoteCandidateId, + componentId, + state, + priority, + nominated, + selected, + bytesSent, + bytesReceived, + } of report.iceCandidatePairStats) { + const local = candidates[localCandidateId]; + if (local) { + const stat = { + ["local-candidate"]: candidateToString(local), + componentId, + state, + priority, + nominated, + selected, + bytesSent, + bytesReceived, + }; + matched[local.id] = true; + if (isTrickled(local.id)) { + stat["local-trickled"] = true; + } + + const remote = candidates[remoteCandidateId]; + if (remote) { + stat["remote-candidate"] = candidateToString(remote); + matched[remote.id] = true; + if (isTrickled(remote.id)) { + stat["remote-trickled"] = true; + } + } + stats.push(stat); + } + } + + // sort (group by) componentId first, then bytesSent if available, else by + // priority + stats.sort((a, b) => { + if (a.componentId != b.componentId) { + return a.componentId - b.componentId; + } + return b.bytesSent + ? b.bytesSent - (a.bytesSent || 0) + : (b.priority || 0) - (a.priority || 0); + }); + } + // Render ICE stats + // don't use |stat.x || ""| here because it hides 0 values + const statsTable = renderSimpleTable( + caption, + [ + "about-webrtc-ice-state", + "about-webrtc-nominated", + "about-webrtc-selected", + "about-webrtc-local-candidate", + "about-webrtc-remote-candidate", + "about-webrtc-ice-component-id", + "about-webrtc-priority", + "about-webrtc-ice-pair-bytes-sent", + "about-webrtc-ice-pair-bytes-received", + ], + stats.map(stat => + [ + stat.state, + stat.nominated, + stat.selected, + stat["local-candidate"], + stat["remote-candidate"], + stat.componentId, + stat.priority, + stat.bytesSent, + stat.bytesReceived, + ].map(entry => (Object.is(entry, undefined) ? "" : entry)) + ) + ); + + // after rendering the table, we need to change the class name for each + // candidate pair's local or remote candidate if it was trickled. + let index = 0; + for (const { + state, + nominated, + selected, + "local-trickled": localTrickled, + "remote-trickled": remoteTrickled, + } of stats) { + // look at statsTable row index + 1 to skip column headers + const { cells } = statsTable.rows[++index]; + cells[0].className = `ice-${state}`; + if (nominated) { + cells[1].className = "ice-succeeded"; + } + if (selected) { + cells[2].className = "ice-succeeded"; + } + if (localTrickled) { + cells[3].className = "ice-trickled"; + } + if (remoteTrickled) { + cells[4].className = "ice-trickled"; + } + } + + // if the current row's component id changes, mark the bottom of the + // previous row with a thin, black border to differentiate the + // component id grouping. + let previousRow; + for (const row of statsTable.rows) { + if (previousRow) { + if (previousRow.cells[5].innerHTML != row.cells[5].innerHTML) { + previousRow.className = "bottom-border"; + } + } + previousRow = row; + } + iceDiv.append(statsTable); + } + // restart/rollback counts. + iceDiv.append( + renderIceMetric("about-webrtc-ice-restart-count-label", report.iceRestarts), + renderIceMetric( + "about-webrtc-ice-rollback-count-label", + report.iceRollbacks + ) + ); + return iceDiv; +} + +function renderRawICEStats(rndr, report) { + const iceDiv = renderElements("div", { id: "ice-stats: " + report.pcid }, [ + renderSubsectionHeading("about-webrtc-raw-candidates-heading", () => + JSON.stringify( + [...report.rawLocalCandidates, ...report.rawRemoteCandidates], + null, + 2 + ) + ), + ]); + // Render raw ICECandidate section + { + const foldSection = renderFoldableSection(iceDiv, { + showMsg: "about-webrtc-raw-cand-section-show-msg", + hideMsg: "about-webrtc-raw-cand-section-hide-msg", + }); + + // render raw candidates + foldSection.append(renderRawICEStatsFold(rndr, report)); + iceDiv.append(foldSection); + } + return iceDiv; +} + +function renderRawICEStatsFold(rndr, report) { + return renderElements("div", { id: "ice-raw-stats-fold: " + report.pcid }, [ + renderRawIceTable( + "about-webrtc-raw-local-candidate", + report.rawLocalCandidates + ), + renderRawIceTable( + "about-webrtc-raw-remote-candidate", + report.rawRemoteCandidates + ), + ]); +} + +function renderIceMetric(label, value) { + return renderElements("div", {}, [ + renderElement("span", { className: "info-label" }, label), + renderText("span", value, { className: "info-body" }), + ]); +} + +function candidateToString({ + type, + address, + port, + protocol, + candidateType, + relayProtocol, + proxied, +} = {}) { + if (!type) { + return "*"; + } + if (relayProtocol) { + candidateType = `${candidateType}-${relayProtocol}`; + } + proxied = type == "local-candidate" ? ` [${proxied}]` : ""; + return `${address}:${port}/${protocol}(${candidateType})${proxied}`; +} + +async function renderConnectionLog() { + const getLog = () => new Promise(r => WGI.getLogging("", r)); + const logView = document.createElement("div"); + const displayLogs = logLines => { + logView.replaceChildren(); + logView.append( + ...logLines.map(line => { + const e = document.createElement("p"); + e.textContent = line; + return e; + }) + ); + }; + const clearLogsButton = document.createElement("button"); + + Object.assign(clearLogsButton, { + className: "no-print", + onclick: async () => { + await WGI.clearLogging(); + displayLogs(await getLog()); + }, + }); + document.l10n.setAttributes(clearLogsButton, "about-webrtc-log-clear"); + return PrimarySection.make({ + headingL10nId: "about-webrtc-log-heading", + disclosureShowL10nId: "about-webrtc-log-section-show-msg", + disclosureHideL10nId: "about-webrtc-log-section-hide-msg", + autoRefreshPref: "media.aboutwebrtc.auto_refresh.connection_log_section", + renderFn: async () => { + displayLogs(await getLog()); + return logView; + }, + updateFn: async () => { + displayLogs(await getLog()); + }, + headerElementsFn: async () => [clearLogsButton], + }); +} + +const PREFERENCES = { + branches: [ + "media.aboutwebrtc", + "media.peerconnection", + "media.navigator", + "media.getusermedia", + "media.gmp-gmpopenh264.enabled", + ], + hidden: [ + "media.aboutwebrtc.auto_refresh.peerconnection_section", + "media.aboutwebrtc.auto_refresh.connection_log_section", + "media.aboutwebrtc.auto_refresh.user_modified_config_section", + "media.aboutwebrtc.auto_refresh.media_ctx_section", + ], +}; + +async function renderUserPrefSection() { + const getConfigPaths = () => { + return PREFERENCES.branches + .flatMap(Services.prefs.getChildList) + .filter(Services.prefs.prefHasUserValue) + .filter(p => !PREFERENCES.hidden.includes(p)); + }; + const prefList = new ConfigurationList(getConfigPaths()); + return PrimarySection.make({ + headingL10nId: "about-webrtc-user-modified-configuration-heading", + disclosureShowL10nId: "about-webrtc-user-modified-configuration-show-msg", + disclosureHideL10nId: "about-webrtc-user-modified-configuration-hide-msg", + autoRefreshPref: + "media.aboutwebrtc.auto_refresh.user_modified_config_section", + renderFn: () => prefList.view(), + updateFn: () => { + prefList.setPrefPaths(getConfigPaths()); + prefList.update(); + }, + }); +} + +function renderFoldableSection(parentElem, options = {}) { + const section = renderElement("div"); + if (parentElem) { + const ctrl = renderElements("div", { className: "section-ctrl no-print" }, [ + new FoldEffect(section, options).render(), + ]); + parentElem.append(ctrl); + } + return section; +} + +function renderSimpleTable(caption, headings, data) { + const heads = headings.map(text => renderElement("th", {}, text)); + const renderCell = text => renderText("td", text); + + return renderElements("table", {}, [ + caption, + renderElements("tr", {}, heads), + ...data.map(line => renderElements("tr", {}, line.map(renderCell))), + ]); +} + +class FoldEffect { + constructor( + target, + { + showMsg = "about-webrtc-fold-default-show-msg", + hideMsg = "about-webrtc-fold-default-hide-msg", + startsCollapsed = true, + } = {} + ) { + Object.assign(this, { target, showMsg, hideMsg, startsCollapsed }); + } + + render() { + this.target.classList.add("fold-target"); + this.trigger = renderElement("div", { className: "fold-trigger" }); + this.trigger.classList.add("heading-medium", this.showMsg, this.hideMsg); + if (this.startsCollapsed) { + this.collapse(); + } + this.trigger.onclick = () => { + if (this.target.classList.contains("fold-closed")) { + this.expand(); + } else { + this.collapse(); + } + }; + return this.trigger; + } + + expand() { + this.target.classList.remove("fold-closed"); + document.l10n.setAttributes(this.trigger, this.hideMsg); + } + + collapse() { + this.target.classList.add("fold-closed"); + document.l10n.setAttributes(this.trigger, this.showMsg); + } + + static expandAll() { + for (const target of document.getElementsByClassName("fold-closed")) { + target.classList.remove("fold-closed"); + } + for (const trigger of document.getElementsByClassName("fold-trigger")) { + const hideMsg = trigger.classList[2]; + document.l10n.setAttributes(trigger, hideMsg); + } + } + + static collapseAll() { + for (const target of document.getElementsByClassName("fold-target")) { + target.classList.add("fold-closed"); + } + for (const trigger of document.getElementsByClassName("fold-trigger")) { + const showMsg = trigger.classList[1]; + document.l10n.setAttributes(trigger, showMsg); + } + } +} + +class PrimarySection { + /** @returns {Promise<PrimarySection>} */ + static async make({ + headingL10nId, + disclosureShowL10nId, + disclosureHideL10nId, + autoRefreshPref, + renderFn = async () => {}, // Creates the filling for the disclosure + updateFn = async section => {}, // Updates the contents. + headerElementsFn = async () => [], // Accessory elements for the heading + }) { + const newSect = new PrimarySection(); + Object.assign(newSect, { + autoRefreshPref, + renderFn, + updateFn, + headerElementsFn, + }); + + // Top level of the section + const sectionContainer = document.createElement("div"); + // Section heading is always visible and contains the disclosure control, + // the section title, the autorefresh button, and any accessory elements. + const sectionHeading = document.createElement("div"); + sectionHeading.className = "section-heading"; + sectionContainer.appendChild(sectionHeading); + // The section body is the portion that contains the disclosure body + // container. + const sectionBody = document.createElement("div"); + sectionBody.className = "section-body"; + sectionContainer.appendChild(sectionBody); + + const disclosure = new Disclosure({ + showMsg: disclosureShowL10nId, + hideMsg: disclosureHideL10nId, + }); + sectionHeading.appendChild(disclosure.control()); + + const heading = document.createElement("h3"); + document.l10n.setAttributes(heading, headingL10nId); + sectionHeading.append(heading); + + const autorefresh = document.createElement("input"); + Object.assign(autorefresh, { + type: "checkbox", + class: "autorefresh", + id: autoRefreshPref, + checked: Services.prefs.getBoolPref(autoRefreshPref), + onchange: () => + Services.prefs.setBoolPref(autoRefreshPref, autorefresh.checked), + }); + newSect.autorefresh = autorefresh; + newSect.autorefreshPrefState = newSect.autorefresh.checked; + const autorefreshLabel = document.createElement("label"); + autorefreshLabel.className = "autorefresh"; + autorefreshLabel.htmlFor = autorefresh.id; + document.l10n.setAttributes( + autorefreshLabel, + "about-webrtc-auto-refresh-label" + ); + sectionHeading.append(autorefresh, autorefreshLabel); + + let rendered = await renderFn(); + if (rendered) { + disclosure.view().appendChild(rendered); + } + sectionBody.append(disclosure.view()); + + let headerElements = (await newSect.headerElementsFn(newSect)) || []; + sectionHeading.append(...headerElements); + + newSect.section = sectionContainer; + return newSect; + } + view() { + return this.section; + } + async update() { + return this.updateFn(this); + } + async autoUpdate() { + let prefState = Services.prefs.getBoolPref(this.autoRefreshPref); + if (prefState != this.autorefreshPrefState) { + this.autorefreshPrefState = prefState; + this.autorefresh.checked = prefState; + } + if (this.autorefresh.checked || this.autorefresh.indeterminate) { + return this.updateFn(this); + } + return null; + } +} + +async function renderMediaCtx(rndr) { + const ctx = WGI.getMediaContext(); + const prefs = [ + "media.peerconnection.video.vp9_enabled", + "media.peerconnection.video.vp9_preferred", + "media.navigator.video.h264.level", + "media.navigator.video.h264.max_mbps", + "media.navigator.video.h264.max_mbps", + "media.navigator.video.max_fs", + "media.navigator.video.max_fr", + "media.navigator.video.use_tmmbr", + "media.navigator.video.use_remb", + "media.navigator.video.use_transport_cc", + "media.navigator.audio.use_fec", + "media.navigator.video.red_ulpfec_enabled", + ]; + + const confList = new ConfigurationList(prefs); + const hasH264Hardware = rndr.text_p( + `hasH264Hardware: ${ctx.hasH264Hardware}` + ); + hasH264Hardware.dataset.value = ctx.hasH264Hardware; + const renderFn = async () => + rndr.elems_div({}, [hasH264Hardware, rndr.elem_hr(), confList.view()]); + const updateFn = async section => { + const newCtx = WGI.getMediaContext(); + if (hasH264Hardware.dataset.value != newCtx.hasH264Hardware) { + hasH264Hardware.dataset.value = newCtx.hasH264Hardware; + hasH264Hardware.textContent = `hasH264Hardware: ${newCtx.hasH264Hardware}`; + } + confList.update(); + }; + + return PrimarySection.make({ + headingL10nId: "about-webrtc-media-context-heading", + disclosureShowL10nId: "about-webrtc-media-context-show-msg", + disclosureHideL10nId: "about-webrtc-media-context-hide-msg", + autoRefreshPref: "media.aboutwebrtc.auto_refresh.media_ctx_section", + renderFn, + updateFn, + }); +} diff --git a/toolkit/content/aboutwebrtc/configurationList.mjs b/toolkit/content/aboutwebrtc/configurationList.mjs new file mode 100644 index 0000000000..d9c209b9df --- /dev/null +++ b/toolkit/content/aboutwebrtc/configurationList.mjs @@ -0,0 +1,118 @@ +/* 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/. */ + +import { CopyButton } from "chrome://global/content/aboutwebrtc/copyButton.mjs"; + +function getPref(path) { + switch (Services.prefs.getPrefType(path)) { + case Services.prefs.PREF_BOOL: + return Services.prefs.getBoolPref(path); + case Services.prefs.PREF_INT: + return Services.prefs.getIntPref(path); + case Services.prefs.PREF_STRING: + return Services.prefs.getStringPref(path); + } + return ""; +} + +/* + * This provides a visual list of configuration settings given an array of + * configuration paths. To change the list one can call setPrefPaths. + */ +class ConfigurationList { + constructor(aPreferencePaths) { + this.list = document.createElement("list"); + this.list.classList.add("prefList"); + this.setPrefPaths(aPreferencePaths); + } + + /** @return {Element} */ + view() { + return this.list; + } + + /** + * @return {Element[]} + */ + getPrefListItems() { + return [...this.list.children].flatMap(e => + e.dataset.prefPath !== undefined ? [e] : [] + ); + } + + /** + * @return {string[]} + */ + getPrefPaths() { + return [...this.getPrefListItems()].map(e => e.dataset.prefPath); + } + + // setPrefPaths adds and removes list items from the list and updates + // existing elements + + setPrefPaths(aPreferencePaths) { + const currentPaths = this.getPrefPaths(); + // Take the difference of the two arrays of preferences. There are three + // groups of paths: those removed from the current list, those to remain + // in the current list, and those to be added. + const { kept: keptPaths, removed: removedPaths } = currentPaths.reduce( + (acc, p) => { + if (aPreferencePaths.includes(p)) { + acc.kept.push(p); + } else { + acc.removed.push(p); + } + return acc; + }, + { removed: [], kept: [] } + ); + + const addedPaths = aPreferencePaths.filter(p => !keptPaths.includes(p)); + + // Remove items + this.getPrefListItems() + .filter(e => removedPaths.includes(e.dataset.prefPath)) + .forEach(e => e.remove() /* Remove from DOM*/); + + const addItemForPath = path => { + const item = document.createElement("li"); + item.dataset.prefPath = path; + + item.appendChild(new CopyButton(() => path).element); + + const pathSpan = document.createElement("span"); + pathSpan.textContent = path; + pathSpan.classList.add(["pathDisplay"]); + item.appendChild(pathSpan); + + const valueSpan = document.createElement("span"); + valueSpan.classList.add(["valueDisplay"]); + item.appendChild(valueSpan); + + this.list.appendChild(item); + }; + + // Add items + addedPaths.forEach(addItemForPath); + + // Update all pref values + this.updatePrefValues(); + } + + updatePrefValues() { + for (const e of this.getPrefListItems()) { + const value = getPref(e.dataset.prefPath); + const valueSpan = e.getElementsByClassName("valueDisplay").item(0); + if ("prefPath" in e.dataset) { + valueSpan.textContent = value; + } + } + } + + update() { + this.updatePrefValues(); + } +} + +export { ConfigurationList }; diff --git a/toolkit/content/aboutwebrtc/copyButton.mjs b/toolkit/content/aboutwebrtc/copyButton.mjs new file mode 100644 index 0000000000..f39210c709 --- /dev/null +++ b/toolkit/content/aboutwebrtc/copyButton.mjs @@ -0,0 +1,83 @@ +/* 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/. */ + +/* + * This creates a button that can be used to copy text to the clipboard. + * Whenever the button is pressed the getCopyContentsFn passed into the + * constructor is called and the resulting text is copied. It uses CSS + * transitions to perform a short animation. + */ +class CopyButton { + constructor(getCopyContentsFn) { + const button = document.createElement("span"); + button.textContent = String.fromCodePoint(0x1f4cb); + button.classList.add("copy-button", "copy-button-base"); + button.onclick = () => { + if (!button.classList.contains("copy-button")) { + return; + } + + const handleAnimation = async () => { + const switchFadeDirection = () => { + if (button.classList.contains("copy-button-fade-out")) { + // We just faded out so let's fade in + button.classList.toggle("copy-button-fade-out"); + button.classList.toggle("copy-button-fade-in"); + } else { + // We just faded in so let's fade out + button.classList.toggle("copy-button-fade-out"); + button.classList.toggle("copy-button-fade-in"); + } + }; + + // Fade out clipboard icon + // Fade out the clipboard character + button.classList.toggle("copy-button-fade-out"); + // Wait for CSS transition to end + await new Promise(r => (button.ontransitionend = r)); + + // Fade in checkmark icon + // This is the start of fade in. + // Switch to the checkmark character + button.textContent = String.fromCodePoint(0x2705); + // Trigger CSS fade in transition + switchFadeDirection(); + // Wait for CSS transition to end + await new Promise(r => (button.ontransitionend = r)); + + // Fade out clipboard icon + // Trigger CSS fade out transition + switchFadeDirection(); + // Wait for CSS transition to end + await new Promise(r => (button.ontransitionend = r)); + + // Fade in clipboard icon + // This is the start of fade in. + // Switch to the clipboard character + button.textContent = String.fromCodePoint(0x1f4cb); + // Trigger CSS fade in transition + switchFadeDirection(); + // Wait for CSS transition to end + await new Promise(r => (button.ontransitionend = r)); + + // Remove fade + button.classList.toggle("copy-button-fade-in"); + // Re-enable clicks and hidding when parent div has lost :hover + button.classList.add("copy-button"); + }; + + // Note the fade effect is handled in the CSS, we just need to swap + // between the different CSS classes. This returns a promise that waits + // for the current fade to end, starts the next fade, then resolves. + + navigator.clipboard.writeText(getCopyContentsFn()); + // Prevent animation from disappearing when parent div losses :hover, + // and prevent additional clicks until the animation finishes. + button.classList.remove("copy-button"); + handleAnimation(); // runs unawaited + }; + this.element = button; + } +} +export { CopyButton }; diff --git a/toolkit/content/aboutwebrtc/disclosure.mjs b/toolkit/content/aboutwebrtc/disclosure.mjs new file mode 100644 index 0000000000..6460f6523d --- /dev/null +++ b/toolkit/content/aboutwebrtc/disclosure.mjs @@ -0,0 +1,95 @@ +/* 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/. */ + +const localization = new Localization(["toolkit/about/aboutWebrtc.ftl"], true); + +/* + * A disclosure area that has localized tooltips for expanding and collapsing + * the area. + */ +class Disclosure { + constructor({ + showMsg = "about-webrtc-fold-default-show-msg", + hideMsg = "about-webrtc-fold-default-hide-msg", + startsCollapsed = true, + } = {}) { + Object.assign(this, { showMsg, hideMsg, startsCollapsed }); + this.target = document.createElement("div"); + this.target.classList.add("fold-target"); + this.trigger = document.createElement("div"); + this.trigger.className = "fold-trigger"; + this.trigger.classList.add( + "heading-medium", + "no-print", + this.showMsg, + this.hideMsg + ); + this.message = document.createElement("span"); + + if (this.startsCollapsed) { + this.collapse(); + } else { + this.expand(); + } + this.trigger.onclick = () => { + if (this.target.classList.contains("fold-closed")) { + this.expand(); + } else { + this.collapse(); + } + }; + } + + /** @return {Element} */ + control() { + return this.trigger; + } + + /** @return {Element} */ + view() { + return this.target; + } + + expand() { + this.target.classList.remove("fold-closed"); + this.control().textContent = String.fromCodePoint(0x25bc); + this.control().setAttribute( + "title", + localization.formatValueSync(this.hideMsg) + ); + document.l10n.setAttributes(this.message, this.hideMsg); + } + + collapse() { + this.target.classList.add("fold-closed"); + this.trigger.textContent = String.fromCodePoint(0x25b6); + this.control().setAttribute( + "title", + localization.formatValueSync(this.showMsg) + ); + document.l10n.setAttributes(this.message, this.showMsg); + } + + static expandAll() { + for (const target of document.getElementsByClassName("fold-closed")) { + target.classList.remove("fold-closed"); + } + for (const trigger of document.getElementsByClassName("fold-trigger")) { + const hideMsg = trigger.classList[2]; + document.l10n.setAttributes(trigger, hideMsg); + } + } + + static collapseAll() { + for (const target of document.getElementsByClassName("fold-target")) { + target.classList.add("fold-closed"); + } + for (const trigger of document.getElementsByClassName("fold-trigger")) { + const showMsg = trigger.classList[1]; + document.l10n.setAttributes(trigger, showMsg); + } + } +} + +export { Disclosure }; diff --git a/toolkit/content/aboutwebrtc/graph.mjs b/toolkit/content/aboutwebrtc/graph.mjs new file mode 100644 index 0000000000..f2c93f0709 --- /dev/null +++ b/toolkit/content/aboutwebrtc/graph.mjs @@ -0,0 +1,186 @@ +/* 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/. */ + +function compStyle(property) { + return getComputedStyle(window.document.body).getPropertyValue(property); +} + +function toHumanReadable(num, fpDecimals) { + const prefixes = [..." kMGTPEYZYRQ"]; + const inner = (curr, remainingPrefixes) => { + return Math.abs(curr >= 1000) + ? inner(curr / 1000, remainingPrefixes.slice(1, -1)) + : [curr.toFixed(fpDecimals), remainingPrefixes[0].trimEnd()]; + }; + return inner(num, prefixes); +} + +class GraphImpl { + constructor(width, height) { + this.width = width; + this.height = height; + } + + // The returns the earliest time to graph + startTime = dataSet => (dataSet.earliest() || { time: 0 }).time; + + // Returns the latest time to graph + stopTime = dataSet => (dataSet.latest() || { time: 0 }).time; + + // The default background color + bgColor = () => compStyle("--in-content-page-background"); + // The color to use for value graph lines + valueLineColor = () => "grey"; + // The color to use for average graph lines and text + averageLineColor = () => "green"; + // The color to use for the max value + maxColor = ({ time, value }) => "grey"; + // The color to use for the min value + minColor = ({ time, value }) => "grey"; + // Title color + titleColor = title => compStyle("--in-content-page-color"); + // The color to use for a data point at a time. + // The destination x coordinate and graph width are also provided. + datumColor = ({ time, value, x, width }) => "red"; + + // Returns an SVG element that needs to be inserted into the DOM for display + drawSparseValues = (dataSet, title, config) => { + const { width, height } = this; + // Clear the canvas + const bgColor = this.bgColor(); + const mkSvgElem = type => + document.createElementNS("http://www.w3.org/2000/svg", type); + const svgText = (x, y, text, color, subclass) => { + const txt = mkSvgElem("text"); + txt.setAttribute("x", x); + txt.setAttribute("y", y); + txt.setAttribute("stroke", bgColor); + txt.setAttribute("fill", color); + txt.setAttribute("paint-order", "stroke"); + txt.textContent = text; + txt.classList.add(["graph-text", ...[subclass]].join("-")); + return txt; + }; + const svg = mkSvgElem("svg"); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute("version", "1.1"); + svg.setAttribute("width", width); + svg.setAttribute("height", height); + svg.classList.add("svg-graph"); + const rect = mkSvgElem("rect"); + rect.setAttribute("fill", bgColor); + rect.setAttribute("width", width); + rect.setAttribute("height", height); + svg.appendChild(rect); + + if (config.toRate) { + dataSet = dataSet.toRateDataSet(); + } + + const startTime = this.startTime(dataSet); + const stopTime = this.stopTime(dataSet); + let timeFilter = ({ time }) => time >= startTime && time <= stopTime; + + let avgDataSet = { dataPoints: [] }; + if (!config.noAvg) { + avgDataSet = dataSet.toRollingAverageDataSet(config.avgPoints); + } + + let filtered = dataSet.filter(timeFilter); + if (filtered.dataPoints == []) { + return svg; + } + + let range = filtered.dataRange(); + if (range === undefined) { + return svg; + } + let { min: rangeMin, max: rangeMax } = range; + + // Adjust the _display_ range to lift flat lines towards the center + if (rangeMin == rangeMax) { + rangeMin = rangeMin - 1; + rangeMax = rangeMax + 1; + } + const yFactor = (height - 26) / (1 + rangeMax - rangeMin); + const yPos = ({ value }) => + this.height - 1 - (value - rangeMin) * yFactor - 13; + const xFactor = width / (1 + stopTime - startTime); + const xPos = ({ time }) => (time - startTime) * xFactor; + + const toPathStr = dataPoints => + [...dataPoints] + .map( + (datum, index) => `${index ? "L" : "M"}${xPos(datum)} ${yPos(datum)}` + ) + .join(" "); + const valuePath = mkSvgElem("path"); + valuePath.setAttribute("d", toPathStr(filtered.dataPoints)); + valuePath.setAttribute("stroke", this.valueLineColor()); + valuePath.setAttribute("fill", "none"); + svg.appendChild(valuePath); + + const avgPath = mkSvgElem("path"); + avgPath.setAttribute("d", toPathStr(avgDataSet.dataPoints)); + avgPath.setAttribute("stroke", this.averageLineColor()); + avgPath.setAttribute("fill", "none"); + svg.appendChild(avgPath); + const fixed = num => num.toFixed(config.fixedPointDecimals); + const formatValue = value => + config.toHuman + ? toHumanReadable(value, config.fixedPointDecimals).join("") + : fixed(value); + + // Draw rolling average text + avgDataSet.dataPoints.slice(-1).forEach(({ value }) => { + svg.appendChild( + svgText( + 5, + height - 4, + `AVG: ${formatValue(value)}`, + this.averageLineColor(), + "avg" + ) + ); + }); + + // Draw title text + if (title) { + svg.appendChild( + svgText( + 5, + 12, + `${title}${config.toRate ? "/s" : ""}`, + this.titleColor(this), + "title" + ) + ); + } + + // Draw max value text + const maxText = svgText( + width - 5, + 12, + `Max: ${formatValue(range.max)}`, + this.maxColor(range.max), + "max" + ); + maxText.setAttribute("text-anchor", "end"); + svg.appendChild(maxText); + + // Draw min value text + const minText = svgText( + width - 5, + height - 4, + `Min: ${formatValue(range.min)}`, + this.minColor(range.min), + "min" + ); + minText.setAttribute("text-anchor", "end"); + svg.appendChild(minText); + return svg; + }; +} + +export { GraphImpl }; diff --git a/toolkit/content/aboutwebrtc/graphdb.mjs b/toolkit/content/aboutwebrtc/graphdb.mjs new file mode 100644 index 0000000000..2d20f334a5 --- /dev/null +++ b/toolkit/content/aboutwebrtc/graphdb.mjs @@ -0,0 +1,211 @@ +/* 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/. */ + +const CHECK_RTC_STATS_COLLECTION = [ + "inboundRtpStreamStats", + "outboundRtpStreamStats", + "remoteInboundRtpStreamStats", + "remoteOutboundRtpStreamStats", +]; + +const DEFAULT_PROPS = { + avgPoints: 10, + histSecs: 15, + toRate: false, + noAvg: false, + fixedPointDecimals: 2, + toHuman: false, +}; + +const REMOTE_RTP_PROPS = "avgPoints=2;histSecs=90"; +const GRAPH_KEYS = [ + "inbound-rtp.framesPerSecond;noAvg", + "inbound-rtp.packetsReceived;toRate", + "inbound-rtp.packetsLost;toRate", + "inbound-rtp.jitter;fixedPointDecimals=4", + `remote-inbound-rtp.roundTripTime;${REMOTE_RTP_PROPS}`, + `remote-inbound-rtp.packetsReceived;toRate;${REMOTE_RTP_PROPS}`, + "outbound-rtp.packetsSent;toRate", + "outbound-rtp.framesSent;toRate", + "outbound-rtp.frameHeight;noAvg", + "outbound-rtp.frameWidth;noAvg", + "outbound-rtp.nackCount", + "outbound-rtp.pliCount", + "outbound-rtp.firCount", + `remote-outbound-rtp.bytesSent;toHuman;toRate;${REMOTE_RTP_PROPS}`, + `remote-outbound-rtp.packetsSent;toRate;${REMOTE_RTP_PROPS}`, +] + .map(k => k.split(".", 2)) + .reduce((mapOfArr, [k, rest]) => { + mapOfArr[k] ??= []; + const [subKey, ...conf] = rest.split(";"); + let config = conf.reduce((c, v) => { + let [configName, ...configVal] = v.split("=", 2); + c[configName] = !configVal.length ? true : configVal[0]; + return c; + }, {}); + mapOfArr[k].push({ subKey, config }); + return mapOfArr; + }, {}); + +// Sliding window iterator of size n (where: n >= 1) over the array. +// Only returns full windows. +// Returns [] if n > array.length. +// eachN(['a','b','c','d','e'], 3) will yield the following values: +// ['a','b','c'], ['b','c','d'], and ['c','d','e'] +const eachN = (array, n) => { + return { + // Index state + index: 0, + // Iteration function + next() { + let slice = array.slice(this.index, this.index + n); + this.index++; + // Done is true _AFTER_ the last value has returned. + // When done is true, value is ignored. + return { value: slice, done: slice.length < n }; + }, + [Symbol.iterator]() { + return this; + }, + }; +}; + +const msToSec = ms => 1000 * ms; + +// +// A subset of the graph data +// +class GraphDataSet { + constructor(dataPoints) { + this.dataPoints = dataPoints; + } + + // The latest + latest = () => (this.dataPoints ? this.dataPoints.slice(-1)[0] : undefined); + + earliest = () => (this.dataPoints ? this.dataPoints[0] : undefined); + + // The returns the earliest time to graph + startTime = () => (this.earliest() || { time: 0 }).time; + + // Returns the latest time to graph + stopTime = () => (this.latest() || { time: 0 }).time; + + // Elapsed time within the display window + elapsed = () => + this.dataPoints ? this.latest().time - this.earliest().time : 0; + + // Return a new data set that has been filtered + filter = fn => new GraphDataSet([...this.dataPoints].filter(fn)); + + // The range of values in the set or or undefined if the set is empty + dataRange = () => + this.dataPoints.reduce( + ({ min, max }, { value }) => ({ + min: Math.min(min, value), + max: Math.max(max, value), + }), + this.dataPoints.length + ? { min: this.dataPoints[0].value, max: this.dataPoints[0].value } + : undefined + ); + + // Get the rates between points. By definition the rates will have + // one fewer data points. + toRateDataSet = () => + new GraphDataSet( + [...eachN(this.dataPoints, 2)].map(([a, b]) => ({ + // Time mid point + time: (b.time + a.time) / 2, + value: msToSec(b.value - a.value) / (b.time - a.time), + })) + ); + + average = samples => + samples.reduce( + ({ time, value }, { time: t, value: v }) => ({ + time: time + t / samples.length, + value: value + v / samples.length, + }), + { time: 0, value: 0 } + ); + + toRollingAverageDataSet = sampleSize => + new GraphDataSet([...eachN(this.dataPoints, sampleSize)].map(this.average)); +} + +class GraphData { + constructor(id, key, subKey, config) { + this.id = id; + this.key = key; + this.subKey = subKey; + this.data = []; + this.config = Object.assign({}, DEFAULT_PROPS, config); + } + + setValueForTime(dataPoint) { + this.data = this.data.filter(({ time: t }) => t != dataPoint.time); + this.data.push(dataPoint); + } + + getValuesSince = time => this.data.filter(dp => dp.time > time); + + getDataSetSince = time => + new GraphDataSet(this.data.filter(dp => dp.time > time)); + + getConfig = () => this.config; + + // Cull old data, but keep twice the window size for average computation + cullData = timeNow => + (this.data = this.data.filter( + ({ time }) => time + msToSec(this.config.histSecs * 2) > timeNow + )); +} + +class GraphDb { + constructor(report) { + this.graphDatas = new Map(); + this.insertReportData(report); + } + + mkStoreKey = ({ id, key, subKey }) => `${key}.${id}.${subKey}`; + + insertDataPoint(id, key, subKey, config, time, value) { + let storeKey = this.mkStoreKey({ id, key, subKey }); + let data = + this.graphDatas.get(storeKey) || new GraphData(id, key, subKey, config); + data.setValueForTime({ time, value }); + data.cullData(time); + this.graphDatas.set(storeKey, data); + } + + insertReportData(report) { + if (report.timestamp == this.lastReportTimestamp) { + return; + } + this.lastReportTimestamp = report.timestamp; + CHECK_RTC_STATS_COLLECTION.forEach(listName => { + (report[listName] || []).forEach(stats => { + (GRAPH_KEYS[stats.type] || []).forEach(({ subKey, config }) => { + if (stats[subKey] !== undefined) { + this.insertDataPoint( + stats.id, + stats.type, + subKey, + config, + stats.timestamp, + stats[subKey] + ); + } + }); + }); + }); + } + + getGraphDataById = id => + [...this.graphDatas.values()].filter(gd => gd.id == id); +} + +export { GraphDb }; diff --git a/toolkit/content/autocomplete.css b/toolkit/content/autocomplete.css new file mode 100644 index 0000000000..d6bef6a63d --- /dev/null +++ b/toolkit/content/autocomplete.css @@ -0,0 +1,25 @@ +/* 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/. */ + +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +@namespace html url("http://www.w3.org/1999/xhtml"); + +/* Apply crisp rendering for favicons at exactly 2dppx resolution */ +@media (resolution: 2dppx) { + .ac-site-icon { + image-rendering: -moz-crisp-edges; + } +} + +.autocomplete-richlistbox > richlistitem { + flex-direction: row; + overflow: hidden; +} + +.ac-title-text, +.ac-url-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/toolkit/content/buildconfig.css b/toolkit/content/buildconfig.css new file mode 100644 index 0000000000..e4c9306c87 --- /dev/null +++ b/toolkit/content/buildconfig.css @@ -0,0 +1,15 @@ +/* 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/. */ + +h2 { + margin-top: 1.5em; +} + +p { + font: message-box; +} + +.build-platform-table { + width: auto; +} diff --git a/toolkit/content/buildconfig.html b/toolkit/content/buildconfig.html new file mode 100644 index 0000000000..9c7ada2c83 --- /dev/null +++ b/toolkit/content/buildconfig.html @@ -0,0 +1,72 @@ +<!DOCTYPE html> +# 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/. +# +#filter substitution +#include @TOPOBJDIR@/source-repo.h +<html> + <head> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src chrome:; object-src 'none'" /> + <meta charset="UTF-8"> + <meta name="color-scheme" content="light dark"> + <meta name="viewport" content="width=device-width; user-scalable=false;"> + <title>Build Configuration</title> + <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" type="text/css"> + <link rel="stylesheet" href="chrome://global/content/buildconfig.css" type="text/css"> + </head> + <body> + <div class="container"> + <h1>Build Configuration</h1> + <p>Please be aware that this page doesn't reflect all the options used to build @MOZ_APP_DISPLAYNAME@.</p> + #ifdef MOZ_SOURCE_URL + <h2>Source</h2> + <p>Built from <a href="@MOZ_SOURCE_URL@">@MOZ_SOURCE_URL@</a></p> + #endif + <h2>Build platform</h2> + <table class="build-platform-table"> + <tbody> + <tr> + <th>target</th> + </tr> + <tr> + <td>@target@</td> + </tr> + </tbody> + </table> + #if defined(CC) && defined(CXX) && defined(RUSTC) + <h2>Build tools</h2> + <table> + <tbody> + <tr> + <th>Compiler</th> + <th>Version</th> + <th>Compiler flags</th> + </tr> + <tr> + <td>@CC@</td> + <td>@CC_VERSION@</td> + <td>@CFLAGS@</td> + </tr> + <tr> + <td>@CXX@</td> + <td>@CC_VERSION@</td> + <td>@CXXFLAGS@</td> + </tr> + <tr> + <td>@RUSTC@</td> + <td>@RUSTC_VERSION@</td> + <td>@RUSTFLAGS@</td> + </tr> + </tbody> + </table> + #endif + <h2>Configure options</h2> + <p>@MOZ_CONFIGURE_OPTIONS@</p> + #ifdef ANDROID + <h2>Package name</h2> + <p>@ANDROID_PACKAGE_NAME@</p> + #endif + </div> + </body> +</html> diff --git a/toolkit/content/contentAreaUtils.js b/toolkit/content/contentAreaUtils.js new file mode 100644 index 0000000000..d9ee83026e --- /dev/null +++ b/toolkit/content/contentAreaUtils.js @@ -0,0 +1,1236 @@ +/* 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/. */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + Deprecated: "resource://gre/modules/Deprecated.sys.mjs", + DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs", + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +var ContentAreaUtils = { + get stringBundle() { + delete this.stringBundle; + return (this.stringBundle = Services.strings.createBundle( + "chrome://global/locale/contentAreaCommands.properties" + )); + }, +}; + +function urlSecurityCheck( + aURL, + aPrincipal, + aFlags = Services.scriptSecurityManager +) { + if (aURL instanceof Ci.nsIURI) { + Services.scriptSecurityManager.checkLoadURIWithPrincipal( + aPrincipal, + aURL, + aFlags + ); + } else { + Services.scriptSecurityManager.checkLoadURIStrWithPrincipal( + aPrincipal, + aURL, + aFlags + ); + } +} + +// Clientele: (Make sure you don't break any of these) +// - File -> Save Page/Frame As... +// - Context -> Save Page/Frame As... +// - Context -> Save Link As... +// - Alt-Click links in web pages +// - Alt-Click links in the UI +// +// Try saving each of these types: +// - A complete webpage using File->Save Page As, and Context->Save Page As +// - A webpage as HTML only using the above methods +// - A webpage as Text only using the above methods +// - An image with an extension (e.g. .jpg) in its file name, using +// Context->Save Image As... +// - An image without an extension (e.g. a banner ad on cnn.com) using +// the above method. +// - A linked document using Save Link As... +// - A linked document using Alt-click Save Link As... +// +function saveURL( + aURL, + aOriginalURL, + aFileName, + aFilePickerTitleKey, + aShouldBypassCache, + aSkipPrompt, + aReferrerInfo, + aCookieJarSettings, + aSourceDocument, + aIsContentWindowPrivate, + aPrincipal +) { + internalSave( + aURL, + aOriginalURL, + null, + aFileName, + null, + null, + aShouldBypassCache, + aFilePickerTitleKey, + null, + aReferrerInfo, + aCookieJarSettings, + aSourceDocument, + aSkipPrompt, + null, + aIsContentWindowPrivate, + aPrincipal + ); +} + +// Save the current document inside any browser/frame-like element, +// whether in-process or out-of-process. +function saveBrowser(aBrowser, aSkipPrompt, aBrowsingContext = null) { + if (!aBrowser) { + throw new Error("Must have a browser when calling saveBrowser"); + } + let persistable = aBrowser.frameLoader; + // PDF.js has its own way to handle saving PDFs since it may need to + // generate a new PDF to save modified form data. + if (aBrowser.contentPrincipal.spec == "resource://pdf.js/web/viewer.html") { + aBrowser.sendMessageToActor("PDFJS:Save", {}, "Pdfjs"); + return; + } + let stack = Components.stack.caller; + persistable.startPersistence(aBrowsingContext, { + onDocumentReady(document) { + if (!document || !(document instanceof Ci.nsIWebBrowserPersistDocument)) { + throw new Error("Must have an nsIWebBrowserPersistDocument!"); + } + + internalSave( + document.documentURI, + null, // originalURL + document, + null, // file name + document.contentDisposition, + document.contentType, + false, // bypass cache + null, // file picker title key + null, // chosen file data + document.referrerInfo, + document.cookieJarSettings, + document, + aSkipPrompt, + document.cacheKey + ); + }, + onError(status) { + throw new Components.Exception( + "saveBrowser failed asynchronously in startPersistence", + status, + stack + ); + }, + }); +} + +function DownloadListener(win, transfer) { + function makeClosure(name) { + return function () { + transfer[name].apply(transfer, arguments); + }; + } + + this.window = win; + + // Now... we need to forward all calls to our transfer + for (var i in transfer) { + if (i != "QueryInterface") { + this[i] = makeClosure(i); + } + } +} + +DownloadListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIInterfaceRequestor", + "nsIWebProgressListener", + "nsIWebProgressListener2", + ]), + + getInterface: function dl_gi(aIID) { + if (aIID.equals(Ci.nsIAuthPrompt) || aIID.equals(Ci.nsIAuthPrompt2)) { + var ww = Cc["@mozilla.org/embedcomp/window-watcher;1"].getService( + Ci.nsIPromptFactory + ); + return ww.getPrompt(this.window, aIID); + } + + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, +}; + +const kSaveAsType_Complete = 0; // Save document with attached objects. +XPCOMUtils.defineConstant(this, "kSaveAsType_Complete", 0); +// const kSaveAsType_URL = 1; // Save document or URL by itself. +const kSaveAsType_Text = 2; // Save document, converting to plain text. +XPCOMUtils.defineConstant(this, "kSaveAsType_Text", kSaveAsType_Text); + +/** + * internalSave: Used when saving a document or URL. + * + * If aChosenData is null, this method: + * - Determines a local target filename to use + * - Prompts the user to confirm the destination filename and save mode + * (aContentType affects this) + * - [Note] This process involves the parameters aURL, aReferrerInfo, + * aDocument, aDefaultFileName, aFilePickerTitleKey, and aSkipPrompt. + * + * If aChosenData is non-null, this method: + * - Uses the provided source URI and save file name + * - Saves the document as complete DOM if possible (aDocument present and + * right aContentType) + * - [Note] The parameters aURL, aDefaultFileName, aFilePickerTitleKey, and + * aSkipPrompt are ignored. + * + * In any case, this method: + * - Creates a 'Persist' object (which will perform the saving in the + * background) and then starts it. + * - [Note] This part of the process only involves the parameters aDocument, + * aShouldBypassCache and aReferrerInfo. The source, the save name and the + * save mode are the ones determined previously. + * + * @param aURL + * The String representation of the URL of the document being saved + * @param aOriginalURL + * The String representation of the original URL of the document being + * saved. It can useful in case aURL is a blob. + * @param aDocument + * The document to be saved + * @param aDefaultFileName + * The caller-provided suggested filename if we don't + * find a better one + * @param aContentDisposition + * The caller-provided content-disposition header to use. + * @param aContentType + * The caller-provided content-type to use + * @param aShouldBypassCache + * If true, the document will always be refetched from the server + * @param aFilePickerTitleKey + * Alternate title for the file picker + * @param aChosenData + * If non-null this contains an instance of object AutoChosen (see below) + * which holds pre-determined data so that the user does not need to be + * prompted for a target filename. + * @param aReferrerInfo + * the referrerInfo object to use, or null if no referrer should be sent. + * @param aCookieJarSettings + * the cookieJarSettings object to use. This will be used for the channel + * used to save. + * @param aInitiatingDocument [optional] + * The document from which the save was initiated. + * If this is omitted then aIsContentWindowPrivate has to be provided. + * @param aSkipPrompt [optional] + * If set to true, we will attempt to save the file to the + * default downloads folder without prompting. + * @param aCacheKey [optional] + * If set will be passed to saveURI. See nsIWebBrowserPersist for + * allowed values. + * @param aIsContentWindowPrivate [optional] + * This parameter is provided when the aInitiatingDocument is not a + * real document object. Stores whether aInitiatingDocument.defaultView + * was private or not. + * @param aPrincipal [optional] + * This parameter is provided when neither aDocument nor + * aInitiatingDocument is provided. Used to determine what level of + * privilege to load the URI with. + */ +function internalSave( + aURL, + aOriginalURL, + aDocument, + aDefaultFileName, + aContentDisposition, + aContentType, + aShouldBypassCache, + aFilePickerTitleKey, + aChosenData, + aReferrerInfo, + aCookieJarSettings, + aInitiatingDocument, + aSkipPrompt, + aCacheKey, + aIsContentWindowPrivate, + aPrincipal +) { + if (aSkipPrompt == undefined) { + aSkipPrompt = false; + } + + if (aCacheKey == undefined) { + aCacheKey = 0; + } + + // Note: aDocument == null when this code is used by save-link-as... + var saveMode = GetSaveModeForContentType(aContentType, aDocument); + + var file, sourceURI, saveAsType; + let contentPolicyType = Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD; + // Find the URI object for aURL and the FileName/Extension to use when saving. + // FileName/Extension will be ignored if aChosenData supplied. + if (aChosenData) { + file = aChosenData.file; + sourceURI = aChosenData.uri; + saveAsType = kSaveAsType_Complete; + + continueSave(); + } else { + var charset = null; + if (aDocument) { + charset = aDocument.characterSet; + } + var fileInfo = new FileInfo(aDefaultFileName); + initFileInfo( + fileInfo, + aURL, + charset, + aDocument, + aContentType, + aContentDisposition + ); + sourceURI = fileInfo.uri; + + if (aContentType && aContentType.startsWith("image/")) { + contentPolicyType = Ci.nsIContentPolicy.TYPE_IMAGE; + } + var fpParams = { + fpTitleKey: aFilePickerTitleKey, + fileInfo, + contentType: aContentType, + saveMode, + saveAsType: kSaveAsType_Complete, + file, + }; + + // Find a URI to use for determining last-downloaded-to directory + let relatedURI = + aOriginalURL || aReferrerInfo?.originalReferrer || sourceURI; + + promiseTargetFile(fpParams, aSkipPrompt, relatedURI) + .then(aDialogAccepted => { + if (!aDialogAccepted) { + return; + } + + saveAsType = fpParams.saveAsType; + file = fpParams.file; + + continueSave(); + }) + .catch(console.error); + } + + function continueSave() { + // XXX We depend on the following holding true in appendFiltersForContentType(): + // If we should save as a complete page, the saveAsType is kSaveAsType_Complete. + // If we should save as text, the saveAsType is kSaveAsType_Text. + var useSaveDocument = + aDocument && + ((saveMode & SAVEMODE_COMPLETE_DOM && + saveAsType == kSaveAsType_Complete) || + (saveMode & SAVEMODE_COMPLETE_TEXT && saveAsType == kSaveAsType_Text)); + // If we're saving a document, and are saving either in complete mode or + // as converted text, pass the document to the web browser persist component. + // If we're just saving the HTML (second option in the list), send only the URI. + + let isPrivate = aIsContentWindowPrivate; + if (isPrivate === undefined) { + isPrivate = + aInitiatingDocument.nodeType == 9 /* DOCUMENT_NODE */ + ? PrivateBrowsingUtils.isContentWindowPrivate( + aInitiatingDocument.defaultView + ) + : aInitiatingDocument.isPrivate; + } + + // We have to cover the cases here where we were either passed an explicit + // principal, or a 'real' document (with a nodePrincipal property), or an + // nsIWebBrowserPersistDocument which has a principal property. + let sourcePrincipal = + aPrincipal || + (aDocument && (aDocument.nodePrincipal || aDocument.principal)) || + (aInitiatingDocument && aInitiatingDocument.nodePrincipal); + + let sourceOriginalURI = aOriginalURL ? makeURI(aOriginalURL) : null; + + var persistArgs = { + sourceURI, + sourceOriginalURI, + sourcePrincipal, + sourceReferrerInfo: aReferrerInfo, + sourceDocument: useSaveDocument ? aDocument : null, + targetContentType: saveAsType == kSaveAsType_Text ? "text/plain" : null, + targetFile: file, + sourceCacheKey: aCacheKey, + sourcePostData: aDocument ? getPostData(aDocument) : null, + bypassCache: aShouldBypassCache, + contentPolicyType, + cookieJarSettings: aCookieJarSettings, + isPrivate, + }; + + // Start the actual save process + internalPersist(persistArgs); + } +} + +/** + * internalPersist: Creates a 'Persist' object (which will perform the saving + * in the background) and then starts it. + * + * @param persistArgs.sourceURI + * The nsIURI of the document being saved + * @param persistArgs.sourceCacheKey [optional] + * If set will be passed to saveURI + * @param persistArgs.sourceDocument [optional] + * The document to be saved, or null if not saving a complete document + * @param persistArgs.sourceReferrerInfo + * Required and used only when persistArgs.sourceDocument is NOT present, + * the nsIReferrerInfo of the referrer info to use, or null if no + * referrer should be sent. + * @param persistArgs.sourcePostData + * Required and used only when persistArgs.sourceDocument is NOT present, + * represents the POST data to be sent along with the HTTP request, and + * must be null if no POST data should be sent. + * @param persistArgs.targetFile + * The nsIFile of the file to create + * @param persistArgs.contentPolicyType + * The type of content we're saving. Will be used to determine what + * content is accepted, enforce sniffing restrictions, etc. + * @param persistArgs.cookieJarSettings [optional] + * The nsICookieJarSettings that will be used for the saving channel, or + * null that saveURI will create one based on the current + * state of the prefs/permissions + * @param persistArgs.targetContentType + * Required and used only when persistArgs.sourceDocument is present, + * determines the final content type of the saved file, or null to use + * the same content type as the source document. Currently only + * "text/plain" is meaningful. + * @param persistArgs.bypassCache + * If true, the document will always be refetched from the server + * @param persistArgs.isPrivate + * Indicates whether this is taking place in a private browsing context. + */ +function internalPersist(persistArgs) { + var persist = makeWebBrowserPersist(); + + // Calculate persist flags. + const nsIWBP = Ci.nsIWebBrowserPersist; + const flags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES; + if (persistArgs.bypassCache) { + persist.persistFlags = flags | nsIWBP.PERSIST_FLAGS_BYPASS_CACHE; + } else { + persist.persistFlags = flags | nsIWBP.PERSIST_FLAGS_FROM_CACHE; + } + + // Leave it to WebBrowserPersist to discover the encoding type (or lack thereof): + persist.persistFlags |= nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION; + + // Find the URI associated with the target file + var targetFileURL = makeFileURI(persistArgs.targetFile); + + // Create download and initiate it (below) + var tr = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer); + tr.init( + persistArgs.sourceURI, + persistArgs.sourceOriginalURI, + targetFileURL, + "", + null, + null, + null, + persist, + persistArgs.isPrivate, + Ci.nsITransfer.DOWNLOAD_ACCEPTABLE, + persistArgs.sourceReferrerInfo + ); + persist.progressListener = new DownloadListener(window, tr); + + if (persistArgs.sourceDocument) { + // Saving a Document, not a URI: + var filesFolder = null; + if (persistArgs.targetContentType != "text/plain") { + // Create the local directory into which to save associated files. + filesFolder = persistArgs.targetFile.clone(); + + var nameWithoutExtension = getFileBaseName(filesFolder.leafName); + var filesFolderLeafName = + ContentAreaUtils.stringBundle.formatStringFromName("filesFolder", [ + nameWithoutExtension, + ]); + + filesFolder.leafName = filesFolderLeafName; + } + + var encodingFlags = 0; + if (persistArgs.targetContentType == "text/plain") { + encodingFlags |= nsIWBP.ENCODE_FLAGS_FORMATTED; + encodingFlags |= nsIWBP.ENCODE_FLAGS_ABSOLUTE_LINKS; + encodingFlags |= nsIWBP.ENCODE_FLAGS_NOFRAMES_CONTENT; + } else { + encodingFlags |= nsIWBP.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES; + } + + const kWrapColumn = 80; + persist.saveDocument( + persistArgs.sourceDocument, + targetFileURL, + filesFolder, + persistArgs.targetContentType, + encodingFlags, + kWrapColumn + ); + } else { + persist.saveURI( + persistArgs.sourceURI, + persistArgs.sourcePrincipal, + persistArgs.sourceCacheKey, + persistArgs.sourceReferrerInfo, + persistArgs.cookieJarSettings, + persistArgs.sourcePostData, + null, + targetFileURL, + persistArgs.contentPolicyType || Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD, + persistArgs.isPrivate + ); + } +} + +/** + * Structure for holding info about automatically supplied parameters for + * internalSave(...). This allows parameters to be supplied so the user does not + * need to be prompted for file info. + * @param aFileAutoChosen This is an nsIFile object that has been + * pre-determined as the filename for the target to save to + * @param aUriAutoChosen This is the nsIURI object for the target + */ +function AutoChosen(aFileAutoChosen, aUriAutoChosen) { + this.file = aFileAutoChosen; + this.uri = aUriAutoChosen; +} + +/** + * Structure for holding info about a URL and the target filename it should be + * saved to. This structure is populated by initFileInfo(...). + * @param aSuggestedFileName This is used by initFileInfo(...) when it + * cannot 'discover' the filename from the url + * @param aFileName The target filename + * @param aFileBaseName The filename without the file extension + * @param aFileExt The extension of the filename + * @param aUri An nsIURI object for the url that is being saved + */ +function FileInfo( + aSuggestedFileName, + aFileName, + aFileBaseName, + aFileExt, + aUri +) { + this.suggestedFileName = aSuggestedFileName; + this.fileName = aFileName; + this.fileBaseName = aFileBaseName; + this.fileExt = aFileExt; + this.uri = aUri; +} + +/** + * Determine what the 'default' filename string is, its file extension and the + * filename without the extension. This filename is used when prompting the user + * for confirmation in the file picker dialog. + * @param aFI A FileInfo structure into which we'll put the results of this method. + * @param aURL The String representation of the URL of the document being saved + * @param aURLCharset The charset of aURL. + * @param aDocument The document to be saved + * @param aContentType The content type we're saving, if it could be + * determined by the caller. + * @param aContentDisposition The content-disposition header for the object + * we're saving, if it could be determined by the caller. + */ +function initFileInfo( + aFI, + aURL, + aURLCharset, + aDocument, + aContentType, + aContentDisposition +) { + try { + let uriExt = null; + // Get an nsIURI object from aURL if possible: + try { + aFI.uri = makeURI(aURL, aURLCharset); + // Assuming nsiUri is valid, calling QueryInterface(...) on it will + // populate extra object fields (eg filename and file extension). + uriExt = aFI.uri.QueryInterface(Ci.nsIURL).fileExtension; + } catch (e) {} + + // Get the default filename: + let fileName = getDefaultFileName( + aFI.suggestedFileName || aFI.fileName, + aFI.uri, + aDocument, + aContentDisposition + ); + + let mimeService = this.getMIMEService(); + aFI.fileName = mimeService.validateFileNameForSaving( + fileName, + aContentType, + mimeService.VALIDATE_FORCE_APPEND_EXTENSION + ); + + // If uriExt is blank, consider: aFI.suggestedFileName is supplied if + // saveURL(...) was the original caller (hence both aContentType and + // aDocument are blank). If they were saving a link to a website then make + // the extension .htm . + if ( + !uriExt && + !aDocument && + !aContentType && + /^http(s?):\/\//i.test(aURL) + ) { + aFI.fileExt = "htm"; + aFI.fileBaseName = aFI.fileName; + } else { + let idx = aFI.fileName.lastIndexOf("."); + aFI.fileBaseName = + idx >= 0 ? aFI.fileName.substring(0, idx) : aFI.fileName; + aFI.fileExt = idx >= 0 ? aFI.fileName.substring(idx + 1) : null; + } + } catch (e) {} +} + +/** + * Given the Filepicker Parameters (aFpP), show the file picker dialog, + * prompting the user to confirm (or change) the fileName. + * @param aFpP + * A structure (see definition in internalSave(...) method) + * containing all the data used within this method. + * @param aSkipPrompt + * If true, attempt to save the file automatically to the user's default + * download directory, thus skipping the explicit prompt for a file name, + * but only if the associated preference is set. + * If false, don't save the file automatically to the user's + * default download directory, even if the associated preference + * is set, but ask for the target explicitly. + * @param aRelatedURI + * An nsIURI associated with the download. The last used + * directory of the picker is retrieved from/stored in the + * Content Pref Service using this URI. + * @return Promise + * @resolve a boolean. When true, it indicates that the file picker dialog + * is accepted. + */ +function promiseTargetFile( + aFpP, + /* optional */ aSkipPrompt, + /* optional */ aRelatedURI +) { + return (async function () { + let downloadLastDir = new DownloadLastDir(window); + let prefBranch = Services.prefs.getBranch("browser.download."); + let useDownloadDir = prefBranch.getBoolPref("useDownloadDir"); + + if (!aSkipPrompt) { + useDownloadDir = false; + } + + // Default to the user's default downloads directory configured + // through download prefs. + let dirPath = await Downloads.getPreferredDownloadsDirectory(); + let dirExists = await IOUtils.exists(dirPath); + let dir = new FileUtils.File(dirPath); + + if (useDownloadDir && dirExists) { + dir.append(aFpP.fileInfo.fileName); + aFpP.file = uniqueFile(dir); + return true; + } + + // We must prompt for the file name explicitly. + // If we must prompt because we were asked to... + let file = null; + if (!useDownloadDir) { + file = await downloadLastDir.getFileAsync(aRelatedURI); + } + if (file && (await IOUtils.exists(file.path))) { + dir = file; + dirExists = true; + } + + if (!dirExists) { + // Default to desktop. + dir = Services.dirsvc.get("Desk", Ci.nsIFile); + } + + let fp = makeFilePicker(); + let titleKey = aFpP.fpTitleKey || "SaveLinkTitle"; + fp.init( + window, + ContentAreaUtils.stringBundle.GetStringFromName(titleKey), + Ci.nsIFilePicker.modeSave + ); + + fp.displayDirectory = dir; + fp.defaultExtension = aFpP.fileInfo.fileExt; + fp.defaultString = aFpP.fileInfo.fileName; + appendFiltersForContentType( + fp, + aFpP.contentType, + aFpP.fileInfo.fileExt, + aFpP.saveMode + ); + + // The index of the selected filter is only preserved and restored if there's + // more than one filter in addition to "All Files". + if (aFpP.saveMode != SAVEMODE_FILEONLY) { + // eslint-disable-next-line mozilla/use-default-preference-values + try { + fp.filterIndex = prefBranch.getIntPref("save_converter_index"); + } catch (e) {} + } + + let result = await new Promise(resolve => { + fp.open(function (aResult) { + resolve(aResult); + }); + }); + if (result == Ci.nsIFilePicker.returnCancel || !fp.file) { + return false; + } + + if (aFpP.saveMode != SAVEMODE_FILEONLY) { + prefBranch.setIntPref("save_converter_index", fp.filterIndex); + } + + // Do not store the last save directory as a pref inside the private browsing mode + downloadLastDir.setFile(aRelatedURI, fp.file.parent); + + aFpP.saveAsType = fp.filterIndex; + aFpP.file = fp.file; + aFpP.file.leafName = validateFileName(aFpP.file.leafName); + + return true; + })(); +} + +// Since we're automatically downloading, we don't get the file picker's +// logic to check for existing files, so we need to do that here. +// +// Note - this code is identical to that in +// mozilla/toolkit/mozapps/downloads/src/nsHelperAppDlg.js.in +// If you are updating this code, update that code too! We can't share code +// here since that code is called in a js component. +function uniqueFile(aLocalFile) { + var collisionCount = 0; + while (aLocalFile.exists()) { + collisionCount++; + if (collisionCount == 1) { + // Append "(2)" before the last dot in (or at the end of) the filename + // special case .ext.gz etc files so we don't wind up with .tar(2).gz + if (aLocalFile.leafName.match(/\.[^\.]{1,3}\.(gz|bz2|Z)$/i)) { + aLocalFile.leafName = aLocalFile.leafName.replace( + /\.[^\.]{1,3}\.(gz|bz2|Z)$/i, + "(2)$&" + ); + } else { + aLocalFile.leafName = aLocalFile.leafName.replace( + /(\.[^\.]*)?$/, + "(2)$&" + ); + } + } else { + // replace the last (n) in the filename with (n+1) + aLocalFile.leafName = aLocalFile.leafName.replace( + /^(.*\()\d+\)/, + "$1" + (collisionCount + 1) + ")" + ); + } + } + return aLocalFile; +} + +/** + * Download a URL using the Downloads API. + * + * @param aURL + * the url to download + * @param [optional] aFileName + * the destination file name, if omitted will be obtained from the url. + * @param aInitiatingDocument + * The document from which the download was initiated. + */ +function DownloadURL(aURL, aFileName, aInitiatingDocument) { + // For private browsing, try to get document out of the most recent browser + // window, or provide our own if there's no browser window. + let isPrivate = aInitiatingDocument.defaultView.docShell.QueryInterface( + Ci.nsILoadContext + ).usePrivateBrowsing; + + let fileInfo = new FileInfo(aFileName); + initFileInfo(fileInfo, aURL, null, null, null, null); + + let filepickerParams = { + fileInfo, + saveMode: SAVEMODE_FILEONLY, + }; + + (async function () { + let accepted = await promiseTargetFile( + filepickerParams, + true, + fileInfo.uri + ); + if (!accepted) { + return; + } + + let file = filepickerParams.file; + let download = await Downloads.createDownload({ + source: { url: aURL, isPrivate }, + target: { path: file.path, partFilePath: file.path + ".part" }, + }); + download.tryToKeepPartialData = true; + + // Ignore errors because failures are reported through the download list. + download.start().catch(() => {}); + + // Add the download to the list, allowing it to be managed. + let list = await Downloads.getList(Downloads.ALL); + list.add(download); + })().catch(console.error); +} + +// We have no DOM, and can only save the URL as is. +const SAVEMODE_FILEONLY = 0x00; +XPCOMUtils.defineConstant(this, "SAVEMODE_FILEONLY", SAVEMODE_FILEONLY); +// We have a DOM and can save as complete. +const SAVEMODE_COMPLETE_DOM = 0x01; +XPCOMUtils.defineConstant(this, "SAVEMODE_COMPLETE_DOM", SAVEMODE_COMPLETE_DOM); +// We have a DOM which we can serialize as text. +const SAVEMODE_COMPLETE_TEXT = 0x02; +XPCOMUtils.defineConstant( + this, + "SAVEMODE_COMPLETE_TEXT", + SAVEMODE_COMPLETE_TEXT +); + +// If we are able to save a complete DOM, the 'save as complete' filter +// must be the first filter appended. The 'save page only' counterpart +// must be the second filter appended. And the 'save as complete text' +// filter must be the third filter appended. +function appendFiltersForContentType( + aFilePicker, + aContentType, + aFileExtension, + aSaveMode +) { + // The bundle name for saving only a specific content type. + var bundleName; + // The corresponding filter string for a specific content type. + var filterString; + + // Every case where GetSaveModeForContentType can return non-FILEONLY + // modes must be handled here. + if (aSaveMode != SAVEMODE_FILEONLY) { + switch (aContentType) { + case "text/html": + bundleName = "WebPageHTMLOnlyFilter"; + filterString = "*.htm; *.html"; + break; + + case "application/xhtml+xml": + bundleName = "WebPageXHTMLOnlyFilter"; + filterString = "*.xht; *.xhtml"; + break; + + case "image/svg+xml": + bundleName = "WebPageSVGOnlyFilter"; + filterString = "*.svg; *.svgz"; + break; + + case "text/xml": + case "application/xml": + bundleName = "WebPageXMLOnlyFilter"; + filterString = "*.xml"; + break; + } + } + + if (!bundleName) { + if (aSaveMode != SAVEMODE_FILEONLY) { + throw new Error(`Invalid save mode for type '${aContentType}'`); + } + + var mimeInfo = getMIMEInfoForType(aContentType, aFileExtension); + if (mimeInfo) { + var extString = ""; + for (var extension of mimeInfo.getFileExtensions()) { + if (extString) { + extString += "; "; + } // If adding more than one extension, + // separate by semi-colon + extString += "*." + extension; + } + + if (extString) { + aFilePicker.appendFilter(mimeInfo.description, extString); + } + } + } + + if (aSaveMode & SAVEMODE_COMPLETE_DOM) { + aFilePicker.appendFilter( + ContentAreaUtils.stringBundle.GetStringFromName("WebPageCompleteFilter"), + filterString + ); + // We should always offer a choice to save document only if + // we allow saving as complete. + aFilePicker.appendFilter( + ContentAreaUtils.stringBundle.GetStringFromName(bundleName), + filterString + ); + } + + if (aSaveMode & SAVEMODE_COMPLETE_TEXT) { + aFilePicker.appendFilters(Ci.nsIFilePicker.filterText); + } + + // Always append the all files (*) filter + aFilePicker.appendFilters(Ci.nsIFilePicker.filterAll); +} + +function getPostData(aDocument) { + if (aDocument instanceof Ci.nsIWebBrowserPersistDocument) { + return aDocument.postData; + } + try { + // Find the session history entry corresponding to the given document. In + // the current implementation, nsIWebPageDescriptor.currentDescriptor always + // returns a session history entry. + let sessionHistoryEntry = aDocument.defaultView.docShell + .QueryInterface(Ci.nsIWebPageDescriptor) + .currentDescriptor.QueryInterface(Ci.nsISHEntry); + return sessionHistoryEntry.postData; + } catch (e) {} + return null; +} + +function makeWebBrowserPersist() { + const persistContractID = + "@mozilla.org/embedding/browser/nsWebBrowserPersist;1"; + const persistIID = Ci.nsIWebBrowserPersist; + return Cc[persistContractID].createInstance(persistIID); +} + +function makeURI(aURL, aOriginCharset, aBaseURI) { + return Services.io.newURI(aURL, aOriginCharset, aBaseURI); +} + +function makeFileURI(aFile) { + return Services.io.newFileURI(aFile); +} + +function makeFilePicker() { + const fpContractID = "@mozilla.org/filepicker;1"; + const fpIID = Ci.nsIFilePicker; + return Cc[fpContractID].createInstance(fpIID); +} + +function getMIMEService() { + const mimeSvcContractID = "@mozilla.org/mime;1"; + const mimeSvcIID = Ci.nsIMIMEService; + const mimeSvc = Cc[mimeSvcContractID].getService(mimeSvcIID); + return mimeSvc; +} + +// Given aFileName, find the fileName without the extension on the end. +function getFileBaseName(aFileName) { + // Remove the file extension from aFileName: + return aFileName.replace(/\.[^.]*$/, ""); +} + +function getMIMETypeForURI(aURI) { + try { + return getMIMEService().getTypeFromURI(aURI); + } catch (e) {} + return null; +} + +function getMIMEInfoForType(aMIMEType, aExtension) { + if (aMIMEType || aExtension) { + try { + return getMIMEService().getFromTypeAndExtension(aMIMEType, aExtension); + } catch (e) {} + } + return null; +} + +function getDefaultFileName( + aDefaultFileName, + aURI, + aDocument, + aContentDisposition +) { + // 1) look for a filename in the content-disposition header, if any + if (aContentDisposition) { + const mhpContractID = "@mozilla.org/network/mime-hdrparam;1"; + const mhpIID = Ci.nsIMIMEHeaderParam; + const mhp = Cc[mhpContractID].getService(mhpIID); + var dummy = { value: null }; // Need an out param... + var charset = getCharsetforSave(aDocument); + + var fileName = null; + try { + fileName = mhp.getParameter( + aContentDisposition, + "filename", + charset, + true, + dummy + ); + } catch (e) { + try { + fileName = mhp.getParameter( + aContentDisposition, + "name", + charset, + true, + dummy + ); + } catch (e) {} + } + if (fileName) { + return Services.textToSubURI.unEscapeURIForUI( + fileName, + /* dontEscape = */ true + ); + } + } + + let docTitle; + if (aDocument && aDocument.title && aDocument.title.trim()) { + // If the document looks like HTML or XML, try to use its original title. + let contentType = aDocument.contentType; + if ( + contentType == "application/xhtml+xml" || + contentType == "application/xml" || + contentType == "image/svg+xml" || + contentType == "text/html" || + contentType == "text/xml" + ) { + // 2) Use the document title + return aDocument.title; + } + } + + try { + var url = aURI.QueryInterface(Ci.nsIURL); + if (url.fileName != "") { + // 3) Use the actual file name, if present + return Services.textToSubURI.unEscapeURIForUI( + url.fileName, + /* dontEscape = */ true + ); + } + } catch (e) { + // This is something like a data: and so forth URI... no filename here. + } + + // Don't use the title if it's from a data URI + if (docTitle && aURI?.scheme != "data") { + // 4) Use the document title + return docTitle; + } + + if (aDefaultFileName) { + // 5) Use the caller-provided name, if any + return aDefaultFileName; + } + + try { + if (aURI.host) { + // 6) Use the host. + return aURI.host; + } + } catch (e) { + // Some files have no information at all, like Javascript generated pages + } + + return ""; +} + +// This is only used after the user has entered a filename. +function validateFileName(aFileName) { + let processed = + DownloadPaths.sanitize(aFileName, { + compressWhitespaces: false, + allowInvalidFilenames: true, + }) || "_"; + if (AppConstants.platform == "android") { + // If a large part of the filename has been sanitized, then we + // will use a default filename instead + if (processed.replace(/_/g, "").length <= processed.length / 2) { + // We purposefully do not use a localized default filename, + // which we could have done using + // ContentAreaUtils.stringBundle.GetStringFromName("UntitledSaveFileName") + // since it may contain invalid characters. + var original = processed; + processed = "download"; + + // Preserve a suffix, if there is one + if (original.includes(".")) { + var suffix = original.split(".").slice(-1)[0]; + if (suffix && !suffix.includes("_")) { + processed += "." + suffix; + } + } + } + } + return processed; +} + +function GetSaveModeForContentType(aContentType, aDocument) { + // We can only save a complete page if we have a loaded document, + if (!aDocument) { + return SAVEMODE_FILEONLY; + } + + // Find the possible save modes using the provided content type + var saveMode = SAVEMODE_FILEONLY; + switch (aContentType) { + case "text/html": + case "application/xhtml+xml": + case "image/svg+xml": + saveMode |= SAVEMODE_COMPLETE_TEXT; + // Fall through + case "text/xml": + case "application/xml": + saveMode |= SAVEMODE_COMPLETE_DOM; + break; + } + + return saveMode; +} + +function getCharsetforSave(aDocument) { + if (aDocument) { + return aDocument.characterSet; + } + + if (document.commandDispatcher.focusedWindow) { + return document.commandDispatcher.focusedWindow.document.characterSet; + } + + return window.content.document.characterSet; +} + +/** + * Open a URL from chrome, determining if we can handle it internally or need to + * launch an external application to handle it. + * @param aURL The URL to be opened + * + * WARNING: Please note that openURL() does not perform any content security checks!!! + */ +function openURL(aURL) { + var uri = aURL instanceof Ci.nsIURI ? aURL : makeURI(aURL); + + var protocolSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + + let recentWindow = Services.wm.getMostRecentWindow("navigator:browser"); + + if (!protocolSvc.isExposedProtocol(uri.scheme)) { + // If we're not a browser, use the external protocol service to load the URI. + protocolSvc.loadURI(uri, recentWindow?.document.contentPrincipal); + } else { + if (recentWindow) { + recentWindow.openWebLinkIn(uri.spec, "tab", { + triggeringPrincipal: recentWindow.document.contentPrincipal, + }); + return; + } + + var loadgroup = Cc["@mozilla.org/network/load-group;1"].createInstance( + Ci.nsILoadGroup + ); + var appstartup = Services.startup; + + var loadListener = { + onStartRequest: function ll_start(aRequest) { + appstartup.enterLastWindowClosingSurvivalArea(); + }, + onStopRequest: function ll_stop(aRequest, aStatusCode) { + appstartup.exitLastWindowClosingSurvivalArea(); + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIRequestObserver", + "nsISupportsWeakReference", + ]), + }; + loadgroup.groupObserver = loadListener; + + var uriListener = { + doContent(ctype, preferred, request, handler) { + return false; + }, + isPreferred(ctype, desired) { + return false; + }, + canHandleContent(ctype, preferred, desired) { + return false; + }, + loadCookie: null, + parentContentListener: null, + getInterface(iid) { + if (iid.equals(Ci.nsIURIContentListener)) { + return this; + } + if (iid.equals(Ci.nsILoadGroup)) { + return loadgroup; + } + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, + }; + + var channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + + if (channel) { + channel.channelIsForDownload = true; + } + + var uriLoader = Cc["@mozilla.org/uriloader;1"].getService(Ci.nsIURILoader); + uriLoader.openURI( + channel, + Ci.nsIURILoader.IS_CONTENT_PREFERRED, + uriListener + ); + } +} diff --git a/toolkit/content/customElements.js b/toolkit/content/customElements.js new file mode 100644 index 0000000000..30958a3a31 --- /dev/null +++ b/toolkit/content/customElements.js @@ -0,0 +1,879 @@ +/* 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/. */ + +// This file defines these globals on the window object. +// Define them here so that ESLint can find them: +/* globals MozXULElement, MozHTMLElement, MozElements */ + +"use strict"; + +// This is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +(() => { + // Handle customElements.js being loaded as a script in addition to the subscriptLoader + // from MainProcessSingleton, to handle pages that can open both before and after + // MainProcessSingleton starts. See Bug 1501845. + if (window.MozXULElement) { + return; + } + + const MozElements = {}; + window.MozElements = MozElements; + + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const instrumentClasses = Services.env.get("MOZ_INSTRUMENT_CUSTOM_ELEMENTS"); + const instrumentedClasses = instrumentClasses ? new Set() : null; + const instrumentedBaseClasses = instrumentClasses ? new WeakSet() : null; + + // If requested, wrap the normal customElements.define to give us a chance + // to modify the class so we can instrument function calls in local development: + if (instrumentClasses) { + let define = window.customElements.define; + window.customElements.define = function (name, c, opts) { + instrumentCustomElementClass(c); + return define.call(this, name, c, opts); + }; + window.addEventListener( + "load", + () => { + MozElements.printInstrumentation(true); + }, + { once: true, capture: true } + ); + } + + MozElements.printInstrumentation = function (collapsed) { + let summaries = []; + let totalCalls = 0; + let totalTime = 0; + for (let c of instrumentedClasses) { + // Allow passing in something like MOZ_INSTRUMENT_CUSTOM_ELEMENTS=MozXULElement,Button to filter + let includeClass = + instrumentClasses == 1 || + instrumentClasses + .split(",") + .some(n => c.name.toLowerCase().includes(n.toLowerCase())); + let summary = c.__instrumentation_summary; + if (includeClass && summary) { + summaries.push(summary); + totalCalls += summary.totalCalls; + totalTime += summary.totalTime; + } + } + if (summaries.length) { + let groupName = `Instrumentation data for custom elements in ${document.documentURI}`; + console[collapsed ? "groupCollapsed" : "group"](groupName); + console.log( + `Total function calls ${totalCalls} and total time spent inside ${totalTime.toFixed( + 2 + )}` + ); + for (let summary of summaries) { + console.log(`${summary.name} (# instances: ${summary.instances})`); + if (Object.keys(summary.data).length > 1) { + console.table(summary.data); + } + } + console.groupEnd(groupName); + } + }; + + function instrumentCustomElementClass(c) { + // Climb up prototype chain to see if we inherit from a MozElement. + // Keep track of classes to instrument, for example: + // MozMenuCaption->MozMenuBase->BaseText->BaseControl->MozXULElement + let inheritsFromBase = instrumentedBaseClasses.has(c); + let classesToInstrument = [c]; + let proto = Object.getPrototypeOf(c); + while (proto) { + classesToInstrument.push(proto); + if (instrumentedBaseClasses.has(proto)) { + inheritsFromBase = true; + break; + } + proto = Object.getPrototypeOf(proto); + } + + if (inheritsFromBase) { + for (let c of classesToInstrument.reverse()) { + instrumentIndividualClass(c); + } + } + } + + function instrumentIndividualClass(c) { + if (instrumentedClasses.has(c)) { + return; + } + + instrumentedClasses.add(c); + let data = { instances: 0 }; + + function wrapFunction(name, fn) { + return function () { + if (!data[name]) { + data[name] = { time: 0, calls: 0 }; + } + data[name].calls++; + let n = performance.now(); + let r = fn.apply(this, arguments); + data[name].time += performance.now() - n; + return r; + }; + } + function wrapPropertyDescriptor(obj, name) { + if (name == "constructor") { + return; + } + let prop = Object.getOwnPropertyDescriptor(obj, name); + if (prop.get) { + prop.get = wrapFunction(`<get> ${name}`, prop.get); + } + if (prop.set) { + prop.set = wrapFunction(`<set> ${name}`, prop.set); + } + if (prop.writable && prop.value && prop.value.apply) { + prop.value = wrapFunction(name, prop.value); + } + Object.defineProperty(obj, name, prop); + } + + // Handle static properties + for (let name of Object.getOwnPropertyNames(c)) { + wrapPropertyDescriptor(c, name); + } + + // Handle instance properties + for (let name of Object.getOwnPropertyNames(c.prototype)) { + wrapPropertyDescriptor(c.prototype, name); + } + + c.__instrumentation_data = data; + Object.defineProperty(c, "__instrumentation_summary", { + enumerable: false, + configurable: false, + get() { + if (data.instances == 0) { + return null; + } + + let clonedData = JSON.parse(JSON.stringify(data)); + delete clonedData.instances; + let totalCalls = 0; + let totalTime = 0; + for (let d in clonedData) { + let { time, calls } = clonedData[d]; + time = parseFloat(time.toFixed(2)); + totalCalls += calls; + totalTime += time; + clonedData[d]["time (ms)"] = time; + delete clonedData[d].time; + clonedData[d].timePerCall = parseFloat((time / calls).toFixed(4)); + } + + let timePerCall = parseFloat((totalTime / totalCalls).toFixed(4)); + totalTime = parseFloat(totalTime.toFixed(2)); + + // Add a spaced-out final row with summed up totals + clonedData["\ntotals"] = { + "time (ms)": `\n${totalTime}`, + calls: `\n${totalCalls}`, + timePerCall: `\n${timePerCall}`, + }; + return { + instances: data.instances, + data: clonedData, + name: c.name, + totalCalls, + totalTime, + }; + }, + }); + } + + // The listener of DOMContentLoaded must be set on window, rather than + // document, because the window can go away before the event is fired. + // In that case, we don't want to initialize anything, otherwise we + // may be leaking things because they will never be destroyed after. + let gIsDOMContentLoaded = false; + const gElementsPendingConnection = new Set(); + window.addEventListener( + "DOMContentLoaded", + () => { + gIsDOMContentLoaded = true; + for (let element of gElementsPendingConnection) { + try { + if (element.isConnected) { + element.isRunningDelayedConnectedCallback = true; + element.connectedCallback(); + } + } catch (ex) { + console.error(ex); + } + element.isRunningDelayedConnectedCallback = false; + } + gElementsPendingConnection.clear(); + }, + { once: true, capture: true } + ); + + const gXULDOMParser = new DOMParser(); + gXULDOMParser.forceEnableXULXBL(); + + MozElements.MozElementMixin = Base => { + let MozElementBase = class extends Base { + constructor() { + super(); + + if (instrumentClasses) { + let proto = this.constructor; + while (proto && proto != Base) { + proto.__instrumentation_data.instances++; + proto = Object.getPrototypeOf(proto); + } + } + } + /* + * A declarative way to wire up attribute inheritance and automatically generate + * the `observedAttributes` getter. For example, if you returned: + * { + * ".foo": "bar,baz=bat" + * } + * + * Then the base class will automatically return ["bar", "bat"] from `observedAttributes`, + * and set up an `attributeChangedCallback` to pass those attributes down onto an element + * matching the ".foo" selector. + * + * See the `inheritAttribute` function for more details on the attribute string format. + * + * @return {Object<string selector, string attributes>} + */ + static get inheritedAttributes() { + return null; + } + + static get flippedInheritedAttributes() { + // Have to be careful here, if a subclass overrides inheritedAttributes + // and its parent class is instantiated first, then reading + // this._flippedInheritedAttributes on the child class will return the + // computed value from the parent. We store it separately on each class + // to ensure everything works correctly when inheritedAttributes is + // overridden. + if (!this.hasOwnProperty("_flippedInheritedAttributes")) { + let { inheritedAttributes } = this; + if (!inheritedAttributes) { + this._flippedInheritedAttributes = null; + } else { + this._flippedInheritedAttributes = {}; + for (let selector in inheritedAttributes) { + let attrRules = inheritedAttributes[selector].split(","); + for (let attrRule of attrRules) { + let attrName = attrRule; + let attrNewName = attrRule; + let split = attrName.split("="); + if (split.length == 2) { + attrName = split[1]; + attrNewName = split[0]; + } + + if (!this._flippedInheritedAttributes[attrName]) { + this._flippedInheritedAttributes[attrName] = []; + } + this._flippedInheritedAttributes[attrName].push([ + selector, + attrNewName, + ]); + } + } + } + } + + return this._flippedInheritedAttributes; + } + /* + * Generate this array based on `inheritedAttributes`, if any. A class is free to override + * this if it needs to do something more complex or wants to opt out of this behavior. + */ + static get observedAttributes() { + return Object.keys(this.flippedInheritedAttributes || {}); + } + + /* + * Provide default lifecycle callback for attribute changes that will inherit attributes + * based on the static `inheritedAttributes` Object. This can be overridden by callers. + */ + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue || !this.initializedAttributeInheritance) { + return; + } + + let list = this.constructor.flippedInheritedAttributes[name]; + if (list) { + this.inheritAttribute(list, name); + } + } + + /* + * After setting content, calling this will cache the elements from selectors in the + * static `inheritedAttributes` Object. It'll also do an initial call to `this.inheritAttributes()`, + * so in the simple case, this is the only function you need to call. + * + * This should be called any time the children that are inheriting attributes changes. For instance, + * it's common in a connectedCallback to do something like: + * + * this.textContent = ""; + * this.append(MozXULElement.parseXULToFragment(`<label />`)) + * this.initializeAttributeInheritance(); + * + */ + initializeAttributeInheritance() { + let { flippedInheritedAttributes } = this.constructor; + if (!flippedInheritedAttributes) { + return; + } + + // Clear out any existing cached elements: + this._inheritedElements = null; + + this.initializedAttributeInheritance = true; + for (let attr in flippedInheritedAttributes) { + if (this.hasAttribute(attr)) { + this.inheritAttribute(flippedInheritedAttributes[attr], attr); + } + } + } + + /* + * Implements attribute value inheritance by child elements. + * + * @param {array} list + * An array of (to-element-selector, to-attr) pairs. + * @param {string} attr + * An attribute to propagate. + */ + inheritAttribute(list, attr) { + if (!this._inheritedElements) { + this._inheritedElements = {}; + } + + let hasAttr = this.hasAttribute(attr); + let attrValue = this.getAttribute(attr); + + for (let [selector, newAttr] of list) { + if (!(selector in this._inheritedElements)) { + this._inheritedElements[selector] = + this.getElementForAttrInheritance(selector); + } + let el = this._inheritedElements[selector]; + if (el) { + if (newAttr == "text") { + el.textContent = hasAttr ? attrValue : ""; + } else if (hasAttr) { + el.setAttribute(newAttr, attrValue); + } else { + el.removeAttribute(newAttr); + } + } + } + } + + /** + * Used in setting up attribute inheritance. Takes a selector and returns + * an element for that selector from shadow DOM if there is a shadowRoot, + * or from the light DOM if not. + * + * Here's one problem this solves. ElementB extends ElementA which extends + * MozXULElement. ElementA has a shadowRoot. ElementB tries to inherit + * attributes in light DOM by calling `initializeAttributeInheritance` + * but that fails because it defaults to inheriting from the shadow DOM + * and not the light DOM. (See bug 1545824.) + * + * To solve this, ElementB can override `getElementForAttrInheritance` so + * it queries the light DOM for some selectors as needed. For example: + * + * class ElementA extends MozXULElement { + * static get inheritedAttributes() { + * return { ".one": "attr" }; + * } + * } + * + * class ElementB extends customElements.get("elementa") { + * static get inheritedAttributes() { + * return Object.assign({}, super.inheritedAttributes(), { + * ".two": "attr", + * }); + * } + * getElementForAttrInheritance(selector) { + * if (selector == ".two") { + * return this.querySelector(selector) + * } else { + * return super.getElementForAttrInheritance(selector); + * } + * } + * } + * + * @param {string} selector + * A selector used to query an element. + * + * @return {Element} The element found by the selector. + */ + getElementForAttrInheritance(selector) { + let parent = this.shadowRoot || this; + return parent.querySelector(selector); + } + + /** + * Sometimes an element may not want to run connectedCallback logic during + * parse. This could be because we don't want to initialize the element before + * the element's contents have been fully parsed, or for performance reasons. + * If you'd like to opt-in to this, then add this to the beginning of your + * `connectedCallback` and `disconnectedCallback`: + * + * if (this.delayConnectedCallback()) { return } + * + * And this at the beginning of your `attributeChangedCallback` + * + * if (!this.isConnectedAndReady) { return; } + */ + delayConnectedCallback() { + if (gIsDOMContentLoaded) { + return false; + } + gElementsPendingConnection.add(this); + return true; + } + + get isConnectedAndReady() { + return gIsDOMContentLoaded && this.isConnected; + } + + /** + * Passes DOM events to the on_<event type> methods. + */ + handleEvent(event) { + let methodName = "on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized event: " + event.type); + } + } + + /** + * Used by custom elements for caching fragments. We now would be + * caching once per class while also supporting subclasses. + * + * If available, returns the cached fragment. + * Otherwise, creates it. + * + * Example: + * + * class ElementA extends MozXULElement { + * static get markup() { + * return `<hbox class="example"`; + * } + * + * connectedCallback() { + * this.appendChild(this.constructor.fragment); + * } + * } + * + * @return {importedNode} The imported node that has not been + * inserted into document tree. + */ + static get fragment() { + if (!this.hasOwnProperty("_fragment")) { + let markup = this.markup; + if (markup) { + this._fragment = MozXULElement.parseXULToFragment( + markup, + this.entities + ); + } else { + throw new Error("Markup is null"); + } + } + return document.importNode(this._fragment, true); + } + + /** + * Allows eager deterministic construction of XUL elements with XBL attached, by + * parsing an element tree and returning a DOM fragment to be inserted in the + * document before any of the inner elements is referenced by JavaScript. + * + * This process is required instead of calling the createElement method directly + * because bindings get attached when: + * + * 1. the node gets a layout frame constructed, or + * 2. the node gets its JavaScript reflector created, if it's in the document, + * + * whichever happens first. The createElement method would return a JavaScript + * reflector, but the element wouldn't be in the document, so the node wouldn't + * get XBL attached. After that point, even if the node is inserted into a + * document, it won't get XBL attached until either the frame is constructed or + * the reflector is garbage collected and the element is touched again. + * + * @param {string} str + * String with the XML representation of XUL elements. + * @param {string[]} [entities] + * An array of DTD URLs containing entity definitions. + * + * @return {DocumentFragment} `DocumentFragment` instance containing + * the corresponding element tree, including element nodes + * but excluding any text node. + */ + static parseXULToFragment(str, entities = []) { + let doc = gXULDOMParser.parseFromSafeString( + ` + ${ + entities.length + ? `<!DOCTYPE bindings [ + ${entities.reduce((preamble, url, index) => { + return ( + preamble + + `<!ENTITY % _dtd-${index} SYSTEM "${url}"> + %_dtd-${index}; + ` + ); + }, "")} + ]>` + : "" + } + <box xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + ${str} + </box> + `, + "application/xml" + ); + + if (doc.documentElement.localName === "parsererror") { + throw new Error("not well-formed XML"); + } + + // The XUL/XBL parser is set to ignore all-whitespace nodes, whereas (X)HTML + // does not do this. Most XUL code assumes that the whitespace has been + // stripped out, so we simply remove all text nodes after using the parser. + let nodeIterator = doc.createNodeIterator(doc, NodeFilter.SHOW_TEXT); + let currentNode = nodeIterator.nextNode(); + while (currentNode) { + // Remove whitespace-only nodes. Regex is taken from: + // https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace_in_the_DOM + if (!/[^\t\n\r ]/.test(currentNode.textContent)) { + currentNode.remove(); + } + + currentNode = nodeIterator.nextNode(); + } + // We use a range here so that we don't access the inner DOM elements from + // JavaScript before they are imported and inserted into a document. + let range = doc.createRange(); + range.selectNodeContents(doc.querySelector("box")); + return range.extractContents(); + } + + /** + * Insert a localization link to an FTL file. This is used so that + * a Custom Element can wait to inject the link until it's connected, + * and so that consuming documents don't require the correct <link> + * present in the markup. + * + * @param path + * The path to the FTL file + */ + static insertFTLIfNeeded(path) { + let container = document.head || document.querySelector("linkset"); + if (!container) { + if ( + document.documentElement.namespaceURI === + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + ) { + container = document.createXULElement("linkset"); + document.documentElement.appendChild(container); + } else if (document.documentURI == AppConstants.BROWSER_CHROME_URL) { + // Special case for browser.xhtml. Here `document.head` is null, so + // just insert the link at the end of the window. + container = document.documentElement; + } else { + throw new Error( + "Attempt to inject localization link before document.head is available" + ); + } + } + + for (let link of container.querySelectorAll("link")) { + if (link.getAttribute("href") == path) { + return; + } + } + + let link = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "link" + ); + link.setAttribute("rel", "localization"); + link.setAttribute("href", path); + + container.appendChild(link); + } + + /** + * Indicate that a class defining a XUL element implements one or more + * XPCOM interfaces by adding a getCustomInterface implementation to it, + * as well as an implementation of QueryInterface. + * + * The supplied class should implement the properties and methods of + * all of the interfaces that are specified. + * + * @param cls + * The class that implements the interface. + * @param names + * Array of interface names. + */ + static implementCustomInterface(cls, ifaces) { + if (cls.prototype.customInterfaces) { + ifaces.push(...cls.prototype.customInterfaces); + } + cls.prototype.customInterfaces = ifaces; + + cls.prototype.QueryInterface = ChromeUtils.generateQI(ifaces); + cls.prototype.getCustomInterfaceCallback = + function getCustomInterfaceCallback(ifaceToCheck) { + if ( + cls.prototype.customInterfaces.some(iface => + iface.equals(ifaceToCheck) + ) + ) { + return getInterfaceProxy(this); + } + return null; + }; + } + }; + + // Rename the class so we can distinguish between MozXULElement and MozXULPopupElement, for example. + Object.defineProperty(MozElementBase, "name", { value: `Moz${Base.name}` }); + if (instrumentedBaseClasses) { + instrumentedBaseClasses.add(MozElementBase); + } + return MozElementBase; + }; + + const MozXULElement = MozElements.MozElementMixin(XULElement); + const MozHTMLElement = MozElements.MozElementMixin(HTMLElement); + + /** + * Given an object, add a proxy that reflects interface implementations + * onto the object itself. + */ + function getInterfaceProxy(obj) { + /* globals MozQueryInterface */ + if (!obj._customInterfaceProxy) { + obj._customInterfaceProxy = new Proxy(obj, { + get(target, prop, receiver) { + let propOrMethod = target[prop]; + if (typeof propOrMethod == "function") { + if (MozQueryInterface.isInstance(propOrMethod)) { + return Reflect.get(target, prop, receiver); + } + return function (...args) { + return propOrMethod.apply(target, args); + }; + } + return propOrMethod; + }, + }); + } + + return obj._customInterfaceProxy; + } + + MozElements.BaseControlMixin = Base => { + class BaseControl extends Base { + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + } + + get tabIndex() { + return parseInt(this.getAttribute("tabindex")) || 0; + } + + set tabIndex(val) { + if (val) { + this.setAttribute("tabindex", val); + } else { + this.removeAttribute("tabindex"); + } + } + } + + MozXULElement.implementCustomInterface(BaseControl, [ + Ci.nsIDOMXULControlElement, + ]); + return BaseControl; + }; + MozElements.BaseControl = MozElements.BaseControlMixin(MozXULElement); + + const BaseTextMixin = Base => + class BaseText extends MozElements.BaseControlMixin(Base) { + set label(val) { + this.setAttribute("label", val); + } + + get label() { + return this.getAttribute("label"); + } + + set image(val) { + this.setAttribute("image", val); + } + + get image() { + return this.getAttribute("image"); + } + + set command(val) { + this.setAttribute("command", val); + } + + get command() { + return this.getAttribute("command"); + } + + set accessKey(val) { + // Always store on the control + this.setAttribute("accesskey", val); + // If there is a label, change the accesskey on the labelElement + // if it's also set there + if (this.labelElement) { + this.labelElement.accessKey = val; + } + } + + get accessKey() { + return this.labelElement + ? this.labelElement.accessKey + : this.getAttribute("accesskey"); + } + }; + MozElements.BaseTextMixin = BaseTextMixin; + MozElements.BaseText = BaseTextMixin(MozXULElement); + + // Attach the base class to the window so other scripts can use it: + window.MozXULElement = MozXULElement; + window.MozHTMLElement = MozHTMLElement; + + customElements.setElementCreationCallback("browser", () => { + Services.scriptloader.loadSubScript( + "chrome://global/content/elements/browser-custom-element.js", + window + ); + }); + + // Skip loading any extra custom elements in the extension dummy document + // and GeckoView windows. + const loadExtraCustomElements = !( + document.documentURI == "chrome://extensions/content/dummy.xhtml" || + document.documentURI == "chrome://geckoview/content/geckoview.xhtml" + ); + if (loadExtraCustomElements) { + // Lazily load the following elements + for (let [tag, script] of [ + ["button-group", "chrome://global/content/elements/named-deck.js"], + ["findbar", "chrome://global/content/elements/findbar.js"], + ["menulist", "chrome://global/content/elements/menulist.js"], + ["message-bar", "chrome://global/content/elements/message-bar.js"], + ["named-deck", "chrome://global/content/elements/named-deck.js"], + ["named-deck-button", "chrome://global/content/elements/named-deck.js"], + ["panel-list", "chrome://global/content/elements/panel-list.js"], + ["search-textbox", "chrome://global/content/elements/search-textbox.js"], + ["stringbundle", "chrome://global/content/elements/stringbundle.js"], + [ + "printpreview-pagination", + "chrome://global/content/printPreviewPagination.js", + ], + [ + "autocomplete-input", + "chrome://global/content/elements/autocomplete-input.js", + ], + ["editor", "chrome://global/content/elements/editor.js"], + ]) { + customElements.setElementCreationCallback(tag, () => { + Services.scriptloader.loadSubScript(script, window); + }); + } + // Bug 1813077: This is a workaround until Bug 1803810 lands + // which will give us the ability to load ESMs synchronously + // like the previous Services.scriptloader.loadSubscript() function + function importCustomElementFromESModule(name) { + switch (name) { + case "moz-button-group": + return import( + "chrome://global/content/elements/moz-button-group.mjs" + ); + case "moz-message-bar": + return import("chrome://global/content/elements/moz-message-bar.mjs"); + case "moz-support-link": + return import( + "chrome://global/content/elements/moz-support-link.mjs" + ); + case "moz-toggle": + return import("chrome://global/content/elements/moz-toggle.mjs"); + case "moz-card": + return import("chrome://global/content/elements/moz-card.mjs"); + } + throw new Error(`Unknown custom element name (${name})`); + } + + /* + This function explicitly returns null so that there is no confusion + about which custom elements from ES Modules have been loaded. + */ + window.ensureCustomElements = function (...elementNames) { + return Promise.all( + elementNames + .filter(name => !customElements.get(name)) + .map(name => importCustomElementFromESModule(name)) + ) + .then(() => null) + .catch(console.error); + }; + + // Immediately load the following elements + for (let script of [ + "chrome://global/content/elements/arrowscrollbox.js", + "chrome://global/content/elements/dialog.js", + "chrome://global/content/elements/general.js", + "chrome://global/content/elements/button.js", + "chrome://global/content/elements/checkbox.js", + "chrome://global/content/elements/menu.js", + "chrome://global/content/elements/menupopup.js", + "chrome://global/content/elements/moz-input-box.js", + "chrome://global/content/elements/notificationbox.js", + "chrome://global/content/elements/panel.js", + "chrome://global/content/elements/popupnotification.js", + "chrome://global/content/elements/radio.js", + "chrome://global/content/elements/richlistbox.js", + "chrome://global/content/elements/autocomplete-popup.js", + "chrome://global/content/elements/autocomplete-richlistitem.js", + "chrome://global/content/elements/tabbox.js", + "chrome://global/content/elements/text.js", + "chrome://global/content/elements/toolbarbutton.js", + "chrome://global/content/elements/tree.js", + "chrome://global/content/elements/wizard.js", + ]) { + Services.scriptloader.loadSubScript(script, window); + } + } +})(); diff --git a/toolkit/content/datepicker.xhtml b/toolkit/content/datepicker.xhtml new file mode 100644 index 0000000000..9752add763 --- /dev/null +++ b/toolkit/content/datepicker.xhtml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +%htmlDTD; ]> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" +> + <head> + <link + rel="stylesheet" + href="chrome://global/skin/datetimeinputpickers.css" + /> + <link rel="localization" href="toolkit/global/datepicker.ftl" /> + <script src="chrome://global/content/bindings/datekeeper.js"></script> + <script src="chrome://global/content/bindings/spinner.js"></script> + <script src="chrome://global/content/bindings/calendar.js"></script> + <script src="chrome://global/content/bindings/datepicker.js"></script> + </head> + <body> + <div + id="date-picker" + role="dialog" + data-l10n-id="date-picker-label" + aria-modal="true" + > + <div class="calendar-container"> + <div class="month-year-nav" data-l10n-id="date-spinner-label"> + <button class="prev" data-l10n-id="date-picker-previous" /> + <div class="month-year-container"> + <button + class="month-year" + id="month-year-label" + aria-live="polite" + /> + </div> + <button class="next" data-l10n-id="date-picker-next" /> + <template id="spinner-template"> + <div class="spinner-container"> + <button class="up" /> + <div class="spinner"></div> + <button class="down" /> + </div> + </template> + <div class="month-year-view"></div> + </div> + <table role="grid" aria-labelledby="month-year-label"> + <thead class="week-header"></thead> + <tbody class="days-view"></tbody> + </table> + </div> + <button id="clear-button" data-l10n-id="date-picker-clear-button" /> + </div> + <script> + /* import-globals-from widgets/datepicker.js */ + // Create a DatePicker instance and prepare to be + // initialized by the "DatePickerInit" event from datetimepopup.xml + const root = document.getElementById("date-picker"); + new DatePicker({ + monthYearNav: root.querySelector(".month-year-nav"), + monthYear: root.querySelector(".month-year"), + monthYearView: root.querySelector(".month-year-view"), + buttonPrev: root.querySelector(".prev"), + buttonNext: root.querySelector(".next"), + weekHeader: root.querySelector(".week-header"), + daysView: root.querySelector(".days-view"), + buttonClear: document.getElementById("clear-button"), + }); + </script> + </body> +</html> diff --git a/toolkit/content/editMenuKeys.inc.xhtml b/toolkit/content/editMenuKeys.inc.xhtml new file mode 100644 index 0000000000..d4331b32dc --- /dev/null +++ b/toolkit/content/editMenuKeys.inc.xhtml @@ -0,0 +1,24 @@ +# 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/. + + <!-- These key nodes are here only for show. The real bindings come from + XBL, in platformHTMLBindings.xml. See bugs 57078 and 71779. --> + + <keyset id="editMenuKeys"> + <key id="key_undo" data-l10n-id="text-action-undo-shortcut" modifiers="accel" command="cmd_undo"/> + <key id="key_redo" +#ifdef XP_UNIX + data-l10n-id="text-action-undo-shortcut" + modifiers="accel,shift" +#else + data-l10n-id="text-action-redo-shortcut" + modifiers="accel" +#endif + command="cmd_redo"/> + <key id="key_cut" data-l10n-id="text-action-cut-shortcut" modifiers="accel" command="cmd_cut"/> + <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" command="cmd_copy"/> + <key id="key_paste" data-l10n-id="text-action-paste-shortcut" modifiers="accel" command="cmd_paste"/> + <key id="key_delete" keycode="VK_DELETE" command="cmd_delete"/> + <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" command="cmd_selectAll"/> + </keyset> diff --git a/toolkit/content/editMenuOverlay.js b/toolkit/content/editMenuOverlay.js new file mode 100644 index 0000000000..b0ec197224 --- /dev/null +++ b/toolkit/content/editMenuOverlay.js @@ -0,0 +1,133 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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/. */ + +// update menu items that rely on focus or on the current selection +function goUpdateGlobalEditMenuItems(force) { + // Don't bother updating the edit commands if they aren't visible in any way + // (i.e. the Edit menu isn't open, nor is the context menu open, nor have the + // cut, copy, and paste buttons been added to the toolbars) for performance. + // This only works in applications/on platforms that set the gEditUIVisible + // flag, so we check to see if that flag is defined before using it. + if (!force && typeof gEditUIVisible != "undefined" && !gEditUIVisible) { + return; + } + + goUpdateUndoEditMenuItems(); + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_copy"); + goUpdatePasteMenuItems(); + goUpdateCommand("cmd_selectAll"); + goUpdateCommand("cmd_delete"); + goUpdateCommand("cmd_switchTextDirection"); +} + +// update menu items that relate to undo/redo +function goUpdateUndoEditMenuItems() { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); +} + +// update menu items that depend on clipboard contents +function goUpdatePasteMenuItems() { + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_pasteNoFormatting"); +} + +// Inject the commandset here instead of relying on preprocessor to share this across documents. +window.addEventListener( + "DOMContentLoaded", + () => { + let container = + document.querySelector("commandset") || document.documentElement; + let fragment = MozXULElement.parseXULToFragment(` + <commandset id="editMenuCommands"> + <commandset id="editMenuCommandSetAll" commandupdater="true" events="focus,select" /> + <commandset id="editMenuCommandSetUndo" commandupdater="true" events="undo" /> + <commandset id="editMenuCommandSetPaste" commandupdater="true" events="clipboard" /> + <command id="cmd_undo" internal="true"/> + <command id="cmd_redo" internal="true" /> + <command id="cmd_cut" internal="true" /> + <command id="cmd_copy" internal="true" /> + <command id="cmd_paste" internal="true" /> + <command id="cmd_pasteNoFormatting" internal="true" /> + <command id="cmd_delete" /> + <command id="cmd_selectAll" internal="true" /> + <command id="cmd_switchTextDirection" /> + </commandset> + `); + + let editMenuCommandSetAll = fragment.querySelector( + "#editMenuCommandSetAll" + ); + editMenuCommandSetAll.addEventListener("commandupdate", function () { + goUpdateGlobalEditMenuItems(); + }); + + let editMenuCommandSetUndo = fragment.querySelector( + "#editMenuCommandSetUndo" + ); + editMenuCommandSetUndo.addEventListener("commandupdate", function () { + goUpdateUndoEditMenuItems(); + }); + + let editMenuCommandSetPaste = fragment.querySelector( + "#editMenuCommandSetPaste" + ); + editMenuCommandSetPaste.addEventListener("commandupdate", function () { + goUpdatePasteMenuItems(); + }); + + fragment.firstElementChild.addEventListener("command", event => { + let commandID = event.target.id; + goDoCommand(commandID); + }); + + container.appendChild(fragment); + }, + { once: true } +); + +// Support context menus on html textareas in the parent process: +window.addEventListener("contextmenu", e => { + const HTML_NS = "http://www.w3.org/1999/xhtml"; + let needsContextMenu = + e.composedTarget.ownerDocument == document && + !e.defaultPrevented && + e.composedTarget.parentNode.nodeName != "moz-input-box" && + ((["textarea", "input"].includes(e.composedTarget.localName) && + e.composedTarget.namespaceURI == HTML_NS) || + e.composedTarget.closest("search-textbox")); + + if (!needsContextMenu) { + return; + } + + let popup = document.getElementById("textbox-contextmenu"); + if (!popup) { + MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + document.documentElement.appendChild( + MozXULElement.parseXULToFragment(` + <menupopup id="textbox-contextmenu" class="textbox-contextmenu"> + <menuitem data-l10n-id="text-action-undo" command="cmd_undo"></menuitem> + <menuitem data-l10n-id="text-action-redo" command="cmd_redo"></menuitem> + <menuseparator></menuseparator> + <menuitem data-l10n-id="text-action-cut" command="cmd_cut"></menuitem> + <menuitem data-l10n-id="text-action-copy" command="cmd_copy"></menuitem> + <menuitem data-l10n-id="text-action-paste" command="cmd_paste"></menuitem> + <menuitem data-l10n-id="text-action-delete" command="cmd_delete"></menuitem> + <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll"></menuitem> + </menupopup> + `) + ); + popup = document.documentElement.lastElementChild; + } + + goUpdateGlobalEditMenuItems(true); + popup.openPopupAtScreen(e.screenX, e.screenY, true, e); + // Don't show any other context menu at the same time. There can be a + // context menu from an ancestor too but we only want to show this one. + e.preventDefault(); +}); diff --git a/toolkit/content/filepicker.properties b/toolkit/content/filepicker.properties new file mode 100644 index 0000000000..03daec114c --- /dev/null +++ b/toolkit/content/filepicker.properties @@ -0,0 +1,13 @@ +# 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/. + +allFilter=* +htmlFilter=*.html; *.htm; *.shtml; *.xhtml +textFilter=*.txt; *.text +imageFilter=*.jpe; *.jpg; *.jpeg; *.gif; *.png; *.bmp; *.ico; *.svg; *.svgz; *.tif; *.tiff; *.ai; *.drw; *.pct; *.psp; *.xcf; *.psd; *.raw; *.webp; *.heic +xmlFilter=*.xml +xulFilter=*.xul +audioFilter=*.aac; *.aif; *.flac; *.iff; *.m4a; *.m4b; *.mid; *.midi; *.mp3; *.mpa; *.mpc; *.oga; *.ogg; *.opus; *.ra; *.ram; *.snd; *.wav; *.wma +videoFilter=*.avi; *.divx; *.flv; *.m4v; *.mkv; *.mov; *.mp4; *.mpeg; *.mpg; *.ogm; *.ogv; *.ogx; *.rm; *.rmvb; *.smil; *.webm; *.wmv; *.xvid +pdfFilter=*.pdf diff --git a/toolkit/content/globalOverlay.js b/toolkit/content/globalOverlay.js new file mode 100644 index 0000000000..2476ce73a0 --- /dev/null +++ b/toolkit/content/globalOverlay.js @@ -0,0 +1,146 @@ +/* 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/. */ + +function closeWindow(aClose, aPromptFunction, aSource) { + let { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + // Closing the last window doesn't quit the application on OS X. + if (AppConstants.platform != "macosx") { + var windowCount = 0; + for (let w of Services.wm.getEnumerator(null)) { + if (w.closed) { + continue; + } + if (++windowCount == 2) { + break; + } + } + + // If we're down to the last window and someone tries to shut down, check to make sure we can! + if (windowCount == 1 && !canQuitApplication("lastwindow", aSource)) { + return false; + } + if ( + windowCount != 1 && + typeof aPromptFunction == "function" && + !aPromptFunction(aSource) + ) { + return false; + } + + // If the user explicitly closes the last tabs in the window close remaining tabs. Bug 490136 + if (aClose) { + window.SessionStore?.maybeDontRestoreTabs(window); + } + } else if ( + typeof aPromptFunction == "function" && + !aPromptFunction(aSource) + ) { + return false; + } + + if (aClose) { + window.close(); + return window.closed; + } + + return true; +} + +function canQuitApplication(aData, aSource) { + const kCID = "@mozilla.org/browser/browserglue;1"; + if (kCID in Cc && !(aData || "").includes("restart")) { + let BrowserGlue = Cc[kCID].getService(Ci.nsISupports).wrappedJSObject; + BrowserGlue._registerQuitSource(aSource); + } + try { + var cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + aData || null + ); + + // Something aborted the quit process. + if (cancelQuit.data) { + return false; + } + } catch (ex) {} + return true; +} + +function goQuitApplication(event) { + // We can't know for sure if the user used a shortcut to trigger quit. + // Proxy by means of checking for the shortcut modifier. + let isMac = navigator.platform.startsWith("Mac"); + let key = isMac ? "metaKey" : "ctrlKey"; + let source = "OS"; + if (event[key]) { + source = "shortcut"; + // Note that macOS likes pretending something came from this menu even if + // activated by keyboard shortcut, hence checking that first. + } else if (event.sourceEvent?.target?.id?.startsWith("menu_")) { + source = "menuitem"; + } else if (event.sourceEvent?.target?.id?.startsWith("appMenu")) { + source = "appmenu"; + } + if (!canQuitApplication(undefined, source)) { + return false; + } + + Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit); + return true; +} + +// +// Command Updater functions +// +function goUpdateCommand(aCommand) { + try { + var controller = + top.document.commandDispatcher.getControllerForCommand(aCommand); + + var enabled = false; + if (controller) { + enabled = controller.isCommandEnabled(aCommand); + } + + goSetCommandEnabled(aCommand, enabled); + } catch (e) { + console.error("An error occurred updating the ", aCommand, " command: ", e); + } +} + +function goDoCommand(aCommand) { + try { + var controller = + top.document.commandDispatcher.getControllerForCommand(aCommand); + if (controller && controller.isCommandEnabled(aCommand)) { + controller.doCommand(aCommand); + } + } catch (e) { + console.error( + "An error occurred executing the ", + aCommand, + " command: ", + e + ); + } +} + +function goSetCommandEnabled(aID, aEnabled) { + var node = document.getElementById(aID); + + if (node) { + if (aEnabled) { + node.removeAttribute("disabled"); + } else { + node.setAttribute("disabled", "true"); + } + } +} diff --git a/toolkit/content/gmp-sources/openh264.json b/toolkit/content/gmp-sources/openh264.json new file mode 100644 index 0000000000..9e6e9b875c --- /dev/null +++ b/toolkit/content/gmp-sources/openh264.json @@ -0,0 +1,82 @@ +{ + "vendors": { + "gmp-gmpopenh264": { + "platforms": { + "Android_aarch64-gcc3": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-android-aarch64-42954cf0fe8a2bdc97fdc180462a3eaefceb035f.zip", + "filesize": 557884, + "hashValue": "307d188876f3612a9168c0b4ed191db2132f2e3193bdd3024ce50adcb9c1e085ab43008531a25e93d570a377283336cda9bcd7609ee6b702c5292f12d20b616b" + }, + "Android_arm-eabi-gcc3": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-android-arm-42954cf0fe8a2bdc97fdc180462a3eaefceb035f.zip", + "filesize": 539311, + "hashValue": "f4f0bfe333b7e0cd0453e787dc3c15bebe9cc771cb3e57540d53f0ac9a37eee4ea8559a45a51824ee4d706ee0b3d80b2d331468a0aa533cd958081f23ee0aaae" + }, + "Android_x86-gcc3": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-android-x86-42954cf0fe8a2bdc97fdc180462a3eaefceb035f.zip", + "filesize": 589947, + "hashValue": "eb7a1c9c2d29a2fd12dfe82d0f575f1d855478640816a7fb9402ce82c65878ffc5aa3d5f8bb46cd01231005c37d86984d7a631cfe45c7d56a6d4dabc427b15a0" + }, + "Darwin_aarch64-gcc3": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-macosx64-aarch64-2e1774ab6dc6c43debb0b5b628bdf122a391d521-2.zip", + "filesize": 395414, + "hashValue": "d0905cd3c23541f67f9ff29ce392afdb7a5dd111906e1b5fa8fad4743b227acd7bd83d50e13ed029118a764983cf9e0a24c5321ecb9e5b88740dfbefcf34864c" + }, + "Darwin_x86_64-gcc3": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-macosx64-2e1774ab6dc6c43debb0b5b628bdf122a391d521-2.zip", + "filesize": 488639, + "hashValue": "d847b153f8ef2b4b095fbaf9f64b6d08658720ca1e4dc7288622c56d00858d038fe0fd07bc1efb0afc8f02dbd07818416ecc2555db8ed1199872b0d165f4eb62" + }, + "Linux_x86-gcc3": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-linux32-2e1774ab6dc6c43debb0b5b628bdf122a391d521.zip", + "filesize": 527704, + "hashValue": "903aecd631624db3047fc477363ac076794517bbc72b33a88a73627066b5997d9c1194975729ef2acbabba19e93574333b54e32763a5a834b8d9431b99181fd1" + }, + "Linux_x86_64-gcc3": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-linux64-2e1774ab6dc6c43debb0b5b628bdf122a391d521.zip", + "filesize": 511815, + "hashValue": "94531e267314de661b2205c606283fb066d781e5c11027578f2a3c3aa353437c2289544074a28101b6b6f0179f0fe6bd890a0ae2bb6e1cf9053650472576366c" + }, + "Linux_x86_64-gcc3-asan": { + "alias": "Linux_x86_64-gcc3" + }, + "WINNT_aarch64-msvc-aarch64": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-win64-aarch64-2e1774ab6dc6c43debb0b5b628bdf122a391d521.zip", + "filesize": 558607, + "hashValue": "8d936bca08dcf3538c5c118c0f468d672c556ac2ac828a4b9d1fcbb4339885d17ebcc748a918457abbea87d21c5cab2c007ca5b4ef87f04a52d44f42ee5fdbb9" + }, + "WINNT_x86-msvc": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-win32-2e1774ab6dc6c43debb0b5b628bdf122a391d521.zip", + "filesize": 491261, + "hashValue": "9ed5b4c27c2c159b83a1b887a1215d0472171cff422d2bc1962312f90e62d1b212955fe68bc88f826d613c9fb58b86f6fa16ebc1533e863f6a5648dcb1319bcb" + }, + "WINNT_x86-msvc-x64": { + "alias": "WINNT_x86-msvc" + }, + "WINNT_x86-msvc-x86": { + "alias": "WINNT_x86-msvc" + }, + "WINNT_x86_64-msvc": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-win64-2e1774ab6dc6c43debb0b5b628bdf122a391d521.zip", + "filesize": 453023, + "hashValue": "06511f1f6c6d44d076b3c593528c26a602348d9c41689dbf5ff716b671c3ca5756b12cb2e5869f836dedce27b1a5cfe79b93c707fd01f8e84b620923bb61b5f1" + }, + "WINNT_x86_64-msvc-x64": { + "alias": "WINNT_x86_64-msvc" + }, + "WINNT_x86_64-msvc-x64-asan": { + "alias": "WINNT_x86_64-msvc" + }, + "android-x86_64": { + "fileUrl": "http://ciscobinary.openh264.org/openh264-android-x86_64-42954cf0fe8a2bdc97fdc180462a3eaefceb035f.zip", + "filesize": 539311, + "hashValue": "2c80df83c84841477cf5489e4109a0913cf3ca801063d788e100a511c9226d46059e4d28ea76496c3208c046cc44c5ce0b5263b1bfda5b731f8461ce8ce7d1b7" + } + }, + "version": "1.8.1.2" + } + }, + "hashFunction": "sha512", + "name": "OpenH264-1.8.1.2", + "schema_version": 1000 +} diff --git a/toolkit/content/gmp-sources/widevinecdm.json b/toolkit/content/gmp-sources/widevinecdm.json new file mode 100644 index 0000000000..2af6363dab --- /dev/null +++ b/toolkit/content/gmp-sources/widevinecdm.json @@ -0,0 +1,60 @@ +{ + "hashFunction": "sha512", + "name": "Widevine-4.10.2710.0", + "schema_version": 1000, + "vendors": { + "gmp-widevinecdm": { + "platforms": { + "Darwin_aarch64-gcc3": { + "fileUrl": "https://redirector.gvt1.com/edgedl/widevine-cdm/4.10.2710.0-mac-arm64.zip", + "filesize": 14190373, + "hashValue": "3aa1e3e34abffb781fbbcb411a0381a4eb641793042987a8b6bcffdb2c366b52b0cb059c36dceff7146e80fbb98c5ccb2f98af726ce2619fa7bbd4b1d388414e" + }, + "Darwin_x86_64-gcc3": { + "alias": "Darwin_x86_64-gcc3-u-i386-x86_64" + }, + "Darwin_x86_64-gcc3-u-i386-x86_64": { + "fileUrl": "https://redirector.gvt1.com/edgedl/widevine-cdm/4.10.2710.0-mac-x64.zip", + "filesize": 14934332, + "hashValue": "02e2e5d30cd35d74c8a32192d48b35863bcc71756a323eab84a3c71acfd41dcb56bbb18a0555139cd111f74f7d18dcd821a89f3be0a34b7517fadeaf8b535ac0" + }, + "Linux_x86_64-gcc3": { + "fileUrl": "https://redirector.gvt1.com/edgedl/widevine-cdm/4.10.2710.0-linux-x64.zip", + "filesize": 13922119, + "hashValue": "661ad969099a89a278384f56a17ae912c3542d585ea4981f3b9a3c6e1a07f8da6ffad9db29cee194bf7834adc3ca258c775cd2b0980e3e6cb7ee8b39600dad58" + }, + "Linux_x86_64-gcc3-asan": { + "alias": "Linux_x86_64-gcc3" + }, + "WINNT_aarch64-msvc-aarch64": { + "fileUrl": "https://redirector.gvt1.com/edgedl/widevine-cdm/4.10.2710.0-win-arm64.zip", + "filesize": 13900511, + "hashValue": "0f42c5dc0e040036653501fe32cb646123a1018804af4d8890d71bbd716c4e379a81a7d70c0cc5ca4b6ec3aa9cc2612cbc7599f63cbee5c82b03de499df3742e" + }, + "WINNT_x86-msvc": { + "fileUrl": "https://redirector.gvt1.com/edgedl/widevine-cdm/4.10.2710.0-win-x86.zip", + "filesize": 14250607, + "hashValue": "5e4b8672e9fd6bf7db4c85d7d49bf28a5ca2ed352238fe93610205d16c9af67855aa0b02c71b7410ad45716410fa540ddf5046201f0ff052b2c2374b4c9a4760" + }, + "WINNT_x86-msvc-x64": { + "alias": "WINNT_x86-msvc" + }, + "WINNT_x86-msvc-x86": { + "alias": "WINNT_x86-msvc" + }, + "WINNT_x86_64-msvc": { + "fileUrl": "https://redirector.gvt1.com/edgedl/widevine-cdm/4.10.2710.0-win-x64.zip", + "filesize": 14485862, + "hashValue": "59521f8c61236641b3299ab460c58c8f5f26fa67e828de853c2cf372f9614d58b9f541aae325b1600ec4f3a47953caacb8122b0dfce7481acfec81045735947d" + }, + "WINNT_x86_64-msvc-x64": { + "alias": "WINNT_x86_64-msvc" + }, + "WINNT_x86_64-msvc-x64-asan": { + "alias": "WINNT_x86_64-msvc" + } + }, + "version": "4.10.2710.0" + } + } +} diff --git a/toolkit/content/gmp-sources/widevinecdm_l1.json b/toolkit/content/gmp-sources/widevinecdm_l1.json new file mode 100644 index 0000000000..216fa27c41 --- /dev/null +++ b/toolkit/content/gmp-sources/widevinecdm_l1.json @@ -0,0 +1,23 @@ +{ + "hashFunction": "sha512", + "name": "Widevine-L1-1.0.2738.0", + "schema_version": 1000, + "vendors": { + "gmp-widevinecdm-l1": { + "platforms": { + "WINNT_x86_64-msvc": { + "fileUrl": "https://redirector.gvt1.com/edgedl/release2/chrome_component/imoffpf67hel7kbknqflao2oo4_1.0.2738.0/neifaoindggfcjicffkgpmnlppeffabd_1.0.2738.0_win64_kj4dp5kifwxbdodqls7e5nzhtm.crx3", + "filesize": 1181927, + "hashValue": "4fd27594c459fb1cd94a857be10f7d1d6216dbf202cd43e8a3fa395a268c72fc5f5c456c9cb314f2220d766af741db469c8bb106acbed419149a44a3b87619f1" + }, + "WINNT_x86_64-msvc-x64": { + "alias": "WINNT_x86_64-msvc" + }, + "WINNT_x86_64-msvc-x64-asan": { + "alias": "WINNT_x86_64-msvc" + } + }, + "version": "1.0.2738.0" + } + } +} diff --git a/toolkit/content/jar.mn b/toolkit/content/jar.mn new file mode 100644 index 0000000000..8b18c94525 --- /dev/null +++ b/toolkit/content/jar.mn @@ -0,0 +1,145 @@ +toolkit.jar: +% content global %content/global/ contentaccessible=yes +* content/global/license.html + content/global/xul.css + content/global/autocomplete.css + content/global/aboutAbout.js + content/global/aboutAbout.html + content/global/aboutLogging.js + content/global/aboutLogging.html + content/global/aboutNetError.mjs + content/global/aboutNetError.html + content/global/aboutNetworking.js + content/global/aboutNetworking.html +#ifndef ANDROID + content/global/aboutProfiles.js + content/global/aboutProfiles.xhtml +#endif + content/global/aboutRights.js +#ifdef MOZILLA_OFFICIAL + content/global/aboutRights.xhtml +#else + content/global/aboutRights.xhtml (aboutRights-unbranded.xhtml) +#endif + content/global/aboutServiceWorkers.js + content/global/aboutServiceWorkers.xhtml + content/global/aboutwebrtc/aboutWebrtc.css (aboutwebrtc/aboutWebrtc.css) + content/global/aboutwebrtc/aboutWebrtc.mjs (aboutwebrtc/aboutWebrtc.mjs) + content/global/aboutwebrtc/graph.mjs (aboutwebrtc/graph.mjs) + content/global/aboutwebrtc/graphdb.mjs (aboutwebrtc/graphdb.mjs) + content/global/aboutwebrtc/configurationList.mjs (aboutwebrtc/configurationList.mjs) + content/global/aboutwebrtc/disclosure.mjs (aboutwebrtc/disclosure.mjs) + content/global/aboutwebrtc/copyButton.mjs (aboutwebrtc/copyButton.mjs) + content/global/aboutwebrtc/aboutWebrtc.html (aboutwebrtc/aboutWebrtc.html) + content/global/aboutSupport.js +* content/global/aboutSupport.xhtml +#ifndef MOZ_GLEAN_ANDROID + content/global/aboutGlean.js + content/global/aboutGlean.html + content/global/aboutGlean.css +#endif + content/global/aboutTelemetry.js + content/global/aboutTelemetry.xhtml + content/global/aboutTelemetry.css + content/global/aboutUrlClassifier.js + content/global/aboutUrlClassifier.xhtml + content/global/aboutUrlClassifier.css +* content/global/buildconfig.html + content/global/buildconfig.css + content/global/contentAreaUtils.js + content/global/datepicker.xhtml +#ifndef MOZ_FENNEC + content/global/editMenuOverlay.js +#endif + content/global/filepicker.properties + content/global/customElements.js + content/global/globalOverlay.js + content/global/mozilla.html + content/global/aboutMozilla.css + content/global/preferencesBindings.js + content/global/process-content.js + content/global/resetProfile.css + content/global/resetProfile.js + content/global/resetProfile.xhtml + content/global/resetProfileProgress.xhtml + content/global/TopLevelVideoDocument.js + content/global/timepicker.xhtml + content/global/treeUtils.js +#ifndef MOZ_FENNEC + content/global/viewZoomOverlay.js +#endif + content/global/widgets.css + content/global/bindings/calendar.js (widgets/calendar.js) + content/global/bindings/datekeeper.js (widgets/datekeeper.js) + content/global/bindings/datepicker.js (widgets/datepicker.js) + content/global/bindings/datetimebox.css (widgets/datetimebox.css) + content/global/bindings/spinner.js (widgets/spinner.js) + content/global/bindings/timekeeper.js (widgets/timekeeper.js) + content/global/bindings/timepicker.js (widgets/timepicker.js) + content/global/elements/autocomplete-input.js (widgets/autocomplete-input.js) + content/global/elements/autocomplete-popup.js (widgets/autocomplete-popup.js) + content/global/elements/autocomplete-richlistitem.js (widgets/autocomplete-richlistitem.js) + content/global/elements/browser-custom-element.js (widgets/browser-custom-element.js) + content/global/elements/button.js (widgets/button.js) + content/global/elements/checkbox.js (widgets/checkbox.js) + content/global/elements/datetimebox.js (widgets/datetimebox.js) + content/global/elements/dialog.js (widgets/dialog.js) + content/global/elements/findbar.js (widgets/findbar.js) + content/global/elements/editor.js (widgets/editor.js) + content/global/elements/general.js (widgets/general.js) + content/global/elements/message-bar.css (widgets/message-bar.css) + content/global/elements/message-bar.js (widgets/message-bar.js) + content/global/elements/menu.js (widgets/menu.js) + content/global/elements/menupopup.js (widgets/menupopup.js) + content/global/elements/moz-button-group.css (widgets/moz-button-group/moz-button-group.css) + content/global/elements/moz-button-group.mjs (widgets/moz-button-group/moz-button-group.mjs) + content/global/elements/moz-card.css (widgets/moz-card/moz-card.css) + content/global/elements/moz-card.mjs (widgets/moz-card/moz-card.mjs) + content/global/elements/moz-five-star.css (widgets/moz-five-star/moz-five-star.css) + content/global/elements/moz-five-star.mjs (widgets/moz-five-star/moz-five-star.mjs) + content/global/elements/moz-input-box.js (widgets/moz-input-box.js) + content/global/elements/moz-label.css (widgets/moz-label/moz-label.css) + content/global/elements/moz-label.mjs (widgets/moz-label/moz-label.mjs) + content/global/elements/moz-message-bar.css (widgets/moz-message-bar/moz-message-bar.css) + content/global/elements/moz-message-bar.mjs (widgets/moz-message-bar/moz-message-bar.mjs) + content/global/elements/moz-support-link.mjs (widgets/moz-support-link/moz-support-link.mjs) + content/global/elements/moz-toggle.css (widgets/moz-toggle/moz-toggle.css) + content/global/elements/moz-toggle.mjs (widgets/moz-toggle/moz-toggle.mjs) + content/global/elements/named-deck.js (widgets/named-deck.js) + content/global/elements/infobar.css (widgets/infobar.css) + content/global/elements/notificationbox.js (widgets/notificationbox.js) + content/global/elements/panel.js (widgets/panel.js) + content/global/elements/panel-item.css (widgets/panel-list/panel-item.css) + content/global/elements/panel-list.css (widgets/panel-list/panel-list.css) + content/global/elements/panel-list.js (widgets/panel-list/panel-list.js) + content/global/elements/radio.js (widgets/radio.js) + content/global/elements/richlistbox.js (widgets/richlistbox.js) + content/global/elements/marquee.css (widgets/marquee.css) + content/global/elements/marquee.js (widgets/marquee.js) + content/global/elements/menulist.js (widgets/menulist.js) + content/global/elements/popupnotification.js (widgets/popupnotification.js) + content/global/elements/arrowscrollbox.js (widgets/arrowscrollbox.js) + content/global/elements/search-textbox.js (widgets/search-textbox.js) + content/global/elements/stringbundle.js (widgets/stringbundle.js) + content/global/elements/tabbox.js (widgets/tabbox.js) + content/global/elements/text.js (widgets/text.js) + content/global/elements/textrecognition.js (widgets/textrecognition.js) + content/global/elements/toolbarbutton.js (widgets/toolbarbutton.js) + content/global/elements/videocontrols.js (widgets/videocontrols.js) + content/global/elements/tree.js (widgets/tree.js) + content/global/elements/wizard.js (widgets/wizard.js) + content/global/vendor/lit.all.mjs (widgets/vendor/lit.all.mjs) + content/global/lit-utils.mjs (widgets/lit-utils.mjs) + content/global/neterror/aboutNetErrorCodes.js (neterror/aboutNetErrorCodes.js) + content/global/neterror/supportpages/connection-not-secure.html (neterror/supportpages/connection-not-secure.html) + content/global/neterror/supportpages/time-errors.html (neterror/supportpages/time-errors.html) +#ifdef XP_MACOSX + content/global/macWindowMenu.js +#endif + content/global/gmp-sources/openh264.json (gmp-sources/openh264.json) + content/global/gmp-sources/widevinecdm.json (gmp-sources/widevinecdm.json) + content/global/gmp-sources/widevinecdm_l1.json (gmp-sources/widevinecdm_l1.json) + +# Third party files + content/global/third_party/d3/d3.js (/third_party/js/d3/d3.js) + content/global/third_party/cfworker/json-schema.js (/third_party/js/cfworker/json-schema.js) diff --git a/toolkit/content/license.html b/toolkit/content/license.html new file mode 100644 index 0000000000..9e0721906b --- /dev/null +++ b/toolkit/content/license.html @@ -0,0 +1,5686 @@ +<!DOCTYPE HTML> +<!-- 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/. --> + +<html lang="en"> + <head> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src chrome:; img-src chrome:; object-src 'none'"> + <meta charset="utf-8"> + <meta name="color-scheme" content="light dark"> + <title>Licenses</title> + <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css"> + <link rel="stylesheet" href="chrome://global/skin/aboutLicense.css"> + </head> + + <body id="lic-info"> + <div class="license-header"> + <div> + <h1><a id="top"></a>Licenses</h1> +#ifdef APP_LICENSE_BLOCK +#includesubst @APP_LICENSE_BLOCK@ +#endif + </div> + </div> + <div> + <p>All of the <b>source code</b> to this product is + available under licenses which are both + <a href="https://www.gnu.org/philosophy/free-sw.html">free</a> and + <a href="https://www.opensource.org/docs/definition.php">open source</a>. + A URL identifying the specific source code used to create this copy can be found + on the <a href="about:buildconfig">build configuration page</a>, and you can read + <a href="https://firefox-source-docs.mozilla.org/contributing/contribution_quickref.html">instructions + on how to download and build the code for yourself</a>. + </p> + + <p>More specifically, most of the source code is available under the + <a href="about:license#mpl">Mozilla Public License 2.0</a> (MPL). + The MPL has a + <a href="https://www.mozilla.org/MPL/2.0/FAQ/">FAQ</a> to help + you understand it. The remainder of the software which is not + under the MPL is available under one of a variety of other + free and open source licenses. Those that require reproduction + of the license text in the distribution are given below. + (Note: your copy of this product may not contain code covered by one + or more of the licenses listed here, depending on the exact product + and version you choose.) + </p> + + <ul> + <li><a href="about:license#mpl">Mozilla Public License 2.0</a> + <br><br> + </li> + <li><a href="about:license#lgpl">GNU Lesser General Public License 2.1</a> + <br><br> + </li> + <li><a href="about:license#lgpl-3.0">GNU Lesser General Public License 3.0</a> + <br><br> + </li> + <li><a href="about:license#acorn">acorn License</a></li> +#ifdef MOZ_INSTALL_TRACKING + <li><a href="about:license#adjust">Adjust SDK License</a></li> +#endif + <li><a href="about:license#android">Android Open Source License</a></li> + <li><a href="about:license#angle">ANGLE License</a></li> + <li><a href="about:license#apache">Apache License 2.0</a></li> + <li><a href="about:license#apache-llvm">Apache License 2.0 with LLVM exception</a></li> + <li><a href="about:license#apple">Apple License</a></li> + <li><a href="about:license#apple-password-rules-parser">Apple Password Rules Parser License</a></li> + <li><a href="about:license#arm">ARM License</a></li> + <li><a href="about:license#boost">boost License</a></li> + <li><a href="about:license#bsd2clause">BSD 2-Clause License</a></li> + <li><a href="about:license#bsd3clause">BSD 3-Clause License</a></li> + <li><a href="about:license#bspatch">bspatch License</a></li> + <li><a href="about:license#cairo">Cairo Component Licenses</a></li> + <li><a href="about:license#chromium">Chromium License</a></li> + <li><a href="about:license#codemirror">CodeMirror License</a></li> + <li><a href="about:license#cryptogams">CRYPTOGAMS License</a></li> + <li><a href="about:license#cubic-bezier">cubic-bezier License</a></li> + <li><a href="about:license#d3">D3 License</a></li> + <li><a href="about:license#dagre-d3">Dagre-D3 License</a></li> + <li><a href="about:license#diff">diff License</a></li> + <li><a href="about:license#disconnect.me">Disconnect.Me License</a> + <li><a href="about:license#dtoa">dtoa License</a></li> + <li><a href="about:license#hunspell-nl">Dutch Spellchecking Dictionary License</a></li> +#if defined(XP_WIN) || defined(XP_LINUX) + <li><a href="about:license#twemoji">Twemoji License</a></li> +#endif + <li><a href="about:license#hunspell-ee">Estonian Spellchecking Dictionary License</a></li> + <li><a href="about:license#expat">Expat License</a></li> + <li><a href="about:license#firebug">Firebug License</a></li> + <li><a href="about:license#gfx-font-list">gfxFontList License</a></li> + <li><a href="about:license#google-bsd">Google BSD License</a></li> + <li><a href="about:license#gears">Google Gears License</a></li> + <li><a href="about:license#gears-istumbler">Google Gears/iStumbler License</a></li> + <li><a href="about:license#vp8">Google VP8 License</a></li> + <li><a href="about:license#gyp">gyp License</a></li> + <li><a href="about:license#halloc">halloc License</a></li> + <li><a href="about:license#harfbuzz">HarfBuzz License</a></li> + <li><a href="about:license#icu">ICU License</a></li> + <li><a href="about:license#immutable">Immutable.js License</a></li> + <li><a href="about:license#jpnic">Japan Network Information Center License</a></li> + <li><a href="about:license#jszip">JSZip License</a></li> + <li><a href="about:license#jemalloc">jemalloc License</a></li> + <li><a href="about:license#jquery">jQuery License</a></li> + <li><a href="about:license#k_exp">k_exp License</a></li> + <li><a href="about:license#khronos">Khronos group License</a></li> + <li><a href="about:license#kiss_fft">Kiss FFT License</a></li> +#ifdef MOZ_USE_LIBCXX + <li><a href="about:license#libc++">libc++ License</a></li> +#endif + <li><a href="about:license#libcubeb">libcubeb License</a></li> + <li><a href="about:license#libevent">libevent License</a></li> + <li><a href="about:license#libffi">libffi License</a></li> + <li><a href="about:license#libjingle">libjingle License</a></li> + <li><a href="about:license#libnestegg">libnestegg License</a></li> + <li><a href="about:license#libsoundtouch">libsoundtouch License</a></li> + <li><a href="about:license#libyuv">libyuv License</a></li> + <li><a href="about:license#hunspell-lt">Lithuanian Spellchecking Dictionary License</a></li> + <li><a href="about:license#lodash">lodash License</a></li> + <li><a href="about:license#matches">matches License</a></li> + <li><a href="about:license#mit">MIT License</a></li> + <li><a href="about:license#myspell">MySpell License</a></li> + <li><a href="about:license#nicer">nICEr License</a></li> + <li><a href="about:license#node-md5">node-md5 License</a></li> + <li><a href="about:license#nom">nom License</a></li> + <li><a href="about:license#nrappkit">nrappkit License</a></li> + <li><a href="about:license#openldap">OpenLDAP Public License</a></li> + <li><a href="about:license#openvision">OpenVision License</a></li> +#if defined(XP_WIN) || defined(XP_MACOSX) || defined(XP_LINUX) + <li><a href="about:license#openvr">OpenVR License</a></li> +#endif + <li><a href="about:license#pbkdf2-sha256">pbkdf2_sha256 License</a></li> + <li><a href="about:license#praton">praton License</a></li> + <li><a href="about:license#praton1">praton and inet_ntop License</a></li> + <li><a href="about:license#qcms">qcms License</a></li> + <li><a href="about:license#qrcode-generator">QR Code Generator License</a></li> + <li><a href="about:license#react">React License</a></li> + <li><a href="about:license#react-redux">React-Redux License</a></li> + <li><a href="about:license#xdg">Red Hat xdg_user_dir_lookup License</a></li> + <li><a href="about:license#redux">Redux License</a></li> + <li><a href="about:license#hunspell-ru">Russian Spellchecking Dictionary License</a></li> + <li><a href="about:license#sctp">SCTP Licenses</a></li> + <li><a href="about:license#skia">Skia License</a></li> + <li><a href="about:license#snappy">Snappy License</a></li> + <li><a href="about:license#sprintf.js">sprintf.js License</a></li> + <li><a href="about:license#sunsoft">SunSoft License</a></li> + <li><a href="about:license#superfasthash">SuperFastHash License</a></li> + <li><a href="about:license#unicase">unicase License</a></li> + <li><a href="about:license#unicode">Unicode License</a></li> + <li><a href="about:license#unicode-v3">Unicode License V3</a></li> + <li><a href="about:license#ucal">University of California License</a></li> + <li><a href="about:license#hunspell-en">English Spellchecking Dictionary Licenses</a></li> + <li><a href="about:license#v8">V8 License</a></li> + <li><a href="about:license#validator">Validator License</a></li> + <li><a href="about:license#vtune">VTune License</a></li> + <li><a href="about:license#webrtc">WebRTC License</a></li> +#ifdef MOZ_DEFAULT_BROWSER_AGENT + <li><a href="about:license#wintoast">WinToast License</a></li> +#endif + <li><a href="about:license#x264">x264 License</a></li> + <li><a href="about:license#xiph">Xiph.org Foundation License</a></li> + </ul> + +<br> + + <ul> + <li><a href="about:license#other-notices">Other Required Notices</a> + <li><a href="about:license#optional-notices">Optional Notices</a> +#ifdef XP_WIN + <li><a href="about:license#proprietary-notices">Proprietary Operating System Components</a> +#endif + </ul> + +#ifdef APP_LICENSE_LIST_BLOCK +#ifndef APP_LICENSE_BODY_BLOCK +#error +#endif +#ifndef APP_LICENSE_PRODUCT_NAME +#error +#endif +The following licenses are specific to code used by the +#includesubst @APP_LICENSE_PRODUCT_NAME@ +product. + +<!-- Index of product-specific licenses for non-Firefox apps. --> +#includesubst @APP_LICENSE_LIST_BLOCK@ +#endif + + </div> + + <hr> + + <h1 id="mpl">Mozilla Public License 2.0</h1> + + <h2 id="definitions">1. Definitions</h2> + + <dl> + <dt>1.1. "Contributor"</dt> + + <dd> + <p>means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software.</p> + </dd> + + <dt>1.2. "Contributor Version"</dt> + + <dd> + <p>means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution.</p> + </dd> + + <dt>1.3. "Contribution"</dt> + + <dd> + <p>means Covered Software of a particular Contributor.</p> + </dd> + + <dt>1.4. "Covered Software"</dt> + + <dd> + <p>means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code Form, + and Modifications of such Source Code Form, in each case including + portions thereof.</p> + </dd> + + <dt>1.5. "Incompatible With Secondary Licenses"</dt> + + <dd> + <p>means</p> + + <ol type="a"> + <li> + <p>that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or</p> + </li> + + <li> + <p>that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the terms + of a Secondary License.</p> + </li> + </ol> + </dd> + + <dt>1.6. "Executable Form"</dt> + + <dd> + <p>means any form of the work other than Source Code Form.</p> + </dd> + + <dt>1.7. "Larger Work"</dt> + + <dd> + <p>means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software.</p> + </dd> + + <dt>1.8. "License"</dt> + + <dd> + <p>means this document.</p> + </dd> + + <dt>1.9. "Licensable"</dt> + + <dd> + <p>means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and all + of the rights conveyed by this License.</p> + </dd> + + <dt>1.10. "Modifications"</dt> + + <dd> + <p>means any of the following:</p> + + <ol type="a"> + <li> + <p>any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered Software; + or</p> + </li> + + <li> + <p>any new file in Source Code Form that contains any Covered + Software.</p> + </li> + </ol> + </dd> + + <dt>1.11. "Patent Claims" of a Contributor</dt> + + <dd> + <p>means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the License, + by the making, using, selling, offering for sale, having made, import, + or transfer of either its Contributions or its Contributor Version.</p> + </dd> + + <dt>1.12. "Secondary License"</dt> + + <dd> + <p>means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses.</p> + </dd> + + <dt>1.13. "Source Code Form"</dt> + + <dd> + <p>means the form of the work preferred for making modifications.</p> + </dd> + + <dt>1.14. "You" (or "Your")</dt> + + <dd> + <p>means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that controls, + is controlled by, or is under common control with You. For purposes of + this definition, "control" means (a) the power, direct or indirect, to + cause the direction or management of such entity, whether by contract + or otherwise, or (b) ownership of more than fifty percent (50%) of the + outstanding shares or beneficial ownership of such entity.</p> + </dd> + </dl> + + <h2 id="license-grants-and-conditions">2. License Grants and + Conditions</h2> + + <h3 id="grants">2.1. Grants</h3> + + <p>Each Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license:</p> + + <ol type="a"> + <li> + <p>under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or as + part of a Larger Work; and</p> + </li> + + <li> + <p>under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version.</p> + </li> + </ol> + + <h3 id="effective-date">2.2. Effective Date</h3> + + <p>The licenses granted in Section 2.1 with respect to any Contribution + become effective for each Contribution on the date the Contributor first + distributes such Contribution.</p> + + <h3 id="limitations-on-grant-scope">2.3. Limitations on Grant Scope</h3> + + <p>The licenses granted in this Section 2 are the only rights granted under + this License. No additional rights or licenses will be implied from the + distribution or licensing of Covered Software under this License. + Notwithstanding Section 2.1(b) above, no patent license is granted by a + Contributor:</p> + + <ol type="a"> + <li> + <p>for any code that a Contributor has removed from Covered Software; + or</p> + </li> + + <li> + <p>for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or</p> + </li> + + <li> + <p>under Patent Claims infringed by Covered Software in the absence of + its Contributions.</p> + </li> + </ol> + + <p>This License does not grant any rights in the trademarks, service marks, + or logos of any Contributor (except as may be necessary to comply with the + notice requirements in Section 3.4).</p> + + <h3 id="subsequent-licenses">2.4. Subsequent Licenses</h3> + + <p>No Contributor makes additional grants as a result of Your choice to + distribute the Covered Software under a subsequent version of this License + (see Section 10.2) or under the terms of a Secondary License (if permitted + under the terms of Section 3.3).</p> + + <h3 id="representation">2.5. Representation</h3> + + <p>Each Contributor represents that the Contributor believes its + Contributions are its original creation(s) or it has sufficient rights to + grant the rights to its Contributions conveyed by this License.</p> + + <h3 id="fair-use">2.6. Fair Use</h3> + + <p>This License is not intended to limit any rights You have under + applicable copyright doctrines of fair use, fair dealing, or other + equivalents.</p> + + <h3 id="conditions">2.7. Conditions</h3> + + <p>Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted + in Section 2.1.</p> + + <h2 id="responsibilities">3. Responsibilities</h2> + + <h3 id="distribution-of-source-form">3.1. Distribution of Source Form</h3> + + <p>All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under the + terms of this License. You must inform recipients that the Source Code Form + of the Covered Software is governed by the terms of this License, and how + they can obtain a copy of this License. You may not attempt to alter or + restrict the recipients' rights in the Source Code Form.</p> + + <h3 id="distribution-of-executable-form">3.2. Distribution of Executable + Form</h3> + + <p>If You distribute Covered Software in Executable Form then:</p> + + <ol type="a"> + <li> + <p>such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code Form + by reasonable means in a timely manner, at a charge no more than the + cost of distribution to the recipient; and</p> + </li> + + <li> + <p>You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter the + recipients' rights in the Source Code Form under this License.</p> + </li> + </ol> + + <h3 id="distribution-of-a-larger-work">3.3. Distribution of a Larger + Work</h3> + + <p>You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for the + Covered Software. If the Larger Work is a combination of Covered Software + with a work governed by one or more Secondary Licenses, and the Covered + Software is not Incompatible With Secondary Licenses, this License permits + You to additionally distribute such Covered Software under the terms of + such Secondary License(s), so that the recipient of the Larger Work may, at + their option, further distribute the Covered Software under the terms of + either this License or such Secondary License(s).</p> + + <h3 id="notices">3.4. Notices</h3> + + <p>You may not remove or alter the substance of any license notices + (including copyright notices, patent notices, disclaimers of warranty, or + limitations of liability) contained within the Source Code Form of the + Covered Software, except that You may alter any license notices to the + extent required to remedy known factual inaccuracies.</p> + + <h3 id="application-of-additional-terms">3.5. Application of Additional + Terms</h3> + + <p>You may choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of Covered + Software. However, You may do so only on Your own behalf, and not on behalf + of any Contributor. You must make it absolutely clear that any such + warranty, support, indemnity, or liability obligation is offered by You + alone, and You hereby agree to indemnify every Contributor for any + liability incurred by such Contributor as a result of warranty, support, + indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any + jurisdiction.</p> + + <h2 id="inability-to-comply-due-to-statute-or-regulation">4. Inability to + Comply Due to Statute or Regulation</h2> + + <p>If it is impossible for You to comply with any of the terms of this + License with respect to some or all of the Covered Software due to statute, + judicial order, or regulation then You must: (a) comply with the terms of + this License to the maximum extent possible; and (b) describe the + limitations and the code they affect. Such description must be placed in a + text file included with all distributions of the Covered Software under + this License. Except to the extent prohibited by statute or regulation, + such description must be sufficiently detailed for a recipient of ordinary + skill to be able to understand it.</p> + + <h2 id="termination">5. Termination</h2> + + <h3>5.1.</h3> + + <p>The rights granted under this License will terminate automatically + if You fail to comply with any of its terms. However, if You become + compliant, then the rights granted under this License from a particular + Contributor are reinstated (a) provisionally, unless and until such + Contributor explicitly and finally terminates Your grants, and (b) on an + ongoing basis, if such Contributor fails to notify You of the + non-compliance by some reasonable means prior to 60 days after You have + come back into compliance. Moreover, Your grants from a particular + Contributor are reinstated on an ongoing basis if such Contributor notifies + You of the non-compliance by some reasonable means, this is the first time + You have received notice of non-compliance with this License from such + Contributor, and You become compliant prior to 30 days after Your receipt + of the notice.</p> + + <h3>5.2.</h3> + + <p>If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, counter-claims, + and cross-claims) alleging that a Contributor Version directly or + indirectly infringes any patent, then the rights granted to You by any and + all Contributors for the Covered Software under Section 2.1 of this License + shall terminate.</p> + + <h3>5.3.</h3> + + <p>In the event of termination under Sections 5.1 or 5.2 above, all + end user license agreements (excluding distributors and resellers) which + have been validly granted by You or Your distributors under this License + prior to termination shall survive termination.</p> + + <h2 id="disclaimer-of-warranty">6. Disclaimer of Warranty</h2> + + <p><em>Covered Software is provided under this License on an "as is" basis, + without warranty of any kind, either expressed, implied, or statutory, + including, without limitation, warranties that the Covered Software is free + of defects, merchantable, fit for a particular purpose or non-infringing. + The entire risk as to the quality and performance of the Covered Software + is with You. Should any Covered Software prove defective in any respect, + You (not any Contributor) assume the cost of any necessary servicing, + repair, or correction. This disclaimer of warranty constitutes an essential + part of this License. No use of any Covered Software is authorized under + this License except under this disclaimer.</em></p> + + <h2 id="limitation-of-liability">7. Limitation of Liability</h2> + + <p><em>Under no circumstances and under no legal theory, whether tort + (including negligence), contract, or otherwise, shall any Contributor, or + anyone who distributes Covered Software as permitted above, be liable to + You for any direct, indirect, special, incidental, or consequential damages + of any character including, without limitation, damages for lost profits, + loss of goodwill, work stoppage, computer failure or malfunction, or any + and all other commercial damages or losses, even if such party shall have + been informed of the possibility of such damages. This limitation of + liability shall not apply to liability for death or personal injury + resulting from such party's negligence to the extent applicable law + prohibits such limitation. Some jurisdictions do not allow the exclusion or + limitation of incidental or consequential damages, so this exclusion and + limitation may not apply to You.</em></p> + + <h2 id="litigation">8. Litigation</h2> + + <p>Any litigation relating to this License may be brought only in the + courts of a jurisdiction where the defendant maintains its principal place + of business and such litigation shall be governed by laws of that + jurisdiction, without reference to its conflict-of-law provisions. Nothing + in this Section shall prevent a party's ability to bring cross-claims or + counter-claims.</p> + + <h2 id="miscellaneous">9. Miscellaneous</h2> + + <p>This License represents the complete agreement concerning the subject + matter hereof. If any provision of this License is held to be + unenforceable, such provision shall be reformed only to the extent + necessary to make it enforceable. Any law or regulation which provides that + the language of a contract shall be construed against the drafter shall not + be used to construe this License against a Contributor.</p> + + <h2 id="versions-of-the-license">10. Versions of the License</h2> + + <h3 id="new-versions">10.1. New Versions</h3> + + <p>Mozilla Foundation is the license steward. Except as provided in Section + 10.3, no one other than the license steward has the right to modify or + publish new versions of this License. Each version will be given a + distinguishing version number.</p> + + <h3 id="effect-of-new-versions">10.2. Effect of New Versions</h3> + + <p>You may distribute the Covered Software under the terms of the version + of the License under which You originally received the Covered Software, or + under the terms of any subsequent version published by the license + steward.</p> + + <h3 id="modified-versions">10.3. Modified Versions</h3> + + <p>If you create software not governed by this License, and you want to + create a new license for such software, you may create and use a modified + version of this License if you rename the license and remove any references + to the name of the license steward (except to note that such modified + license differs from this License).</p> + + <h3 id= + "distributing-source-code-form-that-is-incompatible-with-secondary-licenses"> + 10.4. Distributing Source Code Form that is Incompatible With Secondary + Licenses</h3> + + <p>If You choose to distribute Source Code Form that is Incompatible With + Secondary Licenses under the terms of this version of the License, the + notice described in Exhibit B of this License must be attached.</p> + + <h2 id="exhibit-a---source-code-form-license-notice">Exhibit A - Source + Code Form License Notice</h2> + + <blockquote> + <p>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 https://mozilla.org/MPL/2.0/.</p> + </blockquote> + + <p>If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE file + in a relevant directory) where a recipient would be likely to look for such + a notice.</p> + + <p>You may add additional accurate notices of copyright ownership.</p> + + <h2 id="exhibit-b---incompatible-with-secondary-licenses-notice">Exhibit B + - "Incompatible With Secondary Licenses" Notice</h2> + + <blockquote> + <p>This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0.</p> + </blockquote> + + + <hr> + + <h1 id="lgpl">GNU Lesser General Public License 2.1</h1> + +<p>This product contains code from the following LGPLed libraries:</p> + +<ul> +<li><a href="https://www.surina.net/soundtouch/">libsoundtouch</a> +<li><a href="https://libav.org/">Libav</a> +<li><a href="https://ffmpeg.org/">FFmpeg</a> +</ul> + +<pre> +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] +</pre> + +<h3><a id="SEC2">Preamble</a></h3> + +<p> + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. +</p> +<p> + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. +</p> +<p> + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. +</p> +<p> + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. +</p> +<p> + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. +</p> +<p> + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. +</p> +<p> + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. +</p> +<p> + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. +</p> +<p> + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. +</p> +<p> + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. +</p> +<p> + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. +</p> +<p> + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. +</p> +<p> + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. +</p> +<p> + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. +</p> +<p> + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. +</p> + +<h3><a id="SEC3">TERMS AND CONDITIONS FOR COPYING, +DISTRIBUTION AND MODIFICATION</a></h3> + + +<p> +<strong>0.</strong> +This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". +</p> +<p> + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. +</p> +<p> + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) +</p> +<p> + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. +</p> +<p> + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. +</p> +<p> +<strong>1.</strong> +You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. +</p> +<p> + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. +</p> +<p> +<strong>2.</strong> +You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: +</p> + +<ul> + <li><strong>a)</strong> + The modified work must itself be a software library.</li> + <li><strong>b)</strong> + You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change.</li> + + <li><strong>c)</strong> + You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License.</li> + + <li><strong>d)</strong> + If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + <p> + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.)</p></li> +</ul> + +<p> +These requirements apply to the modified work as a whole. If identifiable +sections of that work are not derived from the Library, and can be +reasonably considered independent and separate works in themselves, then +this License, and its terms, do not apply to those sections when you +distribute them as separate works. But when you distribute the same +sections as part of a whole which is a work based on the Library, the +distribution of the whole must be on the terms of this License, whose +permissions for other licensees extend to the entire whole, and thus to +each and every part regardless of who wrote it. +</p> +<p> +Thus, it is not the intent of this section to claim rights or contest your +rights to work written entirely by you; rather, the intent is to exercise +the right to control the distribution of derivative or collective works +based on the Library. +</p> +<p> +In addition, mere aggregation of another work not based on the Library with +the Library (or with a work based on the Library) on a volume of a storage +or distribution medium does not bring the other work under the scope of +this License. +</p> +<p> +<strong>3.</strong> +You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. +</p> +<p> + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. +</p> +<p> + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. +</p> +<p> +<strong>4.</strong> +You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. +</p> +<p> + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. +</p> +<p> +<strong>5.</strong> +A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. +</p> +<p> + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. +</p> +<p> + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. +</p> +<p> + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) +</p> +<p> + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. +</p> +<p> +<strong>6.</strong> +As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. +</p> +<p> + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: +</p> + +<ul> + <li><strong>a)</strong> Accompany the work with the complete + corresponding machine-readable source code for the Library + including whatever changes were used in the work (which must be + distributed under Sections 1 and 2 above); and, if the work is an + executable linked with the Library, with the complete + machine-readable "work that uses the Library", as object code + and/or source code, so that the user can modify the Library and + then relink to produce a modified executable containing the + modified Library. (It is understood that the user who changes the + contents of definitions files in the Library will not necessarily + be able to recompile the application to use the modified + definitions.)</li> + + <li><strong>b)</strong> Use a suitable shared library mechanism + for linking with the Library. A suitable mechanism is one that + (1) uses at run time a copy of the library already present on the + user's computer system, rather than copying library functions into + the executable, and (2) will operate properly with a modified + version of the library, if the user installs one, as long as the + modified version is interface-compatible with the version that the + work was made with.</li> + + <li><strong>c)</strong> Accompany the work with a written offer, + valid for at least three years, to give the same user the + materials specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution.</li> + + <li><strong>d)</strong> If distribution of the work is made by + offering access to copy from a designated place, offer equivalent + access to copy the above specified materials from the same + place.</li> + + <li><strong>e)</strong> Verify that the user has already received + a copy of these materials or that you have already sent this user + a copy.</li> +</ul> + +<p> + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. +</p> +<p> + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. +</p> +<p> +<strong>7.</strong> You may place library facilities that are a work +based on the Library side-by-side in a single library together with +other library facilities not covered by this License, and distribute +such a combined library, provided that the separate distribution of +the work based on the Library and of the other library facilities is +otherwise permitted, and provided that you do these two things: +</p> + +<ul> + <li><strong>a)</strong> Accompany the combined library with a copy + of the same work based on the Library, uncombined with any other + library facilities. This must be distributed under the terms of + the Sections above.</li> + + <li><strong>b)</strong> Give prominent notice with the combined + library of the fact that part of it is a work based on the + Library, and explaining where to find the accompanying uncombined + form of the same work.</li> +</ul> + +<p> +<strong>8.</strong> You may not copy, modify, sublicense, link with, +or distribute the Library except as expressly provided under this +License. Any attempt otherwise to copy, modify, sublicense, link +with, or distribute the Library is void, and will automatically +terminate your rights under this License. However, parties who have +received copies, or rights, from you under this License will not have +their licenses terminated so long as such parties remain in full +compliance. +</p> +<p> +<strong>9.</strong> +You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. +</p> +<p> +<strong>10.</strong> +Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. +</p> +<p> +<strong>11.</strong> +If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. +</p> +<p> +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. +</p> +<p> +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. +</p> +<p> +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. +</p> +<p> +<strong>12.</strong> +If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. +</p> +<p> +<strong>13.</strong> +The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. +</p> +<p> +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. +</p> +<p> +<strong>14.</strong> +If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. +</p> +<p> +<strong>NO WARRANTY</strong> +</p> +<p> +<strong>15.</strong> +BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. +</p> +<p> +<strong>16.</strong> +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. +</p> + + <hr> + + <h1 id="lgpl-3.0">GNU Lesser General Public License 3.0</h1> + +<p>Some versions of this product contains code from the following LGPLed libraries:</p> + +<ul> +<li><a +href="https://addons.mozilla.org/en-US/firefox/addon/görans-hemmasnickrade-ordli/">Swedish dictionary</a> +</ul> + +<pre>Copyright © 2007 Free Software Foundation, Inc. + <<a href="https://www.fsf.org/">https://www.fsf.org/</a>> + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed.</pre> + +<p>This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below.</p> + +<h3><a id="section0">0. Additional Definitions</a></h3> + +<p>As used herein, “this License” refers to version 3 of the GNU Lesser +General Public License, and the “GNU GPL” refers to version 3 of the GNU +General Public License.</p> + +<p>“The Library” refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below.</p> + +<p>An “Application” is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library.</p> + +<p>A “Combined Work” is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the “Linked +Version”.</p> + +<p>The “Minimal Corresponding Source” for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version.</p> + +<p>The “Corresponding Application Code” for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work.</p> + +<h3><a id="section1">1. Exception to Section 3 of the GNU GPL.</a></h3> + +<p>You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL.</p> + +<h3><a id="section2">2. Conveying Modified Versions.</a></h3> + +<p>If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version:</p> + +<ul> +<li>a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or</li> + +<li>b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy.</li> +</ul> + +<h3><a id="section3">3. Object Code Incorporating Material from Library Header Files.</a></h3> + +<p>The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following:</p> + +<ul> +<li>a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License.</li> + +<li>b) Accompany the object code with a copy of the GNU GPL and this license + document.</li> +</ul> + +<h3><a id="section4">4. Combined Works.</a></h3> + +<p>You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following:</p> + +<ul> +<li>a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License.</li> + +<li>b) Accompany the Combined Work with a copy of the GNU GPL and this license + document.</li> + +<li>c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document.</li> + +<li>d) Do one of the following: + +<ul> +<li>0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source.</li> + +<li>1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version.</li> +</ul></li> + +<li>e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.)</li> +</ul> + +<h3><a id="section5">5. Combined Libraries.</a></h3> + +<p>You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following:</p> + +<ul> +<li>a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License.</li> + +<li>b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work.</li> +</ul> + +<h3><a id="section6">6. Revised Versions of the GNU Lesser General Public License.</a></h3> + +<p>The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns.</p> + +<p>Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License “or any later version” +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation.</p> + +<p>If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library.</p> + + + <hr> + + <h1><a id="android"></a>Android Open Source License</h1> + + <p>This license applies to various files in the Mozilla codebase, + including those in the directory <code>gfx/skia/</code>.</p> +<!-- This is the wrong directory, what was intended? --> + +<pre> + Copyright 2009, The Android Open Source Project + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY + EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="angle"></a>ANGLE License</h1> + + <p>This license applies to files in the directory <code>gfx/angle/</code>.</p> + +<pre> +Copyright (C) 2002-2010 The ANGLE Project Authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of TransGaming Inc., Google Inc., 3DLabs Inc. + Ltd., nor the names of their contributors may be used to endorse + or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="acorn"></a>acorn License</h1> + + <p>This license applies to part of the + <code>devtools/shared/jsbeautify/src/beautify-js.js</code> file. + </p> +<pre> +Copyright (C) 2012 by Marijn Haverbeke <marijnh@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Please note that some subdirectories of the CodeMirror distribution +include their own LICENSE files, and are released under different +licences. +</pre> + + + <hr> + + <h1><a id="apache"></a>Apache License 2.0</h1> + + <p>This license applies to various files in the Mozilla codebase including, but not limited to:<br/> + <code>devtools/client/netmonitor/src/components/messages/parsers/signalr/HandshakeProtocol.js</code><br/> + <code>devtools/client/netmonitor/src/components/messages/parsers/signalr/IHubProtocol.js</code><br/> + <code>devtools/client/netmonitor/src/components/messages/parsers/signalr/JSONHubProtocol.js</code><br/> + <code>devtools/client/netmonitor/src/components/messages/parsers/signalr/TextMessageFormat.js</code><br/> + <code>devtools/client/netmonitor/src/components/messages/parsers/signalr/Utils.js</code><br/> + <code>third_party/cups/include</code><br/> +#ifdef MOZ_JXL + <code>third_party/highway/</code><br/> +#endif + </p> + +<pre> + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS +</pre> + + + <hr> + + <h1><a id="apple"></a>Apple License</h1> + + <p>This license applies to certain files in the directories <code>dom/media/webaudio/blink</code>, and <code>widget/cocoa</code>.</p> + +<pre> +Copyright (C) 2008, 2009 Apple Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="arm"></a>ARM License</h1> + + <p>This license applies to files in the directory <code>js/src/jit/arm64/vixl/</code>.</p> + +<pre> +Copyright 2013, ARM Limited +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of ARM Limited nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="babel"></a>Babel License</h1> + + <p>This license applies to file bundled in + <code>devtools/client/debugger/dist</code>. + </p> + +<pre> +Copyright (c) 2014-2017 Sebastian McKenzie <sebmck@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + + <hr> + + <h1><a id="babylon"></a>Babylon License</h1> + + <p>This license applies to file bundled in + <code>devtools/client/debugger/dist</code>. + </p> + +<pre> +Copyright (C) 2012-2014 by various contributors (see AUTHORS) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + + <hr> + + <h1><a id="boost"></a>boost License</h1> + + <p>This license applies to files in the following directories: + <ul> + <li><code>third_party/function2</code></li> + <li><code>third_party/msgpack</code></li> + </ul> + See the individual LICENSE files for copyright owners.</p> + +<pre> +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + + + <hr> + + <h1><a id="bsd2clause"></a>BSD 2-Clause License</h1> + + <p>This license applies to files in the following directories: + <ul> + <li><code>third_party/rust/arrayref</code></li> + <li><code>third_party/rust/mach</code></li> + <li><code>third_party/rust/qlog</code></li> + </ul> + See the individual LICENSE files for copyright owners.</p> + +<pre> +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + +<hr> + +<h1><a id="bsd3clause"></a>BSD 3-Clause License</h1> + +<p>This license applies to portions of WHATWG specification incorporated +into source code and to files in the following directories: +<ul> + <li><code>browser/components/newtab/vendor/react-transition-group.js</code></li> + <li><code>third_party/rust/bindgen/</code></li> + <li><code>third_party/rust/subtle/</code></li> +#ifdef MOZ_JXL + <li><code>third_party/jpeg-xl/</code></li> +#endif + <li><code>third_party/xsimd/</code></li> +</ul> +See the individual LICENSE files for copyright owners.</p> + +<pre> +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="bspatch"></a>bspatch License</h1> + + <p>This license applies to the files + <code>toolkit/mozapps/update/updater/bspatch/bspatch.cpp</code> and + <code>toolkit/mozapps/update/updater/bspatch/bspatch.h</code>. + </p> + +<pre> +Copyright 2003,2004 Colin Percival +All rights reserved + +Redistribution and use in source and binary forms, with or without +modification, are permitted providing that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="cairo"></a>Cairo Component Licenses</h1> + + <p>This license, with different copyright holders, applies to certain files + in the directory <code>gfx/cairo/</code>. The copyright + holders and the applicable ranges of dates are as follows: + + <ul> +<li>2004 Richard D. Worth +<li>2004, 2005 Red Hat, Inc. +<li>2003 USC, Information Sciences Institute +<li>2004 David Reveman +<li>2005 Novell, Inc. +<li>2004 David Reveman, Peter Nilsson +<li>2000 Keith Packard, member of The XFree86 Project, Inc. +<li>2005 Lars Knoll & Zack Rusin, Trolltech +<li>1998, 2000, 2002, 2004 Keith Packard +<li>2004 Nicholas Miell +<li>2005 Trolltech AS +<li>2000 SuSE, Inc. +<li>2003 Carl Worth +<li>1987, 1988, 1989, 1998 The Open Group +<li>1987, 1988, 1989 Digital Equipment Corporation, Maynard, Massachusetts. +<li>1998 Keith Packard +<li>2003 Richard Henderson + </ul> + +<pre> +Copyright © <date> <copyright holder> + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without +fee, provided that the above copyright notice appear in all copies +and that both that copyright notice and this permission notice +appear in supporting documentation, and that the name of +<copyright holder> not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior permission. +<copyright holder> makes no representations about the suitability of this +software for any purpose. It is provided "as is" without express or +implied warranty. + +<COPYRIGHT HOLDER> DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN +NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION +WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + + <hr> + + <h1><a id="chromium"></a>Chromium License</h1> + + <p>This license applies to parts of the code in:</p> + <ul> + <li><code>browser/extensions/formautofill</code></li> + <li><code>toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs</code></li> + <li><code>toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs</code></li> + <li><code>editor/libeditor/EditorEventListener.cpp</code></li> + <li><code>security/sandbox/</code></li> + <li><code>toolkit/components/passwordmgr/PasswordGenerator.sys.mjs</code></li> + <li><code>widget/cocoa/GfxInfo.mm</code></li> + <li><code>widget/windows/nsWindow.cpp</code></li> + </ul> + <p>and also some files in these directories:</p> + <ul> + <li><code>dom/media/webspeech/recognition/</code></li> + <li><code>dom/plugins/</code></li> + <li><code>gfx/ots/</code></li> + <li><code>gfx/ycbcr/</code></li> + <li><code>ipc/chromium/</code></li> + <li><code>media/openmax_dl/</code></li> + <li><code>toolkit/components/reputationservice/</code></li> + <li><code>toolkit/components/url-classifier/chromium/</code></li> + <li><code>tools/profiler/</code></li> + </ul> + +<pre> +Copyright (c) 2006-2018 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="codemirror"></a>CodeMirror License</h1> + + <p>This license applies to all files in + <code>devtools/client/shared/sourceeditor/codemirror</code> and + to the following files: + </p> + <ul> + <li><code>devtools/client/shared/sourceeditor/test/cm_mode_ruby.js</code></li> + <li><code>devtools/client/shared/sourceeditor/test/codemirror/mode/javascript/test.js</code></li> + <li><code>devtools/client/shared/sourceeditor/test/codemirror/comment_test.js</code></li> + <li><code>devtools/client/shared/sourceeditor/test/codemirror/driver.js</code></li> + <li><code>devtools/client/shared/sourceeditor/test/codemirror/mode_test.css</code></li> + <li><code>devtools/client/shared/sourceeditor/test/codemirror/mode_test.js</code></li> + <li><code>devtools/client/shared/sourceeditor/test/codemirror/test.js</code></li> + </ul> +<pre> +Copyright (C) 2013 by Marijn Haverbeke <marijnh@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Please note that some subdirectories of the CodeMirror distribution +include their own LICENSE files, and are released under different +licences. +</pre> + + + + <hr> + + <h1><a id="cryptogams"></a>CRYPTOGAMS License</h1> + + <p>This license applies all files in + <code>security/nss/lib/freebl/scripts/</code> and to the file + <code>security/nss/lib/freebl/sha512-p8.s</code>. + </p> +<pre> +Copyright (c) 2006, CRYPTOGAMS by <appro@openssl.org> +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain copyright notices, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials + provided with the distribution. + + * Neither the name of the CRYPTOGAMS nor the names of its + copyright holder and contributors may be used to endorse or + promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="cubic-bezier"></a>cubic-bezier License</h1> + + <p>This license applies to the file + <code>devtools/client/shared/widgets/CubicBezierWidget.js + </code>.</p> +<pre> +Copyright (c) 2013 Lea Verou. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + + + <hr> + + <h1><a id="d3"></a>D3 License</h1> + + <p>This license applies to the file + <code>third_party/js/d3/d3.js</code>. + </p> +<pre> +Copyright (c) 2010-2016, Michael Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* The name Michael Bostock may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="dagre-d3"></a>Dagre-D3 License</h1> + + <p>This license applies to the file + <code>devtools/client/shared/vendor/dagre-d3.js</code>. + </p> +<pre> +Copyright (c) 2013 Chris Pettitt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + +<hr> + <h1><a id="disconnect.me"></a>Disconnect.Me License - Creative Commons BY-NC-SA-4.0</h1> + +<p>This license does not apply to any of the code shipped with Firefox, +but may apply to blocklists downloaded after installation for use with +the tracking protection feature. Our blocklist is based on one originally +written by Disconnect.me that is provided to the Mozilla Corporation for use +in Firefox pursuant to a contract between Mozilla and Disconnect.me. For +use outside Firefox, the blocklist is licensed under the Creative Commons +Attribution-NonCommercial-ShareAlike 4.0 International License.</p> + +<p>The Creative Commons' page for that license is at <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">https://creativecommons.org/licenses/by-nc-sa/4.0/</a> +and full license text is available at <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode">https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode</a>.</p> + +<hr> + +<h1><a id="diff"></a>diff License</h1> + +<p>This license applies to the file +<code>devtools/client/inspector/markup/test/helper_diff.js</code>.</p> + +<pre> +Copyright (c) 2014 Slava + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + + +<hr> + +<h1><a id="dtoa"></a>dtoa License</h1> + +<p>This license applies to the file +<code>nsprpub/pr/src/misc/dtoa.c</code>.</p> + +<pre> +The author of this software is David M. Gay. + +Copyright (c) 1991, 2000, 2001 by Lucent Technologies. + +Permission to use, copy, modify, and distribute this software for any +purpose without fee is hereby granted, provided that this entire notice +is included in all copies of any software which is or includes a copy +or modification of this software and in all copies of the supporting +documentation for such software. + +THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED +WARRANTY. IN PARTICULAR, NEITHER THE AUTHOR NOR LUCENT MAKES ANY +REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY +OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. +</pre> + +<hr> + + <h1><a id="hunspell-nl"></a>Dutch Spellchecking Dictionary License</h1> + + <p>This license applies to the Dutch Spellchecking Dictionary. (This + code only ships in some localized versions of this product.)</p> + +<pre> +Copyright (c) 2006, 2007 OpenTaal +Copyright (c) 2001, 2002, 2003, 2005 Simon Brouwer e.a. +Copyright (c) 1996 Nederlandstalige Tex Gebruikersgroep + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. +* Neither the name of the OpenTaal, Simon Brouwer e.a., or Nederlandstalige Tex +Gebruikersgroep nor the names of its contributors may be used to endorse or +promote products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + +#if defined(XP_WIN) || defined(XP_LINUX) + <h1><a id="twemoji"></a>Twemoji License</h1> + + <p>This license applies to the emoji art contained within the bundled +emoji font file.</p> + +<pre> +Copyright (c) 2018 Twitter, Inc and other contributors. + +Creative Commons Attribution 4.0 International (CC BY 4.0) + +See https://creativecommons.org/licenses/by/4.0/legalcode or +for the human readable summary: https://creativecommons.org/licenses/by/4.0/ + +You are free to: + +Share — copy and redistribute the material in any medium or format + +Adapt — remix, transform, and build upon the material for any purpose, even commercially. + +The licensor cannot revoke these freedoms as long as you follow the license terms. + +Under the following terms: + +Attribution — You must give appropriate credit, provide a link to the license, +and indicate if changes were made. You may do so in any reasonable manner, +but not in any way that suggests the licensor endorses you or your use. + +No additional restrictions — You may not apply legal terms or technological +measures that legally restrict others from doing anything the license permits. + +Notices: + +You do not have to comply with the license for elements of the material in +the public domain or where your use is permitted by an applicable exception or +limitation. No warranties are given. The license may not give you all of the +permissions necessary for your intended use. For example, other rights such as +publicity, privacy, or moral rights may limit how you use the material. +</pre> + + + <hr> + +#endif + <h1><a id="hunspell-ee"></a>Estonian Spellchecking Dictionary License</h1> + + <p>This license applies to precursor works to certain files which are + part of the Estonian Spellchecking Dictionary. The + shipped versions are under the GNU Lesser General Public License. (This + code only ships in some localized versions of this product.)</p> + +<pre> +Copyright © Institute of the Estonian Language + +E-mail: litsents@eki.ee +URL: https://www.eki.ee/tarkvara/ + +The present Licence Agreement gives the user of this Software Product +(hereinafter: Product) the right to use the Product for whatever purpose +(incl. distribution, copying, altering, inclusion in other software, and +selling) on the following conditions: + +1. The present Licence Agreement should belong unaltered to each copy ever + made of this Product; +2. Neither the Institute of the Estonian Language (hereinafter: IEL) nor the + author(s) of the Product will take responsibility for any detriment, direct + or indirect, possibly ensuing from the application of the Product; +3. The IEL is ready to share the Product with other users as we wish to + advance research on the Estonian language and to promote the use of + Estonian in rapidly developing infotechnology, yet we refuse to bind + ourselves to any further obligation, which means that the IEL is not + obliged either to warrant the suitability of the Product for a specific + purpose, to improve the software, or to provide a more detailed description + of the underlying algorithms. (Which does not mean, though, that we may not + do it.) + +Notification Request: + +As a courtesy, we would appreciate being informed whenever our linguistic +products are used to create derivative works. If you modify our software or +include it in other products, please inform us by sending e-mail to +litsents@eki.ee or by letter to + +Institute of the Estonian Language +Roosikrantsi 6 +10119 Tallinn +ESTONIA + +Phone & Fax: +372 6411443 +</pre> + + <hr> + + <h1><a id="expat"></a>Expat License</h1> + + <p>This license applies to certain files in the directory + <code>parser/expat/</code>.</p> + +<pre> +Copyright (c) 1998, 1999, 2000 Thai Open Source Software Center Ltd + and Clark Cooper +Copyright (c) 2001, 2002, 2003 Expat maintainers. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + + + + <hr> + + + <h1><a id="firebug"></a>Firebug License</h1> + + <p>This license applies to the code + <code>devtools/shared/network-observer/NetworkHelper.sys.mjs</code>.</p> + +<pre> +Copyright (c) 2007, Parakey Inc. +All rights reserved. + +Redistribution and use of this software in source and binary forms, with or +without modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other + materials provided with the distribution. + +* Neither the name of Parakey Inc. nor the names of its + contributors may be used to endorse or promote products + derived from this software without specific prior + written permission of Parakey Inc. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="gfx-font-list"></a>gfxFontList License</h1> + + <p>This license applies to the files + <code>gfx/thebes/gfxMacPlatformFontList.mm</code> and + <code>gfx/thebes/gfxPlatformFontList.cpp</code>. + </p> + +<pre> +Copyright (C) 2006 Apple Computer, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of + its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + + <hr> + + <h1><a id="google-bsd"></a>Google BSD License</h1> + + <p>This license applies to files in the directories + <code>toolkit/crashreporter/google-breakpad/</code>, + <code>toolkit/components/protobuf/</code> and + <code>devtools/client/netmonitor/src/utils/filter-text-utils.js</code>.</p> + +<pre> +Copyright (c) 2006, Google Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="vp8"></a>Google VP8 License</h1> + + <p>This license applies to certain files in the directory + <code>media/libvpx</code>.</p> +<pre> +Copyright (c) 2010, Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +- Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Subject to the terms and conditions of the above License, Google +hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this +section) patent license to make, have made, use, offer to sell, sell, +import, and otherwise transfer this implementation of VP8, where such +license applies only to those patent claims, both currently owned by +Google and acquired in the future, licensable by Google that are +necessarily infringed by this implementation of VP8. If You or your +agent or exclusive licensee institute or order or agree to the +institution of patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that this +implementation of VP8 or any code incorporated within this +implementation of VP8 constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any rights +granted to You under this License for this implementation of VP8 +shall terminate as of the date such litigation is filed. +</pre> + + <hr> + + <h1><a id="gears-istumbler"></a>Google Gears/iStumbler License</h1> + + <p>This license applies to the file + <code>netwerk/wifi/mac/Wifi.h</code>.</p> + +<pre> +Copyright 2008, Google Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of Google Inc. nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The contents of this file are taken from Apple80211.h from the iStumbler +project (https://istumbler.net/). This project is released under the BSD +license with the following restrictions. + +Copyright (c) 02006, Alf Watt (alf@istumbler.net). All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name of iStumbler nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="gyp"></a>gyp License</h1> + + <p>This license applies to certain files in the directory + <code>third_party/python/gyp</code>.</p> +<pre> +Copyright (c) 2009 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="halloc"></a>halloc License</h1> + + <p>This license applies to certain files in the directory + <code>media/libnestegg/src</code>.</p> +<pre> +Copyright (c) 2004-2010 Alex Pankratov. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of the project nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="harfbuzz"></a>HarfBuzz License</h1> + + <p>This license, with different copyright holders, applies to the files in + the directory <code>gfx/harfbuzz/</code>. + The copyright holders and the applicable ranges of dates are as follows:</p> + + <ul> + <li>1998-2004 David Turner and Werner Lemberg</li> + <li>2004, 2007, 2008, 2009, 2010 Red Hat, Inc.</li> + <li>2006 Behdad Esfahbod</li> + <li>2007 Chris Wilson</li> + <li>2009 Keith Stribley <devel@thanlwinsoft.org></li> + <li>2010 Mozilla Foundation</li> + </ul> + +<pre> +Copyright (C) <date> <copyright holder> + + This is part of HarfBuzz, an OpenType Layout engine library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +</pre> + + + <hr> + + <h1><a id="icu"></a>ICU License</h1> + + <p>This license applies to some code in the + <code>gfx/thebes</code> directory.</p> + +<pre> +ICU License - ICU 1.8.1 and later + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1995-2012 International Business Machines Corporation and +others + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, provided that the above copyright notice(s) and this +permission notice appear in all copies of the Software and that both the +above copyright notice(s) and this permission notice appear in supporting +documentation. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in this Software without prior written authorization of the +copyright holder. +All trademarks and registered trademarks mentioned herein are the property +of their respective owners. +</pre> + <hr> + <h1><a id="immutable"></a>Immutable.js License</h1> + +<pre> +BSD License + +For Immutable JS software + +Copyright (c) 2014-2015, Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="jpnic"></a>Japan Network Information Center License</h1> + <p>This license applies to certain files in the + directory <code>netwerk/dns/</code>.</p> +<pre> +Copyright (c) 2001,2002 Japan Network Information Center. +All rights reserved. + +By using this file, you agree to the terms and conditions set forth below. + + LICENSE TERMS AND CONDITIONS + +The following License Terms and Conditions apply, unless a different +license is obtained from Japan Network Information Center ("JPNIC"), +a Japanese association, Kokusai-Kougyou-Kanda Bldg 6F, 2-3-4 Uchi-Kanda, +Chiyoda-ku, Tokyo 101-0047, Japan. + +1. Use, Modification and Redistribution (including distribution of any + modified or derived work) in source and/or binary forms is permitted + under this License Terms and Conditions. + +2. Redistribution of source code must retain the copyright notices as they + appear in each source code file, this License Terms and Conditions. + +3. Redistribution in binary form must reproduce the Copyright Notice, + this License Terms and Conditions, in the documentation and/or other + materials provided with the distribution. For the purposes of binary + distribution the "Copyright Notice" refers to the following language: + "Copyright (c) 2000-2002 Japan Network Information Center. All rights + reserved." + +4. The name of JPNIC may not be used to endorse or promote products + derived from this Software without specific prior written approval of + JPNIC. + +5. Disclaimer/Limitation of Liability: THIS SOFTWARE IS PROVIDED BY JPNIC + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JPNIC BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. +</pre> + + <hr> + + <h1><a id="jszip"></a>JSZip License</h1> + + <p>This license applies to the file + <code>devtools/client/shared/vendor/jszip.js</code>.</p> + +<pre> +Copyright (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + + <hr> + + <h1><a id="jemalloc"></a>jemalloc License</h1> + + <p>This license applies to portions of the files in the directory + <code>memory/build/</code>. + </p> + +<pre> +Copyright (C) 2006-2008 Jason Evans <jasone@canonware.com>. +All rights reserved. +Copyright (C) 2007-2017 Mozilla Foundation. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice(s), this list of conditions and the following disclaimer as + the first lines of this file unmodified other than the possible + addition of one or more copyright notices. +2. Redistributions in binary form must reproduce the above copyright + notice(s), this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) ``AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER(S) BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="jquery"></a>jQuery License</h1> + + <p>This license applies to all copies of jQuery in the code.</p> + +<pre> +Copyright (c) 2010 John Resig, https://jquery.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + + <hr> + + <h1><a id="k_exp"></a>k_exp License</h1> + + <p>This license applies to the file + <code>modules/fdlibm/src/k_exp.cpp</code>. + </p> + +<pre> +Copyright (c) 2011 David Schultz <das@FreeBSD.ORG> +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="khronos"></a>Khronos group License</h1> + + <p>This license applies to the following files:</p> + + <ul> + <li><code>media/openmax_dl/dl/api/omxtypes.h</code></li> + <li><code>media/openmax_dl/dl/sp/api/omxSP.h</code></li> + </ul> + +<pre> +Copyright 2005-2008 The Khronos Group Inc. All Rights Reserved. + +These materials are protected by copyright laws and contain material +proprietary to the Khronos Group, Inc. You may use these materials +for implementing Khronos specifications, without altering or removing +any trademark, copyright or other notice from the specification. + +Khronos Group makes no, and expressly disclaims any, representations +or warranties, express or implied, regarding these materials, including, +without limitation, any implied warranties of merchantability or fitness +for a particular purpose or non-infringement of any intellectual property. +Khronos Group makes no, and expressly disclaims any, warranties, express +or implied, regarding the correctness, accuracy, completeness, timeliness, +and reliability of these materials. + +Under no circumstances will the Khronos Group, or any of its Promoters, +Contributors or Members or their respective partners, officers, directors, +employees, agents or representatives be liable for any damages, whether +direct, indirect, special or consequential damages for lost revenues, +lost profits, or otherwise, arising from or in connection with these +materials. + +Khronos and OpenMAX are trademarks of the Khronos Group Inc. +</pre> + + <hr> + + <h1><a id="kiss_fft"></a>Kiss FFT License</h1> + + <p>This license applies to files in the directory + <code>media/kiss_fft/</code>.</p> + +<pre> +Copyright (c) 2003-2010 Mark Borgerding + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the author nor the names of any contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + +#ifdef MOZ_USE_LIBCXX + <h1><a id="libc++"></a>libc++ License</h1> + + <p class="correctme">This license applies to the copy of libc++ obtained + from the Android NDK.</p> + +<pre> +Copyright (c) 2009-2014 by the contributors listed in the libc++ CREDITS.TXT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + + <hr> + +#endif + + <h1><a id="libcubeb"></a>libcubeb License</h1> + + <p class="correctme">This license applies to files in the directory + <code>media/libcubeb</code>. + </p> + +<pre> +Copyright © 2011 Mozilla Foundation + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + + <hr> + + <h1><a id="libevent"></a>libevent License</h1> + + <p>This license applies to files in the directory + <code>ipc/chromium/src/third_party/libevent/</code>. + </p> + +<pre> +Copyright (c) 2000-2007 Niels Provos <provos@citi.umich.edu> +Copyright (c) 2007-2012 Niels Provos and Nick Mathewson + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +============================== + +Portions of Libevent are based on works by others, also made available by +them under the three-clause BSD license above. The copyright notices are +available in the corresponding source files; the license is as above. Here's +a list: + +log.c: + Copyright (c) 2000 Dug Song <dugsong@monkey.org> + Copyright (c) 1993 The Regents of the University of California. + +strlcpy.c: + Copyright (c) 1998 Todd C. Miller <Todd.Miller@courtesan.com> + +evport.c: + Copyright (c) 2007 Sun Microsystems + +ht-internal.h: + Copyright (c) 2002 Christopher Clark + +minheap-internal.h: + Copyright (c) 2006 Maxim Yegorushkin <maxim.yegorushkin@gmail.com> + +============================== + +The arc4module is available under the following, sometimes called the +"OpenBSD" license: + + Copyright (c) 1996, David Mazieres <dm@uun.org> + Copyright (c) 2008, Damien Miller <djm@openbsd.org> + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +</pre> + + + <hr> + + <h1><a id="libffi"></a>libffi License</h1> + + <p>This license applies to files in the directory + <code>js/src/ctypes/libffi/</code>. + </p> + +<pre> +libffi - Copyright (c) 1996-2008 Red Hat, Inc and others. +See source files for details. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +``Software''), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED ``AS IS'', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + + + <hr> + + <h1><a id="libjingle"></a>libjingle License</h1> + + <p>This license applies to the following files:</p> + <ul> + <li><code>dom/media/webrtc/transport/sigslot.h</code></li> + <li><code>dom/media/webrtc/transport/test/gtest_utils.h</code></li> + </ul> + +<pre> +Copyright (c) 2004--2005, Google Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="libnestegg"></a>libnestegg License</h1> + + <p>This license applies to certain files in the directory + <code>media/libnestegg</code>. + </p> + +<pre> +Copyright © 2010 Mozilla Foundation + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + + <hr> + + <h1><a id="libsoundtouch"></a>libsoundtouch License</h1> + + <p>This license applies to certain files in the directory + <code>media/libsoundtouch/src/</code>. + </p> + +<pre> +The SoundTouch Library Copyright © Olli Parviainen 2001-2012 + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +</pre> + + <hr> + + <h1><a id="libyuv"></a>libyuv License</h1> + + <p>This license applies to files in the directory + <code>media/libyuv</code>. + </p> + +<pre> +Copyright (c) 2011, The LibYuv project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="hunspell-lt"></a>Lithuanian Spellchecking Dictionary License</h1> + + <p>This license applies to the Lithuanian Spellchecking Dictionary. (This + code only ships in some localized versions of this product.)</p> + +<pre> +Copyright (c) 2000-2013, Albertas Agejevas and contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holders nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL ALBERTAS AGEJEVAS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="lodash"></a>License - lodash</h1> + + <p>This license applies to some of the code in + <var>node_modules/lodash/lodash.js</var>.</p> + +<pre> +Copyright JS Foundation and other contributors <https://js.foundation/> + +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors <https://underscorejs.org/> + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. + +CC0: https://creativecommons.org/publicdomain/zero/1.0/ + +==== + +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. + +</pre> + + + <hr> + + <h1><a id="matches"></a>matches License</h1> + + <p>This license applies to files in the directory + <code>third_party/rust/matches</code>.</p> + +<pre> +Copyright (c) 2014-2016 Simon Sapin + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + + <hr> + + <h1><a id="apple-password-rules-parser"></a>Apple Password Rules Parser License</h1> + + <p>This license applies to the file + <code>toolkit/components/passwordmgr/PasswordRulesParser.sys.mjs</code>.</p> + +<pre> +Copyright 2020 Apple Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + + <hr> + + <h1><a id="mit"></a>MIT License</h1> + + <p>This license applies to the following files or to files in the following directories: + <ul> + <li><code>third_party/rust/bincode</code></li> + <li><code>third_party/rust/byteorder</code></li> + <li><code>third_party/js/cfworker/json-schema.js</code></li> + <li><code>security/nss/lib/freebl/ecl/ecp_secp384r1.c</code> and + <code>security/nss/lib/freebl/ecl/ecp_secp521r1.c</code></li> + <li><code>security/nss/lib/freebl/ecl/curve25519_32.c</code>, + <code>security/nss/lib/freebl/ecl/ecp_secp384r1.c</code> and + <code>security/nss/lib/freebl/ecl/ecp_secp521r1.c</code></li> + <li><code>devtools/client/netmonitor/src/components/messages/parsers/socket-io/component-emitter.js</code></li> + <li><code>mfbt/Span.h</code> and <code>mfbt/tests/gtest/TestSpan.cpp</code></li> + <li><code>third_party/rust/lazy_static</code></li> + <li><code>third_party/rust/libm</code> (with parts dual licensed MIT/<a href="about:license#apache">Apache-2.0</a>)</li> + <li><code>devtools/client/shared/vendor/micromatch/micromatch.js</code></li> + <li><code>devtools/client/shared/vendor/fuzzaldrin-plus.js</code></li> + <li><code>devtools/shared/natural-sort.js</code></li> + <li><code>devtools/shared/node-properties/node-properties.js</code></li> + <li><code>third_party/rust/ordered-float</code></li> + <li><code>third_party/rust/owning_ref</code></li> + <li><code>third_party/rust/phf</code>, + <code>third_party/rust/phf_codegen</code>, + <code>third_party/rust/phf_generator</code>, and + <code>third_party/rust/phf_shared</code></li> + <li><code>third_party/rust/precomputed-hash</code></li> + <li><code>browser/components/newtab/vendor/prop-types*</code></li> + <li><code>devtools/client/shared/vendor/react*</code>, + <code>browser/components/newtab/vendor/react*</code>, + <code>browser/components/pocket/content/panels/js/vendor.bundle.js</code> and + <code>devtools/client/debugger/test/mochitest/examples/react/build/main.js</code></li> + <li><code>devtools/client/shared/vendor/react-router-dom.js</code></li> + <li><code>devtools/client/shared/vendor/reselect.js</code> and + <code>browser/components/newtab/data/content/activity-stream.bundle.js</code></li> + <li><code>third_party/rlbox</code></li> + <li><code>devtools/client/netmonitor/src/components/messages/parsers/socket-io/binary.js</code>, + <code>devtools/client/netmonitor/src/components/messages/parsers/socket-io/index.js</code> and + <code>devtools/client/netmonitor/src/components/messages/parsers/socket-io/is-buffer.js</code></li> + <li><code>devtools/client/netmonitor/src/components/messages/parsers/sockjs/index.js</code></li> + <li><code>devtools/client/netmonitor/src/components/messages/parsers/stomp/byte.js</code>, + <code>devtools/client/netmonitor/src/components/messages/parsers/stomp/frame.js</code> and + <code>devtools/client/netmonitor/src/components/messages/parsers/stomp/parser.js</code></li> + <li><code>third_party/rust/synstructure</code></li> + <li><code>third_party/rust/void</code></li> + <li><code>js/src/zydis</code> (unless otherwise specified)</li> +#ifdef MOZ_DEFAULT_BROWSER_AGENT + <li><code>third_party/WinToast</code> unless otherwise specified</li> +#endif + </ul> + See the individual LICENSE files or headers for copyright owners.</p> + +<pre> +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +</pre> + + <hr> + + <h1><a id="myspell"></a>MySpell License</h1> + + <p>This license applies to some files in the directory + <code>extensions/spellcheck/hunspell</code>.</p> + +<pre> +Copyright 2002 Kevin B. Hendricks, Stratford, Ontario, Canada +And Contributors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. All modifications to the source code must be clearly marked as + such. Binary redistributions based on modified source code + must be clearly marked as modified versions in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY KEVIN B. HENDRICKS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +KEVIN B. HENDRICKS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +</pre> + +<hr> + + <h1><a id="nicer"></a>nICEr License</h1> + + <p>This license applies to certain files in the directory + <code>dom/media/webrtc/transport/third_party/nICEr</code>.</p> + +<pre> + Copyright (C) 2007, Adobe Systems Inc. + Copyright (C) 2007-2008, Network Resonance, Inc. + +Each source file bears an individual copyright notice. + +The following license applies to this distribution as a whole. + + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name of Adobe Systems, Network Resonance nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="openldap"></a>OpenLDAP Public License</h1> + + <p>This license applies to certain files in the directory + <code>third_party/rust/lmdb-rkv-sys/lmdb/libraries/liblmdb</code>. + </p> + +<pre> +The OpenLDAP Public License + Version 2.8, 17 August 2003 + +Redistribution and use of this software and associated documentation +("Software"), with or without modification, are permitted provided +that the following conditions are met: + +1. Redistributions in source form must retain copyright statements + and notices, + +2. Redistributions in binary form must reproduce applicable copyright + statements and notices, this list of conditions, and the following + disclaimer in the documentation and/or other materials provided + with the distribution, and + +3. Redistributions must contain a verbatim copy of this document. + +The OpenLDAP Foundation may revise this license from time to time. +Each revision is distinguished by a version number. You may use +this Software under terms of this license revision or under the +terms of any subsequent revision of the license. + +THIS SOFTWARE IS PROVIDED BY THE OPENLDAP FOUNDATION AND ITS +CONTRIBUTORS ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL THE OPENLDAP FOUNDATION, ITS CONTRIBUTORS, OR THE AUTHOR(S) +OR OWNER(S) OF THE SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +The names of the authors and copyright holders must not be used in +advertising or otherwise to promote the sale, use or other dealing +in this Software without specific, written prior permission. Title +to copyright in this Software shall at all times remain with copyright +holders. + +OpenLDAP is a registered trademark of the OpenLDAP Foundation. + +Copyright 1999-2003 The OpenLDAP Foundation, Redwood City, +California, USA. All Rights Reserved. Permission to copy and +distribute verbatim copies of this document is granted. +</pre> + + + <hr> + + <h1><a id="openvision"></a>OpenVision License</h1> + + <p>This license applies to the file + <code>extensions/auth/gssapi.h</code>.</p> + +<pre> +Copyright 1993 by OpenVision Technologies, Inc. + +Permission to use, copy, modify, distribute, and sell this software +and its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appears in all copies and +that both that copyright notice and this permission notice appear in +supporting documentation, and that the name of OpenVision not be used +in advertising or publicity pertaining to distribution of the software +without specific, written prior permission. OpenVision makes no +representations about the suitability of this software for any +purpose. It is provided "as is" without express or implied warranty. + +OPENVISION DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO +EVENT SHALL OPENVISION BE LIABLE FOR ANY SPECIAL, INDIRECT OR +CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +</pre> + +#if defined(XP_WIN) || defined(XP_MACOSX) || defined(XP_LINUX) + + <hr> + + <h1><a id="openvr"></a>OpenVR License</h1> + + <p>This license applies to certain files in the directory + <code>gfx/vr/service/openvr</code>.</p> +<pre> +Copyright (c) 2015, Valve Corporation +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors +may be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + +#endif + +<hr> + + <h1><a id="node-md5"></a>node-md5 License</h1> + + <p>This license applies to some of the code in + <code>devtools/client/shared/vendor</code>.</p> + +<pre> +Copyright © 2011-2012, Paul Vorbach. +Copyright © 2009, Jeff Mott. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. +* Neither the name Crypto-JS nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="nom"></a>nom License</h1> + + <p>This license applies to files in the directory + <code>third_party/rust/nom</code>.</p> + +<pre> +Copyright (c) 2015 Geoffroy Couprie + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + + + <hr> + + <h1><a id="nrappkit"></a>nrappkit License</h1> + + <p>This license applies to certain files in the directory + <code>dom/media/webrtc/transport/third_party/nrappkit</code>.</p> + +<pre> +Copyright (C) 2001-2007, Network Resonance, Inc. +All Rights Reserved + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of Network Resonance, Inc. nor the name of any + contributors to this software may be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +</pre> + + <p>This license applies to certain files in the directory + <code>dom/media/webrtc/transport/third_party/nrappkit</code>.</p> + +<pre> +Copyright (C) 1999-2003 RTFM, Inc. +All Rights Reserved + +This package is a SSLv3/TLS protocol analyzer written by Eric Rescorla +<ekr@rtfm.com> and licensed by RTFM, Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. All advertising materials mentioning features or use of this software + must display the following acknowledgement: + + This product includes software developed by Eric Rescorla for + RTFM, Inc. + +4. Neither the name of RTFM, Inc. nor the name of Eric Rescorla may be + used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE ERIC RESCORLA AND RTFM ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +oDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +</pre> + + <p>Note that RTFM, Inc. has waived clause (3) above as of June 20, 2012 + for files appearing in this distribution. This waiver applies only to + files included in this distribution. it does not apply to any other + part of ssldump not included in this distribution.</p> + + <p>This license applies to the file <code>dom/media/webrtc/transport/third_party/nrappkit/src/port/generic/include/sys/queue.h</code>.</p> + +<pre> +Copyright (c) 1991, 1993 + The Regents of the University of California. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +4. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +</pre> + + + <p>This license applies to the file: + <code>dom/media/webrtc/transport/third_party/nrappkit/src/util/util.c</code>.</p> + +<pre> +Copyright (c) 1998 Todd C. Miller >Todd.Miller@courtesan.com< +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="praton"></a>praton License</h1> + + <p>This license applies to the file + <code>nsprpub/pr/src/misc/praton.c</code>.</p> + +<pre> +Copyright (c) 1983, 1990, 1993 + The Regents of the University of California. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +4. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + + +Portions Copyright (c) 1993 by Digital Equipment Corporation. + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies, and that +the name of Digital Equipment Corporation not be used in advertising or +publicity pertaining to distribution of the document or software without +specific, written prior permission. + +THE SOFTWARE IS PROVIDED "AS IS" AND DIGITAL EQUIPMENT CORP. DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL DIGITAL EQUIPMENT +CORPORATION BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR +PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. +</pre> + + + <hr> + + <h1><a id="praton1"></a>praton and inet_ntop License</h1> + + <p>This license applies to the files + <code>nsprpub/pr/src/misc/praton.c</code> and + <code>dom/media/webrtc/transport/third_party/nrappkit/src/util/util.c</code>.</p> + +<pre> +Copyright (c) 2004 by Internet Systems Consortium, Inc. ("ISC") +Portions Copyright (c) 1996-1999 by Internet Software Consortium. + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + + <hr> + + <h1><a id="qcms"></a>qcms License</h1> + + <p>This license applies to certain files in the directory + <code>gfx/qcms/</code>.</p> +<pre> +Copyright (C) 2009 Mozilla Corporation +Copyright (C) 1998-2007 Marti Maria + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, subject +to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + + + <hr> + + <h1><a id="qrcode-generator"></a>QR Code Generator License</h1> + + <p>This license applies to certain files in the directory + <code>devtools/shared/qrcode/encoder/</code>.</p> +<pre> +Copyright (c) 2009 Kazuhiko Arase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + + <hr> + + <h1><a id="react"></a>React License</h1> + + <p>This license applies to various files in the Mozilla codebase.</p> + +<pre> +Copyright (c) 2013-2015, Facebook, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="react-redux"></a>React-Redux License</h1> + + <p>This license applies to the files + <code>devtools/client/shared/vendor/react-redux.js</code> and + <code>browser/components/newtab/vendor/react-redux.js</code>.</p> +<pre> +Copyright (c) 2015 Dan Abramov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + + <hr> + + <h1><a id="xdg"></a>Red Hat xdg_user_dir_lookup License</h1> + + <p>This license applies to the + <var>xdg_user_dir_lookup</var> function in + <code>xpcom/io/SpecialSystemDirectory.cpp</code>.</p> + +<pre> +Copyright (c) 2007 Red Hat, Inc. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + + + <hr> + + <h1><a id="redux"></a>Redux License</h1> + + <p>This license applies to the file + <code>devtools/client/shared/vendor/redux.js</code> and + <code>browser/components/newtab/vendor/Redux.sys.mjs</code>.</p> +<pre> +Copyright (c) 2015 Dan Abramov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + +<hr> + + <h1><a id="hunspell-ru"></a>Russian Spellchecking Dictionary License</h1> + + <p>This license applies to the Russian Spellchecking Dictionary. (This + code only ships in some localized versions of this product.)</p> + +<pre> +* Copyright (c) 1997-2008, Alexander I. Lebedev + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Modified versions must be clearly marked as such. +* The name of Alexander I. Lebedev may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="sctp"></a>SCTP Licenses</h1> + + <p>These licenses apply to certain files in the directory + <code>netwerk/sctp/src/</code>.</p> + +<pre> +Copyright (c) 2009-2010 Brad Penoff +Copyright (c) 2009-2010 Humaira Kamal +Copyright (c) 2011-2012 Irene Ruengeler +Copyright (c) 2010-2012, by Michael Tuexen. All rights reserved. +Copyright (c) 2010-2012, by Randall Stewart. All rights reserved. +Copyright (c) 2010-2012, by Robin Seggelmann. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + +Copyright (c) 2001-2008, by Cisco Systems, Inc. All rights reserved. +Copyright (c) 2008-2012, by Randall Stewart. All rights reserved. +Copyright (c) 2008-2012, by Michael Tuexen. All rights reserved. +Copyright (c) 2008-2012, by Brad Penoff. All rights reserved. +Copyright (c) 1980, 1982, 1986, 1987, 1988, 1990, 1993 + The Regents of the University of California. +Copyright (c) 2005 Robert N. M. Watson All rights reserved. +Copyright (C) 1995, 1996, 1997, and 1998 WIDE Project. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +a) Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +b) Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the distribution. +c) Neither the name of Cisco Systems, Inc, the name of the university, + the WIDE project, nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="skia"></a>Skia License</h1> + + <p>This license applies to certain files in the directory + <code>gfx/skia/</code>.</p> + +<pre> +Copyright (c) 2011 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. +* Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="snappy"></a>Snappy License</h1> + + <p>This license applies to certain files in the directory + <code>other-licenses/snappy/</code>.</p> + +<pre> +Copyright 2011, Google Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="sprintf.js"></a>sprintf.js License</h1> + + <p>This license applies to + <code>devtools/shared/sprintfjs/sprintf.js</code>.</p> + +<pre> +Copyright (c) 2007-2016, Alexandru Marasteanu <hello [at) alexei (dot] ro> +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of this software nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="sunsoft"></a>SunSoft License</h1> + + <p>This license applies to the + <var>ICC_H</var> block in + <code>gfx/qcms/qcms.h</code>.</p> + +<pre> +Copyright (c) 1994-1996 SunSoft, Inc. + + Rights Reserved + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restrict- +ion, including without limitation the rights to use, copy, modify, +merge, publish distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON- +INFRINGEMENT. IN NO EVENT SHALL SUNSOFT, INC. OR ITS PARENT +COMPANY BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of SunSoft, Inc. +shall not be used in advertising or otherwise to promote the +sale, use or other dealings in this Software without written +authorization from SunSoft Inc. +</pre> + + + <hr> + + <h1><a id="superfasthash"></a>SuperFastHash License</h1> + + <p>This license applies to files in the directory + <code>security/sandbox/chromium/base/third_party/superfasthash/</code>.</p> + +<pre> +Copyright (c) 2010, Paul Hsieh +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. +* Neither my name, Paul Hsieh, nor the names of any other contributors to the + code use may not be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="unicase"></a>unicase License</h1> + + <p>This license applies to files in the directory + <code>third_party/rust/unicase</code>.</p> + +<pre> +Copyright (c) 2014-2015 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + + + <hr> + + <h1><a id="unicode"></a>Unicode License</h1> + + <p>This license applies to the following files or, in the case of + directories, certain files in those directories:</p> + + <ul> + <li><code>intl/icu</code></li> + <li><code>intl/tzdata</code></li> + <li><code>js/src/util</code></li> + </ul> + +<pre> +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2016 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in https://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Unicode data files and any associated documentation +(the "Data Files") or Unicode software and any associated documentation +(the "Software") to deal in the Data Files or Software +without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of +the Data Files or Software, and to permit persons to whom the Data Files +or Software are furnished to do so, provided that either +(a) this copyright and permission notice appear with all copies +of the Data Files or Software, or +(b) this copyright and permission notice appear in associated +Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS +NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, +use or other dealings in these Data Files or Software without prior +written authorization of the copyright holder. + +--------------------- + +Third-Party Software Licenses + +This section contains third-party software notices and/or additional +terms for licensed third-party software components included within ICU +libraries. + +1. ICU License - ICU 1.8.1 to ICU 57.1 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1995-2016 International Business Machines Corporation and others +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, and/or sell copies of the Software, and to permit persons +to whom the Software is furnished to do so, provided that the above +copyright notice(s) and this permission notice appear in all copies of +the Software and that both the above copyright notice(s) and this +permission notice appear in supporting documentation. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY +SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, use +or other dealings in this Software without prior written authorization +of the copyright holder. + +All trademarks and registered trademarks mentioned herein are the +property of their respective owners. + +2. Chinese/Japanese Word Break Dictionary Data (cjdict.txt) + + # The Google Chrome software developed by Google is licensed under + # the BSD license. Other software included in this distribution is + # provided under other licenses, as set forth below. + # + # The BSD License + # https://opensource.org/licenses/bsd-license.php + # Copyright (C) 2006-2008, Google Inc. + # + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # Redistributions in binary form must reproduce the above + # copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided with + # the distribution. + # Neither the name of Google Inc. nor the names of its + # contributors may be used to endorse or promote products derived from + # this software without specific prior written permission. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + # + # + # The word list in cjdict.txt are generated by combining three word lists + # listed below with further processing for compound word breaking. The + # frequency is generated with an iterative training against Google web + # corpora. + # + # * Libtabe (Chinese) + # - https://sourceforge.net/project/?group_id=1519 + # - Its license terms and conditions are shown below. + # + # * IPADIC (Japanese) + # - http://chasen.aist-nara.ac.jp/chasen/distribution.html + # - Its license terms and conditions are shown below. + # + # ---------COPYING.libtabe ---- BEGIN-------------------- + # + # /* + # * Copyrighy (c) 1999 TaBE Project. + # * Copyright (c) 1999 Pai-Hsiang Hsiao. + # * All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the TaBE Project nor the names of its + # * contributors may be used to endorse or promote products derived + # * from this software without specific prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # /* + # * Copyright (c) 1999 Computer Systems and Communication Lab, + # * Institute of Information Science, Academia + # * Sinica. All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the Computer Systems and Communication Lab + # * nor the names of its contributors may be used to endorse or + # * promote products derived from this software without specific + # * prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # Copyright 1996 Chih-Hao Tsai @ Beckman Institute, + # University of Illinois + # c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4 + # + # ---------------COPYING.libtabe-----END-------------------------------- + # + # + # ---------------COPYING.ipadic-----BEGIN------------------------------- + # + # Copyright 2000, 2001, 2002, 2003 Nara Institute of Science + # and Technology. All Rights Reserved. + # + # Use, reproduction, and distribution of this software is permitted. + # Any copy of this software, whether in its original form or modified, + # must include both the above copyright notice and the following + # paragraphs. + # + # Nara Institute of Science and Technology (NAIST), + # the copyright holders, disclaims all warranties with regard to this + # software, including all implied warranties of merchantability and + # fitness, in no event shall NAIST be liable for + # any special, indirect or consequential damages or any damages + # whatsoever resulting from loss of use, data or profits, whether in an + # action of contract, negligence or other tortuous action, arising out + # of or in connection with the use or performance of this software. + # + # A large portion of the dictionary entries + # originate from ICOT Free Software. The following conditions for ICOT + # Free Software applies to the current dictionary as well. + # + # Each User may also freely distribute the Program, whether in its + # original form or modified, to any third party or parties, PROVIDED + # that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear + # on, or be attached to, the Program, which is distributed substantially + # in the same form as set out herein and that such intended + # distribution, if actually made, will neither violate or otherwise + # contravene any of the laws and regulations of the countries having + # jurisdiction over the User or the intended distribution itself. + # + # NO WARRANTY + # + # The program was produced on an experimental basis in the course of the + # research and development conducted during the project and is provided + # to users as so produced on an experimental basis. Accordingly, the + # program is provided without any warranty whatsoever, whether express, + # implied, statutory or otherwise. The term "warranty" used herein + # includes, but is not limited to, any warranty of the quality, + # performance, merchantability and fitness for a particular purpose of + # the program and the nonexistence of any infringement or violation of + # any right of any third party. + # + # Each user of the program will agree and understand, and be deemed to + # have agreed and understood, that there is no warranty whatsoever for + # the program and, accordingly, the entire risk arising from or + # otherwise connected with the program is assumed by the user. + # + # Therefore, neither ICOT, the copyright holder, or any other + # organization that participated in or was otherwise related to the + # development of the program and their respective officials, directors, + # officers and other employees shall be held liable for any and all + # damages, including, without limitation, general, special, incidental + # and consequential damages, arising out of or otherwise in connection + # with the use or inability to use the program or any product, material + # or result produced or otherwise obtained by using the program, + # regardless of whether they have been advised of, or otherwise had + # knowledge of, the possibility of such damages at any time during the + # project or thereafter. Each user will be deemed to have agreed to the + # foregoing by his or her commencement of use of the program. The term + # "use" as used herein includes, but is not limited to, the use, + # modification, copying and distribution of the program and the + # production of secondary products from the program. + # + # In the case where the program, whether in its original form or + # modified, was distributed or delivered to or received by a user from + # any person, organization or entity other than ICOT, unless it makes or + # grants independently of ICOT any specific warranty to the user in + # writing, such person, organization or entity, will also be exempted + # from and not be held liable to the user for any such damages as noted + # above as far as the program is concerned. + # + # ---------------COPYING.ipadic-----END---------------------------------- + +3. Lao Word Break Dictionary Data (laodict.txt) + + # Copyright (c) 2013 International Business Machines Corporation + # and others. All Rights Reserved. + # + # Project: https://code.google.com/p/lao-dictionary/ + # Dictionary: https://lao-dictionary.googlecode.com/git/Lao-Dictionary.txt + # License: https://lao-dictionary.googlecode.com/git/Lao-Dictionary-LICENSE.txt + # (copied below) + # + # This file is derived from the above dictionary, with slight + # modifications. + # ---------------------------------------------------------------------- + # Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, + # are permitted provided that the following conditions are met: + # + # + # Redistributions of source code must retain the above copyright notice, this + # list of conditions and the following disclaimer. Redistributions in + # binary form must reproduce the above copyright notice, this list of + # conditions and the following disclaimer in the documentation and/or + # other materials provided with the distribution. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # OF THE POSSIBILITY OF SUCH DAMAGE. + # -------------------------------------------------------------------------- + +4. Burmese Word Break Dictionary Data (burmesedict.txt) + + # Copyright (c) 2014 International Business Machines Corporation + # and others. All Rights Reserved. + # + # This list is part of a project hosted at: + # github.com/kanyawtech/myanmar-karen-word-lists + # + # -------------------------------------------------------------------------- + # Copyright (c) 2013, LeRoy Benjamin Sharon + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions + # are met: Redistributions of source code must retain the above + # copyright notice, this list of conditions and the following + # disclaimer. Redistributions in binary form must reproduce the + # above copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided + # with the distribution. + # + # Neither the name Myanmar Karen Word Lists, nor the names of its + # contributors may be used to endorse or promote products derived + # from this software without specific prior written permission. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS + # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + # SUCH DAMAGE. + # -------------------------------------------------------------------------- + +5. Time Zone Database + + ICU uses the public domain data and code derived from Time Zone +Database for its time zone support. The ownership of the TZ database +is explained in BCP 175: Procedure for Maintaining the Time Zone +Database section 7. + + # 7. Database Ownership + # + # The TZ database itself is not an IETF Contribution or an IETF + # document. Rather it is a pre-existing and regularly updated work + # that is in the public domain, and is intended to remain in the + # public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do + # not apply to the TZ Database or contributions that individuals make + # to it. Should any claims be made and substantiated against the TZ + # Database, the organization that is providing the IANA + # Considerations defined in this RFC, under the memorandum of + # understanding with the IETF, currently ICANN, may act in accordance + # with all competent court orders. No ownership claims will be made + # by ICANN or the IETF Trust on the database or the code. Any person + # making a contribution to the database or code waives all rights to + # future claims in that contribution or in the TZ Database.</pre> + + + <hr> + + <h1><a id="unicode-v3"></a>Unicode License V3</h1> + + <p>This license applies to the following files or, in the case of + directories, certain files in those directories:</p> + + <ul> + <li><code>intl/icu_capi</code></li> + <li><code>intl/icu_segmenter_data</code></li> + <li><code>third_party/rust/icu_collections</code></li> + <li><code>third_party/rust/icu_locid</code></li> + <li><code>third_party/rust/icu_locid_transform</code></li> + <li><code>third_party/rust/icu_provider</code></li> + <li><code>third_party/rust/icu_provider_adapters</code></li> + <li><code>third_party/rust/icu_provider_macros</code></li> + <li><code>third_party/rust/icu_segmenter</code></li> + <li><code>third_party/rust/litemap</code></li> + <li><code>third_party/rust/tinystr</code></li> + <li><code>third_party/rust/writeable</code></li> + <li><code>third_party/rust/yoke</code></li> + <li><code>third_party/rust/yoke-derive</code></li> + <li><code>third_party/rust/zerofrom</code></li> + <li><code>third_party/rust/zerofrom-derive</code></li> + <li><code>third_party/rust/zerovec</code></li> + <li><code>third_party/rust/zerovec-derive</code></li> + </ul> + +<pre> +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2020-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +— + +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. +</pre> + + + <hr> + + <h1><a id="ucal"></a>University of California License</h1> + + <p>This license applies to the following files or, in the case of + directories, certain files in those directories:</p> + + <ul> + <li><code>security/nss/lib/dbm</code></li> + <li><code>xpcom/ds/nsQuickSort.cpp</code></li> + <li><code>nsprpub/pr/src/misc/praton.c</code></li> + <li><code>dom/media/webrtc/transport/third_party/nICEr/src/stun/addrs.c</code></li> + </ul> + +<pre> +Copyright (c) 1990, 1993 + The Regents of the University of California. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +[3 Deleted as of 22nd July 1999; see + <a href="ftp://ftp.cs.berkeley.edu/pub/4bsd/README.Impt.License.Change">ftp://ftp.cs.berkeley.edu/pub/4bsd/README.Impt.License.Change</a> + for details] +4. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="hunspell-en"></a>English Spellchecking Dictionary Licenses</h1> + + <p>These licenses apply to certain files in the directory + <code>extensions/spellcheck/locales/en-US/hunspell/</code>, and the + Canadian English dictionary. (This code only ships in some localized + versions of this product.)</p> + +<pre> +Different parts of the SCOWL English dictionaries are subject to the +following licenses as shown below. For additional details, sources, +credits, and public domain references, see <a href="https://searchfox.org/mozilla-central/source/extensions/spellcheck/locales/en-US/hunspell/README_en_US.txt">README.txt</a>. + +The collective work of the Spell Checking Oriented Word Lists (SCOWL) is under +the following copyright: + +Copyright 2000-2007 by Kevin Atkinson +Permission to use, copy, modify, distribute and sell these word lists, the +associated scripts, the output created from the scripts, and its documentation +for any purpose is hereby granted without fee, provided that the above +copyright notice appears in all copies and that both that copyright notice and +this permission notice appear in supporting documentation. Kevin Atkinson makes +no representations about the suitability of this array for any purpose. It is +provided "as is" without express or implied warranty. + +The WordNet database is under the following copyright: + +This software and database is being provided to you, the LICENSEE, by Princeton +University under the following license. By obtaining, using and/or copying +this software and database, you agree that you have read, understood, and will +comply with these terms and conditions: +Permission to use, copy, modify and distribute this software and database and +its documentation for any purpose and without fee or royalty is hereby granted, +provided that you agree to comply with the following copyright notice and +statements, including the disclaimer, and that the same appear on ALL copies of +the software, database and documentation, including modifications that you make +for internal use or for distribution. +WordNet 1.6 Copyright 1997 by Princeton University. All rights reserved. +THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON UNIVERSITY +MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF +EXAMPLE, BUT NOT LIMITATION, PRINCETON UNIVERSITY MAKES NO +REPRESENTATIONS OR WARRANTIES OF MERCHANT- ABILITY OR FITNESS FOR ANY +PARTICULAR PURPOSE OR THAT THE USE OF THE LICENSED SOFTWARE, DATABASE OR +DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, +TRADEMARKS OR OTHER RIGHTS. +The name of Princeton University or Princeton may not be used in advertising or +publicity pertaining to distribution of the software and/or database. Title to +copyright in this software, database and any associated documentation shall at +all times remain with Princeton University and LICENSEE agrees to preserve same. + +The "UK Advanced Cryptics Dictionary" is under the following copyright: + +Copyright (c) J Ross Beresford 1993-1999. All Rights Reserved. +The following restriction is placed on the use of this publication: if The UK +Advanced Cryptics Dictionary is used in a software package or redistributed in +any form, the copyright notice must be prominently displayed and the text of +this document must be included verbatim. There are no other restrictions: I +would like to see the list distributed as widely as possible. + +Various parts are under the Ispell copyright: + +Copyright 1993, Geoff Kuenning, Granada Hills, CA +All rights reserved. Redistribution and use in source and binary forms, with +or without modification, are permitted provided that the following conditions +are met: + 1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + 3. All modifications to the source code must be clearly marked as such. +Binary redistributions based on modified source code must be clearly marked as +modified versions in the documentation and/or other materials provided with +the distribution. + (clause 4 removed with permission from Geoff Kuenning) + 5. The name of Geoff Kuenning may not be used to endorse or promote products +derived from this software without specific prior written permission. + THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +Additional Contributors: + + Alan Beale <biljir@pobox.com> + M Cooper <thegrendel@theriver.com> +</pre> + + <hr> + + <h1><a id="v8"></a>V8 License</h1> + + <p>This license applies to certain files in the directories + <code>js/src/irregexp</code>, + <code>js/src/builtin</code>, + <code>js/src/jit/arm</code>, + <code>js/src/jit/mips*</code> and + <code>js/src/jit/riscv64</code>. + </p> +<pre> +Copyright 2006-2012 the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + +<hr> + +<h1><a id="validator"></a>Validator License</h1> + +<p>This license applies to certain files in the directory + <code>devtools/shared/storage/vendor/stringvalidator/</code>, +</p> +<pre> + +Copyright (c) 2016 Chris O"Hara <cohara87@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + + <hr> + + + <h1><a id="vtune"></a>VTune License</h1> + + <p>This license applies to certain files in the directory + <code>js/src/vtune</code> and <code>tools/profiler/core/vtune</code>.</p> +<pre> +Copyright (c) 2011 Intel Corporation. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Intel Corporation nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="webrtc"></a>WebRTC License</h1> + + <p>This license applies to certain files in the directory + <code>third_party/libwebrtc/</code>.</p> +<pre> +Copyright (c) 2011, The WebRTC project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + <hr> + + <h1><a id="x264"></a>x264 License</h1> + + <p>This license applies to the file <code> + third_party/aom/third_party/x86inc/x86inc.asm</code>. + </p> + +<pre> +Copyright (C) 2005-2012 x264 project + +Authors: Loren Merritt <lorenm@u.washington.edu> + Anton Mitrofanov <BugMaster@narod.ru> + Jason Garrett-Glaser <darkshikari@gmail.com> + Henrik Gramner <hengar-6@student.ltu.se> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + + <hr> + + <h1><a id="xiph"></a>Xiph.org Foundation License</h1> + + <p>This license applies to files in the following directories + with the specified copyright year ranges:</p> + <ul> + <li><code>media/libogg/</code>, 2002</li> + <li><code>media/libtheora/</code>, 2002-2007</li> + <li><code>media/libvorbis/</code>, 2002-2004</li> + <li><code>media/libspeex_resampler/</code>, 2002-2008</li> + </ul> + +<pre> +Copyright (c) <year>, Xiph.org Foundation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of the Xiph.org Foundation nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + + + <hr> + + <h1><a id="other-notices"></a>Other Required Notices</h1> + + <ul> + <li>This software is based in part on the work of the Independent + JPEG Group.</li> + <li>Portions of the OS/2 and Android versions + of this software are copyright ©1996-2012 + <a href="https://www.freetype.org/">The FreeType Project</a>. + All rights reserved.</li> + <li>Google Play and the Google Play logo are trademarks of Google LLC.</li> + <li>App Store® and the App Store® logo are trademarks of Apple, Inc.</li> + </ul> + + + <hr> + + <h1><a id="optional-notices"></a>Optional Notices</h1> + + <p>Some permissive software licenses request but do not require an + acknowledgement of the use of their software. We are very grateful + to the following people and projects for their contributions to + this product:</p> + + <ul> + <li>The <a href="https://www.zlib.net/">zlib</a> compression library + (Jean-loup Gailly, Mark Adler and team)</li> + <li>The <a href="http://www.libpng.org/pub/png/">libpng</a> graphics library + (Glenn Randers-Pehrson and team)</li> + <li>The <a href="https://www.sqlite.org/">sqlite</a> database engine + (D. Richard Hipp and team)</li> + <li>The <a href="http://nsis.sourceforge.net/">Nullsoft Scriptable Install System</a> + (Amir Szekely and team)</li> + <li>The <a href="https://mattmccutchen.net/bigint/">C++ Big Integer Library</a> + (Matt McCutchen)</li> + </ul> + + + +#ifdef XP_WIN + + <hr> + + <h1><a id="proprietary-notices"></a>Proprietary Operating System Components</h1> + + <p>Under some circumstances, under our + <a href="https://www.mozilla.org/foundation/licensing/binary-components/">binary components policy</a>, + Mozilla may decide to include additional + operating system vendor code with the installer of our products designed + for that vendor's proprietary platform, to make our products work well on + that specific operating system. The following license statements + apply to such inclusions.</p> + + <h2><a id="directx"></a>Microsoft Windows: Terms for 'Microsoft Distributable Code'</h2> + + <p>These terms apply to the following files; + they are referred to below as "Distributable Code": + <ul> + <li><var>msvc*.dll</var> (C and C++ runtime libraries)</li> + </ul> + </p> + +<pre> +Copyright (c) Microsoft Corporation. + +The Distributable Code may be used and distributed only if you comply with the +following terms: + +(i) You may use, copy, and distribute the Distributable Code only as part of + this product; +(ii) You may not use the Distributable Code on a platform other than Windows; +(iii) You may not alter any copyright, trademark or patent notice in the + Distributable Code; +(iv) You may not modify or distribute the source code of any Distributable + Code so that any part of the source code becomes subject to the MPL or + any other copyleft license; +(v) You must comply with any technical limitations in the Distributable Code + that only allow you to use it in certain ways; and +(vi) You must comply with all domestic and international export laws and + regulations that apply to the Distributable Code. +</pre> + +#endif + +#ifdef APP_LICENSE_BODY_BLOCK +#ifndef APP_LICENSE_LIST_BLOCK +#error +#endif +<!-- List of product-specific licenses for non-Firefox apps. --> +#includesubst @APP_LICENSE_BODY_BLOCK@ +#endif + + <hr> + + <p><a href="about:license#top">Return to top</a>.</p> + </div> + </body> +</html> diff --git a/toolkit/content/macWindowMenu.js b/toolkit/content/macWindowMenu.js new file mode 100644 index 0000000000..bdd86c50f8 --- /dev/null +++ b/toolkit/content/macWindowMenu.js @@ -0,0 +1,13 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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/. */ + +function zoomWindow() { + if (window.windowState == window.STATE_NORMAL) { + window.maximize(); + } else { + window.restore(); + } +} diff --git a/toolkit/content/moz.build b/toolkit/content/moz.build new file mode 100644 index 0000000000..4ecd694d69 --- /dev/null +++ b/toolkit/content/moz.build @@ -0,0 +1,270 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +TEST_DIRS += ["tests"] + +for var in ("CC", "CC_VERSION", "CXX", "RUSTC", "RUSTC_VERSION"): + if CONFIG[var]: + DEFINES[var] = CONFIG[var] + +for var in ("MOZ_CONFIGURE_OPTIONS", "MOZ_APP_DISPLAYNAME"): + DEFINES[var] = CONFIG[var] + +if CONFIG["MOZ_ANDROID_FAT_AAR_ARCHITECTURES"]: + DEFINES["target"] = "</td><td>".join( + sorted(CONFIG["MOZ_ANDROID_FAT_AAR_ARCHITECTURES"]) + ) +else: + DEFINES["target"] = CONFIG["target"] + +DEFINES["CFLAGS"] = " ".join(CONFIG["OS_CFLAGS"]) + +rustflags = CONFIG["RUSTFLAGS"] +if not rustflags: + rustflags = [] +DEFINES["RUSTFLAGS"] = " ".join(rustflags) + +cxx_flags = [] +for var in ("OS_CPPFLAGS", "OS_CXXFLAGS", "DEBUG", "OPTIMIZE", "FRAMEPTR"): + cxx_flags += COMPILE_FLAGS[var] or [] + +DEFINES["CXXFLAGS"] = " ".join(cxx_flags) + +if CONFIG["OS_TARGET"] == "Android": + DEFINES["ANDROID_PACKAGE_NAME"] = CONFIG["ANDROID_PACKAGE_NAME"] + DEFINES["MOZ_USE_LIBCXX"] = True + +if CONFIG["MOZ_INSTALL_TRACKING"]: + DEFINES["MOZ_INSTALL_TRACKING"] = 1 + +if CONFIG["MOZ_BUILD_APP"] == "mobile/android": + DEFINES["MOZ_FENNEC"] = True + +JAR_MANIFESTS += ["jar.mn"] + +SPHINX_TREES["toolkit_widgets"] = "widgets/docs" + +DEFINES["TOPOBJDIR"] = TOPOBJDIR + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("aboutwebrtc/*"): + BUG_COMPONENT = ("Core", "WebRTC") + +with Files("gmp-sources/*"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("tests/browser/browser_*autoplay*"): + BUG_COMPONENT = ("Core", "Audio/Video: Playback") + +with Files("tests/browser/*silent*"): + BUG_COMPONENT = ("Core", "Audio/Video: Playback") + +with Files("tests/browser/*1170531*"): + BUG_COMPONENT = ("Firefox", "Menus") + +with Files("tests/browser/*1198465*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/browser/*451286*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/browser/*594509*"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("tests/browser/*982298*"): + BUG_COMPONENT = ("Core", "Layout") + +with Files("tests/browser/browser_content_url_annotation.js"): + BUG_COMPONENT = ("Toolkit", "Crash Reporting") + +with Files("tests/browser/browser_default_image_filename.js"): + BUG_COMPONENT = ("Firefox", "File Handling") + +with Files("tests/browser/*caret*"): + BUG_COMPONENT = ("Firefox", "Keyboard Navigation") + +with Files("tests/browser/*find*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/browser/browser_isSynthetic.js"): + BUG_COMPONENT = ("Firefox", "Tabbed Browser") + +with Files("tests/browser/*mediaPlayback*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("tests/browser/*mute*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("tests/browser/*save*"): + BUG_COMPONENT = ("Firefox", "File Handling") + +with Files("tests/browser/*scroll*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("tests/chrome/**"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("tests/chrome/*networking*"): + BUG_COMPONENT = ("Core", "Networking") + +with Files("tests/chrome/*autocomplete*"): + BUG_COMPONENT = ("Toolkit", "Autocomplete") + +with Files("tests/chrome/*drop*"): + BUG_COMPONENT = ("Core", "DOM: Copy & Paste and Drag & Drop") + +with Files("tests/chrome/*1048178*"): + BUG_COMPONENT = ("Core", "XUL") + +with Files("tests/chrome/*263683*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/*304188*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/*331215*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/*360220*"): + BUG_COMPONENT = ("Core", "XUL") + +with Files("tests/chrome/*360437*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/*409624*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/*418874*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/*429723*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/*451540*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/*557987*"): + BUG_COMPONENT = ("Firefox", "Menus") +with Files("tests/chrome/*562554*"): + BUG_COMPONENT = ("Firefox", "Menus") + +with Files("tests/chrome/*findbar*"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("tests/chrome/test_preferences*"): + BUG_COMPONENT = ("Toolkit", "Preferences") + +with Files("tests/mochitest/*autocomplete*"): + BUG_COMPONENT = ("Toolkit", "Autocomplete") + +with Files("tests/mochitest/*mousecapture*"): + BUG_COMPONENT = ("Core", "DOM: UI Events & Focus Handling") + +with Files("tests/reftests/*videocontrols*"): + BUG_COMPONENT = ("Toolkit", "Video/Audio Controls") + +with Files("tests/unit/**"): + BUG_COMPONENT = ("Toolkit", "General") + + +with Files("tests/widgets/*audiocontrols*"): + BUG_COMPONENT = ("Toolkit", "Video/Audio Controls") +with Files("tests/widgets/*898940*"): + BUG_COMPONENT = ("Toolkit", "Video/Audio Controls") + +with Files("tests/widgets/*contextmenu*"): + BUG_COMPONENT = ("Firefox", "Menus") + +with Files("tests/widgets/*editor*"): + BUG_COMPONENT = ("Core", "XUL") + +with Files("tests/widgets/*menubar*"): + BUG_COMPONENT = ("Core", "XUL") + +with Files("tests/widgets/*capture*"): + BUG_COMPONENT = ("Core", "DOM: UI Events & Focus Handling") + +with Files("tests/widgets/*popup*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") +with Files("tests/widgets/*tree*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("tests/widgets/*videocontrols*"): + BUG_COMPONENT = ("Toolkit", "Video/Audio Controls") + +with Files("widgets/*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("TopLevelVideoDocument.js"): + BUG_COMPONENT = ("Toolkit", "Video/Audio Controls") + +with Files("about*"): + BUG_COMPONENT = ("Firefox", "General") + +with Files("aboutGlean.*"): + BUG_COMPONENT = ("Toolkit", "Telemetry") + +with Files("aboutNetError*"): + BUG_COMPONENT = ("Firefox", "Security") + +with Files("aboutNetworking*"): + BUG_COMPONENT = ("Core", "Networking") + +with Files("aboutLogging*"): + BUG_COMPONENT = ("Core", "XPCOM") + +with Files("aboutProfile*"): + BUG_COMPONENT = ("Toolkit", "Startup and Profile System") + +with Files("aboutRights*"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("aboutService*"): + BUG_COMPONENT = ("Core", "DOM: Workers") + +with Files("aboutSupport*"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("aboutTelemetry*"): + BUG_COMPONENT = ("Toolkit", "Telemetry") + +with Files("autocomplete.css"): + BUG_COMPONENT = ("Firefox", "Search") + +with Files("browser-*.js"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("buildconfig.html"): + BUG_COMPONENT = ("Firefox Build System", "General") + +with Files("contentAreaUtils.js"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("*picker*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("edit*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("globalOverlay.*"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("plugins*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("resetProfile*"): + BUG_COMPONENT = ("Firefox", "Migration") + +with Files("timepicker*"): + BUG_COMPONENT = ("Toolkit", "UI Widgets") + +with Files("treeUtils.js"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("viewZoomOverlay.js"): + BUG_COMPONENT = ("Toolkit", "General") diff --git a/toolkit/content/mozilla.html b/toolkit/content/mozilla.html new file mode 100644 index 0000000000..542e79c6b1 --- /dev/null +++ b/toolkit/content/mozilla.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> + +<!-- 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/. --> + +<html> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'none'; style-src chrome:; object-src 'none'" + /> + <meta charset="utf-8" /> + <title data-l10n-id="about-mozilla-title-6-27"></title> + <link rel="stylesheet" href="chrome://global/content/aboutMozilla.css" /> + <link rel="localization" href="toolkit/about/aboutMozilla.ftl" /> + </head> + + <body> + <section> + <p id="moztext" data-l10n-id="about-mozilla-quote-6-27"></p> + <p id="from" data-l10n-id="about-mozilla-from-6-27"></p> + </section> + </body> +</html> diff --git a/toolkit/content/neterror/gen_aboutneterror_codes.py b/toolkit/content/neterror/gen_aboutneterror_codes.py new file mode 100644 index 0000000000..806756422f --- /dev/null +++ b/toolkit/content/neterror/gen_aboutneterror_codes.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# 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/. + +import sys + +from fluent.syntax import parse +from fluent.syntax.ast import Message + + +def find_error_ids(filename, known_strings): + with open(filename, "r", encoding="utf-8") as f: + known_strings += [ + m.id.name for m in parse(f.read()).body if isinstance(m, Message) + ] + + +def main(output, *filenames): + known_strings = [] + for filename in filenames: + find_error_ids(filename, known_strings) + + output.write("const KNOWN_ERROR_MESSAGE_IDS = new Set([\n") + for known_string in known_strings: + output.write(' "{}",\n'.format(known_string)) + output.write("]);\n") + + +if __name__ == "__main__": + sys.exit(main(sys.stdout, *sys.argv[1:])) diff --git a/toolkit/content/neterror/supportpages/connection-not-secure.html b/toolkit/content/neterror/supportpages/connection-not-secure.html new file mode 100644 index 0000000000..1df8b7501f --- /dev/null +++ b/toolkit/content/neterror/supportpages/connection-not-secure.html @@ -0,0 +1,205 @@ +<!DOCTYPE html> +<!-- 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/. --> +<html> + <head> + <meta + http-equiv="Content-Security-Policy" + content="connect-src https:; default-src chrome:; object-src 'none'" + /> + <meta name="referrer" content="no-referrer" /> + <meta charset="UTF-8" /> + <link + rel="stylesheet" + type="text/css" + href="chrome://global/skin/offlineSupportPages.css" + /> + <link + rel="icon" + type="image/png" + id="favicon" + href="chrome://branding/content/icon32.png" + /> + <title>Secure connection failed and Firefox did not connect</title> + </head> + <body> + <div id="offlineSupportContainer"> + <h1>Secure connection failed and Firefox did not connect</h1> + <p> + This article explains why you may see a + <em>Secure Connection Failed</em> or a + <em>Did Not Connect: Potential Security Issue</em> error page and what + you can do. + </p> + <div id="toc"> + <h2>Table of Contents</h2> + <ul> + <li class="toclevel-1"> + <a href="#w_secure-connection-cannot-be-established" + ><span class="tocnumber">1</span> + <span class="toctext" + >Secure connection cannot be established</span + ></a + > + <ul> + <li class="toclevel-2"> + <a href="#w_secure-connection-failed" + ><span class="tocnumber">1.1</span> + <span class="toctext">Secure Connection Failed</span></a + > + </li> + <li class="toclevel-2"> + <a href="#w_did-not-connect-potential-security-issue"> + <span class="tocnumber">1.2</span> + <span class="toctext" + >Did Not Connect: Potential Security Issue</span + ></a + > + </li> + </ul> + </li> + <li class="toclevel-1"> + <a href="#w_website-issues" + ><span class="tocnumber">2</span> + <span class="toctext">Website issues</span></a + > + <ul> + <li class="toclevel-2"> + <a href="#w_tls-version-unsupported" + ><span class="tocnumber">2.1</span> + <span class="toctext">TLS version unsupported</span></a + > + </li> + <li class="toclevel-2"> + <a href="#w_hsts-required" + ><span class="tocnumber">2.2</span> + <span class="toctext">HSTS required</span></a + > + </li> + </ul> + </li> + <li class="toclevel-1"> + <a href="#w_security-software-conflict" + ><span class="tocnumber">3</span> + <span class="toctext">Security software conflict</span></a + > + </li> + <li class="toclevel-1"> + <a href="#w_incorrect-system-clock" + ><span class="tocnumber">4</span> + <span class="toctext">Incorrect system clock</span></a + > + </li> + <li class="toclevel-1"> + <a href="#w_other-secure-connection-issues" + ><span class="tocnumber">5</span> + <span class="toctext">Other secure connection issues</span></a + > + </li> + </ul> + </div> + <h1 id="w_secure-connection-cannot-be-established"> + Secure connection cannot be established + </h1> + <p> + When a website that requires a secure (<strong>https</strong>) + connection tries to secure communication with your computer, Firefox + cross-checks this attempt to make sure that the website certificate and + the connection method are actually secure. If Firefox cannot establish a + secure connection, it will display an error page. + </p> + <h2 id="w_secure-connection-failed">Secure Connection Failed</h2> + <p> + A <em>Secure Connection Failed</em> error page will include a + description of the error, an option to report the error to Mozilla and a + <span class="button">Try Again</span> button. There is no option to add + a security exception to bypass this type of error. + </p> + <p></p> + <p>The error page will also include the following information:</p> + <ul> + <li> + <em + >The page you are trying to view cannot be shown because the + authenticity of the received data could not be verified.</em + > + </li> + <li> + <em + >Please contact the website owners to inform them of this + problem.</em + > + </li> + </ul> + <h2 id="w_did-not-connect-potential-security-issue"> + Did Not Connect: Potential Security Issue + </h2> + <p> + Certain secure connection failures will result in a + <em>Did Not Connect: Potential Security Issue</em> error page. + </p> + <p></p> + <p> + The error page will include a description of the potential security + threat, an option to report the error to Mozilla and an + <span class="button">Advanced…</span> button to view the error code and + other technical details. There is no option to add a security exception + to visit the website. + </p> + <h1 id="w_website-issues">Website issues</h1> + <h2 id="w_tls-version-unsupported">TLS version unsupported</h2> + <p> + Some websites try using outdated (no longer secure) Transport Layer + Security(<em>TLS</em>) mechanisms in an attempt to secure your + connection. Firefox protects you by preventing navigation to such sites + if there is a problem in securely establishing a connection. Contact the + owners of the website and ask them to update their TLS version to a + version that is still current and still secure. + </p> + <p> + Starting in Firefox version 74, the minimum TLS version allowed by + default is TLS 1.2. Websites that don't support TLS version 1.2 or + higher will display a <em>Secure Connection Failed</em> error page with + Error code: SSL_ERROR_UNSUPPORTED_VERSION and a message that + <em + >This website might not support the TLS 1.2 protocol, which is the + minimum version supported by Firefox.</em + > + The error page may also include a button, + <span class="button">Enable TLS 1.0 and 1.1</span> that will allow you + to override the minimum TLS requirement; however, Mozilla plans to + remove this option and permanently disable TLS 1.0 and 1.1 in a future + version of Firefox. + </p> + <h2 id="w_hsts-required">HSTS required</h2> + <p> + Other websites may require HTTP Strict Transport Security (HSTS) and + will not allow access with an insecure connection. + </p> + <h1 id="w_security-software-conflict">Security software conflict</h1> + <p> + Many security products use a feature that intercepts secure connections + by default. This can produce connection errors or warnings on secure + websites. If you see secure connection errors on multiple secure + websites, updating your security product or modifying its settings may + resolve the issue. + </p> + <p> + <span class="for" data-for="win8,win10"> + Alternatively, you can uninstall third-party security software and use + Windows Defender, the built-in antivirus on Windows 8 and Windows 10. + </span> + </p> + <p></p> + <h1 id="w_incorrect-system-clock">Incorrect system clock</h1> + <p> + Firefox uses certificates on secure websites to ensure that your + information is being sent to the intended recipient and can't be read by + eavesdroppers. An incorrect system date can cause Firefox to detect that + the website's security certificate is expired or invalid. Make sure your + computer is set to the correct date, time and time zone. + </p> + </div> + </body> +</html> diff --git a/toolkit/content/neterror/supportpages/time-errors.html b/toolkit/content/neterror/supportpages/time-errors.html new file mode 100644 index 0000000000..6fcde4c3ca --- /dev/null +++ b/toolkit/content/neterror/supportpages/time-errors.html @@ -0,0 +1,259 @@ +<!DOCTYPE html> +<!-- 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/. --> +<html> + <head> + <meta + http-equiv="Content-Security-Policy" + content="connect-src https:; default-src chrome:; object-src 'none'" + /> + <meta name="referrer" content="no-referrer" /> + <meta charset="UTF-8" /> + <link + rel="stylesheet" + type="text/css" + href="chrome://global/skin/offlineSupportPages.css" + /> + <link + rel="icon" + type="image/png" + id="favicon" + href="chrome://branding/content/icon32.png" + /> + <title>How to troubleshoot time related errors on secure websites</title> + </head> + <body> + <div id="offlineSupportContainer"> + <h1>How to troubleshoot time related errors on secure websites</h1> + <p> + Certificates for secure websites (the address begins with + <strong>https://</strong>) are valid only for a certain period of time. + If a website presents a certificate with validity dates that don't match + the date on your computer's clock, Firefox can't verify that it is + secure and will show you an error page. + </p> + <p> + Such issues can often be fixed by setting the correct date, time and + time zone on your computer system. If this does not solve the problem, + it could be caused by other issues, such as a misconfigured web server + or an expired certificate. + </p> + <div id="toc"> + <h2>Table of Contents</h2> + <ul> + <li class="toclevel-1"> + <a href="#w_list-of-time-related-error-codes-you-may-encounter"> + <span class="tocnumber">1</span> + <span class="toctext" + >List of time-related error codes you may encounter</span + > + </a> + </li> + <li class="toclevel-1"> + <a href="#w_set-your-system-clock-to-the-correct-time"> + <span class="tocnumber">2</span> + <span class="toctext" + >Set your system clock to the correct time</span + > + </a> + </li> + <li class="toclevel-1"> + <a href="#w_contact-the-website-owner"> + <span class="tocnumber">3</span> + <span class="toctext">Contact the website owner</span> + </a> + </li> + <li class="toclevel-1"> + <a href="#w_bypass-the-warning"> + <span class="tocnumber">4</span> + <span class="toctext">Bypass the warning</span> + </a> + </li> + </ul> + </div> + <h1 id="w_list-of-time-related-error-codes-you-may-encounter"> + List of time-related error codes you may encounter + </h1> + <div class="for" data-for="fx66"> + <div class="note"> + <strong>Note:</strong> A <em>Your Computer Clock is Wrong</em> error + page almost certainly means that your computer's clock is set to the + wrong date. Some time-related errors will show a + <em>Warning: Potential Security Risk Ahead</em> error page. For other + time-related errors, you'll get a <em>Secure Connection Failed</em> or + <em>Did Not Connect: Potential Security Issue</em> error page. + </div> + <p> + <span class="for" data-for="=fx66"></span> + <span class="for" data-for="fx67"></span> + </p> + <p> + Click + <span class="for" data-for="not fx67"> + <span class="button">More Information</span> or + <span class="button">Advanced…</span>, depending on the error page, + </span> + <span class="for" data-for="fx67"> + <span class="button">Advanced…</span> on the error page</span + > + to view the error code. One of the following error codes will indicate + that the secure connection couldn't be established due to a + time-related error: + </p> + </div> + <div class="for" data-for="not fx66"> + <div class="note"> + <strong>Note:</strong> If you get a + <em>Your connection is not secure</em> error page, click the + <span class="button">Advanced</span> button to view the error code and + other details. A <em>Secure Connection Failed</em> error page may also + indicate a time-related error. + </div> + <p> + One of the following error codes will indicate that the secure + connection couldn't be established due to a time-related error: + </p> + </div> + <p> + <sub>SEC_ERROR_EXPIRED_CERTIFICATE</sub><br /> + <sub>SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE</sub><br /> + <sub>SEC_ERROR_OCSP_FUTURE_RESPONSE</sub><br /> + <sub>SEC_ERROR_OCSP_OLD_RESPONSE</sub><br /> + <sub>MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE</sub><br /> + <sub>MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE</sub> + </p> + <p> + The text on the error page will warn you when Firefox detects that your + system date and time is probably wrong and will also show the date and + time currently set in your system. If the clock settings are incorrect + you should set it to the right time<span class="for" data-for="win,mac"> + as explained below</span + >. Even if the displayed time settings seem to be correct, you should + make sure that the time zone settings of your system match your current + location. + </p> + <h1 id="w_set-your-system-clock-to-the-correct-time"> + Set your system clock to the correct time + </h1> + <p> + Time-related errors on secure websites caused by a skewed system clock + can be resolved by setting your correct date, time and time zone<span + class="for" + data-for="mac" + >:</span + ><span class="for" data-for="win,linux">.</span> + <span class="for" data-for="win"> + Change your date and time settings from the clock on the Windows + taskbar or follow these instructions:</span + > + </p> + <div class="for" data-for="win10"> + <h2>If you are on Windows 10:</h2> + <ol> + <li> + Click the Windows Start button or press the Windows key<span + class="key" + ></span + >. + </li> + <li>In the Start menu, select<span class="menu">Settings</span>.</li> + <li> + In Settings, select<span class="menu">Time & language</span>. + </li> + <li> + In the<span class="menu">Date & time</span> section you can + review the current date and time settings. To change your settings + click on <span class="button">Change</span> below + <span class="menu">Change date and time</span> or expand the + <span class="menu">Time zone</span> dropdown menu. + <div class="note"> + If your system is set to manage the time and time zone + automatically, you cannot make manual changes. + </div> + </li> + <li>If you are done with your changes, close the Settings window.</li> + </ol> + </div> + <div class="for" data-for="mac"> + <h2>If you are on Mac OS:</h2> + <ol> + <li> + Click the Apple menu and select + <span class="menu">System Preferences</span>. + </li> + <li> + In the System Preferences window, click on + <strong>Date & Time</strong>. + </li> + <li> + The panel that opens shows the current date and time settings. In + order to adjust them, disable + <span class="pref">Set date and time automatically</span>, manually + enter the date and time and click + <span class="button">Save</span> to confirm your changes. + </li> + <li> + In order to review your time zone settings, click on the + <strong>Time Zone</strong> tab. In order to adjust your time zone, + disable + <span class="pref" + >Set time zone automatically using current location</span + >, click onto your approximate location in the map and select the + city closest to you in the dropdown panel. + </li> + <li> + If you are done with your changes, close the Date & Time window. + </li> + </ol> + </div> + <div class="note"> + <strong>Note:</strong> If the clock on your device constantly resets + after you power it off, this might indicate that the battery cell that + runs the real-time clock is getting low or is empty. Please consult your + manufacturer's manual on how to replace the CMOS battery. + </div> + <h1 id="w_contact-the-website-owner">Contact the website owner</h1> + <p> + If you get a time related error on a secure website and you have already + checked the correct settings of your system’s clock, please contact the + owner of the website which you can’t access and inform them of the + problem. The website owner might need to renew the expired certificate, + for example. + </p> + <div class="for" data-for="not fx66"> + <h1 id="w_bypass-the-warning">Bypass the warning</h1> + <div class="warning"> + <strong>Warning:</strong> You should never bypass the warning for a + legitimate major website or sites where financial transactions take + place – in this case an invalid certificate can indicate that your + connection is compromised by a third party. + </div> + <p> + If you see a <em>Your connection is not secure</em> warning page and + the website allows it, you can add an exception to be able to visit + the site, despite the fact that the certificate is not trusted by + default: + </p> + <ol> + <li> + On the warning page, click <span class="button">Advanced</span>. + </li> + <li> + Click <span class="button">Add Exception…</span>. The + <em>Add Security Exception</em> dialog will appear. + </li> + <li> + Read the text describing the problems with the website. You can + click <span class="button">View…</span> + to closer inspect the untrusted certificate. + </li> + <li> + Click <span class="button">Confirm Security Exception</span> if you + are sure you want to trust the site. + </li> + </ol> + </div> + </div> + </body> +</html> diff --git a/toolkit/content/preferencesBindings.js b/toolkit/content/preferencesBindings.js new file mode 100644 index 0000000000..b2e4070cf5 --- /dev/null +++ b/toolkit/content/preferencesBindings.js @@ -0,0 +1,671 @@ +/* - 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"; + +// We attach Preferences to the window object so other contexts (tests, JSMs) +// have access to it. +const Preferences = (window.Preferences = (function () { + const { EventEmitter } = ChromeUtils.importESModule( + "resource://gre/modules/EventEmitter.sys.mjs" + ); + + const lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + }); + + function getElementsByAttribute(name, value) { + // If we needed to defend against arbitrary values, we would escape + // double quotes (") and escape characters (\) in them, i.e.: + // ${value.replace(/["\\]/g, '\\$&')} + return value + ? document.querySelectorAll(`[${name}="${value}"]`) + : document.querySelectorAll(`[${name}]`); + } + + const domContentLoadedPromise = new Promise(resolve => { + window.addEventListener("DOMContentLoaded", resolve, { + capture: true, + once: true, + }); + }); + + const Preferences = { + _all: {}, + + _add(prefInfo) { + if (this._all[prefInfo.id]) { + throw new Error(`preference with id '${prefInfo.id}' already added`); + } + const pref = new Preference(prefInfo); + this._all[pref.id] = pref; + domContentLoadedPromise.then(() => { + if (!this.updateQueued) { + pref.updateElements(); + } + }); + return pref; + }, + + add(prefInfo) { + const pref = this._add(prefInfo); + return pref; + }, + + addAll(prefInfos) { + prefInfos.map(prefInfo => this._add(prefInfo)); + }, + + get(id) { + return this._all[id] || null; + }, + + getAll() { + return Object.values(this._all); + }, + + defaultBranch: Services.prefs.getDefaultBranch(""), + + get type() { + return document.documentElement.getAttribute("type") || ""; + }, + + get instantApply() { + // The about:preferences page forces instantApply. + // TODO: Remove forceEnableInstantApply in favor of always applying in a + // parent and never applying in a child (bug 1775386). + if (this._instantApplyForceEnabled) { + return true; + } + + // Dialogs of type="child" are never instantApply. + return this.type !== "child"; + }, + + _instantApplyForceEnabled: false, + + // Override the computed value of instantApply for this window. + forceEnableInstantApply() { + this._instantApplyForceEnabled = true; + }, + + observe(subject, topic, data) { + const pref = this._all[data]; + if (pref) { + pref.value = pref.valueFromPreferences; + } + }, + + updateQueued: false, + + queueUpdateOfAllElements() { + if (this.updateQueued) { + return; + } + + this.updateQueued = true; + + Services.tm.dispatchToMainThread(() => { + let startTime = performance.now(); + + const elements = getElementsByAttribute("preference"); + for (const element of elements) { + const id = element.getAttribute("preference"); + let preference = this.get(id); + if (!preference) { + console.error(`Missing preference for ID ${id}`); + continue; + } + + preference.setElementValue(element); + } + + ChromeUtils.addProfilerMarker( + "Preferences", + { startTime }, + `updateAllElements: ${elements.length} preferences updated` + ); + + this.updateQueued = false; + }); + }, + + onUnload() { + Services.prefs.removeObserver("", this); + }, + + QueryInterface: ChromeUtils.generateQI(["nsITimerCallback", "nsIObserver"]), + + _deferredValueUpdateElements: new Set(), + + writePreferences(aFlushToDisk) { + // Write all values to preferences. + if (this._deferredValueUpdateElements.size) { + this._finalizeDeferredElements(); + } + + const preferences = Preferences.getAll(); + for (const preference of preferences) { + preference.batching = true; + preference.valueFromPreferences = preference.value; + preference.batching = false; + } + if (aFlushToDisk) { + Services.prefs.savePrefFile(null); + } + }, + + getPreferenceElement(aStartElement) { + let temp = aStartElement; + while ( + temp && + temp.nodeType == Node.ELEMENT_NODE && + !temp.hasAttribute("preference") + ) { + temp = temp.parentNode; + } + return temp && temp.nodeType == Node.ELEMENT_NODE ? temp : aStartElement; + }, + + _deferredValueUpdate(aElement) { + delete aElement._deferredValueUpdateTask; + const prefID = aElement.getAttribute("preference"); + const preference = Preferences.get(prefID); + const prefVal = preference.getElementValue(aElement); + preference.value = prefVal; + this._deferredValueUpdateElements.delete(aElement); + }, + + _finalizeDeferredElements() { + for (const el of this._deferredValueUpdateElements) { + if (el._deferredValueUpdateTask) { + el._deferredValueUpdateTask.finalize(); + } + } + }, + + userChangedValue(aElement) { + const element = this.getPreferenceElement(aElement); + if (element.hasAttribute("preference")) { + if (element.getAttribute("delayprefsave") != "true") { + const preference = Preferences.get( + element.getAttribute("preference") + ); + const prefVal = preference.getElementValue(element); + preference.value = prefVal; + } else { + if (!element._deferredValueUpdateTask) { + element._deferredValueUpdateTask = new lazy.DeferredTask( + this._deferredValueUpdate.bind(this, element), + 1000 + ); + this._deferredValueUpdateElements.add(element); + } else { + // Each time the preference is changed, restart the delay. + element._deferredValueUpdateTask.disarm(); + } + element._deferredValueUpdateTask.arm(); + } + } + }, + + onCommand(event) { + // This "command" event handler tracks changes made to preferences by + // the user in this window. + if (event.sourceEvent) { + event = event.sourceEvent; + } + this.userChangedValue(event.target); + }, + + onChange(event) { + // This "change" event handler tracks changes made to preferences by + // the user in this window. + this.userChangedValue(event.target); + }, + + onInput(event) { + // This "input" event handler tracks changes made to preferences by + // the user in this window. + this.userChangedValue(event.target); + }, + + _fireEvent(aEventName, aTarget) { + try { + const event = new CustomEvent(aEventName, { + bubbles: true, + cancelable: true, + }); + return aTarget.dispatchEvent(event); + } catch (e) { + console.error(e); + } + return false; + }, + + onDialogAccept(event) { + let dialog = document.querySelector("dialog"); + if (!this._fireEvent("beforeaccept", dialog)) { + event.preventDefault(); + return false; + } + this.writePreferences(true); + return true; + }, + + close(event) { + if (Preferences.instantApply) { + window.close(); + } + event.stopPropagation(); + event.preventDefault(); + }, + + handleEvent(event) { + switch (event.type) { + case "toggle": + case "change": + return this.onChange(event); + case "command": + return this.onCommand(event); + case "dialogaccept": + return this.onDialogAccept(event); + case "input": + return this.onInput(event); + case "unload": + return this.onUnload(event); + default: + return undefined; + } + }, + + _syncFromPrefListeners: new WeakMap(), + _syncToPrefListeners: new WeakMap(), + + addSyncFromPrefListener(aElement, callback) { + this._syncFromPrefListeners.set(aElement, callback); + if (this.updateQueued) { + return; + } + // Make sure elements are updated correctly with the listener attached. + let elementPref = aElement.getAttribute("preference"); + if (elementPref) { + let pref = this.get(elementPref); + if (pref) { + pref.updateElements(); + } + } + }, + + addSyncToPrefListener(aElement, callback) { + this._syncToPrefListeners.set(aElement, callback); + if (this.updateQueued) { + return; + } + // Make sure elements are updated correctly with the listener attached. + let elementPref = aElement.getAttribute("preference"); + if (elementPref) { + let pref = this.get(elementPref); + if (pref) { + pref.updateElements(); + } + } + }, + + removeSyncFromPrefListener(aElement) { + this._syncFromPrefListeners.delete(aElement); + }, + + removeSyncToPrefListener(aElement) { + this._syncToPrefListeners.delete(aElement); + }, + }; + + Services.prefs.addObserver("", Preferences); + window.addEventListener("toggle", Preferences); + window.addEventListener("change", Preferences); + window.addEventListener("command", Preferences); + window.addEventListener("dialogaccept", Preferences); + window.addEventListener("input", Preferences); + window.addEventListener("select", Preferences); + window.addEventListener("unload", Preferences, { once: true }); + + class Preference extends EventEmitter { + constructor({ id, type, inverted }) { + super(); + this.on("change", this.onChange.bind(this)); + + this._value = null; + this.readonly = false; + this._useDefault = false; + this.batching = false; + + this.id = id; + this.type = type; + this.inverted = !!inverted; + + // In non-instant apply mode, we must try and use the last saved state + // from any previous opens of a child dialog instead of the value from + // preferences, to pick up any edits a user may have made. + + if ( + Preferences.type == "child" && + window.opener && + window.opener.Preferences && + window.opener.document.nodePrincipal.isSystemPrincipal + ) { + // Try to find the preference in the parent window. + const preference = window.opener.Preferences.get(this.id); + + // Don't use the value setter here, we don't want updateElements to be + // prematurely fired. + this._value = preference ? preference.value : this.valueFromPreferences; + } else { + this._value = this.valueFromPreferences; + } + } + + reset() { + // defer reset until preference update + this.value = undefined; + } + + _reportUnknownType() { + const msg = `Preference with id=${this.id} has unknown type ${this.type}.`; + Services.console.logStringMessage(msg); + } + + setElementValue(aElement) { + if (this.locked) { + aElement.disabled = true; + } + + if (!this.isElementEditable(aElement)) { + return; + } + + let rv = undefined; + + if (Preferences._syncFromPrefListeners.has(aElement)) { + rv = Preferences._syncFromPrefListeners.get(aElement)(aElement); + } + let val = rv; + if (val === undefined) { + val = Preferences.instantApply ? this.valueFromPreferences : this.value; + } + // if the preference is marked for reset, show default value in UI + if (val === undefined) { + val = this.defaultValue; + } + + /** + * Initialize a UI element property with a value. Handles the case + * where an element has not yet had a XBL binding attached for it and + * the property setter does not yet exist by setting the same attribute + * on the XUL element using DOM apis and assuming the element's + * constructor or property getters appropriately handle this state. + */ + function setValue(element, attribute, value) { + if (attribute in element) { + element[attribute] = value; + } else if (attribute === "checked" || attribute === "pressed") { + // The "checked" attribute can't simply be set to the specified value; + // it has to be set if the value is true and removed if the value + // is false in order to be interpreted correctly by the element. + if (value) { + // In theory we can set it to anything; however xbl implementation + // of `checkbox` only works with "true". + element.setAttribute(attribute, "true"); + } else { + element.removeAttribute(attribute); + } + } else { + element.setAttribute(attribute, value); + } + } + if ( + aElement.localName == "checkbox" || + (aElement.localName == "input" && aElement.type == "checkbox") + ) { + setValue(aElement, "checked", val); + } else if (aElement.localName == "moz-toggle") { + setValue(aElement, "pressed", val); + } else { + setValue(aElement, "value", val); + } + } + + getElementValue(aElement) { + if (Preferences._syncToPrefListeners.has(aElement)) { + try { + const rv = Preferences._syncToPrefListeners.get(aElement)(aElement); + if (rv !== undefined) { + return rv; + } + } catch (e) { + console.error(e); + } + } + + /** + * Read the value of an attribute from an element, assuming the + * attribute is a property on the element's node API. If the property + * is not present in the API, then assume its value is contained in + * an attribute, as is the case before a binding has been attached. + */ + function getValue(element, attribute) { + if (attribute in element) { + return element[attribute]; + } + return element.getAttribute(attribute); + } + let value; + if ( + aElement.localName == "checkbox" || + (aElement.localName == "input" && aElement.type == "checkbox") + ) { + value = getValue(aElement, "checked"); + } else if (aElement.localName == "moz-toggle") { + value = getValue(aElement, "pressed"); + } else { + value = getValue(aElement, "value"); + } + + switch (this.type) { + case "int": + return parseInt(value, 10) || 0; + case "bool": + return typeof value == "boolean" ? value : value == "true"; + } + return value; + } + + isElementEditable(aElement) { + switch (aElement.localName) { + case "checkbox": + case "input": + case "radiogroup": + case "textarea": + case "menulist": + case "moz-toggle": + return true; + } + return false; + } + + updateElements() { + let startTime = performance.now(); + + if (!this.id) { + return; + } + + const elements = getElementsByAttribute("preference", this.id); + for (const element of elements) { + this.setElementValue(element); + } + + ChromeUtils.addProfilerMarker( + "Preferences", + { startTime, captureStack: true }, + `updateElements for ${this.id}` + ); + } + + onChange() { + this.updateElements(); + } + + get value() { + return this._value; + } + + set value(val) { + if (this.value !== val) { + this._value = val; + if (Preferences.instantApply) { + this.valueFromPreferences = val; + } + this.emit("change"); + } + } + + get locked() { + return Services.prefs.prefIsLocked(this.id); + } + + updateControlDisabledState(val) { + if (!this.id) { + return; + } + + val = val || this.locked; + + const elements = getElementsByAttribute("preference", this.id); + for (const element of elements) { + element.disabled = val; + + const labels = getElementsByAttribute("control", element.id); + for (const label of labels) { + label.disabled = val; + } + } + } + + get hasUserValue() { + return ( + Services.prefs.prefHasUserValue(this.id) && this.value !== undefined + ); + } + + get defaultValue() { + this._useDefault = true; + const val = this.valueFromPreferences; + this._useDefault = false; + return val; + } + + get _branch() { + return this._useDefault ? Preferences.defaultBranch : Services.prefs; + } + + get valueFromPreferences() { + try { + // Force a resync of value with preferences. + switch (this.type) { + case "int": + return this._branch.getIntPref(this.id); + case "bool": { + const val = this._branch.getBoolPref(this.id); + return this.inverted ? !val : val; + } + case "wstring": + return this._branch.getComplexValue( + this.id, + Ci.nsIPrefLocalizedString + ).data; + case "string": + case "unichar": + return this._branch.getStringPref(this.id); + case "fontname": { + const family = this._branch.getStringPref(this.id); + const fontEnumerator = Cc[ + "@mozilla.org/gfx/fontenumerator;1" + ].createInstance(Ci.nsIFontEnumerator); + return fontEnumerator.getStandardFamilyName(family); + } + case "file": { + const f = this._branch.getComplexValue(this.id, Ci.nsIFile); + return f; + } + default: + this._reportUnknownType(); + } + } catch (e) {} + return null; + } + + set valueFromPreferences(val) { + // Exit early if nothing to do. + if (this.readonly || this.valueFromPreferences == val) { + return; + } + + // The special value undefined means 'reset preference to default'. + if (val === undefined) { + Services.prefs.clearUserPref(this.id); + return; + } + + // Force a resync of preferences with value. + switch (this.type) { + case "int": + Services.prefs.setIntPref(this.id, val); + break; + case "bool": + Services.prefs.setBoolPref(this.id, this.inverted ? !val : val); + break; + case "wstring": { + const pls = Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ); + pls.data = val; + Services.prefs.setComplexValue( + this.id, + Ci.nsIPrefLocalizedString, + pls + ); + break; + } + case "string": + case "unichar": + case "fontname": + Services.prefs.setStringPref(this.id, val); + break; + case "file": { + let lf; + if (typeof val == "string") { + lf = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + lf.persistentDescriptor = val; + if (!lf.exists()) { + lf.initWithPath(val); + } + } else { + lf = val.QueryInterface(Ci.nsIFile); + } + Services.prefs.setComplexValue(this.id, Ci.nsIFile, lf); + break; + } + default: + this._reportUnknownType(); + } + if (!this.batching) { + Services.prefs.savePrefFile(null); + } + } + } + + return Preferences; +})()); diff --git a/toolkit/content/process-content.js b/toolkit/content/process-content.js new file mode 100644 index 0000000000..467f33aa3d --- /dev/null +++ b/toolkit/content/process-content.js @@ -0,0 +1,17 @@ +/* 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/. */ + +/* eslint-env mozilla/process-script */ + +"use strict"; + +// Creates a new PageListener for this process. This will listen for page loads +// and for those that match URLs provided by the parent process will set up +// a dedicated message port and notify the parent process. + +Services.cpmm.addMessageListener("gmp-plugin-crash", ({ data }) => { + Cc["@mozilla.org/gecko-media-plugin-service;1"] + .getService(Ci.mozIGeckoMediaPluginService) + .RunPluginCrashCallbacks(data.pluginID, data.pluginName); +}); diff --git a/toolkit/content/resetProfile.css b/toolkit/content/resetProfile.css new file mode 100644 index 0000000000..b989d28032 --- /dev/null +++ b/toolkit/content/resetProfile.css @@ -0,0 +1,11 @@ +/* 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/. */ + +#resetProfileProgressDialog { + padding: 10px; +} + +#infoTitle { + font-weight: 600; +} diff --git a/toolkit/content/resetProfile.js b/toolkit/content/resetProfile.js new file mode 100644 index 0000000000..e00ccf73c2 --- /dev/null +++ b/toolkit/content/resetProfile.js @@ -0,0 +1,27 @@ +/* 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"; + +document.addEventListener("dialogaccept", onResetProfileAccepted); +document + .getElementById("refreshProfileLearnMore") + .addEventListener("click", e => { + e.preventDefault(); + let retVals = window.arguments[0]; + retVals.learnMore = true; + window.close(); + }); + +document.addEventListener("DOMContentLoaded", function () { + document + .getElementById("resetProfileDialog") + .getButton("accept") + .classList.add("danger-button"); +}); + +function onResetProfileAccepted() { + let retVals = window.arguments[0]; + retVals.reset = true; +} diff --git a/toolkit/content/resetProfile.xhtml b/toolkit/content/resetProfile.xhtml new file mode 100644 index 0000000000..5c3872ede1 --- /dev/null +++ b/toolkit/content/resetProfile.xhtml @@ -0,0 +1,70 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<window + id="resetProfileDialogWindow" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + aria-describedby="infoBody" +> + <dialog + id="resetProfileDialog" + buttons="accept,cancel" + defaultButton="accept" + buttonidaccept="refresh-profile-dialog-button" + > + <!-- The empty onload event handler is a hack to get the accept button text applied by Fluent. --> + + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://global/content/commonDialog.css" + /> + <html:link + rel="stylesheet" + href="chrome://global/skin/commonDialog.css" + /> + <html:link + rel="stylesheet" + href="chrome://global/content/resetProfile.css" + /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="toolkit/global/resetProfile.ftl" /> + </linkset> + + <div xmlns="http://www.w3.org/1999/xhtml" id="dialogGrid"> + <div class="dialogRow" id="infoRow"> + <div id="iconContainer"> + <xul:image id="infoIcon" /> + </div> + <div id="infoContainer"> + <xul:description + id="infoTitle" + data-l10n-id="refresh-profile-dialog-title" + /> + <xul:description + id="infoBody" + context="contentAreaContextMenu" + noinitialfocus="true" + data-l10n-id="refresh-profile-dialog-description" + /> + <xul:description id="learnMoreDescription" + ><a + id="refreshProfileLearnMore" + data-l10n-id="refresh-profile-learn-more" + href="" + ></a + ></xul:description> + </div> + </div> + </div> + + <script src="chrome://global/content/resetProfile.js" /> + </dialog> +</window> diff --git a/toolkit/content/resetProfileProgress.xhtml b/toolkit/content/resetProfileProgress.xhtml new file mode 100644 index 0000000000..747b262af5 --- /dev/null +++ b/toolkit/content/resetProfileProgress.xhtml @@ -0,0 +1,33 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<!DOCTYPE window> + +<window + id="resetProfileProgressDialog" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="refresh-profile-progress" + style="min-width: 30em" +> + <vbox> + <linkset> + <html:link + rel="stylesheet" + href="chrome://global/content/resetProfile.css" + /> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="toolkit/global/resetProfile.ftl" /> + </linkset> + + <description + data-l10n-id="refresh-profile-progress-description" + ></description> + <html:progress /> + </vbox> +</window> diff --git a/toolkit/content/tests/browser/audio.ogg b/toolkit/content/tests/browser/audio.ogg Binary files differnew file mode 100644 index 0000000000..7f1833508a --- /dev/null +++ b/toolkit/content/tests/browser/audio.ogg diff --git a/toolkit/content/tests/browser/audio_file.txt b/toolkit/content/tests/browser/audio_file.txt new file mode 100644 index 0000000000..2e4af84485 --- /dev/null +++ b/toolkit/content/tests/browser/audio_file.txt @@ -0,0 +1 @@ +data:audio/mpeg;base64,//tAxAAABvhjIgSxKQEFCi009CDgFQlHhw2YJxQXGTI1QVJ8WRkDAPBHKiG0yuPRcfEiOvayEXHyQnQLqJrMnjbEwTBMIN//8279Bv86QAAiCXbbgbotwRxCSFkvPxUgvmJ3iYWrqAoCs5buniYp/chXmj6wI6c0ILg/qAbENxV4RiApBAEHKD+U//1vkMWgAAAAXJJQ7CSK1lN3jhQIGtJsc/1mY13o/H2DFvCa0JLC4koaBRX/+0LEFAAIDElpp6TGwQaN7LWDFSAAAsKziRUMG0Cx+ZO03eBVJIS/r/6er/r1sAFAAAFtyCPInCApfqXtMafCH4l5Fws6rufUiy3yiYaLKcWLMKq9V7lbuMlIxZUkD72tRFvqJj3pNhu+h+j/fxSJIAAAA13i4R47FCkjw6iOeU4w7xSnggQI/gnyAAdDlwl1nf3n97uZvzrEglCsOSx+cz+bek4VOBxDVMr+xnuqyJApAAEpxtj/+0LEJAAIBG1lh5jJAQuLLfT2JOB+X+EE+bxPBYTSF7q9o1vZw7vGjRKI0Y9vCyA8lcPsdvfSq4nUmcFdATAwuoTD31EhITjEFy4gC79/662wGQAAm44x1TRwFXrpaMzgUkWiscPpglgQqnkQcIO1BsF4eYZhB9ImQfeQuAL18MtHMItQSYpptaRYHVK/17UUVJgAAABJyRiUWqCUx7PyXq4iDjYrwsbhUu6YgwjKohzhF2UAwNX/+0LEM4AIJENzrDzGwQeIbnT3mNgbYEDQBNsCYsfsOHaWAScIIROCc4OrKu5043XorYAAAACbcjGwHQyi9K43TKUqNLAaDqdkydra8PkT02zR1MqFidt+xldbdTutkmokxovBUiODVB0DChhYdFNFkm1P7EL5wgAAAJyS2BeANnooRJTfRqRLAEjxW02wzR3HuGxpXcX6TZNClIxQQqggFTwrOrvcHCCHsaLJKP72NBwRrYVe5///+0LEQwAIjF9xp6UpAQ6I7nT2JNjmKdIgAQAA5LLBXKpWsOvDcUSkizwwE0CdTJHWaauyBA8mcGEoETaBsLfsz0e0yeIZHs8jFc77f1VOzFVNNGQo7aqO3V/6v7GwAAAAW24w/EMgl0V0AfR7rpsLgwgRrUbyKCJhD4tZUBDyMLYrOzA729DKJOsYIlwUJj4wAt3eRaIEpRf/+a2J82AAAAMtt1FosIgV8ncVuRgT6NqhhhhJNvf/+0LEUAAIoMNxrBhLQQQM7fT2CSAy5LAbACMhIoJxLGYc8k6gOBcfila8BhBhyMFlViQkKgobrCwr/+dP11I0pXy72ToeA7iSDuIKtGklHAuaRLme5MHgIgPIZcDg4EAmi8jpFi9uF9+pkABYHxYqHh9O//6lFfrUAAAC7dtRMp7DyrdBD8xCLBzQ6P/MY7PyYUOCFHEHtbnip+g9mgeLlXhkOCxE21c+AAo0iZFTA46HhZs7qvb/+0LEXgAIXE13rCRnAOSKLQD3sODZcq6n/DopgAAABGX/8Rl0EEV2CuYAWUNfDLaqDFZ29QiFaINCRJfstMgA7mY7SpzOVC3ZMj/6yiHfUlMgLuJlLEq6zZ9DobFDYomGhgAAAAAKu34fnsIsqCCgDJY0OfbYkG7BvOyaziJgSmULsSCNV9v/6d5KpUzue6/7KKDntX///czor2Rv/v1faoyXo//9PrUAAAAATLaIdoIeJCZF72r/+0DEcQAIlD95rDDGwRmRbrz0iShVWuISCBERzVXmVBBAAw2bTOo1SwgxXWikcx7SMx3UrX/7xzE//xoADX4teiaLuYjRpAAAAkl20NbeqlKE12FO1N1HQ74MRwX8KB8wTrmR3yJVaM9rksd3LMq0/0Kv////op7G5cizTlZy1c6KOijRH+mv1YdkMAIAAgF7/iRMHQStYUxGWWMdwFziArfGT+crrIpN7TvFk9TircKdVnUSLP/7QsR8AAjU/3XnmEuBAZFuNPSJKPfYnsf//1YEwff2fUoVJmmjbmi54r3xyIhWRCAAAAKb/iblyeogNZlgiKryrKhAikqTbZ/3SzmGMiLgs4OWp2qnmqiHEwnBprQMDLzVyQygOo1sLVEq5SxSHdVnocvxBoYgIABASslDGHeLQG2ys5niY00HUNtOT/NT205Jmzg+V5SLDgDKI3FD4uBkKh1vAyASb0iQDGzQsGyBG7uv////lP/7QsSJgAiBB3usDEuRFBHuvPSNKK2wAAAAApHBcuqDHuxoQXwAXTewW95oai1hSrVtt6o8Orh+wV8vKCJ0UHdyE1MSOP9+If+P6jKQm0bgzb4HJ9mKD2p/e/7/bezhZAAADctg23vQdqPWlIG242YwgobqIp9AwhUwari28fdpfEg/mA7uAzUQ+5LN1ainiQfeUVF0YVF7PuIvuCsBCgNmDKjhAB0fr/nESQQSmU5gn2zNM8KRY//7QsSWAAjMWXPsYGcBDoyufPYJJPLYfMP7tpQqOutKsva1Wm26aAKqcNnI870+dkZ9UNXq0hBRQBF/9P0l2IhhmUeg8nrrJvYtXTzKOIAACRBgTkEvuqkMrpj8AAxMKkU+Wndjikqc2f/uVEzc5XzvQqtaadjOt93+X6OWoQeVHuVveqt2maqs/5lOQcgUCliUbZmnVBEiIBIceuwm2XkqjMeDnYxYbu+gFkUIkSuhEo6llUWHzP/7QsSiAAkEjWunvQlJK5XuNPOVaFeUOBw6SH+jxv78/zNqYprSj3pg0de1CVREReeDp1oWPWGKWXuUpiPv1fg1QQAAAAS3d+CKLeNIvTpCiZF0eMFB1EImoZQwMTAri7LrJM3vdlQXgVWr8Ln9iX+Oehn4O0fU0246plNhIFnIiRQOrkmqscr9qtoiAAAAG5ZQtsY9IKlgVJmCKxoAO0Qsj9RVlURLFEzZl4pStZMjhk0Wp/y2c//7QsSpgAi0mXusNElRIZ0ufZMJbM81+0uCzojepOsw0eXOJF2IDAssCoNjFo//2UdEAAAWvNTZLaojCgjqCEEbrowepo6vm5pM8ogjTLlSQn8uFEQpOS1ISBETd3P/ry+hVzU//dH3T4CEFahAFpk+cD8wt4frnOkUESEAwqbRfybHIRvBIkNQYNfiVX5q1fKz7rWa/z8+mu70BrjYaVmBz6aEPj/3fz3UWUgESOC7CL2ixoSOkf/7QsSzgAmwmW/sDGlhKhPsvPGNKDJa+/ArdPs1/8Cq29sa8iBkMIN15EeU8891iQ82889abDrEKnIphACguPLE9kFo7Sky8Jt1S2srdElD4IMgsnVEexo+j3B8PmSYkQvovu3rkiaTSTIBQyYXB6IGJBdLpKV2iVliMt2rgsi5S3GjxRb3sVccA8DQ0PNPUWa7lIXXRjVNaOZ6u2W7ZqkhypGsBMWFix1Y12VvIVMGXr/ts9ammv/7QMS4gAkYl2GnmGlBGR4sZJGNcACA4EWjYrNMogcaxHMOSm0EL9OuruosVWKV4BgYJTotwMSCcHRyA6YxMZMBlBFIslyRQOBseSOrSg9VfmOokWQ1kYmIh1RlFBQASVwfTC+q87J6RJ7VIbkzxezub6PuG2C4+s8UgDWi3WuHxZoSzpz/85eEzDHMj/BUyqm2CDCyAVVLwZpO33ePnB3m78Lv/lVDuG/p+viFYzVCMSAmjKK1//tCxMGACQzBYgSMy0kemu2gkKFoIYFkIzVCxx56guNZBpzGTuM3IUJxOxq328x0RURjjH1K+znvuSMEjmLVzlys6y3dyHcUhUUqNCUyvit/VfhTxq19v9Te/9IY/dSIeEVEEQBCaUwfMB3CsyQhIB9bDWzFpSFhG6bjY2VCXrt/6M5gZXeuiqk9Dl/fSSVE3VFT+ywbIBOs+KFC9+N874u8xfT4TL1dX6pzfm71/KiIZGMxERSy//tCxMqACaTDb4MNC0Efja50kY0gXjaKH+h5blWWpO3motURFA8k9rK2ZyHv90Oq/8+8WKk5M/dsj8/VSIlQbLypd9pkpNSLWkfNoxrP8GJJXRBsX5eup8Nd/75ibyWuNEgAAN/mnaeojh2QEBOOE1lwFg+KsfMGKriIPlpSdOex7tcw5E3+C6hp1YbSQ82Jdz8umR/pDx0PhPc7SqgxENH4blDXI8z8oXtfcSdq1f26kJAFZri2//tCxNEACnDBd+YYa0lRl668xBVpMpQuZm4zpPTak0GAyH5eM1jryx85tQzjcDoMqb8pUstnjlHZabmPqYOTER0r/9vKvKEfTcF7fTbCP/+HWp5LTGF3DJDHL/uCMwABEACKKUYrkOq3lgWmMKhbwbfCxS8UTmgIWBuztFM8u7fUATFDvYRFnJDZH+l1Ciq9UZin/kU/0GUKXspfQhB83llrfKGsQvWsvpViY/+dl9n5MCEBIQAl//tCxM4ACgDBd+YYS0lIHK789Y1pJN2h3ixyuJ/VIeB/Q5RpAy48WPHB0gOVnrQhLH43cw4Ell0C6aKxm85Fe93esyzpeHG+o8su5grl1HrD1f9Po9u5Y62p7eN2KBzdK/vIEAAABpyW0S4h2xZRxMGUlwrI+mYHpIf2oUTLj4+Y4NvnZz97ze0zrW6p98EYsZbX3h3Bi9KytiwVb/uxGHgkRAF8/5FeFUuvt/7t7v1BiAAAAAht//tCxM4ACg0XZ4Yga4E6kG1xhI0luNsS4Y3CIaKxxtQ2Xt1sNEhTBIod5zmaVD649jNn3uSt9YlMMDe2NRodhU1IloegnRFYiRGMz73fO/zZ4DFzQQbDxl+tvXajgAAAAAC1xKkZU8FO00IBEUj9CA10RigRJQpQ3Sjyg+y90GaGhnWUQ7nxOJwdGjSTQIOBwBKPRxMeRQqLBZ7TKEvBsWz6yZb1CCI2KdzTNP9sZAAAAm47ddg5//tCxM+ACpDRaeyMa2lOBq29l5id4WIxWMKhhhLWkhv4/sThCVXfctpfKcvTxzD3I6htAmGTlQp5fP6d0nxM8sqaxqZ+xIRd1GokBgsoAEJ0LD0KMoECzLYpb03X67UxAAIgJxOS7CNkmVKrM4caV1nEMYY8Dr8yUXh01UqdXU7oUvgvhEaz45uSsGzv//mGki5w5y5U3/+Lu/YEwQW0WMHIoHwAyMA7g6F9nX12aGYAZCTqkt3F//tAxMyACcRba6w8xwk7mi49hY1sUq0ddKPSJY9LTCoOrjc+diJurl84m/tA8ECNMQAf8aDf7OmAVm22TRg0Y4gRsvutHUHSyoRicx8xZ3X1b673etd99P/T7frpEAEAEhNy0YKVET5EVndNQClgPjEVsJ/2XcTwcbjUZM39fyIi/8kcp/djhNFM8uHOoOY06LiUOCVh6xIWWYEVS1NFzNy2DAqsH7os3W3TV6MkAAEgEtotyhT/+0LEzoAJ/DtjjOGGwUyaLv2EDWzIQxUromlZUUFqWMbk6cDKHqTmO8B6t0YqJ0Y7aqTsERekp2zLMiknSzf/TLOvzzT/Hz7yZ81BqhEef/EehkUVjDdheftr6qCgAAACSU5BDQoVESlLzyseNIpdZOhLeAa2h3hvOe55yqaJlaSTM2dLzLN6dGOCxpQaqp5E0FG9EjZ/IgFMseBU2xzGIQGqEas6wVuWpHf1UYiKoARCSbcu/DP/+0LEzgAJ7Mt17KxrYUKKbv2TDOWwZORFwUAus0jOc589CmOL3UrpuJhGdXbyv3/voZ5GZoK/OFNfil1Py7lZPnVyqzMFXIz4aGqqKweADQCDdhAaVECRE0eAj/Qf6oElIAAQBNJ2SjRVcr+PJoL9Erm6Dwl8LC1BwVmC78QUpCnVCKT8ibUxJykyOfDlpm+W+tly7w9Y22Xn2wmOyxGOxvPPsztUc4bFy/VFCrPjewl6iAAAAAT/+0LEzwAKAI1prDxpQUQgLf2ijXSnJBHkJ7CmSv02lMLth0WiVAIwk8FKSN33Uzppy5H6lDBpxIwGT0F14o/T6oSmwagJRtAMioaKoAJV0J0lqmPEVa7yP1qFV0aEO+vNIAAEjV3hjAQRMaotKn4HPgwa2KnAPCwUJ771nPzlnMmc1/+pavVCMZu53+tm9NujsZ/Vf1vUq+ullU3W7YYwaoLVUdurNIAAAgt1hNxBhvVTpNGcZGX/+0LEz4AKHI9lrDBpQVCdLr2ijWwyCIwQlygKy/Oc3Ne6k8xFd3Zv12RDPCXr1qK1sP3B2tKgEIVXDGEoRAQtofkm76B5A8tTbXoi6O2iAASC1+KoDjyqG23ZDjJGHReIunJm5CojEQ/OJq2367tT/Xx5XkdzqzvX9hQ58yLIqdEz3b1R+eTvumR1VDWRiHHd1EgMzMBBr51B4xaYFwvrkABuGwhmIAACRLSbcBtSly6in9IcLzj/+0LEzgAKUN1v7CRrYUIKLDWMmOARLKrB2oIWErf7faLXC8KSUf9i1nI44odAbtqRO/Z2Vy1kTd8lWQ9XnITKrPmEHIUWQdZ/P/+u9Ve98Ct9WxyRGpO7l2QQAAAlHLbaJdGCEiLcAoIITL7rz4TRGmsZRgijnt7P3vr3UPLKCRkHDhkbvXyDwYeEcEWuhUdw7GaU4ot4UyiU6V1+6W+Z/dgDCQIBgEKHt5vpR+6dCAAAjSScu4n/+0LEzYAIuRV3h6hLsSUOrnD1iSZdRTIJ3rrBLDPxVaTR9cdiQpHZ3XYhhUR8/RJu5GzmOF9+F40PuRZQj3kMmButO8v3+0tjNKZMHB85cPIiQQJPA8jht6OxGFCiuqqZVAEBAAABUv4geWL3NyDFqtOLKUlC6sKxEJPoBB9cMTap7zu7e1X+EogX7F1DlXeF2Ry7leMjVz7CL/T06VO9JX9czzypjmpQw9EV+n1iIAABACTlEif/+0DE1wAK3OtxjDxLcVidLv2ECW9FBAdxMlazBBcO/ZFwbeY11egRCyhNpr1BSfEbNo0wd5j/+1d0CuGjzerZONYCYWGvQh6H/X9BrtQ0c+iIQxIhAAQBbEzi5ixDLfFptl4wAIa+AW6FiYRCW6o+PjGInIMacPn/HiG/PnEtXan9+PY4iBPvTvse7413ruFNUivWVFQ4i3h+3vLWeeZLhZVwvTTHmL/tkokAEEp3ARgaNgiBTP/7QsTRAAqo43HsJGthUxkuvZMNbCLOH2xQGY+G6p1AKqlCdIKvssdlgnfD7PtnAcdOXXUTLRnq//pqJiu/7+//9vF5/Ubv8v/ocGBPQ2Vroxt3hJqKowj2i9Om5oc0IAEhAElOXBFptEhjcaJFL3VNskxriBfo0SPtwn3gFN8neb8jnOTvIFKt//9VIVDuJTpmleX9znGSaZSy3/+5jP8oOG6rP7/6ue9RTvv1ar/aiCSACSU7g//7QsTNAAmw52/smGtBFhMutYOJKm68pQG2c6jUULe4Y03dVN8veX3AOB9D1ol/f85RvV18W8E25tP/ZFQrEKlVZn/+10JM+8/b9UdqTnZ19mVqXcQI6PtYuiyX64tK+qIKIABKTlDE1Kk+DrQFYElWLOpuVjSnoMKIOtQWIwikhqdP1KqT+pSo53Zv/mRQjjBRQ5kr70WBgYBiTQG9zmXeS9G3xF0/YwAiAAQCk4IHgskcMSmKMP/7QsTUgArU9XfNnKu5WZOutbQVKwYrL67ObmIHEyZNicapATTeSxhtyQxnX63VR0b/9idKf9//9+yxSfhiUeNTP/jUyIo51TwUCw5g2Q6m/16bqZRDEBIREFEsJIgKAwyGnHGhtqwaAZ8MFJBKhxoegNCEIRZCzmT9r2tlhxwpwkfPIoa0MBsTXCTYxSVDXAJVz1Ig88eZ3MHd4ZHKbzaFIaj0kAAAAUU3cAbZAiVHIiyIOEKhdP/7QsTPAAo4nXntIElZSKRu9YQJs4syrMSCapAAkUlpkT1iZG926nkIqnuzmQrNf/sRgdEo8g6X1f+u/Fihnuv++/Zu6eVGmfxn+rIgUVJUT45LZrCEMgAAAQBJKLgIhkDErXRUXlq+pS2LX2+Y3bZDIUauORluFRQ61+gyf/y8Jh4N+U7+OaBfluBPoneL8UKKZPP/1OL+Lf8b0794d081J8erzqXGhvjQAAABSblwUXC6E8mNxP/7QsTOAAj8oXetDElRPJtt9ZSNaqJP++0hnohPFU9Uw0JhCVezoi7MHP8//zpqfdX2TZrCO87/N3Uim5sVL/n+cUWAjgfagUNHSAeJ6VGCxXOY/hJ5m91HgzIBAAAAm5LuEjxmMDNlk0YeZkWUksyteIfoKfI4ZOyU//OFfzVTkOPimVf9s4X01GM2W8VebVya1CcyPCs1Lsbdzv/uXpzbD9d/myRga6/nX73f5EgAAAElNygY4//7QsTTgAnkc3nMDEkxT5UudPGJa9K2QHKQZj1KMlrbSEl47rhSadKmi8ICrdv/Q69+9leQZT203/ZTGnRrHMQp0IcYDAgAZw40+NHLSgmQcnf/yjHBZ23rJ4QzAQEAAJJuXAODjCoKLDANGlMWKjxdDOZFW3hWm/uOJ4YywSIFFf8kDKpn/r05EDEIjtCbCJg2LH2DTY1oSrdGLpC4q6ZF2BEUMfruKiO56eR/sQBIABWrE5hH0v/7QMTTAApkQ3vsMGb5S5ku9YQNag0FiU7F1L4j77aywMTKhyYcS6HFOjlL53V1+lG62M1f0f2VnWrIpUZFKDPGDyg28mWaGVhMy08GEjB6/Ziqzh56G+mtAAAAAIpNwOuBStzA+k7m7rH9kNTC/D/cSjmMFg+IONFw6EKW2iFsh/1JwP1xQFAEpf/9D5ujjVZ5fel/J/lmZRWhDPqOId0CKDLI7/vf/o/0SBCIAKTjmDNweRDu//tCxNCACoTLe+wMa1k7mC408wlqJQY0oawhnMZ0DTyOaC6FDUY2HhFxZZE4obuqdOzEUQensOFg6FhijCwGpxoNIzQoKg6MKqKIGmw1p/BgySekWi72yxCIEFEAAEttwSYDGdgWy4bYUDYzNWNWUlu6bEqq1SFMGV4IBGeZq/4pcj+77LzBC5l17mPUg53clz3bPfahvRVLTKIYwUEwIpQwianZp6I9DrndYt9RAJIABSTcoauC//tCxNAAClR3d+2YaRE1lq8xkYluJ1OmQwifWbZqxXHGnWYux8M4lbhQTOX5VpSzTdJVdDqi1Xv9plU2397XsmyJWsxymszzsqwoOd/+zzm6sfQom3RvJ8rZ+iWmRBEzMCJbduwgYq8TDTMbo/iEuCOzmVsi2gMzxanXQ80TKSKVKZfl03IwlQGhzymc18zmX/7efnXW5SmiyFo5JGY9zQjlLMzKzhfkmM27es/antRgyIREQACi//tCxNEACijtbawga1FDDq51gZUik3KJeQ1pSstiz0O1Dl+gGa7a7+r9RlWRH3LZis5s88nI3N1goX6nGHzdHYn0tEl4O9UFlCSK55xqFH+yi0pS3yNAfH+ZyRf92pph+vqFMhMiABCBTbgaeIWJcOs5cUWbCcX9JBbnzt1MzVeNd/aHiaWhG9g534rT9mngyyPwcwSNOd7nSLzq5ZR4G1auYTH+j+tx6S/rN3tr78t7//WaAIQA//tCxNEACnzva6wkS1FEni21kYlzBCcloXYvS9wCcqs8l6eAxza+OBfvr5h7S9X7h715zKSdcl2a6uocquba1zVKz3a8tiaM+IcisrMChyi+hRsDkEGmJGNFNCUps9fQAAAAVhFOZfECm35JE7pPI+PDvqmtR4O4SongDvIx8V3QVTHrH2pnUu/M51ZohVr+thcWYfcPLjS4BGImBVwXVhlyP7Sj7v/1xNlFEAEppuCXl00PHPFk//tCxM+ACokXbewMa6FPDqz9kw0lJtQ3OXqwFGP88+tj7IwCuEDhb5Ekocjh38qdP1QMRfC/fIjVYoWjQhWUcUXnHzCUI6iN36MalbXLT5fNoptAApNyULrOKQkWZsHDvs326W9S5DXKQUoReUYUUdnZ77lICn2zU88yv5uR3/J5HfciCBjWKiyo6Naafwqfci/93M/c0SXO47Rq9boeUppgRmV0PGp+pE22QClJLg7x5ytqAVFl//tCxMyAChyLZ+wYaWk4l6x08wloG0+In9NSWe5ZLktoO8iLczpc+MvPn1Jn/k3l/6IqlAYNJDZlf3y3OKU5euX1s62f/zVJc6dQ98jPJP4RdzYGR2OCxTesp60w00QAUm5A1IQEQTrFSZg+Py6rlVqmkS1SyEtTkB6D4ofCjVqeyUGpX++s/nAddH7x2bp/mZGWsspoY4lMjEdYlNPG3sEJBSQYNpAL3sRv5D1/rciTQAKbktB///tAxM4ACSRvWye9iQEnk621gw0qApR4r5cSlUSeOdD96esLRLnPwgtSPZT5uxxq/ovs7gnW7KzFYp0tNR6B36zq8xrTUfo9ciKYOgdl18a+Ut4Hm81ryZE4663//IVVQ0UxABNNOBdRyjIBk/XGUQCBMhSa7iSViQIACACh3+b/tr/r5NVUmS/CvDW+3ze4fhTEkJW+8T+xub0H8VBzNntD9/m9n2V7Ot7uS4Zp4M1QiMQABKX/+0LE1QAK8QNxrJRrkVYmLrWBjbPbgt4dB0rCnIhkZFMeVsbUckiVgzk4SJhtZaTzT5VVsUhnf7OV0s61f3pMSqtE2siO6TuRlN3bb1WzFK4NRrSiFMPoWBE07lEvf0f1xNtABNyW4FyOAUYuRbzhNB7CqLQjXuSmXsEZN2T6uZHfrO5HZ51o+n3UoImNB5yyPX6Ne/lxqu+4TTFnk9/X/ugWxn/5fxw537a7Ocp3CW0AACnLsGr/+0LEz4AKYLVtrCBrUUgdLnTxiWuMxbhLG1KhXbvkndHoD+W3t8WmAHMoDDawsulaDIImrxUFWPMgE5aAlgU4hD3iMJWCwDkHdrVZkyPPAMxc4BjK20sUszufr/bG2yAX3+CHK4fjOziEBKDriPI2pPBoUZGRG09aVl18vpGpTOtGIyyr9/XLf/h2d79nvmtTcksXJlzimCBUWHvcLGR5JDWCIqyixvcLfGkUUCrNUERh9ze0j9v/+0LEzgAKCC197KRkuUscrTz0iWhwiDJ7drPKncpFZRz4pRSPP76/5fTUv9Ksv3y7paeXPvXUj+XJd/1S7xb6q2VZUDg1GObuPh4T6/LEpO4Rz//9I5ZAASIAAJJNuAww/WVVJkIQ3lM0+aHuJvEO7JaTuKul2PI6QIymMsuGmdXdo8rf3pkyGRWeS91m9PN53+6GTfZ0oSqX/w5PKzzuao/kwJTSbLVPvK8MggAiAAEWonA2cRr/+0LEzYAJxJN3p4xJWT+IrLWDJNhUFj7IX2gZjsItWOhkIndFg4eys5pCLWww4a68N67N+TRikKuZSQs4brYuZ8VjX0ZVCLKd+Zv7qNdrtfMHudRqfbf5/21uf9PGmLmwAQAQU5LQhS6N8A9UI3RFQzVC6fyln7ZL2FDkimuFHvzkTIe5OOn2/c9L5sRoptneqxn3rMubsVPOrkuGxKNOLypJMwug8VZUztmtG51or+1sAEAAFNz/+0LEz4AJqO17h4RrcTsdrjGBjW9wNKfF4P8wVSJ6MUW5WvdbnjyPpAyQHwfQ3T7OcPNNOKKtJkGmUPIhwcfDUOmRI0OpeutEmkkdKOWJ1NCRIsLu2ev6c1P36oZDAREAAANtyAQIWA5SbMonyFqpPquI+Hz1DAAXbKKRZqlEHaUOHlkXVdXMy0qY7pNdSqjnV5St2Raz6shtzaHaxaf3T5eYW7kEBKRsdjLxZFAexsAAAABa4Qb/+0LE0oAKnSVh54xtoViYK/2BjW0XxKnM7EcSkuhbYr5qHZJsUPoqxC1SYUVYxnQzAixNrZ5SOahnqFc9QgJwm5702D3jFETkuAUFsAOWpSikSoF44XDNAy/6Eem2MAEACL/glx8BllxygCfKYFoMT1RaN5omP04poiK9I8tCKNT6VZifKA7w5i6a7nENsfpLi44sciqGd3qj3HgjZ1UgMDu/Xf7b/1/Pv/8uyKbBBCSckBJmopH/+0DEzgAKKMFbp5hrQTcIqzT3oNiEuRO0gT2PDRHBKEUSCPUtkc0I8vaF9XjfQxrNcEcK8r6Q/QOSe9NXe/s+T9Fuymqr83Kdaz7XFXNEnz342p9f0+dr+vAD8bccmLw+VaqcNaeYI6FokNIqRUHSeoXbTeKOzCJl7HqzFIWzUudQ+lctVYFfI2UURy9t/uXZJ2Z6njB0cLh8nRXMIC1mWVS1irnykaIIAAAqqANMtpKzQQBPDv/7QsTPAAplC13njEuhP4/p8PSNIGN6FDjWeIsNhAEtUFuGqqcesdvPUWRZs3mdlYzHJLJkq0E2a/jykWbdpPATyy5YoeCbDAwyKQyRqldL0h70xGt96SFxUMpCpsKgiDYGwlKmVta1xpcuSeZNBgESr0luSjMyxxJJQdBXEoKhosdLA0eEwNCUFfyoaq9R7ES3Q7EQNPlgaPYKuTywdaCoKgr/qadni0SIMCljpQugNkwpEQyHhv/7QsTOgAnIRU+HrMbJPo4qNPGNIcQCsSCUVCEaD4wRkBOaCQYSBCxBMgqpZ2dndnZ2uFpETjSizLQRJGnFlHmJpInGlFmWICaaQNb////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////7QsTQgAms3USnmGtBRpfosPGNaP/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////7QsTSAAmgWzIEsMcBKBZSQGSZaf///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////1RBR0hhdHMgSWRlYSAgICAgICAgICAgICAgICAgICAgIFNhbGVtICAgICAgICAgICAgICAgICAgICAgICAgIFNhYnJpbmEgVGhlIFRlZW5hZ2UgV2l0Y2ggICAgIDE5OTcgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA5
\ No newline at end of file diff --git a/toolkit/content/tests/browser/browser.toml b/toolkit/content/tests/browser/browser.toml new file mode 100644 index 0000000000..3f668b39d7 --- /dev/null +++ b/toolkit/content/tests/browser/browser.toml @@ -0,0 +1,201 @@ +[DEFAULT] +support-files = [ + "audio.ogg", + "empty.png", + "file_contentTitle.html", + "file_empty.html", + "file_iframe_media.html", + "file_findinframe.html", + "file_mediaPlayback2.html", + "file_multipleAudio.html", + "file_multiplePlayingAudio.html", + "file_nonAutoplayAudio.html", + "file_redirect.html", + "file_redirect_to.html", + "file_silentAudioTrack.html", + "file_webAudio.html", + "gizmo.mp4", + "head.js", + "image.jpg", + "image_page.html", + "silentAudioTrack.webm", +] + +["browser_about_logging.js"] +skip-if = ["tsan"] # Bug 1804081 + +["browser_about_networking.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked #category-dns may not be focusable +skip-if = ["socketprocess_networking"] + +["browser_autoscroll_disabled.js"] +skip-if = ["true"] # Bug 1312652 + +["browser_autoscroll_disabled_on_editable_content.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked browser may not be focusable + +["browser_autoscroll_disabled_on_links.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked browser may not be focusable + +["browser_bug295977_autoscroll_overflow.js"] +skip-if = [ + "os == 'win' && bits == 64 && (debug || asan)", + "os == 'linux' && bits == 64", # Bug 1710788 +] + +["browser_bug451286.js"] +skip-if = ["true"] # bug 1399845 tracks re-enabling this test. + +["browser_bug594509.js"] + +["browser_bug982298.js"] + +["browser_bug1170531.js"] +skip-if = ["os == 'linux' && !debug && !ccov"] # Bug 1647973 + +["browser_bug1198465.js"] + +["browser_bug1572798.js"] +tags = "audiochannel" +support-files = ["file_document_open_audio.html"] + +["browser_bug1693577.js"] + +["browser_cancel_starting_autoscrolling_requested_by_background_tab.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked tabbrowser-tabpanels may not be accessible + +["browser_charsetMenu_disable_on_ascii.js"] + +["browser_charsetMenu_swapBrowsers.js"] + +["browser_click_event_during_autoscrolling.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked browser may not be accessible + +["browser_contentTitle.js"] + +["browser_content_url_annotation.js"] +run-if = ["crashreporter"] + +["browser_crash_previous_frameloader.js"] +run-if = ["crashreporter"] + +["browser_default_audio_filename.js"] +support-files = ["audio_file.txt"] + +["browser_default_image_filename.js"] + +["browser_default_image_filename_redirect.js"] +support-files = [ + "doggy.png", + "firebird.png", + "firebird.png^headers^", +] + +["browser_delay_autoplay_cross_origin_iframe.js"] +tags = "audiochannel" + +["browser_delay_autoplay_cross_origin_navigation.js"] +tags = "audiochannel" + +["browser_delay_autoplay_media.js"] +tags = "audiochannel" + +["browser_delay_autoplay_media_pausedAfterPlay.js"] +tags = "audiochannel" + +["browser_delay_autoplay_multipleMedia.js"] +tags = "audiochannel" + +["browser_delay_autoplay_notInTreeAudio.js"] +tags = "audiochannel" + +["browser_delay_autoplay_playAfterTabVisible.js"] +tags = "audiochannel" + +["browser_delay_autoplay_playMediaInMuteTab.js"] +tags = "audiochannel" + +["browser_delay_autoplay_silentAudioTrack_media.js"] +tags = "audiochannel" +skip-if = [ + "os == 'mac'", # Bug 1524746 + "os == 'linux' && !debug", # Bug 1524746 +] + +["browser_delay_autoplay_webAudio.js"] +tags = "audiochannel" + +["browser_f7_caret_browsing.js"] + +["browser_findbar.js"] +skip-if = ["os == 'linux' && bits == 64 && os_version == '18.04'"] # Bug 1614739 + +["browser_findbar_disabled_manual.js"] + +["browser_findbar_hiddenframes.js"] + +["browser_findbar_marks.js"] + +["browser_isSynthetic.js"] + +["browser_keyevents_during_autoscrolling.js"] + +["browser_label_textlink.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked label#textlink-text may not be focusable +https_first_disabled = true + +["browser_license_links.js"] + +["browser_media_wakelock.js"] +support-files = [ + "browser_mediaStreamPlayback.html", + "browser_mediaStreamPlaybackWithoutAudio.html", + "file_video.html", + "file_videoWithAudioOnly.html", + "file_videoWithoutAudioTrack.html", + "gizmo.mp4", + "gizmo-noaudio.webm", +] +skip-if = ["apple_silicon"] # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + +["browser_media_wakelock_PIP.js"] +support-files = [ + "file_video.html", + "gizmo.mp4", +] + +["browser_media_wakelock_webaudio.js"] + +["browser_moz_support_link_open_links_in_chrome.js"] + +["browser_quickfind_editable.js"] +skip-if = ["verify && debug && os == 'linux'"] + +["browser_remoteness_change_listeners.js"] + +["browser_resume_bkg_video_on_tab_hover.js"] +skip-if = ["debug"] # Bug 1388959 + +["browser_richlistbox_keyboard.js"] + +["browser_saveImageURL.js"] + +["browser_save_folder_standalone_image.js"] +support-files = ["doggy.png"] + +["browser_save_resend_postdata.js"] +support-files = [ + "common/mockTransfer.js", + "data/post_form_inner.sjs", + "data/post_form_outer.sjs", +] +skip-if = ["true"] # Bug ?????? - test directly manipulates content (gBrowser.contentDocument.getElementById("postForm").submit();) + +["browser_starting_autoscroll_in_about_content.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked body.wide-container may not be accessible + +["browser_suspend_videos_outside_viewport.js"] +support-files = [ + "file_outside_viewport_videos.html", + "gizmo.mp4", +] diff --git a/toolkit/content/tests/browser/browser_about_logging.js b/toolkit/content/tests/browser/browser_about_logging.js new file mode 100644 index 0000000000..f458b36e0d --- /dev/null +++ b/toolkit/content/tests/browser/browser_about_logging.js @@ -0,0 +1,464 @@ +const PAGE = "about:logging"; + +function clearLoggingPrefs() { + for (let pref of Services.prefs.getBranch("logging.").getChildList("")) { + info(`Clearing: ${pref}`); + Services.prefs.clearUserPref("logging." + pref); + } +} + +// Before running, save any MOZ_LOG environment variable that might be preset, +// and restore them at the end of this test. +add_setup(async function saveRestoreLogModules() { + let savedLogModules = Services.env.get("MOZ_LOG"); + Services.env.set("MOZ_LOG", ""); + registerCleanupFunction(() => { + clearLoggingPrefs(); + info(" -- Restoring log modules: " + savedLogModules); + for (let pref of savedLogModules.split(",")) { + let [logModule, level] = pref.split(":"); + Services.prefs.setIntPref("logging." + logModule, parseInt(level)); + } + // Removing this line causes a sandboxxing error in nsTraceRefCnt.cpp (!). + Services.env.set("MOZ_LOG", savedLogModules); + }); +}); + +// Test that some UI elements are disabled in some cirumstances. +add_task(async function testElementsDisabled() { + // This test needs a MOZ_LOG env var set. + Services.env.set("MOZ_LOG", "example:4"); + await BrowserTestUtils.withNewTab(PAGE, async browser => { + await SpecialPowers.spawn(browser, [], async () => { + let $ = content.document.querySelector.bind(content.document); + Assert.ok( + $("#set-log-modules-button").disabled, + "Because a MOZ_LOG env var is set by the harness, it should be impossible to set new log modules." + ); + }); + }); + Services.env.set("MOZ_LOG", ""); + + await BrowserTestUtils.withNewTab( + PAGE + "?modules=example:5&output=profiler", + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + let $ = content.document.querySelector.bind(content.document); + Assert.ok( + !$("#some-elements-unavailable").hidden, + "If a log modules are configured via URL params, a warning should be visible." + ); + Assert.ok( + $("#set-log-modules-button").disabled, + "If a log modules are configured via URL params, some in-page elements should be disabled (button)." + ); + Assert.ok( + $("#log-modules").disabled, + "If a log modules are configured via URL params, some in-page elements should be disabled (input)." + ); + Assert.ok( + $("#logging-preset-dropdown").disabled, + "If a log modules are configured via URL params, some in-page elements should be disabled (dropdown)." + ); + Assert.ok( + $("#radio-logging-profiler").disabled && + $("#radio-logging-file").disabled, + "If the ouptut type is configured via URL param, the radio buttons should be disabled." + ); + }); + } + ); + await BrowserTestUtils.withNewTab( + PAGE + "?preset=media-playback", + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + let $ = content.document.querySelector.bind(content.document); + Assert.ok( + !$("#some-elements-unavailable").hidden, + "If a preset is selected via URL, a warning should be displayed." + ); + Assert.ok( + $("#set-log-modules-button").disabled, + "If a preset is selected via URL, some in-page elements should be disabled (button)." + ); + Assert.ok( + $("#log-modules").disabled, + "If a preset is selected via URL, some in-page elements should be disabled (input)." + ); + Assert.ok( + $("#logging-preset-dropdown").disabled, + "If a preset is selected via URL, some in-page elements should be disabled (dropdown)." + ); + }); + } + ); + clearLoggingPrefs(); +}); + +// Test URL parameters +const modulesInURL = "example:4,otherexample:5"; +const presetInURL = "media-playback"; +const threadsInURL = "example,otherexample"; +const profilerPresetInURL = "media"; +add_task(async function testURLParameters() { + await BrowserTestUtils.withNewTab( + PAGE + "?modules=" + modulesInURL, + async browser => { + await SpecialPowers.spawn(browser, [modulesInURL], async modulesInURL => { + let $ = content.document.querySelector.bind(content.document); + Assert.ok( + !$("#some-elements-unavailable").hidden, + "If modules are selected via URL, a warning should be displayed." + ); + var inPageSorted = $("#current-log-modules") + .innerText.split(",") + .sort() + .join(","); + var inURLSorted = modulesInURL.split(",").sort().join(","); + Assert.equal( + inPageSorted, + inURLSorted, + "When selecting modules via URL params, the same modules are reflected in the page." + ); + }); + } + ); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE + "?preset=" + presetInURL, + }, + async browser => { + await SpecialPowers.spawn(browser, [presetInURL], async presetInURL => { + let $ = content.document.querySelector.bind(content.document); + Assert.ok( + !$("#some-elements-unavailable").hidden, + "If a preset is selected via URL, a warning should be displayed." + ); + var inPageSorted = $("#current-log-modules") + .innerText.split(",") + .sort() + .join(","); + var presetSorted = content + .presets() + [presetInURL].modules.split(",") + .sort() + .join(","); + Assert.equal( + inPageSorted, + presetSorted, + "When selecting a preset via URL params, the correct log modules are reflected in the page." + ); + }); + } + ); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE + "?profiler-preset=" + profilerPresetInURL, + }, + async browser => { + await SpecialPowers.spawn(browser, [profilerPresetInURL], async inURL => { + let $ = content.document.querySelector.bind(content.document); + // Threads override doesn't have a UI element, the warning shouldn't + // be displayed. + Assert.ok( + $("#some-elements-unavailable").hidden, + "When overriding the profiler preset, no warning is displayed on the page." + ); + var inSettings = content.settings().profilerPreset; + Assert.equal( + inSettings, + inURL, + "When overriding the profiler preset via URL param, the correct preset is set in the logging manager settings." + ); + }); + } + ); + await BrowserTestUtils.withNewTab(PAGE + "?profilerstacks", async browser => { + await SpecialPowers.spawn(browser, [], async () => { + let $ = content.document.querySelector.bind(content.document); + Assert.ok( + !$("#some-elements-unavailable").hidden, + "If the profiler stacks config is set via URL, a warning should be displayed." + ); + Assert.ok( + $("#with-profiler-stacks-checkbox").disabled, + "If the profiler stacks config is set via URL, its checkbox should be disabled." + ); + + Assert.ok( + Services.prefs.getBoolPref("logging.config.profilerstacks"), + "The preference for profiler stacks is set initially, as a result of parsing the URL parameter" + ); + + $("#radio-logging-file").click(); + $("#radio-logging-profiler").click(); + + Assert.ok( + $("#with-profiler-stacks-checkbox").disabled, + "If the profiler stacks config is set via URL, its checkbox should be disabled even after clicking around." + ); + }); + }); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE + "?invalid-param", + }, + async browser => { + await SpecialPowers.spawn(browser, [profilerPresetInURL], async inURL => { + let $ = content.document.querySelector.bind(content.document); + Assert.ok( + !$("#error").hidden, + "When an invalid URL param is passed in, the page displays a warning." + ); + }); + } + ); + clearLoggingPrefs(); +}); + +// Test various things related to presets: that it's populated correctly, that +// setting presets work in terms of UI, but also that it sets the logging.* +// prefs correctly. +add_task(async function testAboutLoggingPresets() { + await BrowserTestUtils.withNewTab(PAGE, async browser => { + await SpecialPowers.spawn(browser, [], async () => { + let $ = content.document.querySelector.bind(content.document); + let presetsDropdown = $("#logging-preset-dropdown"); + Assert.equal( + Object.keys(content.presets()).length, + presetsDropdown.childNodes.length, + "Presets populated." + ); + + Assert.equal(presetsDropdown.value, "networking"); + $("#set-log-modules-button").click(); + Assert.ok( + $("#no-log-modules").hidden && !$("#current-log-modules").hidden, + "When log modules are set, they are visible." + ); + var lengthModuleListNetworking = $("#log-modules").value.length; + var lengthCurrentModuleListNetworking = $("#current-log-modules") + .innerText.length; + Assert.notEqual( + lengthModuleListNetworking, + 0, + "When setting a profiler preset, the module string is non-empty (input)." + ); + Assert.notEqual( + lengthCurrentModuleListNetworking, + 0, + "When setting a profiler preset, the module string is non-empty (selected modules)." + ); + + // Change preset + presetsDropdown.value = "media-playback"; + presetsDropdown.dispatchEvent(new content.Event("change")); + + // Check the following after "onchange". + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => content.setTimeout(resolve, 0)); + + Assert.equal( + presetsDropdown.value, + "media-playback", + "Selecting another preset is reflected in the page" + ); + $("#set-log-modules-button").click(); + Assert.ok( + $("#no-log-modules").hidden && !$("#current-log-modules").hidden, + "When other log modules are set, they are still visible" + ); + Assert.notEqual( + $("#log-modules").value.length, + 0, + "When setting a profiler preset, the module string is non-empty (input)." + ); + Assert.notEqual( + $("#current-log-modules").innerText.length, + 0, + "When setting a profiler preset, the module string is non-empty (selected modules)." + ); + Assert.notEqual( + $("#log-modules").value.length, + lengthModuleListNetworking, + "When setting another profiler preset, the module string changes (input)." + ); + let currentLogModulesString = $("#current-log-modules").innerText; + Assert.notEqual( + currentLogModulesString.length, + lengthCurrentModuleListNetworking, + + "When setting another profiler preset, the module string changes (selected modules)." + ); + + // After setting some log modules via the preset dropdown, verify + // that they have been reflected to logging.* preferences. + var activeLogModules = []; + let children = Services.prefs.getBranch("logging.").getChildList(""); + for (let pref of children) { + if (pref.startsWith("config.")) { + continue; + } + + try { + let value = Services.prefs.getIntPref(`logging.${pref}`); + activeLogModules.push(`${pref}:${value}`); + } catch (e) { + console.error(e); + } + } + let mod; + while ((mod = activeLogModules.pop())) { + Assert.ok( + currentLogModulesString.includes(mod), + `${mod} was effectively set` + ); + } + }); + }); + clearLoggingPrefs(); +}); + +// Test various things around the profiler stacks feature +add_task(async function testProfilerStacks() { + // Check the initial state before changing anything. + Assert.ok( + !Services.prefs.getBoolPref("logging.config.profilerstacks", false), + "The preference for profiler stacks isn't set initially" + ); + await BrowserTestUtils.withNewTab(PAGE, async browser => { + await SpecialPowers.spawn(browser, [], async () => { + let $ = content.document.querySelector.bind(content.document); + const checkbox = $("#with-profiler-stacks-checkbox"); + Assert.ok( + !checkbox.checked, + "The profiler stacks checkbox isn't checked at load time." + ); + checkbox.checked = true; + checkbox.dispatchEvent(new content.Event("change")); + Assert.ok( + Services.prefs.getBoolPref("logging.config.profilerstacks"), + "The preference for profiler stacks is now set to true" + ); + checkbox.checked = false; + checkbox.dispatchEvent(new content.Event("change")); + Assert.ok( + !Services.prefs.getBoolPref("logging.config.profilerstacks"), + "The preference for profiler stacks is now back to false" + ); + + $("#radio-logging-file").click(); + Assert.ok( + checkbox.disabled, + "The profiler stacks checkbox is disabled when the output type is 'file'" + ); + $("#radio-logging-profiler").click(); + Assert.ok( + !checkbox.disabled, + "The profiler stacks checkbox is enabled when the output type is 'profiler'" + ); + }); + }); + clearLoggingPrefs(); +}); + +// Here we test that starting and stopping log collection to the Firefox +// Profiler opens a new tab. We don't actually check the content of the profile. +add_task(async function testProfilerOpens() { + await BrowserTestUtils.withNewTab(PAGE, async browser => { + let profilerOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "https://example.com/", + false + ); + SpecialPowers.spawn(browser, [], async savedLogModules => { + let $ = content.document.querySelector.bind(content.document); + // Override the URL the profiler uses to avoid hitting external + // resources (and crash). + await SpecialPowers.pushPrefEnv({ + set: [ + ["devtools.performance.recording.ui-base-url", "https://example.com"], + ["devtools.performance.recording.ui-base-url-path", "/"], + ], + }); + $("#radio-logging-file").click(); + $("#radio-logging-profiler").click(); + $("#logging-preset-dropdown").value = "networking"; + $("#logging-preset-dropdown").dispatchEvent(new content.Event("change")); + $("#set-log-modules-button").click(); + $("#toggle-logging-button").click(); + // Wait for the profiler to start. This can be very slow. + await content.profilerPromise(); + + // Wait for some time for good measure while the profiler collects some + // data. We don't really care about the data itself. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => content.setTimeout(resolve, 1000)); + $("#toggle-logging-button").click(); + }); + let tab = await profilerOpenedPromise; + Assert.ok(true, "Profiler tab opened after profiling"); + await BrowserTestUtils.removeTab(tab); + }); + clearLoggingPrefs(); +}); + +// Same test, outputing to a file, with network logging, while opening and +// closing a tab. We only check that the file exists and has a non-zero size. +add_task(async function testLogFileFound() { + await BrowserTestUtils.withNewTab(PAGE, async browser => { + await SpecialPowers.spawn(browser, [], async () => { + // Clear any previous log file. + let $ = content.document.querySelector.bind(content.document); + $("#radio-logging-file").click(); + $("#log-file").value = ""; + $("#log-file").dispatchEvent(new content.Event("change")); + $("#set-log-file-button").click(); + + Assert.ok( + !$("#no-log-file").hidden, + "When a log file hasn't been set, it's indicated as such." + ); + }); + }); + await BrowserTestUtils.withNewTab(PAGE, async browser => { + let logPath = await SpecialPowers.spawn(browser, [], async () => { + let $ = content.document.querySelector.bind(content.document); + $("#radio-logging-file").click(); + // Set the log file (use the default path) + $("#set-log-file-button").click(); + var logPath = $("#current-log-file").innerText; + // Set log modules for networking + $("#logging-preset-dropdown").value = "networking"; + $("#logging-preset-dropdown").dispatchEvent(new content.Event("change")); + $("#set-log-modules-button").click(); + return logPath; + }); + + // No need to start or stop logging when logging to a file. Just open + // a tab, any URL will do. Wait for this tab to be loaded so we're sure + // something (anything) has happened in necko. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com", + true /* waitForLoad */ + ); + await BrowserTestUtils.removeTab(tab); + let logDirectory = PathUtils.parent(logPath); + let logBasename = PathUtils.filename(logPath); + let entries = await IOUtils.getChildren(logDirectory); + let foundNonEmptyLogFile = false; + for (let entry of entries) { + if (entry.includes(logBasename)) { + info("-- Log file found: " + entry); + let fileinfo = await IOUtils.stat(entry); + foundNonEmptyLogFile |= fileinfo.size > 0; + } + } + Assert.ok(foundNonEmptyLogFile, "Found at least one non-empty log file."); + }); + clearLoggingPrefs(); +}); diff --git a/toolkit/content/tests/browser/browser_about_networking.js b/toolkit/content/tests/browser/browser_about_networking.js new file mode 100644 index 0000000000..bab285904c --- /dev/null +++ b/toolkit/content/tests/browser/browser_about_networking.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_first() { + registerCleanupFunction(() => { + // Must clear mode first, otherwise we'll have non-local connections to + // the cloudflare URL. + Services.prefs.clearUserPref("network.trr.mode"); + Services.prefs.clearUserPref("network.trr.uri"); + }); + + await BrowserTestUtils.withNewTab( + "about:networking#dns", + async function (browser) { + ok(!browser.isRemoteBrowser, "Browser should not be remote."); + await ContentTask.spawn(browser, null, async function () { + let url_tbody = content.document.getElementById("dns_trr_url"); + info(url_tbody); + is( + url_tbody.children[0].children[0].textContent, + "https://mozilla.cloudflare-dns.com/dns-query" + ); + is(url_tbody.children[0].children[1].textContent, "0"); + }); + } + ); + + Services.prefs.setCharPref("network.trr.uri", "https://localhost/testytest"); + Services.prefs.setIntPref("network.trr.mode", 2); + await BrowserTestUtils.withNewTab( + "about:networking#dns", + async function (browser) { + ok(!browser.isRemoteBrowser, "Browser should not be remote."); + await ContentTask.spawn(browser, null, async function () { + let url_tbody = content.document.getElementById("dns_trr_url"); + info(url_tbody); + is( + url_tbody.children[0].children[0].textContent, + "https://localhost/testytest" + ); + is(url_tbody.children[0].children[1].textContent, "2"); + }); + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_autoscroll_disabled.js b/toolkit/content/tests/browser/browser_autoscroll_disabled.js new file mode 100644 index 0000000000..6ab58a7c48 --- /dev/null +++ b/toolkit/content/tests/browser/browser_autoscroll_disabled.js @@ -0,0 +1,82 @@ +add_task(async function () { + const kPrefName_AutoScroll = "general.autoScroll"; + Services.prefs.setBoolPref(kPrefName_AutoScroll, false); + + let dataUri = + 'data:text/html,<html><body id="i" style="overflow-y: scroll"><div style="height: 2000px"></div>\ + <iframe id="iframe" style="display: none;"></iframe>\ +</body></html>'; + + let loadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser, dataUri); + await loadedPromise; + + await BrowserTestUtils.synthesizeMouse( + "#i", + 50, + 50, + { button: 1 }, + gBrowser.selectedBrowser + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + var iframe = content.document.getElementById("iframe"); + + if (iframe) { + var e = new iframe.contentWindow.PageTransitionEvent("pagehide", { + bubbles: true, + cancelable: true, + persisted: false, + }); + iframe.contentDocument.dispatchEvent(e); + iframe.contentDocument.documentElement.dispatchEvent(e); + } + }); + + await BrowserTestUtils.synthesizeMouse( + "#i", + 100, + 100, + { type: "mousemove", clickCount: "0" }, + gBrowser.selectedBrowser + ); + + // If scrolling didn't work, we wouldn't do any redraws and thus time out, so + // request and force redraws to get the chance to check for scrolling at all. + await new Promise(resolve => window.requestAnimationFrame(resolve)); + + let msg = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + // Skip the first animation frame callback as it's the same callback that + // the browser uses to kick off the scrolling. + return new Promise(resolve => { + function checkScroll() { + let msg = ""; + let elem = content.document.getElementById("i"); + if (elem.scrollTop != 0) { + msg += "element should not have scrolled vertically"; + } + if (elem.scrollLeft != 0) { + msg += "element should not have scrolled horizontally"; + } + + resolve(msg); + } + + content.requestAnimationFrame(checkScroll); + }); + } + ); + + ok(!msg, "element scroll " + msg); + + // restore the changed prefs + if (Services.prefs.prefHasUserValue(kPrefName_AutoScroll)) { + Services.prefs.clearUserPref(kPrefName_AutoScroll); + } + + // wait for focus to fix a failure in the next test if the latter runs too soon. + await SimpleTest.promiseFocus(); +}); diff --git a/toolkit/content/tests/browser/browser_autoscroll_disabled_on_editable_content.js b/toolkit/content/tests/browser/browser_autoscroll_disabled_on_editable_content.js new file mode 100644 index 0000000000..6e66644bc9 --- /dev/null +++ b/toolkit/content/tests/browser/browser_autoscroll_disabled_on_editable_content.js @@ -0,0 +1,306 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["general.autoScroll", true], + ["middlemouse.paste", true], + ["middlemouse.contentLoadURL", false], + ["test.events.async.enabled", false], + ], + }); + + let autoScroller; + function onPopupShown(aEvent) { + if (aEvent.originalTarget.id != "autoscroller") { + return false; + } + autoScroller = aEvent.originalTarget; + return true; + } + window.addEventListener("popupshown", onPopupShown, { capture: true }); + registerCleanupFunction(() => { + window.removeEventListener("popupshown", onPopupShown, { capture: true }); + }); + function popupIsNotClosed() { + return autoScroller && autoScroller.state != "closed"; + } + + async function promiseNativeMouseMiddleButtonDown(aBrowser) { + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: aBrowser, + atCenter: true, + }); + return EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + target: aBrowser, + atCenter: true, + button: 1, // middle button + }); + } + async function promiseNativeMouseMiddleButtonUp(aBrowser) { + return EventUtils.promiseNativeMouseEvent({ + type: "mouseup", + target: aBrowser, + atCenter: true, + button: 1, // middle button + }); + } + function promiseWaitForAutoScrollerClosed() { + if (!autoScroller || autoScroller.state == "closed") { + return Promise.resolve(); + } + let result = BrowserTestUtils.waitForEvent( + autoScroller, + "popuphidden", + { capture: true }, + () => { + return true; + } + ); + EventUtils.synthesizeKey("KEY_Escape"); + return result; + } + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + await SpecialPowers.spawn(browser, [], () => { + content.document.body.innerHTML = + '<div contenteditable style="height: 10000px;"></div>'; + content.document.documentElement.scrollTop = 500; + content.document.documentElement.scrollTop; // Flush layout. + }); + await promiseNativeMouseMiddleButtonDown(browser); + try { + await TestUtils.waitForCondition( + popupIsNotClosed, + "Wait for timeout of popup", + 100, + 10 + ); + ok( + false, + "Autoscroll shouldn't be started on editable <div> if middle paste is enabled" + ); + } catch (e) { + ok( + typeof e == "string" && e.includes(" - timed out after 10 tries."), + `Autoscroll shouldn't be started on editable <div> if middle paste is enabled (${ + typeof e == "string" ? e : e.message + })` + ); + } finally { + await promiseNativeMouseMiddleButtonUp(browser); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + await waitForAutoScrollEnd; + } + } + ); + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + await SpecialPowers.spawn(browser, [], () => { + content.document.body.innerHTML = + '<div style="height: 10000px;"></div>'; + content.document.designMode = "on"; + content.document.documentElement.scrollTop = 500; + content.document.documentElement.scrollTop; // Flush layout. + }); + await promiseNativeMouseMiddleButtonDown(browser); + try { + await TestUtils.waitForCondition( + popupIsNotClosed, + "Wait for timeout of popup", + 100, + 10 + ); + ok( + false, + "Autoscroll shouldn't be started in document whose designMode is 'on' if middle paste is enabled" + ); + } catch (e) { + ok( + typeof e == "string" && e.includes(" - timed out after 10 tries."), + `Autoscroll shouldn't be started in document whose designMode is 'on' if middle paste is enabled (${ + typeof e == "string" ? e : e.message + })` + ); + } finally { + await promiseNativeMouseMiddleButtonUp(browser); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + await waitForAutoScrollEnd; + } + } + ); + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + await SpecialPowers.spawn(browser, [], () => { + content.document.body.innerHTML = + '<div contenteditable style="height: 10000px;"><div contenteditable="false" style="height: 10000px;"></div></div>'; + content.document.documentElement.scrollTop = 500; + content.document.documentElement.scrollTop; // Flush layout. + }); + await promiseNativeMouseMiddleButtonDown(browser); + try { + await BrowserTestUtils.waitForEvent( + window, + "popupshown", + { capture: true }, + onPopupShown + ); + ok( + true, + "Auto scroll should be started on non-editable <div> in an editing host if middle paste is enabled" + ); + } finally { + await promiseNativeMouseMiddleButtonUp(browser); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + await waitForAutoScrollEnd; + } + } + ); + + await SpecialPowers.pushPrefEnv({ + set: [["middlemouse.paste", false]], + }); + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + await SpecialPowers.spawn(browser, [], () => { + content.document.body.innerHTML = + '<div contenteditable style="height: 10000px;"></div>'; + content.document.documentElement.scrollTop = 500; + content.document.documentElement.scrollTop; // Flush layout. + }); + await promiseNativeMouseMiddleButtonDown(browser); + try { + await BrowserTestUtils.waitForEvent( + window, + "popupshown", + { capture: true }, + onPopupShown + ); + ok( + true, + "Auto scroll should be started on editable <div> if middle paste is disabled" + ); + } finally { + await promiseNativeMouseMiddleButtonUp(browser); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + await waitForAutoScrollEnd; + } + } + ); + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + await SpecialPowers.spawn(browser, [], () => { + content.document.body.innerHTML = + '<div style="height: 10000px;"></div>'; + content.document.designMode = "on"; + content.document.documentElement.scrollTop = 500; + content.document.documentElement.scrollTop; // Flush layout. + }); + await promiseNativeMouseMiddleButtonDown(browser); + try { + await BrowserTestUtils.waitForEvent( + window, + "popupshown", + { capture: true }, + onPopupShown + ); + ok( + true, + "Auto scroll should be started in document whose designMode is 'on' if middle paste is disabled" + ); + } finally { + await promiseNativeMouseMiddleButtonUp(browser); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + await waitForAutoScrollEnd; + } + } + ); + + await SpecialPowers.pushPrefEnv({ + set: [["middlemouse.paste", false]], + }); + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + await SpecialPowers.spawn(browser, [], () => { + content.document.body.innerHTML = + '<input style="height: 10000px; width: 10000px;">'; + content.document.documentElement.scrollTop = 500; + content.document.documentElement.scrollTop; // Flush layout. + }); + await promiseNativeMouseMiddleButtonDown(browser); + try { + await BrowserTestUtils.waitForEvent( + window, + "popupshown", + { capture: true }, + onPopupShown + ); + ok( + true, + "Auto scroll should be started on <input> if middle paste is disabled" + ); + } finally { + await promiseNativeMouseMiddleButtonUp(browser); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + await waitForAutoScrollEnd; + } + } + ); + + await SpecialPowers.pushPrefEnv({ + set: [["middlemouse.paste", true]], + }); + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + await SpecialPowers.spawn(browser, [], () => { + content.document.body.innerHTML = + '<input style="height: 10000px; width: 10000px;">'; + content.document.documentElement.scrollTop = 500; + content.document.documentElement.scrollTop; // Flush layout. + }); + await promiseNativeMouseMiddleButtonDown(browser); + try { + await TestUtils.waitForCondition( + popupIsNotClosed, + "Wait for timeout of popup", + 100, + 10 + ); + ok( + false, + "Autoscroll shouldn't be started on <input> if middle paste is enabled" + ); + } catch (e) { + ok( + typeof e == "string" && e.includes(" - timed out after 10 tries."), + `Autoscroll shouldn't be started on <input> if middle paste is enabled (${ + typeof e == "string" ? e : e.message + })` + ); + } finally { + await promiseNativeMouseMiddleButtonUp(browser); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + await waitForAutoScrollEnd; + } + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_autoscroll_disabled_on_links.js b/toolkit/content/tests/browser/browser_autoscroll_disabled_on_links.js new file mode 100644 index 0000000000..c1c89e3799 --- /dev/null +++ b/toolkit/content/tests/browser/browser_autoscroll_disabled_on_links.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_autoscroll_links() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["general.autoScroll", true], + ["middlemouse.contentLoadURL", false], + ["test.events.async.enabled", false], + ], + }); + + let autoScroller; + function onPopupShown(aEvent) { + if (aEvent.originalTarget.id != "autoscroller") { + return false; + } + autoScroller = aEvent.originalTarget; + return true; + } + window.addEventListener("popupshown", onPopupShown, { capture: true }); + registerCleanupFunction(() => { + window.removeEventListener("popupshown", onPopupShown, { capture: true }); + }); + function popupIsNotClosed() { + return autoScroller && autoScroller.state != "closed"; + } + + async function promiseNativeMouseMiddleButtonDown(aBrowser) { + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: aBrowser, + atCenter: true, + }); + return EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + target: aBrowser, + atCenter: true, + button: 1, // middle button + }); + } + async function promiseNativeMouseMiddleButtonUp(aBrowser) { + return EventUtils.promiseNativeMouseEvent({ + type: "mouseup", + target: aBrowser, + atCenter: true, + button: 1, // middle button + }); + } + function promiseWaitForAutoScrollerClosed() { + if (!autoScroller || autoScroller.state == "closed") { + return Promise.resolve(); + } + let result = BrowserTestUtils.waitForEvent( + autoScroller, + "popuphidden", + { capture: true }, + () => { + return true; + } + ); + EventUtils.synthesizeKey("KEY_Escape"); + return result; + } + + async function testMarkup(markup) { + return BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + await SpecialPowers.spawn(browser, [markup], html => { + content.document.body.innerHTML = html; + content.document.documentElement.scrollTop = 1; + content.document.documentElement.scrollTop; // Flush layout. + }); + await promiseNativeMouseMiddleButtonDown(browser); + try { + await TestUtils.waitForCondition( + popupIsNotClosed, + "Wait for timeout of popup", + 100, + 10 + ); + ok(false, "Autoscroll shouldn't be started on " + markup); + } catch (e) { + ok( + typeof e == "string" && e.includes(" - timed out after 10 tries."), + `Autoscroll shouldn't be started on ${markup} (${ + typeof e == "string" ? e : e.message + })` + ); + } finally { + await promiseNativeMouseMiddleButtonUp(browser); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + await waitForAutoScrollEnd; + } + } + ); + } + + await testMarkup( + '<a href="https://example.com/" style="display: block; position: absolute; height:100%; width:100%; background: aqua">Click me</a>' + ); + + await testMarkup(` + <svg viewbox="0 0 100 100" style="display: block; height: 100%; width: 100%;"> + <a href="https://example.com/"> + <rect height=100 width=100 fill=blue /> + </a> + </svg>`); + + await testMarkup(` + <a href="https://example.com/"> + <svg viewbox="0 0 100 100" style="display: block; height: 100%; width: 100%;"> + <use href="#x"/> + </svg> + </a> + + <svg viewbox="0 0 100 100" style="display: none"> + <rect id="x" height=100 width=100 fill=green /> + </svg> + `); +}); diff --git a/toolkit/content/tests/browser/browser_bug1170531.js b/toolkit/content/tests/browser/browser_bug1170531.js new file mode 100644 index 0000000000..0d471890c3 --- /dev/null +++ b/toolkit/content/tests/browser/browser_bug1170531.js @@ -0,0 +1,139 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +// Test for bug 1170531 +// https://bugzilla.mozilla.org/show_bug.cgi?id=1170531 + +add_task(async function () { + // Get a bunch of DOM nodes + let editMenu = document.getElementById("edit-menu"); + let menuPopup = editMenu.menupopup; + + let closeMenu = function (aCallback) { + if (Services.appinfo.OS == "Darwin") { + executeSoon(aCallback); + return; + } + + menuPopup.addEventListener( + "popuphidden", + function () { + executeSoon(aCallback); + }, + { once: true } + ); + + executeSoon(function () { + editMenu.open = false; + }); + }; + + let openMenu = function (aCallback) { + if (Services.appinfo.OS == "Darwin") { + goUpdateGlobalEditMenuItems(); + // On OSX, we have a native menu, so it has to be updated. In single process browsers, + // this happens synchronously, but in e10s, we have to wait for the main thread + // to deal with it for us. 1 second should be plenty of time. + setTimeout(aCallback, 1000); + return; + } + + menuPopup.addEventListener( + "popupshown", + function () { + executeSoon(aCallback); + }, + { once: true } + ); + + executeSoon(function () { + editMenu.open = true; + }); + }; + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function (browser) { + let menu_cut_disabled, menu_copy_disabled; + + BrowserTestUtils.startLoadingURIString( + browser, + "data:text/html,<div>hello!</div>" + ); + await BrowserTestUtils.browserLoaded(browser); + browser.focus(); + await new Promise(resolve => waitForFocus(resolve, window)); + await new Promise(resolve => + window.requestAnimationFrame(() => executeSoon(resolve)) + ); + await new Promise(openMenu); + menu_cut_disabled = + menuPopup.querySelector("#menu_cut").getAttribute("disabled") == "true"; + is(menu_cut_disabled, false, "menu_cut should be enabled"); + menu_copy_disabled = + menuPopup.querySelector("#menu_copy").getAttribute("disabled") == + "true"; + is(menu_copy_disabled, false, "menu_copy should be enabled"); + await new Promise(closeMenu); + + // When there is no text selected in the contentEditable, we expect the Cut + // and Copy commands to be disabled. + BrowserTestUtils.startLoadingURIString( + browser, + "data:text/html,<div contentEditable='true'>hello!</div>" + ); + await BrowserTestUtils.browserLoaded(browser); + browser.focus(); + await new Promise(resolve => waitForFocus(resolve, window)); + await new Promise(resolve => + window.requestAnimationFrame(() => executeSoon(resolve)) + ); + await new Promise(openMenu); + menu_cut_disabled = + menuPopup.querySelector("#menu_cut").getAttribute("disabled") == "true"; + is(menu_cut_disabled, true, "menu_cut should be disabled"); + menu_copy_disabled = + menuPopup.querySelector("#menu_copy").getAttribute("disabled") == + "true"; + is(menu_copy_disabled, true, "menu_copy should be disabled"); + await new Promise(closeMenu); + + // When the text of the contentEditable is selected, the Cut and Copy commands + // should be enabled. + BrowserTestUtils.startLoadingURIString( + browser, + "data:text/html,<div contentEditable='true'>hello!</div><script>r=new Range;r.selectNodeContents(document.body.firstChild);document.getSelection().addRange(r);</script>" + ); + await BrowserTestUtils.browserLoaded(browser); + browser.focus(); + await new Promise(resolve => waitForFocus(resolve, window)); + await new Promise(resolve => + window.requestAnimationFrame(() => executeSoon(resolve)) + ); + await new Promise(openMenu); + menu_cut_disabled = + menuPopup.querySelector("#menu_cut").getAttribute("disabled") == "true"; + is(menu_cut_disabled, false, "menu_cut should be enabled"); + menu_copy_disabled = + menuPopup.querySelector("#menu_copy").getAttribute("disabled") == + "true"; + is(menu_copy_disabled, false, "menu_copy should be enabled"); + await new Promise(closeMenu); + + BrowserTestUtils.startLoadingURIString(browser, "about:preferences"); + await BrowserTestUtils.browserLoaded(browser); + browser.focus(); + await new Promise(resolve => waitForFocus(resolve, window)); + await new Promise(resolve => + window.requestAnimationFrame(() => executeSoon(resolve)) + ); + await new Promise(openMenu); + menu_cut_disabled = + menuPopup.querySelector("#menu_cut").getAttribute("disabled") == "true"; + is(menu_cut_disabled, true, "menu_cut should be disabled"); + menu_copy_disabled = + menuPopup.querySelector("#menu_copy").getAttribute("disabled") == + "true"; + is(menu_copy_disabled, true, "menu_copy should be disabled"); + await new Promise(closeMenu); + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_bug1198465.js b/toolkit/content/tests/browser/browser_bug1198465.js new file mode 100644 index 0000000000..52a3705ac4 --- /dev/null +++ b/toolkit/content/tests/browser/browser_bug1198465.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var kPrefName = "accessibility.typeaheadfind.prefillwithselection"; +var kEmptyURI = "data:text/html,"; + +// This pref is false by default in OSX; ensure the test still works there. +Services.prefs.setBoolPref(kPrefName, true); + +registerCleanupFunction(function () { + Services.prefs.clearUserPref(kPrefName); +}); + +add_task(async function () { + let aTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, kEmptyURI); + ok(!gFindBarInitialized, "findbar isn't initialized yet"); + + // Note: the use case here is when the user types directly in the findbar + // _before_ it's prefilled with a text selection in the page. + + // So `yield BrowserTestUtils.sendChar()` can't be used here: + // - synthesizing a key in the browser won't actually send it to the + // findbar; the findbar isn't part of the browser content. + // - we need to _not_ wait for _startFindDeferred to be resolved; yielding + // a synthesized keypress on the browser implicitely happens after the + // browser has dispatched its return message with the prefill value for + // the findbar, which essentially nulls these tests. + + // The parent-side of the sidebar initialization is also async, so we do + // need to wait for that. We verify a bit further down that _startFindDeferred + // hasn't been resolved yet. + await gFindBarPromise; + + let findBar = gFindBar; + is(findBar._findField.value, "", "findbar is empty"); + + // Test 1 + // Any input in the findbar should erase a previous search. + + findBar._findField.value = "xy"; + findBar.startFind(); + is(findBar._findField.value, "xy", "findbar should have xy initial query"); + is(findBar._findField, document.activeElement, "findbar is now focused"); + + EventUtils.sendChar("z", window); + is(findBar._findField.value, "z", "z erases xy"); + + findBar._findField.value = ""; + ok(!findBar._findField.value, "erase findbar after first test"); + + // Test 2 + // Prefilling the findbar should be ignored if a search has been run. + + findBar.startFind(); + ok(findBar._startFindDeferred, "prefilled value hasn't been fetched yet"); + is(findBar._findField, document.activeElement, "findbar is still focused"); + + EventUtils.sendChar("a", window); + EventUtils.sendChar("b", window); + is(findBar._findField.value, "ab", "initial ab typed in the findbar"); + + // This resolves _startFindDeferred if it's still pending; let's just skip + // over waiting for the browser's return message that should do this as it + // doesn't really matter. + findBar.onCurrentSelection("foo", true); + ok(!findBar._startFindDeferred, "prefilled value fetched"); + is(findBar._findField.value, "ab", "ab kept instead of prefill value"); + + EventUtils.sendChar("c", window); + is(findBar._findField.value, "abc", "c is appended after ab"); + + // Clear the findField value to make the test run successfully + // for multiple runs in the same browser session. + findBar._findField.value = ""; + BrowserTestUtils.removeTab(aTab); +}); diff --git a/toolkit/content/tests/browser/browser_bug1572798.js b/toolkit/content/tests/browser/browser_bug1572798.js new file mode 100644 index 0000000000..d14b9afd6a --- /dev/null +++ b/toolkit/content/tests/browser/browser_bug1572798.js @@ -0,0 +1,29 @@ +add_task(async function test_bug_1572798() { + let tab = BrowserTestUtils.addTab(window.gBrowser, "about:blank"); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "https://example.com/browser/toolkit/content/tests/browser/file_document_open_audio.html" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let windowLoaded = BrowserTestUtils.waitForNewWindow(); + info("- clicking button to spawn a new window -"); + await ContentTask.spawn(tab.linkedBrowser, null, function () { + content.document.querySelector("button").click(); + }); + info("- waiting for the new window -"); + let newWin = await windowLoaded; + info("- checking that the new window plays the audio -"); + let documentOpenedBrowser = newWin.gBrowser.selectedBrowser; + await ContentTask.spawn(documentOpenedBrowser, null, async function () { + try { + await content.document.querySelector("audio").play(); + ok(true, "Could play the audio"); + } catch (e) { + ok(false, "Rejected audio promise" + e); + } + }); + + info("- Cleaning up -"); + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_bug1693577.js b/toolkit/content/tests/browser/browser_bug1693577.js new file mode 100644 index 0000000000..712749dc89 --- /dev/null +++ b/toolkit/content/tests/browser/browser_bug1693577.js @@ -0,0 +1,49 @@ +/* + * This test checks that the popupshowing event for input fields, which do not + * have a dedicated contextmenu event, but use the global one (added by + * editMenuOverlay.js, see bug 1693577) include a triggerNode. + * + * The search-input field of the browser-sidebar is one of the rare cases in + * mozilla-central, which can be used to test this. There are a few more in + * comm-central, which need the triggerNode information. + */ + +add_task(async function test_search_input_popupshowing() { + let sidebar = document.getElementById("sidebar"); + + let loadPromise = BrowserTestUtils.waitForEvent(sidebar, "load", true); + SidebarUI.toggle("viewBookmarksSidebar"); + await loadPromise; + + let inputField = + sidebar.contentDocument.getElementById("search-box").inputField; + const popupshowing = BrowserTestUtils.waitForEvent( + sidebar.contentWindow, + "popupshowing" + ); + + EventUtils.synthesizeMouseAtCenter( + inputField, + { + type: "contextmenu", + button: 2, + }, + sidebar.contentWindow + ); + let popupshowingEvent = await popupshowing; + + Assert.equal( + popupshowingEvent.target.triggerNode?.id, + "search-box", + "Popupshowing event for the search input includes triggernode." + ); + + const popup = popupshowingEvent.target; + await BrowserTestUtils.waitForEvent(popup, "popupshown"); + + const popuphidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.hidePopup(); + await popuphidden; + + SidebarUI.toggle("viewBookmarksSidebar"); +}); diff --git a/toolkit/content/tests/browser/browser_bug295977_autoscroll_overflow.js b/toolkit/content/tests/browser/browser_bug295977_autoscroll_overflow.js new file mode 100644 index 0000000000..b1cdbfa62c --- /dev/null +++ b/toolkit/content/tests/browser/browser_bug295977_autoscroll_overflow.js @@ -0,0 +1,389 @@ +requestLongerTimeout(2); +add_task(async function () { + function pushPrefs(prefs) { + return SpecialPowers.pushPrefEnv({ set: prefs }); + } + + await pushPrefs([ + ["general.autoScroll", true], + ["test.events.async.enabled", true], + ]); + + const expectScrollNone = 0; + const expectScrollVert = 1; + const expectScrollHori = 2; + const expectScrollBoth = 3; + + var allTests = [ + { + dataUri: + 'data:text/html,<html><head><meta charset="utf-8"></head><body><style type="text/css">div { display: inline-block; }</style>\ + <div id="a" style="width: 100px; height: 100px; overflow: hidden;"><div style="width: 200px; height: 200px;"></div></div>\ + <div id="b" style="width: 100px; height: 100px; overflow: auto;"><div style="width: 200px; height: 200px;"></div></div>\ + <div id="c" style="width: 100px; height: 100px; overflow-x: auto; overflow-y: hidden;"><div style="width: 200px; height: 200px;"></div></div>\ + <div id="d" style="width: 100px; height: 100px; overflow-y: auto; overflow-x: hidden;"><div style="width: 200px; height: 200px;"></div></div>\ + <select id="e" style="width: 100px; height: 100px;" multiple="multiple"><option>aaaaaaaaaaaaaaaaaaaaaaaa</option><option>a</option><option>a</option>\ + <option>a</option><option>a</option><option>a</option><option>a</option><option>a</option><option>a</option><option>a</option>\ + <option>a</option><option>a</option><option>a</option><option>a</option><option>a</option><option>a</option><option>a</option></select>\ + <select id="f" style="width: 100px; height: 100px;"><option>a</option><option>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</option><option>a</option>\ + <option>a</option><option>a</option><option>a</option><option>a</option><option>a</option><option>a</option><option>a</option>\ + <option>a</option><option>a</option><option>a</option><option>a</option><option>a</option><option>a</option><option>a</option></select>\ + <div id="g" style="width: 99px; height: 99px; border: 10px solid black; margin: 10px; overflow: auto;"><div style="width: 100px; height: 100px;"></div></div>\ + <div id="h" style="width: 100px; height: 100px; overflow: clip;"><div style="width: 200px; height: 200px;"></div></div>\ + <iframe id="iframe" style="display: none;"></iframe>\ + </body></html>', + }, + { elem: "a", expected: expectScrollNone }, + { elem: "b", expected: expectScrollBoth }, + { elem: "c", expected: expectScrollHori }, + { elem: "d", expected: expectScrollVert }, + { elem: "e", expected: expectScrollVert }, + { elem: "f", expected: expectScrollNone }, + { elem: "g", expected: expectScrollBoth }, + { elem: "h", expected: expectScrollNone }, + { + dataUri: + 'data:text/html,<html><head><meta charset="utf-8"></head><body id="i" style="overflow-y: scroll"><div style="height: 2000px"></div>\ + <iframe id="iframe" style="display: none;"></iframe>\ + </body></html>', + }, + { elem: "i", expected: expectScrollVert }, // bug 695121 + { + dataUri: + 'data:text/html,<html><head><meta charset="utf-8"></head><style>html, body { width: 100%; height: 100%; overflow-x: hidden; overflow-y: scroll; }</style>\ + <body id="j"><div style="height: 2000px"></div>\ + <iframe id="iframe" style="display: none;"></iframe>\ + </body></html>', + }, + { elem: "j", expected: expectScrollVert }, // bug 914251 + { + dataUri: + 'data:text/html,<html><head><meta charset="utf-8">\ +<style>\ +body > div {scroll-behavior: smooth;width: 300px;height: 300px;overflow: scroll;}\ +body > div > div {width: 1000px;height: 1000px;}\ +</style>\ +</head><body><div id="t"><div></div></div></body></html>', + }, + { elem: "t", expected: expectScrollBoth }, // bug 1308775 + { + dataUri: + 'data:text/html,<html><head><meta charset="utf-8"></head><body>\ +<div id="k" style="height: 150px; width: 200px; overflow: scroll; border: 1px solid black;">\ +<iframe style="height: 200px; width: 300px;"></iframe>\ +</div>\ +<div id="l" style="height: 150px; width: 300px; overflow: scroll; border: 1px dashed black;">\ +<iframe style="height: 200px; width: 200px;" src="data:text/html,<div style=\'border: 5px solid blue; height: 200%; width: 200%;\'></div>"></iframe>\ +</div>\ +<iframe id="m"></iframe>\ +<div style="height: 200%; border: 5px dashed black;">filler to make document overflow: scroll;</div>\ +</body></html>', + }, + { elem: "k", expected: expectScrollBoth }, + { elem: "k", expected: expectScrollNone, testwindow: true }, + { elem: "l", expected: expectScrollNone }, + { elem: "m", expected: expectScrollVert, testwindow: true }, + { + dataUri: + 'data:text/html,<html><head><meta charset="utf-8"></head><body>\ +<img width="100" height="100" alt="image map" usemap="%23planetmap">\ +<map name="planetmap">\ + <area id="n" shape="rect" coords="0,0,100,100" href="javascript:void(null)">\ +</map>\ +<a href="javascript:void(null)" id="o" style="width: 100px; height: 100px; border: 1px solid black; display: inline-block; vertical-align: top;">link</a>\ +<input id="p" style="width: 100px; height: 100px; vertical-align: top;">\ +<textarea id="q" style="width: 100px; height: 100px; vertical-align: top;"></textarea>\ +<div style="height: 200%; border: 1px solid black;"></div>\ +</body></html>', + }, + { elem: "n", expected: expectScrollNone, testwindow: true }, + { elem: "o", expected: expectScrollNone, testwindow: true }, + { + elem: "p", + expected: expectScrollVert, + testwindow: true, + middlemousepastepref: false, + }, + { + elem: "q", + expected: expectScrollVert, + testwindow: true, + middlemousepastepref: false, + }, + { + dataUri: + 'data:text/html,<html><head><meta charset="utf-8"></head><body>\ +<input id="r" style="width: 100px; height: 100px; vertical-align: top;">\ +<textarea id="s" style="width: 100px; height: 100px; vertical-align: top;"></textarea>\ +<div style="height: 200%; border: 1px solid black;"></div>\ +</body></html>', + }, + { + elem: "r", + expected: expectScrollNone, + testwindow: true, + middlemousepastepref: true, + }, + { + elem: "s", + expected: expectScrollNone, + testwindow: true, + middlemousepastepref: true, + }, + { + dataUri: + "data:text/html," + + encodeURIComponent(` +<!doctype html> +<iframe id=i height=100 width=100 scrolling="no" srcdoc="<div style='height: 200px'>Auto-scrolling should never make me disappear"></iframe> +<div style="height: 100vh"></div> + `), + }, + { + elem: "i", + // We expect the outer window to scroll vertically, not the iframe's window. + expected: expectScrollVert, + testwindow: true, + }, + { + dataUri: + "data:text/html," + + encodeURIComponent(` +<!doctype html> +<iframe id=i height=100 width=100 srcdoc="<div style='height: 200px'>Auto-scrolling should make me disappear"></iframe> +<div style="height: 100vh"></div> + `), + }, + { + elem: "i", + // We expect the iframe's window to scroll vertically, so the outer window should not scroll. + expected: expectScrollNone, + testwindow: true, + }, + { + // Test: scroll is initiated in out of process iframe having no scrollable area + dataUri: + "data:text/html," + + encodeURIComponent(` +<!doctype html> +<head><meta content="text/html;charset=utf-8"></head><body> +<div id="scroller" style="width: 300px; height: 300px; overflow-y: scroll; overflow-x: hidden; border: solid 1px blue;"> + <iframe id="noscroll-outofprocess-iframe" + src="https://example.com/document-builder.sjs?html=<html><body>Hey!</body></html>" + style="border: solid 1px green; margin: 2px;"></iframe> + <div style="width: 100%; height: 200px;"></div> +</div></body> + `), + }, + { + elem: "noscroll-outofprocess-iframe", + // We expect the div to scroll vertically, not the iframe's window. + expected: expectScrollVert, + scrollable: "scroller", + }, + ]; + + for (let test of allTests) { + if (test.dataUri) { + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString(gBrowser, test.dataUri); + await loadedPromise; + await ContentTask.spawn(gBrowser.selectedBrowser, {}, async () => { + // Wait for a paint so that hit-testing works correctly. + await new Promise(resolve => + content.requestAnimationFrame(() => + content.requestAnimationFrame(resolve) + ) + ); + }); + continue; + } + + let prefsChanged = "middlemousepastepref" in test; + if (prefsChanged) { + await pushPrefs([["middlemouse.paste", test.middlemousepastepref]]); + } + + await BrowserTestUtils.synthesizeMouse( + "#" + test.elem, + 50, + 80, + { button: 1 }, + gBrowser.selectedBrowser + ); + + // This ensures bug 605127 is fixed: pagehide in an unrelated document + // should not cancel the autoscroll. + await ContentTask.spawn( + gBrowser.selectedBrowser, + { waitForAutoScrollStart: test.expected != expectScrollNone }, + async ({ waitForAutoScrollStart }) => { + var iframe = content.document.getElementById("iframe"); + + if (iframe) { + var e = new iframe.contentWindow.PageTransitionEvent("pagehide", { + bubbles: true, + cancelable: true, + persisted: false, + }); + iframe.contentDocument.dispatchEvent(e); + iframe.contentDocument.documentElement.dispatchEvent(e); + } + if (waitForAutoScrollStart) { + await new Promise(resolve => + Services.obs.addObserver(resolve, "autoscroll-start") + ); + } + } + ); + + is( + document.activeElement, + gBrowser.selectedBrowser, + "Browser still focused after autoscroll started" + ); + + await BrowserTestUtils.synthesizeMouse( + "#" + test.elem, + 100, + 100, + { type: "mousemove", clickCount: "0" }, + gBrowser.selectedBrowser + ); + + if (prefsChanged) { + await SpecialPowers.popPrefEnv(); + } + + // Start checking for the scroll. + let firstTimestamp = undefined; + let timeCompensation; + do { + let timestamp = await new Promise(resolve => + window.requestAnimationFrame(resolve) + ); + if (firstTimestamp === undefined) { + firstTimestamp = timestamp; + } + + // This value is calculated similarly to the value of the same name in + // ClickEventHandler.autoscrollLoop, except here it's cumulative across + // all frames after the first one instead of being based only on the + // current frame. + timeCompensation = (timestamp - firstTimestamp) / 20; + info( + "timestamp=" + + timestamp + + " firstTimestamp=" + + firstTimestamp + + " timeCompensation=" + + timeCompensation + ); + + // Try to wait until enough time has passed to allow the scroll to happen. + // autoscrollLoop incrementally scrolls during each animation frame, but + // due to how its calculations work, when a frame is very close to the + // previous frame, no scrolling may actually occur during that frame. + // After 100ms's worth of frames, timeCompensation will be 1, making it + // more likely that the accumulated scroll in autoscrollLoop will be >= 1, + // although it also depends on acceleration, which here in this test + // should be > 1 due to how it synthesizes mouse events below. + } while (timeCompensation < 5); + + // Close the autoscroll popup by synthesizing Esc. + EventUtils.synthesizeKey("KEY_Escape"); + let scrollVert = test.expected & expectScrollVert; + let scrollHori = test.expected & expectScrollHori; + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [ + { + scrollVert, + scrollHori, + elemid: test.scrollable || test.elem, + checkWindow: test.testwindow, + }, + ], + async function (args) { + let msg = ""; + if (args.checkWindow) { + if ( + !( + (args.scrollVert && content.scrollY > 0) || + (!args.scrollVert && content.scrollY == 0) + ) + ) { + msg += "Failed: "; + } + msg += + "Window for " + + args.elemid + + " should" + + (args.scrollVert ? "" : " not") + + " have scrolled vertically\n"; + + if ( + !( + (args.scrollHori && content.scrollX > 0) || + (!args.scrollHori && content.scrollX == 0) + ) + ) { + msg += "Failed: "; + } + msg += + " Window for " + + args.elemid + + " should" + + (args.scrollHori ? "" : " not") + + " have scrolled horizontally\n"; + } else { + let elem = content.document.getElementById(args.elemid); + if ( + !( + (args.scrollVert && elem.scrollTop > 0) || + (!args.scrollVert && elem.scrollTop == 0) + ) + ) { + msg += "Failed: "; + } + msg += + " " + + args.elemid + + " should" + + (args.scrollVert ? "" : " not") + + " have scrolled vertically\n"; + if ( + !( + (args.scrollHori && elem.scrollLeft > 0) || + (!args.scrollHori && elem.scrollLeft == 0) + ) + ) { + msg += "Failed: "; + } + msg += + args.elemid + + " should" + + (args.scrollHori ? "" : " not") + + " have scrolled horizontally"; + } + + Assert.ok(!msg.includes("Failed"), msg); + } + ); + + // Before continuing the test, we need to ensure that the IPC + // message that stops autoscrolling has had time to arrive. + await new Promise(resolve => executeSoon(resolve)); + } + + // remove 2 tabs that were opened by middle-click on links + while (gBrowser.visibleTabs.length > 1) { + gBrowser.removeTab(gBrowser.visibleTabs[gBrowser.visibleTabs.length - 1]); + } + + // wait for focus to fix a failure in the next test if the latter runs too soon. + await SimpleTest.promiseFocus(); +}); diff --git a/toolkit/content/tests/browser/browser_bug451286.js b/toolkit/content/tests/browser/browser_bug451286.js new file mode 100644 index 0000000000..e7f03c96f7 --- /dev/null +++ b/toolkit/content/tests/browser/browser_bug451286.js @@ -0,0 +1,166 @@ +Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/WindowSnapshot.js", + this +); + +add_task(async function () { + const SEARCH_TEXT = "text"; + const DATAURI = "data:text/html," + SEARCH_TEXT; + + // Bug 451286. An iframe that should be highlighted + let visible = "<iframe id='visible' src='" + DATAURI + "'></iframe>"; + + // Bug 493658. An invisible iframe that shouldn't interfere with + // highlighting matches lying after it in the document + let invisible = + "<iframe id='invisible' style='display: none;' " + + "src='" + + DATAURI + + "'></iframe>"; + + let uri = DATAURI + invisible + SEARCH_TEXT + visible + SEARCH_TEXT; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, uri); + let contentRect = tab.linkedBrowser.getBoundingClientRect(); + let noHighlightSnapshot = snapshotRect(window, contentRect); + ok(noHighlightSnapshot, "Got noHighlightSnapshot"); + + await openFindBarAndWait(); + gFindBar._findField.value = SEARCH_TEXT; + await findAgainAndWait(); + var matchCase = gFindBar.getElement("find-case-sensitive"); + if (matchCase.checked) { + matchCase.doCommand(); + } + + // Turn on highlighting + await toggleHighlightAndWait(true); + await closeFindBarAndWait(); + + // Take snapshot of highlighting + let findSnapshot = snapshotRect(window, contentRect); + ok(findSnapshot, "Got findSnapshot"); + + // Now, remove the highlighting, and take a snapshot to compare + // to our original state + await openFindBarAndWait(); + await toggleHighlightAndWait(false); + await closeFindBarAndWait(); + + let unhighlightSnapshot = snapshotRect(window, contentRect); + ok(unhighlightSnapshot, "Got unhighlightSnapshot"); + + // Select the matches that should have been highlighted manually + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let doc = content.document; + let win = doc.defaultView; + + // Create a manual highlight in the visible iframe to test bug 451286 + let iframe = doc.getElementById("visible"); + let ifBody = iframe.contentDocument.body; + let range = iframe.contentDocument.createRange(); + range.selectNodeContents(ifBody.childNodes[0]); + let ifWindow = iframe.contentWindow; + let ifDocShell = ifWindow.docShell; + + let ifController = ifDocShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + + let frameFindSelection = ifController.getSelection( + ifController.SELECTION_FIND + ); + frameFindSelection.addRange(range); + + // Create manual highlights in the main document (the matches that lie + // before/after the iframes + let docShell = win.docShell; + + let controller = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + + let docFindSelection = controller.getSelection(ifController.SELECTION_FIND); + + range = doc.createRange(); + range.selectNodeContents(doc.body.childNodes[0]); + docFindSelection.addRange(range); + range = doc.createRange(); + range.selectNodeContents(doc.body.childNodes[2]); + docFindSelection.addRange(range); + range = doc.createRange(); + range.selectNodeContents(doc.body.childNodes[4]); + docFindSelection.addRange(range); + }); + + // Take snapshot of manual highlighting + let manualSnapshot = snapshotRect(window, contentRect); + ok(manualSnapshot, "Got manualSnapshot"); + + // Test 1: Were the matches in iframe correctly highlighted? + let res = compareSnapshots(findSnapshot, manualSnapshot, true); + ok(res[0], "Matches found in iframe correctly highlighted"); + + // Test 2: Were the matches in iframe correctly unhighlighted? + res = compareSnapshots(noHighlightSnapshot, unhighlightSnapshot, true); + ok(res[0], "Highlighting in iframe correctly removed"); + + BrowserTestUtils.removeTab(tab); +}); + +function toggleHighlightAndWait(shouldHighlight) { + return new Promise(resolve => { + let listener = { + onFindResult() {}, + onHighlightFinished() { + gFindBar.browser.finder.removeResultListener(listener); + resolve(); + }, + onMatchesCountResult() {}, + }; + gFindBar.browser.finder.addResultListener(listener); + gFindBar.toggleHighlight(shouldHighlight); + }); +} + +function findAgainAndWait() { + return new Promise(resolve => { + let listener = { + onFindResult() { + gFindBar.browser.finder.removeResultListener(listener); + resolve(); + }, + onHighlightFinished() {}, + onMatchesCountResult() {}, + }; + gFindBar.browser.finder.addResultListener(listener); + gFindBar.onFindAgainCommand(); + }); +} + +async function openFindBarAndWait() { + await gFindBarPromise; + let awaitTransitionEnd = BrowserTestUtils.waitForEvent( + gFindBar, + "transitionend" + ); + gFindBar.open(); + await awaitTransitionEnd; +} + +// This test is comparing snapshots. It is necessary to wait for the gFindBar +// to close before taking the snapshot so the gFindBar does not take up space +// on the new snapshot. +async function closeFindBarAndWait() { + let awaitTransitionEnd = BrowserTestUtils.waitForEvent( + gFindBar, + "transitionend", + false, + event => { + return event.propertyName == "visibility"; + } + ); + gFindBar.close(); + await awaitTransitionEnd; +} diff --git a/toolkit/content/tests/browser/browser_bug594509.js b/toolkit/content/tests/browser/browser_bug594509.js new file mode 100644 index 0000000000..b177c00d7c --- /dev/null +++ b/toolkit/content/tests/browser/browser_bug594509.js @@ -0,0 +1,15 @@ +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:rights" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + Assert.ok( + content.document.getElementById("your-rights"), + "about:rights content loaded" + ); + }); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_bug982298.js b/toolkit/content/tests/browser/browser_bug982298.js new file mode 100644 index 0000000000..ffbc916a5e --- /dev/null +++ b/toolkit/content/tests/browser/browser_bug982298.js @@ -0,0 +1,79 @@ +const scrollHtml = + '<textarea id="textarea1" row=2>Firefox\n\nFirefox\n\n\n\n\n\n\n\n\n\n' + + '</textarea><a href="about:blank">blank</a>'; + +add_task(async function () { + let url = "data:text/html;base64," + btoa(scrollHtml); + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) { + let awaitFindResult = new Promise(resolve => { + let listener = { + onFindResult(aData) { + info("got find result"); + browser.finder.removeResultListener(listener); + + Assert.equal( + aData.result, + Ci.nsITypeAheadFind.FIND_FOUND, + "should find string" + ); + resolve(); + }, + onCurrentSelection() {}, + onMatchesCountResult() {}, + }; + info( + "about to add results listener, open find bar, and send 'F' string" + ); + browser.finder.addResultListener(listener); + }); + await gFindBarPromise; + gFindBar.onFindCommand(); + EventUtils.sendString("F"); + info("added result listener and sent string 'F'"); + await awaitFindResult; + + // scroll textarea to bottom + await SpecialPowers.spawn(browser, [], () => { + let textarea = content.document.getElementById("textarea1"); + textarea.scrollTop = textarea.scrollHeight; + }); + BrowserTestUtils.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser); + + Assert.equal( + browser.currentURI.spec, + "about:blank", + "got load event for about:blank" + ); + + let awaitFindResult2 = new Promise(resolve => { + let listener = { + onFindResult(aData) { + info("got find result #2"); + browser.finder.removeResultListener(listener); + resolve(); + }, + onCurrentSelection() {}, + onMatchesCountResult() {}, + }; + + browser.finder.addResultListener(listener); + info("added result listener"); + }); + // find again needs delay for crash test + setTimeout(function () { + // ignore exception if occured + try { + info("about to send find again command"); + gFindBar.onFindAgainCommand(false); + info("sent find again command"); + } catch (e) { + info("got exception from onFindAgainCommand: " + e); + } + }, 0); + await awaitFindResult2; + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_cancel_starting_autoscrolling_requested_by_background_tab.js b/toolkit/content/tests/browser/browser_cancel_starting_autoscrolling_requested_by_background_tab.js new file mode 100644 index 0000000000..3674926a5a --- /dev/null +++ b/toolkit/content/tests/browser/browser_cancel_starting_autoscrolling_requested_by_background_tab.js @@ -0,0 +1,155 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testStopStartingAutoScroll() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["general.autoScroll", true], + ["middlemouse.contentLoadURL", false], + ["test.events.async.enabled", true], + [ + "accessibility.mouse_focuses_formcontrol", + !navigator.platform.includes("Mac"), + ], + ], + }); + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + async function doTest({ + aInnerHTML, + aDescription, + aExpectedActiveElement, + }) { + await SpecialPowers.spawn(browser, [aInnerHTML], async contentHTML => { + content.document.body.innerHTML = contentHTML; + content.document.documentElement.scrollTop; // Flush layout. + const iframe = content.document.querySelector("iframe"); + // If the test page has an iframe, we need to ensure it has loaded. + if (!iframe || iframe.contentDocument?.readyState == "complete") { + return; + } + // It's too late to check "load" event. Let's check + // Document#readyState instead. + await ContentTaskUtils.waitForCondition( + () => iframe.contentDocument?.readyState == "complete", + "Waiting for loading the subdocument" + ); + }); + + let autoScroller; + let onPopupShown = event => { + if (event.originalTarget.id !== "autoscroller") { + return false; + } + autoScroller = event.originalTarget; + info(`${aDescription}: "popupshown" event is fired`); + autoScroller.getBoundingClientRect(); // Flush layout of the autoscroller + return true; + }; + window.addEventListener("popupshown", onPopupShown, { capture: true }); + registerCleanupFunction(() => { + window.removeEventListener("popupshown", onPopupShown, { + capture: true, + }); + }); + + let waitForNewTabForeground = BrowserTestUtils.waitForEvent( + gBrowser, + "TabSwitchDone" + ); + await EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + button: 1, // middle click + target: browser, + atCenter: true, + }); + info(`${aDescription}: Waiting for active tab switched...`); + await waitForNewTabForeground; + // To confirm that autoscrolling won't start accidentally, we should + // wait a while. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + is( + autoScroller, + undefined, + `${aDescription}: autoscroller should not be open because requested tab is now in background` + ); + // Clean up + await EventUtils.promiseNativeMouseEvent({ + type: "mouseup", + button: 1, // middle click + target: browser, + atCenter: true, + }); // release implicit capture + EventUtils.synthesizeKey("KEY_Escape"); // To close unexpected autoscroller + isnot( + browser, + gBrowser.selectedBrowser, + `${aDescription}: The clicked tab shouldn't be foreground tab` + ); + is( + gBrowser.selectedTab, + gBrowser.tabs[gBrowser.tabs.length - 1], + `${aDescription}: The opened tab should be foreground tab` + ); + await SpecialPowers.spawn( + browser, + [aExpectedActiveElement, aDescription], + (expectedActiveElement, description) => { + if (expectedActiveElement != null) { + if (expectedActiveElement == "iframe") { + // Check only whether the subdocument gets focus. + return; + } + Assert.equal( + content.document.activeElement, + content.document.querySelector(expectedActiveElement), + `${description}: Active element should be the result of querySelector("${expectedActiveElement}")` + ); + } else { + Assert.deepEqual( + content.document.activeElement, + new content.window.Object(null), + `${description}: No element should be active` + ); + } + } + ); + gBrowser.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + await SimpleTest.promiseFocus(browser); + if (aExpectedActiveElement == "iframe") { + await SpecialPowers.spawn(browser, [aDescription], description => { + // XXX Due to no `Assert#todo`, this checks opposite result. + Assert.ok( + !content.document + .querySelector("iframe") + .contentDocument.hasFocus(), + `TODO: ${description}: The subdocument should have focus when the tab gets foreground` + ); + }); + } + } + await doTest({ + aInnerHTML: `<div style="height: 10000px;" onmousedown="window.open('https://example.com/', '_blank')">Click to open new tab</div>`, + aDescription: "Clicking non-focusable <div> with middle mouse button", + aExpectedActiveElement: null, + }); + await doTest({ + aInnerHTML: `<button style="height: 10000px; width: 100%" onmousedown="window.open('https://example.com/', '_blank')">Click to open new tab</button>`, + aDescription: `Clicking focusable <button> with middle mouse button`, + aExpectedActiveElement: navigator.platform.includes("Mac") + ? null + : "button", + }); + await doTest({ + aInnerHTML: `<iframe style="height: 90vh; width: 90vw" srcdoc="<div onmousedown='window.open(\`https://example.com/\`, \`_blank\`)' style='width: 100%; height: 10000px'>Click to open new tab"></iframe>`, + aDescription: `Clicking non-focusable <div> in <iframe> with middle mouse button`, + aExpectedActiveElement: "iframe", + }); + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_charsetMenu_disable_on_ascii.js b/toolkit/content/tests/browser/browser_charsetMenu_disable_on_ascii.js new file mode 100644 index 0000000000..2982a63141 --- /dev/null +++ b/toolkit/content/tests/browser/browser_charsetMenu_disable_on_ascii.js @@ -0,0 +1,18 @@ +/* Test that the charset menu is properly enabled when swapping browsers. */ +add_task(async function test() { + function charsetMenuEnabled() { + return !document + .getElementById("repair-text-encoding") + .hasAttribute("disabled"); + } + + const PAGE = "data:text/html,<!DOCTYPE html><body>ASCII-only"; + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: PAGE, + waitForStateStop: true, + }); + ok(!charsetMenuEnabled(), "should have a charset menu here"); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_charsetMenu_swapBrowsers.js b/toolkit/content/tests/browser/browser_charsetMenu_swapBrowsers.js new file mode 100644 index 0000000000..9ea8fccf29 --- /dev/null +++ b/toolkit/content/tests/browser/browser_charsetMenu_swapBrowsers.js @@ -0,0 +1,39 @@ +/* Test that the charset menu is properly enabled when swapping browsers. */ +add_task(async function test() { + function charsetMenuEnabled() { + return !document + .getElementById("repair-text-encoding") + .hasAttribute("disabled"); + } + + const PAGE = + "data:text/html;charset=windows-1252,<!DOCTYPE html><body>hello %e4"; + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: PAGE, + }); + await BrowserTestUtils.waitForMutationCondition( + document.getElementById("repair-text-encoding"), + { attributes: true }, + charsetMenuEnabled + ); + ok(charsetMenuEnabled(), "should have a charset menu here"); + + let tab2 = await BrowserTestUtils.openNewForegroundTab({ gBrowser }); + ok(!charsetMenuEnabled(), "about:blank shouldn't have a charset menu"); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + + let swapped = BrowserTestUtils.waitForEvent( + tab2.linkedBrowser, + "SwapDocShells" + ); + + // NB: Closes tab1. + gBrowser.swapBrowsersAndCloseOther(tab2, tab1); + await swapped; + + ok(charsetMenuEnabled(), "should have a charset after the swap"); + + BrowserTestUtils.removeTab(tab2); +}); diff --git a/toolkit/content/tests/browser/browser_click_event_during_autoscrolling.js b/toolkit/content/tests/browser/browser_click_event_during_autoscrolling.js new file mode 100644 index 0000000000..b47f72ed5b --- /dev/null +++ b/toolkit/content/tests/browser/browser_click_event_during_autoscrolling.js @@ -0,0 +1,577 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["general.autoScroll", true], + ["middlemouse.contentLoadURL", false], + ["test.events.async.enabled", false], + ], + }); + + await BrowserTestUtils.withNewTab( + "https://example.com/browser/toolkit/content/tests/browser/file_empty.html", + async function (browser) { + ok(browser.isRemoteBrowser, "This test passes only in e10s mode"); + await SpecialPowers.spawn(browser, [], () => { + content.document.body.innerHTML = + '<div style="height: 10000px;"></div>'; + content.document.documentElement.scrollTop = 500; + content.document.documentElement.scrollTop; // Flush layout. + // Prevent to open context menu when testing the secondary button click. + content.window.addEventListener( + "contextmenu", + event => event.preventDefault(), + { capture: true } + ); + }); + + function promiseFlushLayoutInContent() { + return SpecialPowers.spawn(browser, [], () => { + content.document.documentElement.scrollTop; // Flush layout in the remote content. + }); + } + + function promiseContentTick() { + return SpecialPowers.spawn(browser, [], async () => { + await new Promise(r => { + content.requestAnimationFrame(() => { + content.requestAnimationFrame(r); + }); + }); + }); + } + + let autoScroller; + function promiseWaitForAutoScrollerOpen() { + if (autoScroller?.state == "open") { + info("The autoscroller has already been open"); + return Promise.resolve(); + } + return BrowserTestUtils.waitForEvent( + window, + "popupshown", + { capture: true }, + event => { + if (event.originalTarget.id != "autoscroller") { + return false; + } + autoScroller = event.originalTarget; + info('"popupshown" event is fired'); + autoScroller.getBoundingClientRect(); // Flush layout of the autoscroller + return true; + } + ); + } + + function promiseWaitForAutoScrollerClosed() { + if (!autoScroller || autoScroller.state == "closed") { + info("The autoscroller has already been closed"); + return Promise.resolve(); + } + return BrowserTestUtils.waitForEvent( + autoScroller, + "popuphidden", + { capture: true }, + () => { + info('"popuphidden" event is fired'); + return true; + } + ); + } + + // Unfortunately, we cannot use synthesized mouse events for starting and + // stopping autoscrolling because they may run different path from user + // operation especially when there is a popup. + + /** + * Instead of using `waitForContentEvent`, we use `addContentEventListener` + * for checking which events are fired because `waitForContentEvent` cannot + * detect redundant event since it's removed automatically at first event + * or timeout if the expected count is 0. + */ + class ContentEventCounter { + constructor(aBrowser, aEventTypes) { + this.eventData = new Map(); + for (let eventType of aEventTypes) { + const removeEventListener = + BrowserTestUtils.addContentEventListener( + aBrowser, + eventType, + () => { + let eventData = this.eventData.get(eventType); + eventData.count++; + }, + { capture: true } + ); + this.eventData.set(eventType, { + count: 0, // how many times the event fired. + removeEventListener, // function to remove the event listener. + }); + } + } + + getCountAndRemoveEventListener(aEventType) { + let eventData = this.eventData.get(aEventType); + if (eventData.removeEventListener) { + eventData.removeEventListener(); + eventData.removeEventListener = null; + } + return eventData.count; + } + + promiseMouseEvents(aEventTypes, aMessage) { + let needsToWait = []; + for (const eventType of aEventTypes) { + let eventData = this.eventData.get(eventType); + if (eventData.count > 0) { + info(`${aMessage}: Waiting "${eventType}" event in content...`); + needsToWait.push( + // Let's use `waitForCondition` here. "timeout" is not worthwhile + // to debug this test. We want clearer failure log. + TestUtils.waitForCondition( + () => eventData.count > 0, + `${aMessage}: "${eventType}" should be fired, but timed-out` + ) + ); + break; + } + } + return Promise.all(needsToWait); + } + } + + await SpecialPowers.pushPrefEnv({ set: [["middlemouse.paste", true]] }); + await (async function testMouseEventsAtStartingAutoScrolling() { + info( + "Waiting autoscroller popup for testing mouse events at starting autoscrolling" + ); + await promiseFlushLayoutInContent(); + let eventsInContent = new ContentEventCounter(browser, [ + "click", + "auxclick", + "mousedown", + "mouseup", + "paste", + ]); + // Ensure that the event listeners added in the content with accessing + // the remote content. + await promiseFlushLayoutInContent(); + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: browser, + atCenter: true, + }); + const waitForOpenAutoScroll = promiseWaitForAutoScrollerOpen(); + await EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + target: browser, + atCenter: true, + button: 1, // middle button + }); + await waitForOpenAutoScroll; + // In the wild, native "mouseup" event occurs after the popup is open. + await EventUtils.promiseNativeMouseEvent({ + type: "mouseup", + target: browser, + atCenter: true, + button: 1, // middle button + }); + await promiseFlushLayoutInContent(); + await promiseContentTick(); + await eventsInContent.promiseMouseEvents( + ["mouseup"], + "At starting autoscrolling" + ); + for (let eventType of ["click", "auxclick", "paste"]) { + is( + eventsInContent.getCountAndRemoveEventListener(eventType), + 0, + `"${eventType}" event shouldn't be fired in the content when a middle click starts autoscrolling` + ); + } + for (let eventType of ["mousedown", "mouseup"]) { + is( + eventsInContent.getCountAndRemoveEventListener(eventType), + 1, + `"${eventType}" event should be fired in the content when a middle click starts autoscrolling` + ); + } + info("Waiting autoscroller close for preparing the following tests"); + let waitForAutoScrollEnd = promiseWaitForAutoScrollerClosed(); + EventUtils.synthesizeKey("KEY_Escape"); + await waitForAutoScrollEnd; + })(); + + if ( + // Bug 1693240: We don't support setting modifiers while posting a mouse event on Windows. + !navigator.platform.includes("Win") && + // Bug 1693237: We don't support setting modifiers on Android. + !navigator.appVersion.includes("Android") && + // In Headless mode, modifiers are not supported by this kind of APIs. + !Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless + ) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["general.autoscroll.prevent_to_start.shiftKey", true], + ["general.autoscroll.prevent_to_start.altKey", true], + ["general.autoscroll.prevent_to_start.ctrlKey", true], + ["general.autoscroll.prevent_to_start.metaKey", true], + ], + }); + for (const modifier of ["Shift", "Control", "Alt", "Meta"]) { + if (modifier == "Meta" && !navigator.platform.includes("Mac")) { + continue; // Delete this after fixing bug 1232918. + } + await (async function modifiersPreventToStartAutoScrolling() { + info( + `Waiting to check not to open autoscroller popup with middle button click with ${modifier}` + ); + await promiseFlushLayoutInContent(); + let eventsInContent = new ContentEventCounter(browser, [ + "click", + "auxclick", + "mousedown", + "mouseup", + "paste", + ]); + // Ensure that the event listeners added in the content with accessing + // the remote content. + await promiseFlushLayoutInContent(); + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: browser, + atCenter: true, + }); + info( + `Waiting to MozAutoScrollNoStart event for the middle button click with ${modifier}` + ); + await EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + target: browser, + atCenter: true, + button: 1, // middle button + modifiers: { + altKey: modifier == "Alt", + ctrlKey: modifier == "Control", + metaKey: modifier == "Meta", + shiftKey: modifier == "Shift", + }, + }); + try { + await TestUtils.waitForCondition( + () => autoScroller?.state == "open", + `Waiting to check not to open autoscroller popup with ${modifier}`, + 100, + 10 + ); + ok( + false, + `The autoscroller popup shouldn't be opened by middle click with ${modifier}` + ); + } catch (ex) { + ok( + true, + `The autoscroller popup was not open as expected after middle click with ${modifier}` + ); + } + // In the wild, native "mouseup" event occurs after the popup is open. + await EventUtils.promiseNativeMouseEvent({ + type: "mouseup", + target: browser, + atCenter: true, + button: 1, // middle button + }); + await promiseFlushLayoutInContent(); + await promiseContentTick(); + await eventsInContent.promiseMouseEvents( + ["paste"], + `At middle clicking with ${modifier}` + ); + for (let eventType of [ + "mousedown", + "mouseup", + "click", + "auxclick", + "paste", + ]) { + is( + eventsInContent.getCountAndRemoveEventListener(eventType), + 1, + `"${eventType}" event should be fired in the content when a middle click with ${modifier}` + ); + } + info( + "Waiting autoscroller close for preparing the following tests" + ); + })(); + } + } + + async function doTestMouseEventsAtStoppingAutoScrolling({ + aButton = 0, + aClickOutsideAutoScroller = false, + aDescription = "Unspecified", + }) { + info( + `Starting autoscrolling for testing to stop autoscrolling with ${aDescription}` + ); + await promiseFlushLayoutInContent(); + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: browser, + atCenter: true, + }); + const waitForOpenAutoScroll = promiseWaitForAutoScrollerOpen(); + await EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + target: browser, + atCenter: true, + button: 1, // middle button + }); + // In the wild, native "mouseup" event occurs after the popup is open. + await waitForOpenAutoScroll; + await EventUtils.promiseNativeMouseEvent({ + type: "mouseup", + target: browser, + atCenter: true, + button: 1, // middle button + }); + await promiseFlushLayoutInContent(); + // Just to be sure, wait for a tick for wait APZ stable. + await TestUtils.waitForTick(); + + let eventsInContent = new ContentEventCounter(browser, [ + "click", + "auxclick", + "mousedown", + "mouseup", + "paste", + "contextmenu", + ]); + // Ensure that the event listeners added in the content with accessing + // the remote content. + await promiseFlushLayoutInContent(); + + aDescription = `Stop autoscrolling with ${aDescription}`; + info( + `${aDescription}: Synthesizing primary mouse button event on the autoscroller` + ); + const autoScrollerRect = autoScroller.getOuterScreenRect(); + info( + `${aDescription}: autoScroller: { left: ${autoScrollerRect.left}, top: ${autoScrollerRect.top}, width: ${autoScrollerRect.width}, height: ${autoScrollerRect.height} }` + ); + const waitForCloseAutoScroller = promiseWaitForAutoScrollerClosed(); + if (aClickOutsideAutoScroller) { + info( + `${aDescription}: Synthesizing mousemove move cursor outside the autoscroller...` + ); + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: autoScroller, + offsetX: -10, + offsetY: -10, + elementOnWidget: browser, // use widget for the parent window of the autoscroller + }); + info( + `${aDescription}: Synthesizing mousedown to stop autoscrolling...` + ); + await EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + target: autoScroller, + offsetX: -10, + offsetY: -10, + button: aButton, + elementOnWidget: browser, // use widget for the parent window of the autoscroller + }); + } else { + info( + `${aDescription}: Synthesizing mousemove move cursor onto the autoscroller...` + ); + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: autoScroller, + atCenter: true, + elementOnWidget: browser, // use widget for the parent window of the autoscroller + }); + info( + `${aDescription}: Synthesizing mousedown to stop autoscrolling...` + ); + await EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + target: autoScroller, + atCenter: true, + button: aButton, + elementOnWidget: browser, // use widget for the parent window of the autoscroller + }); + } + // In the wild, native "mouseup" event occurs after the popup is closed. + await waitForCloseAutoScroller; + info( + `${aDescription}: Synthesizing mouseup event for preceding mousedown which is for stopping autoscrolling` + ); + await EventUtils.promiseNativeMouseEvent({ + type: "mouseup", + target: browser, + atCenter: true, + button: aButton, + }); + await promiseFlushLayoutInContent(); + await promiseContentTick(); + await eventsInContent.promiseMouseEvents( + aButton != 2 ? ["mouseup"] : ["mouseup", "contextmenu"], + aDescription + ); + is( + autoScroller.state, + "closed", + `${aDescription}: The autoscroller should've been closed` + ); + // - On macOS, when clicking outside autoscroller, nsChildView + // intentionally blocks both "mousedown" and "mouseup" events in the + // case of the primary button click, and only "mousedown" for the + // middle button when the "mousedown". I'm not sure how it should work + // on macOS for conforming to the platform manner. Note that autoscroll + // isn't available on the other browsers on macOS. So, there is no + // reference, but for consistency between platforms, it may be better + // to ignore the platform manner. + // - On Windows, when clicking outside autoscroller, nsWindow + // intentionally blocks only "mousedown" events for the primary button + // and the middle button. But this behavior is different from Chrome + // so that we need to fix this in the future. + // - On Linux, when clicking outside autoscroller, nsWindow + // intentionally blocks only "mousedown" events for any buttons. But + // on Linux, autoscroll isn't available by the default settings. So, + // not so urgent, but should be fixed in the future for consistency + // between platforms and compatibility with Chrome on Windows. + const rollingUpPopupConsumeMouseDown = + aClickOutsideAutoScroller && + (aButton != 2 || navigator.platform.includes("Linux")); + const rollingUpPopupConsumeMouseUp = + aClickOutsideAutoScroller && + aButton == 0 && + navigator.platform.includes("Mac"); + const checkFuncForClick = + aClickOutsideAutoScroller && + aButton == 2 && + !navigator.platform.includes("Linux") + ? todo_is + : is; + for (let eventType of ["click", "auxclick"]) { + checkFuncForClick( + eventsInContent.getCountAndRemoveEventListener(eventType), + 0, + `${aDescription}: "${eventType}" event shouldn't be fired in the remote content` + ); + } + is( + eventsInContent.getCountAndRemoveEventListener("paste"), + 0, + `${aDescription}: "paste" event shouldn't be fired in the remote content` + ); + const checkFuncForMouseDown = rollingUpPopupConsumeMouseDown + ? todo_is + : is; + checkFuncForMouseDown( + eventsInContent.getCountAndRemoveEventListener("mousedown"), + 1, + `${aDescription}: "mousedown" event should be fired in the remote content` + ); + const checkFuncForMouseUp = rollingUpPopupConsumeMouseUp ? todo_is : is; + checkFuncForMouseUp( + eventsInContent.getCountAndRemoveEventListener("mouseup"), + 1, + `${aDescription}: "mouseup" event should be fired in the remote content` + ); + const checkFuncForContextMenu = + aButton == 2 && + aClickOutsideAutoScroller && + navigator.platform.includes("Linux") + ? todo_is + : is; + checkFuncForContextMenu( + eventsInContent.getCountAndRemoveEventListener("contextmenu"), + aButton == 2 ? 1 : 0, + `${aDescription}: "contextmenu" event should${ + aButton != 2 ? " not" : "" + } be fired in the remote content` + ); + + const promiseClickEvent = BrowserTestUtils.waitForContentEvent( + browser, + "click", + { + capture: true, + } + ); + await promiseFlushLayoutInContent(); + info(`${aDescription}: Waiting for click event in the remote content`); + EventUtils.synthesizeNativeMouseEvent({ + type: "click", + target: browser, + atCenter: true, + }); + await promiseClickEvent; + ok( + true, + `${aDescription}: click event is fired in the remote content after stopping autoscrolling` + ); + } + + // Clicking the primary button to stop autoscrolling. + await doTestMouseEventsAtStoppingAutoScrolling({ + aButton: 0, + aClickOutsideAutoScroller: false, + aDescription: "a primary button click on autoscroller", + }); + await doTestMouseEventsAtStoppingAutoScrolling({ + aButton: 0, + aClickOutsideAutoScroller: true, + aDescription: "a primary button click outside autoscroller", + }); + + // Clicking the secondary button to stop autoscrolling. + await doTestMouseEventsAtStoppingAutoScrolling({ + aButton: 2, + aClickOutsideAutoScroller: false, + aDescription: "a secondary button click on autoscroller", + }); + await doTestMouseEventsAtStoppingAutoScrolling({ + aButton: 2, + aClickOutsideAutoScroller: true, + aDescription: "a secondary button click outside autoscroller", + }); + + // Clicking the middle button to stop autoscrolling. + await SpecialPowers.pushPrefEnv({ set: [["middlemouse.paste", true]] }); + await doTestMouseEventsAtStoppingAutoScrolling({ + aButton: 1, + aClickOutsideAutoScroller: false, + aDescription: + "a middle button click on autoscroller (middle click paste enabled)", + }); + await doTestMouseEventsAtStoppingAutoScrolling({ + aButton: 1, + aClickOutsideAutoScroller: true, + aDescription: + "a middle button click outside autoscroller (middle click paste enabled)", + }); + await SpecialPowers.pushPrefEnv({ set: [["middlemouse.paste", false]] }); + await doTestMouseEventsAtStoppingAutoScrolling({ + aButton: 1, + aClickOutsideAutoScroller: false, + aDescription: + "a middle button click on autoscroller (middle click paste disabled)", + }); + await doTestMouseEventsAtStoppingAutoScrolling({ + aButton: 1, + aClickOutsideAutoScroller: true, + aDescription: + "a middle button click outside autoscroller (middle click paste disabled)", + }); + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_contentTitle.js b/toolkit/content/tests/browser/browser_contentTitle.js new file mode 100644 index 0000000000..e8330eca0f --- /dev/null +++ b/toolkit/content/tests/browser/browser_contentTitle.js @@ -0,0 +1,17 @@ +var url = + "https://example.com/browser/toolkit/content/tests/browser/file_contentTitle.html"; + +add_task(async function () { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url)); + await BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "TestLocationChange", + true, + null, + true + ); + + is(gBrowser.contentTitle, "Test Page", "Should have the right title."); + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_content_url_annotation.js b/toolkit/content/tests/browser/browser_content_url_annotation.js new file mode 100644 index 0000000000..e480a531e5 --- /dev/null +++ b/toolkit/content/tests/browser/browser_content_url_annotation.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* global Services, requestLongerTimeout, TestUtils, BrowserTestUtils, + ok, info, dump, is, Ci, Cu, Components, ctypes, + gBrowser, add_task, addEventListener, removeEventListener, ContentTask */ + +"use strict"; + +// Running this test in ASAN is slow. +requestLongerTimeout(2); + +/** + * Removes a file from a directory. This is a no-op if the file does not + * exist. + * + * @param directory + * The nsIFile representing the directory to remove from. + * @param filename + * A string for the file to remove from the directory. + */ +function removeFile(directory, filename) { + let file = directory.clone(); + file.append(filename); + if (file.exists()) { + file.remove(false); + } +} + +/** + * Returns the directory where crash dumps are stored. + * + * @return nsIFile + */ +function getMinidumpDirectory() { + let dir = Services.dirsvc.get("ProfD", Ci.nsIFile); + dir.append("minidumps"); + return dir; +} + +/** + * Checks that the URL is correctly annotated on a content process crash. + */ +add_task(async function test_content_url_annotation() { + let url = + "https://example.com/browser/toolkit/content/tests/browser/file_redirect.html"; + let redirect_url = + "https://example.com/browser/toolkit/content/tests/browser/file_redirect_to.html"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + }, + async function (browser) { + ok(browser.isRemoteBrowser, "Should be a remote browser"); + + // file_redirect.html should send us to file_redirect_to.html + let promise = BrowserTestUtils.waitForContentEvent( + browser, + "RedirectDone", + true, + null, + true + ); + BrowserTestUtils.startLoadingURIString(browser, url); + await promise; + + // Crash the tab + let annotations = await BrowserTestUtils.crashFrame(browser); + + ok("URL" in annotations, "annotated a URL"); + is( + annotations.URL, + redirect_url, + "Should have annotated the URL after redirect" + ); + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_crash_previous_frameloader.js b/toolkit/content/tests/browser/browser_crash_previous_frameloader.js new file mode 100644 index 0000000000..0fa2f17912 --- /dev/null +++ b/toolkit/content/tests/browser/browser_crash_previous_frameloader.js @@ -0,0 +1,131 @@ +"use strict"; + +/** + * Returns the id of the crash minidump. + * + * @param subject (nsISupports) + * The subject passed through the ipc:content-shutdown + * observer notification when a content process crash has + * occurred. + * @returns {String} The crash dump id. + */ +function getCrashDumpId(subject) { + Assert.ok( + subject instanceof Ci.nsIPropertyBag2, + "Subject needs to be a nsIPropertyBag2 to clean up properly" + ); + + return subject.getPropertyAsAString("dumpID"); +} + +/** + * Cleans up the .dmp and .extra file from a crash. + * + * @param id {String} The crash dump id. + */ +function cleanUpMinidump(id) { + let dir = Services.dirsvc.get("ProfD", Ci.nsIFile); + dir.append("minidumps"); + + let file = dir.clone(); + file.append(id + ".dmp"); + file.remove(true); + + file = dir.clone(); + file.append(id + ".extra"); + file.remove(true); +} + +/** + * This test ensures that if a remote frameloader crashes after + * the frameloader owner swaps it out for a new frameloader, + * that a oop-browser-crashed event is not sent to the new + * frameloader's browser element. + */ +add_task(async function test_crash_in_previous_frameloader() { + // On debug builds, crashing tabs results in much thinking, which + // slows down the test and results in intermittent test timeouts, + // so we'll pump up the expected timeout for this test. + requestLongerTimeout(2); + + if (!gMultiProcessBrowser) { + Assert.ok(false, "This test should only be run in multi-process mode."); + return; + } + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://example.com", + }, + async function (browser) { + // First, sanity check... + Assert.ok( + browser.isRemoteBrowser, + "This browser needs to be remote if this test is going to " + + "work properly." + ); + + // We will wait for the oop-browser-crashed event to have + // a chance to appear. That event is fired when RemoteTabs + // are destroyed, and that occurs _before_ ContentParents + // are destroyed, so we'll wait on the ipc:content-shutdown + // observer notification, which is fired when a ContentParent + // goes away. After we see this notification, oop-browser-crashed + // events should have fired. + let contentProcessGone = TestUtils.topicObserved("ipc:content-shutdown"); + let sawTabCrashed = false; + let onTabCrashed = () => { + sawTabCrashed = true; + }; + + browser.addEventListener("oop-browser-crashed", onTabCrashed); + + // The name of the game is to cause a crash in a remote browser, + // and then immediately swap out the browser for a non-remote one. + await SpecialPowers.spawn(browser, [], function () { + const { ctypes } = ChromeUtils.importESModule( + "resource://gre/modules/ctypes.sys.mjs" + ); + + let dies = function () { + ChromeUtils.privateNoteIntentionalCrash(); + let zero = new ctypes.intptr_t(8); + let badptr = ctypes.cast(zero, ctypes.PointerType(ctypes.int32_t)); + badptr.contents; + }; + + // When the parent flips the remoteness of the browser, the + // page should receive the pagehide event, which we'll then + // use to crash the frameloader. + docShell.chromeEventHandler.addEventListener("pagehide", function () { + dump("\nEt tu, Brute?\n"); + dies(); + }); + }); + + gBrowser.updateBrowserRemoteness(browser, { + remoteType: E10SUtils.NOT_REMOTE, + }); + info("Waiting for content process to go away."); + let [subject /* , data */] = await contentProcessGone; + + // If we don't clean up the minidump, the harness will + // complain. + let dumpID = getCrashDumpId(subject); + + Assert.ok(dumpID, "There should be a dumpID"); + if (dumpID) { + await Services.crashmanager.ensureCrashIsPresent(dumpID); + cleanUpMinidump(dumpID); + } + + info("Content process is gone!"); + Assert.ok( + !sawTabCrashed, + "Should not have seen the oop-browser-crashed event." + ); + browser.removeEventListener("oop-browser-crashed", onTabCrashed); + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_default_audio_filename.js b/toolkit/content/tests/browser/browser_default_audio_filename.js new file mode 100644 index 0000000000..c32dda6878 --- /dev/null +++ b/toolkit/content/tests/browser/browser_default_audio_filename.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); +registerCleanupFunction(function () { + MockFilePicker.cleanup(); +}); + +/** + * TestCase for bug 789550 + * <https://bugzilla.mozilla.org/show_bug.cgi?id=789550> + */ +add_task(async function () { + const DATA_AUDIO_URL = await fetch( + getRootDirectory(gTestPath) + "audio_file.txt" + ).then(async response => response.text()); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: DATA_AUDIO_URL, + }, + async function (browser) { + let popupShownPromise = BrowserTestUtils.waitForEvent( + document, + "popupshown" + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "video", + { + type: "contextmenu", + button: 2, + }, + browser + ); + + await popupShownPromise; + + let showFilePickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + is( + fp.defaultString.startsWith("Untitled"), + true, + "File name should be Untitled" + ); + resolve(); + }; + }); + + // Select "Save Audio As" option from context menu + var saveImageAsCommand = document.getElementById("context-saveaudio"); + saveImageAsCommand.doCommand(); + + await showFilePickerPromise; + + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; + } + ); +}); + +/** + * TestCase for bug 789550 + * <https://bugzilla.mozilla.org/show_bug.cgi?id=789550> + */ +add_task(async function () { + const DATA_AUDIO_URL = await fetch( + getRootDirectory(gTestPath) + "audio_file.txt" + ).then(async response => response.text()); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: DATA_AUDIO_URL, + }, + async function (browser) { + let showFilePickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + is( + fp.defaultString.startsWith("Untitled"), + true, + "File name should be Untitled" + ); + resolve(); + }; + }); + + saveBrowser(browser); + + await showFilePickerPromise; + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_default_image_filename.js b/toolkit/content/tests/browser/browser_default_image_filename.js new file mode 100644 index 0000000000..9add704664 --- /dev/null +++ b/toolkit/content/tests/browser/browser_default_image_filename.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); +const DATA_IMAGE_GIF_URL = + "data:image/gif;base64,R0lGODlhEAAOALMAAOazToeHh0tLS/7LZv/0jvb29t/f3//Ub//ge8WSLf/rhf/3kdbW1mxsbP//mf///yH5BAAAAAAALAAAAAAQAA4AAARe8L1Ekyky67QZ1hLnjM5UUde0ECwLJoExKcppV0aCcGCmTIHEIUEqjgaORCMxIC6e0CcguWw6aFjsVMkkIr7g77ZKPJjPZqIyd7sJAgVGoEGv2xsBxqNgYPj/gAwXEQA7"; +registerCleanupFunction(function () { + MockFilePicker.cleanup(); +}); +/** + * TestCase for bug 564387 + * <https://bugzilla.mozilla.org/show_bug.cgi?id=564387> + */ +add_task(async function () { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: DATA_IMAGE_GIF_URL, + }, + async function (browser) { + let popupShownPromise = BrowserTestUtils.waitForEvent( + document, + "popupshown" + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "img", + { + type: "contextmenu", + button: 2, + }, + browser + ); + + await popupShownPromise; + + let showFilePickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + is(fp.defaultString, "Untitled.gif"); + resolve(); + }; + }); + + // Select "Save Image As" option from context menu + var saveImageAsCommand = document.getElementById("context-saveimage"); + saveImageAsCommand.doCommand(); + + await showFilePickerPromise; + + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; + } + ); +}); + +/** + * TestCase for bug 789550 + * <https://bugzilla.mozilla.org/show_bug.cgi?id=789550> + */ +add_task(async function () { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: DATA_IMAGE_GIF_URL, + }, + async function (browser) { + let showFilePickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + is(fp.defaultString, "Untitled.gif"); + resolve(); + }; + }); + + saveBrowser(browser); + + await showFilePickerPromise; + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_default_image_filename_redirect.js b/toolkit/content/tests/browser/browser_default_image_filename_redirect.js new file mode 100644 index 0000000000..a3fdd2d19e --- /dev/null +++ b/toolkit/content/tests/browser/browser_default_image_filename_redirect.js @@ -0,0 +1,53 @@ +/** + * TestCase for bug 1406253 + * <https://bugzilla.mozilla.org/show_bug.cgi?id=1406253> + * + * Load firebird.png, redirect it to doggy.png, and verify the filename is + * doggy.png in file picker dialog. + */ + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); +add_task(async function () { + // This URL will redirect to doggy.png. + const URL_FIREBIRD = + "http://mochi.test:8888/browser/toolkit/content/tests/browser/firebird.png"; + + await BrowserTestUtils.withNewTab(URL_FIREBIRD, async function (browser) { + // Click image to show context menu. + let popupShownPromise = BrowserTestUtils.waitForEvent( + document, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "img", + { type: "contextmenu", button: 2 }, + browser + ); + await popupShownPromise; + + // Prepare mock file picker. + let showFilePickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = fp => resolve(fp.defaultString); + }); + registerCleanupFunction(function () { + MockFilePicker.cleanup(); + }); + + // Select "Save Image As" option from context menu + var saveImageAsCommand = document.getElementById("context-saveimage"); + saveImageAsCommand.doCommand(); + + let filename = await showFilePickerPromise; + is(filename, "doggy.png", "Verify image filename."); + + // Close context menu. + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; + }); +}); diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_cross_origin_iframe.js b/toolkit/content/tests/browser/browser_delay_autoplay_cross_origin_iframe.js new file mode 100644 index 0000000000..63be8b01a0 --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_cross_origin_iframe.js @@ -0,0 +1,91 @@ +/** + * After the tab has been visited, all media should be able to start playing. + * This test is used to ensure that playing media from a cross-origin iframe in + * a tab that has been already visited won't fail. + */ +"use strict"; + +add_task(async function setupTestEnvironment() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.autoplay.default", 0], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +add_task(async function testCrossOriginIframeShouldBeAbleToStart() { + info("Create a new foreground tab"); + const originalTab = gBrowser.selectedTab; + const tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "about:blank" + ); + + info("As tab has been visited, media should be allowed to start"); + const MEDIA_FILE = "gizmo.mp4"; + await SpecialPowers.spawn( + tab.linkedBrowser, + [getTestWebBasedURL(MEDIA_FILE)], + async url => { + let vid = content.document.createElement("video"); + vid.src = url; + ok( + await vid.play().then( + _ => true, + _ => false + ), + "video started playing" + ); + } + ); + + info("Make the tab to background"); + await BrowserTestUtils.switchTab(gBrowser, originalTab); + + info( + "As tab has been visited, a cross-origin iframe should be able to start media" + ); + const IFRAME_FILE = "file_iframe_media.html"; + await createIframe( + tab.linkedBrowser, + getTestWebBasedURL(IFRAME_FILE, { crossOrigin: true }) + ); + await ensureCORSIframeCanStartPlayingMedia(tab.linkedBrowser); + + info("Remove tab"); + BrowserTestUtils.removeTab(tab); +}); + +/** + * Following are helper functions + */ +function createIframe(browser, iframeUrl) { + return SpecialPowers.spawn(browser, [iframeUrl], async url => { + info(`Create iframe and wait until it finishes loading`); + const iframe = content.document.createElement("iframe"); + const iframeLoaded = new Promise(r => (iframe.onload = r)); + iframe.src = url; + content.document.body.appendChild(iframe); + await iframeLoaded; + }); +} + +function ensureCORSIframeCanStartPlayingMedia(browser) { + return SpecialPowers.spawn(browser, [], async _ => { + info(`check if media in iframe can start playing`); + const iframe = content.document.querySelector("iframe"); + if (!iframe) { + ok(false, `can not get the iframe!`); + return; + } + const playPromise = new Promise(r => { + content.onmessage = event => { + is(event.data, "played", `started playing media from CORS iframe`); + r(); + }; + }); + iframe.contentWindow.postMessage("play", "*"); + await playPromise; + }); +} diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_cross_origin_navigation.js b/toolkit/content/tests/browser/browser_delay_autoplay_cross_origin_navigation.js new file mode 100644 index 0000000000..e4613b5da5 --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_cross_origin_navigation.js @@ -0,0 +1,65 @@ +/** + * This test is used to ensure that media would still be able to play even if + * the page has been navigated to a cross-origin url. + */ +"use strict"; + +add_task(async function setupTestEnvironment() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.autoplay.default", 0], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +add_task(async function testCrossOriginNavigation() { + info("Create a new foreground tab"); + const tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "about:blank" + ); + + info("As tab has been visited, media should be allowed to start"); + const MEDIA_FILE = "gizmo.mp4"; + await SpecialPowers.spawn( + tab.linkedBrowser, + [getTestWebBasedURL(MEDIA_FILE)], + async url => { + let vid = content.document.createElement("video"); + vid.src = url; + ok( + await vid.play().then( + _ => true, + _ => false + ), + "video started playing" + ); + } + ); + + info("Navigate to a cross-origin video file"); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + getTestWebBasedURL(MEDIA_FILE, { crossOrigin: true }) + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info( + "As tab has been visited, a cross-origin media should also be able to start" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async _ => { + let vid = content.document.querySelector("video"); + ok(vid, "Video exists"); + ok( + await vid.play().then( + _ => true, + _ => false + ), + "video started playing" + ); + }); + + info("Remove tab"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_media.js b/toolkit/content/tests/browser/browser_delay_autoplay_media.js new file mode 100644 index 0000000000..b369f00a08 --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_media.js @@ -0,0 +1,145 @@ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_multipleAudio.html"; +const PAGE_NO_AUTOPLAY = + "https://example.com/browser/toolkit/content/tests/browser/file_nonAutoplayAudio.html"; + +function check_audio_paused(browser, shouldBePaused) { + return SpecialPowers.spawn(browser, [shouldBePaused], shouldBePaused => { + var autoPlay = content.document.getElementById("autoplay"); + if (!autoPlay) { + ok(false, "Can't get the audio element!"); + } + is( + autoPlay.paused, + shouldBePaused, + "autoplay audio should " + (!shouldBePaused ? "not " : "") + "be paused." + ); + }); +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.useAudioChannelService.testing", true], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +function set_media_autoplay() { + let audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + return; + } + audio.autoplay = true; +} + +add_task(async function delay_media_with_autoplay_keyword() { + info("- open new background tab -"); + const tab = BrowserTestUtils.addTab(window.gBrowser, PAGE_NO_AUTOPLAY); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- set media's autoplay property -"); + await SpecialPowers.spawn(tab.linkedBrowser, [], set_media_autoplay); + + info("- should delay autoplay media -"); + await waitForTabBlockEvent(tab, true); + + info("- switch tab to foreground -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + + info("- media should be resumed because tab has been visited -"); + await waitForTabPlayingEvent(tab, true); + await waitForTabBlockEvent(tab, false); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function delay_media_with_play_invocation() { + info("- open new background tab1 -"); + let tab1 = BrowserTestUtils.addTab(window.gBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab1.linkedBrowser); + + info("- should delay autoplay media for non-visited tab1 -"); + await waitForTabBlockEvent(tab1, true); + + info("- open new background tab2 -"); + let tab2 = BrowserTestUtils.addTab(window.gBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); + + info("- should delay autoplay for non-visited tab2 -"); + await waitForTabBlockEvent(tab2, true); + + info("- switch to tab1 -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab1); + + info("- media in tab1 should be unblocked because the tab was visited -"); + await waitForTabPlayingEvent(tab1, true); + await waitForTabBlockEvent(tab1, false); + + info("- open another new foreground tab3 -"); + let tab3 = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "about:blank" + ); + info("- should still play media from tab1 -"); + await waitForTabPlayingEvent(tab1, true); + + info("- should still block media from tab2 -"); + await waitForTabPlayingEvent(tab2, false); + await waitForTabBlockEvent(tab2, true); + + info("- remove tabs -"); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab3); +}); + +add_task(async function resume_delayed_media_when_enable_blocking_autoplay() { + // Disable autoplay and verify that when a tab is opened in the + // background and has had its playback start delayed, resuming via the audio + // tab indicator overrides the autoplay blocking logic. + // + // Clicking "play" on the audio tab indicator should always start playback + // in that tab, even if it's in an autoplay-blocked origin. + // + // Also test that that this block-autoplay logic override doesn't survive + // a new document being loaded into the tab; the new document should have + // to satisfy the autoplay requirements on its own. + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.autoplay.default", SpecialPowers.Ci.nsIAutoplay.BLOCKED], + ["media.autoplay.blocking_policy", 0], + ], + }); + + info("- open new background tab -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- should block autoplay for non-visited tab -"); + await waitForTabBlockEvent(tab, true); + await check_audio_paused(tab.linkedBrowser, true); + tab.linkedBrowser.resumeMedia(); + + info("- should not block media from tab -"); + await waitForTabPlayingEvent(tab, true); + await check_audio_paused(tab.linkedBrowser, false); + + info( + "- check that loading a new URI in page clears gesture activation status -" + ); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- should block autoplay again as gesture activation status cleared -"); + await check_audio_paused(tab.linkedBrowser, true); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); + + // Clear the block-autoplay pref. + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_media_pausedAfterPlay.js b/toolkit/content/tests/browser/browser_delay_autoplay_media_pausedAfterPlay.js new file mode 100644 index 0000000000..09dc5f9691 --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_media_pausedAfterPlay.js @@ -0,0 +1,121 @@ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_nonAutoplayAudio.html"; + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.useAudioChannelService.testing", true], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +/** + * When media starts in an unvisited tab, we would delay its playback and resume + * media playback when the tab goes to foreground first time. There are two test + * cases are used to check different situations. + * + * The first one is used to check if the delayed media has been paused before + * the tab goes to foreground. Then, when the tab goes to foreground, media + * should still be paused. + * + * The second one is used to check if the delayed media has been paused, but + * it eventually was started again before the tab goes to foreground. Then, when + * the tab goes to foreground, media should be resumed. + */ +add_task(async function testShouldNotResumePausedMedia() { + info("- open new background tab and wait until it finishes loading -"); + const tab = BrowserTestUtils.addTab(window.gBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- play media and then immediately pause it -"); + await doPlayThenPauseOnMedia(tab); + + info("- show delay media playback icon on tab -"); + await waitForTabBlockEvent(tab, true); + + info("- selecting tab as foreground tab would resume the tab -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + + info("- resuming tab should dismiss delay autoplay icon -"); + await waitForTabBlockEvent(tab, false); + + info("- paused media should still be paused -"); + await checkAudioPauseState(tab, true /* should be paused */); + + info("- paused media won't generate tab playing icon -"); + await waitForTabPlayingEvent(tab, false); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testShouldResumePlayedMedia() { + info("- open new background tab and wait until it finishes loading -"); + const tab = BrowserTestUtils.addTab(window.gBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- play media, pause it, then play it again -"); + await doPlayPauseThenPlayOnMedia(tab); + + info("- show delay media playback icon on tab -"); + await waitForTabBlockEvent(tab, true); + + info("- select tab as foreground tab -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + + info("- resuming tab should dismiss delay autoplay icon -"); + await waitForTabBlockEvent(tab, false); + + info("- played media should still be played -"); + await checkAudioPauseState(tab, false /* should be played */); + + info("- played media would generate tab playing icon -"); + await waitForTabPlayingEvent(tab, true); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); + +/** + * Helper functions. + */ +async function checkAudioPauseState(tab, expectedPaused) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [expectedPaused], + expectedPaused => { + const audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + is(audio.paused, expectedPaused, "The pause state of audio is corret."); + } + ); +} + +async function doPlayThenPauseOnMedia(tab) { + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + audio.play(); + audio.pause(); + }); +} + +async function doPlayPauseThenPlayOnMedia(tab) { + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + audio.play(); + audio.pause(); + audio.play(); + }); +} diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_multipleMedia.js b/toolkit/content/tests/browser/browser_delay_autoplay_multipleMedia.js new file mode 100644 index 0000000000..84d021a10a --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_multipleMedia.js @@ -0,0 +1,77 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_multipleAudio.html"; + +function check_autoplay_audio_onplay() { + let autoPlay = content.document.getElementById("autoplay"); + if (!autoPlay) { + ok(false, "Can't get the audio element!"); + } + + return new Promise((resolve, reject) => { + autoPlay.onplay = () => { + ok(false, "Should not receive play event!"); + this.onplay = null; + reject(); + }; + + autoPlay.pause(); + autoPlay.play(); + + content.setTimeout(() => { + ok(true, "Doesn't receive play event when media was blocked."); + autoPlay.onplay = null; + resolve(); + }, 1000); + }); +} + +function play_nonautoplay_audio_should_be_blocked() { + let nonAutoPlay = content.document.getElementById("nonautoplay"); + if (!nonAutoPlay) { + ok(false, "Can't get the audio element!"); + } + + nonAutoPlay.play(); + ok(nonAutoPlay.paused, "The blocked audio can't be playback."); +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.useAudioChannelService.testing", true], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +add_task(async function block_multiple_media() { + info("- open new background tab -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + BrowserTestUtils.startLoadingURIString(browser, PAGE); + await BrowserTestUtils.browserLoaded(browser); + + info("- tab should be blocked -"); + await waitForTabBlockEvent(tab, true); + + info("- autoplay media should be blocked -"); + await SpecialPowers.spawn(browser, [], check_autoplay_audio_onplay); + + info("- non-autoplay can't start playback when the tab is blocked -"); + await SpecialPowers.spawn( + browser, + [], + play_nonautoplay_audio_should_be_blocked + ); + + info("- select tab as foreground tab -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + + info("- tab should be resumed -"); + await waitForTabPlayingEvent(tab, true); + await waitForTabBlockEvent(tab, false); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_notInTreeAudio.js b/toolkit/content/tests/browser/browser_delay_autoplay_notInTreeAudio.js new file mode 100644 index 0000000000..3e2df53648 --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_notInTreeAudio.js @@ -0,0 +1,66 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_nonAutoplayAudio.html"; + +function check_audio_pause_state(expectPause) { + var audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + is(audio.paused, expectPause, "The pause state of audio is corret."); +} + +function play_not_in_tree_audio() { + var audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + content.document.body.removeChild(audio); + + // Add timeout to ensure the audio is removed from DOM tree. + content.setTimeout(function () { + info("Prepare to start playing audio."); + audio.play(); + }, 1000); +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.useAudioChannelService.testing", true], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +add_task(async function block_not_in_tree_media() { + info("- open new background tab -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- tab should not be blocked -"); + await waitForTabBlockEvent(tab, false); + + info("- check audio's playing state -"); + await SpecialPowers.spawn(tab.linkedBrowser, [true], check_audio_pause_state); + + info("- playing audio -"); + await SpecialPowers.spawn(tab.linkedBrowser, [], play_not_in_tree_audio); + + info("- tab should be blocked -"); + await waitForTabBlockEvent(tab, true); + + info("- switch tab -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + + info("- tab should be resumed -"); + await waitForTabBlockEvent(tab, false); + + info("- tab should be audible -"); + await waitForTabPlayingEvent(tab, true); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_playAfterTabVisible.js b/toolkit/content/tests/browser/browser_delay_autoplay_playAfterTabVisible.js new file mode 100644 index 0000000000..198deeb85f --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_playAfterTabVisible.js @@ -0,0 +1,68 @@ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_nonAutoplayAudio.html"; + +function check_audio_pause_state(expectPause) { + var audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + is(audio.paused, expectPause, "The pause state of audio is corret."); +} + +function play_audio() { + var audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + audio.play(); + return new Promise(resolve => { + audio.onplay = function () { + audio.onplay = null; + ok(true, "Audio starts playing."); + resolve(); + }; + }); +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.useAudioChannelService.testing", true], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +/** + * This test is used for testing the visible tab which was not resumed yet. + * If the tab doesn't have any media component, it won't be resumed even it + * has already gone to foreground until we start audio. + */ +add_task(async function media_should_be_able_to_play_in_visible_tab() { + info("- open new background tab, and check tab's media pause state -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await SpecialPowers.spawn(tab.linkedBrowser, [true], check_audio_pause_state); + + info( + "- select tab as foreground tab, and tab's media should still be paused -" + ); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + await SpecialPowers.spawn(tab.linkedBrowser, [true], check_audio_pause_state); + + info("- start audio in tab -"); + await SpecialPowers.spawn(tab.linkedBrowser, [], play_audio); + + info("- audio should be playing -"); + await waitForTabBlockEvent(tab, false); + await SpecialPowers.spawn( + tab.linkedBrowser, + [false], + check_audio_pause_state + ); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_playMediaInMuteTab.js b/toolkit/content/tests/browser/browser_delay_autoplay_playMediaInMuteTab.js new file mode 100644 index 0000000000..c333021697 --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_playMediaInMuteTab.js @@ -0,0 +1,97 @@ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_nonAutoplayAudio.html"; + +function wait_for_event(browser, event) { + return BrowserTestUtils.waitForEvent(browser, event, false, event => { + is( + event.originalTarget, + browser, + "Event must be dispatched to correct browser." + ); + return true; + }); +} + +function check_audio_volume_and_mute(expectedMute) { + var audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + let expectedVolume = expectedMute ? 0.0 : 1.0; + is(expectedVolume, audio.computedVolume, "Audio's volume is correct!"); + is(expectedMute, audio.computedMuted, "Audio's mute state is correct!"); +} + +function check_audio_pause_state(expectedPauseState) { + var audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + is(audio.paused, expectedPauseState, "Audio is paused."); +} + +function play_audio() { + var audio = content.document.getElementById("testAudio"); + if (!audio) { + ok(false, "Can't get the audio element!"); + } + + audio.play(); + ok(audio.paused, "Can't play audio, because the tab was still blocked."); +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.useAudioChannelService.testing", true], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +add_task(async function unblock_icon_should_disapear_after_resume_tab() { + info("- open new background tab -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- audio doesn't be started in beginning -"); + await SpecialPowers.spawn(tab.linkedBrowser, [true], check_audio_pause_state); + + info("- audio shouldn't be muted -"); + await SpecialPowers.spawn( + tab.linkedBrowser, + [false], + check_audio_volume_and_mute + ); + + info("- tab shouldn't display unblocking icon -"); + await waitForTabBlockEvent(tab, false); + + info("- mute tab -"); + tab.linkedBrowser.mute(); + ok(tab.linkedBrowser.audioMuted, "Audio should be muted now"); + + info("- try to start audio in background tab -"); + await SpecialPowers.spawn(tab.linkedBrowser, [], play_audio); + + info("- tab should display unblocking icon -"); + await waitForTabBlockEvent(tab, true); + + info("- select tab as foreground tab -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + + info("- audio shoule be muted, but not be blocked -"); + await SpecialPowers.spawn( + tab.linkedBrowser, + [true], + check_audio_volume_and_mute + ); + + info("- tab should not display unblocking icon -"); + await waitForTabBlockEvent(tab, false); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_silentAudioTrack_media.js b/toolkit/content/tests/browser/browser_delay_autoplay_silentAudioTrack_media.js new file mode 100644 index 0000000000..578d4c1496 --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_silentAudioTrack_media.js @@ -0,0 +1,63 @@ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_silentAudioTrack.html"; + +async function click_unblock_icon(tab) { + let icon = tab.overlayIcon; + + await hover_icon(icon, document.getElementById("tabbrowser-tab-tooltip")); + EventUtils.synthesizeMouseAtCenter(icon, { button: 0 }); + leave_icon(icon); +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.useAudioChannelService.testing", true], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +add_task(async function unblock_icon_should_disapear_after_resume_tab() { + info("- open new background tab -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, "about:blank"); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- tab should display unblocking icon -"); + await waitForTabBlockEvent(tab, true); + + info("- select tab as foreground tab -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + + info("- should not display unblocking icon -"); + await waitForTabBlockEvent(tab, false); + + info("- should not display sound indicator icon -"); + await waitForTabPlayingEvent(tab, false); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function should_not_show_sound_indicator_after_resume_tab() { + info("- open new background tab -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, "about:blank"); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- tab should display unblocking icon -"); + await waitForTabBlockEvent(tab, true); + + info("- click play tab icon -"); + await click_unblock_icon(tab); + + info("- should not display unblocking icon -"); + await waitForTabBlockEvent(tab, false); + + info("- should not display sound indicator icon -"); + await waitForTabPlayingEvent(tab, false); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_delay_autoplay_webAudio.js b/toolkit/content/tests/browser/browser_delay_autoplay_webAudio.js new file mode 100644 index 0000000000..4289c1fec3 --- /dev/null +++ b/toolkit/content/tests/browser/browser_delay_autoplay_webAudio.js @@ -0,0 +1,42 @@ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_webAudio.html"; + +// The tab closing code leaves an uncaught rejection. This test has been +// whitelisted until the issue is fixed. +if (!gMultiProcessBrowser) { + const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" + ); + PromiseTestUtils.expectUncaughtRejection(/is no longer, usable/); +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.useAudioChannelService.testing", true], + ["media.block-autoplay-until-in-foreground", true], + ], + }); +}); + +add_task(async function block_web_audio() { + info("- open new background tab -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, "about:blank"); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("- tab should be blocked -"); + await waitForTabBlockEvent(tab, true); + + info("- switch tab -"); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + + info("- tab should be resumed -"); + await waitForTabBlockEvent(tab, false); + + info("- tab should be audible -"); + await waitForTabPlayingEvent(tab, true); + + info("- remove tab -"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_f7_caret_browsing.js b/toolkit/content/tests/browser/browser_f7_caret_browsing.js new file mode 100644 index 0000000000..be6ae7d1f7 --- /dev/null +++ b/toolkit/content/tests/browser/browser_f7_caret_browsing.js @@ -0,0 +1,367 @@ +var gListener = null; +const kURL = + "data:text/html;charset=utf-8,Caret browsing is fun.<input id='in'>"; + +const kPrefShortcutEnabled = "accessibility.browsewithcaret_shortcut.enabled"; +const kPrefWarnOnEnable = "accessibility.warn_on_browsewithcaret"; +const kPrefCaretBrowsingOn = "accessibility.browsewithcaret"; + +var oldPrefs = {}; +for (let pref of [ + kPrefShortcutEnabled, + kPrefWarnOnEnable, + kPrefCaretBrowsingOn, +]) { + oldPrefs[pref] = Services.prefs.getBoolPref(pref); +} + +Services.prefs.setBoolPref(kPrefShortcutEnabled, true); +Services.prefs.setBoolPref(kPrefWarnOnEnable, true); +Services.prefs.setBoolPref(kPrefCaretBrowsingOn, false); + +registerCleanupFunction(function () { + for (let pref of [ + kPrefShortcutEnabled, + kPrefWarnOnEnable, + kPrefCaretBrowsingOn, + ]) { + Services.prefs.setBoolPref(pref, oldPrefs[pref]); + } +}); + +// NB: not using BrowserTestUtils.promiseAlertDialog here because there's no way to +// undo waiting for a dialog. If we don't want the window to be opened, and +// wait for it to verify that it indeed does not open, we need to be able to +// then "stop" waiting so that when we next *do* want it to open, our "old" +// listener doesn't fire and do things we don't want (like close the window...). +let gCaretPromptOpeningObserver; +function promiseCaretPromptOpened() { + return new Promise(resolve => { + function observer(subject, topic, data) { + info("Dialog opened."); + resolve(subject); + gCaretPromptOpeningObserver(); + } + Services.obs.addObserver(observer, "common-dialog-loaded"); + gCaretPromptOpeningObserver = () => { + Services.obs.removeObserver(observer, "common-dialog-loaded"); + gCaretPromptOpeningObserver = () => {}; + }; + }); +} + +function hitF7() { + SimpleTest.executeSoon(() => EventUtils.synthesizeKey("KEY_F7")); +} + +async function toggleCaretNoDialog(expected) { + let openedDialog = false; + promiseCaretPromptOpened().then(function (win) { + openedDialog = true; + win.close(); // This will eventually return focus here and allow the test to continue... + }); + // Cause the dialog to appear synchronously when focused element is in chrome, + // otherwise, i.e., when focused element is in remote content, it appears + // asynchronously. + const focusedElementInChrome = Services.focus.focusedElement; + const isAsync = focusedElementInChrome?.isRemoteBrowser; + const waitForF7KeyHandled = new Promise(resolve => { + let eventCount = 0; + const expectedEventCount = isAsync ? 2 : 1; + let listener = async event => { + if (event.key == "F7") { + info("F7 keypress is fired"); + if (++eventCount == expectedEventCount) { + window.removeEventListener("keypress", listener, { + capture: true, + mozSystemGroup: true, + }); + // Wait for the event handled in chrome. + await TestUtils.waitForTick(); + resolve(); + return; + } + info( + "Waiting for next F7 keypress which is a reply event from the remote content" + ); + } + }; + info( + `Synthesizing "F7" key press and wait ${expectedEventCount} keypress events...` + ); + window.addEventListener("keypress", listener, { + capture: true, + mozSystemGroup: true, + }); + }); + hitF7(); + await waitForF7KeyHandled; + + let expectedStr = expected ? "on." : "off."; + ok( + !openedDialog, + "Shouldn't open a dialog to turn caret browsing " + expectedStr + ); + // Need to clean up if the dialog wasn't opened, so the observer doesn't get + // re-triggered later on causing "issues". + if (!openedDialog) { + gCaretPromptOpeningObserver(); + } + let prefVal = Services.prefs.getBoolPref(kPrefCaretBrowsingOn); + is(prefVal, expected, "Caret browsing should now be " + expectedStr); +} + +function waitForFocusOnInput(browser) { + return SpecialPowers.spawn(browser, [], async function () { + let textEl = content.document.getElementById("in"); + return ContentTaskUtils.waitForCondition(() => { + return content.document.activeElement == textEl; + }, "Input should get focused."); + }); +} + +function focusInput(browser) { + return SpecialPowers.spawn(browser, [], async function () { + let textEl = content.document.getElementById("in"); + textEl.focus(); + }); +} + +add_task(async function checkTogglingCaretBrowsing() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, kURL); + await focusInput(tab.linkedBrowser); + + let promiseGotKey = promiseCaretPromptOpened(); + hitF7(); + let prompt = await promiseGotKey; + let doc = prompt.document; + let dialog = doc.getElementById("commonDialog"); + is(dialog.defaultButton, "cancel", "No button should be the default"); + ok( + !doc.getElementById("checkbox").checked, + "Checkbox shouldn't be checked by default." + ); + let promiseDialogUnloaded = BrowserTestUtils.waitForEvent(prompt, "unload"); + + dialog.cancelDialog(); + await promiseDialogUnloaded; + info("Dialog unloaded"); + await waitForFocusOnInput(tab.linkedBrowser); + ok( + !Services.prefs.getBoolPref(kPrefCaretBrowsingOn), + "Caret browsing should still be off after cancelling the dialog." + ); + + promiseGotKey = promiseCaretPromptOpened(); + hitF7(); + prompt = await promiseGotKey; + + doc = prompt.document; + dialog = doc.getElementById("commonDialog"); + is(dialog.defaultButton, "cancel", "No button should be the default"); + ok( + !doc.getElementById("checkbox").checked, + "Checkbox shouldn't be checked by default." + ); + promiseDialogUnloaded = BrowserTestUtils.waitForEvent(prompt, "unload"); + + dialog.acceptDialog(); + await promiseDialogUnloaded; + info("Dialog unloaded"); + await waitForFocusOnInput(tab.linkedBrowser); + ok( + Services.prefs.getBoolPref(kPrefCaretBrowsingOn), + "Caret browsing should be on after accepting the dialog." + ); + + await toggleCaretNoDialog(false); + + promiseGotKey = promiseCaretPromptOpened(); + hitF7(); + prompt = await promiseGotKey; + doc = prompt.document; + dialog = doc.getElementById("commonDialog"); + + is(dialog.defaultButton, "cancel", "No button should be the default"); + ok( + !doc.getElementById("checkbox").checked, + "Checkbox shouldn't be checked by default." + ); + + promiseDialogUnloaded = BrowserTestUtils.waitForEvent(prompt, "unload"); + dialog.cancelDialog(); + await promiseDialogUnloaded; + info("Dialog unloaded"); + await waitForFocusOnInput(tab.linkedBrowser); + + ok( + !Services.prefs.getBoolPref(kPrefCaretBrowsingOn), + "Caret browsing should still be off after cancelling the dialog." + ); + + Services.prefs.setBoolPref(kPrefShortcutEnabled, true); + Services.prefs.setBoolPref(kPrefWarnOnEnable, true); + Services.prefs.setBoolPref(kPrefCaretBrowsingOn, false); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function toggleCheckboxNoCaretBrowsing() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, kURL); + await focusInput(tab.linkedBrowser); + + let promiseGotKey = promiseCaretPromptOpened(); + hitF7(); + let prompt = await promiseGotKey; + let doc = prompt.document; + let dialog = doc.getElementById("commonDialog"); + is(dialog.defaultButton, "cancel", "No button should be the default"); + let checkbox = doc.getElementById("checkbox"); + ok(!checkbox.checked, "Checkbox shouldn't be checked by default."); + + // Check the box: + checkbox.click(); + + let promiseDialogUnloaded = BrowserTestUtils.waitForEvent(prompt, "unload"); + + // Say no: + dialog.getButton("cancel").click(); + + await promiseDialogUnloaded; + info("Dialog unloaded"); + await waitForFocusOnInput(tab.linkedBrowser); + ok( + !Services.prefs.getBoolPref(kPrefCaretBrowsingOn), + "Caret browsing should still be off." + ); + ok( + !Services.prefs.getBoolPref(kPrefShortcutEnabled), + "Shortcut should now be disabled." + ); + + await toggleCaretNoDialog(false); + ok( + !Services.prefs.getBoolPref(kPrefShortcutEnabled), + "Shortcut should still be disabled." + ); + + Services.prefs.setBoolPref(kPrefShortcutEnabled, true); + Services.prefs.setBoolPref(kPrefWarnOnEnable, true); + Services.prefs.setBoolPref(kPrefCaretBrowsingOn, false); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function toggleCheckboxWantCaretBrowsing() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, kURL); + await focusInput(tab.linkedBrowser); + + let promiseGotKey = promiseCaretPromptOpened(); + hitF7(); + let prompt = await promiseGotKey; + let doc = prompt.document; + let dialog = doc.getElementById("commonDialog"); + is(dialog.defaultButton, "cancel", "No button should be the default"); + let checkbox = doc.getElementById("checkbox"); + ok(!checkbox.checked, "Checkbox shouldn't be checked by default."); + + // Check the box: + checkbox.click(); + + let promiseDialogUnloaded = BrowserTestUtils.waitForEvent(prompt, "unload"); + + // Say yes: + dialog.acceptDialog(); + await promiseDialogUnloaded; + info("Dialog unloaded"); + await waitForFocusOnInput(tab.linkedBrowser); + ok( + Services.prefs.getBoolPref(kPrefCaretBrowsingOn), + "Caret browsing should now be on." + ); + ok( + Services.prefs.getBoolPref(kPrefShortcutEnabled), + "Shortcut should still be enabled." + ); + ok( + !Services.prefs.getBoolPref(kPrefWarnOnEnable), + "Should no longer warn when enabling." + ); + + await toggleCaretNoDialog(false); + await toggleCaretNoDialog(true); + await toggleCaretNoDialog(false); + + Services.prefs.setBoolPref(kPrefShortcutEnabled, true); + Services.prefs.setBoolPref(kPrefWarnOnEnable, true); + Services.prefs.setBoolPref(kPrefCaretBrowsingOn, false); + + BrowserTestUtils.removeTab(tab); +}); + +// Test for bug 1743878: Many repeated modal caret-browsing dialogs, if you +// accidentally hold down F7 for a few seconds +add_task(async function testF7SpamDoesNotOpenDialogs() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + registerCleanupFunction(() => BrowserTestUtils.removeTab(tab)); + + let promiseGotKey = promiseCaretPromptOpened(); + hitF7(); + let prompt = await promiseGotKey; + let doc = prompt.document; + let dialog = doc.getElementById("commonDialog"); + + let promiseDialogUnloaded = BrowserTestUtils.waitForEvent(prompt, "unload"); + + // Listen for an additional prompt to open, which should not happen. + let promiseDialogOrTimeout = () => + Promise.race([ + promiseCaretPromptOpened(), + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + new Promise(resolve => setTimeout(resolve, 100)), + ]); + + let openedPromise = promiseDialogOrTimeout(); + + // Hit F7 two more times: once to test that _awaitingToggleCaretBrowsingPrompt + // is applied, and again to test that its value isn't somehow reset by + // pressing F7 while the dialog is open. + for (let i = 0; i < 2; i++) { + await new Promise(resolve => + SimpleTest.executeSoon(() => { + hitF7(); + resolve(); + }) + ); + } + + // Say no: + dialog.cancelDialog(); + await promiseDialogUnloaded; + info("Dialog unloaded"); + + let openedDialog = await openedPromise; + ok(!openedDialog, "No additional dialog should have opened."); + + // If the test fails, clean up any dialogs we erroneously opened so they don't + // interfere with other tests. + let extraDialogs = 0; + while (openedDialog) { + extraDialogs += 1; + let doc = openedDialog.document; + let dialog = doc.getElementById("commonDialog"); + openedPromise = promiseDialogOrTimeout(); + dialog.cancelDialog(); + openedDialog = await openedPromise; + } + if (extraDialogs) { + info(`Closed ${extraDialogs} extra dialogs.`); + } + + // Either way, we now have an extra observer, so clean it up. + gCaretPromptOpeningObserver(); + + Services.prefs.setBoolPref(kPrefShortcutEnabled, true); + Services.prefs.setBoolPref(kPrefWarnOnEnable, true); + Services.prefs.setBoolPref(kPrefCaretBrowsingOn, false); +}); diff --git a/toolkit/content/tests/browser/browser_findbar.js b/toolkit/content/tests/browser/browser_findbar.js new file mode 100644 index 0000000000..144c9dfdc1 --- /dev/null +++ b/toolkit/content/tests/browser/browser_findbar.js @@ -0,0 +1,564 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +requestLongerTimeout(2); + +const TEST_PAGE_URI = "data:text/html;charset=utf-8,The letter s."; +// Using 'javascript' schema to bypass E10SUtils.canLoadURIInRemoteType, because +// it does not allow 'data:' URI to be loaded in the parent process. +const E10S_PARENT_TEST_PAGE_URI = + getRootDirectory(gTestPath) + "file_empty.html"; +const TEST_PAGE_URI_WITHIFRAME = + "https://example.com/browser/toolkit/content/tests/browser/file_findinframe.html"; + +/** + * Makes sure that the findbar hotkeys (' and /) event listeners + * are added to the system event group and do not get blocked + * by calling stopPropagation on a keypress event on a page. + */ +add_task(async function test_hotkey_event_propagation() { + info("Ensure hotkeys are not affected by stopPropagation."); + + // Opening new tab + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + let browser = gBrowser.getBrowserForTab(tab); + let findbar = await gBrowser.getFindBar(); + + // Pressing these keys open the findbar. + const HOTKEYS = ["/", "'"]; + + // Checking if findbar appears when any hotkey is pressed. + for (let key of HOTKEYS) { + is(findbar.hidden, true, "Findbar is hidden now."); + gBrowser.selectedTab = tab; + await SimpleTest.promiseFocus(gBrowser.selectedBrowser); + await BrowserTestUtils.sendChar(key, browser); + is(findbar.hidden, false, "Findbar should not be hidden."); + await closeFindbarAndWait(findbar); + } + + // Stop propagation for all keyboard events. + await SpecialPowers.spawn(browser, [], () => { + const stopPropagation = e => { + e.stopImmediatePropagation(); + }; + let window = content.document.defaultView; + window.addEventListener("keydown", stopPropagation); + window.addEventListener("keypress", stopPropagation); + window.addEventListener("keyup", stopPropagation); + }); + + // Checking if findbar still appears when any hotkey is pressed. + for (let key of HOTKEYS) { + is(findbar.hidden, true, "Findbar is hidden now."); + gBrowser.selectedTab = tab; + await SimpleTest.promiseFocus(gBrowser.selectedBrowser); + await BrowserTestUtils.sendChar(key, browser); + is(findbar.hidden, false, "Findbar should not be hidden."); + await closeFindbarAndWait(findbar); + } + + gBrowser.removeTab(tab); +}); + +add_task(async function test_not_found() { + info("Check correct 'Phrase not found' on new tab"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + + // Search for the first word. + await promiseFindFinished(gBrowser, "--- THIS SHOULD NEVER MATCH ---", false); + let findbar = gBrowser.getCachedFindBar(); + is( + findbar._findStatusDesc.dataset.l10nId, + "findbar-not-found", + "Findbar status text should be 'Phrase not found'" + ); + + gBrowser.removeTab(tab); +}); + +add_task(async function test_found() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + + // Search for a string that WILL be found, with 'Highlight All' on + await promiseFindFinished(gBrowser, "S", true); + Assert.strictEqual( + gBrowser.getCachedFindBar()._findStatusDesc.dataset.l10nId, + undefined, + "Findbar status should be empty" + ); + + gBrowser.removeTab(tab); +}); + +// Setting first findbar to case-sensitive mode should not affect +// new tab find bar. +add_task(async function test_tabwise_case_sensitive() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + let findbar1 = await gBrowser.getFindBar(); + + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + let findbar2 = await gBrowser.getFindBar(); + + // Toggle case sensitivity for first findbar + findbar1.getElement("find-case-sensitive").click(); + + gBrowser.selectedTab = tab1; + + // Not found for first tab. + await promiseFindFinished(gBrowser, "S", true); + is( + findbar1._findStatusDesc.dataset.l10nId, + "findbar-not-found", + "Findbar status text should be 'Phrase not found'" + ); + + gBrowser.selectedTab = tab2; + + // But it didn't affect the second findbar. + await promiseFindFinished(gBrowser, "S", true); + Assert.strictEqual( + findbar2._findStatusDesc.dataset.l10nId, + undefined, + "Findbar status should be empty" + ); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); +}); + +/** + * Navigating from a web page (for example mozilla.org) to an internal page + * (like about:addons) might trigger a change of browser's remoteness. + * 'Remoteness change' means that rendering page content moves from child + * process into the parent process or the other way around. + * This test ensures that findbar properly handles such a change. + */ +add_task(async function test_reinitialization_at_remoteness_change() { + // This test only makes sence in e10s evironment. + if (!gMultiProcessBrowser) { + info("Skipping this test because of non-e10s environment."); + return; + } + + info("Ensure findbar re-initialization at remoteness change."); + + // Load a remote page and trigger findbar construction. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + let browser = gBrowser.getBrowserForTab(tab); + let findbar = await gBrowser.getFindBar(); + + // Findbar should operate normally. + await promiseFindFinished(gBrowser, "z", false); + is( + findbar._findStatusDesc.dataset.l10nId, + "findbar-not-found", + "Findbar status text should be 'Phrase not found'" + ); + + await promiseFindFinished(gBrowser, "s", false); + Assert.strictEqual( + findbar._findStatusDesc.dataset.l10nId, + undefined, + "Findbar status should be empty" + ); + + // Moving browser into the parent process and reloading sample data. + ok(browser.isRemoteBrowser, "Browser should be remote now."); + await promiseRemotenessChange(tab, false); + let docLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + E10S_PARENT_TEST_PAGE_URI + ); + BrowserTestUtils.startLoadingURIString(browser, E10S_PARENT_TEST_PAGE_URI); + await docLoaded; + ok(!browser.isRemoteBrowser, "Browser should not be remote any more."); + browser.contentDocument.body.append("The letter s."); + browser.contentDocument.body.clientHeight; // Force flush. + + // Findbar should keep operating normally after remoteness change. + await promiseFindFinished(gBrowser, "z", false); + is( + findbar._findStatusDesc.dataset.l10nId, + "findbar-not-found", + "Findbar status text should be 'Phrase not found'" + ); + + await promiseFindFinished(gBrowser, "s", false); + Assert.strictEqual( + findbar._findStatusDesc.dataset.l10nId, + undefined, + "Findbar status should be empty" + ); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * Ensure that the initial typed characters aren't lost immediately after + * opening the find bar. + */ +add_task(async function e10sLostKeys() { + // This test only makes sence in e10s evironment. + if (!gMultiProcessBrowser) { + info("Skipping this test because of non-e10s environment."); + return; + } + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + + ok(!gFindBarInitialized, "findbar isn't initialized yet"); + + await gFindBarPromise; + let findBar = gFindBar; + let initialValue = findBar._findField.value; + + await EventUtils.synthesizeAndWaitKey( + "F", + { accelKey: true }, + window, + null, + () => { + // We can't afford to wait for the promise to resolve, by then the + // find bar is visible and focused, so sending characters to the + // content browser wouldn't work. + isnot( + document.activeElement, + findBar._findField, + "findbar is not yet focused" + ); + EventUtils.synthesizeKey("a"); + EventUtils.synthesizeKey("b"); + EventUtils.synthesizeKey("c"); + is( + findBar._findField.value, + initialValue, + "still has initial find query" + ); + } + ); + + await BrowserTestUtils.waitForCondition( + () => findBar._findField.value.length == 3 + ); + is(document.activeElement, findBar._findField, "findbar is now focused"); + is(findBar._findField.value, "abc", "abc fully entered as find query"); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * This test makes sure that keyboard operations still occur + * after the findbar is opened and closed. + */ +add_task(async function test_open_and_close_keys() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "data:text/html,<body style='height: 5000px;'>Hello There</body>" + ); + + await gFindBarPromise; + let findBar = gFindBar; + + is(findBar.hidden, true, "Findbar is hidden now."); + let openedPromise = BrowserTestUtils.waitForEvent(findBar, "findbaropen"); + await EventUtils.synthesizeKey("f", { accelKey: true }); + await openedPromise; + + is(findBar.hidden, false, "Findbar should not be hidden."); + + let closedPromise = BrowserTestUtils.waitForEvent(findBar, "findbarclose"); + await EventUtils.synthesizeKey("KEY_Escape"); + await closedPromise; + + let scrollPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "scroll" + ); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await scrollPromise; + + let scrollPosition = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + async function () { + return content.document.body.scrollTop; + } + ); + + Assert.greater(scrollPosition, 0, "Scrolled ok to " + scrollPosition); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * This test makes sure that keyboard navigation (for example arrow up/down, + * accel+arrow up/down) still works while the findbar is open. + */ +add_task(async function test_input_keypress() { + await SpecialPowers.pushPrefEnv({ set: [["general.smoothScroll", false]] }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + /* html */ + `data:text/html, + <!DOCTYPE html> + <body style='height: 5000px;'> + Hello There + </body>` + ); + + await gFindBarPromise; + let findBar = gFindBar; + + is(findBar.hidden, true, "Findbar is hidden now."); + let openedPromise = BrowserTestUtils.waitForEvent(findBar, "findbaropen"); + await EventUtils.synthesizeKey("f", { accelKey: true }); + await openedPromise; + + is(findBar.hidden, false, "Findbar should not be hidden."); + + let scrollPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "scroll" + ); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await scrollPromise; + + await ContentTask.spawn(tab.linkedBrowser, null, async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.defaultView.innerHeight + + content.document.defaultView.pageYOffset > + 0, + "Scroll with ArrowDown" + ); + }); + + let completeScrollPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "scroll" + ); + await EventUtils.synthesizeKey("KEY_ArrowDown", { accelKey: true }); + await completeScrollPromise; + + await ContentTask.spawn(tab.linkedBrowser, null, async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.defaultView.innerHeight + + content.document.defaultView.pageYOffset >= + content.document.body.offsetHeight, + "Scroll with Accel+ArrowDown" + ); + }); + + let closedPromise = BrowserTestUtils.waitForEvent(findBar, "findbarclose"); + await EventUtils.synthesizeKey("KEY_Escape"); + await closedPromise; + + info("Scrolling ok"); + + BrowserTestUtils.removeTab(tab); +}); + +// This test loads an editable area within an iframe and then +// performs a search. Focusing the editable area should still +// allow keyboard events to be received. +add_task(async function test_hotkey_insubframe() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI_WITHIFRAME + ); + + await gFindBarPromise; + let findBar = gFindBar; + + // Focus the editable area within the frame. + let browser = gBrowser.selectedBrowser; + let frameBC = browser.browsingContext.children[0]; + await SpecialPowers.spawn(frameBC, [], async () => { + content.document.body.focus(); + content.document.defaultView.focus(); + }); + + // Start a find and wait for the findbar to open. + let findBarOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser, + "findbaropen" + ); + EventUtils.synthesizeKey("f", { accelKey: true }); + await findBarOpenPromise; + + // Opening the findbar would have focused the find textbox. + // Focus the editable area again. + let cursorPos = await SpecialPowers.spawn(frameBC, [], async () => { + content.document.body.focus(); + content.document.defaultView.focus(); + return content.getSelection().anchorOffset; + }); + is(cursorPos, 0, "initial cursor position"); + + // Try moving the caret. + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, frameBC); + + cursorPos = await SpecialPowers.spawn(frameBC, [], async () => { + return content.getSelection().anchorOffset; + }); + is(cursorPos, 1, "cursor moved"); + + await closeFindbarAndWait(findBar); + gBrowser.removeTab(tab); +}); + +/** + * Reloading a page should use the same match case / whole word + * state for the search. + */ +add_task(async function test_preservestate_on_reload() { + for (let stateChange of ["case-sensitive", "entire-word"]) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "data:text/html,<!DOCTYPE html><p>There is a cat named Theo in the kitchen with another cat named Catherine. The two of them are thirsty." + ); + + // Start a find and wait for the findbar to open. + let findBarOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser, + "findbaropen" + ); + EventUtils.synthesizeKey("f", { accelKey: true }); + await findBarOpenPromise; + + let isEntireWord = stateChange == "entire-word"; + + let findbar = await gBrowser.getFindBar(); + + // Find some text. + let promiseMatches = promiseGetMatchCount(findbar); + await promiseFindFinished(gBrowser, "The", true); + + let matches = await promiseMatches; + is(matches.current, 1, "Correct match position " + stateChange); + is(matches.total, 7, "Correct number of matches " + stateChange); + + // Turn on the case sensitive or entire word option. + findbar.getElement("find-" + stateChange).click(); + + promiseMatches = promiseGetMatchCount(findbar); + gFindBar.onFindAgainCommand(); + matches = await promiseMatches; + is( + matches.current, + 2, + "Correct match position after state change matches " + stateChange + ); + is( + matches.total, + isEntireWord ? 2 : 3, + "Correct number after state change matches " + stateChange + ); + + // Reload the page. + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + true + ); + gBrowser.reload(); + await loadedPromise; + + // Perform a find again. The state should be preserved. + promiseMatches = promiseGetMatchCount(findbar); + gFindBar.onFindAgainCommand(); + matches = await promiseMatches; + is( + matches.current, + 1, + "Correct match position after reload and find again " + stateChange + ); + is( + matches.total, + isEntireWord ? 2 : 3, + "Correct number of matches after reload and find again " + stateChange + ); + + // Turn off the case sensitive or entire word option and find again. + findbar.getElement("find-" + stateChange).click(); + + promiseMatches = promiseGetMatchCount(findbar); + gFindBar.onFindAgainCommand(); + matches = await promiseMatches; + + is( + matches.current, + isEntireWord ? 4 : 2, + "Correct match position after reload and find again reset " + stateChange + ); + is( + matches.total, + 7, + "Correct number of matches after reload and find again reset " + + stateChange + ); + + findbar.clear(); + await closeFindbarAndWait(findbar); + + gBrowser.removeTab(tab); + } +}); + +function promiseGetMatchCount(findbar) { + return new Promise(resolve => { + let resultListener = { + onFindResult() {}, + onCurrentSelection() {}, + onHighlightFinished() {}, + onMatchesCountResult(response) { + if (response.total > 0) { + findbar.browser.finder.removeResultListener(resultListener); + resolve(response); + } + }, + }; + findbar.browser.finder.addResultListener(resultListener); + }); +} + +function promiseRemotenessChange(tab, shouldBeRemote) { + return new Promise(resolve => { + let browser = gBrowser.getBrowserForTab(tab); + tab.addEventListener( + "TabRemotenessChange", + function () { + resolve(); + }, + { once: true } + ); + let remoteType = shouldBeRemote + ? E10SUtils.DEFAULT_REMOTE_TYPE + : E10SUtils.NOT_REMOTE; + gBrowser.updateBrowserRemoteness(browser, { remoteType }); + }); +} diff --git a/toolkit/content/tests/browser/browser_findbar_disabled_manual.js b/toolkit/content/tests/browser/browser_findbar_disabled_manual.js new file mode 100644 index 0000000000..ef37f7dc9a --- /dev/null +++ b/toolkit/content/tests/browser/browser_findbar_disabled_manual.js @@ -0,0 +1,33 @@ +const TEST_PAGE_URI = "data:text/html;charset=utf-8,The letter s."; + +// Disable manual (FAYT) findbar hotkeys. +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [["accessibility.typeaheadfind.manual", false]], + }); +}); + +// Makes sure that the findbar hotkeys (' and /) have no effect. +add_task(async function test_hotkey_disabled() { + // Opening new tab. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + let browser = gBrowser.getBrowserForTab(tab); + let findbar = await gBrowser.getFindBar(); + + // Pressing these keys open the findbar normally. + const HOTKEYS = ["/", "'"]; + + // Make sure no findbar appears when pressed. + for (let key of HOTKEYS) { + is(findbar.hidden, true, "Findbar is hidden now."); + gBrowser.selectedTab = tab; + await SimpleTest.promiseFocus(gBrowser.selectedBrowser); + await BrowserTestUtils.sendChar(key, browser); + is(findbar.hidden, true, "Findbar should still be hidden."); + } + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_findbar_hiddenframes.js b/toolkit/content/tests/browser/browser_findbar_hiddenframes.js new file mode 100644 index 0000000000..f0f4131464 --- /dev/null +++ b/toolkit/content/tests/browser/browser_findbar_hiddenframes.js @@ -0,0 +1,59 @@ +const TEST_PAGE = "https://example.com/document-builder.sjs?html="; + +let content = + "<html><body><iframe id='a' src='data:text/html,This is the first page'></iframe><iframe id='b' src='data:text/html,That is another page'></iframe></body></html>"; + +async function doAndCheckFind(bc, text) { + await promiseFindFinished(gBrowser, text, false); + + let foundText = await SpecialPowers.spawn(bc, [], () => { + return content.getSelection().toString(); + }); + is(foundText, text, text + " is found"); +} + +// This test verifies that find continues to work when a find begins and the frame +// is hidden during the next find step. +add_task(async function test_frame() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE + content + ); + let browser = gBrowser.getBrowserForTab(tab); + + let findbar = await gBrowser.getFindBar(); + + await doAndCheckFind(browser.browsingContext.children[0], "This"); + + await SpecialPowers.spawn(browser, [], () => { + content.document.getElementById("a").style.display = "none"; + content.document.getElementById("a").getBoundingClientRect(); // flush + }); + + await doAndCheckFind(browser.browsingContext.children[1], "another"); + + await SpecialPowers.spawn(browser, [], () => { + content.document.getElementById("a").style.display = ""; + content.document.getElementById("a").getBoundingClientRect(); + }); + + await doAndCheckFind(browser.browsingContext.children[0], "first"); + + await SpecialPowers.spawn(browser, [], () => { + content.document.getElementById("a").style.visibility = "hidden"; + content.document.getElementById("a").getBoundingClientRect(); + }); + + await doAndCheckFind(browser.browsingContext.children[1], "That"); + + await SpecialPowers.spawn(browser, [], () => { + content.document.getElementById("a").style.visibility = ""; + content.document.getElementById("a").getBoundingClientRect(); + }); + + await doAndCheckFind(browser.browsingContext.children[0], "This"); + + await closeFindbarAndWait(findbar); + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_findbar_marks.js b/toolkit/content/tests/browser/browser_findbar_marks.js new file mode 100644 index 0000000000..bd025086f7 --- /dev/null +++ b/toolkit/content/tests/browser/browser_findbar_marks.js @@ -0,0 +1,263 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +// This test verifies that the find scrollbar marks are triggered in the right locations. +// Reftests in layout/xul/reftest are used to verify their appearance. + +const TEST_PAGE_URI = + "data:text/html,<body style='font-size: 20px; margin: 0;'><p style='margin: 0; block-size: 30px;'>This is some fun text.</p><p style='margin-block-start: 2000px; block-size: 30px;'>This is some tex to find.</p><p style='margin-block-start: 500px; block-size: 30px;'>This is some text to find.</p></body>"; + +let gUpdateCount = 0; + +requestLongerTimeout(5); + +function initForBrowser(browser) { + gUpdateCount = 0; + + browser.sendMessageToActor( + "Finder:EnableMarkTesting", + { enable: true }, + "Finder" + ); + + let checkFn = event => { + event.target.lastMarks = event.detail; + event.target.eventsCount = event.target.eventsCount + ? event.target.eventsCount + 1 + : 1; + return false; + }; + + let endFn = BrowserTestUtils.addContentEventListener( + browser, + "find-scrollmarks-changed", + () => {}, + { capture: true }, + checkFn + ); + + return () => { + browser.sendMessageToActor( + "Finder:EnableMarkTesting", + { enable: false }, + "Finder" + ); + + endFn(); + }; +} + +add_task(async function test_findmarks() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + + // Open the findbar so that the content scroll size can be measured. + await promiseFindFinished(gBrowser, "s"); + + let browser = tab.linkedBrowser; + let scrollMaxY = await SpecialPowers.spawn(browser, [], () => { + return content.scrollMaxY; + }); + + let endFn = initForBrowser(browser); + + for (let step = 0; step < 3; step++) { + // If the document root or body is absolutely positioned, this can affect the scroll height. + await SpecialPowers.spawn(browser, [step], stepChild => { + let document = content.document; + let adjustments = [ + () => {}, + () => { + document.documentElement.style.position = "absolute;"; + }, + () => { + document.documentElement.style.position = ""; + document.body.style.position = "absolute"; + }, + ]; + + adjustments[stepChild](); + }); + + // For the first value, get the numbers and ensure that they are approximately + // in the right place. Later tests should give the same values. + await promiseFindFinished(gBrowser, "tex", true); + + let values = await getMarks(browser, true); + + // The exact values vary on each platform, so use fuzzy matches. + // 2610 is the approximate expected document height, and + // 10, 2040, 2570 are the approximate positions of the marks. + const expectedDocHeight = 2610; + isfuzzy( + values[0], + Math.round(10 * (scrollMaxY / expectedDocHeight)), + 10, + "first value" + ); + isfuzzy( + values[1], + Math.round(2040 * (scrollMaxY / expectedDocHeight)), + 10, + "second value" + ); + isfuzzy( + values[2], + Math.round(2570 * (scrollMaxY / expectedDocHeight)), + 10, + "third value" + ); + + await doAndVerifyFind(browser, "text", true, [values[0], values[2]]); + await doAndVerifyFind(browser, "", true, []); + await doAndVerifyFind(browser, "isz", false, [], true); // marks should not be updated here + await doAndVerifyFind(browser, "tex", true, values); + await doAndVerifyFind(browser, "isz", true, []); + await doAndVerifyFind(browser, "tex", true, values); + + let findbar = await gBrowser.getFindBar(); + let closedPromise = BrowserTestUtils.waitForEvent(findbar, "findbarclose"); + await EventUtils.synthesizeKey("KEY_Escape"); + await closedPromise; + + await verifyFind(browser, "", true, []); + } + + endFn(); + + gBrowser.removeTab(tab); +}); + +add_task(async function test_findmarks_vertical() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + let browser = tab.linkedBrowser; + let endFn = initForBrowser(browser); + + for (let mode of [ + "sideways-lr", + "sideways-rl", + "vertical-lr", + "vertical-rl", + ]) { + const maxMarkPos = await SpecialPowers.spawn( + browser, + [mode], + writingMode => { + let document = content.document; + document.documentElement.style.writingMode = writingMode; + + return content.scrollMaxX - content.scrollMinX; + } + ); + + await promiseFindFinished(gBrowser, "tex", true); + const marks = await getMarks(browser, true, true); + Assert.equal(marks.length, 3, `marks count with text "tex"`); + for (const markPos of marks) { + Assert.ok( + 0 <= markPos <= maxMarkPos, + `mark position ${markPos} should be in the range 0 ~ ${maxMarkPos}` + ); + } + } + + endFn(); + gBrowser.removeTab(tab); +}); + +// This test verifies what happens when scroll marks are visible and the window is resized. +add_task(async function test_found_resize() { + let window2 = await BrowserTestUtils.openNewBrowserWindow({}); + let tab = await BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + TEST_PAGE_URI + ); + + let browser = tab.linkedBrowser; + let endFn = initForBrowser(browser); + + await promiseFindFinished(window2.gBrowser, "tex", true); + let values = await getMarks(browser, true); + + let resizePromise = BrowserTestUtils.waitForContentEvent( + browser, + "resize", + true + ); + window2.resizeTo(window2.outerWidth - 100, window2.outerHeight - 80); + await resizePromise; + + // Some number of extra scrollbar adjustment and painting events can occur + // when resizing the window, so don't use an exact match for the count. + let resizedValues = await getMarks(browser, true); + info(`values: ${JSON.stringify(values)}`); + info(`resizedValues: ${JSON.stringify(resizedValues)}`); + isfuzzy(resizedValues[0], values[0], 2, "first value"); + Assert.greater(resizedValues[1] - 50, values[1], "second value"); + Assert.greater(resizedValues[2] - 50, values[2], "third value"); + + endFn(); + + await BrowserTestUtils.closeWindow(window2); +}); + +// Returns the scroll marks that should have been assigned +// to the scrollbar after a find. As a side effect, also +// verifies that the marks have been updated since the last +// call to getMarks. If increase is true, then the marks should +// have been updated, and if increase is false, the marks should +// not have been updated. +async function getMarks(browser, increase, shouldBeOnHScrollbar = false) { + let results = await SpecialPowers.spawn(browser, [], () => { + let { marks, onHorizontalScrollbar } = content.lastMarks; + content.lastMarks = {}; + return { + onHorizontalScrollbar, + marks: marks || [], + count: content.eventsCount, + }; + }); + + // The marks are updated whenever the scrollbar is updated and + // this could happen several times as either a find for multiple + // characters occurs. This check allows for mutliple updates to occur. + if (increase) { + Assert.ok(results.count > gUpdateCount, "expected events count"); + + Assert.strictEqual( + results.onHorizontalScrollbar, + shouldBeOnHScrollbar, + "marks should be on the horizontal scrollbar" + ); + } else { + Assert.equal(results.count, gUpdateCount, "expected events count"); + } + + gUpdateCount = results.count; + return results.marks; +} + +async function doAndVerifyFind(browser, text, increase, expectedMarks) { + await promiseFindFinished(browser.getTabBrowser(), text, true); + return verifyFind(browser, text, increase, expectedMarks); +} + +async function verifyFind(browser, text, increase, expectedMarks) { + let foundMarks = await getMarks(browser, increase); + + is(foundMarks.length, expectedMarks.length, "marks count with text " + text); + for (let t = 0; t < foundMarks.length; t++) { + isfuzzy( + foundMarks[t], + expectedMarks[t], + 5, + "mark " + t + " with text " + text + ); + } + + Assert.deepEqual(foundMarks, expectedMarks, "basic find with text " + text); +} diff --git a/toolkit/content/tests/browser/browser_isSynthetic.js b/toolkit/content/tests/browser/browser_isSynthetic.js new file mode 100644 index 0000000000..21b22fe171 --- /dev/null +++ b/toolkit/content/tests/browser/browser_isSynthetic.js @@ -0,0 +1,69 @@ +function LocationChangeListener(browser) { + this.browser = browser; + browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); +} + +LocationChangeListener.prototype = { + wasSynthetic: false, + browser: null, + + destroy() { + this.browser.removeProgressListener(this); + }, + + onLocationChange(webProgress, request, location, flags) { + this.wasSynthetic = this.browser.isSyntheticDocument; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), +}; + +const FILES = gTestPath + .replace("browser_isSynthetic.js", "") + .replace("chrome://mochitests/content/", "http://example.com/"); + +function waitForPageShow(browser) { + return BrowserTestUtils.waitForContentEvent(browser, "pageshow", true); +} + +add_task(async function () { + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + let listener = new LocationChangeListener(browser); + + is(browser.isSyntheticDocument, false, "Should not be synthetic"); + + let loadPromise = waitForPageShow(browser); + BrowserTestUtils.startLoadingURIString( + browser, + "data:text/html;charset=utf-8,<html/>" + ); + await loadPromise; + is(listener.wasSynthetic, false, "Should not be synthetic"); + is(browser.isSyntheticDocument, false, "Should not be synthetic"); + + loadPromise = waitForPageShow(browser); + BrowserTestUtils.startLoadingURIString(browser, FILES + "empty.png"); + await loadPromise; + is(listener.wasSynthetic, true, "Should be synthetic"); + is(browser.isSyntheticDocument, true, "Should be synthetic"); + + loadPromise = waitForPageShow(browser); + browser.goBack(); + await loadPromise; + is(listener.wasSynthetic, false, "Should not be synthetic"); + is(browser.isSyntheticDocument, false, "Should not be synthetic"); + + loadPromise = waitForPageShow(browser); + browser.goForward(); + await loadPromise; + is(listener.wasSynthetic, true, "Should be synthetic"); + is(browser.isSyntheticDocument, true, "Should be synthetic"); + + listener.destroy(); + gBrowser.removeTab(tab); +}); diff --git a/toolkit/content/tests/browser/browser_keyevents_during_autoscrolling.js b/toolkit/content/tests/browser/browser_keyevents_during_autoscrolling.js new file mode 100644 index 0000000000..f92d7089ab --- /dev/null +++ b/toolkit/content/tests/browser/browser_keyevents_during_autoscrolling.js @@ -0,0 +1,129 @@ +add_task(async function () { + const kPrefName_AutoScroll = "general.autoScroll"; + Services.prefs.setBoolPref(kPrefName_AutoScroll, true); + registerCleanupFunction(() => + Services.prefs.clearUserPref(kPrefName_AutoScroll) + ); + + const kNoKeyEvents = 0; + const kKeyDownEvent = 1; + const kKeyPressEvent = 2; + const kKeyUpEvent = 4; + const kAllKeyEvents = 7; + + var expectedKeyEvents; + var dispatchedKeyEvents; + var key; + + /** + * Encapsulates EventUtils.sendChar(). + */ + function sendChar(aChar) { + key = aChar; + dispatchedKeyEvents = kNoKeyEvents; + EventUtils.sendChar(key); + is( + dispatchedKeyEvents, + expectedKeyEvents, + "unexpected key events were dispatched or not dispatched: " + key + ); + } + + /** + * Encapsulates EventUtils.sendKey(). + */ + function sendKey(aKey) { + key = aKey; + dispatchedKeyEvents = kNoKeyEvents; + EventUtils.sendKey(key); + is( + dispatchedKeyEvents, + expectedKeyEvents, + "unexpected key events were dispatched or not dispatched: " + key + ); + } + + function onKey(aEvent) { + // if (aEvent.target != root && aEvent.target != root.ownerDocument.body) { + // ok(false, "unknown target: " + aEvent.target.tagName); + // return; + // } + + var keyFlag; + switch (aEvent.type) { + case "keydown": + keyFlag = kKeyDownEvent; + break; + case "keypress": + keyFlag = kKeyPressEvent; + break; + case "keyup": + keyFlag = kKeyUpEvent; + break; + default: + ok(false, "Unknown events: " + aEvent.type); + return; + } + dispatchedKeyEvents |= keyFlag; + is(keyFlag, expectedKeyEvents & keyFlag, aEvent.type + " fired: " + key); + } + + var dataUri = 'data:text/html,<body style="height:10000px;"></body>'; + + await BrowserTestUtils.withNewTab(dataUri, async function (browser) { + info("Loaded data URI in new tab"); + await SimpleTest.promiseFocus(browser); + info("Focused selected browser"); + + window.addEventListener("keydown", onKey); + window.addEventListener("keypress", onKey); + window.addEventListener("keyup", onKey); + registerCleanupFunction(() => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("keypress", onKey); + window.removeEventListener("keyup", onKey); + }); + + // Test whether the key events are handled correctly under normal condition + expectedKeyEvents = kAllKeyEvents; + sendChar("A"); + + // Start autoscrolling by middle button click on the page + info("Creating popup shown promise"); + let shownPromise = BrowserTestUtils.waitForEvent( + window, + "popupshown", + false, + event => event.originalTarget.className == "autoscroller" + ); + await BrowserTestUtils.synthesizeMouseAtPoint( + 10, + 10, + { button: 1 }, + gBrowser.selectedBrowser + ); + info("Waiting for autoscroll popup to show"); + await shownPromise; + + // Most key events should be eaten by the browser. + expectedKeyEvents = kNoKeyEvents; + sendChar("A"); + sendKey("DOWN"); + sendKey("RETURN"); + sendKey("RETURN"); + sendKey("HOME"); + sendKey("END"); + sendKey("TAB"); + sendKey("RETURN"); + + // Finish autoscrolling by ESC key. Note that only keydown and keypress + // events are eaten because keyup event is fired *after* the autoscrolling + // is finished. + expectedKeyEvents = kKeyUpEvent; + sendKey("ESCAPE"); + + // Test whether the key events are handled correctly under normal condition + expectedKeyEvents = kAllKeyEvents; + sendChar("A"); + }); +}); diff --git a/toolkit/content/tests/browser/browser_label_textlink.js b/toolkit/content/tests/browser/browser_label_textlink.js new file mode 100644 index 0000000000..4d53c2b5e9 --- /dev/null +++ b/toolkit/content/tests/browser/browser_label_textlink.js @@ -0,0 +1,65 @@ +add_task(async function () { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:preferences" }, + async function (browser) { + let newTabURL = "http://www.example.com/"; + await SpecialPowers.spawn( + browser, + [newTabURL], + async function (newTabURL) { + let doc = content.document; + let label = doc.createXULElement("label", { is: "text-link" }); + label.href = newTabURL; + label.id = "textlink-test"; + label.textContent = "click me"; + doc.body.append(label); + } + ); + + // Test that click will open tab in foreground. + let awaitNewTab = BrowserTestUtils.waitForNewTab(gBrowser, newTabURL); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#textlink-test", + {}, + browser + ); + let newTab = await awaitNewTab; + is( + newTab.linkedBrowser, + gBrowser.selectedBrowser, + "selected tab should be example page" + ); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + // Test that ctrl+shift+click/meta+shift+click will open tab in background. + awaitNewTab = BrowserTestUtils.waitForNewTab(gBrowser, newTabURL); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#textlink-test", + { ctrlKey: true, metaKey: true, shiftKey: true }, + browser + ); + await awaitNewTab; + is( + gBrowser.selectedBrowser, + browser, + "selected tab should be original tab" + ); + BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + + // Middle-clicking should open tab in foreground. + awaitNewTab = BrowserTestUtils.waitForNewTab(gBrowser, newTabURL); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#textlink-test", + { button: 1 }, + browser + ); + newTab = await awaitNewTab; + is( + newTab.linkedBrowser, + gBrowser.selectedBrowser, + "selected tab should be example page" + ); + BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_license_links.js b/toolkit/content/tests/browser/browser_license_links.js new file mode 100644 index 0000000000..3eff69ba75 --- /dev/null +++ b/toolkit/content/tests/browser/browser_license_links.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Verify that we can reach about:rights and about:buildconfig using links + * from about:license. + */ +add_task(async function check_links() { + await BrowserTestUtils.withNewTab("about:license", async browser => { + for (let otherPage of ["about:rights", "about:buildconfig"]) { + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, otherPage); + await BrowserTestUtils.synthesizeMouse( + `a[href='${otherPage}']`, + 2, + 2, + { accelKey: true }, + browser + ); + info("Clicked " + otherPage + " link"); + let tab = await tabPromise; + ok(true, otherPage + " tab opened correctly"); + BrowserTestUtils.removeTab(tab); + } + }); +}); diff --git a/toolkit/content/tests/browser/browser_mediaStreamPlayback.html b/toolkit/content/tests/browser/browser_mediaStreamPlayback.html new file mode 100644 index 0000000000..09685d488e --- /dev/null +++ b/toolkit/content/tests/browser/browser_mediaStreamPlayback.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html> +<body> +<video id="v" controls></video> +<script> +const v = document.getElementById("v"); + +function audioTrack() { + const ctx = new AudioContext(), oscillator = ctx.createOscillator(); + const dst = oscillator.connect(ctx.createMediaStreamDestination()); + oscillator.start(); + return dst.stream.getAudioTracks()[0]; +} + +function videoTrack(width = 640, height = 480) { + const canvas = Object.assign(document.createElement("canvas"), {width, height}); + canvas.getContext('2d').fillRect(0, 0, width, height); + return canvas.captureStream(10).getVideoTracks()[0]; +} + +onload = () => v.srcObject = new MediaStream([videoTrack(), audioTrack()]); +</script> +</body> +</html> diff --git a/toolkit/content/tests/browser/browser_mediaStreamPlaybackWithoutAudio.html b/toolkit/content/tests/browser/browser_mediaStreamPlaybackWithoutAudio.html new file mode 100644 index 0000000000..fbc9fcb033 --- /dev/null +++ b/toolkit/content/tests/browser/browser_mediaStreamPlaybackWithoutAudio.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> +<body> +<video id="v" controls></video> +<script> +const v = document.getElementById("v"); + +function videoTrack(width = 640, height = 480) { + const canvas = Object.assign(document.createElement("canvas"), {width, height}); + canvas.getContext('2d').fillRect(0, 0, width, height); + return canvas.captureStream(10).getVideoTracks()[0]; +} + +onload = () => v.srcObject = new MediaStream([videoTrack()]); +</script> +</body> +</html> diff --git a/toolkit/content/tests/browser/browser_media_wakelock.js b/toolkit/content/tests/browser/browser_media_wakelock.js new file mode 100644 index 0000000000..da27805a9d --- /dev/null +++ b/toolkit/content/tests/browser/browser_media_wakelock.js @@ -0,0 +1,159 @@ +/** + * Test whether the wakelock state is correct under different situations. However, + * the lock state of power manager doesn't equal to the actual platform lock. + * Now we don't have any way to detect whether platform lock is set correctly or + * not, but we can at least make sure the specific topic's state in power manager + * is correct. + */ +"use strict"; + +// Import this in order to use `triggerPictureInPicture()`. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js", + this +); + +const LOCATION = "https://example.com/browser/toolkit/content/tests/browser/"; +const AUDIO_WAKELOCK_NAME = "audio-playing"; +const VIDEO_WAKELOCK_NAME = "video-playing"; + +add_task(async function testCheckWakelockWhenChangeTabVisibility() { + await checkWakelockWhenChangeTabVisibility({ + description: "playing video", + url: "file_video.html", + lockAudio: true, + lockVideo: true, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing muted video", + url: "file_video.html", + additionalParams: { + muted: true, + }, + lockAudio: false, + lockVideo: true, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing volume=0 video", + url: "file_video.html", + additionalParams: { + volume: 0.0, + }, + lockAudio: false, + lockVideo: true, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing video without audio in it", + url: "file_videoWithoutAudioTrack.html", + lockAudio: false, + lockVideo: false, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing audio in video element", + url: "file_videoWithAudioOnly.html", + lockAudio: true, + lockVideo: false, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing audio in audio element", + url: "file_mediaPlayback2.html", + lockAudio: true, + lockVideo: false, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing video from media stream with audio and video tracks", + url: "browser_mediaStreamPlayback.html", + lockAudio: true, + lockVideo: true, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing video from media stream without audio track", + url: "browser_mediaStreamPlaybackWithoutAudio.html", + lockAudio: true, + lockVideo: true, + }); +}); + +/** + * Following are helper functions. + */ +async function checkWakelockWhenChangeTabVisibility({ + description, + url, + additionalParams, + lockAudio, + lockVideo, +}) { + const originalTab = gBrowser.selectedTab; + info(`start a new tab for '${description}'`); + const mediaTab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + LOCATION + url + ); + + info(`wait for media starting playing`); + await waitUntilVideoStarted(mediaTab, additionalParams); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: lockAudio, + isForegroundLock: true, + }); + await waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { + needLock: lockVideo, + isForegroundLock: true, + }); + + info(`switch media tab to background`); + await BrowserTestUtils.switchTab(window.gBrowser, originalTab); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: lockAudio, + isForegroundLock: false, + }); + await waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { + needLock: lockVideo, + isForegroundLock: false, + }); + + info(`switch media tab to foreground again`); + await BrowserTestUtils.switchTab(window.gBrowser, mediaTab); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: lockAudio, + isForegroundLock: true, + }); + await waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { + needLock: lockVideo, + isForegroundLock: true, + }); + + info(`remove tab`); + if (mediaTab.PIPWindow) { + await BrowserTestUtils.closeWindow(mediaTab.PIPWindow); + } + BrowserTestUtils.removeTab(mediaTab); +} + +async function waitUntilVideoStarted(tab, { muted, volume } = {}) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [muted, volume], + async (muted, volume) => { + const video = content.document.getElementById("v"); + if (!video) { + ok(false, "can't get media element!"); + return; + } + if (muted) { + video.muted = muted; + } + if (volume !== undefined) { + video.volume = volume; + } + ok( + await video.play().then( + () => true, + () => false + ), + `video started playing.` + ); + } + ); +} diff --git a/toolkit/content/tests/browser/browser_media_wakelock_PIP.js b/toolkit/content/tests/browser/browser_media_wakelock_PIP.js new file mode 100644 index 0000000000..d550fc2ffa --- /dev/null +++ b/toolkit/content/tests/browser/browser_media_wakelock_PIP.js @@ -0,0 +1,155 @@ +/** + * Test the wakelock usage for video being used in the picture-in-picuture (PIP) + * mode. When video is playing in PIP window, we would always request a video + * wakelock, and request audio wakelock only when video is audible. + */ +add_task(async function testCheckWakelockForPIPVideo() { + await checkWakelockWhenChangeTabVisibility({ + description: "playing a PIP video", + lockAudio: true, + lockVideo: true, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing a muted PIP video", + additionalParams: { + muted: true, + }, + lockAudio: false, + lockVideo: true, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "playing a volume=0 PIP video", + additionalParams: { + volume: 0.0, + }, + lockAudio: false, + lockVideo: true, + }); +}); + +/** + * Following are helper functions and variables. + */ +const PAGE_URL = + "https://example.com/browser/toolkit/content/tests/browser/file_video.html"; +const AUDIO_WAKELOCK_NAME = "audio-playing"; +const VIDEO_WAKELOCK_NAME = "video-playing"; +const TEST_VIDEO_ID = "v"; + +// Import this in order to use `triggerPictureInPicture()`. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/components/pictureinpicture/tests/head.js", + this +); + +async function checkWakelockWhenChangeTabVisibility({ + description, + additionalParams, + lockAudio, + lockVideo, +}) { + const originalTab = gBrowser.selectedTab; + info(`start a new tab for '${description}'`); + const tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + PAGE_URL + ); + + info(`wait for PIP video starting playing`); + await startPIPVideo(tab, additionalParams); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: lockAudio, + isForegroundLock: true, + }); + await waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { + needLock: lockVideo, + isForegroundLock: true, + }); + + info( + `switch tab to background and still own foreground locks due to visible PIP video` + ); + await BrowserTestUtils.switchTab(window.gBrowser, originalTab); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: lockAudio, + isForegroundLock: true, + }); + await waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { + needLock: lockVideo, + isForegroundLock: true, + }); + + info(`pausing PIP video should release all locks`); + await pausePIPVideo(tab); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: false, + }); + await waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { + needLock: false, + }); + + info(`resuming PIP video should request locks again`); + await resumePIPVideo(tab); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: lockAudio, + isForegroundLock: true, + }); + await waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { + needLock: lockVideo, + isForegroundLock: true, + }); + + info(`switch tab to foreground again`); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: lockAudio, + isForegroundLock: true, + }); + await waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { + needLock: lockVideo, + isForegroundLock: true, + }); + + info(`remove tab`); + await BrowserTestUtils.closeWindow(tab.PIPWindow); + BrowserTestUtils.removeTab(tab); +} + +async function startPIPVideo(tab, { muted, volume } = {}) { + tab.PIPWindow = await triggerPictureInPicture( + tab.linkedBrowser, + TEST_VIDEO_ID + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [muted, volume, TEST_VIDEO_ID], + async (muted, volume, Id) => { + const video = content.document.getElementById(Id); + if (muted) { + video.muted = muted; + } + if (volume !== undefined) { + video.volume = volume; + } + ok( + await video.play().then( + () => true, + () => false + ), + `video started playing.` + ); + } + ); +} + +function pausePIPVideo(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [TEST_VIDEO_ID], Id => { + content.document.getElementById(Id).pause(); + }); +} + +function resumePIPVideo(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [TEST_VIDEO_ID], async Id => { + await content.document.getElementById(Id).play(); + }); +} diff --git a/toolkit/content/tests/browser/browser_media_wakelock_webaudio.js b/toolkit/content/tests/browser/browser_media_wakelock_webaudio.js new file mode 100644 index 0000000000..7c40b5fe1a --- /dev/null +++ b/toolkit/content/tests/browser/browser_media_wakelock_webaudio.js @@ -0,0 +1,127 @@ +/** + * Test if wakelock can be required correctly when we play web audio. The + * wakelock should only be required when web audio is audible. + */ + +const AUDIO_WAKELOCK_NAME = "audio-playing"; +const VIDEO_WAKELOCK_NAME = "video-playing"; + +add_task(async function testCheckAudioWakelockWhenChangeTabVisibility() { + await checkWakelockWhenChangeTabVisibility({ + description: "playing audible web audio", + needLock: true, + }); + await checkWakelockWhenChangeTabVisibility({ + description: "suspended web audio", + additionalParams: { + suspend: true, + }, + needLock: false, + }); +}); + +add_task( + async function testBrieflyAudibleAudioContextReleasesAudioWakeLockWhenInaudible() { + const tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "about:blank" + ); + + info(`make a short noise on web audio`); + await Promise.all([ + // As the sound would only happen for a really short period, calling + // checking wakelock first helps to ensure that we won't miss that moment. + waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock: true, + isForegroundLock: true, + }), + createWebAudioDocument(tab, { stopTimeOffset: 0.1 }), + ]); + await ensureNeverAcquireVideoWakelock(); + + info(`audio wakelock should be released after web audio becomes silent`); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, false, { + needLock: false, + }); + await ensureNeverAcquireVideoWakelock(); + + await BrowserTestUtils.removeTab(tab); + } +); + +/** + * Following are helper functions. + */ +async function checkWakelockWhenChangeTabVisibility({ + description, + additionalParams, + needLock, + elementIdForEnteringPIPMode, +}) { + const originalTab = gBrowser.selectedTab; + info(`start a new tab for '${description}'`); + const mediaTab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "about:blank" + ); + await createWebAudioDocument(mediaTab, additionalParams); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock, + isForegroundLock: true, + }); + await ensureNeverAcquireVideoWakelock(); + + info(`switch media tab to background`); + await BrowserTestUtils.switchTab(window.gBrowser, originalTab); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock, + isForegroundLock: false, + }); + await ensureNeverAcquireVideoWakelock(); + + info(`switch media tab to foreground again`); + await BrowserTestUtils.switchTab(window.gBrowser, mediaTab); + await waitForExpectedWakeLockState(AUDIO_WAKELOCK_NAME, { + needLock, + isForegroundLock: true, + }); + await ensureNeverAcquireVideoWakelock(); + + info(`remove media tab`); + BrowserTestUtils.removeTab(mediaTab); +} + +function createWebAudioDocument(tab, { stopTimeOffset, suspend } = {}) { + return SpecialPowers.spawn( + tab.linkedBrowser, + [suspend, stopTimeOffset], + async (suspend, stopTimeOffset) => { + // Create an oscillatorNode to produce sound. + content.ac = new content.AudioContext(); + const ac = content.ac; + const dest = ac.destination; + const source = new content.OscillatorNode(ac); + source.start(ac.currentTime); + source.connect(dest); + + if (stopTimeOffset) { + source.stop(ac.currentTime + 0.1); + } + + if (suspend) { + await content.ac.suspend(); + } else { + while (ac.state != "running") { + info(`wait until AudioContext starts running`); + await new Promise(r => (ac.onstatechange = r)); + } + info("AudioContext is running"); + } + } + ); +} + +function ensureNeverAcquireVideoWakelock() { + // Web audio won't play any video, we never need video wakelock. + return waitForExpectedWakeLockState(VIDEO_WAKELOCK_NAME, { needLock: false }); +} diff --git a/toolkit/content/tests/browser/browser_moz_support_link_open_links_in_chrome.js b/toolkit/content/tests/browser/browser_moz_support_link_open_links_in_chrome.js new file mode 100644 index 0000000000..be5909b9c8 --- /dev/null +++ b/toolkit/content/tests/browser/browser_moz_support_link_open_links_in_chrome.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + Ensures that the moz-support-link opens links correctly when + this widget is used in the chrome. +*/ + +async function createMozSupportLink() { + await import("chrome://global/content/elements/moz-support-link.mjs"); + let supportLink = document.createElement("a", { is: "moz-support-link" }); + supportLink.setAttribute("support-page", "dnt"); + let navigatorToolbox = document.getElementById("navigator-toolbox"); + navigatorToolbox.appendChild(supportLink); + + // If we do not wait for the element to be translated, + // then there will be no visible text. + await document.l10n.translateElements([supportLink]); + return supportLink; +} + +add_task(async function test_open_link_in_chrome_with_keyboard() { + let supportTab; + // Open link with Enter key + let supportLink = await createMozSupportLink(); + supportLink.focus(); + const supportTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + Services.urlFormatter.formatURLPref("app.support.baseURL") + "dnt" + ); + await EventUtils.synthesizeKey("KEY_Enter"); + supportTab = await supportTabPromise; + Assert.ok(supportTab, "Support tab in new tab opened with Enter key"); + + await BrowserTestUtils.removeTab(supportTab); + + let supportWindow; + + // Open link with Shift + Enter key combination + supportLink.focus(); + const supportWindowPromise = BrowserTestUtils.waitForNewWindow( + Services.urlFormatter.formatURLPref("app.support.baseURL") + "dnt" + ); + await EventUtils.synthesizeKey("KEY_Enter", { shiftKey: true }); + supportWindow = await supportWindowPromise; + Assert.ok( + supportWindow, + "Support tab in new window opened with Shift+Enter key" + ); + supportLink.remove(); + await BrowserTestUtils.closeWindow(supportWindow); +}); + +add_task(async function test_open_link_in_chrome_with_mouse() { + let supportTab; + // Open link with mouse click + + let supportLink = await createMozSupportLink(); + const supportTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + Services.urlFormatter.formatURLPref("app.support.baseURL") + "dnt" + ); + // This synthesize call works if you add a debugger statement before the call. + EventUtils.synthesizeMouseAtCenter(supportLink, {}); + + supportTab = await supportTabPromise; + Assert.ok(supportTab, "Support tab in new tab opened"); + + await BrowserTestUtils.removeTab(supportTab); + + let supportWindow; + + // Open link with Shift + mouse click combination + const supportWindowPromise = BrowserTestUtils.waitForNewWindow( + Services.urlFormatter.formatURLPref("app.support.baseURL") + "dnt" + ); + await EventUtils.synthesizeMouseAtCenter(supportLink, { shiftKey: true }); + supportWindow = await supportWindowPromise; + Assert.ok(supportWindow, "Support tab in new window opened"); + await BrowserTestUtils.closeWindow(supportWindow); + supportLink = document.querySelector("a[support-page]"); + supportLink.remove(); +}); diff --git a/toolkit/content/tests/browser/browser_quickfind_editable.js b/toolkit/content/tests/browser/browser_quickfind_editable.js new file mode 100644 index 0000000000..7ece285602 --- /dev/null +++ b/toolkit/content/tests/browser/browser_quickfind_editable.js @@ -0,0 +1,59 @@ +const PAGE = + "data:text/html,<div contenteditable>foo</div><input><textarea></textarea>"; +const DESIGNMODE_PAGE = + "data:text/html,<body onload='document.designMode=\"on\";'>"; +const HOTKEYS = ["/", "'"]; + +async function test_hotkeys(browser, expected) { + let findbar = await gBrowser.getFindBar(); + for (let key of HOTKEYS) { + is(findbar.hidden, true, "findbar is hidden"); + await BrowserTestUtils.sendChar(key, gBrowser.selectedBrowser); + is( + findbar.hidden, + expected, + "findbar should" + (expected ? "" : " not") + " be hidden" + ); + if (!expected) { + await closeFindbarAndWait(findbar); + } + } +} + +async function focus_element(browser, query) { + await SpecialPowers.spawn(browser, [query], async function focus(query) { + let element = content.document.querySelector(query); + element.focus(); + }); +} + +add_task(async function test_hotkey_on_editable_element() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE, + }, + async function do_tests(browser) { + await test_hotkeys(browser, false); + const ELEMENTS = ["div", "input", "textarea"]; + for (let elem of ELEMENTS) { + await focus_element(browser, elem); + await test_hotkeys(browser, true); + await focus_element(browser, ":root"); + await test_hotkeys(browser, false); + } + } + ); +}); + +add_task(async function test_hotkey_on_designMode_document() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: DESIGNMODE_PAGE, + }, + async function do_tests(browser) { + await test_hotkeys(browser, true); + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_remoteness_change_listeners.js b/toolkit/content/tests/browser/browser_remoteness_change_listeners.js new file mode 100644 index 0000000000..a1cc42dd97 --- /dev/null +++ b/toolkit/content/tests/browser/browser_remoteness_change_listeners.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that adding progress listeners to a browser doesn't break things + * when switching the remoteness of that browser. + */ +add_task(async function test_remoteness_switch_listeners() { + await BrowserTestUtils.withNewTab("about:support", async function (browser) { + let wpl; + let navigated = new Promise(resolve => { + wpl = { + onLocationChange() { + is(browser.currentURI.spec, "https://example.com/"); + if (browser.currentURI?.spec == "https://example.com/") { + resolve(); + } + }, + QueryInterface: ChromeUtils.generateQI([ + Ci.nsISupportsWeakReference, + Ci.nsIWebProgressListener2, + Ci.nsIWebProgressListener, + ]), + }; + browser.addProgressListener(wpl); + }); + + let loaded = BrowserTestUtils.browserLoaded( + browser, + null, + "https://example.com/" + ); + BrowserTestUtils.startLoadingURIString(browser, "https://example.com/"); + await Promise.all([loaded, navigated]); + browser.removeProgressListener(wpl); + }); +}); diff --git a/toolkit/content/tests/browser/browser_resume_bkg_video_on_tab_hover.js b/toolkit/content/tests/browser/browser_resume_bkg_video_on_tab_hover.js new file mode 100644 index 0000000000..4b46fd9351 --- /dev/null +++ b/toolkit/content/tests/browser/browser_resume_bkg_video_on_tab_hover.js @@ -0,0 +1,154 @@ +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_silentAudioTrack.html"; + +async function check_video_decoding_state(args) { + let video = content.document.getElementById("autoplay"); + if (!video) { + ok(false, "Can't get the video element!"); + } + + let isSuspended = args.suspend; + let reload = args.reload; + + if (reload) { + // It is too late to register event handlers when playback is half + // way done. Let's start playback from the beginning so we won't + // miss any events. + video.load(); + video.play(); + } + + let state = isSuspended ? "suspended" : "resumed"; + let event = isSuspended ? "mozentervideosuspend" : "mozexitvideosuspend"; + return new Promise(resolve => { + video.addEventListener( + event, + function () { + ok(true, `Video decoding is ${state}.`); + resolve(); + }, + { once: true } + ); + }); +} + +async function check_should_send_unselected_tab_hover_msg(browser) { + info("did not update the value now, wait until it changes."); + if (browser.shouldHandleUnselectedTabHover) { + ok( + true, + "Should send unselected tab hover msg, someone is listening for it." + ); + return true; + } + return BrowserTestUtils.waitForCondition( + () => browser.shouldHandleUnselectedTabHover, + "Should send unselected tab hover msg, someone is listening for it." + ); +} + +async function check_should_not_send_unselected_tab_hover_msg(browser) { + info("did not update the value now, wait until it changes."); + return BrowserTestUtils.waitForCondition( + () => !browser.shouldHandleUnselectedTabHover, + "Should not send unselected tab hover msg, no one is listening for it." + ); +} + +function get_video_decoding_suspend_promise(browser, reload) { + let suspend = true; + return SpecialPowers.spawn( + browser, + [{ suspend, reload }], + check_video_decoding_state + ); +} + +function get_video_decoding_resume_promise(browser) { + let suspend = false; + let reload = false; + return ContentTask.spawn( + browser, + { suspend, reload }, + check_video_decoding_state + ); +} + +/** + * Because of bug1029451, we can't receive "mouseover" event correctly when + * we disable non-test mouse event. Therefore, we can't synthesize mouse event + * to simulate cursor hovering, so we temporarily use a hacky way to resume and + * suspend video decoding. + */ +function cursor_hover_over_tab_and_resume_video_decoding(browser) { + // TODO : simulate cursor hovering over the tab after fixing bug1029451. + browser.unselectedTabHover(true /* hover */); +} + +function cursor_leave_tab_and_suspend_video_decoding(browser) { + // TODO : simulate cursor leaveing the tab after fixing bug1029451. + browser.unselectedTabHover(false /* leave */); +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.block-autoplay-until-in-foreground", false], + ["media.suspend-background-video.enabled", true], + ["media.suspend-background-video.delay-ms", 0], + ["media.resume-background-video-on-tabhover", true], + ], + }); +}); + +/** + * TODO : add the following user-level tests after fixing bug1029451. + * test1 - only affect the unselected tab + * test2 - only affect the tab with suspended video + */ +add_task(async function resume_and_suspend_background_video_decoding() { + info("- open new background tab -"); + let tab = BrowserTestUtils.addTab(window.gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + info("- before loading media, we shoudn't send the tab hover msg for tab -"); + await check_should_not_send_unselected_tab_hover_msg(browser); + BrowserTestUtils.startLoadingURIString(browser, PAGE); + await BrowserTestUtils.browserLoaded(browser); + + info("- should suspend background video decoding -"); + await get_video_decoding_suspend_promise(browser, true); + await check_should_send_unselected_tab_hover_msg(browser); + + info("- when cursor is hovering over the tab, resuming the video decoding -"); + let promise = get_video_decoding_resume_promise(browser); + await cursor_hover_over_tab_and_resume_video_decoding(browser); + await promise; + await check_should_send_unselected_tab_hover_msg(browser); + + info("- when cursor leaves the tab, suspending the video decoding -"); + promise = get_video_decoding_suspend_promise(browser); + await cursor_leave_tab_and_suspend_video_decoding(browser); + await promise; + await check_should_send_unselected_tab_hover_msg(browser); + + info("- select video's owner tab as foreground tab, should resume video -"); + promise = get_video_decoding_resume_promise(browser); + await BrowserTestUtils.switchTab(window.gBrowser, tab); + await promise; + await check_should_send_unselected_tab_hover_msg(browser); + + info("- video's owner tab goes to background again, should suspend video -"); + promise = get_video_decoding_suspend_promise(browser); + let blankTab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "about:blank" + ); + await promise; + await check_should_send_unselected_tab_hover_msg(browser); + + info("- remove tabs -"); + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(blankTab); +}); diff --git a/toolkit/content/tests/browser/browser_richlistbox_keyboard.js b/toolkit/content/tests/browser/browser_richlistbox_keyboard.js new file mode 100644 index 0000000000..a1287335b4 --- /dev/null +++ b/toolkit/content/tests/browser/browser_richlistbox_keyboard.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_richlistbox_keyboard() { + await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] }); + await BrowserTestUtils.withNewTab("about:about", browser => { + let document = browser.contentDocument; + let box = document.createXULElement("richlistbox"); + + function checkTabIndices(selectedLine) { + for (let button of box.querySelectorAll(`.line${selectedLine} button`)) { + is( + button.tabIndex, + 0, + `Should have ensured buttons inside selected line ${selectedLine} are focusable` + ); + } + for (let otherButton of box.querySelectorAll( + `richlistitem:not(.line${selectedLine}) button` + )) { + is( + otherButton.tabIndex, + -1, + `Should have ensured buttons outside selected line ${selectedLine} are not focusable` + ); + } + } + + let poem = `I wandered lonely as a cloud + That floats on high o'er vales and hills + When all at once I saw a crowd + A host, of golden daffodils; + Beside the lake, beneath the trees, + Fluttering and dancing in the breeze.`; + let items = poem.split("\n").map((line, index) => { + let item = document.createXULElement("richlistitem"); + item.className = `line${index + 1}`; + let button1 = document.createXULElement("button"); + button1.textContent = "Like"; + let button2 = document.createXULElement("button"); + button2.textContent = "Subscribe"; + item.append(line.trim(), button1, button2); + return item; + }); + box.append(...items); + document.body.prepend(box); + box.focus(); + box.getBoundingClientRect(); // force a flush + box.selectedItem = box.firstChild; + checkTabIndices(1); + EventUtils.synthesizeKey("VK_DOWN", {}, document.defaultView); + is( + box.selectedItem.className, + "line2", + "Should have moved selection to the next line." + ); + checkTabIndices(2); + EventUtils.synthesizeKey("VK_TAB", {}, document.defaultView); + is( + document.activeElement, + box.selectedItem.querySelector("button"), + "Initial button gets focus in the selected list item." + ); + EventUtils.synthesizeKey("VK_UP", {}, document.defaultView); + checkTabIndices(1); + is( + document.activeElement, + box.selectedItem.querySelector("button"), + "Initial button gets focus in the selected list item when moving up with arrow key." + ); + EventUtils.synthesizeKey("VK_DOWN", {}, document.defaultView); + checkTabIndices(2); + is( + document.activeElement, + box.selectedItem.querySelector("button"), + "Initial button gets focus in the selected list item when moving down with arrow key." + ); + }); +}); diff --git a/toolkit/content/tests/browser/browser_saveImageURL.js b/toolkit/content/tests/browser/browser_saveImageURL.js new file mode 100644 index 0000000000..c936b8ef84 --- /dev/null +++ b/toolkit/content/tests/browser/browser_saveImageURL.js @@ -0,0 +1,76 @@ +"use strict"; + +const IMAGE_PAGE = + "https://example.com/browser/toolkit/content/tests/browser/image_page.html"; + +var MockFilePicker = SpecialPowers.MockFilePicker; + +MockFilePicker.init(window); +MockFilePicker.returnValue = MockFilePicker.returnCancel; + +registerCleanupFunction(function () { + MockFilePicker.cleanup(); +}); + +function waitForFilePicker() { + return new Promise(resolve => { + MockFilePicker.showCallback = () => { + MockFilePicker.showCallback = null; + ok(true, "Saw the file picker"); + resolve(); + }; + }); +} + +/** + * Test that internalSave works when saving an image like the context menu does. + */ +add_task(async function preferred_API() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: IMAGE_PAGE, + }, + async function (browser) { + let url = await SpecialPowers.spawn(browser, [], async function () { + let image = content.document.getElementById("image"); + return image.href; + }); + + let filePickerPromise = waitForFilePicker(); + internalSave( + url, + null, // originalURL + null, // document + "image.jpg", + null, // content disposition + "image/jpeg", + true, // bypass cache + null, // dialog title key + null, // chosen data + null, // no referrer info + null, // no document + false, // don't skip the filename prompt + null, // cache key + false, // not private. + gBrowser.contentPrincipal + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let channel = docShell.currentDocumentChannel; + if (channel) { + todo( + channel.QueryInterface(Ci.nsIHttpChannelInternal) + .channelIsForDownload + ); + + // Throttleable is the only class flag assigned to downloads. + todo( + channel.QueryInterface(Ci.nsIClassOfService).classFlags == + Ci.nsIClassOfService.Throttleable + ); + } + }); + await filePickerPromise; + } + ); +}); diff --git a/toolkit/content/tests/browser/browser_save_folder_standalone_image.js b/toolkit/content/tests/browser/browser_save_folder_standalone_image.js new file mode 100644 index 0000000000..ce45d04fdc --- /dev/null +++ b/toolkit/content/tests/browser/browser_save_folder_standalone_image.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * TestCase for bug 1726801 + * <https://bugzilla.mozilla.org/show_bug.cgi?id=1726801> + * + * Load an image in a standalone tab and verify that the per-site download + * folder is correctly retrieved when using "Save Page As" to save the image. + */ + +/* + * ================ + * Helper functions + * ================ + */ + +async function setFile(downloadLastDir, aURI, aValue) { + downloadLastDir.setFile(aURI, aValue); + await TestUtils.waitForTick(); +} + +function newDirectory() { + let dir = FileUtils.getDir("TmpD", ["testdir"]); + dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + return dir; +} + +function clearHistory() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); +} + +async function clearHistoryAndWait() { + clearHistory(); + await TestUtils.waitForTick(); + await TestUtils.waitForTick(); +} + +/* + * ==== + * Test + * ==== + */ + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +add_task(async function () { + const IMAGE_URL = + "http://mochi.test:8888/browser/toolkit/content/tests/browser/doggy.png"; + + await BrowserTestUtils.withNewTab(IMAGE_URL, async function (browser) { + let tmpDir = FileUtils.getDir("TmpD", []); + let dir = newDirectory(); + let downloadLastDir = new DownloadLastDir(null); + // Set the desired target directory for the IMAGE_URL + await setFile(downloadLastDir, IMAGE_URL, dir); + // Ensure that "browser.download.lastDir" points to a different directory + await setFile(downloadLastDir, null, tmpDir); + registerCleanupFunction(async function () { + await clearHistoryAndWait(); + dir.remove(true); + }); + + // Prepare mock file picker. + let showFilePickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = fp => resolve(fp.displayDirectory.path); + }); + registerCleanupFunction(function () { + MockFilePicker.cleanup(); + }); + + // Run "Save Page As" + EventUtils.synthesizeKey("s", { accelKey: true }); + + let dirPath = await showFilePickerPromise; + is(dirPath, dir.path, "Verify proposed download folder."); + }); +}); diff --git a/toolkit/content/tests/browser/browser_save_resend_postdata.js b/toolkit/content/tests/browser/browser_save_resend_postdata.js new file mode 100644 index 0000000000..5eb1b1c904 --- /dev/null +++ b/toolkit/content/tests/browser/browser_save_resend_postdata.js @@ -0,0 +1,169 @@ +/* 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/. */ + +var MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +/** + * Test for bug 471962 <https://bugzilla.mozilla.org/show_bug.cgi?id=471962>: + * When saving an inner frame as file only, the POST data of the outer page is + * sent to the address of the inner page. + * + * Test for bug 485196 <https://bugzilla.mozilla.org/show_bug.cgi?id=485196>: + * Web page generated by POST is retried as GET when Save Frame As used, and the + * page is no longer in the cache. + */ +function test() { + waitForExplicitFinish(); + + BrowserTestUtils.startLoadingURIString( + gBrowser, + "http://mochi.test:8888/browser/toolkit/content/tests/browser/data/post_form_outer.sjs" + ); + + gBrowser.addEventListener("pageshow", function pageShown(event) { + if (event.target.location == "about:blank") { + return; + } + gBrowser.removeEventListener("pageshow", pageShown); + + // Submit the form in the outer page, then wait for both the outer + // document and the inner frame to be loaded again. + gBrowser.addEventListener("DOMContentLoaded", handleOuterSubmit); + gBrowser.contentDocument.getElementById("postForm").submit(); + }); + + var framesLoaded = 0; + var innerFrame; + + function handleOuterSubmit() { + if (++framesLoaded < 2) { + return; + } + + gBrowser.removeEventListener("DOMContentLoaded", handleOuterSubmit); + + innerFrame = gBrowser.contentDocument.getElementById("innerFrame"); + + // Submit the form in the inner page. + gBrowser.addEventListener("DOMContentLoaded", handleInnerSubmit); + innerFrame.contentDocument.getElementById("postForm").submit(); + } + + function handleInnerSubmit() { + gBrowser.removeEventListener("DOMContentLoaded", handleInnerSubmit); + + // Create the folder the page will be saved into. + var destDir = createTemporarySaveDirectory(); + var file = destDir.clone(); + file.append("no_default_file_name"); + MockFilePicker.setFiles([file]); + MockFilePicker.showCallback = function (fp) { + MockFilePicker.filterIndex = 1; // kSaveAsType_URL + }; + + mockTransferCallback = onTransferComplete; + mockTransferRegisterer.register(); + + registerCleanupFunction(function () { + mockTransferRegisterer.unregister(); + MockFilePicker.cleanup(); + destDir.remove(true); + }); + + var docToSave = innerFrame.contentDocument; + // We call internalSave instead of saveDocument to bypass the history + // cache. + internalSave( + docToSave.location.href, + null, + docToSave, + null, + null, + docToSave.contentType, + false, + null, + null, + docToSave.referrer ? makeURI(docToSave.referrer) : null, + docToSave, + false, + null + ); + } + + function onTransferComplete(downloadSuccess) { + ok( + downloadSuccess, + "The inner frame should have been downloaded successfully" + ); + + // Read the entire saved file. + var file = MockFilePicker.getNsIFile(); + var fileContents = readShortFile(file); + + // Check if outer POST data is found (bug 471962). + is( + fileContents.indexOf("inputfield=outer"), + -1, + "The saved inner frame does not contain outer POST data" + ); + + // Check if inner POST data is found (bug 485196). + isnot( + fileContents.indexOf("inputfield=inner"), + -1, + "The saved inner frame was generated using the correct POST data" + ); + + finish(); + } +} + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js", + this +); + +function createTemporarySaveDirectory() { + var saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + saveDir.append("testsavedir"); + if (!saveDir.exists()) { + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + return saveDir; +} + +/** + * Reads the contents of the provided short file (up to 1 MiB). + * + * @param aFile + * nsIFile object pointing to the file to be read. + * + * @return + * String containing the raw octets read from the file. + */ +function readShortFile(aFile) { + var inputStream = Cc[ + "@mozilla.org/network/file-input-stream;1" + ].createInstance(Ci.nsIFileInputStream); + inputStream.init(aFile, -1, 0, 0); + try { + var scrInputStream = Cc[ + "@mozilla.org/scriptableinputstream;1" + ].createInstance(Ci.nsIScriptableInputStream); + scrInputStream.init(inputStream); + try { + // Assume that the file is much shorter than 1 MiB. + return scrInputStream.read(1048576); + } finally { + // Close the scriptable stream after reading, even if the operation + // failed. + scrInputStream.close(); + } + } finally { + // Close the stream after reading, if it is still open, even if the read + // operation failed. + inputStream.close(); + } +} diff --git a/toolkit/content/tests/browser/browser_starting_autoscroll_in_about_content.js b/toolkit/content/tests/browser/browser_starting_autoscroll_in_about_content.js new file mode 100644 index 0000000000..083b6a13a0 --- /dev/null +++ b/toolkit/content/tests/browser/browser_starting_autoscroll_in_about_content.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testStartingAutoScrollInAboutContent() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["general.autoScroll", true], + ["middlemouse.contentLoadURL", false], + ["test.events.async.enabled", true], + ], + }); + + await BrowserTestUtils.withNewTab("about:support", async function (browser) { + let autoScroller; + let promiseStartAutoScroll = new Promise(resolve => { + let onPopupShown = event => { + if (event.originalTarget.id != "autoscroller") { + return; + } + autoScroller = event.originalTarget; + info('"popupshown" event is fired'); + autoScroller.getBoundingClientRect(); // Flush layout of the autoscroller + resolve(); + }; + window.addEventListener("popupshown", onPopupShown, { capture: true }); + registerCleanupFunction(() => { + window.removeEventListener("popupshown", onPopupShown, { + capture: true, + }); + }); + }); + + ok(!browser.isRemoteBrowser, "Browser should not be remote."); + await ContentTask.spawn(browser, null, async function () { + await ContentTaskUtils.waitForCondition( + () => + content.document.documentElement.scrollHeight > + content.document.documentElement.clientHeight, + "The document should become scrollable" + ); + }); + + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: browser, + offsetX: 10, + offsetY: 10, // XXX Assuming that there is no interactive content here. + }); + await EventUtils.promiseNativeMouseEvent({ + type: "mousedown", + button: 1, // middle click + target: browser, + offsetX: 10, + offsetY: 10, + }); + info("Waiting to start autoscrolling"); + await promiseStartAutoScroll; + Assert.notEqual(autoScroller, null, "Autoscrolling should be started"); + await EventUtils.promiseNativeMouseEvent({ + type: "mouseup", + button: 1, // middle click + target: browser, + offsetX: 10, + offsetY: 10, + }); // release implicit capture + EventUtils.synthesizeKey("KEY_Escape"); // Close autoscroller + await TestUtils.waitForCondition( + () => autoScroller.state == "closed", + "autoscroll should be canceled" + ); + }); +}); diff --git a/toolkit/content/tests/browser/browser_suspend_videos_outside_viewport.js b/toolkit/content/tests/browser/browser_suspend_videos_outside_viewport.js new file mode 100644 index 0000000000..e36f1c75b8 --- /dev/null +++ b/toolkit/content/tests/browser/browser_suspend_videos_outside_viewport.js @@ -0,0 +1,39 @@ +/** + * This test is used to ensure we suspend video decoding if video is not in the + * viewport. + */ +"use strict"; + +const PAGE = + "https://example.com/browser/toolkit/content/tests/browser/file_outside_viewport_videos.html"; + +async function test_suspend_video_decoding() { + let videos = content.document.getElementsByTagName("video"); + for (let video of videos) { + info(`- start video on the ${video.id} side and outside the viewport -`); + await video.play(); + ok(true, `video started playing`); + ok(video.isVideoDecodingSuspended, `video decoding is suspended`); + } +} + +add_task(async function setup_test_preference() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.suspend-background-video.enabled", true], + ["media.suspend-background-video.delay-ms", 0], + ], + }); +}); + +add_task(async function start_test() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE, + }, + async browser => { + await SpecialPowers.spawn(browser, [], test_suspend_video_decoding); + } + ); +}); diff --git a/toolkit/content/tests/browser/common/mockTransfer.js b/toolkit/content/tests/browser/common/mockTransfer.js new file mode 100644 index 0000000000..f4afa44903 --- /dev/null +++ b/toolkit/content/tests/browser/common/mockTransfer.js @@ -0,0 +1,85 @@ +/* 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/. */ + +Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/MockObjects.js", + this +); + +var mockTransferCallback; + +/** + * This "transfer" object implementation continues the currently running test + * when the download is completed, reporting true for success or false for + * failure as the first argument of the testRunner.continueTest function. + */ +function MockTransfer() { + this._downloadIsSuccessful = true; +} + +MockTransfer.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsIWebProgressListener2", + "nsITransfer", + ]), + + /* nsIWebProgressListener */ + onStateChange: function MTFC_onStateChange( + aWebProgress, + aRequest, + aStateFlags, + aStatus + ) { + // If at least one notification reported an error, the download failed. + if (!Components.isSuccessCode(aStatus)) { + this._downloadIsSuccessful = false; + } + + // If the download is finished + if ( + aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ) { + // Continue the test, reporting the success or failure condition. + mockTransferCallback(this._downloadIsSuccessful); + } + }, + onProgressChange() {}, + onLocationChange() {}, + onStatusChange: function MTFC_onStatusChange( + aWebProgress, + aRequest, + aStatus, + aMessage + ) { + // If at least one notification reported an error, the download failed. + if (!Components.isSuccessCode(aStatus)) { + this._downloadIsSuccessful = false; + } + }, + onSecurityChange() {}, + onContentBlockingEvent() {}, + + /* nsIWebProgressListener2 */ + onProgressChange64() {}, + onRefreshAttempted() {}, + + /* nsITransfer */ + init() {}, + initWithBrowsingContext() {}, + setSha256Hash() {}, + setSignatureInfo() {}, +}; + +// Create an instance of a MockObjectRegisterer whose methods can be used to +// temporarily replace the default "@mozilla.org/transfer;1" object factory with +// one that provides the mock implementation above. To activate the mock object +// factory, call the "register" method. Starting from that moment, all the +// transfer objects that are requested will be mock objects, until the +// "unregister" method is called. +var mockTransferRegisterer = new MockObjectRegisterer( + "@mozilla.org/transfer;1", + MockTransfer +); diff --git a/toolkit/content/tests/browser/data/post_form_inner.sjs b/toolkit/content/tests/browser/data/post_form_inner.sjs new file mode 100644 index 0000000000..14ce93ab84 --- /dev/null +++ b/toolkit/content/tests/browser/data/post_form_inner.sjs @@ -0,0 +1,33 @@ +/* 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/. */ + +const CC = Components.Constructor; +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function handleRequest(request, response) { + var body = + "<html>\ + <body>\ + Inner POST data: "; + + var bodyStream = new BinaryInputStream(request.bodyInputStream); + var avail = 0; + while ((avail = bodyStream.available()) > 0) { + body += String.fromCharCode.apply(String, bodyStream.readByteArray(avail)); + } + + body += + '<form id="postForm" action="post_form_inner.sjs" method="post">\ + <input type="text" name="inputfield" value="inner">\ + <input type="submit">\ + </form>\ + </body>\ + </html>'; + + response.bodyOutputStream.write(body, body.length); +} diff --git a/toolkit/content/tests/browser/data/post_form_outer.sjs b/toolkit/content/tests/browser/data/post_form_outer.sjs new file mode 100644 index 0000000000..0826a47cd0 --- /dev/null +++ b/toolkit/content/tests/browser/data/post_form_outer.sjs @@ -0,0 +1,36 @@ +/* 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/. */ + +const CC = Components.Constructor; +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function handleRequest(request, response) { + var body = + "<html>\ + <body>\ + Outer POST data: "; + + var bodyStream = new BinaryInputStream(request.bodyInputStream); + var avail = 0; + while ((avail = bodyStream.available()) > 0) { + body += String.fromCharCode.apply(String, bodyStream.readByteArray(avail)); + } + + body += + '<form id="postForm" action="post_form_outer.sjs" method="post">\ + <input type="text" name="inputfield" value="outer">\ + <input type="submit">\ + </form>\ + \ + <iframe id="innerFrame" src="post_form_inner.sjs" width="400" height="200">\ + \ + </body>\ + </html>'; + + response.bodyOutputStream.write(body, body.length); +} diff --git a/toolkit/content/tests/browser/datetime/browser.toml b/toolkit/content/tests/browser/datetime/browser.toml new file mode 100644 index 0000000000..6e8580ddc4 --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser.toml @@ -0,0 +1,96 @@ +[DEFAULT] +support-files = ["head.js"] + +["browser_datetime_blur.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked td.outside may not be accessible +# This file was skipped before new tests were written based on it in Bug 1676068 +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker_clear.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker_focus.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker_keynav.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker_markup.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker_min_max.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked TD may not be accessible +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker_monthyear.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker_mousenav.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked td.weekend.outside may not be accessible +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_datepicker_prev_next_month.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_datetime_showPicker.js"] +# do not skip + +["browser_datetime_toplevel.js"] +fail-if = ["a11y_checks"] # Bug 1854538 clicked input may not be accessible + +["browser_spinner.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] + +["browser_spinner_keynav.js"] +skip-if = [ + "tsan", # Frequently times out on TSan + "os == 'win' && asan", # fails on asan + "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 +] diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_blur.js b/toolkit/content/tests/browser/datetime/browser_datetime_blur.js new file mode 100644 index 0000000000..e7ac0037b9 --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_blur.js @@ -0,0 +1,265 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_CONTENT = `data:text/html, + <body onload='gBlurEvents = 0; gDateFocusEvents = 0; gTextFocusEvents = 0'> + <input type='date' id='date' onfocus='gDateFocusEvents++' onblur='gBlurEvents++'> + <input type='text' id='text' onfocus='gTextFocusEvents++'> + </body>`; + +function getBlurEvents() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.gBlurEvents; + }); +} + +function getDateFocusEvents() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.gDateFocusEvents; + }); +} + +function getTextFocusEvents() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.gTextFocusEvents; + }); +} + +/** + * Test that when a picker panel is opened by an input + * the input is not blurred + */ +add_task(async function test_parent_blur() { + info( + "Test that when a picker panel is opened by an input the parent is not blurred" + ); + + // Set "prefers-reduced-motion" media to "reduce" + // to avoid intermittent scroll failures (1803612, 1803687) + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 1]], + }); + Assert.ok( + matchMedia("(prefers-reduced-motion: reduce)").matches, + "The reduce motion mode is active" + ); + + await helper.openPicker(PAGE_CONTENT, false, "showPicker"); + + Assert.equal( + await getDateFocusEvents(), + 0, + "Date input field is not calling a focus event when the '.showPicker()' method is called" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const input = content.document.querySelector("#date"); + + Assert.ok( + !input.matches(":focus"), + `The keyboard focus is not placed on the date input after showPicker is called` + ); + }); + + let closedOnEsc = helper.promisePickerClosed(); + + // Close a date picker + EventUtils.synthesizeKey("KEY_Escape", {}); + + await closedOnEsc; + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed on Escape" + ); + Assert.equal( + await getDateFocusEvents(), + 0, + "Date input field is not focused when its picker is dismissed with Escape key" + ); + Assert.equal( + await getBlurEvents(), + 0, + "Date input field is not blurred when the picker is closed with Escape key" + ); + + // Ensure focus is on the input field + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const input = content.document.querySelector("#date"); + + input.focus(); + + Assert.ok( + input.matches(":focus"), + `The keyboard focus is placed on the date input field` + ); + }); + Assert.equal( + await getDateFocusEvents(), + 1, + "A focus event was fired on the Date input field" + ); + + let readyOnKey = helper.waitForPickerReady(); + + // Open a date picker + EventUtils.synthesizeKey(" ", {}); + + await readyOnKey; + + Assert.equal( + helper.panel.state, + "open", + "Date picker panel should be opened" + ); + Assert.equal( + helper.panel + .querySelector("#dateTimePopupFrame") + .contentDocument.activeElement.getAttribute("role"), + "gridcell", + "The picker is opened and a calendar day is focused" + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const input = content.document.querySelector("#date"); + + Assert.ok( + input.matches(":focus"), + `The keyboard focus is retained on the date input field` + ); + Assert.equal( + input, + content.document.activeElement, + "Input field does not loose focus when its picker is opened and focused" + ); + }); + + Assert.equal( + await getBlurEvents(), + 0, + "Date input field is not blurred when its picker is opened and focused" + ); + Assert.equal( + await getDateFocusEvents(), + 1, + "No new focus events were fired on the Date input while its picker is opened" + ); + + info( + `Test that the date input field is not blurred after interacting + with a month-year panel` + ); + + // Move focus from the today's date to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + + Assert.ok( + helper.getElement(BTN_MONTH_YEAR).matches(":focus"), + "The month-year toggle button is focused" + ); + + // Open the month-year selection panel: + EventUtils.synthesizeKey(" ", {}); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "true", + "Month-year button is expanded when the spinners are shown" + ); + Assert.ok( + BrowserTestUtils.isVisible(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection panel is visible" + ); + + // Move focus from the month-year toggle button to the year spinner: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + + // Change the year spinner value from February 2023 to March 2023: + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const input = content.document.querySelector("#date"); + + Assert.ok( + input.matches(":focus"), + `The keyboard focus is retained on the date input field` + ); + Assert.equal( + input, + content.document.activeElement, + "Input field does not loose focus when the month-year picker is opened and interacted with" + ); + }); + + Assert.equal( + await getBlurEvents(), + 0, + "Date input field is not blurred after interacting with a month-year panel" + ); + + info(`Test that when a picker panel is opened and then it is closed + with a click on the other field, the focus is updated`); + + let closedOnClick = helper.promisePickerClosed(); + + // Close a picker by clicking on another input + await BrowserTestUtils.synthesizeMouseAtCenter( + "#text", + {}, + gBrowser.selectedBrowser + ); + + await closedOnClick; + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed when another element is clicked" + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const inputText = content.document.querySelector("#text"); + const inputDate = content.document.querySelector("#date"); + + Assert.ok( + inputText.matches(":focus"), + `The keyboard focus is moved to the text input field` + ); + Assert.equal( + inputText, + content.document.activeElement, + "Text input field gains a focus when clicked" + ); + Assert.ok( + !inputDate.matches(":focus"), + `The keyboard focus is moved from the date input field` + ); + Assert.notEqual( + inputDate, + content.document.activeElement, + "Date input field is not focused anymore" + ); + }); + + Assert.equal( + await getBlurEvents(), + 1, + "Date input field is blurred when focus is moved to the text input field" + ); + Assert.equal( + await getTextFocusEvents(), + 1, + "Text input field is focused when it is clicked" + ); + Assert.equal( + await getDateFocusEvents(), + 1, + "No new focus events were fired on the Date input after its picker was closed" + ); + + await helper.tearDown(); + // Clear the prefers-reduced-motion pref from the test profile: + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker.js new file mode 100644 index 0000000000..b7c4df8d2a --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker.js @@ -0,0 +1,369 @@ +/* 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"; + +// Create a list of abbreviations for calendar class names +const W = "weekend", + O = "outside", + S = "selection", + R = "out-of-range", + T = "today", + P = "off-step"; + +// Calendar classlist for 2016-12. Used to verify the classNames are correct. +const calendarClasslist_201612 = [ + [W, O], + [O], + [O], + [O], + [], + [], + [W], + [W], + [], + [], + [], + [], + [], + [W], + [W], + [], + [], + [], + [S], + [], + [W], + [W], + [], + [], + [], + [], + [], + [W], + [W], + [], + [], + [], + [], + [], + [W], + [W, O], + [O], + [O], + [O], + [O], + [O], + [W, O], +]; + +/** + * Test that date picker opens to today's date when input field is blank + */ +add_task(async function test_datepicker_today() { + info("Test that date picker opens to today's date when input field is blank"); + + const date = new Date(); + + await helper.openPicker("data:text/html, <input type='date'>"); + + if (date.getMonth() === new Date().getMonth()) { + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT_LOCAL(date), + "Today's date is opened" + ); + Assert.equal( + helper.getElement(DAY_TODAY).getAttribute("aria-current"), + "date", + "Today's date is programmatically current" + ); + Assert.equal( + helper.getElement(DAY_TODAY).getAttribute("tabindex"), + "0", + "Today's date is included in the focus order, when nothing is selected" + ); + } else { + Assert.ok( + true, + "Skipping datepicker today test if month changes when opening picker." + ); + } + + await helper.tearDown(); +}); + +/** + * Test that date picker opens to the correct month, with calendar days + * displayed correctly, given a date value is set. + */ +add_task(async function test_datepicker_open() { + info("Test the date picker markup with a set input date value"); + + const inputValue = "2016-12-15"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)), + "2016-12-15 date is opened" + ); + + Assert.deepEqual( + getCalendarText(), + [ + "27", + "28", + "29", + "30", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + ], + "Calendar text for 2016-12 is correct" + ); + Assert.deepEqual( + getCalendarClassList(), + calendarClasslist_201612, + "2016-12 classNames of the picker are correct" + ); + Assert.equal( + helper.getElement(DAY_SELECTED).getAttribute("aria-selected"), + "true", + "Chosen date is programmatically selected" + ); + Assert.equal( + helper.getElement(DAY_SELECTED).getAttribute("tabindex"), + "0", + "Selected date is included in the focus order" + ); + + await helper.tearDown(); +}); + +/** + * Ensure that the datepicker popup appears correctly positioned when + * the input field has been transformed. + */ +add_task(async function test_datepicker_transformed_position() { + const inputValue = "2016-12-15"; + + const style = + "transform: translateX(7px) translateY(13px); border-top: 2px; border-left: 5px; margin: 30px;"; + const iframeContent = `<input id="date" type="date" value="${inputValue}" style="${style}">`; + await helper.openPicker( + "data:text/html,<iframe id='iframe' src='http://example.net/document-builder.sjs?html=" + + encodeURI(iframeContent) + + "'>", + true + ); + + let bc = helper.tab.linkedBrowser.browsingContext.children[0]; + await verifyPickerPosition(bc, "date"); + + await helper.tearDown(); +}); + +/** + * Make sure picker is in correct state when it is reopened. + */ +add_task(async function test_datepicker_reopen_state() { + const inputValue = "2016-12-15"; + const nextMonth = "2017-01-01"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + // Navigate to the next month but do not commit the change + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)) + ); + + helper.click(helper.getElement(BTN_NEXT_MONTH)); + + // January 2017 + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextMonth)) + ); + + let closed = helper.promisePickerClosed(); + + EventUtils.synthesizeKey("KEY_Escape", {}); + + await closed; + + Assert.equal(helper.panel.state, "closed", "Panel should be closed"); + + // December 2016 + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let input = content.document.querySelector("input"); + Assert.equal( + input.value, + "2016-12-15", + "The input value remains unchanged after the picker is dismissed" + ); + }); + + let ready = helper.waitForPickerReady(); + + // Move focus from the browser to an input field and open a picker: + EventUtils.synthesizeKey("KEY_Tab", {}); + EventUtils.synthesizeKey(" ", {}); + + await ready; + + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + + // December 2016 + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)) + ); + + await helper.tearDown(); +}); + +/** + * When step attribute is set, calendar should show some dates as off-step. + */ +add_task(async function test_datepicker_step() { + const inputValue = "2016-12-15"; + const inputStep = "5"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}" step="${inputStep}">` + ); + + Assert.deepEqual( + getCalendarClassList(), + mergeArrays(calendarClasslist_201612, [ + // P denotes off-step + [P], + [P], + [P], + [], + [P], + [P], + [P], + [P], + [], + [P], + [P], + [P], + [P], + [], + [P], + [P], + [P], + [P], + [], + [P], + [P], + [P], + [P], + [], + [P], + [P], + [P], + [P], + [], + [P], + [P], + [P], + [P], + [], + [P], + [P], + [P], + [P], + [], + [P], + [P], + [P], + ]), + "2016-12 with step" + ); + + await helper.tearDown(); +}); + +// This test checks if the change event is considered as user input event. +add_task(async function test_datepicker_handling_user_input() { + await helper.openPicker(`data:text/html, <input type="date">`); + + let changeEventPromise = helper.promiseChange(); + + // Click the first item (top-left corner) of the calendar + helper.click(helper.getElement(DAYS_VIEW).children[0]); + await changeEventPromise; + + await helper.tearDown(); +}); + +/** + * Ensure datetime-local picker closes when selection is made. + */ +add_task(async function test_datetime_focus_to_input() { + info("Ensure datetime-local picker closes when focus moves to a time input"); + + await helper.openPicker( + `data:text/html,<input id=datetime type=datetime-local>` + ); + let browser = helper.tab.linkedBrowser; + await verifyPickerPosition(browser, "datetime"); + + Assert.equal(helper.panel.state, "open", "Panel should be visible"); + + // Make selection to close the date dialog + await EventUtils.synthesizeKey(" ", {}); + + let closed = helper.promisePickerClosed(); + + await closed; + + Assert.equal(helper.panel.state, "closed", "Panel should be closed now"); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_clear.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_clear.js new file mode 100644 index 0000000000..3c3de2dc98 --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_clear.js @@ -0,0 +1,56 @@ +/* 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"; + +async function testClear(key) { + const inputValue = "2023-03-03"; + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + let browser = helper.tab.linkedBrowser; + + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + + let closed = helper.promisePickerClosed(); + + // Clear the input fields + if (key) { + // Move focus from the selected date to the Clear button: + EventUtils.synthesizeKey("KEY_Tab", {}); + + Assert.ok( + helper.getElement(BTN_CLEAR).matches(":focus"), + "The Clear button can receive keyboard focus" + ); + + EventUtils.synthesizeKey(key, {}); + } else { + helper.click(helper.getElement(BTN_CLEAR)); + } + + await closed; + + await SpecialPowers.spawn(browser, [], () => { + is( + content.document.querySelector("input").value, + "", + "The input value is reset after the Clear button is pressed" + ); + }); + + await helper.tearDown(); +} + +add_task(async function test_datepicker_clear_keyboard() { + await testClear(" "); +}); + +add_task(async function test_datepicker_clear_keyboard_enter() { + await testClear("KEY_Enter"); +}); + +add_task(async function test_datepicker_clear_mouse() { + await testClear(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_focus.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_focus.js new file mode 100644 index 0000000000..b99e8ed0e8 --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_focus.js @@ -0,0 +1,191 @@ +/* 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"; + +/** + * Ensure navigating through Datepicker using keyboard after a date + * has already been selected will keep the keyboard focus + * when reaching a different month (bug 1804466). + */ +add_task(async function test_focus_after_selection() { + info( + `Ensure navigating through Datepicker using keyboard after a date has already been selected will not lose keyboard focus when reaching a different month.` + ); + + // Set "prefers-reduced-motion" media to "reduce" + // to avoid intermittent scroll failures (1803612, 1803687) + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 1]], + }); + Assert.ok( + matchMedia("(prefers-reduced-motion: reduce)").matches, + "The reduce motion mode is active" + ); + + const inputValue = "2022-12-12"; + const prevMonth = "2022-10-01"; + const nextYear = "2023-11-01"; + const nextYearAfter = "2024-01-01"; + + await helper.openPicker( + `data:text/html, <input type="date" value=${inputValue}>` + ); + let browser = helper.tab.linkedBrowser; + + info("Test behavior when selection is done on the calendar grid"); + + // Move focus from 2022-12-12 to 2022-10-24 by week + // Changing 2 month views along the way: + EventUtils.synthesizeKey("KEY_ArrowUp", { repeat: 7 }); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)), + "The calendar is updated to show the second previous month (2022-10)." + ); + + // 2022-10-24: + const focusedDayEl = getDayEl(24); + + Assert.ok( + focusedDayEl.matches(":focus"), + "An expected focusable day within a calendar grid is focused" + ); + + let closed = helper.promisePickerClosed(); + + // Make a selection and close the picker + EventUtils.synthesizeKey(" ", {}); + + // Check the focus is returned to main browser window when a panel is closed + await SpecialPowers.spawn(browser, [], async () => { + const body = content.document.body; + // Testing the focus position within content: + Assert.deepEqual( + body, + content.document.activeElement, + `The main content's <body> received programmatic focus` + ); + }); + + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Panel is closed when the selection is made" + ); + + let ready = helper.waitForPickerReady(); + + // Move the keyboard focus to the input field to reopen the picker + EventUtils.synthesizeKey("KEY_Tab", {}); + + // Check the focus is returned to the Calendar button + await SpecialPowers.spawn(browser, [], async () => { + const input = content.document.querySelector("input"); + // Testing the focus position within content: + Assert.deepEqual( + input, + content.document.activeElement, + `The input field includes programmatic focus` + ); + }); + + // Reopen the picker + EventUtils.synthesizeKey(" ", {}); + + await ready; + + Assert.equal(helper.panel.state, "open", "Panel is reopened"); + + // Move focus from 2022-10-24 to 2022-12-12 by week + // Changing 2 month views along the way: + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: 7 }); + + // 2022-12-12: + const focusedDay = getDayEl(12); + const monthYearEl = helper.getElement(MONTH_YEAR); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(inputValue)); + }, + `Should change to December 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + Assert.equal( + focusedDay, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "There is a focusable day within a calendar grid" + ); + Assert.ok( + focusedDay.matches(":focus"), + "The focusable day within a calendar grid is focused" + ); + + info("Test behavior when selection is done on the month-year panel"); + + // Move focus to the month-year toggle button and open it: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + EventUtils.synthesizeKey(" "); + + // Move focus to the month spin button and change its value + // from December to November: + EventUtils.synthesizeKey("KEY_Tab"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + + // Move focus to the year spin button and change its value + // from 2022 to 2023: + EventUtils.synthesizeKey("KEY_Tab"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(nextYear)); + }, + `Should change to November 2023, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + // Make a selection, close the month picker + EventUtils.synthesizeKey(" ", {}); + + Assert.ok( + BrowserTestUtils.isHidden(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection panel is not visible" + ); + + // Move focus from 2023-11-12 to 2024-01-07 by week + // Changing 2 month views along the way: + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: 8 }); + + // 2024-01-07: + const newFocusedDay = getDayEl(7); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextYearAfter)), + "The calendar is updated to show another month (2024-01)." + ); + Assert.equal( + newFocusedDay, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "There is a focusable day within a calendar grid" + ); + Assert.ok( + newFocusedDay.matches(":focus"), + "The focusable day within a calendar grid is focused" + ); + + await helper.tearDown(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_keynav.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_keynav.js new file mode 100644 index 0000000000..0b271ed77a --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_keynav.js @@ -0,0 +1,576 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensure picker opens, closes, and updates its value with key bindings appropriately. + */ +add_task(async function test_datepicker_keyboard_nav() { + info( + "Ensure picker opens, closes, and updates its value with key bindings appropriately." + ); + + const inputValue = "2016-12-15"; + const prevMonth = "2016-11-01"; + await helper.openPicker( + `data:text/html,<input id=date type=date value=${inputValue}>` + ); + let browser = helper.tab.linkedBrowser; + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + + await testCalendarBtnAttribute("aria-expanded", "true"); + + let closed = helper.promisePickerClosed(); + + // Close on Escape anywhere + EventUtils.synthesizeKey("KEY_Escape", {}); + + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed after Escape from anywhere on the window" + ); + + await testCalendarBtnAttribute("aria-expanded", "false"); + + let ready = helper.waitForPickerReady(); + + // Ensure focus is on the input field + await SpecialPowers.spawn(browser, [], () => { + content.document.querySelector("#date").focus(); + }); + + info("Test that input updates with the keyboard update the picker"); + + // NOTE: After a Tab, the first input field (the month one) is focused, + // so down arrow will change the selected month. + // + // This assumes en-US locale, which seems fine for testing purposes (as + // DATE_FORMAT and other bits around do the same). + BrowserTestUtils.synthesizeKey("KEY_ArrowDown", {}, browser); + + // Toggle the picker on Space anywhere within the input + BrowserTestUtils.synthesizeKey(" ", {}, browser); + + await ready; + + await testCalendarBtnAttribute("aria-expanded", "true"); + + Assert.equal( + helper.panel.state, + "open", + "Panel should be opened on Space from anywhere within the input field" + ); + + Assert.equal( + helper.panel.querySelector("#dateTimePopupFrame").contentDocument + .activeElement.textContent, + "15", + "Picker is opened with a focus set to the currently selected date" + ); + + let monthYearEl = helper.getElement(MONTH_YEAR); + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(prevMonth)); + }, + `Should change to November 2016, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.ok( + true, + "The date on both the Calendar and Month-Year button was updated when updating months with Down arrow key" + ); + + closed = helper.promisePickerClosed(); + + // Close on Escape and return the focus to the input field (the month input in en-US locale) + EventUtils.synthesizeKey("KEY_Escape", {}, window); + + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed on Escape" + ); + + // Check the focus is returned to the Month field + await SpecialPowers.spawn(browser, [], async () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + // Separators "/" are odd children of the wrapper + const monthField = shadowRoot.getElementById("edit-wrapper").children[0]; + // Testing the focus position within content: + Assert.equal( + input, + content.document.activeElement, + `The input field includes programmatic focus` + ); + // Testing the focus indication within the shadow-root: + Assert.ok( + monthField.matches(":focus"), + `The keyboard focus was returned to the Month field` + ); + }); + + // Move focus to the second field (the day input in en-US locale) + BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, browser); + + // Change the day to 2016-12-16 + BrowserTestUtils.synthesizeKey("KEY_ArrowUp", {}, browser); + + ready = helper.waitForPickerReady(); + + // Open the picker on Space within the input to check the date update + await BrowserTestUtils.synthesizeKey(" ", {}, browser); + + await ready; + + await testCalendarBtnAttribute("aria-expanded", "true"); + + Assert.equal(helper.panel.state, "open", "Panel should be opened on Space"); + + let selectedDayEl = helper.getElement(DAY_SELECTED); + await BrowserTestUtils.waitForMutationCondition( + selectedDayEl, + { childList: true }, + () => { + return selectedDayEl.textContent === "16"; + }, + `Should change to the 16th, instead got ${ + helper.getElement(DAY_SELECTED).textContent + }` + ); + + Assert.ok( + true, + "The date on the Calendar was updated when updating days with Up arrow key" + ); + + closed = helper.promisePickerClosed(); + + // Close on Escape and return the focus to the input field (the day input in en-US locale) + EventUtils.synthesizeKey("KEY_Escape", {}, window); + + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed on Escape" + ); + + await testCalendarBtnAttribute("aria-expanded", "false"); + + // Check the focus is returned to the Day field + await SpecialPowers.spawn(browser, [], async () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + // Separators "/" are odd children of the wrapper + const dayField = shadowRoot.getElementById("edit-wrapper").children[2]; + // Testing the focus position within content: + Assert.equal( + input, + content.document.activeElement, + `The input field includes programmatic focus` + ); + // Testing the focus indication within the shadow-root: + Assert.ok( + dayField.matches(":focus"), + `The keyboard focus was returned to the Day field` + ); + }); + + info("Test the Calendar button can toggle the picker with Enter/Space"); + + // Move focus to the Calendar button + BrowserTestUtils.synthesizeKey("KEY_Tab", {}, browser); + BrowserTestUtils.synthesizeKey("KEY_Tab", {}, browser); + + // Toggle the picker on Enter on Calendar button + await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser); + + await helper.waitForPickerReady(); + + Assert.equal( + helper.panel.state, + "open", + "Panel should be opened on Enter from the Calendar button" + ); + + await testCalendarBtnAttribute("aria-expanded", "true"); + + // Move focus from 2016-11-16 to 2016-11-17 + EventUtils.synthesizeKey("KEY_ArrowRight", {}); + + // Make a selection by pressing Space on date gridcell + await EventUtils.synthesizeKey(" ", {}); + + await helper.promisePickerClosed(); + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed on Space from the date gridcell" + ); + await testCalendarBtnAttribute("aria-expanded", "false"); + + // Check the focus is returned to the Calendar button + await SpecialPowers.spawn(browser, [], async () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + const calendarBtn = shadowRoot.getElementById("calendar-button"); + // Testing the focus position within content: + Assert.equal( + input, + content.document.activeElement, + `The input field includes programmatic focus` + ); + // Testing the focus indication within the shadow-root: + Assert.ok( + calendarBtn.matches(":focus"), + `The keyboard focus was returned to the Calendar button` + ); + }); + + // Check the Backspace on Calendar button is not doing anything + await EventUtils.synthesizeKey("KEY_Backspace", {}); + + // The Calendar button is on its place and the input value is not changed + // (bug 1804669) + await SpecialPowers.spawn(browser, [], () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + const calendarBtn = shadowRoot.getElementById("calendar-button"); + Assert.equal( + calendarBtn.children[0].tagName, + "svg", + `Calendar button has an <svg> child` + ); + Assert.equal(input.value, "2016-11-17", `Input's value is not removed`); + }); + + // Toggle the picker on Space on Calendar button + await EventUtils.synthesizeKey(" ", {}); + + await helper.waitForPickerReady(); + + Assert.equal( + helper.panel.state, + "open", + "Panel should be opened on Space from the Calendar button" + ); + + await testCalendarBtnAttribute("aria-expanded", "true"); + + await helper.tearDown(); +}); + +/** + * Ensure calendar follows Arrow key bindings appropriately. + */ +add_task(async function test_datepicker_keyboard_arrows() { + info("Ensure calendar follows Arrow key bindings appropriately."); + + const inputValue = "2016-12-10"; + const prevMonth = "2016-11-01"; + await helper.openPicker( + `data:text/html,<input id=date type=date value=${inputValue}>` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + + // Move focus from 2016-12-10 to 2016-12-11: + EventUtils.synthesizeKey("KEY_ArrowRight", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "11", + "Arrow Right moves focus to the next day" + ); + + // Move focus from 2016-12-11 to 2016-12-04: + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "4", + "Arrow Up moves focus to the same weekday of the previous week" + ); + + // Move focus from 2016-12-04 to 2016-12-03: + EventUtils.synthesizeKey("KEY_ArrowLeft", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "3", + "Arrow Left moves focus to the previous day" + ); + + // Move focus from 2016-12-03 to 2016-11-26: + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "26", + "Arrow Up updates the view to be on the previous month, if needed" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)), + "Arrow Up updates the spinner to show the previous month, if needed" + ); + + // Move focus from 2016-11-26 to 2016-12-03: + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + Assert.equal( + pickerDoc.activeElement.textContent, + "3", + "Arrow Down updates the view to be on the next month, if needed" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)), + "Arrow Down updates the spinner to show the next month, if needed" + ); + + // Move focus from 2016-12-03 to 2016-12-10: + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "10", + "Arrow Down moves focus to the same day of the next week" + ); + + await helper.tearDown(); +}); + +/** + * Ensure calendar follows Home/End key bindings appropriately. + */ +add_task(async function test_datepicker_keyboard_home_end() { + info("Ensure calendar follows Home/End key bindings appropriately."); + + const inputValue = "2016-12-15"; + const prevMonth = "2016-11-01"; + await helper.openPicker( + `data:text/html,<input id=date type=date value=${inputValue}>` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + + // Move focus from 2016-12-15 to 2016-12-11 (in the en-US locale): + EventUtils.synthesizeKey("KEY_Home", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "11", + "Home key moves focus to the first day/Sunday of the current week" + ); + + // Move focus from 2016-12-11 to 2016-12-17 (in the en-US locale): + EventUtils.synthesizeKey("KEY_End", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "17", + "End key moves focus to the last day/Saturday of the current week" + ); + + // Move focus from 2016-12-17 to 2016-12-31: + EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }); + + Assert.equal( + pickerDoc.activeElement.textContent, + "31", + "Ctrl + End keys move focus to the last day of the current month" + ); + + // Move focus from 2016-12-31 to 2016-12-01: + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }); + + Assert.equal( + pickerDoc.activeElement.textContent, + "1", + "Ctrl + Home keys move focus to the first day of the current month" + ); + + // Move focus from 2016-12-01 to 2016-11-27 (in the en-US locale): + EventUtils.synthesizeKey("KEY_Home", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "27", + "Home key updates the view to be on the previous month, if needed" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)), + "Home key updates the spinner to show the previous month, if needed" + ); + + // Move focus from 2016-11-27 to 2016-12-03 (in the en-US locale): + EventUtils.synthesizeKey("KEY_End", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "3", + "End key updates the view to be on the next month, if needed" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)), + "End key updates the spinner to show the next month, if needed" + ); + + await helper.tearDown(); +}); + +/** + * Ensure calendar follows Page Up/Down key bindings appropriately. + */ +add_task(async function test_datepicker_keyboard_pgup_pgdown() { + info("Ensure calendar follows Page Up/Down key bindings appropriately."); + + const inputValue = "2023-01-31"; + const prevMonth = "2022-12-31"; + const prevYear = "2021-12-01"; + const nextMonth = "2023-01-31"; + const nextShortMonth = "2023-03-03"; + await helper.openPicker( + `data:text/html,<input id=date type=date value=${inputValue}>` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + + // Move focus from 2023-01-31 to 2022-12-31: + EventUtils.synthesizeKey("KEY_PageUp", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "31", + "Page Up key moves focus to the same day of the previous month" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)), + "Page Up key updates the month-year button to show the previous month" + ); + + // Move focus from 2022-12-31 to 2022-12-01 + // (because 2022-11-31 does not exist): + EventUtils.synthesizeKey("KEY_PageUp", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "1", + `When the same day does not exists in the previous month Page Up key moves + focus to the same day of the same week of the current month` + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)), + `When the same day does not exist in the previous month + Page Up key does not update the month-year button and shows the current month` + ); + + // Move focus from 2022-12-01 to 2021-12-01: + EventUtils.synthesizeKey("KEY_PageUp", { shiftKey: true }); + Assert.equal( + pickerDoc.activeElement.textContent, + "1", + "Page Up with Shift key moves focus to the same day of the same month of the previous year" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevYear)), + "Page Up with Shift key updates the month-year button to show the same month of the previous year" + ); + + // Move focus from 2021-12-01 to 2022-12-01 month by month (bug 1806645): + EventUtils.synthesizeKey("KEY_PageDown", { repeat: 12 }); + Assert.equal( + pickerDoc.activeElement.textContent, + "1", + "When repeated, Page Down key moves focus to the same day of the same month of the next year" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)), + "When repeated, Page Down key updates the month-year button to show the same month of the next year" + ); + + // Move focus from 2022-12-01 to 2021-12-01 month by month (bug 1806645): + EventUtils.synthesizeKey("KEY_PageUp", { repeat: 12 }); + Assert.equal( + pickerDoc.activeElement.textContent, + "1", + "When repeated, Page Up moves focus to the same day of the same month of the previous year" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevYear)), + "When repeated, Page Up key updates the month-year button to show the same month of the previous year" + ); + + // Move focus from 2021-12-01 to 2022-12-01: + EventUtils.synthesizeKey("KEY_PageDown", { shiftKey: true }); + Assert.equal( + pickerDoc.activeElement.textContent, + "1", + "Page Down with Shift key moves focus to the same day of the same month of the next year" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)), + "Page Down with Shift key updates the month-year button to show the same month of the next year" + ); + + // Move focus from 2016-12-01 to 2016-12-31: + EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }); + // Move focus from 2022-12-31 to 2023-01-31: + EventUtils.synthesizeKey("KEY_PageDown", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "31", + "Page Down key moves focus to the same day of the next month" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextMonth)), + "Page Down key updates the month-year button to show the next month" + ); + + // Move focus from 2023-01-31 to 2023-03-03: + EventUtils.synthesizeKey("KEY_PageDown", {}); + + Assert.equal( + pickerDoc.activeElement.textContent, + "3", + `When the same day does not exists in the next month, Page Down key moves + focus to the same day of the same week of the month after` + ); + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextShortMonth)), + "Page Down key updates the month-year button to show the month after" + ); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_markup.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_markup.js new file mode 100644 index 0000000000..efa2fbfeab --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_markup.js @@ -0,0 +1,483 @@ +/* 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"; + +/** + * Test that date picker opens with accessible markup + */ +add_task(async function test_datepicker_markup() { + info("Test that the date picker opens with accessible markup"); + + await helper.openPicker("data:text/html, <input type='date'>"); + + Assert.equal( + helper.getElement(DIALOG_PICKER).getAttribute("role"), + "dialog", + "Datepicker dialog has an appropriate ARIA role" + ); + Assert.ok( + helper.getElement(DIALOG_PICKER).getAttribute("aria-modal"), + "Datepicker dialog is a modal" + ); + Assert.equal( + helper.getElement(BTN_PREV_MONTH).tagName, + "button", + "Previous Month control is a button" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).tagName, + "button", + "Month picker view toggle is a button" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).getAttribute("aria-expanded"), + "false", + "Month picker view toggle is collapsed when the dialog is hidden" + ); + Assert.equal( + helper.getElement(MONTH_YEAR).getAttribute("aria-live"), + "polite", + "Month picker view toggle is a live region when it's not expanded" + ); + Assert.ok( + BrowserTestUtils.isHidden(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection spinner is not visible" + ); + Assert.ok( + BrowserTestUtils.isHidden(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection spinner is programmatically hidden" + ); + Assert.equal( + helper.getElement(BTN_NEXT_MONTH).tagName, + "button", + "Next Month control is a button" + ); + Assert.equal( + helper.getElement(DAYS_VIEW).parentNode.tagName, + "table", + "Calendar view is marked up as a table" + ); + Assert.equal( + helper.getElement(DAYS_VIEW).parentNode.getAttribute("role"), + "grid", + "Calendar view is a grid" + ); + Assert.ok( + helper.getElement( + `#${helper + .getElement(DAYS_VIEW) + .parentNode.getAttribute("aria-labelledby")}` + ), + "Calendar view has a valid accessible name" + ); + Assert.equal( + helper.getElement(WEEK_HEADER).firstChild.tagName, + "tr", + "Week headers within the Calendar view are marked up as table rows" + ); + Assert.equal( + helper.getElement(WEEK_HEADER).firstChild.firstChild.tagName, + "th", + "Weekdays within the Calendar view are marked up as header cells" + ); + Assert.equal( + helper.getElement(WEEK_HEADER).firstChild.firstChild.getAttribute("role"), + "columnheader", + "Weekdays within the Calendar view are grid column headers" + ); + Assert.equal( + helper.getElement(DAYS_VIEW).firstChild.tagName, + "tr", + "Weeks within the Calendar view are marked up as table rows" + ); + Assert.equal( + helper.getElement(DAYS_VIEW).firstChild.firstChild.tagName, + "td", + "Days within the Calendar view are marked up as table cells" + ); + Assert.equal( + helper.getElement(DAYS_VIEW).firstChild.firstChild.getAttribute("role"), + "gridcell", + "Days within the Calendar view are gridcells" + ); + Assert.equal( + helper.getElement(BTN_CLEAR).tagName, + "button", + "Clear control is a button" + ); + + await helper.tearDown(); +}); + +/** + * Test that date picker has localizable labels + */ +add_task(async function test_datepicker_l10n() { + info("Test that the date picker has localizable labels"); + + await helper.openPicker("data:text/html, <input type='date'>"); + + const testcases = [ + { + selector: DIALOG_PICKER, + id: "date-picker-label", + args: null, + }, + { + selector: MONTH_YEAR_NAV, + id: "date-spinner-label", + args: null, + }, + { + selector: BTN_PREV_MONTH, + id: "date-picker-previous", + args: null, + }, + { + selector: BTN_NEXT_MONTH, + id: "date-picker-next", + args: null, + }, + { + selector: BTN_CLEAR, + id: "date-picker-clear-button", + args: null, + }, + ]; + + // Check "aria-label" attributes + for (let { selector, id, args } of testcases) { + const el = helper.getElement(selector); + const l10nAttrs = document.l10n.getAttributes(el); + + Assert.ok( + el.hasAttribute("aria-label") || el.textContent, + `Datepicker "${selector}" element has accessible name` + ); + Assert.deepEqual( + l10nAttrs, + { + id, + args, + }, + `Datepicker "${selector}" element's accessible name is localizable` + ); + } + + await helper.tearDown(); +}); + +/** + * Test that date picker opens to today's date, with today's and selected days + * marked up correctly, given a date value is set. + */ +add_task(async function test_datepicker_today_and_selected() { + info("Test today's and selected days' markup when a date value is set"); + + const date = new Date(); + let inputValue = new Date(); + // Both 2 and 10 dates are used as an example only to test that + // the current date and selected dates are marked up differently. + if (date.getDate() === 2) { + inputValue.setDate(10); + } else { + inputValue.setDate(2); + } + inputValue = inputValue.toISOString().split("T")[0]; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}"> ` + ); + + if (date.getMonth() === new Date().getMonth()) { + Assert.notEqual( + helper.getElement(DAY_TODAY), + helper.getElement(DAY_SELECTED), + "Today and selected dates are different" + ); + Assert.equal( + helper.getElement(DAY_TODAY).getAttribute("aria-current"), + "date", + "Today's date is programmatically current" + ); + Assert.equal( + helper.getElement(DAY_SELECTED).getAttribute("aria-selected"), + "true", + "Chosen date is programmatically selected" + ); + Assert.ok( + !helper.getElement(DAY_TODAY).hasAttribute("tabindex"), + "Today is not included in the focus order, when another day is selected" + ); + Assert.equal( + helper.getElement(DAY_SELECTED).getAttribute("tabindex"), + "0", + "Selected date is included in the focus order" + ); + } else { + Assert.ok( + true, + "Skipping datepicker today test if month changes when opening picker." + ); + } + + await helper.tearDown(); +}); + +/** + * Test that date picker refreshes ARIA properties + * after the other month was displayed. + */ +add_task(async function test_datepicker_markup_refresh() { + const inputValue = "2016-12-05"; + const minValue = "2016-12-05"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}" min="${minValue}">` + ); + + const secondRowDec = helper.getChildren(DAYS_VIEW)[1].children; + + // 2016-12-05 Monday is selected (in en_US locale) + if (secondRowDec[1] === helper.getElement(DAY_SELECTED)) { + Assert.equal( + secondRowDec[1].getAttribute("aria-selected"), + "true", + "Chosen date is programmatically selected" + ); + Assert.ok( + !secondRowDec[1].classList.contains("out-of-range"), + "Chosen date is not styled as out-of-range" + ); + Assert.ok( + !secondRowDec[1].hasAttribute("aria-disabled"), + "Chosen date is not programmatically disabled" + ); + // I.e. 2016-12-04 Sunday is out-of-range (in en_US locale) + Assert.ok( + secondRowDec[0].classList.contains("out-of-range"), + "Less than min date is styled as out-of-range" + ); + Assert.equal( + secondRowDec[0].getAttribute("aria-disabled"), + "true", + "Less than min date is programmatically disabled" + ); + + // Change month view from December 2016 to January 2017 + // to check an updated markup + helper.click(helper.getElement(BTN_NEXT_MONTH)); + + const secondRowJan = helper.getChildren(DAYS_VIEW)[1].children; + + // 2017-01-02 Monday is not selected and in-range (in en_US locale) + Assert.equal( + secondRowJan[1].getAttribute("aria-selected"), + "false", + "Day with the same position as selected is not programmatically selected" + ); + Assert.ok( + !secondRowJan[1].classList.contains("out-of-range"), + "Day with the same position as selected is not styled as out-of-range" + ); + Assert.ok( + !secondRowJan[1].hasAttribute("aria-disabled"), + "Day with the same position as selected is not programmatically disabled" + ); + // I.e. 2017-01-01 Sunday is in-range (in en_US locale) + Assert.ok( + !secondRowJan[0].classList.contains("out-of-range"), + "Day with the same as less than min date is not styled as out-of-range" + ); + Assert.ok( + !secondRowJan[0].hasAttribute("aria-disabled"), + "Day with the same as less than min date is not programmatically disabled" + ); + // 2016-12-05 was focused before the change, thus the same day of the month + // is expected to be focused now (2017-01-05): + Assert.equal( + secondRowJan[4].getAttribute("tabindex"), + "0", + "The same day of the month is made focusable" + ); + Assert.ok( + !secondRowJan[0].hasAttribute("tabindex"), + "The first day of the month is not focusable" + ); + Assert.ok( + !secondRowJan[1].hasAttribute("tabindex"), + "Day with the same position as selected is not focusable" + ); + Assert.ok(!helper.getElement(DAY_TODAY), "No date is marked up as today"); + Assert.ok( + !helper.getElement(DAY_SELECTED), + "No date is marked up as selected" + ); + } else { + Assert.ok( + true, + "Skipping datepicker attributes flushing test if the week/locale is different from the en_US used for the test" + ); + } + + await helper.tearDown(); +}); + +/** + * Test that date input field has a Calendar button with an accessible markup + */ +add_task(async function test_calendar_button_markup_date() { + info( + "Test that type=date input field has a Calendar button with an accessible markup" + ); + + await helper.openPicker("data:text/html, <input type='date'>"); + let browser = helper.tab.linkedBrowser; + + Assert.equal(helper.panel.state, "open", "Panel is visible"); + + let closed = helper.promisePickerClosed(); + + await testCalendarBtnAttribute("aria-expanded", "true"); + await testCalendarBtnAttribute("aria-label", null, true); + await testCalendarBtnAttribute("data-l10n-id", "datetime-calendar"); + + await SpecialPowers.spawn(browser, [], () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + const calendarBtn = shadowRoot.getElementById("calendar-button"); + + Assert.equal(calendarBtn.tagName, "BUTTON", "Calendar control is a button"); + Assert.ok( + ContentTaskUtils.isVisible(calendarBtn), + "The Calendar button is visible" + ); + + calendarBtn.click(); + }); + + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed on click on the Calendar button" + ); + + await testCalendarBtnAttribute("aria-expanded", "false"); + + await helper.tearDown(); +}); + +/** + * Test that datetime-local input field has a Calendar button + * with an accessible markup + */ +add_task(async function test_calendar_button_markup_datetime() { + info( + "Test that type=datetime-local input field has a Calendar button with an accessible markup" + ); + + await helper.openPicker("data:text/html, <input type='datetime-local'>"); + let browser = helper.tab.linkedBrowser; + + Assert.equal(helper.panel.state, "open", "Panel is visible"); + + let closed = helper.promisePickerClosed(); + + await testCalendarBtnAttribute("aria-expanded", "true"); + await testCalendarBtnAttribute("aria-label", null, true); + await testCalendarBtnAttribute("data-l10n-id", "datetime-calendar"); + + await SpecialPowers.spawn(browser, [], () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + const calendarBtn = shadowRoot.getElementById("calendar-button"); + + Assert.equal(calendarBtn.tagName, "BUTTON", "Calendar control is a button"); + Assert.ok( + ContentTaskUtils.isVisible(calendarBtn), + "The Calendar button is visible" + ); + + calendarBtn.click(); + }); + + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed on click on the Calendar button" + ); + + await testCalendarBtnAttribute("aria-expanded", "false"); + + await helper.tearDown(); +}); + +/** + * Test that time input field does not include a Calendar button, + * but opens a time picker panel on click within the field (with a pref) + */ +add_task(async function test_calendar_button_markup_time() { + info("Test that type=time input field does not include a Calendar button"); + + // Toggle a pref to allow a time picker to be shown + await SpecialPowers.pushPrefEnv({ + set: [["dom.forms.datetime.timepicker", true]], + }); + + let testTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "data:text/html, <input type='time'>" + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + const calendarBtn = shadowRoot.getElementById("calendar-button"); + + Assert.ok( + ContentTaskUtils.isHidden(calendarBtn), + "The Calendar control within a type=time input field is programmatically hidden" + ); + }); + + let ready = helper.waitForPickerReady(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "input", + {}, + gBrowser.selectedBrowser + ); + + await ready; + + Assert.equal( + helper.panel.state, + "open", + "Time picker panel should be opened on click from anywhere within the time input field" + ); + + let closed = helper.promisePickerClosed(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "input", + {}, + gBrowser.selectedBrowser + ); + + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Time picker panel should be closed on click from anywhere within the time input field" + ); + + BrowserTestUtils.removeTab(testTab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_min_max.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_min_max.js new file mode 100644 index 0000000000..3b0de45672 --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_min_max.js @@ -0,0 +1,405 @@ +/* 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"; + +// Create a list of abbreviations for calendar class names +const W = "weekend", + O = "outside", + S = "selection", + R = "out-of-range", + T = "today", + P = "off-step"; + +// Calendar classlist for 2016-12. Used to verify the classNames are correct. +const calendarClasslist_201612 = [ + [W, O], + [O], + [O], + [O], + [], + [], + [W], + [W], + [], + [], + [], + [], + [], + [W], + [W], + [], + [], + [], + [S], + [], + [W], + [W], + [], + [], + [], + [], + [], + [W], + [W], + [], + [], + [], + [], + [], + [W], + [W, O], + [O], + [O], + [O], + [O], + [O], + [W, O], +]; + +/** + * When min and max attributes are set, calendar should show some dates as + * out-of-range. + */ +add_task(async function test_datepicker_min_max() { + const inputValue = "2016-12-15"; + const inputMin = "2016-12-05"; + const inputMax = "2016-12-25"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}" min="${inputMin}" max="${inputMax}">` + ); + + Assert.deepEqual( + getCalendarClassList(), + mergeArrays(calendarClasslist_201612, [ + // R denotes out-of-range + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + ]), + "2016-12 with min & max" + ); + + Assert.ok( + helper + .getElement(DAYS_VIEW) + .firstChild.firstChild.getAttribute("aria-disabled"), + "An out-of-range date is programmatically disabled" + ); + + Assert.ok( + !helper.getElement(DAY_SELECTED).hasAttribute("aria-disabled"), + "An in-range date is not programmatically disabled" + ); + + await helper.tearDown(); +}); + +add_task(async function test_datepicker_abs_min() { + const inputValue = "0001-01-01"; + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + Assert.deepEqual( + getCalendarText(), + [ + "", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + ], + "0001-01" + ); + + await helper.tearDown(); +}); + +add_task(async function test_datepicker_abs_max() { + const inputValue = "275760-09-13"; + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + Assert.deepEqual( + getCalendarText(), + [ + "31", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ], + "275760-09" + ); + + await helper.tearDown(); +}); + +// Bug 1726546 +add_task(async function test_datetime_local_min() { + const inputValue = "2016-12-15T04:00"; + const inputMin = "2016-12-05T12:22"; + const inputMax = "2016-12-25T12:22"; + + await helper.openPicker( + `data:text/html,<input type="datetime-local" value="${inputValue}" min="${inputMin}" max="${inputMax}">` + ); + + Assert.deepEqual( + getCalendarClassList(), + mergeArrays(calendarClasslist_201612, [ + // R denotes out-of-range + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + [R], + ]), + "2016-12 with min & max" + ); + + await helper.tearDown(); +}); + +// Bug 1726546 +add_task(async function test_datetime_local_min_select_invalid() { + const inputValue = "2016-12-15T05:00"; + const inputMin = "2016-12-05T12:22"; + const inputMax = "2016-12-25T12:22"; + + await helper.openPicker( + `data:text/html,<input type="datetime-local" value="${inputValue}" min="${inputMin}" max="${inputMax}">` + ); + + let changePromise = helper.promiseChange(); + + // Select the minimum day (the 5th, which is the 2nd child of 2nd row). + // The date becomes invalid (we select 2016-12-05T05:00). + helper.click(helper.getElement(DAYS_VIEW).children[1].children[1]); + + await changePromise; + + let [value, invalid] = await SpecialPowers.spawn( + helper.tab.linkedBrowser, + [], + async () => { + let input = content.document.querySelector("input"); + return [input.value, input.matches(":invalid")]; + } + ); + + Assert.equal(value, "2016-12-05T05:00", "Value should've changed"); + Assert.ok(invalid, "input should be now invalid"); + + await helper.tearDown(); +}); + +/** + * Test that date picker opens to the minium valid date when the value property is lower than the min property + */ +add_task(async function test_datepicker_value_lower_than_min() { + const date = new Date(); + const inputValue = "2001-02-03"; + const minValue = "2004-05-06"; + const maxValue = "2007-08-09"; + + await helper.openPicker( + `data:text/html, <input type='date' value="${inputValue}" min="${minValue}" max="${maxValue}">` + ); + + if (date.getMonth() === new Date().getMonth()) { + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(minValue)) + ); + } else { + Assert.ok( + true, + "Skipping datepicker value lower than min test if month changes when opening picker." + ); + } + + await helper.tearDown(); +}); + +/** + * Test that date picker opens to the maximum valid date when the value property is higher than the max property + */ +add_task(async function test_datepicker_value_higher_than_max() { + const date = new Date(); + const minValue = "2001-02-03"; + const maxValue = "2004-05-06"; + const inputValue = "2007-08-09"; + + await helper.openPicker( + `data:text/html, <input type='date' value="${inputValue}" min="${minValue}" max="${maxValue}">` + ); + + if (date.getMonth() === new Date().getMonth()) { + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(maxValue)) + ); + } else { + Assert.ok( + true, + "Skipping datepicker value higher than max test if month changes when opening picker." + ); + } + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_monthyear.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_monthyear.js new file mode 100644 index 0000000000..e722f883d5 --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_monthyear.js @@ -0,0 +1,209 @@ +/* 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"; + +/** + * Ensure the month-year panel of a date input handles Space and Enter appropriately. + */ +add_task(async function test_monthyear_close_date() { + info( + "Ensure the month-year panel of a date input handles Space and Enter appropriately." + ); + + const inputValue = "2022-11-11"; + + await helper.openPicker( + `data:text/html, <input type="date" value=${inputValue}>` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + + // Move focus from the selected date to the month-year toggle button: + await EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + + // Test a month spinner + await testKeyOnSpinners("KEY_Enter", pickerDoc); + await testKeyOnSpinners(" ", pickerDoc); + + // Test a year spinner + await testKeyOnSpinners("KEY_Enter", pickerDoc, 2); + await testKeyOnSpinners(" ", pickerDoc, 2); + + await helper.tearDown(); +}); + +/** + * Ensure the month-year panel of a datetime-local input handles Space and Enter appropriately. + */ +add_task(async function test_monthyear_close_datetime() { + info( + "Ensure the month-year panel of a datetime-local input handles Space and Enter appropriately." + ); + + const inputValue = "2022-11-11T11:11"; + + await helper.openPicker( + `data:text/html, <input type="datetime-local" value=${inputValue}>` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + + // Move focus from the selected date to the month-year toggle button: + await EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + + // Test a month spinner + await testKeyOnSpinners("KEY_Enter", pickerDoc); + await testKeyOnSpinners(" ", pickerDoc); + + // Test a year spinner + await testKeyOnSpinners("KEY_Enter", pickerDoc, 2); + await testKeyOnSpinners(" ", pickerDoc, 2); + + await helper.tearDown(); +}); + +/** + * Ensure the month-year panel of a date input can be closed with Escape key. + */ +add_task(async function test_monthyear_escape_date() { + info("Ensure the month-year panel of a date input can be closed with Esc."); + + const inputValue = "2022-12-12"; + + await helper.openPicker( + `data:text/html, <input type="date" value=${inputValue}>` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + + // Move focus from the today's date to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + + // Test a month spinner + await testKeyOnSpinners("KEY_Escape", pickerDoc); + + // Test a year spinner + await testKeyOnSpinners("KEY_Escape", pickerDoc, 2); + + info( + `Testing "KEY_Escape" behavior without any interaction with spinners + (bug 1815184)` + ); + + Assert.ok( + helper.getElement(BTN_MONTH_YEAR).matches(":focus"), + "The month-year toggle button is focused" + ); + + // Open the month-year selection panel with spinners: + EventUtils.synthesizeKey(" ", {}); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "true", + "Month-year button is expanded when the spinners are shown" + ); + Assert.ok( + BrowserTestUtils.isVisible(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection panel is visible" + ); + + // Close the month-year selection panel without interacting with its spinners: + EventUtils.synthesizeKey("KEY_Escape", {}); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "false", + "Month-year button is collapsed when the spinners are hidden" + ); + Assert.ok( + BrowserTestUtils.isHidden(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection panel is not visible" + ); + Assert.ok( + helper + .getElement(DAYS_VIEW) + .querySelector('[tabindex="0"]') + .matches(":focus"), + "A focusable day within a calendar grid is focused" + ); + + await helper.tearDown(); +}); + +/** + * Ensure the month-year panel of a datetime-local input can be closed with Escape key. + */ +add_task(async function test_monthyear_escape_datetime() { + info( + "Ensure the month-year panel of a datetime-local input can be closed with Esc." + ); + + const inputValue = "2022-12-12T01:01"; + + await helper.openPicker( + `data:text/html, <input type="datetime-local" value=${inputValue}>` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + + // Move focus from the today's date to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + + // Test a month spinner + await testKeyOnSpinners("KEY_Escape", pickerDoc); + + // Test a year spinner + await testKeyOnSpinners("KEY_Escape", pickerDoc, 2); + + info( + `Testing "KEY_Escape" behavior without any interaction with spinners + (bug 1815184)` + ); + + Assert.ok( + helper.getElement(BTN_MONTH_YEAR).matches(":focus"), + "The month-year toggle button is focused" + ); + + // Open the month-year selection panel with spinners: + EventUtils.synthesizeKey(" ", {}); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "true", + "Month-year button is expanded when the spinners are shown" + ); + Assert.ok( + BrowserTestUtils.isVisible(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection panel is visible" + ); + + // Close the month-year selection panel without interacting with its spinners: + EventUtils.synthesizeKey("KEY_Escape", {}); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "false", + "Month-year button is collapsed when the spinners are hidden" + ); + Assert.ok( + BrowserTestUtils.isHidden(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection panel is not visible" + ); + Assert.ok( + helper + .getElement(DAYS_VIEW) + .querySelector('[tabindex="0"]') + .matches(":focus"), + "A focusable day within a calendar grid is focused" + ); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_mousenav.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_mousenav.js new file mode 100644 index 0000000000..d38992df1b --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_mousenav.js @@ -0,0 +1,201 @@ +/* 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"; + +/** + * When the previous month button is clicked, calendar should display the dates + * for the previous month. + */ +add_task(async function test_datepicker_prev_month_btn() { + const inputValue = "2016-12-15"; + const prevMonth = "2016-11-01"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + helper.click(helper.getElement(BTN_PREV_MONTH)); + + // 2016-11-15: + const focusableDay = getDayEl(15); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)) + ); + Assert.deepEqual( + getCalendarText(), + [ + "30", + "31", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + ], + "2016-11" + ); + Assert.equal( + focusableDay.textContent, + "15", + "The same day of the month is present within a calendar grid" + ); + Assert.equal( + focusableDay, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "The same day of the month is focusable within a calendar grid" + ); + + await helper.tearDown(); +}); + +/** + * When the next month button is clicked, calendar should display the dates for + * the next month. + */ +add_task(async function test_datepicker_next_month_btn() { + const inputValue = "2016-12-15"; + const nextMonth = "2017-01-01"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + helper.click(helper.getElement(BTN_NEXT_MONTH)); + + // 2017-01-15: + const focusableDay = getDayEl(15); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextMonth)) + ); + Assert.deepEqual( + getCalendarText(), + [ + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "1", + "2", + "3", + "4", + ], + "2017-01" + ); + Assert.equal( + focusableDay.textContent, + "15", + "The same day of the month is present within a calendar grid" + ); + Assert.equal( + focusableDay, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "The same day of the month is focusable within a calendar grid" + ); + + await helper.tearDown(); +}); + +/** + * When a date on the calendar is clicked, date picker should close and set + * value to the input box. + */ +add_task(async function test_datepicker_clicked() { + info("When a calendar day is clicked, the picker closes, the value is set"); + const inputValue = "2016-12-15"; + const firstDayOnCalendar = "2016-11-27"; + + await helper.openPicker( + `data:text/html, <input id="date" type="date" value="${inputValue}">` + ); + + let browser = helper.tab.linkedBrowser; + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + + // Click the first item (top-left corner) of the calendar + let promise = BrowserTestUtils.waitForContentEvent(browser, "input"); + helper.click(helper.getElement(DAYS_VIEW).querySelector("td")); + await promise; + + let value = await SpecialPowers.spawn(browser, [], () => { + return content.document.querySelector("input").value; + }); + + Assert.equal(value, firstDayOnCalendar); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_prev_next_month.js b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_prev_next_month.js new file mode 100644 index 0000000000..1734e6fdc0 --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_datepicker_prev_next_month.js @@ -0,0 +1,534 @@ +/* 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"; + +/** + * When the Previous Month button is pressed, calendar should display + * the dates for the previous month. + */ +add_task(async function test_datepicker_prev_month_btn() { + const inputValue = "2016-12-15"; + const prevMonth = "2016-11-01"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + // Move focus from the selected date to the Previous Month button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + EventUtils.synthesizeKey(" ", {}); + + // 2016-11-15: + const focusableDay = getDayEl(15); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)) + ); + Assert.deepEqual( + getCalendarText(), + [ + "30", + "31", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + ], + "The calendar is updated to show the previous month (2016-11)" + ); + Assert.ok( + helper.getElement(BTN_PREV_MONTH).matches(":focus"), + "Focus stays on a Previous Month button after it's pressed" + ); + Assert.equal( + focusableDay.textContent, + "15", + "The same day of the month is present within a calendar grid" + ); + Assert.equal( + focusableDay, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "The same day of the month is focusable within a calendar grid" + ); + + // Move focus from the Previous Month button to the same day of the month (2016-11-15): + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + + Assert.ok( + focusableDay.matches(":focus"), + "The same day of the previous month can be focused with a keyboard" + ); + + await helper.tearDown(); +}); + +/** + * When the Next Month button is clicked, calendar should display the dates for + * the next month. + */ +add_task(async function test_datepicker_next_month_btn() { + const inputValue = "2016-12-15"; + const nextMonth = "2017-01-01"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + // Move focus from the selected date to the Next Month button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 4 }); + EventUtils.synthesizeKey(" ", {}); + + // 2017-01-15: + const focusableDay = getDayEl(15); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextMonth)) + ); + Assert.deepEqual( + getCalendarText(), + [ + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "1", + "2", + "3", + "4", + ], + "The calendar is updated to show the next month (2017-01)." + ); + Assert.ok( + helper.getElement(BTN_NEXT_MONTH).matches(":focus"), + "Focus stays on a Next Month button after it's pressed" + ); + Assert.equal( + focusableDay.textContent, + "15", + "The same day of the month is present within a calendar grid" + ); + Assert.equal( + focusableDay, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "The same day of the month is focusable within a calendar grid" + ); + + // Move focus from the Next Month button to the same day of the month (2017-01-15): + EventUtils.synthesizeKey("KEY_Tab", {}); + + Assert.ok( + focusableDay.matches(":focus"), + "The same day of the next month can be focused with a keyboard" + ); + + await helper.tearDown(); +}); + +/** + * When the Previous Month button is pressed, calendar should display + * the dates for the previous month on RTL build (bug 1806823). + */ +add_task(async function test_datepicker_prev_month_btn_rtl() { + const inputValue = "2016-12-15"; + const prevMonth = "2016-11-01"; + + await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] }); + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + // Move focus from the selected date to the Previous Month button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + EventUtils.synthesizeKey(" ", {}); + + // 2016-11-15: + const focusableDay = getDayEl(15); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(prevMonth)), + "The calendar is updated to show the previous month (2016-11)" + ); + Assert.ok( + helper.getElement(BTN_PREV_MONTH).matches(":focus"), + "Focus stays on a Previous Month button after it's pressed" + ); + Assert.equal( + focusableDay.textContent, + "15", + "The same day of the month is present within a calendar grid" + ); + Assert.equal( + focusableDay, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "The same day of the month is focusable within a calendar grid" + ); + + // Move focus from the Previous Month button to the same day of the month (2016-11-15): + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + + Assert.ok( + focusableDay.matches(":focus"), + "The same day of the previous month can be focused with a keyboard" + ); + + await helper.tearDown(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * When the Next Month button is clicked, calendar should display the dates for + * the next month on RTL build (bug 1806823). + */ +add_task(async function test_datepicker_next_month_btn_rtl() { + const inputValue = "2016-12-15"; + const nextMonth = "2017-01-01"; + + await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] }); + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + + // Move focus from the selected date to the Next Month button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 4 }); + EventUtils.synthesizeKey(" ", {}); + + // 2017-01-15: + const focusableDay = getDayEl(15); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextMonth)), + "The calendar is updated to show the next month (2017-01)." + ); + Assert.ok( + helper.getElement(BTN_NEXT_MONTH).matches(":focus"), + "Focus stays on a Next Month button after it's pressed" + ); + Assert.equal( + focusableDay.textContent, + "15", + "The same day of the month is present within a calendar grid" + ); + Assert.equal( + focusableDay, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "The same day of the month is focusable within a calendar grid" + ); + + // Move focus from the Next Month button to the same day of the month (2017-01-15): + EventUtils.synthesizeKey("KEY_Tab", {}); + + Assert.ok( + focusableDay.matches(":focus"), + "The same day of the next month can be focused with a keyboard" + ); + + await helper.tearDown(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * When Previous/Next Month buttons or arrow keys are used to change a month view + * when a time value is incomplete for datetime-local inputs, + * calendar should update the month (bug 1817785). + */ +add_task(async function test_datepicker_reopened_prev_next_month_btn() { + info("Setup a datetime-local datepicker to its reopened state for testing"); + + let inputValueDT = "2023-05-02T01:01"; + let prevMonth = new Date("2023-04-02"); + + await helper.openPicker( + `data:text/html, <input type="datetime-local" value="${inputValueDT}">` + ); + + let closed = helper.promisePickerClosed(); + EventUtils.synthesizeKey("KEY_Escape", {}); + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Date picker panel should be closed" + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + const editFields = shadowRoot.querySelectorAll(".datetime-edit-field"); + const amPm = editFields[5]; + amPm.focus(); + + Assert.ok( + amPm.matches(":focus"), + "Period of the day within the input is focused" + ); + }); + + // Use Backspace key to clear the value of the AM/PM section of the input + // and wait for input.value to change to null (bug 1833988): + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const input = content.document.querySelector("input"); + + const EventUtils = ContentTaskUtils.getEventUtils(content); + EventUtils.synthesizeKey("KEY_Backspace", {}, content); + + await ContentTaskUtils.waitForMutationCondition( + input, + { attributeFilter: ["value"] }, + () => input.value == "" + ); + + Assert.ok( + !input.value, + `Expected an input value to be changed to 'null' when a time value became incomplete, instead got ${input.value}` + ); + }); + + let ready = helper.waitForPickerReady(); + + // Move focus to a day section of the input and open a picker: + EventUtils.synthesizeKey("KEY_Tab", {}); + EventUtils.synthesizeKey(" ", {}); + + await ready; + + Assert.equal( + helper.panel.querySelector("#dateTimePopupFrame").contentDocument + .activeElement.textContent, + "2", + "Picker is opened with a focus set to the currently selected date" + ); + + info("Test the Previous Month button behavior"); + + // Move focus from the selected date to the Previous Month button, + // and activate it to move calendar from 2023-05-02 to 2023-04-02: + EventUtils.synthesizeKey("KEY_Tab", { + repeat: 2, + }); + EventUtils.synthesizeKey(" ", {}); + + // Same date of the previous month should be visible and focusable + // (2023-04-02) but the focus should remain on the Previous Month button: + const focusableDayPrevMonth = getDayEl(2); + const monthYearEl = helper.getElement(MONTH_YEAR); + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { + childList: true, + }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(prevMonth)); + }, + `Should change to the previous month (April 2023), instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + Assert.ok( + true, + `The date on both the Calendar and Month-Year button was updated + when Previous Month button was used` + ); + Assert.ok( + helper.getElement(BTN_PREV_MONTH).matches(":focus"), + "Focus stays on a Previous Month button after it's pressed" + ); + Assert.equal( + focusableDayPrevMonth, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "The same day of the month is focusable within a calendar grid" + ); + Assert.equal( + focusableDayPrevMonth.textContent, + "2", + "The same day of the month is present within a calendar grid" + ); + + // Move focus from the Previous Month button to the same day of the month (2023-04-02): + EventUtils.synthesizeKey("KEY_Tab", { + repeat: 3, + }); + + Assert.ok( + focusableDayPrevMonth.matches(":focus"), + "The same day of the previous month can be focused with a keyboard" + ); + + info("Test the Next Month button behavior"); + + // Move focus from the focused date to the Next Month button and activate it, + // (from 2023-04-02 to 2023-05-02): + EventUtils.synthesizeKey("KEY_Tab", { + repeat: 4, + }); + EventUtils.synthesizeKey(" ", {}); + + // Same date of the next month should be visible and focusable + // (2023-05-02) but the focus should remain on the Next Month button: + const focusableDayNextMonth = getDayEl(2); + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { + childList: true, + }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(inputValueDT)); + }, + `Should change to May 2023, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + Assert.ok( + true, + `The date on both the Calendar and Month-Year button was updated + when Next Month button was used` + ); + Assert.ok( + helper.getElement(BTN_NEXT_MONTH).matches(":focus"), + "Focus stays on a Next Month button after it's pressed" + ); + Assert.equal( + focusableDayNextMonth, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "The same day of the month is focusable within a calendar grid" + ); + Assert.equal( + focusableDayNextMonth.textContent, + "2", + "The same day of the month is present within a calendar grid" + ); + + // Move focus from the Next Month button to the focusable day of the month (2023-05-02): + EventUtils.synthesizeKey("KEY_Tab", {}); + + Assert.ok( + focusableDayNextMonth.matches(":focus"), + "The same day of the month can be focused with a keyboard" + ); + + info("Test the arrow navigation behavior"); + + // Move focus from the focused date to the same weekday of the previous month, + // (From 2023-05-02 to 2023-04-25): + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { + childList: true, + }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(prevMonth)); + }, + `Should change to the previous month, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + Assert.ok( + true, + `The date on both the Calendar and Month-Year button was updated + when an Up Arrow key was used` + ); + + // Move focus from the focused date to the same weekday of the next month, + // (from 2023-04-25 to 2023-05-02): + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { + childList: true, + }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(inputValueDT)); + }, + `Should change to the previous month, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + Assert.ok( + true, + `The date on both the Calendar and Month-Year button was updated + when a Down Arrow key was used` + ); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_showPicker.js b/toolkit/content/tests/browser/datetime/browser_datetime_showPicker.js new file mode 100644 index 0000000000..817c8958cd --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_showPicker.js @@ -0,0 +1,52 @@ +/* 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"; + +/** + * Test that date picker opens with showPicker. + */ +add_task(async function test_datepicker_showPicker() { + const date = new Date(); + + await helper.openPicker( + "data:text/html, <input type='date'>", + false, + "showPicker" + ); + + if (date.getMonth() === new Date().getMonth()) { + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT_LOCAL(date), + "Date picker opens when a showPicker method is called" + ); + } else { + Assert.ok( + true, + "Skipping datepicker today test if month changes when opening picker." + ); + } + + await helper.tearDown(); +}); + +/** + * Test that date picker opens with showPicker and the explicit value. + */ +add_task(async function test_datepicker_showPicker_value() { + await helper.openPicker( + "data:text/html, <input type='date' value='2012-10-15'>", + false, + "showPicker" + ); + + Assert.equal( + helper.getElement(MONTH_YEAR).textContent, + DATE_FORMAT_LOCAL(new Date("2012-10-12")), + "Date picker opens when a showPicker method is called" + ); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_toplevel.js b/toolkit/content/tests/browser/datetime/browser_datetime_toplevel.js new file mode 100644 index 0000000000..2e97e4d2da --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_datetime_toplevel.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + let input = document.createElement("input"); + input.type = "date"; + registerCleanupFunction(() => input.remove()); + document.body.appendChild(input); + + let shown = BrowserTestUtils.waitForDateTimePickerPanelShown(window); + + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + + EventUtils.synthesizeMouseAtCenter( + shadowRoot.getElementById("calendar-button"), + {} + ); + + let popup = await shown; + ok(!!popup, "Should've shown the popup"); + + let hidden = BrowserTestUtils.waitForPopupEvent(popup, "hidden"); + popup.hidePopup(); + + await hidden; + popup.remove(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_spinner.js b/toolkit/content/tests/browser/datetime/browser_spinner.js new file mode 100644 index 0000000000..81ccef39ea --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_spinner.js @@ -0,0 +1,180 @@ +/* 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"; + +/** + * Test that the Month spinner opens with an accessible markup + */ +add_task(async function test_spinner_month_markup() { + info("Test that the Month spinner opens with an accessible markup"); + + const inputValue = "2022-09-09"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + helper.click(helper.getElement(MONTH_YEAR)); + + const spinnerMonth = helper.getElement(SPINNER_MONTH); + const spinnerMonthPrev = spinnerMonth.children[0]; + const spinnerMonthBtn = spinnerMonth.children[1]; + const spinnerMonthNext = spinnerMonth.children[2]; + + Assert.equal( + spinnerMonthPrev.tagName, + "button", + "Spinner's Previous Month control is a button" + ); + Assert.equal( + spinnerMonthBtn.getAttribute("role"), + "spinbutton", + "Spinner control is a spinbutton" + ); + Assert.equal( + spinnerMonthBtn.getAttribute("tabindex"), + "0", + "Spinner control is included in the focus order" + ); + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuemin"), + "0", + "Spinner control has a min value set" + ); + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuemax"), + "11", + "Spinner control has a max value set" + ); + // September 2022 as an example + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "8", + "Spinner control has a current value set" + ); + Assert.equal( + spinnerMonthNext.tagName, + "button", + "Spinner's Next Month control is a button" + ); + + testAttribute(spinnerMonthBtn, "aria-valuetext"); + + let visibleEls = spinnerMonthBtn.querySelectorAll( + ":scope > :not([aria-hidden])" + ); + Assert.equal( + visibleEls.length, + 0, + "There should be no children of the spinner without aria-hidden" + ); + + info("Test that the month spinner has localizable labels"); + + testAttributeL10n( + spinnerMonthPrev, + "aria-label", + "date-spinner-month-previous" + ); + testAttributeL10n(spinnerMonthBtn, "aria-label", "date-spinner-month"); + testAttributeL10n(spinnerMonthNext, "aria-label", "date-spinner-month-next"); + + await testReducedMotionProp( + spinnerMonthBtn, + "scroll-behavior", + "smooth", + "auto" + ); + + await helper.tearDown(); +}); + +/** + * Test that the Year spinner opens with an accessible markup + */ +add_task(async function test_spinner_year_markup() { + info("Test that the year spinner opens with an accessible markup"); + + const inputValue = "2022-06-06"; + const inputMin = "2020-06-01"; + const inputMax = "2030-12-31"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}" min="${inputMin}" max="${inputMax}">` + ); + helper.click(helper.getElement(MONTH_YEAR)); + + const spinnerYear = helper.getElement(SPINNER_YEAR); + const spinnerYearPrev = spinnerYear.children[0]; + const spinnerYearBtn = spinnerYear.children[1]; + const spinnerYearNext = spinnerYear.children[2]; + + Assert.equal( + spinnerYearPrev.tagName, + "button", + "Spinner's Previous Year control is a button" + ); + Assert.equal( + spinnerYearBtn.getAttribute("role"), + "spinbutton", + "Spinner control is a spinbutton" + ); + Assert.equal( + spinnerYearBtn.getAttribute("tabindex"), + "0", + "Spinner control is included in the focus order" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuemin"), + "2020", + "Spinner control has a min value set, when the range is provided" + ); + // 2020-2030 range is an example + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuemax"), + "2030", + "Spinner control has a max value set, when the range is provided" + ); + // June 2022 is an example + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Spinner control has a current value set" + ); + Assert.equal( + spinnerYearNext.tagName, + "button", + "Spinner's Next Year control is a button" + ); + + testAttribute(spinnerYearBtn, "aria-valuetext"); + + let visibleEls = spinnerYearBtn.querySelectorAll( + ":scope > :not([aria-hidden])" + ); + Assert.equal( + visibleEls.length, + 0, + "There should be no children of the spinner without aria-hidden" + ); + + info("Test that the year spinner has localizable labels"); + + testAttributeL10n( + spinnerYearPrev, + "aria-label", + "date-spinner-year-previous" + ); + testAttributeL10n(spinnerYearBtn, "aria-label", "date-spinner-year"); + testAttributeL10n(spinnerYearNext, "aria-label", "date-spinner-year-next"); + + await testReducedMotionProp( + spinnerYearBtn, + "scroll-behavior", + "smooth", + "auto" + ); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_spinner_keynav.js b/toolkit/content/tests/browser/datetime/browser_spinner_keynav.js new file mode 100644 index 0000000000..ece96ce1cf --- /dev/null +++ b/toolkit/content/tests/browser/datetime/browser_spinner_keynav.js @@ -0,0 +1,622 @@ +/* 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"; + +add_setup(async function setPrefsReducedMotion() { + // Set "prefers-reduced-motion" media to "reduce" + // to avoid intermittent scroll failures (1803612, 1803687) + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 1]], + }); + Assert.ok( + matchMedia("(prefers-reduced-motion: reduce)").matches, + "The reduce motion mode is active" + ); +}); + +/** + * Ensure the month spinner follows arrow key bindings appropriately. + */ +add_task(async function test_spinner_month_keyboard_arrows() { + info("Ensure the month spinner follows arrow key bindings appropriately."); + + const inputValue = "2022-12-10"; + const nextMonthValue = "2022-01-01"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + + info("Testing general keyboard navigation"); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "false", + "Month-year button is collapsed when a picker is opened (by default)" + ); + + // Move focus from the selection to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + // Open month-year selection panel with spinners: + EventUtils.synthesizeKey(" ", {}); + + const spinnerMonthBtn = helper.getElement(SPINNER_MONTH).children[1]; + const spinnerYearBtn = helper.getElement(SPINNER_YEAR).children[1]; + + let monthYearEl = helper.getElement(MONTH_YEAR); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "true", + "Month-year button is expanded when the spinners are shown" + ); + // December 2022 is an example: + Assert.equal( + pickerDoc.activeElement.textContent, + DATE_FORMAT(new Date(inputValue)), + "Month-year toggle button is focused" + ); + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "Month Spinner control is ready" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Year Spinner control is ready" + ); + + // Move focus from the month-year toggle button to the month spinner: + EventUtils.synthesizeKey("KEY_Tab", {}); + + Assert.equal( + pickerDoc.activeElement.getAttribute("aria-valuenow"), + "11", + "Tab moves focus to the month spinner" + ); + + info("Testing Up/Down Arrow keys behavior of the Month Spinner"); + + // Change the month-year from December 2022 to January 2022: + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(nextMonthValue)); + }, + `Should change to January 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "0", + "Down Arrow selects the next month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Down Arrow on a month spinner does not update the year" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextMonthValue)), + "Down Arrow updates the month-year button to the next month" + ); + + // Change the month-year from January 2022 to December 2022: + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(inputValue)); + }, + `Should change to December 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "Up Arrow selects the previous month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Up Arrow on a month spinner does not update the year" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)), + "Up Arrow updates the month-year button to the previous month" + ); + + await helper.tearDown(); +}); + +/** + * Ensure the month spinner follows Page Up/Down key bindings appropriately. + */ +add_task(async function test_spinner_month_keyboard_pageup_pagedown() { + info( + "Ensure the month spinner follows Page Up/Down key bindings appropriately." + ); + + const inputValue = "2022-12-10"; + const nextFifthMonthValue = "2022-05-10"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + // const browser = helper.tab.linkedBrowser; + // Move focus from the selection to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + // Open month-year selection panel with spinners: + EventUtils.synthesizeKey(" ", {}); + + const spinnerMonthBtn = helper.getElement(SPINNER_MONTH).children[1]; + const spinnerYearBtn = helper.getElement(SPINNER_YEAR).children[1]; + + let monthYearEl = helper.getElement(MONTH_YEAR); + + // Move focus from the month-year toggle button to the month spinner: + EventUtils.synthesizeKey("KEY_Tab", {}); + + // Change the month-year from December 2022 to May 2022: + EventUtils.synthesizeKey("KEY_PageDown", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return ( + monthYearEl.textContent == DATE_FORMAT(new Date(nextFifthMonthValue)) + ); + }, + `Should change to May 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "4", + "Page Down selects the fifth later month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Page Down on a month spinner does not update the year" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextFifthMonthValue)), + "Page Down updates the month-year button to the fifth later month" + ); + + // Change the month-year from May 2022 to December 2022: + EventUtils.synthesizeKey("KEY_PageUp", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(inputValue)); + }, + `Should change to December 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "Page Up selects the fifth earlier month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Page Up on a month spinner does not update the year" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)), + "Page Up updates the month-year button to the fifth earlier month" + ); + + await helper.tearDown(); +}); + +/** + * Ensure the month spinner follows Home/End key bindings appropriately. + */ +add_task(async function test_spinner_month_keyboard_home_end() { + info("Ensure the month spinner follows Home/End key bindings appropriately."); + + const inputValue = "2022-12-11"; + const firstMonthValue = "2022-01-11"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + // const browser = helper.tab.linkedBrowser; + // Move focus from the selection to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + // Open month-year selection panel with spinners: + EventUtils.synthesizeKey(" ", {}); + + const spinnerMonthBtn = helper.getElement(SPINNER_MONTH).children[1]; + const spinnerYearBtn = helper.getElement(SPINNER_YEAR).children[1]; + + let monthYearEl = helper.getElement(MONTH_YEAR); + + // Move focus from the month-year toggle button to the month spinner: + EventUtils.synthesizeKey("KEY_Tab", {}); + + // Change the month-year from December 2022 to January 2022: + EventUtils.synthesizeKey("KEY_Home", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(firstMonthValue)); + }, + `Should change to January 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "0", + "Home key selects the first month of the year (min value)" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Home key does not update the year value" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(firstMonthValue)), + "Home key updates the month-year button to the first month of the same year (min value)" + ); + + // Change the month-year from January 2022 to December 2022: + EventUtils.synthesizeKey("KEY_End", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(inputValue)); + }, + `Should change to December 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "End key selects the last month of the year (max value)" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "End key does not update the year value" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)), + "End key updates the month-year button to the last month of the same year (max value)" + ); + + await helper.tearDown(); +}); + +/** + * Ensure the year spinner follows arrow key bindings appropriately. + */ +add_task(async function test_spinner_year_keyboard_arrows() { + info("Ensure the year spinner follows arrow key bindings appropriately."); + + const inputValue = "2022-12-10"; + const nextYearValue = "2023-12-01"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + let pickerDoc = helper.panel.querySelector( + "#dateTimePopupFrame" + ).contentDocument; + + info("Testing general keyboard navigation"); + + // Move focus from the selection to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + // Open month-year selection panel with spinners: + EventUtils.synthesizeKey(" ", {}); + + const spinnerMonthBtn = helper.getElement(SPINNER_MONTH).children[1]; + const spinnerYearBtn = helper.getElement(SPINNER_YEAR).children[1]; + + let monthYearEl = helper.getElement(MONTH_YEAR); + + // December 2022 is an example: + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Year Spinner control is ready" + ); + + // Move focus from the month-year toggle button to the year spinner: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + + Assert.equal( + pickerDoc.activeElement.getAttribute("aria-valuenow"), + "2022", + "Tab can move the focus to the year spinner" + ); + + info("Testing Up/Down Arrow keys behavior of the Year Spinner"); + + // Change the month-year from December 2022 to December 2023: + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(nextYearValue)); + }, + `Should change to December 2023, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "Down Arrow on the year spinner does not change the month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2023", + "Down Arrow updates the year to the next" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextYearValue)), + "Down Arrow updates the month-year button to the next year" + ); + + // Change the month-year from December 2023 to December 2022: + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(inputValue)); + }, + `Should change to December 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "Up Arrow on the year spinner does not change the month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Up Arrow updates the year to the previous" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)), + "Up Arrow updates the month-year button to the previous year" + ); + + await helper.tearDown(); +}); + +/** + * Ensure the year spinner follows Page Up/Down key bindings appropriately. + */ +add_task(async function test_spinner_year_keyboard_pageup_pagedown() { + info( + "Ensure the year spinner follows Page Up/Down key bindings appropriately." + ); + + const inputValue = "2022-12-10"; + const nextFifthYearValue = "2027-12-10"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}">` + ); + // const browser = helper.tab.linkedBrowser; + // Move focus from the selection to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + // Open month-year selection panel with spinners: + EventUtils.synthesizeKey(" ", {}); + + const spinnerMonthBtn = helper.getElement(SPINNER_MONTH).children[1]; + const spinnerYearBtn = helper.getElement(SPINNER_YEAR).children[1]; + + let monthYearEl = helper.getElement(MONTH_YEAR); + + // Move focus from the month-year toggle button to the year spinner: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + + // Change the month-year from December 2022 to December 2027: + EventUtils.synthesizeKey("KEY_PageDown", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return ( + monthYearEl.textContent == DATE_FORMAT(new Date(nextFifthYearValue)) + ); + }, + `Should change to December 2027, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "Page Down on the year spinner does not change the month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2027", + "Page Down selects the fifth later year" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(nextFifthYearValue)), + "Page Down updates the month-year button to the fifth later year" + ); + + // Change the month-year from December 2027 to December 2022: + EventUtils.synthesizeKey("KEY_PageUp", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(inputValue)); + }, + `Should change to December 2022, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "Page Up on the year spinner does not change the month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2022", + "Page Up selects the fifth earlier year" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(inputValue)), + "Page Up updates the month-year button to the fifth earlier year" + ); + + await helper.tearDown(); +}); + +/** + * Ensure the year spinner follows Home/End key bindings appropriately. + */ +add_task(async function test_spinner_year_keyboard_home_end() { + info("Ensure the year spinner follows Home/End key bindings appropriately."); + + const inputValue = "2022-12-10"; + const minValue = "2020-10-10"; + const maxValue = "2030-12-31"; + const minYearValue = "2020-12-10"; + const maxYearValue = "2030-12-10"; + + await helper.openPicker( + `data:text/html, <input type="date" value="${inputValue}" min="${minValue}" max="${maxValue}">` + ); + + // Move focus from the selection to the month-year toggle button: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); + // Open month-year selection panel with spinners: + EventUtils.synthesizeKey(" ", {}); + + const spinnerMonthBtn = helper.getElement(SPINNER_MONTH).children[1]; + const spinnerYearBtn = helper.getElement(SPINNER_YEAR).children[1]; + + let monthYearEl = helper.getElement(MONTH_YEAR); + + // Move focus from the month-year toggle button to the year spinner: + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + + // Change the month-year from December 2022 to December 2020: + EventUtils.synthesizeKey("KEY_Home", {}); + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(minYearValue)); + }, + `Should change to December 2020, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "Home key on the year spinner does not change the month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2020", + "Home key selects the min year value" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(minYearValue)), + "Home key updates the month-year button to the min year value" + ); + + // Change the month-year from December 2022 to December 2030: + EventUtils.synthesizeKey("KEY_End", {}); + + await BrowserTestUtils.waitForMutationCondition( + monthYearEl, + { childList: true }, + () => { + return monthYearEl.textContent == DATE_FORMAT(new Date(maxYearValue)); + }, + `Should change to December 2030, instead got ${ + helper.getElement(MONTH_YEAR).textContent + }` + ); + + Assert.equal( + spinnerMonthBtn.getAttribute("aria-valuenow"), + "11", + "End key on the year spinner does not change the month" + ); + Assert.equal( + spinnerYearBtn.getAttribute("aria-valuenow"), + "2030", + "End key selects the max year value" + ); + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).textContent, + DATE_FORMAT(new Date(maxYearValue)), + "End key updates the month-year button to the max year value" + ); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/head.js b/toolkit/content/tests/browser/datetime/head.js new file mode 100644 index 0000000000..bbef72873c --- /dev/null +++ b/toolkit/content/tests/browser/datetime/head.js @@ -0,0 +1,441 @@ +/* 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"; + +/** + * Helper class for testing datetime input picker widget + */ +class DateTimeTestHelper { + constructor() { + this.panel = null; + this.tab = null; + this.frame = null; + } + + /** + * Opens a new tab with the URL of the test page, and make sure the picker is + * ready for testing. + * + * @param {String} pageUrl + * @param {bool} inFrame true if input is in the first child frame + * @param {String} openMethod "click" or "showPicker" + */ + async openPicker(pageUrl, inFrame, openMethod = "click") { + this.tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl); + let bc = gBrowser.selectedBrowser; + if (inFrame) { + await SpecialPowers.spawn(bc, [], async function () { + const iframe = content.document.querySelector("iframe"); + // Ensure the iframe's position is correct before doing any + // other operations + iframe.getBoundingClientRect(); + }); + bc = bc.browsingContext.children[0]; + } + await SpecialPowers.spawn(bc, [], async function () { + // Ensure that screen coordinates are ok. + await SpecialPowers.contentTransformsReceived(content); + }); + + let shown = this.waitForPickerReady(); + + if (openMethod === "click") { + await SpecialPowers.spawn(bc, [], () => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + shadowRoot.getElementById("calendar-button").click(); + }); + } else if (openMethod === "showPicker") { + await SpecialPowers.spawn(bc, [], function () { + content.document.notifyUserGestureActivation(); + content.document.querySelector("input").showPicker(); + }); + } + this.panel = await shown; + this.frame = this.panel.querySelector("#dateTimePopupFrame"); + } + + promisePickerClosed() { + return new Promise(resolve => { + this.panel.addEventListener("popuphidden", resolve, { once: true }); + }); + } + + promiseChange(selector = "input") { + return SpecialPowers.spawn( + this.tab.linkedBrowser, + [selector], + async selector => { + let input = content.document.querySelector(selector); + await ContentTaskUtils.waitForEvent(input, "change", false, e => { + ok( + content.window.windowUtils.isHandlingUserInput, + "isHandlingUserInput should be true" + ); + return true; + }); + } + ); + } + + waitForPickerReady() { + return BrowserTestUtils.waitForDateTimePickerPanelShown(window); + } + + /** + * Find an element on the picker. + * + * @param {String} selector + * @return {DOMElement} + */ + getElement(selector) { + return this.frame.contentDocument.querySelector(selector); + } + + /** + * Find the children of an element on the picker. + * + * @param {String} selector + * @return {Array<DOMElement>} + */ + getChildren(selector) { + return Array.from(this.getElement(selector).children); + } + + /** + * Click on an element + * + * @param {DOMElement} element + */ + click(element) { + EventUtils.synthesizeMouseAtCenter(element, {}, this.frame.contentWindow); + } + + /** + * Close the panel and the tab + */ + async tearDown() { + if (this.panel.state != "closed") { + let pickerClosePromise = this.promisePickerClosed(); + this.panel.hidePopup(); + await pickerClosePromise; + } + BrowserTestUtils.removeTab(this.tab); + this.tab = null; + } + + /** + * Clean up after tests. Remove the frame to prevent leak. + */ + cleanup() { + this.frame?.remove(); + this.frame = null; + this.panel = null; + } +} + +let helper = new DateTimeTestHelper(); + +registerCleanupFunction(() => { + helper.cleanup(); +}); + +const BTN_MONTH_YEAR = "#month-year-label", + BTN_NEXT_MONTH = ".next", + BTN_PREV_MONTH = ".prev", + BTN_CLEAR = "#clear-button", + DAY_SELECTED = ".selection", + DAY_TODAY = ".today", + DAYS_VIEW = ".days-view", + DIALOG_PICKER = "#date-picker", + MONTH_YEAR = ".month-year", + MONTH_YEAR_NAV = ".month-year-nav", + MONTH_YEAR_VIEW = ".month-year-view", + SPINNER_MONTH = "#spinner-month", + SPINNER_YEAR = "#spinner-year", + WEEK_HEADER = ".week-header"; +const DATE_FORMAT = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + timeZone: "UTC", +}).format; +const DATE_FORMAT_LOCAL = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", +}).format; + +/** + * Helper function to find and return a gridcell element + * for a specific day of the month + * + * @return {Array[String]} TextContent of each gridcell within a calendar grid + */ +function getCalendarText() { + let calendarCells = []; + for (const tr of helper.getChildren(DAYS_VIEW)) { + for (const td of tr.children) { + calendarCells.push(td.textContent); + } + } + return calendarCells; +} + +function getCalendarClassList() { + let calendarCellsClasses = []; + for (const tr of helper.getChildren(DAYS_VIEW)) { + for (const td of tr.children) { + calendarCellsClasses.push(td.classList); + } + } + return calendarCellsClasses; +} + +/** + * Helper function to find and return a gridcell element + * for a specific day of the month + * + * @param {Number} day: A day of the month to find in the month grid + * + * @return {HTMLElement} A gridcell that represents the needed day of the month + */ +function getDayEl(dayNum) { + const dayEls = Array.from( + helper.getElement(DAYS_VIEW).querySelectorAll("td") + ); + return dayEls.find(el => el.textContent === dayNum.toString()); +} + +function mergeArrays(a, b) { + return a.map((classlist, index) => classlist.concat(b[index])); +} + +/** + * Helper function to check if a DOM element has a specific attribute + * + * @param {DOMElement} el: DOM Element to be tested + * @param {String} attr: The name of the attribute to be tested + */ +function testAttribute(el, attr) { + Assert.ok( + el.hasAttribute(attr), + `The "${el}" element has a "${attr}" attribute` + ); +} + +/** + * Helper function to check for l10n of an element's attribute + * + * @param {DOMElement} el: DOM Element to be tested + * @param {String} attr: The name of the attribute to be tested + * @param {String} id: Value of the "data-l10n-id" attribute of the element + * @param {Object} args: Args provided by the l10n object of the element + */ +function testAttributeL10n(el, attr, id, args = null) { + testAttribute(el, attr); + testLocalization(el, id, args); +} + +/** + * Helper function to check the value of a Calendar button's specific attribute + * + * @param {String} attr: The name of the attribute to be tested + * @param {String} val: Value that is expected to be assigned to the attribute. + * @param {Boolean} presenceOnly: If "true", test only the presence of the attribute + */ +async function testCalendarBtnAttribute(attr, val, presenceOnly = false) { + let browser = helper.tab.linkedBrowser; + + await SpecialPowers.spawn( + browser, + [attr, val, presenceOnly], + (attr, val, presenceOnly) => { + const input = content.document.querySelector("input"); + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + const calendarBtn = shadowRoot.getElementById("calendar-button"); + + if (presenceOnly) { + Assert.ok( + calendarBtn.hasAttribute(attr), + `Calendar button has ${attr} attribute` + ); + } else { + Assert.equal( + calendarBtn.getAttribute(attr), + val, + `Calendar button has ${attr} attribute set to ${val}` + ); + } + } + ); +} + +/** + * Helper function to test if a submission/dismissal keyboard shortcut works + * on a month or a year selection spinner + * + * @param {String} key: A keyboard Event.key that will be synthesized + * @param {Object} document: Reference to the content document + * of the #dateTimePopupFrame + * @param {Number} tabs: How many times "Tab" key should be pressed + * to move a keyboard focus to a needed spinner + * (1 for month/default and 2 for year) + * + * @description Starts with the month-year toggle button being focused + * on the date/datetime-local input's datepicker panel + */ +async function testKeyOnSpinners(key, document, tabs = 1) { + info(`Testing "${key}" key behavior`); + + Assert.equal( + document.activeElement, + helper.getElement(BTN_MONTH_YEAR), + "The month-year toggle button is focused" + ); + + // Open the month-year selection panel with spinners: + await EventUtils.synthesizeKey(" ", {}); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "true", + "Month-year button is expanded when the spinners are shown" + ); + Assert.ok( + BrowserTestUtils.isVisible(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection panel is visible" + ); + + // Move focus from the month-year toggle button to one of spinners: + await EventUtils.synthesizeKey("KEY_Tab", { repeat: tabs }); + + Assert.equal( + document.activeElement.getAttribute("role"), + "spinbutton", + "The spinner is focused" + ); + + // Confirm the spinbutton choice and close the month-year selection panel: + await EventUtils.synthesizeKey(key, {}); + + Assert.equal( + helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), + "false", + "Month-year button is collapsed when the spinners are hidden" + ); + Assert.ok( + BrowserTestUtils.isHidden(helper.getElement(MONTH_YEAR_VIEW)), + "Month-year selection panel is not visible" + ); + Assert.equal( + document.activeElement, + helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), + "A focusable day within a calendar grid is focused" + ); + + // Return the focus to the month-year toggle button for future tests + // (passing a Previous button along the way): + await EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); +} + +/** + * Helper function to check for localization attributes of a DOM element + * + * @param {DOMElement} el: DOM Element to be tested + * @param {String} id: Value of the "data-l10n-id" attribute of the element + * @param {Object} args: Args provided by the l10n object of the element + */ +function testLocalization(el, id, args = null) { + const l10nAttrs = document.l10n.getAttributes(el); + + Assert.deepEqual( + l10nAttrs, + { + id, + args, + }, + `The "${id}" element is localizable` + ); +} + +/** + * Helper function to check if a CSS property respects reduced motion mode + * + * @param {DOMElement} el: DOM Element to be tested + * @param {String} prop: The name of the CSS property to be tested + * @param {Object} valueNotReduced: Default value of the tested CSS property + * for "prefers-reduced-motion: no-preference" + * @param {String} valueReduced: Value of the tested CSS property + * for "prefers-reduced-motion: reduce" + */ +async function testReducedMotionProp(el, prop, valueNotReduced, valueReduced) { + info(`Test the panel's CSS ${prop} value depending on a reduced motion mode`); + + // Set "prefers-reduced-motion" media to "no-preference" + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 0]], + }); + + ok( + matchMedia("(prefers-reduced-motion: no-preference)").matches, + "The reduce motion mode is not active" + ); + is( + getComputedStyle(el).getPropertyValue(prop), + valueNotReduced, + `Default ${prop} will be provided, when a reduce motion mode is not active` + ); + + // Set "prefers-reduced-motion" media to "reduce" + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 1]], + }); + + ok( + matchMedia("(prefers-reduced-motion: reduce)").matches, + "The reduce motion mode is active" + ); + is( + getComputedStyle(el).getPropertyValue(prop), + valueReduced, + `Reduced ${prop} will be provided, when a reduce motion mode is active` + ); +} + +async function verifyPickerPosition(browsingContext, inputId) { + let inputRect = await SpecialPowers.spawn( + browsingContext, + [inputId], + async function (inputIdChild) { + let rect = content.document + .getElementById(inputIdChild) + .getBoundingClientRect(); + return { + left: content.mozInnerScreenX + rect.left, + bottom: content.mozInnerScreenY + rect.bottom, + }; + } + ); + + function is_close(got, exp, msg) { + // on some platforms we see differences of a fraction of a pixel - so + // allow any difference of < 1 pixels as being OK. + Assert.ok( + Math.abs(got - exp) < 1, + msg + ": " + got + " should be equal(-ish) to " + exp + ); + } + const marginLeft = parseFloat(getComputedStyle(helper.panel).marginLeft); + const marginTop = parseFloat(getComputedStyle(helper.panel).marginTop); + is_close( + helper.panel.screenX - marginLeft, + inputRect.left, + "datepicker x position" + ); + is_close( + helper.panel.screenY - marginTop, + inputRect.bottom, + "datepicker y position" + ); +} diff --git a/toolkit/content/tests/browser/doggy.png b/toolkit/content/tests/browser/doggy.png Binary files differnew file mode 100644 index 0000000000..73632d3229 --- /dev/null +++ b/toolkit/content/tests/browser/doggy.png diff --git a/toolkit/content/tests/browser/empty.png b/toolkit/content/tests/browser/empty.png Binary files differnew file mode 100644 index 0000000000..17ddf0c3ee --- /dev/null +++ b/toolkit/content/tests/browser/empty.png diff --git a/toolkit/content/tests/browser/file_contentTitle.html b/toolkit/content/tests/browser/file_contentTitle.html new file mode 100644 index 0000000000..8d330aa0f2 --- /dev/null +++ b/toolkit/content/tests/browser/file_contentTitle.html @@ -0,0 +1,14 @@ +<html> +<head><title>Test Page</title></head> +<body> +<script type="text/javascript"> +dump("Script!\n"); +addEventListener("load", () => { + // Trigger an onLocationChange event. We want to make sure the title is still correct afterwards. + location.hash = "#x2"; + var event = new Event("TestLocationChange"); + document.dispatchEvent(event); +}, false); +</script> +</body> +</html> diff --git a/toolkit/content/tests/browser/file_document_open_audio.html b/toolkit/content/tests/browser/file_document_open_audio.html new file mode 100644 index 0000000000..1234299c67 --- /dev/null +++ b/toolkit/content/tests/browser/file_document_open_audio.html @@ -0,0 +1,11 @@ +<!doctype html> +<title>Test for bug 1572798</title> +<script> + function openVideo() { + var w = window.open('', '', 'width = 640, height = 480, scrollbars=yes, menubar=no, toolbar=no, resizable=yes'); + w.document.open(); + w.document.write('<!DOCTYPE html><title>test popup</title><audio controls src="audio.ogg"></audio>'); + w.document.close(); + } +</script> +<button onclick="openVideo()">Open video</button> diff --git a/toolkit/content/tests/browser/file_empty.html b/toolkit/content/tests/browser/file_empty.html new file mode 100644 index 0000000000..d2b0361f09 --- /dev/null +++ b/toolkit/content/tests/browser/file_empty.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html> + <head> + <title>Page left intentionally blank...</title> + </head> + <body> + </body> +</html> diff --git a/toolkit/content/tests/browser/file_findinframe.html b/toolkit/content/tests/browser/file_findinframe.html new file mode 100644 index 0000000000..27a9d00a97 --- /dev/null +++ b/toolkit/content/tests/browser/file_findinframe.html @@ -0,0 +1,5 @@ +<html> + <body> + <iframe src="data:text/html,<body contenteditable>Test</body>"></iframe> + </body> +</html> diff --git a/toolkit/content/tests/browser/file_iframe_media.html b/toolkit/content/tests/browser/file_iframe_media.html new file mode 100644 index 0000000000..929fb84002 --- /dev/null +++ b/toolkit/content/tests/browser/file_iframe_media.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<video id="video" src="gizmo.mp4" loop></video> +<script type="text/javascript"> + +window.onmessage = async event => { + const video = document.getElementById("video"); + const w = window.opener || window.parent; + if (event.data == "play") { + await video.play(); + w.postMessage("played", "*"); + } +} + +</script> diff --git a/toolkit/content/tests/browser/file_mediaPlayback2.html b/toolkit/content/tests/browser/file_mediaPlayback2.html new file mode 100644 index 0000000000..890b494a05 --- /dev/null +++ b/toolkit/content/tests/browser/file_mediaPlayback2.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<body> +<script type="text/javascript"> +var audio = new Audio(); +audio.oncanplay = function() { + audio.oncanplay = null; + audio.play(); +}; +audio.src = "audio.ogg"; +audio.loop = true; +audio.id = "v"; +document.body.appendChild(audio); +</script> +</body> diff --git a/toolkit/content/tests/browser/file_multipleAudio.html b/toolkit/content/tests/browser/file_multipleAudio.html new file mode 100644 index 0000000000..5dc37febb4 --- /dev/null +++ b/toolkit/content/tests/browser/file_multipleAudio.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<head> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> +</head> +<body> +<audio id="autoplay" src="audio.ogg"></audio> +<audio id="nonautoplay" src="audio.ogg"></audio> +<script type="text/javascript"> + +// In linux debug on try server, sometimes the download process would fail, so +// we can't activate the "auto-play" or playing after receving "oncanplay". +// Therefore, we just call play here. +var audio = document.getElementById("autoplay"); +audio.loop = true; +audio.play(); + +</script> +</body> diff --git a/toolkit/content/tests/browser/file_multiplePlayingAudio.html b/toolkit/content/tests/browser/file_multiplePlayingAudio.html new file mode 100644 index 0000000000..ae122506fb --- /dev/null +++ b/toolkit/content/tests/browser/file_multiplePlayingAudio.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<head> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> +</head> +<body> +<audio id="audio1" src="audio.ogg" controls></audio> +<audio id="audio2" src="audio.ogg" controls></audio> +<script type="text/javascript"> + +// In linux debug on try server, sometimes the download process would fail, so +// we can't activate the "auto-play" or playing after receving "oncanplay". +// Therefore, we just call play here. +var audio1 = document.getElementById("audio1"); +audio1.loop = true; +audio1.play(); + +var audio2 = document.getElementById("audio2"); +audio2.loop = true; +audio2.play(); + +</script> +</body> diff --git a/toolkit/content/tests/browser/file_nonAutoplayAudio.html b/toolkit/content/tests/browser/file_nonAutoplayAudio.html new file mode 100644 index 0000000000..4d2641021a --- /dev/null +++ b/toolkit/content/tests/browser/file_nonAutoplayAudio.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<head> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> +</head> +<body> +<audio id="testAudio" src="audio.ogg" loop></audio> diff --git a/toolkit/content/tests/browser/file_outside_viewport_videos.html b/toolkit/content/tests/browser/file_outside_viewport_videos.html new file mode 100644 index 0000000000..84aa34358d --- /dev/null +++ b/toolkit/content/tests/browser/file_outside_viewport_videos.html @@ -0,0 +1,41 @@ +<html> +<head> + <title>outside viewport videos</title> +<style> +/** + * These CSS would move elements to the far left/right/top/bottom where user + * can not see elements in the viewport if user doesn't scroll the page. + */ +.outside-left { + position: absolute; + left: -1000%; +} +.outside-right { + position: absolute; + right: -1000%; +} +.outside-top { + position: absolute; + top: -1000%; +} +.outside-bottom { + position: absolute; + bottom: -1000%; +} +</style> +</head> +<body> + <div class="outside-left"> + <video id="left" src="gizmo.mp4"> + </div> + <div class="outside-right"> + <video id="right" src="gizmo.mp4"> + </div> + <div class="outside-top"> + <video id="top" src="gizmo.mp4"> + </div> + <div class="outside-bottom"> + <video id="bottom" src="gizmo.mp4"> + </div> +</body> +</html> diff --git a/toolkit/content/tests/browser/file_redirect.html b/toolkit/content/tests/browser/file_redirect.html new file mode 100644 index 0000000000..4d5fa9dfd1 --- /dev/null +++ b/toolkit/content/tests/browser/file_redirect.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title>redirecting...</title> +<script> +window.addEventListener("load", + () => window.location = "file_redirect_to.html"); +</script> +<body> +redirectin u bro +</body> +</html> diff --git a/toolkit/content/tests/browser/file_redirect_to.html b/toolkit/content/tests/browser/file_redirect_to.html new file mode 100644 index 0000000000..28c0b53713 --- /dev/null +++ b/toolkit/content/tests/browser/file_redirect_to.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title>redirected!</title> +<script> +window.addEventListener("load", () => { + var event = new Event("RedirectDone"); + document.dispatchEvent(event); +}); +</script> +<body> +u got redirected, bro +</body> +</html> diff --git a/toolkit/content/tests/browser/file_silentAudioTrack.html b/toolkit/content/tests/browser/file_silentAudioTrack.html new file mode 100644 index 0000000000..afdf2c5297 --- /dev/null +++ b/toolkit/content/tests/browser/file_silentAudioTrack.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<head> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> +</head> +<body> +<video id="autoplay" src="silentAudioTrack.webm"></video> +<script type="text/javascript"> + +// In linux debug on try server, sometimes the download process would fail, so +// we can't activate the "auto-play" or playing after receving "oncanplay". +// Therefore, we just call play here. +var video = document.getElementById("autoplay"); +video.loop = true; +video.play(); + +</script> +</body> diff --git a/toolkit/content/tests/browser/file_video.html b/toolkit/content/tests/browser/file_video.html new file mode 100644 index 0000000000..3c70268fbb --- /dev/null +++ b/toolkit/content/tests/browser/file_video.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> +<title>video</title> +</head> +<body> +<video id="v" src="gizmo.mp4" controls loop></video> +</body> +</html> diff --git a/toolkit/content/tests/browser/file_videoWithAudioOnly.html b/toolkit/content/tests/browser/file_videoWithAudioOnly.html new file mode 100644 index 0000000000..be84d60c34 --- /dev/null +++ b/toolkit/content/tests/browser/file_videoWithAudioOnly.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> +<title>video</title> +</head> +<body> +<video id="v" src="audio.ogg" controls loop></video> +</body> +</html> diff --git a/toolkit/content/tests/browser/file_videoWithoutAudioTrack.html b/toolkit/content/tests/browser/file_videoWithoutAudioTrack.html new file mode 100644 index 0000000000..a732b7c9d0 --- /dev/null +++ b/toolkit/content/tests/browser/file_videoWithoutAudioTrack.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> +<title>video without audio track</title> +</head> +<body> +<video id="v" src="gizmo-noaudio.webm" controls loop></video> +</body> +</html> diff --git a/toolkit/content/tests/browser/file_webAudio.html b/toolkit/content/tests/browser/file_webAudio.html new file mode 100644 index 0000000000..f6fb5e7c07 --- /dev/null +++ b/toolkit/content/tests/browser/file_webAudio.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<head> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> +</head> +<body> +<pre id=state></pre> +<button id="start" onclick="start_webaudio()">Start</button> +<button id="stop" onclick="stop_webaudio()">Stop</button> +<script type="text/javascript"> + var ac = new AudioContext(); + var dest = ac.destination; + var osc = ac.createOscillator(); + osc.connect(dest); + osc.start(); + document.querySelector("pre").innerText = ac.state; + ac.onstatechange = function() { + document.querySelector("pre").innerText = ac.state; + } + + function start_webaudio() { + ac.resume(); + } + + function stop_webaudio() { + ac.suspend(); + } +</script> +</body> diff --git a/toolkit/content/tests/browser/firebird.png b/toolkit/content/tests/browser/firebird.png Binary files differnew file mode 100644 index 0000000000..de5c22f8ce --- /dev/null +++ b/toolkit/content/tests/browser/firebird.png diff --git a/toolkit/content/tests/browser/firebird.png^headers^ b/toolkit/content/tests/browser/firebird.png^headers^ new file mode 100644 index 0000000000..2918fdbe5f --- /dev/null +++ b/toolkit/content/tests/browser/firebird.png^headers^ @@ -0,0 +1,2 @@ +HTTP 302 Found +Location: doggy.png diff --git a/toolkit/content/tests/browser/gizmo-noaudio.webm b/toolkit/content/tests/browser/gizmo-noaudio.webm Binary files differnew file mode 100644 index 0000000000..9f412cb6e3 --- /dev/null +++ b/toolkit/content/tests/browser/gizmo-noaudio.webm diff --git a/toolkit/content/tests/browser/gizmo.mp4 b/toolkit/content/tests/browser/gizmo.mp4 Binary files differnew file mode 100644 index 0000000000..87efad5ade --- /dev/null +++ b/toolkit/content/tests/browser/gizmo.mp4 diff --git a/toolkit/content/tests/browser/head.js b/toolkit/content/tests/browser/head.js new file mode 100644 index 0000000000..be15cd9684 --- /dev/null +++ b/toolkit/content/tests/browser/head.js @@ -0,0 +1,252 @@ +"use strict"; + +/** + * Set the findbar value to the given text, start a search for that text, and + * return a promise that resolves when the find has completed. + * + * @param gBrowser tabbrowser to search in the current tab. + * @param searchText text to search for. + * @param highlightOn true if highlight mode should be enabled before searching. + * @returns Promise resolves when find is complete. + */ +async function promiseFindFinished(gBrowser, searchText, highlightOn = false) { + let findbar = await gBrowser.getFindBar(); + findbar.startFind(findbar.FIND_NORMAL); + let highlightElement = findbar.getElement("highlight"); + if (highlightElement.checked != highlightOn) { + highlightElement.click(); + } + return new Promise(resolve => { + executeSoon(() => { + findbar._findField.value = searchText; + + let resultListener; + // When highlighting is on the finder sends a second "FOUND" message after + // the search wraps. This causes timing problems with e10s. waitMore + // forces foundOrTimeout wait for the second "FOUND" message before + // resolving the promise. + let waitMore = highlightOn; + let findTimeout = setTimeout(() => foundOrTimedout(null), 5000); + let foundOrTimedout = function (aData) { + if (aData !== null && waitMore) { + waitMore = false; + return; + } + if (aData === null) { + info("Result listener not called, timeout reached."); + } + clearTimeout(findTimeout); + findbar.browser?.finder.removeResultListener(resultListener); + resolve(); + }; + + resultListener = { + onFindResult: foundOrTimedout, + onCurrentSelection() {}, + onMatchesCountResult() {}, + onHighlightFinished() {}, + }; + findbar.browser.finder.addResultListener(resultListener); + findbar._find(); + }); + }); +} + +/** + * A wrapper for the findbar's method "close", which is not synchronous + * because of animation. + */ +function closeFindbarAndWait(findbar) { + return new Promise(resolve => { + if (findbar.hidden) { + resolve(); + return; + } + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + BrowserTestUtils.waitForMutationCondition( + findbar, + { attributes: true, attributeFilter: ["hidden"] }, + () => findbar.hidden + ).then(resolve); + } else { + findbar.addEventListener("transitionend", function cont(aEvent) { + if (aEvent.propertyName != "visibility") { + return; + } + findbar.removeEventListener("transitionend", cont); + resolve(); + }); + } + let close = findbar.getElement("find-closebutton"); + close.doCommand(); + }); +} + +function pushPrefs(...aPrefs) { + return new Promise(resolve => { + SpecialPowers.pushPrefEnv({ set: aPrefs }, resolve); + }); +} + +/** + * Used to check whether the audio unblocking icon is in the tab. + */ +async function waitForTabBlockEvent(tab, expectBlocked) { + if (tab.activeMediaBlocked == expectBlocked) { + ok(true, "The tab should " + (expectBlocked ? "" : "not ") + "be blocked"); + } else { + info("Block state doens't match, wait for attributes changes."); + await BrowserTestUtils.waitForEvent( + tab, + "TabAttrModified", + false, + event => { + if (event.detail.changed.includes("activemedia-blocked")) { + is( + tab.activeMediaBlocked, + expectBlocked, + "The tab should " + (expectBlocked ? "" : "not ") + "be blocked" + ); + return true; + } + return false; + } + ); + } +} + +/** + * Used to check whether the tab has soundplaying attribute. + */ +async function waitForTabPlayingEvent(tab, expectPlaying) { + if (tab.soundPlaying == expectPlaying) { + ok(true, "The tab should " + (expectPlaying ? "" : "not ") + "be playing"); + } else { + info("Playing state doesn't match, wait for attributes changes."); + await BrowserTestUtils.waitForEvent( + tab, + "TabAttrModified", + false, + event => { + if (event.detail.changed.includes("soundplaying")) { + is( + tab.soundPlaying, + expectPlaying, + "The tab should " + (expectPlaying ? "" : "not ") + "be playing" + ); + return true; + } + return false; + } + ); + } +} + +function disable_non_test_mouse(disable) { + let utils = window.windowUtils; + utils.disableNonTestMouseEvents(disable); +} + +function hover_icon(icon, tooltip) { + disable_non_test_mouse(true); + + let popupShownPromise = BrowserTestUtils.waitForEvent(tooltip, "popupshown"); + EventUtils.synthesizeMouse(icon, 1, 1, { type: "mouseover" }); + EventUtils.synthesizeMouse(icon, 2, 2, { type: "mousemove" }); + EventUtils.synthesizeMouse(icon, 3, 3, { type: "mousemove" }); + EventUtils.synthesizeMouse(icon, 4, 4, { type: "mousemove" }); + return popupShownPromise; +} + +function leave_icon(icon) { + EventUtils.synthesizeMouse(icon, 0, 0, { type: "mouseout" }); + EventUtils.synthesizeMouseAtCenter(document.documentElement, { + type: "mousemove", + }); + EventUtils.synthesizeMouseAtCenter(document.documentElement, { + type: "mousemove", + }); + EventUtils.synthesizeMouseAtCenter(document.documentElement, { + type: "mousemove", + }); + + disable_non_test_mouse(false); +} + +/** + * Used to listen events if you just need it once + */ +function once(target, name) { + var p = new Promise(function (resolve, reject) { + target.addEventListener( + name, + function () { + resolve(); + }, + { once: true } + ); + }); + return p; +} + +/** + * check if current wakelock is equal to expected state, if not, then wait until + * the wakelock changes its state to expected state. + * @param needLock + * the wakolock should be locked or not + * @param isForegroundLock + * when the lock is on, the wakelock should be in the foreground or not + */ +async function waitForExpectedWakeLockState( + topic, + { needLock, isForegroundLock } +) { + const powerManagerService = Cc["@mozilla.org/power/powermanagerservice;1"]; + const powerManager = powerManagerService.getService( + Ci.nsIPowerManagerService + ); + const wakelockState = powerManager.getWakeLockState(topic); + let expectedLockState = "unlocked"; + if (needLock) { + expectedLockState = isForegroundLock + ? "locked-foreground" + : "locked-background"; + } + if (wakelockState != expectedLockState) { + info(`wait until wakelock becomes ${expectedLockState}`); + await wakeLockObserved( + powerManager, + topic, + state => state == expectedLockState + ); + } + is( + powerManager.getWakeLockState(topic), + expectedLockState, + `the wakelock state for '${topic}' is equal to '${expectedLockState}'` + ); +} + +function wakeLockObserved(powerManager, observeTopic, checkFn) { + return new Promise(resolve => { + function wakeLockListener() {} + wakeLockListener.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIDOMMozWakeLockListener"]), + callback(topic, state) { + if (topic == observeTopic && checkFn(state)) { + powerManager.removeWakeLockListener(wakeLockListener.prototype); + resolve(); + } + }, + }; + powerManager.addWakeLockListener(wakeLockListener.prototype); + }); +} + +function getTestWebBasedURL(fileName, { crossOrigin = false } = {}) { + const origin = crossOrigin ? "http://example.org" : "http://example.com"; + return ( + getRootDirectory(gTestPath).replace("chrome://mochitests/content", origin) + + fileName + ); +} diff --git a/toolkit/content/tests/browser/image.jpg b/toolkit/content/tests/browser/image.jpg Binary files differnew file mode 100644 index 0000000000..5031808ad2 --- /dev/null +++ b/toolkit/content/tests/browser/image.jpg diff --git a/toolkit/content/tests/browser/image_page.html b/toolkit/content/tests/browser/image_page.html new file mode 100644 index 0000000000..522a1d8cf9 --- /dev/null +++ b/toolkit/content/tests/browser/image_page.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title>OHAI</title> +<body> +<img id="image" src="image.jpg" /> +</body> +</html> diff --git a/toolkit/content/tests/browser/silentAudioTrack.webm b/toolkit/content/tests/browser/silentAudioTrack.webm Binary files differnew file mode 100644 index 0000000000..8e08a86c45 --- /dev/null +++ b/toolkit/content/tests/browser/silentAudioTrack.webm diff --git a/toolkit/content/tests/chrome/RegisterUnregisterChrome.js b/toolkit/content/tests/chrome/RegisterUnregisterChrome.js new file mode 100644 index 0000000000..26e4e80922 --- /dev/null +++ b/toolkit/content/tests/chrome/RegisterUnregisterChrome.js @@ -0,0 +1,141 @@ +/* This code is mostly copied from chrome/test/unit/head_crtestutils.js */ + +// This file assumes chrome-harness.js is loaded in the global scope. +/* import-globals-from ../../../../testing/mochitest/chrome-harness.js */ + +const NS_CHROME_MANIFESTS_FILE_LIST = "ChromeML"; +const XUL_CACHE_PREF = "nglayout.debug.disable_xul_cache"; + +var gChromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( + Ci.nsIXULChromeRegistry +); + +// Create the temporary file in the profile, instead of in TmpD, because +// we know the mochitest harness kills off the profile when it's done. +function copyToTemporaryFile(f) { + let tmpd = Services.dirsvc.get("ProfD", Ci.nsIFile); + let tmpf = tmpd.clone(); + tmpf.append("temp.manifest"); + tmpf.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + tmpf.remove(false); + f.copyTo(tmpd, tmpf.leafName); + return tmpf; +} + +function* dirIter(directory) { + var testsDir = Services.io + .newURI(directory) + .QueryInterface(Ci.nsIFileURL).file; + + let en = testsDir.directoryEntries; + while (en.hasMoreElements()) { + yield en.nextFile; + } +} + +function getParent(path) { + let lastSlash = path.lastIndexOf("/"); + if (lastSlash == -1) { + lastSlash = path.lastIndexOf("\\"); + if (lastSlash == -1) { + return ""; + } + return "/" + path.substring(0, lastSlash).replace(/\\/g, "/"); + } + return path.substring(0, lastSlash); +} + +function copyDirToTempProfile(path, subdirname) { + if (subdirname === undefined) { + subdirname = "mochikit-tmp"; + } + + let tmpdir = Services.dirsvc.get("ProfD", Ci.nsIFile); + tmpdir.append(subdirname); + tmpdir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o777); + + let rootDir = getParent(path); + if (rootDir == "") { + return tmpdir; + } + + // The SimpleTest directory is hidden + var files = Array.from(dirIter("file://" + rootDir)); + for (let f in files) { + files[f].copyTo(tmpdir, ""); + } + return tmpdir; +} + +function convertChromeURI(chromeURI) { + let uri = Services.io.newURI(chromeURI); + return gChromeReg.convertChromeURL(uri); +} + +function chromeURIToFile(chromeURI) { + var jar = getJar(chromeURI); + if (jar) { + var tmpDir = extractJarToTmp(jar); + let parts = chromeURI.split("/"); + if (parts[parts.length - 1] != "") { + tmpDir.append(parts[parts.length - 1]); + } + return tmpDir; + } + + return convertChromeURI(chromeURI).QueryInterface(Ci.nsIFileURL).file; +} + +// Register a chrome manifest temporarily and return a function which un-does +// the registrarion when no longer needed. +function createManifestTemporarily(tempDir, manifestText) { + Services.prefs.setBoolPref(XUL_CACHE_PREF, true); + + tempDir.append("temp.manifest"); + + let foStream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance( + Ci.nsIFileOutputStream + ); + foStream.init(tempDir, 0x02 | 0x08 | 0x20, 0o664, 0); // write, create, truncate + foStream.write(manifestText, manifestText.length); + foStream.close(); + let tempfile = copyToTemporaryFile(tempDir); + + Components.manager + .QueryInterface(Ci.nsIComponentRegistrar) + .autoRegister(tempfile); + + return function () { + tempfile.fileSize = 0; // truncate the manifest + gChromeReg.checkForNewChrome(); + Services.prefs.clearUserPref(XUL_CACHE_PREF); + }; +} + +// Register a chrome manifest temporarily and return a function which un-does +// the registrarion when no longer needed. +function registerManifestTemporarily(manifestURI) { + Services.prefs.setBoolPref(XUL_CACHE_PREF, true); + + let file = chromeURIToFile(manifestURI); + + let tempfile = copyToTemporaryFile(file); + Components.manager + .QueryInterface(Ci.nsIComponentRegistrar) + .autoRegister(tempfile); + + return function () { + tempfile.fileSize = 0; // truncate the manifest + gChromeReg.checkForNewChrome(); + Services.prefs.clearUserPref(XUL_CACHE_PREF); + }; +} + +function registerManifestPermanently(manifestURI) { + var chromepath = chromeURIToFile(manifestURI); + + Components.manager + .QueryInterface(Ci.nsIComponentRegistrar) + .autoRegister(chromepath); + return chromepath; +} diff --git a/toolkit/content/tests/chrome/bug263683_window.xhtml b/toolkit/content/tests/chrome/bug263683_window.xhtml new file mode 100644 index 0000000000..b124361fd7 --- /dev/null +++ b/toolkit/content/tests/chrome/bug263683_window.xhtml @@ -0,0 +1,206 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<window id="263683test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + onload="SimpleTest.executeSoon(startTest);" + title="263683 test"> + + <script type="application/javascript"><![CDATA[ + const {AppConstants} = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + var gFindBar = null; + var gBrowser; + + var SimpleTest = window.arguments[0].SimpleTest; + var ok = window.arguments[0].ok; + var info = window.arguments[0].info; + var is = window.arguments[0].is; + + function startTest() { + (async function() { + gFindBar = document.getElementById("FindToolbar"); + // Testing on a remote browser has been disabled due to frequent + // intermittent failures. + for (let browserId of ["content"/*, "content-remote"*/]) { + await startTestWithBrowser(browserId); + } + })().then(() => { + window.close(); + SimpleTest.finish(); + }); + } + + async function startTestWithBrowser(browserId) { + // We're bailing out when testing a remote browser on OSX 10.6, because it + // fails permanently. + if (browserId.endsWith("remote") && AppConstants.isPlatformAndVersionAtMost("macosx", 11)) { + return; + } + + info("Starting test with browser '" + browserId + "'"); + gBrowser = document.getElementById(browserId); + gFindBar.browser = gBrowser; + let promise = BrowserTestUtils.browserLoaded(gBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser, 'data:text/html,<h2>Text mozilla</h2><input id="inp" type="text" />'); + await promise; + await onDocumentLoaded(); + } + + function toggleHighlightAndWait(highlight) { + return new Promise(resolve => { + let listener = { + onHighlightFinished() { + gFindBar.browser.finder.removeResultListener(listener); + resolve(); + } + }; + gFindBar.browser.finder.addResultListener(listener); + gFindBar.toggleHighlight(highlight); + }); + } + + async function onDocumentLoaded() { + gFindBar.open(); + var search = "mozilla"; + gFindBar._findField.focus(); + gFindBar._findField.value = search; + var matchCase = gFindBar.getElement("find-case-sensitive"); + if (matchCase.checked) { + matchCase.doCommand(); + } + + let promise = toggleHighlightAndWait(true); + gFindBar._find(); + await promise; + + await SpecialPowers.spawn(gBrowser, [{ search }], async function(args) { + let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + Assert.ok("SELECTION_FIND" in controller, "Correctly detects new selection type"); + let selection = controller.getSelection(controller.SELECTION_FIND); + + Assert.equal(selection.rangeCount, 1, + "Correctly added a match to the selection type"); + Assert.equal(selection.getRangeAt(0).toString().toLowerCase(), + args.search, "Added the correct match"); + }); + + await toggleHighlightAndWait(false); + + await SpecialPowers.spawn(gBrowser, [{ search }], async function(args) { + let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + let selection = controller.getSelection(controller.SELECTION_FIND); + Assert.equal(selection.rangeCount, 0, "Correctly removed the range"); + + let input = content.document.getElementById("inp"); + input.value = args.search; + }); + + await toggleHighlightAndWait(true); + + await SpecialPowers.spawn(gBrowser, [{ search }], async function(args) { + let input = content.document.getElementById("inp"); + let inputController = input.editor.selectionController; + let inputSelection = inputController.getSelection(inputController.SELECTION_FIND); + + Assert.equal(inputSelection.rangeCount, 1, + "Correctly added a match from input to the selection type"); + Assert.equal(inputSelection.getRangeAt(0).toString().toLowerCase(), + args.search, "Added the correct match"); + }); + + await toggleHighlightAndWait(false); + + await SpecialPowers.spawn(gBrowser, [], async function() { + let input = content.document.getElementById("inp"); + let inputController = input.editor.selectionController; + let inputSelection = inputController.getSelection(inputController.SELECTION_FIND); + + Assert.equal(inputSelection.rangeCount, 0, "Correctly removed the range"); + }); + + // For posterity, test iframes too. + + promise = BrowserTestUtils.browserLoaded(gBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser, 'data:text/html,<h2>Text mozilla</h2><iframe id="leframe" ' + + 'src="data:text/html,Text mozilla"></iframe>'); + await promise; + + await toggleHighlightAndWait(true); + + await SpecialPowers.spawn(gBrowser, [{ search }], async function(args) { + function getSelection(docShell) { + let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + return controller.getSelection(controller.SELECTION_FIND); + } + + let selection = getSelection(docShell); + Assert.equal(selection.rangeCount, 1, + "Correctly added a match to the selection type"); + Assert.equal(selection.getRangeAt(0).toString().toLowerCase(), + args.search, "Added the correct match"); + + // Check the iframe too: + let frame = content.document.getElementById("leframe"); + // Hoops! Get the docShell first, then the selection. + selection = getSelection(frame.contentWindow.docShell); + Assert.equal(selection.rangeCount, 1, + "Correctly added a match to the selection type"); + Assert.equal(selection.getRangeAt(0).toString().toLowerCase(), + args.search, "Added the correct match"); + }); + + await toggleHighlightAndWait(false); + + const matches = JSON.parse(gFindBar._foundMatches.dataset.l10nArgs); + is(matches.total, 2, "Found correct amount of matches") + + await SpecialPowers.spawn(gBrowser, [], async function(args) { + function getSelection(docShell) { + let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + return controller.getSelection(controller.SELECTION_FIND); + } + + let selection = getSelection(docShell); + Assert.equal(selection.rangeCount, 0, "Correctly removed the range"); + + // Check the iframe too: + let frame = content.document.getElementById("leframe"); + // Hoops! Get the docShell first, then the selection. + selection = getSelection(frame.contentWindow.docShell); + Assert.equal(selection.rangeCount, 0, "Correctly removed the range"); + + content.document.documentElement.focus(); + }); + + gFindBar.close(true); + } + ]]></script> + + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <browser type="content" primary="true" flex="1" id="content-remote" remote="true" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/bug304188_window.xhtml b/toolkit/content/tests/chrome/bug304188_window.xhtml new file mode 100644 index 0000000000..11343f2ace --- /dev/null +++ b/toolkit/content/tests/chrome/bug304188_window.xhtml @@ -0,0 +1,94 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + onload="onLoad();" + title="FindbarTest for bug 304188 - +find-menu appears in editor element which has had makeEditable() called but designMode not set"> + + <script type="application/javascript"><![CDATA[ + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + const {ContentTask} = ChromeUtils.importESModule( + "resource://testing-common/ContentTask.sys.mjs" + ); + ContentTask.setTestScope(window.arguments[0]); + + var gFindBar = null; + var gBrowser; + + var SimpleTest = window.arguments[0].SimpleTest; + var info = window.arguments[0].info; + var ok = window.arguments[0].ok; + + function onLoad() { + (async function() { + gFindBar = document.getElementById("FindToolbar"); + for (let browserId of ["content", "content-remote"]) { + await startTestWithBrowser(browserId); + } + })().then(() => { + window.close(); + SimpleTest.finish(); + }); + } + + async function startTestWithBrowser(browserId) { + info("Starting test with browser '" + browserId + "'"); + gBrowser = document.getElementById(browserId); + gFindBar.browser = gBrowser; + let promise = ContentTask.spawn(gBrowser, [], async function() { + return new Promise(resolve => { + addEventListener("DOMContentLoaded", () => resolve(), { once: true }); + }); + }); + BrowserTestUtils.startLoadingURIString(gBrowser, "data:text/html;charset=utf-8,some%20random%20text"); + await promise; + await onDocumentLoaded(); + } + + async function onDocumentLoaded() { + await ContentTask.spawn(gBrowser, [], async function() { + var edsession = content.docShell.editingSession; + edsession.makeWindowEditable(content, "html", false, true, false); + content.focus(); + }); + + await enterStringIntoEditor("'"); + await enterStringIntoEditor("/"); + + ok(gFindBar.hidden, + "Findfield should have stayed hidden after entering editor test"); + } + + async function enterStringIntoEditor(aString) { + for (let i = 0; i < aString.length; i++) { + await ContentTask.spawn(gBrowser, [{ charCode: aString.charCodeAt(i) }], async function(args) { + let event = new content.window.KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: args.charCode, + }); + content.document.body.dispatchEvent(event); + }); + } + } + ]]></script> + + <browser id="content" flex="1" src="about:blank" type="content" primary="true" messagemanagergroup="test"/> + <browser id="content-remote" remote="true" flex="1" src="about:blank" type="content" primary="true" messagemanagergroup="test"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/bug331215_window.xhtml b/toolkit/content/tests/chrome/bug331215_window.xhtml new file mode 100644 index 0000000000..20c08dbd66 --- /dev/null +++ b/toolkit/content/tests/chrome/bug331215_window.xhtml @@ -0,0 +1,99 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<window id="331215test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + onload="SimpleTest.executeSoon(startTest);" + title="331215 test"> + + <script type="application/javascript"><![CDATA[ + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + var gFindBar = null; + var gBrowser; + + var SimpleTest = window.arguments[0].SimpleTest; + var info = window.arguments[0].info; + var ok = window.arguments[0].ok; + SimpleTest.requestLongerTimeout(2); + + function startTest() { + (async function() { + gFindBar = document.getElementById("FindToolbar"); + for (let browserId of ["content", "content-remote"]) { + await startTestWithBrowser(browserId); + } + })().then(() => { + window.close(); + SimpleTest.finish(); + }); + } + + async function startTestWithBrowser(browserId) { + info("Starting test with browser '" + browserId + "'"); + gBrowser = document.getElementById(browserId); + gFindBar.browser = gBrowser; + let promise = BrowserTestUtils.browserLoaded(gBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser, "data:text/plain,latest"); + await promise; + await onDocumentLoaded(); + } + + async function onDocumentLoaded() { + document.getElementById("cmd_find").doCommand(); + await promiseEnterStringIntoFindField("test"); + document.commandDispatcher + .getControllerForCommand("cmd_moveTop") + .doCommand("cmd_moveTop"); + await promiseEnterStringIntoFindField("l"); + ok(gFindBar._findField.getAttribute("status") == "notfound", + "Findfield status attribute should have been 'notfound' after entering test"); + await promiseEnterStringIntoFindField("a"); + ok(gFindBar._findField.getAttribute("status") != "notfound", + "Findfield status attribute should not have been 'notfound' after entering latest"); + } + + function promiseEnterStringIntoFindField(aString) { + return new Promise(resolve => { + let listener = { + onFindResult(result) { + if (result.result == Ci.nsITypeAheadFind.FIND_FOUND && result.searchString != aString) + return; + gFindBar.browser.finder.removeResultListener(listener); + resolve(); + } + }; + gFindBar.browser.finder.addResultListener(listener); + + for (let c of aString) { + let code = c.charCodeAt(0); + let ev = new KeyboardEvent("keypress", { + keyCode: code, + charCode: code, + bubbles: true + }); + gFindBar._findField.dispatchEvent(ev); + } + }); + } + ]]></script> + + <commandset> + <command id="cmd_find" oncommand="document.getElementById('FindToolbar').onFindCommand();"/> + </commandset> + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <browser type="content" primary="true" flex="1" id="content-remote" remote="true" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/bug360437_window.xhtml b/toolkit/content/tests/chrome/bug360437_window.xhtml new file mode 100644 index 0000000000..dd5e336555 --- /dev/null +++ b/toolkit/content/tests/chrome/bug360437_window.xhtml @@ -0,0 +1,129 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<window id="360437Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + width="600" + height="600" + onload="startTest();" + title="360437 test"> + + <script type="application/javascript"><![CDATA[ + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + const {ContentTask} = ChromeUtils.importESModule( + "resource://testing-common/ContentTask.sys.mjs" + ); + ContentTask.setTestScope(window.arguments[0]); + + var gFindBar = null; + var gBrowser; + + var SimpleTest = window.arguments[0].SimpleTest; + var ok = window.arguments[0].ok; + var is = window.arguments[0].is; + var info = window.arguments[0].info; + + function startTest() { + (async function() { + gFindBar = document.getElementById("FindToolbar"); + for (let browserId of ["content", "content-remote"]) { + await startTestWithBrowser(browserId); + } + })().then(() => { + window.close(); + SimpleTest.finish(); + }); + } + + async function startTestWithBrowser(browserId) { + info("Starting test with browser '" + browserId + "'"); + gBrowser = document.getElementById(browserId); + gFindBar.browser = gBrowser; + + let loadedPromise = BrowserTestUtils.browserLoaded(gBrowser); + let contentLoadedPromise = ContentTask.spawn(gBrowser, null, async function() { + return new Promise(resolve => { + addEventListener("DOMContentLoaded", () => resolve(), { once: true }); + }); + }); + BrowserTestUtils.startLoadingURIString(gBrowser, "data:text/html,<form><input id='input' type='text' value='text inside an input element'></form>"); + await loadedPromise; + await contentLoadedPromise; + + gFindBar.onFindCommand(); + + // Make sure the findfield is correctly focused on open + var searchStr = "text inside an input element"; + await promiseEnterStringIntoFindField(searchStr); + is(document.commandDispatcher.focusedElement, + gFindBar._findField, "Find field isn't focused"); + + // Make sure "find again" correctly transfers focus to the content element + // when the find bar is closed. + await new Promise(resolve => { + window.addEventListener("findbarclose", resolve, { once: true }); + gFindBar.close(); + }); + gFindBar.onFindAgainCommand(false); + await SpecialPowers.spawn(gBrowser, [], async function() { + Assert.equal(content.document.activeElement, + content.document.getElementById("input"), "Input Element isn't focused"); + }); + + // Make sure "find again" doesn't focus the content element if focus + // isn't in the content document. + var textbox = document.getElementById("textbox"); + textbox.focus(); + + ok(gFindBar.hidden, "Findbar is hidden"); + gFindBar.onFindAgainCommand(false); + is(document.activeElement, textbox, + "Focus was stolen from a chrome element"); + } + + function promiseFindResult(str = null) { + return new Promise(resolve => { + let listener = { + onFindResult({ searchString }) { + if (str !== null && str != searchString) { + return; + } + gFindBar.browser.finder.removeResultListener(listener); + resolve(); + } + }; + gFindBar.browser.finder.addResultListener(listener); + }); + } + + function promiseEnterStringIntoFindField(str) { + let promise = promiseFindResult(str); + for (let i = 0; i < str.length; i++) { + let event = new KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: str.charCodeAt(i), + }); + gFindBar._findField.dispatchEvent(event); + } + return promise; + } + ]]></script> + <html:input id="textbox"/> + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <browser type="content" primary="true" flex="1" id="content-remote" remote="true" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/bug366992_window.xhtml b/toolkit/content/tests/chrome/bug366992_window.xhtml new file mode 100644 index 0000000000..698d26b43a --- /dev/null +++ b/toolkit/content/tests/chrome/bug366992_window.xhtml @@ -0,0 +1,74 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window id="366992 test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="onLoad();" + width="600" + height="600" + title="366992 test"> + + <commandset id="editMenuCommands"> + <commandset id="editMenuCommandSetAll" commandupdater="true" events="focus,select" + oncommandupdate="goUpdateGlobalEditMenuItems()"/> + <commandset id="editMenuCommandSetUndo" commandupdater="true" events="undo" + oncommandupdate="goUpdateUndoEditMenuItems()"/> + <commandset id="editMenuCommandSetPaste" commandupdater="true" events="clipboard" + oncommandupdate="goUpdatePasteMenuItems()"/> + <command id="cmd_undo" oncommand="goDoCommand('cmd_undo')"/> + <command id="cmd_redo" oncommand="goDoCommand('cmd_redo')"/> + <command id="cmd_cut" oncommand="goDoCommand('cmd_cut')"/> + <command id="cmd_copy" oncommand="goDoCommand('cmd_copy')"/> + <command id="cmd_paste" oncommand="goDoCommand('cmd_paste')"/> + <command id="cmd_delete" oncommand="goDoCommand('cmd_delete')"/> + <command id="cmd_selectAll" oncommand="goDoCommand('cmd_selectAll')"/> + <command id="cmd_switchTextDirection" oncommand="goDoCommand('cmd_switchTextDirection');"/> + </commandset> + + <script type="application/javascript" + src="chrome://global/content/globalOverlay.js"/> + <script type="application/javascript" + src="chrome://global/content/editMenuOverlay.js"/> + <script type="application/javascript"><![CDATA[ + // Without the fix for bug 366992, the delete command would be enabled + // for the input even though the input's controller for this command + // disables it. + var gShouldNotBeReachedController = { + supportsCommand(aCommand) { + return aCommand == "cmd_delete"; + }, + isCommandEnabled(aCommand) { + return aCommand == "cmd_delete"; + }, + doCommand(aCommand) { } + } + + function ok(condition, message) { + window.arguments[0].SimpleTest.ok(condition, message); + } + function finish() { + window.controllers.removeController(gShouldNotBeReachedController); + window.close(); + window.arguments[0].SimpleTest.finish(); + } + + function onLoad() { + document.getElementById("input").focus(); + var deleteDisabled = document.getElementById("cmd_delete") + .getAttribute("disabled") == "true"; + ok(deleteDisabled, + "cmd_delete should be disabled when the empty input is focused"); + finish(); + } + + window.controllers.appendController(gShouldNotBeReachedController); + ]]></script> + + <html:input id="input"/> +</window> diff --git a/toolkit/content/tests/chrome/bug409624_window.xhtml b/toolkit/content/tests/chrome/bug409624_window.xhtml new file mode 100644 index 0000000000..88033ba794 --- /dev/null +++ b/toolkit/content/tests/chrome/bug409624_window.xhtml @@ -0,0 +1,98 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window id="409624test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + title="409624 test"> + + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <script type="application/javascript"><![CDATA[ + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + var gFindBar = null; + var gBrowser; + + var SimpleTest = window.arguments[0].SimpleTest; + var ok = window.arguments[0].ok; + var is = window.arguments[0].is; + + function finish() { + window.close(); + SimpleTest.finish(); + } + + function startTest() { + gFindBar = document.getElementById("FindToolbar"); + gBrowser = document.getElementById("content"); + gBrowser.addEventListener("pageshow", onPageShow, { once: true }); + BrowserTestUtils.startLoadingURIString(gBrowser, 'data:text/html,<h2>Text mozilla</h2><input id="inp" type="text" />'); + } + + function onPageShow() { + gFindBar.clear(); + let textbox = gFindBar.getElement("findbar-textbox"); + + // Clear should work regardless of whether the editor has been lazily + // initialised yet + ok(!gFindBar.hasTransactions, "No transactions when findbar empty"); + textbox.value = "mozilla"; + ok(gFindBar.hasTransactions, "Has transactions when findbar value set without editor init"); + gFindBar.clear(); + is(textbox.value, '', "findbar input value cleared after clear() call without editor init"); + ok(!gFindBar.hasTransactions, "No transactions after clear() call"); + + gFindBar.open(); + let matchCaseCheckbox = gFindBar.getElement("find-case-sensitive"); + if (!matchCaseCheckbox.hidden && matchCaseCheckbox.checked) + matchCaseCheckbox.click(); + ok(!matchCaseCheckbox.checked, "case-insensitivity correctly set"); + + // Simulate typical input + textbox.focus(); + gFindBar.clear(); + sendChar("m"); + ok(gFindBar.hasTransactions, "Has transactions after input"); + let preSelection = gBrowser.contentWindow.getSelection(); + ok(!preSelection.isCollapsed, "Found item and selected range"); + gFindBar.clear(); + is(textbox.value, '', "findbar input value cleared after clear() call"); + let postSelection = gBrowser.contentWindow.getSelection(); + ok(postSelection.isCollapsed, "item found deselected after clear() call"); + let fp = gFindBar.getElement("find-previous"); + ok(fp.disabled, "find-previous button disabled after clear() call"); + let fn = gFindBar.getElement("find-next"); + ok(fn.disabled, "find-next button disabled after clear() call"); + + // Test status updated after a search for text not in page + textbox.focus(); + sendChar("x"); + gFindBar.clear(); + let ftext = gFindBar.getElement("find-status"); + is(ftext.textContent, "", "status text disabled after clear() call"); + + // Test input empty with undo stack non-empty + textbox.focus(); + sendChar("m"); + sendKey("BACK_SPACE"); + ok(gFindBar.hasTransactions, "Has transactions when undo available"); + gFindBar.clear(); + gFindBar.close(); + + finish(); + } + + SimpleTest.waitForFocus(startTest, window); + ]]></script> + + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/bug429723_window.xhtml b/toolkit/content/tests/chrome/bug429723_window.xhtml new file mode 100644 index 0000000000..52e743239b --- /dev/null +++ b/toolkit/content/tests/chrome/bug429723_window.xhtml @@ -0,0 +1,94 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window id="429723Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + onload="onLoad();" + title="429723 test"> + + <script type="application/javascript"><![CDATA[ + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + var gFindBar = null; + var gBrowser; + + function ok(condition, message) { + window.arguments[0].SimpleTest.ok(condition, message); + } + + function finish() { + window.close(); + window.arguments[0].SimpleTest.finish(); + } + + function onLoad() { + var _delayedOnLoad = function() { + gFindBar = document.getElementById("FindToolbar"); + gBrowser = document.getElementById("content"); + gBrowser.addEventListener("pageshow", onPageShow, { once: true }); + BrowserTestUtils.startLoadingURIString(gBrowser, "data:text/html,<h2 id='h2'>mozilla</h2>"); + } + setTimeout(_delayedOnLoad, 1000); + } + + function enterStringIntoFindField(aString) { + for (var i=0; i < aString.length; i++) { + var event = new KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: aString.charCodeAt(i), + }); + gFindBar._findField.dispatchEvent(event); + } + } + + function onPageShow() { + var findField = gFindBar._findField; + document.getElementById("cmd_find").doCommand(); + + var matchCaseCheckbox = gFindBar.getElement("find-case-sensitive"); + if (!matchCaseCheckbox.hidden & matchCaseCheckbox.checked) + matchCaseCheckbox.click(); + + // Perform search + var searchStr = "z"; + enterStringIntoFindField(searchStr); + + // Highlight search term + var highlight = gFindBar.getElement("highlight"); + if (!highlight.checked) + highlight.click(); + + // Delete search term + var event = new KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: KeyEvent.DOM_VK_BACK_SPACE, + charCode: 0, + }); + gFindBar._findField.dispatchEvent(event); + + var notRed = !findField.hasAttribute("status") || + (findField.getAttribute("status") != "notfound"); + ok(notRed, "Find Bar textbox is correct colour"); + finish(); + } + ]]></script> + + <commandset> + <command id="cmd_find" oncommand="document.getElementById('FindToolbar').onFindCommand();"/> + </commandset> + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/bug451540_window.xhtml b/toolkit/content/tests/chrome/bug451540_window.xhtml new file mode 100644 index 0000000000..f8b8900ac6 --- /dev/null +++ b/toolkit/content/tests/chrome/bug451540_window.xhtml @@ -0,0 +1,255 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css"?> + +<window id="451540test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + title="451540 test"> + + <script type="application/javascript"><![CDATA[ + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + const SEARCH_TEXT = "minefield"; + + let gFindBar = null; + let gBrowser; + + var SimpleTest = window.arguments[0].SimpleTest; + var ok = window.arguments[0].ok; + var is = window.arguments[0].is; + var info = window.arguments[0].info; + + SimpleTest.requestLongerTimeout(2); + + function startTest() { + gFindBar = document.getElementById("FindToolbar"); + gBrowser = document.getElementById("content"); + gBrowser.addEventListener("pageshow", onPageShow, { once: true }); + let data = `data:text/html,<input id="inp" type="text" /> + <textarea id="tarea"/>`; + BrowserTestUtils.startLoadingURIString(gBrowser, data); + } + + function promiseHighlightFinished() { + return new Promise(resolve => { + let listener = { + onHighlightFinished() { + gFindBar.browser.finder.removeResultListener(listener); + resolve(); + } + }; + gFindBar.browser.finder.addResultListener(listener); + }); + } + + async function resetForNextTest(elementId, aText) { + if (!aText) + aText = SEARCH_TEXT; + + // Turn off highlighting + let highlightButton = gFindBar.getElement("highlight"); + if (highlightButton.checked) { + highlightButton.click(); + } + + // Initialise input + info(`setting element value to ${aText}`); + await SpecialPowers.spawn(gBrowser, [{elementId, aText}], async function(args) { + let {elementId, aText} = args; + let doc = content.document; + let element = doc.getElementById(elementId); + element.value = aText; + element.focus(); + }); + info(`just set element value to ${aText}`); + gFindBar._findField.value = SEARCH_TEXT; + + // Perform search and turn on highlighting + gFindBar._find(); + highlightButton.click(); + await promiseHighlightFinished(); + + // Move caret to start of element + info(`focusing element`); + await SpecialPowers.spawn(gBrowser, [elementId], async function(elementId) { + let doc = content.document; + let element = doc.getElementById(elementId); + element.focus(); + }); + info(`focused element`); + if (navigator.platform.includes("Mac")) { + await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", { metaKey: true }, gBrowser); + } else { + await BrowserTestUtils.synthesizeKey("KEY_Home", {}, gBrowser); + } + } + + async function testSelection(elementId, expectedRangeCount, message) { + await SpecialPowers.spawn(gBrowser, [{elementId, expectedRangeCount, message}], async function(args) { + let {elementId, expectedRangeCount, message} = args; + let doc = content.document; + let element = doc.getElementById(elementId); + let controller = element.editor.selectionController; + let selection = controller.getSelection(controller.SELECTION_FIND); + Assert.equal(selection.rangeCount, expectedRangeCount, message); + }); + } + + async function testInput(elementId, testTypeText) { + let isEditableElement = await SpecialPowers.spawn(gBrowser, [elementId], async function(elementId) { + let doc = content.document; + let element = doc.getElementById(elementId); + let elementClass = ChromeUtils.getClassName(element); + return elementClass === "HTMLInputElement" || + elementClass === "HTMLTextAreaElement"; + }); + if (!isEditableElement) { + return; + } + + let moveCaretToNextWordBoundary = async (aBrowser) => { + if (!navigator.platform.includes("Mac")) { + return BrowserTestUtils.synthesizeKey("KEY_ArrowRight", { accelKey: true }, aBrowser); + } + // macOS does not have default shortcut key to move caret per word. + return SpecialPowers.spawn(aBrowser, [], async () => { + content.docShell.doCommand("cmd_wordNext"); + }); + }; + + // Initialize the findbar + let matchCase = gFindBar.getElement("find-case-sensitive"); + if (matchCase.checked) { + matchCase.doCommand(); + } + + // First check match has been correctly highlighted + await resetForNextTest(elementId); + + await testSelection(elementId, 1, testTypeText + " correctly highlighted match"); + + // Test 2: check highlight removed when text added within the highlight + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, gBrowser); + await BrowserTestUtils.synthesizeKey("a", {}, gBrowser); + + await testSelection(elementId, 0, testTypeText + " correctly removed highlight on text insertion"); + + // Test 3: check highlighting remains when text added before highlight + await resetForNextTest(elementId); + await BrowserTestUtils.synthesizeKey("a", {}, gBrowser); + await testSelection(elementId, 1, testTypeText + " highlight correctly remained on text insertion at start"); + + // Test 4: check highlighting remains when text added after highlight + await resetForNextTest(elementId); + for (let x = 0; x < SEARCH_TEXT.length; x++) { + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, gBrowser); + } + await BrowserTestUtils.synthesizeKey("a", {}, gBrowser); + await testSelection(elementId, 1, testTypeText + " highlight correctly remained on text insertion at end"); + + // Test 5: deleting text within the highlight + await resetForNextTest(elementId); + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_Backspace", {}, gBrowser); + await testSelection(elementId, 0, testTypeText + " correctly removed highlight on text deletion"); + + // Test 6: deleting text at end of highlight + await resetForNextTest(elementId, SEARCH_TEXT + "A"); + for (let x = 0; x < (SEARCH_TEXT + "A").length; x++) { + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, gBrowser); + } + await BrowserTestUtils.synthesizeKey("KEY_Backspace", {}, gBrowser); + await testSelection(elementId, 1, testTypeText + " highlight correctly remained on text deletion at end"); + + // Test 7: deleting text at start of highlight + await resetForNextTest(elementId, "A" + SEARCH_TEXT); + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_Backspace", {}, gBrowser); + await testSelection(elementId, 1, testTypeText + " highlight correctly remained on text deletion at start"); + + // Test 8: deleting selection + await resetForNextTest(elementId); + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, gBrowser); + await BrowserTestUtils.synthesizeKey("x", { accelKey: true }, gBrowser); + await testSelection(elementId, 0, testTypeText + " correctly removed highlight on selection deletion"); + + // Test 9: Multiple matches within one editor (part 1) + // Check second match remains highlighted after inserting text into + // first match, and that its highlighting gets removed when the + // second match is edited + await resetForNextTest(elementId, SEARCH_TEXT + " " + SEARCH_TEXT); + await testSelection(elementId, 2, testTypeText + " correctly highlighted both matches"); + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, gBrowser); + await BrowserTestUtils.synthesizeKey("a", {}, gBrowser); + await testSelection(elementId, 1, testTypeText + " correctly removed only the first highlight on text insertion"); + await moveCaretToNextWordBoundary(gBrowser); + await moveCaretToNextWordBoundary(gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", {}, gBrowser); + await BrowserTestUtils.synthesizeKey("a", {}, gBrowser); + await testSelection(elementId, 0, testTypeText + " correctly removed second highlight on text insertion"); + + // Test 10: Multiple matches within one editor (part 2) + // Check second match remains highlighted after deleting text in + // first match, and that its highlighting gets removed when the + // second match is edited + await resetForNextTest(elementId, SEARCH_TEXT + " " + SEARCH_TEXT); + await testSelection(elementId, 2, testTypeText + " correctly highlighted both matches"); + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", {}, gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_Backspace", {}, gBrowser); + await testSelection(elementId, 1, testTypeText + " correctly removed only the first highlight on text deletion"); + await moveCaretToNextWordBoundary(gBrowser); + await moveCaretToNextWordBoundary(gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", {}, gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_Backspace", {}, gBrowser); + await testSelection(elementId, 0, testTypeText + " correctly removed second highlight on text deletion"); + + // Test 11: Multiple matches within one editor (part 3) + // Check second match remains highlighted after deleting selection + // in first match, and that second match highlighting gets correctly + // removed when it has a selection deleted from it + await resetForNextTest(elementId, SEARCH_TEXT + " " + SEARCH_TEXT); + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, gBrowser); + await BrowserTestUtils.synthesizeKey("x", { accelKey: true }, gBrowser); + await testSelection(elementId, 1, testTypeText + " correctly removed only first highlight on selection deletion"); + await moveCaretToNextWordBoundary(gBrowser); + await moveCaretToNextWordBoundary(gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, gBrowser); + await BrowserTestUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, gBrowser); + await BrowserTestUtils.synthesizeKey("x", { accelKey: true }, gBrowser); + await testSelection(elementId, 0, testTypeText + " correctly removed second highlight on selection deletion"); + + // Turn off highlighting + let highlightButton = gFindBar.getElement("highlight"); + if (highlightButton.checked) { + highlightButton.click(); + } + } + + function onPageShow() { + (async function() { + gFindBar.open(); + await testInput("inp", "Input:"); + await testInput("tarea", "Textarea:"); + })().then(() => { + window.close(); + SimpleTest.finish(); + }); + } + + SimpleTest.waitForFocus(startTest, window); + ]]></script> + + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <browser type="content" primary="true" flex="1" id="content-remote" remote="true" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/bug624329_window.xhtml b/toolkit/content/tests/chrome/bug624329_window.xhtml new file mode 100644 index 0000000000..8cef32e4e5 --- /dev/null +++ b/toolkit/content/tests/chrome/bug624329_window.xhtml @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Test for bug 624329 context menu position" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + context="menu"> + + <script> + window.arguments[0].SimpleTest.waitForFocus(window.arguments[0].childFocused, window); + </script> + + <menupopup id="menu"> + <!-- The bug demonstrated only when the accesskey was presented separately + from the label. + e.g. because the accesskey is not a letter in the label. + + The bug demonstrates only on the first show of the context menu + unless menu items are removed/added each time the menu is + constructed. --> + <menuitem label="Long label to ensure the popup would hit the right of the screen" accesskey="1"/> + </menupopup> +</window> diff --git a/toolkit/content/tests/chrome/chrome.toml b/toolkit/content/tests/chrome/chrome.toml new file mode 100644 index 0000000000..70fa12c4b6 --- /dev/null +++ b/toolkit/content/tests/chrome/chrome.toml @@ -0,0 +1,360 @@ +[DEFAULT] +skip-if = ["os == 'android'"] +support-files = [ + "../widgets/popup_shared.js", + "../widgets/tree_shared.js", + "RegisterUnregisterChrome.js", + "bug263683_window.xhtml", + "bug304188_window.xhtml", + "bug331215_window.xhtml", + "bug360437_window.xhtml", + "bug366992_window.xhtml", + "bug409624_window.xhtml", + "bug429723_window.xhtml", + "bug624329_window.xhtml", + "dialog_button.xhtml", + "dialog_dialogfocus.xhtml", + "dialog_dialogfocus2.xhtml", + "file_empty.xhtml", + "file_edit_contextmenu.xhtml", + "file_about_networking_wsh.py", + "file_autocomplete_with_composition.js", + "file_editor_with_autocomplete.js", + "findbar_entireword_window.xhtml", + "findbar_events_window.xhtml", + "findbar_window.xhtml", + "frame_popup_anchor.xhtml", + "frame_subframe_origin_subframe1.xhtml", + "frame_subframe_origin_subframe2.xhtml", + "popup_trigger.js", + "sample_entireword_latin1.html", + "window_browser_drop.xhtml", + "window_keys.xhtml", + "window_largemenu.xhtml", + "window_panel.xhtml", + "window_panel_anchoradjust.xhtml", + "window_popup_anchor.xhtml", + "window_popup_anchoratrect.xhtml", + "window_popup_attribute.xhtml", + "window_popup_button.xhtml", + "window_popup_preventdefault_chrome.xhtml", + "window_preferences.xhtml", + "window_preferences2.xhtml", + "window_preferences3.xhtml", + "window_preferences_commandretarget.xhtml", + "window_preferences_disabled.xhtml", + "window_screenPosSize.xhtml", + "window_showcaret.xhtml", + "window_subframe_origin.xhtml", + "window_tooltip.xhtml", + "xul_selectcontrol.js", +] +prefs = [ + "gfx.font_rendering.fallback.async=false", + "widget.non-native-theme.enabled=false", +] + +["test_about_networking.html"] + +["test_arrowpanel.xhtml"] +skip-if = [ + "os == 'win' && verify", + "win10_2009", # Bug 1727507 + "win11_2009", # Bug 1797751 +] + +["test_autocomplete2.xhtml"] + +["test_autocomplete3.xhtml"] + +["test_autocomplete4.xhtml"] + +["test_autocomplete5.xhtml"] + +["test_autocomplete_emphasis.xhtml"] + +["test_autocomplete_mac_caret.xhtml"] +run-if = ["os == 'mac'"] + +["test_autocomplete_placehold_last_complete.xhtml"] + +["test_autocomplete_with_composition_on_input.html"] +skip-if = [ + "apple_catalina" # Bug 1784825 +] +["test_browser_drop.xhtml"] + +["test_bug1048178.xhtml"] +skip-if = ["apple_catalina"] + +["test_bug263683.xhtml"] +skip-if = [ + "debug && os == 'linux'", + "debug && os == 'win'", +] + +["test_bug304188.xhtml"] +skip-if = ["true"] + +["test_bug331215.xhtml"] +skip-if = ["true"] # Bug 1339326 #Bug 1582327 + +["test_bug360220.xhtml"] + +["test_bug360437.xhtml"] +skip-if = ["true"] # Bug 1264604 # Bug 1784826 + +["test_bug365773.xhtml"] + +["test_bug366992.xhtml"] + +["test_bug382990.xhtml"] + +["test_bug409624.xhtml"] + +["test_bug418874.xhtml"] + +["test_bug429723.xhtml"] + +["test_bug451540.xhtml"] +support-files = ["bug451540_window.xhtml"] + +["test_bug457632.xhtml"] + +["test_bug460942.xhtml"] + +["test_bug471776.xhtml"] + +["test_bug509732.xhtml"] + +["test_bug557987.xhtml"] + +["test_bug562554.xhtml"] + +["test_bug624329.xhtml"] +fail-if = ["os == 'linux' && os_version == '18.04'"] # Bug 1600194 + +["test_bug792324.xhtml"] + +["test_button.xhtml"] + +["test_chromemargin.xhtml"] +support-files = "window_chromemargin.xhtml" +skip-if = ["apple_catalina"] + +["test_closemenu_attribute.xhtml"] + +["test_contextmenu_list.xhtml"] + +["test_contextmenu_rtl.xhtml"] + +["test_cursorsnap.xhtml"] +disabled = true +#skip-if = os != 'win' +support-files = [ + "window_cursorsnap_dialog.xhtml", + "window_cursorsnap_wizard.xhtml", +] + +["test_custom_element_base.xhtml"] + +["test_custom_element_delay_connection.xhtml"] + +["test_custom_element_parts.html"] + +["test_deck.xhtml"] + +["test_dialog_button.xhtml"] + +["test_dialogfocus.xhtml"] + +["test_edit_contextmenu.html"] + +["test_editor_for_input_with_autocomplete.html"] + +["test_findbar.xhtml"] +skip-if = ["apple_catalina"] # macosx1014/15 due to 1550078 + +["test_findbar_entireword.xhtml"] + +["test_findbar_events.xhtml"] + +["test_frames.xhtml"] + +["test_hiddenitems.xhtml"] + +["test_hiddenpaging.xhtml"] + +["test_keys.xhtml"] + +["test_labelcontrol.xhtml"] + +["test_largemenu.html"] +skip-if = ["os == 'linux' && !debug"] # Bug 1207174 + +["test_maximized_persist.xhtml"] +support-files = [ + "window_maximized_persist.xhtml", + "file_maximized_persist.js", +] + +["test_maximized_persist_with_no_titlebar.xhtml"] +support-files = [ + "window_maximized_persist_with_no_titlebar.xhtml", + "file_maximized_persist.js", +] + +["test_menu.xhtml"] + +["test_menu_activateitem.xhtml"] + +["test_menu_hide.xhtml"] + +["test_menu_mouse_menuactive.xhtml"] + +["test_menu_withcapture.xhtml"] + +["test_menuchecks.xhtml"] + +["test_menuitem_blink.xhtml"] + +["test_menuitem_commands.xhtml"] + +["test_menulist.xhtml"] + +["test_menulist_in_popup.xhtml"] + +["test_menulist_keynav.xhtml"] + +["test_menulist_null_value.xhtml"] + +["test_menulist_paging.xhtml"] + +["test_menulist_position.xhtml"] + +["test_mousescroll.xhtml"] + +["test_mozinputbox_dictionary.xhtml"] + +["test_named_deck.html"] + +["test_navigate_persist.html"] +support-files = ["window_navigate_persist.html"] + +["test_notificationbox.xhtml"] +skip-if = [ + "os == 'linux' && debug", # Bug 1429649 + "os == 'win'", # Bug 1429649 +] + +["test_panel.xhtml"] +skip-if = ["apple_catalina"] # macosx1014 due to 1550078 + +["test_panel_anchoradjust.xhtml"] + +# test_panel_focus.xhtml won't work if the Full Keyboard Access preference is set to +# textboxes and lists only, so skip this test on Mac +["test_panel_focus.xhtml"] +support-files = "window_panel_focus.xhtml" +skip-if = ["apple_catalina"] + +["test_panel_hover_menu.xhtml"] + +["test_panel_open.xhtml"] + +["test_panelfrommenu.xhtml"] + +["test_popup_anchor.xhtml"] + +["test_popup_anchoratrect.xhtml"] +skip-if = ["os == 'linux'"] # 1167694 + +["test_popup_attribute.xhtml"] +skip-if = [ + "os == 'linux' && os_version == '18.04' && asan", # Bug 1582610 + "apple_catalina && debug", # Bug 1281360 +] + +["test_popup_button.xhtml"] +skip-if = [ + "os == 'linux' && os_version == '18.04' && asan", # Bug 1582610 + "apple_catalina && debug", # Bug 1281360 +] + +["test_popup_coords.xhtml"] + +["test_popup_keys.xhtml"] + +["test_popup_moveToAnchor.xhtml"] + +["test_popup_preventdefault.xhtml"] + +["test_popup_preventdefault_chrome.xhtml"] + +["test_popup_recreate.xhtml"] + +["test_popup_scaled.xhtml"] + +["test_popup_tree.xhtml"] + +["test_popuphidden.xhtml"] + +["test_popupincontent.xhtml"] +skip-if = ["verify && os == 'win'"] + +["test_popupremoving.xhtml"] + +["test_position.xhtml"] + +["test_preferences.xhtml"] + +["test_preferences_beforeaccept.xhtml"] +support-files = ["window_preferences_beforeaccept.xhtml"] + +["test_preferences_onsyncfrompreference.xhtml"] +support-files = ["window_preferences_onsyncfrompreference.xhtml"] + +["test_props.xhtml"] + +["test_radio.xhtml"] + +["test_richlistbox.xhtml"] + +["test_screenPersistence.xhtml"] + +["test_scrollbar.xhtml"] + +["test_showcaret.xhtml"] + +["test_subframe_origin.xhtml"] + +["test_tabbox.xhtml"] + +["test_tabindex.xhtml"] + +["test_textbox_search.xhtml"] + +["test_tooltip.xhtml"] +skip-if = [ + "apple_catalina", # Bug 1141245, frequent timeouts on OSX 10.14, Windows + "os == 'win'", # Bug 1141245, frequent timeouts on OSX 10.14, Windows +] + +["test_tooltip_noautohide.xhtml"] + +["test_tree.xhtml"] + +["test_tree_hier.xhtml"] + +["test_tree_scroll.xhtml"] +support-files = [ + "!/gfx/layers/apz/test/mochitest/apz_test_utils.js", + "!/gfx/layers/apz/test/mochitest/apz_test_native_event_utils.js", +] + +["test_tree_single.xhtml"] + +["test_tree_view.xhtml"] + +["test_window_intrinsic_size.xhtml"] +support-files = ["window_intrinsic_size.xhtml"] diff --git a/toolkit/content/tests/chrome/dialog_button.xhtml b/toolkit/content/tests/chrome/dialog_button.xhtml new file mode 100644 index 0000000000..64bf916fff --- /dev/null +++ b/toolkit/content/tests/chrome/dialog_button.xhtml @@ -0,0 +1,9 @@ +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<window id='root' xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog id="dialog-focus" + buttons="accept" + buttonlabelaccept="accept" + buttonaccesskeyaccept="a"> + <button id="button"></button> + </dialog> +</window> diff --git a/toolkit/content/tests/chrome/dialog_dialogfocus.xhtml b/toolkit/content/tests/chrome/dialog_dialogfocus.xhtml new file mode 100644 index 0000000000..bcd303b52f --- /dev/null +++ b/toolkit/content/tests/chrome/dialog_dialogfocus.xhtml @@ -0,0 +1,62 @@ +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> +<dialog id="dialog-focus" + buttons="extra2,accept,cancel"> + +<tabbox id="tabbox" hidden="true"> + <tabs> + <tab id="tab" label="Tab"/> + </tabs> + <tabpanels> + <tabpanel> + <button id="tabbutton" label="Tab Button"/> + <button id="tabbutton2" label="Tab Button 2"/> + </tabpanel> + </tabpanels> +</tabbox> + +<html:input id="textbox-yes" value="textbox-yes" hidden="true"/> +<html:input id="textbox-no" value="textbox-no" noinitialfocus="true" hidden="true"/> +<button id="one" label="One"/> +<button id="two" label="Two" hidden="true"/> + +<script> +if (window.arguments) { + var step = window.arguments[0]; + switch (step) { + case 2: + document.getElementById("one").setAttribute("noinitialfocus", "true"); + break; + case 3: + document.getElementById("one").hidden = true; + // no-fallthrough + case 4: + document.getElementById("tabbutton2").setAttribute("noinitialfocus", "true"); + // no-fallthrough + case 5: + document.getElementById("tabbutton").setAttribute("noinitialfocus", "true"); + // no-fallthrough + case 6: + document.getElementById("tabbox").hidden = false; + break; + case 7: + window.addEventListener("load", function() { + var two = document.getElementById("two"); + two.hidden = false; + two.focus(); + }); + break; + case 8: + document.getElementById("textbox-yes").hidden = false; + break; + case 9: + document.getElementById("textbox-no").hidden = false; + break; + } +} +</script> + +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/dialog_dialogfocus2.xhtml b/toolkit/content/tests/chrome/dialog_dialogfocus2.xhtml new file mode 100644 index 0000000000..da4b3dbdfa --- /dev/null +++ b/toolkit/content/tests/chrome/dialog_dialogfocus2.xhtml @@ -0,0 +1,8 @@ +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window id="root" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<dialog id="dialog-focus" + buttons="none"> + <button id="nonbutton" noinitialfocus="true"/> +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/file_about_networking_wsh.py b/toolkit/content/tests/chrome/file_about_networking_wsh.py new file mode 100644 index 0000000000..57bd353c21 --- /dev/null +++ b/toolkit/content/tests/chrome/file_about_networking_wsh.py @@ -0,0 +1,10 @@ +from mod_pywebsocket import msgutil + + +def web_socket_do_extra_handshake(request): + pass + + +def web_socket_transfer_data(request): + while not request.client_terminated: + msgutil.receive_message(request) diff --git a/toolkit/content/tests/chrome/file_autocomplete_with_composition.js b/toolkit/content/tests/chrome/file_autocomplete_with_composition.js new file mode 100644 index 0000000000..86619a95d1 --- /dev/null +++ b/toolkit/content/tests/chrome/file_autocomplete_with_composition.js @@ -0,0 +1,714 @@ +// nsDoTestsForAutoCompleteWithComposition tests autocomplete with composition. +// Users must include SimpleTest.js and EventUtils.js. + +function waitForCondition(condition, nextTest) { + var tries = 0; + var interval = setInterval(function () { + if (condition() || tries >= 30) { + moveOn(); + } + tries++; + }, 100); + var moveOn = function () { + clearInterval(interval); + nextTest(); + }; +} + +function nsDoTestsForAutoCompleteWithComposition( + aDescription, + aWindow, + aTarget, + aAutoCompleteController, + aIsFunc, + aGetTargetValueFunc, + aOnFinishFunc +) { + this._description = aDescription; + this._window = aWindow; + this._target = aTarget; + this._controller = aAutoCompleteController; + + this._is = aIsFunc; + this._getTargetValue = aGetTargetValueFunc; + this._onFinish = aOnFinishFunc; + + this._target.focus(); + + this._DefaultCompleteDefaultIndex = + this._controller.input.completeDefaultIndex; + + this._doTests(); +} + +nsDoTestsForAutoCompleteWithComposition.prototype = { + _window: null, + _target: null, + _controller: null, + _DefaultCompleteDefaultIndex: false, + _description: "", + + _is: null, + _getTargetValue() { + return "not initialized"; + }, + _onFinish: null, + + _doTests() { + if (++this._testingIndex == this._tests.length) { + this._controller.input.completeDefaultIndex = + this._DefaultCompleteDefaultIndex; + this._onFinish(); + return; + } + + var test = this._tests[this._testingIndex]; + if ( + this._controller.input.completeDefaultIndex != test.completeDefaultIndex + ) { + this._controller.input.completeDefaultIndex = test.completeDefaultIndex; + } + test.execute(this._window); + + if (test.popup) { + waitForCondition( + () => this._controller.input.popupOpen, + this._checkResult.bind(this) + ); + } else { + waitForCondition(() => { + this._controller.searchStatus >= + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH; + }, this._checkResult.bind(this)); + } + }, + + _checkResult() { + var test = this._tests[this._testingIndex]; + this._is( + this._getTargetValue(), + test.value, + this._description + ", " + test.description + ": value" + ); + this._is( + this._controller.searchString, + test.searchString, + this._description + ", " + test.description + ": searchString" + ); + this._is( + this._controller.input.popupOpen, + test.popup, + this._description + ", " + test.description + ": popupOpen" + ); + this._doTests(); + }, + + _testingIndex: -1, + _tests: [ + // Simple composition when popup hasn't been shown. + // The autocomplete popup should not be shown during composition, but + // after compositionend, the popup should be shown. + { + description: "compositionstart shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "M", + clauses: [{ length: 1, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 1, length: 0 }, + key: { key: "M" }, + }, + aWindow + ); + }, + popup: false, + value: "M", + searchString: "", + }, + { + description: "modifying composition string shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "Mo", + clauses: [{ length: 2, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 2, length: 0 }, + key: { key: "o" }, + }, + aWindow + ); + }, + popup: false, + value: "Mo", + searchString: "", + }, + { + description: "compositionend should open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeComposition( + { type: "compositioncommitasis", key: { key: "KEY_Enter" } }, + aWindow + ); + }, + popup: true, + value: "Mo", + searchString: "Mo", + }, + // If composition starts when popup is shown, the compositionstart event + // should cause closing the popup. + { + description: "compositionstart should close the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "z", + clauses: [{ length: 1, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 1, length: 0 }, + key: { key: "z" }, + }, + aWindow + ); + }, + popup: false, + value: "Moz", + searchString: "Mo", + }, + { + description: "modifying composition string shouldn't reopen the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "zi", + clauses: [{ length: 2, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 2, length: 0 }, + key: { key: "i" }, + }, + aWindow + ); + }, + popup: false, + value: "Mozi", + searchString: "Mo", + }, + { + description: + "compositionend should research the result and open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeComposition( + { type: "compositioncommitasis", key: { key: "KEY_Enter" } }, + aWindow + ); + }, + popup: true, + value: "Mozi", + searchString: "Mozi", + }, + // If composition is cancelled, the value shouldn't be changed. + { + description: "compositionstart should reclose the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "l", + clauses: [{ length: 1, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 1, length: 0 }, + key: { key: "l" }, + }, + aWindow + ); + }, + popup: false, + value: "Mozil", + searchString: "Mozi", + }, + { + description: "modifying composition string shouldn't reopen the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "ll", + clauses: [{ length: 2, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 2, length: 0 }, + key: { key: "l" }, + }, + aWindow + ); + }, + popup: false, + value: "Mozill", + searchString: "Mozi", + }, + { + description: + "modifying composition string to empty string shouldn't reopen the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { string: "", clauses: [{ length: 0, attr: 0 }] }, + caret: { start: 0, length: 0 }, + key: { key: "KEY_Backspace" }, + }, + aWindow + ); + }, + popup: false, + value: "Mozi", + searchString: "Mozi", + }, + { + description: "cancled compositionend should reopen the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeComposition( + { type: "compositioncommit", data: "", key: { key: "KEY_Escape" } }, + aWindow + ); + }, + popup: true, + value: "Mozi", + searchString: "Mozi", + }, + // But if composition replaces some characters and canceled, the search + // string should be the latest value. + { + description: + "compositionstart with selected string should close the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeKey("VK_LEFT", { shiftKey: true }, aWindow); + synthesizeKey("VK_LEFT", { shiftKey: true }, aWindow); + synthesizeCompositionChange( + { + composition: { + string: "z", + clauses: [{ length: 1, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 1, length: 0 }, + key: { key: "z" }, + }, + aWindow + ); + }, + popup: false, + value: "Moz", + searchString: "Mozi", + }, + { + description: "modifying composition string shouldn't reopen the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "zi", + clauses: [{ length: 2, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 2, length: 0 }, + key: { key: "i" }, + }, + aWindow + ); + }, + popup: false, + value: "Mozi", + searchString: "Mozi", + }, + { + description: + "modifying composition string to empty string shouldn't reopen the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { string: "", clauses: [{ length: 0, attr: 0 }] }, + caret: { start: 0, length: 0 }, + key: { key: "KEY_Backspace" }, + }, + aWindow + ); + }, + popup: false, + value: "Mo", + searchString: "Mozi", + }, + { + description: + "canceled compositionend should search the result with the latest value", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeComposition( + { type: "compositioncommitasis", key: { key: "KEY_Escape" } }, + aWindow + ); + }, + popup: true, + value: "Mo", + searchString: "Mo", + }, + // If all characters are removed, the popup should be closed. + { + description: + "the value becomes empty by backspace, the popup should be closed", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeKey("KEY_Backspace", {}, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + }, + popup: false, + value: "", + searchString: "", + }, + // composition which is canceled shouldn't cause opening the popup. + { + description: "compositionstart shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "M", + clauses: [{ length: 1, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 1, length: 0 }, + key: { key: "M" }, + }, + aWindow + ); + }, + popup: false, + value: "M", + searchString: "", + }, + { + description: "modifying composition string shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "Mo", + clauses: [{ length: 2, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 2, length: 0 }, + key: { key: "o" }, + }, + aWindow + ); + }, + popup: false, + value: "Mo", + searchString: "", + }, + { + description: + "modifying composition string to empty string shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { string: "", clauses: [{ length: 0, attr: 0 }] }, + caret: { start: 0, length: 0 }, + key: { key: "KEY_Backspace" }, + }, + aWindow + ); + }, + popup: false, + value: "", + searchString: "", + }, + { + description: + "canceled compositionend shouldn't open the popup if it was closed", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeComposition( + { type: "compositioncommitasis", key: { key: "KEY_Escape" } }, + aWindow + ); + }, + popup: false, + value: "", + searchString: "", + }, + // Down key should open the popup even if the editor is empty. + { + description: "DOWN key should open the popup even if the value is empty", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeKey("KEY_ArrowDown", {}, aWindow); + }, + popup: true, + value: "", + searchString: "", + }, + // If popup is open at starting composition, the popup should be reopened + // after composition anyway. + { + description: "compositionstart shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "M", + clauses: [{ length: 1, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 1, length: 0 }, + key: { key: "M" }, + }, + aWindow + ); + }, + popup: false, + value: "M", + searchString: "", + }, + { + description: "modifying composition string shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "Mo", + clauses: [{ length: 2, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 2, length: 0 }, + key: { key: "o" }, + }, + aWindow + ); + }, + popup: false, + value: "Mo", + searchString: "", + }, + { + description: + "modifying composition string to empty string shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { string: "", clauses: [{ length: 0, attr: 0 }] }, + caret: { start: 0, length: 0 }, + key: { key: "KEY_Backspace" }, + }, + aWindow + ); + }, + popup: false, + value: "", + searchString: "", + }, + { + description: + "canceled compositionend should open the popup if it was opened", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeComposition( + { type: "compositioncommitasis", key: { key: "KEY_Escape" } }, + aWindow + ); + }, + popup: true, + value: "", + searchString: "", + }, + // Type normally, and hit escape, the popup should be closed. + { + description: "ESCAPE should close the popup after typing something", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeKey("M", {}, aWindow); + synthesizeKey("o", {}, aWindow); + synthesizeKey("KEY_Escape", {}, aWindow); + }, + popup: false, + value: "Mo", + searchString: "Mo", + }, + // Even if the popup is closed, composition which is canceled should open + // the popup if the value isn't empty. + // XXX This might not be good behavior, but anyway, this is minor issue... + { + description: "compositionstart shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "z", + clauses: [{ length: 1, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 1, length: 0 }, + key: { key: "z" }, + }, + aWindow + ); + }, + popup: false, + value: "Moz", + searchString: "Mo", + }, + { + description: "modifying composition string shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "zi", + clauses: [{ length: 2, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 2, length: 0 }, + key: { key: "i" }, + }, + aWindow + ); + }, + popup: false, + value: "Mozi", + searchString: "Mo", + }, + { + description: + "modifying composition string to empty string shouldn't open the popup", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { string: "", clauses: [{ length: 0, attr: 0 }] }, + caret: { start: 0, length: 0 }, + key: { key: "KEY_Backspace" }, + }, + aWindow + ); + }, + popup: false, + value: "Mo", + searchString: "Mo", + }, + { + description: + "canceled compositionend shouldn't open the popup if the popup was closed", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeComposition( + { type: "compositioncommitasis", key: { key: "KEY_Escape" } }, + aWindow + ); + }, + popup: true, + value: "Mo", + searchString: "Mo", + }, + // House keeping... + { + description: "house keeping for next tests", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeKey("KEY_Backspace", {}, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + }, + popup: false, + value: "", + searchString: "", + }, + // Testing for nsIAutoCompleteInput.completeDefaultIndex being true. + { + description: + "compositionstart shouldn't open the popup (completeDefaultIndex is true)", + completeDefaultIndex: true, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "M", + clauses: [{ length: 1, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 1, length: 0 }, + key: { key: "M" }, + }, + aWindow + ); + }, + popup: false, + value: "M", + searchString: "", + }, + { + description: + "modifying composition string shouldn't open the popup (completeDefaultIndex is true)", + completeDefaultIndex: true, + execute(aWindow) { + synthesizeCompositionChange( + { + composition: { + string: "Mo", + clauses: [{ length: 2, attr: COMPOSITION_ATTR_RAW_CLAUSE }], + }, + caret: { start: 2, length: 0 }, + key: { key: "o" }, + }, + aWindow + ); + }, + popup: false, + value: "Mo", + searchString: "", + }, + { + description: + "compositionend should open the popup (completeDefaultIndex is true)", + completeDefaultIndex: true, + execute(aWindow) { + synthesizeComposition( + { type: "compositioncommitasis", key: { key: "KEY_Enter" } }, + aWindow + ); + }, + popup: true, + value: "Mozilla", + searchString: "Mo", + }, + // House keeping... + { + description: "house keeping for next tests", + completeDefaultIndex: false, + execute(aWindow) { + synthesizeKey("KEY_Backspace", {}, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + }, + popup: false, + value: "", + searchString: "", + }, + ], +}; diff --git a/toolkit/content/tests/chrome/file_edit_contextmenu.xhtml b/toolkit/content/tests/chrome/file_edit_contextmenu.xhtml new file mode 100644 index 0000000000..e4d1a79040 --- /dev/null +++ b/toolkit/content/tests/chrome/file_edit_contextmenu.xhtml @@ -0,0 +1,46 @@ +<?xml version="1.0" encoding="UTF-8"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> +<script> +customElements.define("shadow-input", class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(document.createElement("input")); + } +}); +</script> +<script type="application/javascript" src="chrome://global/content/globalOverlay.js"/> +<!-- Copied from toolkit/content/editMenuCommands.inc.xul --> +<script type="application/javascript" src="chrome://global/content/editMenuOverlay.js"/> +<commandset id="editMenuCommands"> + <commandset id="editMenuCommandSetAll" commandupdater="true" events="focus,select" + oncommandupdate="goUpdateGlobalEditMenuItems()"/> + <commandset id="editMenuCommandSetUndo" commandupdater="true" events="undo" + oncommandupdate="goUpdateUndoEditMenuItems()"/> + <commandset id="editMenuCommandSetPaste" commandupdater="true" events="clipboard" + oncommandupdate="goUpdatePasteMenuItems()"/> + <command id="cmd_undo" oncommand="goDoCommand('cmd_undo')"/> + <command id="cmd_redo" oncommand="goDoCommand('cmd_redo')"/> + <command id="cmd_cut" oncommand="goDoCommand('cmd_cut')"/> + <command id="cmd_copy" oncommand="goDoCommand('cmd_copy')"/> + <command id="cmd_paste" oncommand="goDoCommand('cmd_paste')"/> + <command id="cmd_delete" oncommand="goDoCommand('cmd_delete')"/> + <command id="cmd_selectAll" oncommand="goDoCommand('cmd_selectAll')"/> + <command id="cmd_switchTextDirection" oncommand="goDoCommand('cmd_switchTextDirection');"/> +</commandset> + +<menupopup id="outer-context-menu"> + <menuseparator id="customizeMailToolbarMenuSeparator"/> + <menuitem id="hello" label="Hello" accesskey="H"/> +</menupopup> + +<hbox context="outer-context-menu"> +<html:textarea /> +<html:input /> +<search-textbox /> +<html:shadow-input /> +</hbox> + +</window> diff --git a/toolkit/content/tests/chrome/file_editor_with_autocomplete.js b/toolkit/content/tests/chrome/file_editor_with_autocomplete.js new file mode 100644 index 0000000000..78611efc70 --- /dev/null +++ b/toolkit/content/tests/chrome/file_editor_with_autocomplete.js @@ -0,0 +1,627 @@ +// nsDoTestsForEditorWithAutoComplete tests basic functions of editor with autocomplete. +// Users must include SimpleTest.js and EventUtils.js, and register "Mozilla" to the autocomplete for the target. + +async function waitForCondition(condition) { + return new Promise(resolve => { + var tries = 0; + var interval = setInterval(function () { + if (condition() || tries >= 60) { + moveOn(); + } + tries++; + }, 100); + var moveOn = function () { + clearInterval(interval); + resolve(); + }; + }); +} + +function nsDoTestsForEditorWithAutoComplete( + aDescription, + aWindow, + aTarget, + aAutoCompleteController, + aIsFunc, + aTodoIsFunc, + aGetTargetValueFunc +) { + this._description = aDescription; + this._window = aWindow; + this._target = aTarget; + this._controller = aAutoCompleteController; + + this._is = aIsFunc; + this._todo_is = aTodoIsFunc; + this._getTargetValue = aGetTargetValueFunc; + + this._target.focus(); + + this._DefaultCompleteDefaultIndex = + this._controller.input.completeDefaultIndex; +} + +nsDoTestsForEditorWithAutoComplete.prototype = { + _window: null, + _target: null, + _controller: null, + _DefaultCompleteDefaultIndex: false, + _description: "", + + _is: null, + _getTargetValue() { + return "not initialized"; + }, + + run: async function runTestsImpl() { + for (let test of this._tests) { + if ( + this._controller.input.completeDefaultIndex != test.completeDefaultIndex + ) { + this._controller.input.completeDefaultIndex = test.completeDefaultIndex; + } + + let beforeInputEvents = []; + let inputEvents = []; + function onBeforeInput(aEvent) { + beforeInputEvents.push(aEvent); + } + function onInput(aEvent) { + inputEvents.push(aEvent); + } + this._target.addEventListener("beforeinput", onBeforeInput); + this._target.addEventListener("input", onInput); + + if (test.execute(this._window, this._target) === false) { + this._target.removeEventListener("beforeinput", onBeforeInput); + this._target.removeEventListener("input", onInput); + continue; + } + + await waitForCondition(() => { + return ( + this._controller.searchStatus >= + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH + ); + }); + this._target.removeEventListener("beforeinput", onBeforeInput); + this._target.removeEventListener("input", onInput); + this._checkResult(test, beforeInputEvents, inputEvents); + } + this._controller.input.completeDefaultIndex = + this._DefaultCompleteDefaultIndex; + }, + + _checkResult(aTest, aBeforeInputEvents, aInputEvents) { + this._is( + this._getTargetValue(), + aTest.value, + this._description + ", " + aTest.description + ": value" + ); + this._is( + this._controller.searchString, + aTest.searchString, + this._description + ", " + aTest.description + ": searchString" + ); + this._is( + this._controller.input.popupOpen, + aTest.popup, + this._description + ", " + aTest.description + ": popupOpen" + ); + this._is( + this._controller.searchStatus, + Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH, + this._description + ", " + aTest.description + ": status" + ); + this._is( + aBeforeInputEvents.length, + aTest.inputEvents.length, + this._description + + ", " + + aTest.description + + ": number of beforeinput events wrong" + ); + this._is( + aInputEvents.length, + aTest.inputEvents.length, + this._description + + ", " + + aTest.description + + ": number of input events wrong" + ); + for (let events of [aBeforeInputEvents, aInputEvents]) { + for (let i = 0; i < events.length; i++) { + if (aTest.inputEvents[i] === undefined) { + this._is( + true, + false, + this._description + + ", " + + aTest.description + + ': "beforeinput" and "input" event shouldn\'t be dispatched anymore' + ); + return; + } + this._is( + events[i] instanceof this._window.InputEvent, + true, + `${this._description}, ${aTest.description}: "${events[i].type}" event should be dispatched with InputEvent interface` + ); + let expectCancelable = + events[i].type === "beforeinput" && + (aTest.inputEvents[i].inputType !== "insertReplacementText" || + SpecialPowers.getBoolPref( + "dom.input_event.allow_to_cancel_set_user_input" + )); + + this._is( + events[i].cancelable, + expectCancelable, + `${this._description}, ${aTest.description}: "${ + events[i].type + }" event should ${expectCancelable ? "be" : "be never"} cancelable` + ); + this._is( + events[i].bubbles, + true, + `${this._description}, ${aTest.description}: "${events[i].type}" event should always bubble` + ); + this._is( + events[i].inputType, + aTest.inputEvents[i].inputType, + `${this._description}, ${aTest.description}: inputType of "${events[i].type}" event should be "${aTest.inputEvents[i].inputType}"` + ); + this._is( + events[i].data, + aTest.inputEvents[i].data, + `${this._description}, ${aTest.description}: data of "${events[i].type}" event should be ${aTest.inputEvents[i].data}` + ); + this._is( + events[i].dataTransfer, + null, + `${this._description}, ${aTest.description}: dataTransfer of "${events[i].type}" event should be null` + ); + this._is( + events[i].getTargetRanges().length, + 0, + `${this._description}, ${aTest.description}: getTargetRanges() of "${events[i].type}" event should return empty array` + ); + } + } + }, + + _tests: [ + { + description: + "Undo/Redo behavior check when typed text exactly matches the case: type 'Mo'", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("M", { shiftKey: true }, aWindow); + synthesizeKey("o", {}, aWindow); + return true; + }, + popup: true, + value: "Mo", + searchString: "Mo", + inputEvents: [ + { inputType: "insertText", data: "M" }, + { inputType: "insertText", data: "o" }, + ], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case: select 'Mozilla' to complete the word", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("KEY_ArrowDown", {}, aWindow); + synthesizeKey("KEY_Enter", {}, aWindow); + return true; + }, + popup: false, + value: "Mozilla", + searchString: "Mozilla", + inputEvents: [{ inputType: "insertReplacementText", data: "Mozilla" }], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case: undo the word, but typed text shouldn't be canceled", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: true, + value: "Mo", + searchString: "Mo", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case: undo the typed text", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: false, + value: "", + searchString: "", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case: redo the typed text", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "Mo", + searchString: "Mo", + inputEvents: [{ inputType: "historyRedo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case: redo the word", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "Mozilla", + searchString: "Mozilla", + inputEvents: [{ inputType: "historyRedo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case: removing all text for next test...", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("a", { accelKey: true }, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + return true; + }, + popup: false, + value: "", + searchString: "", + inputEvents: [{ inputType: "deleteContentBackward", data: null }], + }, + + { + description: + "Undo/Redo behavior check when typed text does not match the case: type 'mo'", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("m", {}, aWindow); + synthesizeKey("o", {}, aWindow); + return true; + }, + popup: true, + value: "mo", + searchString: "mo", + inputEvents: [ + { inputType: "insertText", data: "m" }, + { inputType: "insertText", data: "o" }, + ], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case: select 'Mozilla' to complete the word", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("KEY_ArrowDown", {}, aWindow); + synthesizeKey("KEY_Enter", {}, aWindow); + return true; + }, + popup: false, + value: "Mozilla", + searchString: "Mozilla", + inputEvents: [{ inputType: "insertReplacementText", data: "Mozilla" }], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case: undo the word, but typed text shouldn't be canceled", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: true, + value: "mo", + searchString: "mo", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case: undo the typed text", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: false, + value: "", + searchString: "", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case: redo the typed text", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "mo", + searchString: "mo", + inputEvents: [{ inputType: "historyRedo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case: redo the word", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "Mozilla", + searchString: "Mozilla", + inputEvents: [{ inputType: "historyRedo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case: removing all text for next test...", + completeDefaultIndex: false, + execute(aWindow, aTarget) { + synthesizeKey("a", { accelKey: true }, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + return true; + }, + popup: false, + value: "", + searchString: "", + inputEvents: [{ inputType: "deleteContentBackward", data: null }], + }, + + // Testing for nsIAutoCompleteInput.completeDefaultIndex being true. + { + description: + "Undo/Redo behavior check when typed text exactly matches the case (completeDefaultIndex is true): type 'Mo'", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("M", { shiftKey: true }, aWindow); + synthesizeKey("o", {}, aWindow); + return true; + }, + popup: true, + value: "Mozilla", + searchString: "Mo", + inputEvents: [ + { inputType: "insertText", data: "M" }, + { inputType: "insertText", data: "o" }, + { inputType: "insertReplacementText", data: "Mozilla" }, + ], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case (completeDefaultIndex is true): select 'Mozilla' to complete the word", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("KEY_ArrowDown", {}, aWindow); + synthesizeKey("KEY_Enter", {}, aWindow); + return true; + }, + popup: false, + value: "Mozilla", + searchString: "Mozilla", + inputEvents: [], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case (completeDefaultIndex is true): undo the word, but typed text shouldn't be canceled", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: true, + value: "Mo", + searchString: "Mo", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case (completeDefaultIndex is true): undo the typed text", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: false, + value: "", + searchString: "", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case (completeDefaultIndex is true): redo the typed text", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "Mozilla", + searchString: "Mo", + inputEvents: [ + { inputType: "historyRedo", data: null }, + { inputType: "insertReplacementText", data: "Mozilla" }, + ], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case (completeDefaultIndex is true): redo the word", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "Mozilla", + searchString: "Mo", + inputEvents: [], + }, + { + description: + "Undo/Redo behavior check when typed text exactly matches the case (completeDefaultIndex is true): removing all text for next test...", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("a", { accelKey: true }, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + return true; + }, + popup: false, + value: "", + searchString: "", + inputEvents: [{ inputType: "deleteContentBackward", data: null }], + }, + + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): type 'mo'", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("m", {}, aWindow); + synthesizeKey("o", {}, aWindow); + return true; + }, + popup: true, + value: "mozilla", + searchString: "mo", + inputEvents: [ + { inputType: "insertText", data: "m" }, + { inputType: "insertText", data: "o" }, + { inputType: "insertReplacementText", data: "mozilla" }, + ], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): select 'Mozilla' to complete the word", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("KEY_ArrowDown", {}, aWindow); + synthesizeKey("KEY_Enter", {}, aWindow); + return true; + }, + popup: false, + value: "Mozilla", + searchString: "Mozilla", + inputEvents: [{ inputType: "insertReplacementText", data: "Mozilla" }], + }, + // Different from "exactly matches the case" case, modifying the case causes one additional transaction. + // Although we could make this transaction ignored. + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): undo the selected word, but typed text shouldn't be canceled", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: true, + value: "mozilla", + searchString: "mozilla", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): undo the word, but typed text shouldn't be canceled", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: true, + value: "mo", + searchString: "mo", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): undo the typed text", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("z", { accelKey: true }, aWindow); + return true; + }, + popup: false, + value: "", + searchString: "", + inputEvents: [{ inputType: "historyUndo", data: null }], + }, + // XXX This is odd case. Consistency with undo behavior, this should restore "mo". + // However, looks like that autocomplete automatically restores "mozilla". + // Additionally, looks like that it causes clearing the redo stack. + // Therefore, the following redo operations do nothing. + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): redo the typed text", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "mozilla", + searchString: "mo", + inputEvents: [ + { inputType: "historyRedo", data: null }, + { inputType: "insertReplacementText", data: "mozilla" }, + ], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): redo the default index word", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "mozilla", + searchString: "mo", + inputEvents: [], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): redo the word", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("Z", { accelKey: true, shiftKey: true }, aWindow); + return true; + }, + popup: true, + value: "mozilla", + searchString: "mo", + inputEvents: [], + }, + { + description: + "Undo/Redo behavior check when typed text does not match the case (completeDefaultIndex is true): removing all text for next test...", + completeDefaultIndex: true, + execute(aWindow, aTarget) { + synthesizeKey("a", { accelKey: true }, aWindow); + synthesizeKey("KEY_Backspace", {}, aWindow); + return true; + }, + popup: false, + value: "", + searchString: "", + inputEvents: [{ inputType: "deleteContentBackward", data: null }], + }, + ], +}; diff --git a/toolkit/content/tests/chrome/file_empty.xhtml b/toolkit/content/tests/chrome/file_empty.xhtml new file mode 100644 index 0000000000..cda0d080bd --- /dev/null +++ b/toolkit/content/tests/chrome/file_empty.xhtml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<html xmlns='http://www.w3.org/1999/xhtml'> +</html>
\ No newline at end of file diff --git a/toolkit/content/tests/chrome/file_maximized_persist.js b/toolkit/content/tests/chrome/file_maximized_persist.js new file mode 100644 index 0000000000..c0d543728b --- /dev/null +++ b/toolkit/content/tests/chrome/file_maximized_persist.js @@ -0,0 +1,141 @@ +SimpleTest.waitForExplicitFinish(); +const WIDTH = 300; +const HEIGHT = 300; +let gWindow; +let gTitlebar; + +function promiseMessage(msg) { + info(`wait for message "${msg}"`); + return new Promise(resolve => { + function listener(evt) { + info(`got message "${evt.data}"`); + if (evt.data == msg) { + window.removeEventListener("message", listener); + resolve(); + } + } + window.addEventListener("message", listener); + }); +} + +function openWindow(features = "") { + return window.browsingContext.topChromeWindow.openDialog( + gWindow, + "_blank", + "chrome,dialog=no,all," + features, + window + ); +} + +function checkWindow(msg, win, sizemode, width, height) { + is(win.windowState, sizemode, "sizemode should match " + msg); + if (sizemode == win.STATE_NORMAL) { + is(win.innerWidth, width, "width should match " + msg); + is(win.innerHeight, height, "height should match " + msg); + } +} + +function todoCheckWindow(msg, win, sizemode) { + todo_is(win.windowState, sizemode, "sizemode should match " + msg); +} + +// Persistence of "sizemode" is delayed to 500ms after it's changed. +// See SIZE_PERSISTENCE_TIMEOUT in nsWebShellWindow.cpp. +// We wait for 1000ms to ensure that it is actually persisted. +// We can also wait for condition that XULStore does have the value +// set, but that way we cannot test the cases where we don't expect +// persistence to happen. +function waitForSizeModePersisted() { + return new Promise(resolve => { + setTimeout(resolve, 1000); + }); +} + +async function changeSizeMode(func) { + let promiseSizeModeChange = promiseMessage("sizemodechange"); + func(); + await promiseSizeModeChange; + await waitForSizeModePersisted(); +} + +async function runTest(aWindow) { + gWindow = aWindow; + gTitlebar = aWindow != "window_maximized_persist_with_no_titlebar.xhtml"; + let win = openWindow(); + await SimpleTest.promiseFocus(win); + + // Check the default state. + const chrome_url = win.location.href; + checkWindow("when open initially", win, win.STATE_NORMAL, WIDTH, HEIGHT); + const widthDiff = win.outerWidth - win.innerWidth; + const heightDiff = win.outerHeight - win.innerHeight; + // Maximize the window. + await changeSizeMode(() => win.maximize()); + checkWindow("after maximize window", win, win.STATE_MAXIMIZED); + win.close(); + + // Open a new window to check persisted sizemode. + win = openWindow(); + await SimpleTest.promiseFocus(win); + checkWindow("when reopen to maximized", win, win.STATE_MAXIMIZED); + // Restore the window. + if (win.windowState == win.STATE_MAXIMIZED) { + await changeSizeMode(() => win.restore()); + } + checkWindow("after restore window", win, win.STATE_NORMAL, WIDTH, HEIGHT); + win.close(); + + // Open a new window again to check persisted sizemode. + win = openWindow(); + await SimpleTest.promiseFocus(win); + checkWindow("when reopen to normal", win, win.STATE_NORMAL, WIDTH, HEIGHT); + // And maximize the window again for next test. + await changeSizeMode(() => win.maximize()); + win.close(); + + // Open a new window again with centerscreen which shouldn't revert + // the persisted sizemode. + win = openWindow("centerscreen"); + await SimpleTest.promiseFocus(win); + checkWindow("when open with centerscreen", win, win.STATE_MAXIMIZED); + win.close(); + + // Linux doesn't seem to persist sizemode across opening window + // with specified size, so mark it expected fail for now. + const isLinux = navigator.platform.includes("Linux"); + let checkWindowMayFail = isLinux ? todoCheckWindow : checkWindow; + + // Open a new window with size specified. + win = openWindow("width=400,height=400"); + await SimpleTest.promiseFocus(win); + checkWindow("when reopen with size", win, win.STATE_NORMAL, 400, 400); + await waitForSizeModePersisted(); + win.close(); + + // Open a new window without size specified. + // The window opened before should not change persisted sizemode. + win = openWindow(); + await SimpleTest.promiseFocus(win); + checkWindowMayFail("when reopen without size", win, win.STATE_MAXIMIZED); + win.close(); + + // Open a new window with sizing synchronously. + win = openWindow(); + win.resizeTo(500 + widthDiff, 500 + heightDiff); + await SimpleTest.promiseFocus(win); + checkWindow("when sized synchronously", win, win.STATE_NORMAL, 500, 500); + await waitForSizeModePersisted(); + win.close(); + + // Open a new window without any sizing. + // The window opened before should not change persisted sizemode. + win = openWindow(); + await SimpleTest.promiseFocus(win); + checkWindowMayFail("when reopen without sizing", win, win.STATE_MAXIMIZED); + win.close(); + + // Clean up the XUL store for the given window. + Services.xulStore.removeDocument(chrome_url); + + SimpleTest.finish(); +} diff --git a/toolkit/content/tests/chrome/findbar_entireword_window.xhtml b/toolkit/content/tests/chrome/findbar_entireword_window.xhtml new file mode 100644 index 0000000000..1cb85cffca --- /dev/null +++ b/toolkit/content/tests/chrome/findbar_entireword_window.xhtml @@ -0,0 +1,274 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css"?> + +<window id="FindbarEntireWordTest" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + onload="onLoad();" + title="findbar test - entire words only"> + + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <script type="application/javascript"><![CDATA[ + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + var gFindBar = null; + var gBrowser; + + var SimpleTest = window.arguments[0].SimpleTest; + var SpecialPowers = window.arguments[0].SpecialPowers; + var ok = window.arguments[0].ok; + var is = window.arguments[0].is; + var isnot = window.arguments[0].isnot; + var info = window.arguments[0].info; + SimpleTest.requestLongerTimeout(2); + + const kBaseURL = "chrome://mochitests/content/chrome/toolkit/content/tests/chrome"; + const kTests = { + latin1: { + testSimpleEntireWord: { + "and": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'and' should've been found"); + is(results.matches.total, 6, "should've found 6 matches"); + }, + "an": results => { + is(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'an' shouldn't have been found"); + is(results.matches.total, 0, "should've found 0 matches"); + }, + "darkness": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'darkness' should've been found"); + is(results.matches.total, 3, "should've found 3 matches"); + }, + "mammon": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'mammon' should've been found"); + is(results.matches.total, 1, "should've found 1 match"); + } + }, + testCaseSensitive: { + "And": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'And' should've been found"); + is(results.matches.total, 1, "should've found 1 match"); + }, + "and": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'and' should've been found"); + is(results.matches.total, 5, "should've found 5 matches"); + }, + "Mammon": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'mammon' should've been found"); + is(results.matches.total, 1, "should've found 1 match"); + } + }, + testWordBreakChars: { + "a": results => { + // 'a' is a common charactar, but there should only be one occurrence + // separated by word boundaries. + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'a' should've been found"); + is(results.matches.total, 1, "should've found 1 match"); + }, + "quarrelled": results => { + // 'quarrelled' is denoted as a word by a period char. + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'quarrelled' should've been found"); + is(results.matches.total, 1, "should've found 1 match"); + } + }, + testQuickfind: { + "and": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'and' should've been found"); + is(results.matches.total, 6, "should've found 6 matches"); + }, + "an": results => { + is(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'an' shouldn't have been found"); + is(results.matches.total, 0, "should've found 0 matches"); + }, + "darkness": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'darkness' should've been found"); + is(results.matches.total, 3, "should've found 3 matches"); + }, + "mammon": results => { + isnot(results.find.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "'mammon' should've been found"); + is(results.matches.total, 1, "should've found 1 match"); + } + } + } + }; + + function onLoad() { + (async function() { + await SpecialPowers.pushPrefEnv( + { set: [["findbar.entireword", true]] }); + + gFindBar = document.getElementById("FindToolbar"); + for (let browserId of ["content", "content-remote"]) { + // XXXmikedeboer: when multiple test samples are available, make this + // a nested loop that iterates over them. For now, only + // latin is available. + await startTestWithBrowser("latin1", browserId); + } + })().then(() => { + window.close(); + SimpleTest.finish(); + }); + } + + async function startTestWithBrowser(testName, browserId) { + info("Starting test with browser '" + browserId + "'"); + gBrowser = document.getElementById(browserId); + + let promise = BrowserTestUtils.browserLoaded(gBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser, kBaseURL + "/sample_entireword_" + testName + ".html"); + await promise; + gFindBar.browser = gBrowser; + await onDocumentLoaded(testName); + } + + async function onDocumentLoaded(testName) { + let suite = kTests[testName]; + await testSimpleEntireWord(suite.testSimpleEntireWord); + await testCaseSensitive(suite.testCaseSensitive); + await testWordBreakChars(suite.testWordBreakChars); + await testQuickfind(suite.testQuickfind); + } + + var enterStringIntoFindField = async function(str, waitForResult = true) { + for (let promise, i = 0; i < str.length; i++) { + if (waitForResult) { + promise = promiseFindResult(); + } + let event = new KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: str.charCodeAt(i), + }); + gFindBar._findField.dispatchEvent(event); + if (waitForResult) { + await promise; + } + } + }; + + function openFindbar() { + document.getElementById("cmd_find").doCommand(); + return gFindBar._startFindDeferred && gFindBar._startFindDeferred.promise; + } + + function promiseFindResult(searchString) { + return new Promise(resolve => { + let data = {}; + let listener = { + onFindResult: res => { + if (searchString && res.searchString != searchString) + return; + + gFindBar.browser.finder.removeResultListener(listener); + data.find = res; + if (res.result == Ci.nsITypeAheadFind.FIND_NOTFOUND) { + data.matches = { total: 0, current: 0 }; + resolve(data); + return; + } + listener = { + onMatchesCountResult: res => { + if (searchString && res.searchString != searchString) + return; + + gFindBar.browser.finder.removeResultListener(listener); + data.matches = res; + resolve(data); + } + }; + gFindBar.browser.finder.addResultListener(listener); + } + }; + + gFindBar.browser.finder.addResultListener(listener); + }); + } + + async function testIterator(tests) { + for (let searchString of Object.getOwnPropertyNames(tests)) { + gFindBar.clear(); + + let promise = promiseFindResult(searchString); + + await enterStringIntoFindField(searchString, false); + + let result = await promise; + tests[searchString](result); + } + } + + async function testSimpleEntireWord(tests) { + await openFindbar(); + ok(!gFindBar.hidden, "testSimpleEntireWord: findbar should be open"); + + await testIterator(tests); + + gFindBar.close(); + } + + async function testCaseSensitive(tests) { + await openFindbar(); + ok(!gFindBar.hidden, "testCaseSensitive: findbar should be open"); + + let matchCaseCheckbox = gFindBar.getElement("find-case-sensitive"); + if (!matchCaseCheckbox.hidden && !matchCaseCheckbox.checked) + matchCaseCheckbox.click(); + + await testIterator(tests); + + if (!matchCaseCheckbox.hidden) + matchCaseCheckbox.click(); + gFindBar.close(); + } + + async function testWordBreakChars(tests) { + await openFindbar(); + ok(!gFindBar.hidden, "testWordBreakChars: findbar should be open"); + + await testIterator(tests); + + gFindBar.close(); + } + + async function testQuickfind(tests) { + await SpecialPowers.spawn(gBrowser, [], async function() { + let event = new content.window.KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: "/".charCodeAt(0), + }); + content.document.documentElement.dispatchEvent(event); + }); + + ok(!gFindBar.hidden, "testQuickfind: failed to open findbar"); + ok(document.commandDispatcher.focusedElement == gFindBar._findField, + "testQuickfind: find field is not focused"); + ok(!gFindBar.getElement("entire-word-status").hidden, + "testQuickfind: entire word mode status text should be visible"); + + await testIterator(tests); + + gFindBar.close(); + } + ]]></script> + + <commandset> + <command id="cmd_find" oncommand="document.getElementById('FindToolbar').onFindCommand();"/> + </commandset> + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <browser type="content" primary="true" flex="1" id="content-remote" remote="true" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/findbar_events_window.xhtml b/toolkit/content/tests/chrome/findbar_events_window.xhtml new file mode 100644 index 0000000000..bc02ef6bcc --- /dev/null +++ b/toolkit/content/tests/chrome/findbar_events_window.xhtml @@ -0,0 +1,195 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<window id="FindbarTest" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + onload="SimpleTest.executeSoon(startTest);" + title="findbar events test"> + + <script type="application/javascript"><![CDATA[ + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + var gFindBar = null; + var gBrowser; + const kTimeout = 5000; // 5 seconds. + + var SimpleTest = window.arguments[0].SimpleTest; + var ok = window.arguments[0].ok; + var is = window.arguments[0].is; + var info = window.arguments[0].info; + SimpleTest.requestLongerTimeout(2); + + function startTest() { + (async function() { + gFindBar = document.getElementById("FindToolbar"); + for (let browserId of ["content", "content-remote"]) { + await startTestWithBrowser(browserId); + } + })().then(() => { + window.close(); + SimpleTest.finish(); + }); + } + + async function startTestWithBrowser(browserId) { + info("Starting test with browser '" + browserId + "'"); + gBrowser = document.getElementById(browserId); + const url = "data:text/html,hello there"; + let promise = BrowserTestUtils.browserLoaded(gBrowser, false, url); + BrowserTestUtils.startLoadingURIString(gBrowser, url); + await promise; + gFindBar.browser = gBrowser; + await onDocumentLoaded(); + } + + async function onDocumentLoaded() { + gFindBar.open(); + gFindBar.onFindCommand(); + + await testFind(); + await testFindAgain(); + await testCaseSensitivity(); + await testDiacriticMatching(); + await testHighlight(); + } + + function checkSelection() { + return new Promise(resolve => { + SimpleTest.executeSoon(() => { + SpecialPowers.spawn(gBrowser, [], async function() { + let selected = content.getSelection(); + Assert.equal(String(selected), "", "No text is selected"); + + let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + let selection = controller.getSelection(controller.SELECTION_FIND); + Assert.equal(selection.rangeCount, 0, "No text is highlighted"); + }).then(resolve); + }); + }); + } + + function once(node, eventName, preventDefault = true) { + return new Promise((resolve, reject) => { + let timeout = window.setTimeout(() => { + reject("Event wasn't fired within " + kTimeout + "ms for event '" + + eventName + "'."); + }, kTimeout); + + node.addEventListener(eventName, function clb(e) { + window.clearTimeout(timeout); + node.removeEventListener(eventName, clb); + if (preventDefault) + e.preventDefault(); + resolve(e); + }); + }); + } + + async function testFind() { + info("Testing normal find."); + let query = "t"; + let promise = once(gFindBar, "find"); + + // Put some text in the find box. + let event = new KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: query.charCodeAt(0), + }); + gFindBar._findField.dispatchEvent(event); + + let e = await promise; + ok(e.detail.query === query, "find event query should match '" + query + "'"); + // Since we're preventing the default make sure nothing was selected. + await checkSelection(); + } + + async function testFindAgain() { + info("Testing repeating normal find."); + let promise = once(gFindBar, "findagain"); + + gFindBar.onFindAgainCommand(); + + await promise; + // Since we're preventing the default make sure nothing was selected. + await checkSelection(); + } + + async function testCaseSensitivity() { + info("Testing normal case sensitivity."); + let promise = once(gFindBar, "findcasesensitivitychange", false); + + let matchCaseCheckbox = gFindBar.getElement("find-case-sensitive"); + matchCaseCheckbox.click(); + + let e = await promise; + ok(e.detail.caseSensitive, "find should be case sensitive"); + + // Toggle it back to the original setting. + matchCaseCheckbox.click(); + + // Changing case sensitivity does the search so clear the selected text + // before the next test. + await SpecialPowers.spawn(gBrowser, [], () => content.getSelection().removeAllRanges()); + } + + async function testDiacriticMatching() { + info("Testing normal diacritic matching."); + let promise = once(gFindBar, "finddiacriticmatchingchange", false); + + let matchDiacriticsCheckbox = gFindBar.getElement("find-match-diacritics"); + matchDiacriticsCheckbox.click(); + + let e = await promise; + ok(e.detail.matchDiacritics, "find should match diacritics"); + + // Toggle it back to the original setting. + matchDiacriticsCheckbox.click(); + + // Changing diacritic matching does the search so clear the selected text + // before the next test. + await SpecialPowers.spawn(gBrowser, [], () => content.getSelection().removeAllRanges()); + } + + async function testHighlight() { + info("Testing find with highlight all."); + // Update the find state so the highlight button is clickable. + gFindBar.updateControlState(Ci.nsITypeAheadFind.FIND_FOUND, false); + + let promise = once(gFindBar, "findhighlightallchange"); + + let highlightButton = gFindBar.getElement("highlight"); + if (!highlightButton.checked) + highlightButton.click(); + + let e = await promise; + ok(e.detail.highlightAll, "find event should have highlight all set"); + // Since we're preventing the default make sure nothing was highlighted. + await checkSelection(); + + // Toggle it back to the original setting. + if (highlightButton.checked) + highlightButton.click(); + } + ]]></script> + + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <browser type="content" primary="true" flex="1" id="content-remote" remote="true" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/findbar_window.xhtml b/toolkit/content/tests/chrome/findbar_window.xhtml new file mode 100644 index 0000000000..76bf1cae13 --- /dev/null +++ b/toolkit/content/tests/chrome/findbar_window.xhtml @@ -0,0 +1,792 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<window id="FindbarTest" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="600" + height="600" + onload="onLoad();" + title="findbar test"> + + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <script type="application/javascript"><![CDATA[ + const {AppConstants} = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + const SAMPLE_URL = "http://www.mozilla.org/"; + const SAMPLE_TEXT = "Some text in a text field."; + const SEARCH_TEXT = "Text Test (δοκιμή)"; + const NOT_FOUND_TEXT = "This text is not on the page." + const ITERATOR_TIMEOUT = Services.prefs.getIntPref("findbar.iteratorTimeout"); + + var gFindBar = null; + var gBrowser; + + var gHasFindClipboard = Services.clipboard.isClipboardTypeSupported(Services.clipboard.kFindClipboard); + + var gStatusText; + var gXULBrowserWindow = { + QueryInterface: ChromeUtils.generateQI(["nsIXULBrowserWindow"]), + + setOverLink(aStatusText) { + gStatusText = aStatusText; + }, + + onBeforeLinkTraversal() { } + }; + + var SimpleTest = window.arguments[0].SimpleTest; + var ok = window.arguments[0].ok; + var is = window.arguments[0].is; + var info = window.arguments[0].info; + SimpleTest.requestLongerTimeout(2); + + function onLoad() { + (async function() { + window.docShell + .treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow) + .XULBrowserWindow = gXULBrowserWindow; + + gFindBar = document.getElementById("FindToolbar"); + for (let browserId of ["content", "content-remote"]) { + await startTestWithBrowser(browserId); + } + })().then(() => { + window.close(); + SimpleTest.finish(); + }); + } + + async function startTestWithBrowser(browserId) { + info("Starting test with browser '" + browserId + "'"); + gBrowser = document.getElementById(browserId); + + // Tests delays the loading of a document for one second. + await new Promise(resolve => setTimeout(resolve, 1000)); + + let promise = BrowserTestUtils.browserLoaded(gBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser, "data:text/html;charset=utf-8,<h2 id='h2'>" + SEARCH_TEXT + + "</h2><h2><a href='" + SAMPLE_URL + "'>Link Test</a></h2><input id='text' type='text' value='" + + SAMPLE_TEXT + "'></input><input id='button' type='button'></input><img id='img' width='50' height='50'/>", + { triggeringPrincipal: window.document.nodePrincipal }); + await promise; + gFindBar.browser = gBrowser; + await onDocumentLoaded(); + } + + async function onDocumentLoaded() { + await testNormalFind(); + gFindBar.close(); + ok(gFindBar.hidden, "Failed to close findbar after testNormalFind"); + await openFindbar(); + await testNormalFindWithComposition(); + gFindBar.close(); + ok(gFindBar.hidden, "findbar should be hidden after testNormalFindWithComposition"); + await openFindbar(); + await testAutoCaseSensitivityUI(); + await testQuickFindText(); + gFindBar.close(); + ok(gFindBar.hidden, "Failed to close findbar after testQuickFindText"); + // TODO: `testFindWithHighlight` tests fastFind integrity, which can not + // be accessed with RemoteFinder. We're skipping it for now. + if (gFindBar._browser.finder._fastFind) { + await testFindWithHighlight(); + gFindBar.close(); + ok(gFindBar.hidden, "Failed to close findbar after testFindWithHighlight"); + } + await testFindbarSelection(); + ok(gFindBar.hidden, "Failed to close findbar after testFindbarSelection"); + // TODO: I don't know how to drop a content element on a chrome input. + if (!gBrowser.hasAttribute("remote")) + testDrop(); + await testQuickFindLink(); + if (gHasFindClipboard) { + await testStatusText(); + } + + if (!AppConstants.DEBUG) { + await testFindCountUI(); + gFindBar.close(); + ok(gFindBar.hidden, "Failed to close findbar after testFindCountUI"); + await testFindCountUI(true); + gFindBar.close(); + ok(gFindBar.hidden, "Failed to close findbar after testFindCountUI - linksOnly"); + } + + await openFindbar(); + await testFindAfterCaseChanged(); + gFindBar.close(); + await openFindbar(); + await testFailedStringReset(); + gFindBar.close(); + await testQuickFindClose(); + // TODO: This doesn't seem to work when the findbar is connected to a + // remote browser element. + if (!gBrowser.hasAttribute("remote")) + await testFindAgainNotFound(); + await testToggleEntireWord(); + } + + async function testFindbarSelection() { + function checkFindbarState(aTestName, aExpSelection) { + ok(!gFindBar.hidden, "testFindbarSelection: failed to open findbar: " + aTestName); + ok(document.commandDispatcher.focusedElement == gFindBar._findField, + "testFindbarSelection: find field is not focused: " + aTestName); + if (!gHasFindClipboard) { + ok(gFindBar._findField.value == aExpSelection, + "Incorrect selection in testFindbarSelection: " + aTestName + + ". Selection: " + gFindBar._findField.value); + } + + // Clear the value, close the findbar. + gFindBar._findField.value = ""; + gFindBar.close(); + } + + // Test normal selected text. + await SpecialPowers.spawn(gBrowser, [], async function() { + let document = content.document; + let cH2 = document.getElementById("h2"); + let cSelection = content.getSelection(); + let cRange = document.createRange(); + cRange.setStart(cH2, 0); + cRange.setEnd(cH2, 1); + cSelection.removeAllRanges(); + cSelection.addRange(cRange); + }); + await openFindbar(); + checkFindbarState("plain text", SEARCH_TEXT); + + // Test editable element with selection. + await SpecialPowers.spawn(gBrowser, [], async function() { + let textInput = content.document.getElementById("text"); + textInput.focus(); + textInput.select(); + }); + await openFindbar(); + checkFindbarState("text input", SAMPLE_TEXT); + + // Test non-editable input element (type="button"). + await SpecialPowers.spawn(gBrowser, [], async function() { + content.document.getElementById("button").focus(); + }); + await openFindbar(); + checkFindbarState("button", ""); + } + + function testDrop() { + gFindBar.open(); + // use an dummy image to start the drag so it doesn't get interrupted by a selection + var img = gBrowser.contentDocument.getElementById("img"); + synthesizeDrop(img, gFindBar._findField, [[ {type: "text/plain", data: "Rabbits" } ]], "copy", window); + is(gFindBar._findField.value, "Rabbits", "drop on findbar"); + gFindBar.close(); + } + + function testQuickFindClose() { + return new Promise(resolve => { + var _isClosedCallback = function() { + ok(gFindBar.hidden, + "_isClosedCallback: Failed to auto-close quick find bar after " + + gFindBar._quickFindTimeoutLength + "ms"); + resolve(); + }; + setTimeout(_isClosedCallback, gFindBar._quickFindTimeoutLength + 100); + }); + } + + function testStatusText() { + return new Promise(resolve => { + var _delayedCheckStatusText = function() { + ok(gStatusText == SAMPLE_URL, "testStatusText: Failed to set status text of found link"); + resolve(); + }; + setTimeout(_delayedCheckStatusText, 100); + }); + } + + function promiseFindResult(expectedString) { + return new Promise(resolve => { + let listener = { + onFindResult(result) { + if (expectedString && result.searchString != expectedString) { + return; + } + + gFindBar.browser.finder.removeResultListener(listener); + resolve(result); + }, + }; + gFindBar.browser.finder.addResultListener(listener); + }); + } + + function promiseMatchesCountResult(expectedString) { + return new Promise(resolve => { + let listener = { + onMatchesCountResult(result) { + if (expectedString && result.searchString != expectedString) + return; + + gFindBar.browser.finder.removeResultListener(listener); + resolve(); + }, + }; + gFindBar.browser.finder.addResultListener(listener); + // Make sure we resolve _at least_ after five times the find iterator timeout. + setTimeout(resolve, (ITERATOR_TIMEOUT * 5) + 20); + }); + } + + function promiseHighlightFinished(expectedString) { + return new Promise(resolve => { + let listener = { + onHighlightFinished(result) { + if (expectedString && result.searchString != expectedString) + return; + + gFindBar.browser.finder.removeResultListener(listener); + resolve(); + } + }; + gFindBar.browser.finder.addResultListener(listener); + }); + } + + var enterStringIntoFindField = async function(str, waitForResult = true) { + for (let promise, i = 0; i < str.length; i++) { + if (waitForResult) { + promise = promiseFindResult(); + } + let event = new KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: str.charCodeAt(i), + }); + gFindBar._findField.dispatchEvent(event); + if (waitForResult) { + await promise; + } + } + }; + + function promiseExpectRangeCount(rangeCount) { + return SpecialPowers.spawn(gBrowser, [{ rangeCount }], async function(args) { + let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); + Assert.equal(sel.rangeCount, args.rangeCount, + "Expected the correct amount of ranges inside the Find selection"); + }); + } + + // also test match-case + async function testNormalFind() { + document.getElementById("cmd_find").doCommand(); + + ok(!gFindBar.hidden, "testNormalFind: failed to open findbar"); + ok(document.commandDispatcher.focusedElement == gFindBar._findField, + "testNormalFind: find field is not focused"); + + let promise; + let matchCaseCheckbox = gFindBar.getElement("find-case-sensitive"); + if (!matchCaseCheckbox.hidden && matchCaseCheckbox.checked) { + promise = promiseFindResult(); + matchCaseCheckbox.click(); + await promise; + } + + var searchStr = "text tes"; + await enterStringIntoFindField(searchStr); + + let sel = await SpecialPowers.spawn(gBrowser, [{ searchStr }], async function(args) { + let sel = content.getSelection().toString(); + Assert.equal(sel.toLowerCase(), args.searchStr, + "testNormalFind: failed to find '" + args.searchStr + "'"); + return sel; + }); + testClipboardSearchString(sel); + + if (!matchCaseCheckbox.hidden) { + promise = promiseFindResult(); + matchCaseCheckbox.click(); + await promise; + enterStringIntoFindField("t"); + await SpecialPowers.spawn(gBrowser, [{ searchStr }], async function(args) { + Assert.notEqual(content.getSelection().toString(), args.searchStr, + "testNormalFind: Case-sensitivy is broken '" + args.searchStr + "'"); + }); + promise = promiseFindResult(); + matchCaseCheckbox.click(); + await promise; + } + } + + function openFindbar() { + document.getElementById("cmd_find").doCommand(); + return gFindBar._startFindDeferred && gFindBar._startFindDeferred.promise; + } + + async function testNormalFindWithComposition() { + ok(!gFindBar.hidden, "testNormalFindWithComposition: findbar should be open"); + ok(document.commandDispatcher.focusedElement == gFindBar._findField, + "testNormalFindWithComposition: find field should be focused"); + + var matchCaseCheckbox = gFindBar.getElement("find-case-sensitive"); + var clicked = false; + if (!matchCaseCheckbox.hidden & matchCaseCheckbox.checked) { + matchCaseCheckbox.click(); + clicked = true; + } + + gFindBar._findField.focus(); + + var searchStr = "text"; + + synthesizeCompositionChange( + { "composition": + { "string": searchStr, + "clauses": + [ + { "length": searchStr.length, "attr": COMPOSITION_ATTR_RAW_CLAUSE } + ] + }, + "caret": { "start": searchStr.length, "length": 0 } + }); + + await SpecialPowers.spawn(gBrowser, [{ searchStr }], async function(args) { + Assert.notEqual(content.getSelection().toString().toLowerCase(), args.searchStr, + "testNormalFindWithComposition: text shouldn't be found during composition"); + }); + + synthesizeComposition({ type: "compositioncommitasis" }); + + let sel = await SpecialPowers.spawn(gBrowser, [{ searchStr }], async function(args) { + let sel = content.getSelection().toString(); + Assert.equal(sel.toLowerCase(), args.searchStr, + "testNormalFindWithComposition: text should be found after committing composition"); + return sel; + }); + testClipboardSearchString(sel); + + if (clicked) { + matchCaseCheckbox.click(); + } + } + + async function testAutoCaseSensitivityUI() { + var matchCaseCheckbox = gFindBar.getElement("find-case-sensitive"); + var matchCaseLabel = gFindBar.getElement("match-case-status"); + ok(!matchCaseCheckbox.hidden, "match case box is hidden in manual mode"); + ok(matchCaseLabel.hidden, "match case label is visible in manual mode"); + + await changeCase(2); + + ok(matchCaseCheckbox.hidden, + "match case box is visible in automatic mode"); + ok(!matchCaseLabel.hidden, + "match case label is hidden in automatic mode"); + + await enterStringIntoFindField("a"); + ok(matchCaseLabel.hidden, + "match case label is hidden in automatic mode with lower-case input"); + await enterStringIntoFindField("A"); + ok(!matchCaseLabel.hidden, + "match case label is visible in automatic mode with upper-case input"); + + // bug 365551 + gFindBar.onFindAgainCommand(); + ok(matchCaseCheckbox.hidden && !matchCaseLabel.hidden, + "bug 365551: case sensitivity UI is broken after find-again"); + await changeCase(0); + gFindBar.close(); + } + + async function clearFocus() { + document.commandDispatcher.focusedElement = null; + document.commandDispatcher.focusedWindow = null; + await SpecialPowers.spawn(gBrowser, [], async function() { + content.focus(); + }); + } + + async function testQuickFindLink() { + await clearFocus(); + + await SpecialPowers.spawn(gBrowser, [], async function() { + let event = new content.window.KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: "'".charCodeAt(0), + }); + content.document.documentElement.dispatchEvent(event); + }); + + ok(!gFindBar.hidden, "testQuickFindLink: failed to open findbar"); + ok(document.commandDispatcher.focusedElement == gFindBar._findField, + "testQuickFindLink: find field is not focused"); + + var searchStr = "Link Test"; + await enterStringIntoFindField(searchStr); + await SpecialPowers.spawn(gBrowser, [{ searchStr }], async function(args) { + Assert.equal(content.getSelection().toString(), args.searchStr, + "testQuickFindLink: failed to find sample link"); + }); + testClipboardSearchString(searchStr); + } + + // See bug 963925 for more details on this test. + async function testFindWithHighlight() { + gFindBar._findField.value = ""; + + // For this test, we want to closely control the selection. The easiest + // way to do so is to replace the implementation of + // Finder.getInitialSelection with a no-op and call the findbar's callback + // (onCurrentSelection(..., true)) ourselves with our hand-picked + // selection. + let oldGetInitialSelection = gFindBar.browser.finder.getInitialSelection; + let searchStr; + gFindBar.browser.finder.getInitialSelection = function(){}; + + let findCommand = document.getElementById("cmd_find"); + findCommand.doCommand(); + + gFindBar.onCurrentSelection("", true); + + searchStr = "e"; + await enterStringIntoFindField(searchStr); + + let a = gFindBar._findField.value; + let b = gFindBar._browser.finder._fastFind.searchString; + let c = gFindBar._browser.finder.searchString; + ok(a == b && b == c, "testFindWithHighlight 1: " + a + ", " + b + ", " + c + "."); + + searchStr = "t"; + findCommand.doCommand(); + + gFindBar.onCurrentSelection(searchStr, true); + gFindBar.browser.finder.getInitialSelection = oldGetInitialSelection; + + a = gFindBar._findField.value; + b = gFindBar._browser.finder._fastFind.searchString; + c = gFindBar._browser.finder.searchString; + ok(a == searchStr && b == c, "testFindWithHighlight 2: " + searchStr + + ", " + a + ", " + b + ", " + c + "."); + + let highlightButton = gFindBar.getElement("highlight"); + highlightButton.click(); + ok(highlightButton.checked, "testFindWithHighlight 3: Highlight All should be checked."); + + a = gFindBar._findField.value; + b = gFindBar._browser.finder._fastFind.searchString; + c = gFindBar._browser.finder.searchString; + ok(a == searchStr && b == c, "testFindWithHighlight 4: " + a + ", " + b + ", " + c + "."); + + gFindBar.onFindAgainCommand(); + a = gFindBar._findField.value; + b = gFindBar._browser.finder._fastFind.searchString; + c = gFindBar._browser.finder.searchString; + ok(a == b && b == c, "testFindWithHighlight 5: " + a + ", " + b + ", " + c + "."); + + highlightButton.click(); + ok(!highlightButton.checked, "testFindWithHighlight: Highlight All should be unchecked."); + + // Regression test for bug 1316515. + searchStr = "e"; + gFindBar.clear(); + await enterStringIntoFindField(searchStr); + await promiseExpectRangeCount(0); + + highlightButton.click(); + ok(highlightButton.checked, "testFindWithHighlight: Highlight All should be checked."); + await promiseHighlightFinished(searchStr); + await promiseExpectRangeCount(3); + + synthesizeKey("KEY_Backspace"); + await promiseExpectRangeCount(0); + + // Regression test for bug 1316513. + highlightButton.click(); + ok(!highlightButton.checked, "testFindWithHighlight - 1316513: Highlight All should be unchecked."); + await enterStringIntoFindField(searchStr); + + highlightButton.click(); + ok(highlightButton.checked, "testFindWithHighlight - 1316513: Highlight All should be checked."); + await promiseHighlightFinished(searchStr); + await promiseExpectRangeCount(3); + + let promise = BrowserTestUtils.browserLoaded(gBrowser); + gBrowser.reload(); + await promise; + + ok(highlightButton.checked, "testFindWithHighlight - 1316513: Highlight All " + + "should still be checked after a reload."); + synthesizeKey("KEY_Enter"); + await promiseHighlightFinished(searchStr); + await promiseExpectRangeCount(3); + + // Uncheck at test end to not interfere with other test functions that are + // run after this one. + highlightButton.click(); + } + + async function testQuickFindText() { + await clearFocus(); + + await SpecialPowers.spawn(gBrowser, [], async function() { + let event = new content.window.KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: "/".charCodeAt(0), + }); + content.document.documentElement.dispatchEvent(event); + }); + + ok(!gFindBar.hidden, "testQuickFindText: failed to open findbar"); + ok(document.commandDispatcher.focusedElement == gFindBar._findField, + "testQuickFindText: find field is not focused"); + + await enterStringIntoFindField(SEARCH_TEXT); + await SpecialPowers.spawn(gBrowser, [{ SEARCH_TEXT }], async function(args) { + Assert.equal(content.getSelection().toString(), args.SEARCH_TEXT, + "testQuickFindText: failed to find '" + args.SEARCH_TEXT + "'"); + }); + testClipboardSearchString(SEARCH_TEXT); + } + + async function testFindCountUI(linksOnly = false) { + await clearFocus(); + + if (linksOnly) { + await SpecialPowers.spawn(gBrowser, [], async function() { + let event = new content.window.KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: 0, + charCode: "'".charCodeAt(0), + }); + content.document.documentElement.dispatchEvent(event); + }); + } else { + document.getElementById("cmd_find").doCommand(); + } + + ok(!gFindBar.hidden, "testFindCountUI: failed to open findbar"); + ok(document.commandDispatcher.focusedElement == gFindBar._findField, + "testFindCountUI: find field is not focused"); + + let promise; + let matchCase = gFindBar.getElement("find-case-sensitive"); + if (matchCase.checked) { + promise = promiseFindResult(); + matchCase.click(); + await new Promise(resolve => setTimeout(resolve, ITERATOR_TIMEOUT + 20)); + await promise; + } + + let foundMatches = gFindBar._foundMatches; + let tests = [{ + text: "t", + current: linksOnly ? 1 : 5, + total: linksOnly ? 2 : 10, + }, { + text: "te", + current: linksOnly ? 1 : 3, + total: linksOnly ? 1 : 5, + }, { + text: "tes", + current: 1, + total: linksOnly ? 1 : 2, + }, { + text: "texxx", + current: 0, + total: 0 + }]; + + function assertMatches(aTest) { + const matches = JSON.parse(foundMatches.dataset.l10nArgs); + is(matches.current, aTest.current, + `${linksOnly ? "[Links-only] " : ""}Currently highlighted match should be at ${aTest.current} for '${aTest.text}'`); + is(matches.total, aTest.total, + `${linksOnly ? "[Links-only] " : ""}Total amount of matches should be ${aTest.total} for '${aTest.text}'`); + } + + for (let test of tests) { + gFindBar._findField.select(); + gFindBar._findField.focus(); + + let timeout = ITERATOR_TIMEOUT; + if (test.text.length == 1) + timeout *= 4; + else if (test.text.length == 2) + timeout *= 2; + timeout += 20; + await new Promise(resolve => setTimeout(resolve, timeout)); + await enterStringIntoFindField(test.text, false); + await promiseMatchesCountResult(test.text); + if (!test.total) { + ok(!foundMatches.dataset.l10nId, "No message should be shown when 0 matches are expected"); + } else { + assertMatches(test); + for (let i = 1; i < test.total; i++) { + await new Promise(resolve => setTimeout(resolve, timeout)); + gFindBar.onFindAgainCommand(); + await promiseMatchesCountResult(test.text); + // test.current + 1, test.current + 2, ..., test.total, 1, ..., test.current + let current = (test.current + i - 1) % test.total + 1; + assertMatches({ + text: test.text, + current, + total: test.total + }); + } + } + } + } + + // See bug 1051187. + async function testFindAfterCaseChanged() { + // Search to set focus on "Text Test" so that searching for "t" selects first + // (upper case!) "T". + await enterStringIntoFindField(SEARCH_TEXT); + gFindBar.clear(); + + // Case-insensitive should already be the current value. + Services.prefs.setIntPref("accessibility.typeaheadfind.casesensitive", 0); + + await enterStringIntoFindField("t"); + await SpecialPowers.spawn(gBrowser, [], async function() { + Assert.equal(content.getSelection().toString(), "T", "First T should be selected."); + }); + + await changeCase(1); + await SpecialPowers.spawn(gBrowser, [], async function() { + Assert.equal(content.getSelection().toString(), "t", "First t should be selected."); + }); + } + + // Make sure that _findFailedString is cleared: + // 1. Do a search that fails with case sensitivity but matches with no case sensitivity. + // 2. Uncheck case sensitivity button to match the string. + async function testFailedStringReset() { + Services.prefs.setIntPref("accessibility.typeaheadfind.casesensitive", 1); + + let promise = promiseFindResult(gBrowser.hasAttribute("remote") ? SEARCH_TEXT.toUpperCase() : ""); + await enterStringIntoFindField(SEARCH_TEXT.toUpperCase(), false); + await promise; + await SpecialPowers.spawn(gBrowser, [], async function() { + Assert.equal(content.getSelection().toString(), "", "Not found."); + }); + + await changeCase(0); + await SpecialPowers.spawn(gBrowser, [{ SEARCH_TEXT }], async function(args) { + Assert.equal(content.getSelection().toString(), args.SEARCH_TEXT, + "Search text should be selected."); + }); + } + + function testClipboardSearchString(aExpected) { + if (!gHasFindClipboard) + return; + + if (!aExpected) + aExpected = ""; + var searchStr = gFindBar.browser.finder.clipboardSearchString; + ok(searchStr.toLowerCase() == aExpected.toLowerCase(), + "testClipboardSearchString: search string not set to '" + aExpected + + "', instead found '" + searchStr + "'"); + } + + // See bug 967982. + async function testFindAgainNotFound() { + await openFindbar(); + await enterStringIntoFindField(NOT_FOUND_TEXT, false); + gFindBar.close(); + ok(gFindBar.hidden, "The findbar is closed."); + let promise = promiseFindResult(); + gFindBar.onFindAgainCommand(); + await promise; + ok(!gFindBar.hidden, "Unsuccessful Find Again opens the find bar."); + + await enterStringIntoFindField(SEARCH_TEXT); + gFindBar.close(); + ok(gFindBar.hidden, "The findbar is closed."); + promise = promiseFindResult(); + gFindBar.onFindAgainCommand(); + await promise; + ok(gFindBar.hidden, "Successful Find Again leaves the find bar closed."); + } + + async function testToggleDiacriticMatching() { + await openFindbar(); + let promise = promiseFindResult(); + await enterStringIntoFindField("δοκιμη", false); + let result = await promise; + is(result.result, Ci.nsITypeAheadFind.FIND_FOUND, "Text should be found"); + + await new Promise(resolve => setTimeout(resolve, ITERATOR_TIMEOUT + 20)); + promise = promiseFindResult(); + let check = gFindBar.getElement("find-match-diacritics"); + check.click(); + result = await promise; + is(result.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "Text should NOT be found"); + + check.click(); + gFindBar.close(true); + } + + async function testToggleEntireWord() { + await openFindbar(); + let promise = promiseFindResult(); + await enterStringIntoFindField("Tex", false); + let result = await promise; + is(result.result, Ci.nsITypeAheadFind.FIND_FOUND, "Text should be found"); + + await new Promise(resolve => setTimeout(resolve, ITERATOR_TIMEOUT + 20)); + promise = promiseFindResult(); + let check = gFindBar.getElement("find-entire-word"); + check.click(); + result = await promise; + is(result.result, Ci.nsITypeAheadFind.FIND_NOTFOUND, "Text should NOT be found"); + + check.click(); + gFindBar.close(true); + } + + function changeCase(value) { + let promise = gBrowser.hasAttribute("remote") ? promiseFindResult() : Promise.resolve(); + Services.prefs.setIntPref("accessibility.typeaheadfind.casesensitive", value); + return promise; + } + ]]></script> + + <commandset> + <command id="cmd_find" oncommand="document.getElementById('FindToolbar').onFindCommand();"/> + </commandset> + <browser type="content" primary="true" flex="1" id="content" messagemanagergroup="test" src="about:blank"/> + <browser type="content" primary="true" flex="1" id="content-remote" remote="true" messagemanagergroup="test" src="about:blank"/> + <findbar id="FindToolbar" browserid="content"/> +</window> diff --git a/toolkit/content/tests/chrome/frame_popup_anchor.xhtml b/toolkit/content/tests/chrome/frame_popup_anchor.xhtml new file mode 100644 index 0000000000..934cea6faf --- /dev/null +++ b/toolkit/content/tests/chrome/frame_popup_anchor.xhtml @@ -0,0 +1,84 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<menupopup id="popup" onpopupshowing="if (isSecondTest) popupShowing(event)" onpopupshown="popupShown()" + onpopuphidden="nextTest()"> + <menuitem label="One"/> + <menuitem label="Two"/> +</menupopup> + +<button id="button" label="OK" popup="popup"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +var isSecondTest = false; + +function openPopup() +{ + document.getElementById("popup").openPopup(parent.document.getElementById("outerbutton"), "after_start", 3, 1); +} + +function popupShowing(event) +{ + var buttonrect = document.getElementById("button").getBoundingClientRect(); + parent.arguments[0].SimpleTest.is(event.clientX, buttonrect.left + 6, "popup clientX with mouse"); + parent.arguments[0].SimpleTest.is(event.clientY, buttonrect.top + 6, "popup clientY with mouse"); +} + +function popupShown() +{ + var left, top; + var popup = document.getElementById("popup"); + var popuprect = popup.getBoundingClientRect(); + if (isSecondTest) { + let buttonrect = document.getElementById("button").getBoundingClientRect(); + left = buttonrect.left + 6; + top = buttonrect.top + 6; + } else { + let iframerect = parent.document.getElementById("frame").getBoundingClientRect(); + let buttonrect = parent.document.getElementById("outerbutton").getBoundingClientRect(); + + // The popup should appear anchored on the bottom left edge of the button, however + // the client rectangle is relative to the iframe's document. Thus the coordinates + // are: + // left = iframe's left - anchor button's left - 3 pixel offset passed to openPopup + + // iframe border (17px) + iframe padding (0) + // top = iframe's top - anchor button's bottom - 1 pixel offset passed to openPopup + + // iframe border (0) + iframe padding (3px); + left = -(Math.round(iframerect.left) - Math.round(buttonrect.left) + 14); + top = -(Math.round(iframerect.top) - Math.round(buttonrect.bottom) + 2); + } + + left += parseFloat(getComputedStyle(popup).marginLeft); + top += parseFloat(getComputedStyle(popup).marginTop); + + var testid = isSecondTest ? "with mouse" : "anchored to parent frame"; + parent.arguments[0].SimpleTest.is(Math.round(popuprect.left), left, "popup left " + testid); + parent.arguments[0].SimpleTest.is(Math.round(popuprect.top), top, "popup top " + testid); + + document.getElementById("popup").hidePopup(); +} + +function nextTest() +{ + if (isSecondTest) { + parent.arguments[0].SimpleTest.finish(); + parent.close(); + } + else { + // this second test ensures that the popupshowing coordinates when a popup in + // a frame is opened are correct + isSecondTest = true; + synthesizeMouse(document.getElementById("button"), 6, 6, { }); + } +} + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/frame_subframe_origin_subframe1.xhtml b/toolkit/content/tests/chrome/frame_subframe_origin_subframe1.xhtml new file mode 100644 index 0000000000..f68a06d85d --- /dev/null +++ b/toolkit/content/tests/chrome/frame_subframe_origin_subframe1.xhtml @@ -0,0 +1,41 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window id="frame1" + style="background-color:green;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<spacer height="10px"/> +<iframe + style="margin:10px; min-height:170px; max-width:200px; max-height:200px; border:solid 1px white;" + src="frame_subframe_origin_subframe2.xhtml"></iframe> +<spacer height="3px"/> +<caption id="cap1" style="min-width:200px; max-width:200px; background-color:white;" label=""/> +<script class="testbody" type="application/javascript"> + +// Fire a mouse move event aimed at this window, and check to be +// sure the client coords translate from widget to the dom correctly. + +function runTests() +{ + synthesizeMouse(document.getElementById("frame1"), 3, 4, { type: "mousemove" }); +} + +function mouseMove(e) { + e.stopPropagation(); + var el = document.getElementById("cap1"); + el.label = "client: (" + e.clientX + "," + e.clientY + ")"; + parent.arguments[0].SimpleTest.is(e.clientX, 3, "mouse event clientX on sub frame 1"); + parent.arguments[0].SimpleTest.is(e.clientY, 4, "mouse event clientY on sub frame 1"); + // fire the next test on the sub frame + frames[0].runTests(); +} + +window.addEventListener("mousemove", mouseMove); + +</script> +</window> diff --git a/toolkit/content/tests/chrome/frame_subframe_origin_subframe2.xhtml b/toolkit/content/tests/chrome/frame_subframe_origin_subframe2.xhtml new file mode 100644 index 0000000000..338877ff96 --- /dev/null +++ b/toolkit/content/tests/chrome/frame_subframe_origin_subframe2.xhtml @@ -0,0 +1,37 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window id="frame2" + style="background-color:red;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<spacer height="10px"/> +<caption id="cap2" style="background-color:white;" label=""/> +<script class="testbody" type="application/javascript"> + +// Fire a mouse move event aimed at this window, and check to be +// sure the client coords translate from widget to the dom correctly. + +function runTests() +{ + synthesizeMouse(document.getElementById("frame2"), 6, 5, { type: "mousemove" }); +} + +function mouseMove(e) { + e.stopPropagation(); + var el = document.getElementById("cap2"); + el.label = "client: (" + e.clientX + "," + e.clientY + ")"; + parent.parent.arguments[0].SimpleTest.is(e.clientX, 6, "mouse event clientX on sub frame 2"); + parent.parent.arguments[0].SimpleTest.is(e.clientY, 5, "mouse event clientY on sub frame 2"); + parent.parent.arguments[0].SimpleTest.finish(); + parent.parent.close(); +} + +window.addEventListener("mousemove", mouseMove); + +</script> +</window> diff --git a/toolkit/content/tests/chrome/popup_trigger.js b/toolkit/content/tests/chrome/popup_trigger.js new file mode 100644 index 0000000000..003af044e5 --- /dev/null +++ b/toolkit/content/tests/chrome/popup_trigger.js @@ -0,0 +1,1210 @@ +/* import-globals-from ../widgets/popup_shared.js */ + +var gMenuPopup = null; +var gTrigger = null; +var gIsMenu = false; +var gScreenX = -1, + gScreenY = -1; +var gCachedEvent = null; +var gCachedEvent2 = null; + +function cacheEvent(modifiers) { + var cachedEvent = null; + + var mouseFn = function (event) { + cachedEvent = event; + }; + + window.addEventListener("mousedown", mouseFn); + synthesizeMouse(document.documentElement, 0, 0, modifiers); + window.removeEventListener("mousedown", mouseFn); + + return cachedEvent; +} + +function runTests() { + if (screen.height < 768) { + ok( + false, + "popup tests are likely to fail for screen heights less than 768 pixels" + ); + } + + gMenuPopup = document.getElementById("thepopup"); + gTrigger = document.getElementById("trigger"); + + gIsMenu = gTrigger.hasMenu(); + + // a hacky way to get the screen position of the document. Cache the event + // so that we can use it in calls to openPopup. + gCachedEvent = cacheEvent({ shiftKey: true }); + gScreenX = gCachedEvent.screenX; + gScreenY = gCachedEvent.screenY; + gCachedEvent2 = cacheEvent({ + altKey: true, + ctrlKey: true, + shiftKey: true, + metaKey: true, + }); + + startPopupTests(popupTests); +} + +var popupTests = [ + { + testname: "mouse click on trigger", + events: ["popupshowing thepopup", "popupshown thepopup"], + test() { + // for menus, no trigger will be set. For non-menus using the popup + // attribute, the trigger will be set to the node with the popup attribute + gExpectedTriggerNode = gIsMenu ? "notset" : gTrigger; + synthesizeMouse(gTrigger, 4, 4, {}); + }, + async result(testname) { + gExpectedTriggerNode = null; + // menus are the anchor but non-menus are opened at screen coordinates + is( + gMenuPopup.anchorNode, + gIsMenu ? gTrigger : null, + testname + " anchorNode" + ); + // menus are opened internally, but non-menus have a mouse event which + // triggered them + is( + gMenuPopup.triggerNode, + gIsMenu ? null : gTrigger, + testname + " triggerNode" + ); + + // Popup may have wrong initial size in non e10s mode tests, because + // layout is not yet ready for popup content lazy population on + // popupshowing event. + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(r)) + ); + + // this will be used in some tests to ensure the size doesn't change + var popuprect = gMenuPopup.getBoundingClientRect(); + gPopupWidth = Math.round(popuprect.width); + gPopupHeight = Math.round(popuprect.height); + + checkActive(gMenuPopup, "", testname); + checkOpen("trigger", testname); + // if a menu, the popup should be opened underneath the menu in the + // 'after_start' position, otherwise it is opened at the mouse position + if (gIsMenu) { + compareEdge(gTrigger, gMenuPopup, "after_start", 0, 0, testname); + } + }, + }, + { + // check that pressing cursor down while there is no selection + // highlights the first item + testname: "cursor down no selection", + events: ["DOMMenuItemActive item1"], + test() { + synthesizeKey("KEY_ArrowDown"); + }, + result(testname) { + checkActive(gMenuPopup, "item1", testname); + }, + }, + { + // check that pressing cursor up wraps and highlights the last item + testname: "cursor up wrap", + events() { + // No wrapping on menus on Mac + return platformIsMac() + ? [] + : ["DOMMenuItemInactive item1", "DOMMenuItemActive last"]; + }, + test() { + synthesizeKey("KEY_ArrowUp"); + }, + result(testname) { + checkActive(gMenuPopup, platformIsMac() ? "item1" : "last", testname); + }, + }, + { + // check that pressing cursor down wraps and highlights the first item + testname: "cursor down wrap", + condition() { + return !platformIsMac(); + }, + events: ["DOMMenuItemInactive last", "DOMMenuItemActive item1"], + test() { + synthesizeKey("KEY_ArrowDown"); + }, + result(testname) { + checkActive(gMenuPopup, "item1", testname); + }, + }, + { + // check that pressing cursor down highlights the second item + testname: "cursor down", + events: ["DOMMenuItemInactive item1", "DOMMenuItemActive item2"], + test() { + synthesizeKey("KEY_ArrowDown"); + }, + result(testname) { + checkActive(gMenuPopup, "item2", testname); + }, + }, + { + // check that pressing cursor up highlights the second item + testname: "cursor up", + events: ["DOMMenuItemInactive item2", "DOMMenuItemActive item1"], + test() { + synthesizeKey("KEY_ArrowUp"); + }, + result(testname) { + checkActive(gMenuPopup, "item1", testname); + }, + }, + { + // cursor left should not do anything + testname: "cursor left", + test() { + synthesizeKey("KEY_ArrowLeft"); + }, + result(testname) { + checkActive(gMenuPopup, "item1", testname); + }, + }, + { + // cursor right should not do anything + testname: "cursor right", + test() { + synthesizeKey("KEY_ArrowRight"); + }, + result(testname) { + checkActive(gMenuPopup, "item1", testname); + }, + }, + { + // check cursor down when a disabled item exists in the menu + testname: "cursor down disabled", + events() { + // On Windows, disabled items are included when navigating, but on + // other platforms, disabled items are skipped over + if (navigator.platform.indexOf("Win") == 0) { + return ["DOMMenuItemInactive item1", "DOMMenuItemActive item2"]; + } + return ["DOMMenuItemInactive item1", "DOMMenuItemActive amenu"]; + }, + test() { + document.getElementById("item2").disabled = true; + synthesizeKey("KEY_ArrowDown"); + }, + }, + { + // check cursor up when a disabled item exists in the menu + testname: "cursor up disabled", + events() { + if (navigator.platform.indexOf("Win") == 0) { + return [ + "DOMMenuItemInactive item2", + "DOMMenuItemActive amenu", + "DOMMenuItemInactive amenu", + "DOMMenuItemActive item2", + "DOMMenuItemInactive item2", + "DOMMenuItemActive item1", + ]; + } + return ["DOMMenuItemInactive amenu", "DOMMenuItemActive item1"]; + }, + test() { + if (navigator.platform.indexOf("Win") == 0) { + synthesizeKey("KEY_ArrowDown"); + } + synthesizeKey("KEY_ArrowUp"); + if (navigator.platform.indexOf("Win") == 0) { + synthesizeKey("KEY_ArrowUp"); + } + }, + }, + { + testname: "mouse click outside", + events: [ + "popuphiding thepopup", + "popuphidden thepopup", + "DOMMenuItemInactive item1", + "DOMMenuInactive thepopup", + ], + test() { + gMenuPopup.hidePopup(); + // XXXndeakin event simulation fires events outside of the platform specific + // widget code so the popup capturing isn't handled. Thus, the menu won't + // rollup this way. + // synthesizeMouse(gTrigger, 0, -12, { }); + }, + result(testname, step) { + is(gMenuPopup.anchorNode, null, testname + " anchorNode"); + is(gMenuPopup.triggerNode, null, testname + " triggerNode"); + checkClosed("trigger", testname); + }, + }, + { + // these tests check to ensure that passing an anchor and position + // puts the popup in the right place + testname: "open popup anchored", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + steps: [ + "before_start", + "before_end", + "after_start", + "after_end", + "start_before", + "start_after", + "end_before", + "end_after", + "after_pointer", + "overlap", + "topleft topleft", + "topcenter topleft", + "topright topleft", + "leftcenter topright", + "rightcenter topright", + "bottomleft bottomleft", + "bottomcenter bottomleft", + "bottomright bottomleft", + "topleft bottomright", + "bottomcenter bottomright", + "rightcenter topright", + "bottomcenter topcenter", + "rightcenter leftcenter", + ], + test(testname, step) { + gExpectedTriggerNode = "notset"; + gMenuPopup.openPopup(gTrigger, step, 0, 0, false, false); + }, + result(testname, step) { + // no triggerNode because it was opened without passing an event + gExpectedTriggerNode = null; + is(gMenuPopup.anchorNode, gTrigger, testname + " anchorNode"); + is(gMenuPopup.triggerNode, null, testname + " triggerNode"); + compareEdge(gTrigger, gMenuPopup, step, 0, 0, testname); + }, + }, + { + // these tests check the same but with a 10 pixel margin on the popup + testname: "open popup anchored with margin", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + steps: [ + "before_start", + "before_end", + "after_start", + "after_end", + "start_before", + "start_after", + "end_before", + "end_after", + "after_pointer", + "overlap", + "topleft topleft", + "topcenter topleft", + "topright topleft", + "leftcenter topright", + "rightcenter topright", + "bottomleft bottomleft", + "bottomcenter bottomleft", + "bottomright bottomleft", + "topleft bottomright", + "bottomcenter bottomright", + "rightcenter topright", + ], + test(testname, step) { + gMenuPopup.setAttribute("style", "margin: 10px;"); + gMenuPopup.openPopup(gTrigger, step, 0, 0, false, false); + }, + result(testname, step) { + var rightmod = + step == "before_end" || + step == "after_end" || + step == "start_before" || + step == "start_after" || + step.match(/topright$/) || + step.match(/bottomright$/); + var bottommod = + step == "before_start" || + step == "before_end" || + step == "start_after" || + step == "end_after" || + step.match(/bottomleft$/) || + step.match(/bottomright$/); + compareEdge( + gTrigger, + gMenuPopup, + step, + rightmod ? -10 : 10, + bottommod ? -10 : 10, + testname + ); + gMenuPopup.removeAttribute("style"); + }, + }, + { + // these tests check the same but with a -8 pixel margin on the popup + testname: "open popup anchored with negative margin", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + steps: [ + "before_start", + "before_end", + "after_start", + "after_end", + "start_before", + "start_after", + "end_before", + "end_after", + "after_pointer", + "overlap", + ], + test(testname, step) { + gMenuPopup.setAttribute("style", "margin: -8px;"); + gMenuPopup.openPopup(gTrigger, step, 0, 0, false, false); + }, + result(testname, step) { + var rightmod = + step == "before_end" || + step == "after_end" || + step == "start_before" || + step == "start_after"; + var bottommod = + step == "before_start" || + step == "before_end" || + step == "start_after" || + step == "end_after"; + compareEdge( + gTrigger, + gMenuPopup, + step, + rightmod ? 8 : -8, + bottommod ? 8 : -8, + testname + ); + gMenuPopup.removeAttribute("style"); + }, + }, + { + testname: "open popup with large positive margin", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + steps: [ + "before_start", + "before_end", + "after_start", + "after_end", + "start_before", + "start_after", + "end_before", + "end_after", + "after_pointer", + "overlap", + ], + test(testname, step) { + gMenuPopup.setAttribute("style", "margin: 1000px;"); + gMenuPopup.openPopup(gTrigger, step, 0, 0, false, false); + }, + result(testname, step) { + var popuprect = gMenuPopup.getBoundingClientRect(); + // as there is more room on the 'end' or 'after' side, popups will always + // appear on the right or bottom corners, depending on which side they are + // allowed to be flipped by. + var expectedleft = + step == "before_end" || step == "after_end" + ? 0 + : Math.round(window.innerWidth - gPopupWidth); + var expectedtop = + step == "start_after" || step == "end_after" + ? 0 + : Math.round(window.innerHeight - gPopupHeight); + is( + Math.round(popuprect.left), + expectedleft, + testname + " x position " + step + ); + is( + Math.round(popuprect.top), + expectedtop, + testname + " y position " + step + ); + gMenuPopup.removeAttribute("style"); + }, + }, + { + testname: "open popup with large negative margin", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + steps: [ + "before_start", + "before_end", + "after_start", + "after_end", + "start_before", + "start_after", + "end_before", + "end_after", + "after_pointer", + "overlap", + ], + test(testname, step) { + gMenuPopup.setAttribute("style", "margin: -1000px;"); + gMenuPopup.openPopup(gTrigger, step, 0, 0, false, false); + }, + result(testname, step) { + var popuprect = gMenuPopup.getBoundingClientRect(); + // using negative margins causes the reverse of positive margins, and + // popups will appear on the left or top corners. + var expectedleft = + step == "before_end" || step == "after_end" + ? Math.round(window.innerWidth - gPopupWidth) + : 0; + var expectedtop = + step == "start_after" || step == "end_after" + ? Math.round(window.innerHeight - gPopupHeight) + : 0; + is( + Math.round(popuprect.left), + expectedleft, + testname + " x position " + step + ); + is( + Math.round(popuprect.top), + expectedtop, + testname + " y position " + step + ); + gMenuPopup.removeAttribute("style"); + }, + }, + { + testname: "popup with unknown step", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + test() { + gMenuPopup.openPopup(gTrigger, "other", 0, 0, false, false); + }, + result(testname) { + var triggerrect = gMenuPopup.getBoundingClientRect(); + var popuprect = gMenuPopup.getBoundingClientRect(); + is( + Math.round(popuprect.left), + triggerrect.left, + testname + " x position " + ); + is(Math.round(popuprect.top), triggerrect.top, testname + " y position "); + }, + }, + { + // these tests check to ensure that the position attribute can be used + // to set the position of a popup instead of passing it as an argument + testname: "open popup anchored with attribute", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + steps: [ + "before_start", + "before_end", + "after_start", + "after_end", + "start_before", + "start_after", + "end_before", + "end_after", + "after_pointer", + "overlap", + "topcenter topleft", + "topright bottomright", + "leftcenter topright", + ], + test(testname, step) { + gMenuPopup.setAttribute("position", step); + gMenuPopup.openPopup(gTrigger, "", 0, 0, false, false); + }, + result(testname, step) { + compareEdge(gTrigger, gMenuPopup, step, 0, 0, testname); + }, + }, + { + // this test checks to ensure that the attributes override flag to openPopup + // can be used to override the popup's position. This test also passes an + // event to openPopup to check the trigger node. + testname: "open popup anchored with override", + events: ["popupshowing thepopup 0010", "popupshown thepopup"], + test(testname, step) { + // attribute overrides the position passed in + gMenuPopup.setAttribute("position", "end_after"); + gExpectedTriggerNode = gCachedEvent.target; + gMenuPopup.openPopup( + gTrigger, + "before_start", + 0, + 0, + false, + true, + gCachedEvent + ); + }, + result(testname, step) { + gExpectedTriggerNode = null; + is(gMenuPopup.anchorNode, gTrigger, testname + " anchorNode"); + is( + gMenuPopup.triggerNode, + gCachedEvent.target, + testname + " triggerNode" + ); + compareEdge(gTrigger, gMenuPopup, "end_after", 0, 0, testname); + }, + }, + { + testname: "close popup with escape", + events: [ + "popuphiding thepopup", + "popuphidden thepopup", + "DOMMenuInactive thepopup", + ], + test(testname, step) { + synthesizeKey("KEY_Escape"); + checkClosed("trigger", testname); + }, + }, + { + // check that offsets may be supplied to the openPopup method + testname: "open popup anchored with offsets", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + // attribute is empty so does not override + gMenuPopup.setAttribute("position", ""); + gMenuPopup.openPopup(gTrigger, "before_start", 5, 10, true, true); + }, + result(testname, step) { + compareEdge(gTrigger, gMenuPopup, "before_start", 5, 10, testname); + }, + }, + { + // if no anchor is supplied to openPopup, it should be opened relative + // to the viewport. + testname: "open popup unanchored", + events: ["popupshowing thepopup", "popupshown thepopup"], + test(testname, step) { + gMenuPopup.openPopup(null, "after_start", 6, 8, false); + }, + result(testname, step) { + var rect = gMenuPopup.getBoundingClientRect(); + ok( + rect.left == 6 && rect.top == 8 && rect.right && rect.bottom, + testname + ); + }, + }, + { + testname: "activate menuitem with mouse", + events: [ + "DOMMenuInactive thepopup", + "command item3", + "popuphiding thepopup", + "popuphidden thepopup", + ], + test(testname, step) { + var item3 = document.getElementById("item3"); + synthesizeMouse(item3, 4, 4, {}); + }, + result(testname, step) { + checkClosed("trigger", testname); + }, + }, + { + testname: "close popup", + condition() { + return false; + }, + events: [ + "popuphiding thepopup", + "popuphidden thepopup", + "DOMMenuInactive thepopup", + ], + test(testname, step) { + gMenuPopup.hidePopup(); + }, + }, + { + testname: "open popup at screen", + events: ["popupshowing thepopup", "popupshown thepopup"], + test(testname, step) { + gExpectedTriggerNode = "notset"; + gMenuPopup.openPopupAtScreen(gScreenX + 24, gScreenY + 20, false); + }, + result(testname, step) { + gExpectedTriggerNode = null; + is(gMenuPopup.anchorNode, null, testname + " anchorNode"); + is(gMenuPopup.triggerNode, null, testname + " triggerNode"); + var rect = gMenuPopup.getBoundingClientRect(); + is(rect.left, 24, testname + " left"); + is(rect.top, 20, testname + " top"); + ok(rect.right, testname + " right is " + rect.right); + ok(rect.bottom, testname + " bottom is " + rect.bottom); + }, + }, + { + // check that pressing a menuitem's accelerator selects it. Note that + // the menuitem with the M accesskey overrides the earlier menuitem that + // begins with M. + testname: "menuitem accelerator", + events: [ + "DOMMenuItemActive amenu", + "DOMMenuItemInactive amenu", + "DOMMenuInactive thepopup", + "command amenu", + "popuphiding thepopup", + "popuphidden thepopup", + ], + test() { + sendString("M"); + }, + result(testname) { + checkClosed("trigger", testname); + }, + }, + { + testname: "open context popup at screen", + events: ["popupshowing thepopup 0010", "popupshown thepopup"], + test(testname, step) { + gExpectedTriggerNode = gCachedEvent.target; + gMenuPopup.openPopupAtScreen( + gScreenX + 8, + gScreenY + 16, + true, + gCachedEvent + ); + }, + result(testname, step) { + gExpectedTriggerNode = null; + is(gMenuPopup.anchorNode, null, testname + " anchorNode"); + is( + gMenuPopup.triggerNode, + gCachedEvent.target, + testname + " triggerNode" + ); + + var openX = 8; + var openY = 16; + var rect = gMenuPopup.getBoundingClientRect(); + is(rect.left, openX + (platformIsMac() ? 1 : 2), testname + " left"); + is(rect.top, openY + (platformIsMac() ? -6 : 2), testname + " top"); + ok(rect.right, testname + " right is " + rect.right); + ok(rect.bottom, testname + " bottom is " + rect.bottom); + }, + }, + { + // pressing a letter that doesn't correspond to an accelerator, but does + // correspond to the first letter in a menu's label. The menu should not + // close because there is more than one item corresponding to that letter + testname: "menuitem with non accelerator", + events: ["DOMMenuItemActive one"], + test() { + sendString("O"); + }, + result(testname) { + checkOpen("trigger", testname); + checkActive(gMenuPopup, "one", testname); + }, + }, + { + // pressing the letter again should select the next one that starts with + // that letter + testname: "menuitem with non accelerator again", + events: ["DOMMenuItemInactive one", "DOMMenuItemActive submenu"], + test() { + sendString("O"); + }, + result(testname) { + // 'submenu' is a menu but it should not be open + checkOpen("trigger", testname); + checkClosed("submenu", testname); + checkActive(gMenuPopup, "submenu", testname); + }, + }, + { + // open the submenu with the cursor right key + testname: "open submenu with cursor right", + events: [ + "popupshowing submenupopup", + "DOMMenuItemActive submenuitem", + "popupshown submenupopup", + ], + test() { + synthesizeKey("KEY_ArrowRight"); + }, + result(testname) { + checkOpen("trigger", testname); + checkOpen("submenu", testname); + checkActive(gMenuPopup, "submenu", testname); + checkActive( + document.getElementById("submenupopup"), + "submenuitem", + testname + ); + }, + }, + { + // close the submenu with the cursor left key + testname: "close submenu with cursor left", + events: [ + "popuphiding submenupopup", + "popuphidden submenupopup", + "DOMMenuItemInactive submenuitem", + "DOMMenuInactive submenupopup", + ], + test() { + synthesizeKey("KEY_ArrowLeft"); + }, + result(testname) { + checkOpen("trigger", testname); + checkClosed("submenu", testname); + checkActive(gMenuPopup, "submenu", testname); + checkActive(document.getElementById("submenupopup"), "", testname); + }, + }, + { + // open the submenu with the enter key + testname: "open submenu with enter", + events: [ + "popupshowing submenupopup", + "DOMMenuItemActive submenuitem", + "popupshown submenupopup", + ], + test() { + synthesizeKey("KEY_Enter"); + }, + result(testname) { + checkOpen("trigger", testname); + checkOpen("submenu", testname); + checkActive(gMenuPopup, "submenu", testname); + checkActive( + document.getElementById("submenupopup"), + "submenuitem", + testname + ); + }, + }, + { + // close the submenu with the escape key + testname: "close submenu with escape", + events: [ + "popuphiding submenupopup", + "popuphidden submenupopup", + "DOMMenuItemInactive submenuitem", + "DOMMenuInactive submenupopup", + ], + test() { + synthesizeKey("KEY_Escape"); + }, + result(testname) { + checkOpen("trigger", testname); + checkClosed("submenu", testname); + checkActive(gMenuPopup, "submenu", testname); + checkActive(document.getElementById("submenupopup"), "", testname); + }, + }, + { + // pressing the letter again when the next item is disabled should still + // select the disabled item on Windows, but select the next item on other + // platforms + testname: "menuitem with non accelerator disabled", + events() { + if (navigator.platform.indexOf("Win") == 0) { + return [ + "DOMMenuItemInactive submenu", + "DOMMenuItemActive other", + "DOMMenuItemInactive other", + "DOMMenuItemActive item1", + ]; + } + return [ + "DOMMenuItemInactive submenu", + "DOMMenuItemActive last", + "DOMMenuItemInactive last", + "DOMMenuItemActive item1", + ]; + }, + test() { + sendString("OF"); + }, + result(testname) { + checkActive(gMenuPopup, "item1", testname); + }, + }, + { + // pressing a letter that doesn't correspond to an accelerator nor the + // first letter of a menu. This should have no effect. + testname: "menuitem with keypress no accelerator found", + test() { + sendString("G"); + }, + result(testname) { + checkOpen("trigger", testname); + checkActive(gMenuPopup, "item1", testname); + }, + }, + { + // when only one menuitem starting with that letter exists, it should be + // selected and the menu closed + testname: "menuitem with non accelerator single", + events: [ + "DOMMenuItemInactive item1", + "DOMMenuItemActive amenu", + "DOMMenuItemInactive amenu", + "DOMMenuInactive thepopup", + "command amenu", + "popuphiding thepopup", + "popuphidden thepopup", + ], + test() { + sendString("M"); + }, + result(testname) { + checkClosed("trigger", testname); + checkActive(gMenuPopup, "", testname); + }, + }, + { + testname: "open context popup at screen with all modifiers set", + events: ["popupshowing thepopup 1111", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + gMenuPopup.openPopupAtScreen( + gScreenX + 8, + gScreenY + 16, + true, + gCachedEvent2 + ); + }, + }, + { + testname: "open popup with open property", + events: ["popupshowing thepopup", "popupshown thepopup"], + test(testname, step) { + openMenu(gTrigger); + }, + result(testname, step) { + checkOpen("trigger", testname); + if (gIsMenu) { + compareEdge(gTrigger, gMenuPopup, "after_start", 0, 0, testname); + } + }, + }, + { + testname: "open submenu with open property", + events: [ + "popupshowing submenupopup", + "DOMMenuItemActive submenu", + "popupshown submenupopup", + ], + test(testname, step) { + openMenu(document.getElementById("submenu")); + }, + result(testname, step) { + checkOpen("trigger", testname); + checkOpen("submenu", testname); + // XXXndeakin + // getBoundingClientRect doesn't seem to working right for submenus + // so disable this test for now + // compareEdge(document.getElementById("submenu"), + // document.getElementById("submenupopup"), "end_before", 0, 0, testname); + }, + }, + { + testname: "hidePopup hides entire chain", + events: [ + "popuphiding submenupopup", + "popuphidden submenupopup", + "popuphiding thepopup", + "popuphidden thepopup", + "DOMMenuInactive submenupopup", + "DOMMenuItemInactive submenu", + "DOMMenuInactive thepopup", + ], + test() { + gMenuPopup.hidePopup(); + }, + result(testname, step) { + checkClosed("trigger", testname); + checkClosed("submenu", testname); + }, + }, + { + testname: "open submenu with open property without parent open", + test(testname, step) { + openMenu(document.getElementById("submenu")); + }, + result(testname, step) { + checkClosed("trigger", testname); + checkClosed("submenu", testname); + }, + }, + { + testname: "open popup with open property and position", + condition() { + return gIsMenu; + }, + events: ["popupshowing thepopup", "popupshown thepopup"], + test(testname, step) { + gMenuPopup.setAttribute("position", "before_start"); + openMenu(gTrigger); + }, + result(testname, step) { + compareEdge(gTrigger, gMenuPopup, "before_start", 0, 0, testname); + }, + }, + { + testname: "close popup with open property", + condition() { + return gIsMenu; + }, + events: [ + "popuphiding thepopup", + "popuphidden thepopup", + "DOMMenuInactive thepopup", + ], + test(testname, step) { + closeMenu(gTrigger, gMenuPopup); + }, + result(testname, step) { + checkClosed("trigger", testname); + }, + }, + { + testname: "open popup with open property, position, anchor and alignment", + condition() { + return gIsMenu; + }, + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + gMenuPopup.setAttribute("position", "start_after"); + gMenuPopup.setAttribute("popupanchor", "topright"); + gMenuPopup.setAttribute("popupalign", "bottomright"); + openMenu(gTrigger); + }, + result(testname, step) { + compareEdge(gTrigger, gMenuPopup, "start_after", 0, 0, testname); + }, + }, + { + testname: "open popup with open property, anchor and alignment", + condition() { + return gIsMenu; + }, + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + gMenuPopup.removeAttribute("position"); + gMenuPopup.setAttribute("popupanchor", "bottomright"); + gMenuPopup.setAttribute("popupalign", "topright"); + openMenu(gTrigger); + }, + result(testname, step) { + compareEdge(gTrigger, gMenuPopup, "after_end", 0, 0, testname); + gMenuPopup.removeAttribute("popupanchor"); + gMenuPopup.removeAttribute("popupalign"); + }, + }, + { + testname: "focus and cursor down on trigger", + condition() { + return gIsMenu; + }, + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + gTrigger.focus(); + synthesizeKey("KEY_ArrowDown", { altKey: !platformIsMac() }); + }, + result(testname, step) { + checkOpen("trigger", testname); + checkActive(gMenuPopup, "", testname); + }, + }, + { + testname: "focus and cursor up on trigger", + condition() { + return gIsMenu; + }, + events: ["popupshowing thepopup", "popupshown thepopup"], + test(testname, step) { + gTrigger.focus(); + synthesizeKey("KEY_ArrowUp", { altKey: !platformIsMac() }); + }, + result(testname, step) { + checkOpen("trigger", testname); + checkActive(gMenuPopup, "", testname); + }, + }, + { + testname: "select and enter on menuitem", + condition() { + return gIsMenu; + }, + events: [ + "DOMMenuItemActive item1", + "DOMMenuItemInactive item1", + "DOMMenuInactive thepopup", + "command item1", + "popuphiding thepopup", + "popuphidden thepopup", + ], + test(testname, step) { + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); + }, + result(testname, step) { + checkClosed("trigger", testname); + }, + }, + { + testname: "focus trigger and key to open", + condition() { + return gIsMenu; + }, + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + gTrigger.focus(); + synthesizeKey(platformIsMac() ? " " : "KEY_F4"); + }, + result(testname, step) { + checkOpen("trigger", testname); + checkActive(gMenuPopup, "", testname); + }, + }, + { + // the menu should only open when the meta or alt key is not pressed + testname: "focus trigger and key wrong modifier", + condition() { + return gIsMenu; + }, + test(testname, step) { + gTrigger.focus(); + if (platformIsMac()) { + synthesizeKey("KEY_F4", { altKey: true }); + } else { + synthesizeKey("", { metaKey: true }); + } + }, + result(testname, step) { + checkClosed("trigger", testname); + }, + }, + { + testname: "mouse click on disabled menu", + condition() { + return gIsMenu; + }, + test(testname, step) { + gTrigger.setAttribute("disabled", "true"); + synthesizeMouse(gTrigger, 4, 4, {}); + }, + result(testname, step) { + checkClosed("trigger", testname); + gTrigger.removeAttribute("disabled"); + }, + }, + { + // openPopup using object as position argument + testname: "openPopup with object argument", + events: ["popupshowing thepopup 0000", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + gMenuPopup.openPopup(gTrigger, { position: "before_start", x: 5, y: 7 }); + checkOpen("trigger", testname); + }, + result(testname, step) { + var triggerrect = gTrigger.getBoundingClientRect(); + var popuprect = gMenuPopup.getBoundingClientRect(); + is( + Math.round(popuprect.left), + Math.round(triggerrect.left + 5), + testname + " x position " + ); + is( + Math.round(popuprect.bottom), + Math.round(triggerrect.top + 7), + testname + " y position " + ); + }, + }, + { + testname: "openPopup with object argument with event", + events: ["popupshowing thepopup 1000", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + gMenuPopup.openPopup(gTrigger, { + position: "after_start", + x: 0, + y: 0, + triggerEvent: new MouseEvent("mousedown", { altKey: true }), + }); + checkOpen("trigger", testname); + }, + }, + { + testname: "openPopup with no arguments", + events: ["popupshowing thepopup", "popupshown thepopup"], + autohide: "thepopup", + test(testname, step) { + gMenuPopup.openPopup(); + }, + result(testname, step) { + let isMenu = gTrigger.type == "menu"; + // With no arguments, open in default menu position + var triggerrect = gTrigger.getBoundingClientRect(); + var popuprect = gMenuPopup.getBoundingClientRect(); + is( + Math.round(popuprect.left), + isMenu ? Math.round(triggerrect.left) : 0, + testname + " x position " + ); + is( + Math.round(popuprect.top), + isMenu ? Math.round(triggerrect.bottom) : 0, + testname + " y position " + ); + }, + }, + { + // openPopup should open the menu synchronously, however popupshown + // is fired asynchronously + testname: "openPopup synchronous", + events: [ + "popupshowing thepopup", + "popupshowing submenupopup", + "popupshown thepopup", + "DOMMenuItemActive submenu", + "popupshown submenupopup", + ], + test(testname, step) { + gMenuPopup.openPopup(gTrigger, "after_start", 0, 0, false, true); + document + .getElementById("submenupopup") + .openPopup(gTrigger, "end_before", 0, 0, false, true); + checkOpen("trigger", testname); + checkOpen("submenu", testname); + }, + }, + { + // remove the content nodes for the popup + testname: "remove content", + test(testname, step) { + var submenupopup = document.getElementById("submenupopup"); + submenupopup.remove(); + var popup = document.getElementById("thepopup"); + popup.remove(); + }, + }, +]; + +function platformIsMac() { + return navigator.platform.indexOf("Mac") > -1; +} diff --git a/toolkit/content/tests/chrome/sample_entireword_latin1.html b/toolkit/content/tests/chrome/sample_entireword_latin1.html new file mode 100644 index 0000000000..b2d66fa3c4 --- /dev/null +++ b/toolkit/content/tests/chrome/sample_entireword_latin1.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head><title>Latin entire-word find test page</title></head> + <body> + <!-- Feel free to extend the contents of this page with more comprehensive + - Latin punctuation and/ or word markers. + --> + <p>The twins of Mammon quarrelled. Their warring plunged the world into a new darkness, and the beast abhorred the darkness. So it began to move swiftly, and grew more powerful, and went forth and multiplied. And the beasts brought fire and light to the darkness.</p> + <p>from The Book of Mozilla, 15:1</p> + </body> +</html> diff --git a/toolkit/content/tests/chrome/test_about_networking.html b/toolkit/content/tests/chrome/test_about_networking.html new file mode 100644 index 0000000000..5465c07751 --- /dev/null +++ b/toolkit/content/tests/chrome/test_about_networking.html @@ -0,0 +1,54 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=912103 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script type="application/javascript"> + + SimpleTest.waitForExplicitFinish(); + + function runTest() { + var dashboard = Cc["@mozilla.org/network/dashboard;1"] + .getService(Ci.nsIDashboard); + dashboard.enableLogging = true; + + var wsURI = "ws://mochi.test:8888/chrome/toolkit/content/tests/chrome/file_about_networking"; + var websocket = new WebSocket(wsURI); + + websocket.addEventListener("open", function() { + dashboard.requestWebsocketConnections(function(data) { + var found = false; + for (var i = 0; i < data.websockets.length; i++) { + if (data.websockets[i].hostport == "mochi.test:8888") { + found = true; + break; + } + } + isnot(found, false, "tested websocket entry not found"); + websocket.close(); + SimpleTest.finish(); + }); + }); + } + + window.addEventListener("DOMContentLoaded", function() { + runTest(); + }, {once: true}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=912103">Mozilla Bug </a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_arrowpanel.xhtml b/toolkit/content/tests/chrome/test_arrowpanel.xhtml new file mode 100644 index 0000000000..cd8d312e1d --- /dev/null +++ b/toolkit/content/tests/chrome/test_arrowpanel.xhtml @@ -0,0 +1,332 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Arrow Panels" + style="padding: 10px;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<stack flex="1"> + <label id="topleft" value="Top Left Corner" style="justify-self: left; margin-left: 15px; align-self: start; margin-top: 15px;"/> + <label id="topright" value="Top Right" style="justify-self: right; margin-right: 15px; align-self: start; margin-top: 15px;"/> + <label id="bottomleft" value="Bottom Left Corner" style="justify-self: left; margin-left: 15px; align-self: end; margin-bottom: 15px;"/> + <label id="bottomright" value="Bottom Right" style="justify-self: right; margin-right: 15px; align-self: end; margin-bottom: 15px;"/> + <!-- Our SimpleTest/TestRunner.js runs tests inside an iframe which sizes are W=500 H=300. + 'left' and 'top' values need to be set so that the panel (popup) has enough room to display on its 4 sides. --> + <label id="middle" value="+/- Centered" style="justify-self: left; margin-left: 225px; align-self: start; margin-top: 135px;"/> + <iframe id="frame" type="content" + src="data:text/html,<input id='input'>" style="width: 100px; height: 100px; justify-self: left; margin-left: 225px; align-self: start; margin-top: 120px;"/> +</stack> + +<panel id="panel" type="arrow" animate="false" + onpopupshown="checkPanelPosition(this)" onpopuphidden="runNextTest.next()"> + <box style="width: 115px; height: 65px"/> +</panel> + +<panel id="bigpanel" type="arrow" animate="false" + onpopupshown="checkBigPanel(this)" onpopuphidden="runNextTest.next()"> + <box style="width: 125px; height: 3000px"/> +</panel> + +<panel id="animatepanel" type="arrow" + onpopupshown="animatedPopupShown = true;" + onpopuphidden="animatedPopupHidden = true; runNextTest.next();"> + <label value="Animate Closed" style="height: 40px"/> +</panel> + +<html:style type="text/css"> + panel { + /** + * We hardcode a panel padding here to avoid rounding issues caused by + * using em unit padding, which is the default as of bug 1701920. + */ + --arrowpanel-padding: 16px; + /** + * Linux and windows have some negative margin-inline that can change the + * overflow calculations + */ + margin-inline: 0 !important; + } +</html:style> + +<script type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var expectedAnchor = null; +var expectedSide = "", expectedAnchorEdge = ""; +var zoomFactor = 1; +var animatedPopupShown = false; +var animatedPopupHidden = false; +var runNextTest; + +function startTest() +{ + runNextTest = nextTest(); + runNextTest.next(); +} + +function* nextTest() +{ + var panel = $("panel"); + + function openPopup(position, anchor, expected, anchorEdge) + { + expectedAnchor = Node.isInstance(anchor) ? anchor : $(anchor); + expectedSide = expected; + expectedAnchorEdge = anchorEdge; + + panel.removeAttribute("side"); + panel.openPopup(expectedAnchor, position, 0, 0, false, false, null); + } + + for (var iter = 0; iter < 2; iter++) { + openPopup("after_start", "topleft", "top", "left"); + yield; + openPopup("after_start", "bottomleft", "bottom", "left"); + yield; + openPopup("before_start", "topleft", "top", "left"); + yield; + openPopup("before_start", "bottomleft", "bottom", "left"); + yield; + openPopup("after_start", "middle", "top", "left"); + yield; + openPopup("before_start", "middle", "bottom", "left"); + yield; + + openPopup("after_start", "topright", "top", "right"); + yield; + openPopup("after_start", "bottomright", "bottom", "right"); + yield; + openPopup("before_start", "topright", "top", "right"); + yield; + openPopup("before_start", "bottomright", "bottom", "right"); + yield; + + openPopup("after_end", "middle", "top", "right"); + yield; + openPopup("before_end", "middle", "bottom", "right"); + yield; + + openPopup("start_before", "topleft", "left", "top"); + yield; + openPopup("start_before", "topright", "right", "top"); + yield; + openPopup("end_before", "topleft", "left", "top"); + yield; + openPopup("end_before", "topright", "right", "top"); + yield; + openPopup("start_before", "middle", "right", "top"); + yield; + openPopup("end_before", "middle", "left", "top"); + yield; + + openPopup("start_before", "bottomleft", "left", "bottom"); + yield; + openPopup("start_before", "bottomright", "right", "bottom"); + yield; + openPopup("end_before", "bottomleft", "left", "bottom"); + yield; + openPopup("end_before", "bottomright", "right", "bottom"); + yield; + + openPopup("start_after", "middle", "right", "bottom"); + yield; + openPopup("end_after", "middle", "left", "bottom"); + yield; + + openPopup("topcenter bottomleft", "bottomleft", "bottom", "center left"); + yield; + openPopup("bottomcenter topleft", "topleft", "top", "center left"); + yield; + openPopup("topcenter bottomright", "bottomright", "bottom", "center right"); + yield; + openPopup("bottomcenter topright", "topright", "top", "center right"); + yield; + openPopup("topcenter bottomleft", "middle", "bottom", "center left"); + yield; + openPopup("bottomcenter topleft", "middle", "top", "center left"); + yield; + + openPopup("leftcenter topright", "middle", "right", "center top"); + yield; + openPopup("rightcenter bottomleft", "middle", "left", "center bottom"); + yield; + +/* + XXXndeakin disable these parts of the test which often cause problems, see bug 626563 + + openPopup("after_start", frames[0].document.getElementById("input"), "top", "left"); + yield; + + setScale(frames[0], 1.5); + openPopup("after_start", frames[0].document.getElementById("input"), "top", "left"); + yield; + + setScale(frames[0], 2.5); + openPopup("before_start", frames[0].document.getElementById("input"), "bottom", "left"); + yield; + + setScale(frames[0], 1); +*/ + + $("bigpanel").openPopup($("topleft"), "after_start", 0, 0, false, false, null, "start"); + yield; + + // switch to rtl mode + document.documentElement.style.direction = "rtl"; + + $("topleft").style.marginRight = "15px"; + $("topleft").style.justifySelf = "right"; + + $("topright").style.marginLeft = "15px"; + $("topright").style.justifySelf = "left"; + + $("bottomleft").style.marginRight = "15px"; + $("bottomleft").style.justifySelf = "right"; + + $("bottomright").style.marginLeft = "15px"; + $("bottomright").style.justifySelf = "left"; + + $("topleft").style.removeProperty("margin-left"); + $("topright").style.removeProperty("margin-right"); + $("bottomleft").style.removeProperty("margin-left"); + $("bottomright").style.removeProperty("margin-right"); + } + + // Test that a transition occurs when opening or closing the popup. + if (matchMedia("(-moz-panel-animations").matches) { + function transitionEnded(event) { + if ($("animatepanel").state != "open") { + is($("animatepanel").state, "showing", "state is showing during transitionend"); + ok(!animatedPopupShown, "popupshown not fired yet") + } else { + is($("animatepanel").state, "open", "state is open after transitionend"); + ok(animatedPopupShown, "popupshown now fired") + SimpleTest.executeSoon(() => runNextTest.next()); + } + } + + // Check that the transition occurs for an arrow panel with animate="true" + $("animatepanel").addEventListener("transitionend", transitionEnded); + $("animatepanel").openPopup($("topleft"), "after_start", 0, 0, false, false, null, "start"); + is($("animatepanel").state, "showing", "state is showing"); + yield; + $("animatepanel").removeEventListener("transitionend", transitionEnded); + + synthesizeKey("KEY_Escape"); + ok(!animatedPopupHidden, "animated popup not hidden yet"); + yield; + } + + SimpleTest.finish() +} + +function setScale(win, scale) +{ + SpecialPowers.setFullZoom(win, scale); + zoomFactor = scale; +} + +function checkPanelPosition(panel) +{ + let anchor = panel.anchorNode; + let adj = 0, hwinpos = 0, vwinpos = 0; + if (anchor.ownerDocument != document) { + var framerect = anchor.ownerGlobal.frameElement.getBoundingClientRect(); + hwinpos = framerect.left; + vwinpos = framerect.top; + } + + // Positions are reversed in rtl yet the coordinates used in the computations + // are not, so flip the expected label side and anchor edge. + var isRTL = (window.getComputedStyle(panel).direction == "rtl"); + if (isRTL) { + var flipLeftRight = val => val == "left" ? "right" : "left"; + expectedAnchorEdge = expectedAnchorEdge.replace(/(left|right)/, flipLeftRight); + expectedSide = expectedSide.replace(/(left|right)/, flipLeftRight); + } + + var panelRect = panel.getBoundingClientRect(); + var anchorRect = anchor.getBoundingClientRect(); + var contentRect = panel.firstChild.getBoundingClientRect(); + switch (expectedSide) { + case "top": + ok(contentRect.top > vwinpos + anchorRect.bottom * zoomFactor + 5, "panel content is below"); + break; + case "bottom": + ok(contentRect.bottom < vwinpos + anchorRect.top * zoomFactor - 5, "panel content is above"); + break; + case "left": + ok(contentRect.left > hwinpos + anchorRect.right * zoomFactor + 5, "panel content is right"); + break; + case "right": + ok(contentRect.right < hwinpos + anchorRect.left * zoomFactor - 5, "panel content is left"); + break; + } + + let desc = panel.id + ": anchored on " + expectedAnchorEdge + " to " + anchor.id + " | " + (isRTL ? "rtl" : "ltr") + " | " + anchor.getAttribute("style"); + let iscentered = false; + if (expectedAnchorEdge.indexOf("center ") == 0) { + expectedAnchorEdge = expectedAnchorEdge.substring(7); + iscentered = true; + } + + switch (expectedAnchorEdge) { + case "top": + adj = vwinpos + parseInt(getComputedStyle(panel).marginTop); + if (iscentered) + adj += anchorRect.height / 2; + isWithinHalfPixel(panelRect.top, anchorRect.top * zoomFactor + adj, desc); + break; + case "bottom": + adj = vwinpos + parseInt(getComputedStyle(panel).marginBottom); + if (iscentered) + adj += anchorRect.height / 2; + isWithinHalfPixel(panelRect.bottom, anchorRect.bottom * zoomFactor - adj, desc); + break; + case "left": + adj = hwinpos + parseInt(getComputedStyle(panel).marginLeft); + if (iscentered) + adj += anchorRect.width / 2; + isWithinHalfPixel(panelRect.left, anchorRect.left * zoomFactor + adj, desc); + break; + case "right": + adj = hwinpos + parseInt(getComputedStyle(panel).marginRight); + if (iscentered) + adj += anchorRect.width / 2; + isWithinHalfPixel(panelRect.right, anchorRect.right * zoomFactor - adj, desc); + break; + } + + is(anchor, expectedAnchor, "anchor"); + + is(panel.getAttribute("side"), expectedSide, "panel arrow side"); + + panel.hidePopup(); +} + +function isWithinHalfPixel(a, b, desc) +{ + ok(Math.abs(a - b) <= 0.5, `${desc}: ${a} vs. ${b}`); +} + +function checkBigPanel(panel) +{ + ok(panel.getBoundingClientRect().height < screen.height, "big panel height"); + panel.hidePopup(); +} + +SimpleTest.waitForFocus(startTest); + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"/> + +</window> diff --git a/toolkit/content/tests/chrome/test_autocomplete2.xhtml b/toolkit/content/tests/chrome/test_autocomplete2.xhtml new file mode 100644 index 0000000000..844305c321 --- /dev/null +++ b/toolkit/content/tests/chrome/test_autocomplete2.xhtml @@ -0,0 +1,189 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Autocomplete Widget Test 2" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<html:input id="autocomplete" + is="autocomplete-input" + autocompletesearch="simple"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +// Set to indicate whether or not we want autoCompleteSimple to return a result +var returnResult = false; + +const ACR = Ci.nsIAutoCompleteResult; + +// This result can't be constructed in-line, because otherwise we leak memory. +function nsAutoCompleteSimpleResult(aString) +{ + this.searchString = aString; + if (returnResult) { + this.searchResult = ACR.RESULT_SUCCESS; + this.matchCount = 1; + this._param = "SUCCESS"; + } +} + +nsAutoCompleteSimpleResult.prototype = { + _param: "", + searchString: null, + searchResult: ACR.RESULT_FAILURE, + defaultIndex: -1, + errorDescription: null, + matchCount: 0, + getValueAt() { return this._param; }, + getCommentAt() { return null; }, + getStyleAt() { return null; }, + getImageAtn() { return null; }, + getFinalCompleteValueAt() { return this.getValueAt(); }, + getLabelAt() { return null; }, + removeValueAt() {} +}; + +// A basic autocomplete implementation that either returns one result or none +var autoCompleteSimpleID = Components.ID("0a2afbdb-f30e-47d1-9cb1-0cd160240aca"); +var autoCompleteSimpleName = "@mozilla.org/autocomplete/search;1?name=simple" +var autoCompleteSimple = { + QueryInterface: ChromeUtils.generateQI(["nsIFactory", "nsIAutoCompleteSearch"]), + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + startSearch(aString, aParam, aResult, aListener) { + var result = new nsAutoCompleteSimpleResult(aString); + aListener.onSearchResult(this, result); + }, + + stopSearch() {} +}; + +var componentManager = Components.manager + .QueryInterface(Ci.nsIComponentRegistrar); +componentManager.registerFactory(autoCompleteSimpleID, "Test Simple Autocomplete", + autoCompleteSimpleName, autoCompleteSimple); + +var element = document.getElementById("autocomplete"); + +// Create stub to intercept `onSearchComplete` event. +element.onSearchComplete = function(original) { + return function() { + original.apply(this, arguments); + checkResult(); + }; +}(element.onSearchComplete); + +// Test Bug 441530 - correctly setting "nomatch" +// Test Bug 441526 - correctly setting style with "highlightnonmatches" + +SimpleTest.waitForExplicitFinish(); +setTimeout(startTest, 0); + +function startTest() { + var autocomplete = $("autocomplete"); + + // Ensure highlightNonMatches can be set correctly. + + // This should not be set by default. + is(autocomplete.hasAttribute("highlightnonmatches"), false, + "highlight nonmatches not set by default"); + + autocomplete.highlightNonMatches = "true"; + + is(autocomplete.getAttribute("highlightnonmatches"), "true", + "highlight non matches attribute set correctly"); + is(autocomplete.highlightNonMatches, true, + "highlight non matches getter returned correctly"); + + autocomplete.highlightNonMatches = "false"; + + is(autocomplete.getAttribute("highlightnonmatches"), "false", + "highlight non matches attribute set to false correctly"); + is(autocomplete.highlightNonMatches, false, + "highlight non matches getter returned false correctly"); + + check(); +} + +function check() { + var autocomplete = $("autocomplete"); + + // Toggle this value, so we can re-use the one function. + returnResult = !returnResult; + + // blur the field to ensure that the popup is closed and that the previous + // search has stopped, then start a new search. + autocomplete.blur(); + autocomplete.focus(); + sendString("r"); +} + +function checkResult() { + var autocomplete = $("autocomplete"); + var style = window.getComputedStyle(autocomplete); + + if (returnResult) { + // Result was returned, so there should not be a nomatch attribute + is(autocomplete.hasAttribute("nomatch"), false, + "nomatch attribute shouldn't be present here"); + + // Ensure that the style is set correctly whichever way highlightNonMatches + // is set. + autocomplete.highlightNonMatches = "true"; + + isnot(style.color, "rgb(255, 0, 0)", + "not nomatch and highlightNonMatches - should not be red"); + + autocomplete.highlightNonMatches = "false"; + + isnot(style.color, "rgb(255, 0, 0)", + "not nomatch and not highlightNonMatches - should not be red"); + + setTimeout(check, 0); + } + else { + // No result was returned, so there should be nomatch attribute + is(autocomplete.getAttribute("nomatch"), "true", + "nomatch attribute not correctly set when expected"); + + // Ensure that the style is set correctly whichever way highlightNonMatches + // is set. + autocomplete.highlightNonMatches = "true"; + + is(style.color, "rgb(255, 0, 0)", + "nomatch and highlightNonMatches - should be red"); + + autocomplete.highlightNonMatches = "false"; + + isnot(style.color, "rgb(255, 0, 0)", + "nomatch and not highlightNonMatches - should not be red"); + + setTimeout(function() { + // Unregister the factory so that we don't get in the way of other tests + componentManager.unregisterFactory(autoCompleteSimpleID, autoCompleteSimple); + SimpleTest.finish(); + }, 0); + } +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_autocomplete3.xhtml b/toolkit/content/tests/chrome/test_autocomplete3.xhtml new file mode 100644 index 0000000000..a1b9ef84ea --- /dev/null +++ b/toolkit/content/tests/chrome/test_autocomplete3.xhtml @@ -0,0 +1,200 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Autocomplete Widget Test 3" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<html:input id="autocomplete" + is="autocomplete-input" + autocompletesearch="simple"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +// Set to indicate whether or not we want autoCompleteSimple to return a result +var returnResult = true; + +const ACR = Ci.nsIAutoCompleteResult; + +// This result can't be constructed in-line, because otherwise we leak memory. +function nsAutoCompleteSimpleResult(aString) +{ + this.searchString = aString; + if (returnResult) { + this.searchResult = ACR.RESULT_SUCCESS; + this.matchCount = 1; + this._param = "Result"; + } +} + +nsAutoCompleteSimpleResult.prototype = { + _param: "", + searchString: null, + searchResult: ACR.RESULT_FAILURE, + defaultIndex: 0, + errorDescription: null, + matchCount: 0, + getValueAt() { return this._param; }, + getCommentAt() { return null; }, + getStyleAt() { return null; }, + getImageAt() { return null; }, + getFinalCompleteValueAt() { return this.getValueAt(); }, + getLabelAt() { return null; }, + removeValueAt() {} +}; + +// A basic autocomplete implementation that either returns one result or none +var autoCompleteSimpleID = Components.ID("0a2afbdb-f30e-47d1-9cb1-0cd160240aca"); +var autoCompleteSimpleName = "@mozilla.org/autocomplete/search;1?name=simple" +var autoCompleteSimple = { + QueryInterface: ChromeUtils.generateQI(["nsIFactory", "nsIAutoCompleteSearch"]), + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + startSearch(aString, aParam, aResult, aListener) { + var result = new nsAutoCompleteSimpleResult(aString); + aListener.onSearchResult(this, result); + }, + + stopSearch() {} +}; + +var componentManager = Components.manager + .QueryInterface(Ci.nsIComponentRegistrar); +componentManager.registerFactory(autoCompleteSimpleID, "Test Simple Autocomplete", + autoCompleteSimpleName, autoCompleteSimple); + +let element = document.getElementById("autocomplete"); + +// Create stub to intercept `onSearchComplete` event. +element.onSearchComplete = function(original) { + return function() { + original.apply(this, arguments); + checkResult(); + }; +}(element.onSearchComplete); + +// Test Bug 325842 - completeDefaultIndex + +SimpleTest.waitForExplicitFinish(); +setTimeout(startTest, 0); + +var currentTest = 0; + +// Note the entries for these tests (key) are incremental. +const tests = [ + { completeDefaultIndex: "false", key: "r", result: "r", + start: 1, end: 1 }, + { completeDefaultIndex: "true", key: "e", result: "result", + start: 2, end: 6 }, + { completeDefaultIndex: "true", key: "t", result: "ret >> Result", + start: 3, end: 13 } +]; + +function startTest() { + var autocomplete = $("autocomplete"); + + // These should not be set by default. + is(autocomplete.hasAttribute("completedefaultindex"), false, + "completedefaultindex not set by default"); + + autocomplete.completeDefaultIndex = "true"; + + is(autocomplete.getAttribute("completedefaultindex"), "true", + "completedefaultindex attribute set correctly"); + is(autocomplete.completeDefaultIndex, true, + "autoFill getter returned correctly"); + + autocomplete.completeDefaultIndex = "false"; + + is(autocomplete.getAttribute("completedefaultindex"), "false", + "completedefaultindex attribute set to false correctly"); + is(autocomplete.completeDefaultIndex, false, + "completeDefaultIndex getter returned false correctly"); + + checkNext(); +} + +function checkNext() { + var autocomplete = $("autocomplete"); + + autocomplete.completeDefaultIndex = tests[currentTest].completeDefaultIndex; + autocomplete.focus(); + + synthesizeKey(tests[currentTest].key); +} + +function checkResult() { + var autocomplete = $("autocomplete"); + + is(autocomplete.value, tests[currentTest].result, + "Test " + currentTest + ": autocomplete.value should equal '" + + tests[currentTest].result + "'"); + + is(autocomplete.selectionStart, tests[currentTest].start, + "Test " + currentTest + ": autocomplete selection should start at " + + tests[currentTest].start); + + is(autocomplete.selectionEnd, tests[currentTest].end, + "Test " + currentTest + ": autocomplete selection should end at " + + tests[currentTest].end); + + ++currentTest; + + if (currentTest < tests.length) { + setTimeout(checkNext, 0); + } else { + // TODO (bug 494809): Autocomplete-in-the-middle should take in count RTL + // and complete on KEY_ArrowRight or KEY_ArrowLeft based on that. It should also revert + // what user has typed to far if he moves in the opposite direction. + if (!autocomplete.value.includes(">>")) { + // Test result if user accepts autocomplete suggestion. + synthesizeKey("KEY_ArrowRight"); + is( + autocomplete.value, + "Result", + "Test complete: autocomplete.value should equal 'Result'" + ); + is( + autocomplete.selectionStart, + 6, + "Test complete: autocomplete selection should start at 6" + ); + is( + autocomplete.selectionEnd, + 6, + "Test complete: autocomplete selection should end at 6" + ); + } + + setTimeout(function () { + // Unregister the factory so that we don't get in the way of other tests + componentManager.unregisterFactory( + autoCompleteSimpleID, + autoCompleteSimple + ); + SimpleTest.finish(); + }, 0); + } +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_autocomplete4.xhtml b/toolkit/content/tests/chrome/test_autocomplete4.xhtml new file mode 100644 index 0000000000..bb16194e55 --- /dev/null +++ b/toolkit/content/tests/chrome/test_autocomplete4.xhtml @@ -0,0 +1,280 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Autocomplete Widget Test 4" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<html:input id="autocomplete" + is="autocomplete-input" + completedefaultindex="true" + autocompletesearch="simple"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +// Set to indicate whether or not we want autoCompleteSimple to return a result +var returnResult = true; + +const IS_MAC = navigator.platform.includes("Mac"); + +const ACR = Ci.nsIAutoCompleteResult; + +// This result can't be constructed in-line, because otherwise we leak memory. +function nsAutoCompleteSimpleResult(aString) +{ + this.searchString = aString; + if (returnResult) { + this.searchResult = ACR.RESULT_SUCCESS; + this.matchCount = 1; + this._param = "Result"; + } +} + +nsAutoCompleteSimpleResult.prototype = { + _param: "", + searchString: null, + searchResult: ACR.RESULT_FAILURE, + defaultIndex: 0, + errorDescription: null, + matchCount: 0, + getValueAt() { return this._param; }, + getCommentAt() { return null; }, + getStyleAt() { return null; }, + getImageAt() { return null; }, + getFinalCompleteValueAt() { return this.getValueAt(); }, + getLabelAt() { return null; }, + removeValueAt() {} +}; + +// A basic autocomplete implementation that either returns one result or none +var autoCompleteSimpleID = Components.ID("0a2afbdb-f30e-47d1-9cb1-0cd160240aca"); +var autoCompleteSimpleName = "@mozilla.org/autocomplete/search;1?name=simple" +var autoCompleteSimple = { + QueryInterface: ChromeUtils.generateQI(["nsIFactory", "nsIAutoCompleteSearch"]), + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + startSearch(aString, aParam, aResult, aListener) { + var result = new nsAutoCompleteSimpleResult(aString); + aListener.onSearchResult(this, result); + }, + + stopSearch() {} +}; + +var componentManager = Components.manager + .QueryInterface(Ci.nsIComponentRegistrar); +componentManager.registerFactory(autoCompleteSimpleID, "Test Simple Autocomplete", + autoCompleteSimpleName, autoCompleteSimple); + +let element = document.getElementById("autocomplete"); + +// Create stub to intercept `onSearchComplete` event. +element.onSearchComplete = function(original) { + return function() { + original.apply(this, arguments); + searchComplete(); + }; +}(element.onSearchComplete); + +// Test Bug 325842 - completeDefaultIndex + +SimpleTest.waitForExplicitFinish(); + +setTimeout(nextTest, 0); + +var currentTest = null; + +// Note the entries for these tests (key) are incremental. +const tests = [ + { + desc: "HOME key remove selection", + key: "KEY_Home", + removeSelection: true, + result: "re", + start: 0, end: 0 + }, + { + desc: "LEFT key remove selection", + key: "KEY_ArrowLeft", + removeSelection: true, + result: "re", + start: 1, end: 1 + }, + { desc: "RIGHT key remove selection", + key: "KEY_ArrowRight", + removeSelection: true, + result: "re", + start: 2, end: 2 + }, + { desc: "ENTER key remove selection", + key: "KEY_Enter", + removeSelection: true, + result: "re", + start: 2, end: 2 + }, + { + desc: "HOME key", + key: "KEY_Home", + removeSelection: false, + result: "Result", + start: 0, end: 0 + }, + { + desc: "LEFT key", + key: "KEY_ArrowLeft", + removeSelection: false, + result: "Result", + start: 5, end: 5 + }, + { desc: "RIGHT key", + key: "KEY_ArrowRight", + removeSelection: false, + result: "Result", + start: 6, end: 6 + }, + { desc: "RETURN key", + key: "KEY_Enter", + removeSelection: false, + result: "Result", + start: 6, end: 6 + }, + { desc: "TAB key should confirm suggestion when forcecomplete is set", + key: "KEY_Tab", + removeSelection: false, + forceComplete: true, + result: "Result", + start: 6, end: 6 + }, + + { desc: "RIGHT key complete from middle", + key: "KEY_ArrowRight", + forceComplete: true, + completeFromMiddle: true, + result: "Result", + start: 6, end: 6 + }, + { + desc: "RIGHT key w/ minResultsForPopup=2", + key: "KEY_ArrowRight", + removeSelection: false, + minResultsForPopup: 2, + result: "Result", + start: 6, end: 6 + }, +]; + +function nextTest() { + if (!tests.length) { + // No more tests to run, finish. + setTimeout(function() { + // Unregister the factory so that we don't get in the way of other tests + componentManager.unregisterFactory(autoCompleteSimpleID, autoCompleteSimple); + SimpleTest.finish(); + }, 0); + return; + } + + var autocomplete = $("autocomplete"); + autocomplete.value = ""; + currentTest = tests.shift(); + + // HOME key works differently on Mac, so we skip tests using it. + if (currentTest.key == "KEY_Home" && IS_MAC) + nextTest(); + else + setTimeout(runCurrentTest, 0); +} + +function runCurrentTest() { + var autocomplete = $("autocomplete"); + if ("minResultsForPopup" in currentTest) + autocomplete.setAttribute("minresultsforpopup", currentTest.minResultsForPopup) + else + autocomplete.removeAttribute("minresultsforpopup"); + + autocomplete.focus(); + + if (!currentTest.completeFromMiddle) { + sendString("re"); + } + else { + sendString("lt"); + } +} + +function searchComplete() { + var autocomplete = $("autocomplete"); + autocomplete.setAttribute("forcecomplete", currentTest.forceComplete); + + if (currentTest.completeFromMiddle) { + if (!currentTest.forceComplete) { + synthesizeKey(currentTest.key); + } + else if (!/ >> /.test(autocomplete.value)) { + // At this point we should have a value like "lt >> Result" showing. + throw new Error("Expected an middle-completed value, got " + autocomplete.value); + } + + // For forceComplete a blur should cause a value from the results to get + // completed to. E.g. "lt >> Result" will turn into "Result". + if (currentTest.forceComplete) + autocomplete.blur(); + + checkResult(); + return; + } + + is(autocomplete.value, "result", + "Test '" + currentTest.desc + "': autocomplete.value should equal 'result'"); + + if (autocomplete.selectionStart == 2) { // Finished inserting "re" string. + if (currentTest.removeSelection) { + // remove current selection + synthesizeKey("KEY_Delete"); + } + + synthesizeKey(currentTest.key); + + checkResult(); + } +} + +function checkResult() { + var autocomplete = $("autocomplete"); + + is(autocomplete.value, currentTest.result, + "Test '" + currentTest.desc + "': autocomplete.value should equal '" + + currentTest.result + "'"); + + is(autocomplete.selectionStart, currentTest.start, + "Test '" + currentTest.desc + "': autocomplete selection should start at " + + currentTest.start); + + is(autocomplete.selectionEnd, currentTest.end, + "Test '" + currentTest.desc + "': autocomplete selection should end at " + + currentTest.end); + + setTimeout(nextTest, 0); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_autocomplete5.xhtml b/toolkit/content/tests/chrome/test_autocomplete5.xhtml new file mode 100644 index 0000000000..7c252f355e --- /dev/null +++ b/toolkit/content/tests/chrome/test_autocomplete5.xhtml @@ -0,0 +1,164 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Autocomplete Widget Test 5" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<html:input id="autocomplete" + is="autocomplete-input" + autocompletesearch="simple" + notifylegacyevents="true"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +const ACR = Ci.nsIAutoCompleteResult; + +// This result can't be constructed in-line, because otherwise we leak memory. +function nsAutoCompleteSimpleResult(aString) +{ + this.searchString = aString; + this.searchResult = ACR.RESULT_SUCCESS; + this.matchCount = 1; + this._param = "SUCCESS"; +} + +nsAutoCompleteSimpleResult.prototype = { + _param: "", + searchString: null, + searchResult: ACR.RESULT_FAILURE, + defaultIndex: -1, + errorDescription: null, + matchCount: 0, + getValueAt() { return this._param; }, + getCommentAt() { return null; }, + getStyleAt() { return null; }, + getImageAt() { return null; }, + getFinalCompleteValueAt() { return this.getValueAt(); }, + getLabelAt() { return null; }, + removeValueAt() {} +}; + +// A basic autocomplete implementation that either returns one result or none +var autoCompleteSimpleID = Components.ID("0a2afbdb-f30e-47d1-9cb1-0cd160240aca"); +var autoCompleteSimpleName = "@mozilla.org/autocomplete/search;1?name=simple" +var autoCompleteSimple = { + QueryInterface: ChromeUtils.generateQI(["nsIFactory", "nsIAutoCompleteSearch"]), + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + startSearch(aString, aParam, aResult, aListener) { + var result = new nsAutoCompleteSimpleResult(aString); + aListener.onSearchResult(this, result); + }, + + stopSearch() {} +}; + + +let element = document.getElementById("autocomplete"); + +// Create stub to intercept `onSearchBegin` event. +element.onSearchBegin = function(original) { + return function() { + original.apply(this, arguments); + checkSearchBegin(); + }; +}(element.onSearchBegin); + +// Create stub to intercept `onSearchComplete` event. +element.onSearchComplete = function(original) { + return function() { + original.apply(this, arguments); + checkSearchCompleted(); + }; +}(element.onSearchComplete); + +element.addEventListener("textEntered", checkTextEntered); +element.addEventListener("textReverted", checkTextReverted); + +var componentManager = Components.manager + .QueryInterface(Ci.nsIComponentRegistrar); +componentManager.registerFactory(autoCompleteSimpleID, "Test Simple Autocomplete", + autoCompleteSimpleName, autoCompleteSimple); + +SimpleTest.waitForExplicitFinish(); +setTimeout(startTest, 0); + +function startTest() { + let autocomplete = $("autocomplete"); + + // blur the field to ensure that the popup is closed and that the previous + // search has stopped, then start a new search. + autocomplete.blur(); + autocomplete.focus(); + sendString("r"); +} + +let hasTextEntered = false; +let hasSearchBegun = false; + +function checkSearchBegin() { + hasSearchBegun = true; +} + +let test = 0; +function checkSearchCompleted() { + is(hasSearchBegun, true, "onsearchbegin handler has been correctly called."); + + if (test == 0) { + hasSearchBegun = false; + synthesizeKey("KEY_Enter"); + } else if (test == 1) { + hasSearchBegun = false; + synthesizeKey("KEY_Escape"); + } else { + throw new Error("checkSearchCompleted should only be called twice."); + } +} + +function checkTextEntered() { + is(test, 0, "checkTextEntered should be reached from first test."); + is(hasSearchBegun, false, "onsearchbegin handler should not be called on text revert."); + + // fire second test + test++; + + let autocomplete = $("autocomplete"); + autocomplete.textValue = ""; + autocomplete.blur(); + autocomplete.focus(); + sendString("r"); +} + +function checkTextReverted() { + is(test, 1, "checkTextReverted should be the second test reached."); + is(hasSearchBegun, false, "onsearchbegin handler should not be called on text revert."); + + setTimeout(function() { + // Unregister the factory so that we don't get in the way of other tests + componentManager.unregisterFactory(autoCompleteSimpleID, autoCompleteSimple); + SimpleTest.finish(); + }, 0); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_autocomplete_emphasis.xhtml b/toolkit/content/tests/chrome/test_autocomplete_emphasis.xhtml new file mode 100644 index 0000000000..20eb96323f --- /dev/null +++ b/toolkit/content/tests/chrome/test_autocomplete_emphasis.xhtml @@ -0,0 +1,180 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Autocomplete emphasis test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<html:input id="richautocomplete" + is="autocomplete-input" + autocompletesearch="simple" + autocompletepopup="richpopup"/> +<panel is="autocomplete-richlistbox-popup" + id="richpopup" + type="autocomplete-richlistbox" + noautofocus="true"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +const ACR = Ci.nsIAutoCompleteResult; + +// A global variable to hold the search result for the current search. +var resultText = ""; + +// This result can't be constructed in-line, because otherwise we leak memory. +function nsAutoCompleteSimpleResult(aString) +{ + this.searchString = aString; + this.searchResult = ACR.RESULT_SUCCESS; + this.matchCount = 1; +} + +nsAutoCompleteSimpleResult.prototype = { + searchString: null, + searchResult: ACR.RESULT_FAILURE, + defaultIndex: -1, + errorDescription: null, + matchCount: 0, + getValueAt() { return resultText; }, + getCommentAt() { return this.getValueAt(); }, + getStyleAt() { return null; }, + getImageAt() { return null; }, + getFinalCompleteValueAt() { return this.getValueAt(); }, + getLabelAt() { return this.getValueAt(); }, + removeValueAt() {} +}; + +// A basic autocomplete implementation that returns the string contained in 'resultText'. +var autoCompleteSimpleID = Components.ID("0a2afbdb-f30e-47d1-9cb1-0cd160240aca"); +var autoCompleteSimpleName = "@mozilla.org/autocomplete/search;1?name=simple" +var autoCompleteSimple = { + QueryInterface: ChromeUtils.generateQI(["nsIFactory", "nsIAutoCompleteSearch"]), + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + startSearch(aString, aParam, aResult, aListener) { + var result = new nsAutoCompleteSimpleResult(aString); + aListener.onSearchResult(this, result); + }, + + stopSearch() {} +}; + +var componentManager = Components.manager + .QueryInterface(Ci.nsIComponentRegistrar); +componentManager.registerFactory(autoCompleteSimpleID, "Test Simple Autocomplete", + autoCompleteSimpleName, autoCompleteSimple); + +var element = document.getElementById("richautocomplete"); + +// Create stub to intercept `onSearchComplete` event. +element.onSearchComplete = function(original) { + return function() { + original.apply(this, arguments); + checkSearchCompleted(); + }; +}(element.onSearchComplete); + +SimpleTest.waitForExplicitFinish(); +setTimeout(nextTest, 0); + +/* Test cases have the following attributes: + * - search: A search string, to be emphasized in the result. + * - result: A fixed result string, so we can hardcode the expected emphasis. + * - emphasis: A list of chunks that should be emphasized or not, in strict alternation. + * - emphasizeFirst: Whether the first element of 'emphasis' should be emphasized; + * The emphasis of the other elements is defined by the strict alternation rule. + */ +let testcases = [ + { search: "test", + result: "A test string", + emphasis: ["A ", "test", " string"], + emphasizeFirst: false + }, + { search: "tea two", + result: "Tea for two, and two for tea...", + emphasis: ["Tea", " for ", "two", ", and ", "two", " for ", "tea", "..."], + emphasizeFirst: true + }, + { search: "tat", + result: "tatatat", + emphasis: ["tatatat"], + emphasizeFirst: true + }, + { search: "cheval valise", + result: "chevalise", + emphasis: ["chevalise"], + emphasizeFirst: true + } +]; +let test = -1; +let currentTest = null; + +function nextTest() { + test++; + + if (test >= testcases.length) { + // Unregister the factory so that we don't get in the way of other tests + componentManager.unregisterFactory(autoCompleteSimpleID, autoCompleteSimple); + SimpleTest.finish(); + return; + } + + // blur the field to ensure that the popup is closed and that the previous + // search has stopped, then start a new search. + let autocomplete = $("richautocomplete"); + autocomplete.blur(); + autocomplete.focus(); + + currentTest = testcases[test]; + resultText = currentTest.result; + autocomplete.value = currentTest.search; + synthesizeKey("KEY_ArrowDown"); +} + +function checkSearchCompleted() { + let autocomplete = $("richautocomplete"); + let result = autocomplete.popup.richlistbox.firstChild; + + for (let attribute of [result._titleText, result._urlText]) { + is(attribute.childNodes.length, currentTest.emphasis.length, + "The element should have the expected number of children."); + for (let i = 0; i < currentTest.emphasis.length; i++) { + let node = attribute.childNodes[i]; + // Emphasized parts strictly alternate. + if ((i % 2 == 0) == currentTest.emphasizeFirst) { + // Check that this part is correctly emphasized. + is(node.nodeName, "span", ". That child should be a span node"); + ok(node.classList.contains("ac-emphasize-text"), ". That child should be emphasized"); + is(node.textContent, currentTest.emphasis[i], ". That emphasis should be as expected."); + } else { + // Check that this part is _not_ emphasized. + is(node.nodeName, "#text", ". That child should be a text node"); + is(node.textContent, currentTest.emphasis[i], ". That text should be as expected."); + } + } + } + + setTimeout(nextTest, 0); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_autocomplete_mac_caret.xhtml b/toolkit/content/tests/chrome/test_autocomplete_mac_caret.xhtml new file mode 100644 index 0000000000..b49f8a1d5e --- /dev/null +++ b/toolkit/content/tests/chrome/test_autocomplete_mac_caret.xhtml @@ -0,0 +1,80 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Autocomplete Widget Test" + onload="setTimeout(keyCaretTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<html:input id="autocomplete" is="autocomplete-input"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function keyCaretTest() +{ + var autocomplete = $("autocomplete"); + + autocomplete.focus(); + checkKeyCaretTest("KEY_ArrowUp", 0, 0, true, "no value up"); + checkKeyCaretTest("KEY_ArrowDown", 0, 0, true, "no value down"); + + autocomplete.value = "Sample"; + + autocomplete.selectionStart = 3; + autocomplete.selectionEnd = 3; + checkKeyCaretTest("KEY_ArrowUp", 0, 0, true, "value up with caret in middle"); + checkKeyCaretTest("KEY_ArrowUp", 0, 0, true, "value up with caret in middle again"); + + autocomplete.selectionStart = 2; + autocomplete.selectionEnd = 2; + checkKeyCaretTest("KEY_ArrowDown", 6, 6, true, "value down with caret in middle"); + checkKeyCaretTest("KEY_ArrowDown", 6, 6, true, "value down with caret in middle again"); + + autocomplete.selectionStart = 1; + autocomplete.selectionEnd = 4; + checkKeyCaretTest("KEY_ArrowUp", 0, 0, true, "value up with selection"); + + autocomplete.selectionStart = 1; + autocomplete.selectionEnd = 4; + checkKeyCaretTest("KEY_ArrowDown", 6, 6, true, "value down with selection"); + + SimpleTest.finish(); +} + +function checkKeyCaretTest(key, expectedStart, expectedEnd, result, testid) +{ + var autocomplete = $("autocomplete"); + var keypressFired = false; + function listener(event) { + if (event.target == autocomplete) { + keypressFired = true; + } + } + SpecialPowers.addSystemEventListener(window, "keypress", listener, false); + synthesizeKey(key, {}); + SpecialPowers.removeSystemEventListener(window, "keypress", listener, false); + is(keypressFired, result, `${testid} keypress event should${result ? "" : " not"} be fired`); + is(autocomplete.selectionStart, expectedStart, testid + " selectionStart"); + is(autocomplete.selectionEnd, expectedEnd, testid + " selectionEnd"); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_autocomplete_placehold_last_complete.xhtml b/toolkit/content/tests/chrome/test_autocomplete_placehold_last_complete.xhtml new file mode 100644 index 0000000000..33fca83c32 --- /dev/null +++ b/toolkit/content/tests/chrome/test_autocomplete_placehold_last_complete.xhtml @@ -0,0 +1,303 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Autocomplete Widget Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="runTest();"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + <script type="application/javascript" + src="chrome://global/content/globalOverlay.js"/> + +<html:input id="autocomplete" + is="autocomplete-input" + completedefaultindex="true" + timeout="0" + autocompletesearch="simple"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +function autoCompleteSimpleResult(aString, searchId) { + this.searchString = aString; + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + this.matchCount = 1; + if (aString.startsWith('ret')) { + this._param = autoCompleteSimpleResult.retireCompletion; + } else { + this._param = "Result"; + } + this._searchId = searchId; +} +autoCompleteSimpleResult.retireCompletion = "Retire"; +autoCompleteSimpleResult.prototype = { + _param: "", + searchString: null, + searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE, + defaultIndex: 0, + errorDescription: null, + matchCount: 0, + getValueAt() { return this._param; }, + getCommentAt() { return null; }, + getStyleAt() { return null; }, + getImageAt() { return null; }, + getLabelAt() { return null; }, + removeValueAt() {} +}; + +var searchCounter = 0; + +// A basic autocomplete implementation that returns one result. +let autoCompleteSimple = { + classID: Components.ID("0a2afbdb-f30e-47d1-9cb1-0cd160240aca"), + contractID: "@mozilla.org/autocomplete/search;1?name=simple", + searchAsync: false, + pendingSearch: null, + + QueryInterface: ChromeUtils.generateQI([ + "nsIFactory", + "nsIAutoCompleteSearch" + ]), + createInstance(iid) { + return this.QueryInterface(iid); + }, + + registerFactory() { + let registrar = + Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory(this.classID, "Test Simple Autocomplete", + this.contractID, this); + }, + unregisterFactory() { + let registrar = + Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(this.classID, this); + }, + + startSearch(aString, aParam, aResult, aListener) { + let result = new autoCompleteSimpleResult(aString); + + if (this.searchAsync) { + // Simulate an async search by using a timeout before invoking the + // |onSearchResult| callback. + // Store the searchTimeout such that it can be canceled if stopSearch is called. + this.pendingSearch = setTimeout(() => { + this.pendingSearch = null; + + aListener.onSearchResult(this, result); + + // Move to the next step in the async test. + asyncTest.next(); + }, 0); + } else { + aListener.onSearchResult(this, result); + } + }, + stopSearch() { + clearTimeout(this.pendingSearch); + } +}; + +SimpleTest.waitForExplicitFinish(); + +let gACTimer; +let gAutoComplete; +let asyncTest; + +let searchCompleteTimeoutId = null; + +function finishTest() { + // Unregister the factory so that we don't get in the way of other tests + autoCompleteSimple.unregisterFactory(); + SimpleTest.finish(); +} + +function runTest() { + autoCompleteSimple.registerFactory(); + gAutoComplete = $("autocomplete"); + gAutoComplete.focus(); + + // Return the search results synchronous, which also makes the completion + // happen synchronous. + autoCompleteSimple.searchAsync = false; + + sendString("r"); + is(gAutoComplete.value, "result", "Value should be autocompleted immediately"); + + sendString("e"); + is(gAutoComplete.value, "result", "Value should be autocompleted immediately"); + + synthesizeKey("KEY_Delete"); + is(gAutoComplete.value, "re", "Deletion should not complete value"); + + synthesizeKey("KEY_Backspace"); + is(gAutoComplete.value, "r", "Backspace should not complete value"); + + synthesizeKey("KEY_ArrowLeft"); + is(gAutoComplete.value, "r", "Value should stay same when navigating with cursor"); + + runAsyncTest(); +} + +function* asyncTestGenerator() { + sendString("re"); + is(gAutoComplete.value, "re", "Value should not be autocompleted immediately"); + + // Calling |yield undefined| makes this generator function wait until + // |asyncTest.next();| is called. This happens from within the + // |autoCompleteSimple.startSearch()| function once the simulated async + // search has finished. + // Therefore, the effect of the |yield undefined;| here (and the ones) below + // is to wait until the async search result comes back. + yield undefined; + + is(gAutoComplete.value, "result", "Value should be autocompleted"); + + // Test if typing the `s` character completes directly based on the last + // completion + sendString("s"); + is(gAutoComplete.value, "result", "Value should be completed immediately"); + + yield undefined; + + is(gAutoComplete.value, "result", "Value should be autocompleted to same value"); + synthesizeKey("KEY_Delete"); + is(gAutoComplete.value, "res", "Deletion should not complete value"); + + // No |yield undefined| needed here as no completion is triggered by the deletion. + + is(gAutoComplete.value, "res", "Still no complete value after deletion"); + + synthesizeKey("KEY_Backspace"); + is(gAutoComplete.value, "re", "Backspace should not complete value"); + + yield undefined; + + is(gAutoComplete.value, "re", "Value after search due to backspace should stay the same"); (3) + + // Typing a character that is not like the previous match. In this case, the + // completion cannot happen directly and therefore the value will be completed + // only after the search has finished. + sendString("t"); + is(gAutoComplete.value, "ret", "Value should not be autocompleted immediately"); + + yield undefined; + + is(gAutoComplete.value, "retire", "Value should be autocompleted"); + + sendString("i"); + is(gAutoComplete.value, "retire", "Value should be autocompleted immediately"); + + yield undefined; + + is(gAutoComplete.value, "retire", "Value should be autocompleted to the same value"); + + // Setup the scene to test how the completion behaves once the placeholder + // completion and the result from the search do not agree with each other. + gAutoComplete.value = 'r'; + // Need to type two characters as the input was reset and the autocomplete + // controller things, ther user hit the backspace button, in which case + // no completion is performed. But as a completion is desired, another + // character `t` is typed afterwards. + sendString("e"); + yield undefined; + sendString("t"); + is(gAutoComplete.value, "ret", "Value should not be autocompleted"); + + yield undefined; + + is(gAutoComplete.value, "retire", "Value should be autocompleted"); + + // The placeholder string is now set to "retire". Changing the completion + // string to "retirement" and see what the completion will turn out like. + autoCompleteSimpleResult.retireCompletion = "Retirement"; + sendString("i"); + is(gAutoComplete.value, "retire", "Value should be autocompleted based on placeholder"); + + yield undefined; + + is(gAutoComplete.value, "retirement", "Value should be autocompleted based on search result"); + + // Change the search result to `Retire` again and see if the new result is + // complited. + autoCompleteSimpleResult.retireCompletion = "Retire"; + sendString("r"); + is(gAutoComplete.value, "retirement", "Value should be autocompleted based on placeholder"); + + yield undefined; + + is(gAutoComplete.value, "retire", "Value should be autocompleted based on search result"); + + // Complete the value + gAutoComplete.value = 're'; + sendString("t"); + yield undefined; + sendString("i"); + is(gAutoComplete.value, "reti", "Value should not be autocompleted"); + + yield undefined; + + is(gAutoComplete.value, "retire", "Value should be autocompleted"); + + // Remove the selected text "re" (1) and the "et" (2). Afterwards, add it again (3). + // This should not cause the completion to kick in. + synthesizeKey("KEY_Delete"); // (1) + + is(gAutoComplete.value, "reti", "Value should not complete after deletion"); + + gAutoComplete.selectionStart = 1; + gAutoComplete.selectionEnd = 3; + synthesizeKey("KEY_Delete"); // (2) + + is(gAutoComplete.value, "ri", "Value should stay unchanged after removing character in the middle"); + + yield undefined; + + sendString("e"); // (3.1) + is(gAutoComplete.value, "rei", "Inserting a character in the middle should not complete the value"); + + yield undefined; + + sendString("t"); // (3.2) + is(gAutoComplete.value, "reti", "Inserting a character in the middle should not complete the value"); + + yield undefined; + + // Adding a new character at the end should not cause the completion to happen again + // as the completion failed before. + gAutoComplete.selectionStart = 4; + gAutoComplete.selectionEnd = 4; + sendString("r"); + is(gAutoComplete.value, "retir", "Value should not be autocompleted immediately"); + + yield undefined; + + is(gAutoComplete.value, "retire", "Value should be autocompleted"); + + finishTest(); + yield undefined; +} + +function runAsyncTest() { + gAutoComplete.value = ''; + autoCompleteSimple.searchAsync = true; + + asyncTest = asyncTestGenerator(); + asyncTest.next(); +} +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_autocomplete_with_composition_on_input.html b/toolkit/content/tests/chrome/test_autocomplete_with_composition_on_input.html new file mode 100644 index 0000000000..fbcd44e830 --- /dev/null +++ b/toolkit/content/tests/chrome/test_autocomplete_with_composition_on_input.html @@ -0,0 +1,67 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>autocomplete with composition tests on HTML input element</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="file_autocomplete_with_composition.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <iframe id="formTarget" name="formTarget"></iframe> + <form action="data:text/html," target="formTarget"> + <input name="test" id="input"><input type="submit"> + </form> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> +SimpleTest.waitForExplicitFinish(); + +function runTests() { + var formFillController = + SpecialPowers.getFormFillController() + .QueryInterface(Ci.nsIAutoCompleteInput); + var originalFormFillTimeout = formFillController.timeout; + + SpecialPowers.attachFormFillControllerTo(window); + var target = document.getElementById("input"); + + // Register a word to the form history. + let chromeScript = SpecialPowers.loadChromeScript(function addEntry() { + /* eslint-env mozilla/chrome-script */ + let {FormHistory} = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" + ); + FormHistory.update({ op: "add", fieldname: "test", value: "Mozilla" }); + }); + chromeScript.destroy(); + target.focus(); + + new nsDoTestsForAutoCompleteWithComposition( + "Testing on HTML input (asynchronously search)", + window, target, formFillController.controller, is, + function() { return target.value; }, + function() { + target.setAttribute("timeout", 0); + new nsDoTestsForAutoCompleteWithComposition( + "Testing on HTML input (synchronously search)", + window, target, formFillController.controller, is, + function() { return target.value; }, + function() { + formFillController.timeout = originalFormFillTimeout; + SpecialPowers.detachFormFillControllerFrom(window); + SimpleTest.finish(); + }); + }); +} + +SimpleTest.waitForFocus(runTests); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_browser_drop.xhtml b/toolkit/content/tests/chrome/test_browser_drop.xhtml new file mode 100644 index 0000000000..3b0f0fdb2b --- /dev/null +++ b/toolkit/content/tests/chrome/test_browser_drop.xhtml @@ -0,0 +1,35 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Browser Drop Test" + onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + + <script><![CDATA[ +SimpleTest.waitForExplicitFinish(); +function runTest() { + add_task(async function() { + let win = window.browsingContext.topChromeWindow.openDialog("window_browser_drop.xhtml", "_blank", "chrome,width=200,height=200", window); + await SimpleTest.promiseFocus(win); + for (let browserType of ["content", "remote-content"]) { + await win.dropLinksOnBrowser(win.document.getElementById(browserType + "child"), browserType); + } + await win.dropLinksOnBrowser(win.document.getElementById("chromechild"), "chrome"); + }); +} +//]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug1048178.xhtml b/toolkit/content/tests/chrome/test_bug1048178.xhtml new file mode 100644 index 0000000000..d9a34c1da3 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug1048178.xhtml @@ -0,0 +1,83 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1048178 +--> +<window title="Mozilla Bug 1048178" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"/> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1048178" + target="_blank">Mozilla Bug 1048178</a> + + <hbox> + <scrollbar id="scroller" + orient="horizontal" + curpos="0" + maxpos="500" + pageincrement="500" + style="width: 500px; margin:0"/> + </hbox> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +/** Test for Bug 1048178 **/ +var scrollbarTester = { + scrollbar: null, + startTest() { + this.scrollbar = $("scroller"); + this.setScrollToClick(false); + this.testThumbDragging(); + SimpleTest.finish(); + }, + testThumbDragging() { + var x = 400; // on the right half of the scroolbar + var y = 5; + + this.mousedown(x, y, 0); + this.mousedown(x, y, 2); + this.mouseup(x, y, 2); + this.mouseup(x, y, 0); + + var newPos = this.getPos(); // should be '500' + + this.mousedown(x, y, 0); + this.mousemove(x-1, y, 0); + this.mouseup(x-1, y, 0); + + var newPos2 = this.getPos(); + ok(newPos2 < newPos, + "Scrollbar thumb should follow the mouse when dragged."); + }, + setScrollToClick(value) { + SpecialPowers.Services.prefs.getBranch("ui.") + .setIntPref("scrollToClick", value ? 1 : 0); + }, + getPos() { + return this.scrollbar.getAttribute("curpos"); + }, + mousedown(x, y, button) { + synthesizeMouse(this.scrollbar, x, y, { type: "mousedown", 'button': button }); + }, + mousemove(x, y, button) { + synthesizeMouse(this.scrollbar, x, y, { type: "mousemove", 'button': button }); + }, + mouseup(x, y, button) { + synthesizeMouse(this.scrollbar, x, y, { type: "mouseup", 'button': button }); + } +} + +function doTest() { + setTimeout(function() { scrollbarTester.startTest(); }, 0); +} + +SimpleTest.waitForExplicitFinish(); +addLoadEvent(doTest); + +]]></script> +</window> diff --git a/toolkit/content/tests/chrome/test_bug263683.xhtml b/toolkit/content/tests/chrome/test_bug263683.xhtml new file mode 100644 index 0000000000..64c7df08ac --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug263683.xhtml @@ -0,0 +1,39 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=263683 +--> +<window title="Mozilla Bug 263683" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=263683"> + Mozilla Bug 263683 + </a> + + <p id="display"></p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + + <script class="testbody" type="application/javascript"> + <![CDATA[ + + /** Test for Bug 263683 **/ + SimpleTest.waitForExplicitFinish(); + window.openDialog("bug263683_window.xhtml", "263683test", + "chrome,width=600,height=600,noopener", window); + + ]]> + </script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug304188.xhtml b/toolkit/content/tests/chrome/test_bug304188.xhtml new file mode 100644 index 0000000000..1047528d20 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug304188.xhtml @@ -0,0 +1,37 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=304188 +--> +<window title="Mozilla Bug 304188" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=304188">Mozilla Bug 304188</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +/** Test for Bug 304188 **/ +SimpleTest.waitForExplicitFinish(); +window.openDialog("bug304188_window.xhtml", "findbartest", + "chrome,width=600,height=600,noopener", window); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug331215.xhtml b/toolkit/content/tests/chrome/test_bug331215.xhtml new file mode 100644 index 0000000000..22560089c1 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug331215.xhtml @@ -0,0 +1,38 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=331215 +--> +<window title="Mozilla Bug 331215" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=331215">Mozilla Bug 331215</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +/** Test for Bug 331215 **/ + +SimpleTest.waitForExplicitFinish(); +window.openDialog("bug331215_window.xhtml", "331215test", + "chrome,width=600,height=600,noopener", window); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug360220.xhtml b/toolkit/content/tests/chrome/test_bug360220.xhtml new file mode 100644 index 0000000000..a942e0acd6 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug360220.xhtml @@ -0,0 +1,61 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=360220 +--> +<window title="Mozilla Bug 360220" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=360220">Mozilla Bug 360220</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> + +<menulist id="menulist"> + <menupopup> + <menuitem id="firstItem" label="foo" selected="true"/> + <menuitem id="secondItem" label="bar"/> + </menupopup> +</menulist> +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +/** Test for Bug 360220 **/ + +var menulist = document.getElementById("menulist"); +var secondItem = document.getElementById("secondItem"); +menulist.selectedItem = secondItem; + +is(menulist.label, "bar", "second item was not selected"); + +let mutObserver = new MutationObserver(() => { + is(menulist.label, "new label", "menulist label was not updated to the label of its selected item"); + done(); +}); +mutObserver.observe(menulist, { attributeFilter: ['label'] }); +secondItem.label = "new label"; + +let failureTimeout = setTimeout(function() { + ok(false, "menulist label should have updated"); + done(); +}, 2000); + +function done() { + mutObserver.disconnect(); + clearTimeout(failureTimeout); + SimpleTest.finish(); +} +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug360437.xhtml b/toolkit/content/tests/chrome/test_bug360437.xhtml new file mode 100644 index 0000000000..dc592e4141 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug360437.xhtml @@ -0,0 +1,40 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=360437 +--> +<window title="Mozilla Bug 360437" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=360437">Mozilla Bug 360437</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +/** Test for Bug 360437 **/ +SimpleTest.waitForExplicitFinish(); +window.openDialog("bug360437_window.xhtml", "360437test", + "chrome,width=600,height=600,noopener", window); + + + + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug365773.xhtml b/toolkit/content/tests/chrome/test_bug365773.xhtml new file mode 100644 index 0000000000..e85a590608 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug365773.xhtml @@ -0,0 +1,67 @@ +<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=365773
+-->
+<window title="Mozilla Bug 365773"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+<body xmlns="http://www.w3.org/1999/xhtml">
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=365773">Mozilla Bug 365773</a>
+<p id="display">
+ <radiogroup id="group" collapsed="true" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <radio id="item" label="Item"/>
+ </radiogroup>
+</p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+</pre>
+</body>
+
+<script class="testbody" type="application/javascript">
+<![CDATA[
+
+/** Test for Bug 365773 **/
+
+function selectItem(item, isIndex, testName) {
+ var exception = null;
+ try {
+ if (isIndex)
+ document.getElementById("group").selectedIndex = item;
+ else
+ document.getElementById("group").selectedItem = item;
+ }
+ catch(e) {
+ exception = e;
+ }
+
+ ok(exception == null, testName);
+}
+
+SimpleTest.waitForExplicitFinish();
+
+window.onload = function runTests() {
+ var item = document.getElementById("item");
+
+ selectItem(item, false, "Radio button selected with selectedItem (not focused)");
+ selectItem(null, false, "Radio button deselected with selectedItem (not focused)");
+ selectItem(0, true, "Radio button selected with selectedIndex (not focused)");
+ selectItem(-1, true, "Radio button deselected with selectedIndex (not focused)");
+
+ document.getElementById("group").focus();
+
+ selectItem(item, false, "Radio button selected with selectedItem (focused)");
+ selectItem(null, false, "Radio button deselected with selectedItem (focused)");
+ selectItem(0, true, "Radio button selected with selectedIndex (focused)");
+ selectItem(-1, true, "Radio button deselected with selectedIndex (focused)");
+
+ SimpleTest.finish();
+};
+]]>
+</script>
+
+</window>
diff --git a/toolkit/content/tests/chrome/test_bug366992.xhtml b/toolkit/content/tests/chrome/test_bug366992.xhtml new file mode 100644 index 0000000000..9b178f1abe --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug366992.xhtml @@ -0,0 +1,40 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=366992 +--> +<window title="Mozilla Bug 366992" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=366992">Mozilla Bug 366992</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +/** Test for Bug 366992 **/ +SimpleTest.waitForExplicitFinish(); +window.openDialog("bug366992_window.xhtml", "findbartest", + "chrome,width=600,height=600,noopener", window); + + + + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug382990.xhtml b/toolkit/content/tests/chrome/test_bug382990.xhtml new file mode 100644 index 0000000000..1f937ac1b6 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug382990.xhtml @@ -0,0 +1,44 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=382990 +--> +<window title="Mozilla Bug 382990" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="startThisTest()"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=382990" + target="_blank">Mozilla Bug 382990</a> + </body> + + <tree id="testTree" height="200px"> + <treecols> + <treecol flex="1" label="Name" id="name"/> + </treecols> + <treechildren> + <treeitem><treerow><treecell label="a"/></treerow></treeitem> + <treeitem><treerow><treecell label="z"/></treerow></treeitem> + </treechildren> + </tree> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + /** Test for Bug 382990 **/ + + SimpleTest.waitForExplicitFinish(); + function startThisTest() + { + var treeElem = document.getElementById("testTree"); + treeElem.view.selection.select(0); + treeElem.focus(); + synthesizeKey("z", {ctrlKey: true}); + ok(!treeElem.view.selection.isSelected(1), "Tree selection should not change for key events with ctrl pressed."); + SimpleTest.finish(); + } + ]]></script> +</window> diff --git a/toolkit/content/tests/chrome/test_bug409624.xhtml b/toolkit/content/tests/chrome/test_bug409624.xhtml new file mode 100644 index 0000000000..6aa5f2ded9 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug409624.xhtml @@ -0,0 +1,38 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=409624 +--> +<window title="Mozilla Bug 409624" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=409624"> + Mozilla Bug 409624 + </a> + + <p id="display"></p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + + <script class="testbody" type="application/javascript"> + <![CDATA[ + + /** Test for Bug 409624 **/ + SimpleTest.waitForExplicitFinish(); + window.openDialog("bug409624_window.xhtml", "409624test", + "chrome,width=600,height=600,noopener", window); + + ]]> + </script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug418874.xhtml b/toolkit/content/tests/chrome/test_bug418874.xhtml new file mode 100644 index 0000000000..d91289f11b --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug418874.xhtml @@ -0,0 +1,64 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Textbox with placeholder test" width="500" height="600" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <hbox> + <html:input id="t1" placeholder="empty"/> + </hbox> + + <hbox> + <html:input id="t2" placeholder="empty"/> + </hbox> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"> + <p id="display"> + </p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + SimpleTest.waitForExplicitFinish(); + + function doTest() { + var t1 = $("t1"); + var t2 = $("t2"); + setTextboxValue(t1, "1"); + is(t1.editor.canUndo, true, + "undo correctly enabled when placeholder was not changed through property"); + + t2.placeholder = "reallyempty"; + setTextboxValue(t2, "2"); + is(t2.editor.canUndo, true, + "undo correctly enabled when placeholder explicitly changed through property"); + + SimpleTest.finish(); + } + + function setTextboxValue(textbox, value) { + textbox.focus(); + sendString(value); + textbox.blur(); + } + + SimpleTest.waitForFocus(doTest); + + ]]></script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug429723.xhtml b/toolkit/content/tests/chrome/test_bug429723.xhtml new file mode 100644 index 0000000000..865f0d66f8 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug429723.xhtml @@ -0,0 +1,38 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=429723 +--> +<window title="Mozilla Bug 429723" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + +<body xmlns="http://www.w3.org/1999/xhtml"> +<a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=429723">Mozilla Bug 429723</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +/** Test for Bug 429723 **/ +SimpleTest.waitForExplicitFinish(); +window.openDialog("bug429723_window.xhtml", "429723test", + "chrome,width=600,height=600,noopener", window); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug451540.xhtml b/toolkit/content/tests/chrome/test_bug451540.xhtml new file mode 100644 index 0000000000..debcadfe05 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug451540.xhtml @@ -0,0 +1,39 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=451540 +--> +<window title="Mozilla Bug 451540" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=451540"> + Mozilla Bug 451540 + </a> + + <p id="display"></p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + + <script class="testbody" type="application/javascript"> + <![CDATA[ + + /** Test for Bug 451540 **/ + SimpleTest.waitForExplicitFinish(); + window.openDialog("bug451540_window.xhtml", "451540test", + "chrome,width=600,height=600,noopener", window); + + ]]> + </script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug457632.xhtml b/toolkit/content/tests/chrome/test_bug457632.xhtml new file mode 100644 index 0000000000..12267c1dec --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug457632.xhtml @@ -0,0 +1,160 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for bug 457632 + --> +<window title="Bug 457632" width="500" height="600" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <vbox id="nb"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;" + onload="test()"/> + + <!-- test code goes here --> +<script type="application/javascript"> +<![CDATA[ +var gNotificationBox; + +function completeAnimation(nextTest) { + if (!gNotificationBox._animating) { + nextTest(); + return; + } + + setTimeout(completeAnimation, 50, nextTest); +} + +async function test() { + SimpleTest.waitForExplicitFinish(); + gNotificationBox = new MozElements.NotificationBox(e => { + document.getElementById("nb").appendChild(e); + }); + + is(gNotificationBox.allNotifications.length, 0, "There should be no initial notifications"); + await gNotificationBox.appendNotification("notification1", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + is(gNotificationBox.allNotifications.length, 1, "Notification exists while animating in"); + let notification = gNotificationBox.getNotificationWithValue("notification1"); + ok(notification, "Notification should exist while animating in"); + + // Wait for the notificaton to finish displaying + completeAnimation(test1); +} + +// Tests that a notification that is fully animated in gets removed immediately +async function test1() { + let notification = gNotificationBox.getNotificationWithValue("notification1"); + gNotificationBox.removeNotification(notification); + notification = gNotificationBox.getNotificationWithValue("notification1"); + ok(!notification, "Test 1 showed notification was still present"); + ok(!gNotificationBox.currentNotification, "Test 1 said there was still a current notification"); + is(gNotificationBox.allNotifications.length, 0, "Test 1 should show no notifications present"); + + // Wait for the notificaton to finish hiding + completeAnimation(test2); +} + +// Tests that a notification that is animating in gets removed immediately +async function test2() { + let notification = await gNotificationBox.appendNotification("notification2", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + gNotificationBox.removeNotification(notification); + notification = gNotificationBox.getNotificationWithValue("notification2"); + ok(!notification, "Test 2 showed notification was still present"); + ok(!gNotificationBox.currentNotification, "Test 2 said there was still a current notification"); + is(gNotificationBox.allNotifications.length, 0, "Test 2 should show no notifications present"); + + // Get rid of the hiding notifications + gNotificationBox.removeAllNotifications(true); + test3(); +} + +// Tests that a background notification goes away immediately +async function test3() { + let notification = await gNotificationBox.appendNotification("notification3", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + let notification2 = await gNotificationBox.appendNotification("notification4", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + is(gNotificationBox.allNotifications.length, 2, "Test 3 should show 2 notifications present"); + gNotificationBox.removeNotification(notification); + is(gNotificationBox.allNotifications.length, 1, "Test 3 should show 1 notifications present"); + notification = gNotificationBox.getNotificationWithValue("notification3"); + ok(!notification, "Test 3 showed notification was still present"); + gNotificationBox.removeNotification(notification2); + is(gNotificationBox.allNotifications.length, 0, "Test 3 should show 0 notifications present"); + notification2 = gNotificationBox.getNotificationWithValue("notification4"); + ok(!notification2, "Test 3 showed notification2 was still present"); + ok(!gNotificationBox.currentNotification, "Test 3 said there was still a current notification"); + + // Get rid of the hiding notifications + gNotificationBox.removeAllNotifications(true); + test4(); +} + +// Tests that a foreground notification hiding a background one goes away +async function test4() { + let notification = await gNotificationBox.appendNotification("notification5", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + let notification2 = await gNotificationBox.appendNotification("notification6", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + gNotificationBox.removeNotification(notification2); + notification2 = gNotificationBox.getNotificationWithValue("notification6"); + ok(!notification2, "Test 4 showed notification2 was still present"); + is(gNotificationBox.currentNotification, notification, "Test 4 said the current notification was wrong"); + is(gNotificationBox.allNotifications.length, 1, "Test 4 should show 1 notifications present"); + gNotificationBox.removeNotification(notification); + notification = gNotificationBox.getNotificationWithValue("notification5"); + ok(!notification, "Test 4 showed notification was still present"); + ok(!gNotificationBox.currentNotification, "Test 4 said there was still a current notification"); + is(gNotificationBox.allNotifications.length, 0, "Test 4 should show 0 notifications present"); + + // Get rid of the hiding notifications + gNotificationBox.removeAllNotifications(true); + test5(); +} + +// Tests that removeAllNotifications gets rid of everything +async function test5() { + let notification = await gNotificationBox.appendNotification("notification7", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + let notification2 = await gNotificationBox.appendNotification("notification8", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + gNotificationBox.removeAllNotifications(); + notification = gNotificationBox.getNotificationWithValue("notification7"); + notification2 = gNotificationBox.getNotificationWithValue("notification8"); + ok(!notification, "Test 5 showed notification was still present"); + ok(!notification2, "Test 5 showed notification2 was still present"); + ok(!gNotificationBox.currentNotification, "Test 5 said there was still a current notification"); + is(gNotificationBox.allNotifications.length, 0, "Test 5 should show 0 notifications present"); + + await gNotificationBox.appendNotification("notification9", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + + // Wait for the notificaton to finish displaying + completeAnimation(test6); +} + +// Tests whether removing an already removed notification doesn't break things +async function test6() { + let notification = gNotificationBox.getNotificationWithValue("notification9"); + ok(notification, "Test 6 should have an initial notification"); + gNotificationBox.removeNotification(notification); + gNotificationBox.removeNotification(notification); + + ok(!gNotificationBox.currentNotification, "Test 6 shouldn't be any current notification"); + is(gNotificationBox.allNotifications.length, 0, "Test 6 allNotifications.length should be 0"); + notification = await gNotificationBox.appendNotification("notification10", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + is(notification, gNotificationBox.currentNotification, "Test 6 should have made the current notification"); + gNotificationBox.removeNotification(notification); + + SimpleTest.finish(); +} +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug460942.xhtml b/toolkit/content/tests/chrome/test_bug460942.xhtml new file mode 100644 index 0000000000..53f33302be --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug460942.xhtml @@ -0,0 +1,42 @@ +<?xml version="1.0"?> +<?xml-stylesheet type="text/css" href="chrome://global/skin"?> +<?xml-stylesheet type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=460942 +--> +<window title="Mozilla Bug 460942" + onload="runTests()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=460942" + target="_blank">Mozilla Bug 460942</a> + </body> + + <!-- test code goes here --> + + <richlistbox> + <richlistitem id="item1"> + <label value="one"/> + <box> + <label value="two"/> + </box> + </richlistitem> + <richlistitem id="item2"><description>one</description><description>two</description></richlistitem> + </richlistbox> + + <script type="application/javascript"> + <![CDATA[ + /** Test for Bug 460942 **/ + function runTests() { + is ($("item1").label, "one two"); + is ($("item2").label, ""); + SimpleTest.finish(); + } + SimpleTest.waitForExplicitFinish(); + ]]> + </script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug471776.xhtml b/toolkit/content/tests/chrome/test_bug471776.xhtml new file mode 100644 index 0000000000..dcd45bfd77 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug471776.xhtml @@ -0,0 +1,45 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Textbox with placeholder undo test" width="500" height="600" + onload="doTest();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <hbox> + <html:input id="t1" placeholder="empty"/> + </hbox> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"> + <p id="display"> + </p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + SimpleTest.waitForExplicitFinish(); + + function doTest() { + var t1 = $("t1"); + t1.focus(); + ok(!t1.editor.canUndo, "undo correctly disabled when no user edits"); + SimpleTest.finish(); + } + + ]]></script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug509732.xhtml b/toolkit/content/tests/chrome/test_bug509732.xhtml new file mode 100644 index 0000000000..7e340322b2 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug509732.xhtml @@ -0,0 +1,53 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for bug 509732 + --> +<window title="Bug 509732" width="500" height="600" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <vbox id="nb" hidden="true"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;" + onload="test()"/> + + <!-- test code goes here --> +<script type="application/javascript"> +<![CDATA[ +var gNotificationBox; + +// Tests that a notification that is added in an hidden box didn't throw the animation +async function test() { + SimpleTest.waitForExplicitFinish(); + gNotificationBox = new MozElements.NotificationBox(e => { + document.getElementById("nb").appendChild(e); + }); + + is(gNotificationBox.allNotifications.length, 0, "There should be no initial notifications"); + + await gNotificationBox.appendNotification("notification1", + { label: "Test notification", priority: gNotificationBox.PRIORITY_INFO_LOW }); + + is(gNotificationBox.allNotifications.length, 1, "Notification exists"); + is(gNotificationBox._animating, false, "Notification shouldn't be animating"); + + test1(); +} + +// Tests that a notification that is removed from an hidden box didn't throw the animation +function test1() { + let notification = gNotificationBox.getNotificationWithValue("notification1"); + gNotificationBox.removeNotification(notification); + ok(!gNotificationBox.currentNotification, "Test 1 should show no current animation"); + is(gNotificationBox._animating, false, "Notification shouldn't be animating"); + is(gNotificationBox.allNotifications.length, 0, "Test 1 should show no notifications present"); + + SimpleTest.finish(); +} +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_bug557987.xhtml b/toolkit/content/tests/chrome/test_bug557987.xhtml new file mode 100644 index 0000000000..6af1b13700 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug557987.xhtml @@ -0,0 +1,68 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for bug 557987 + --> +<window title="Bug 557987" width="400" height="400" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <toolbarbutton id="button" type="menu" label="Test bug 557987" + onclick="eventReceived('click');" + oncommand="eventReceived('command');"> + <menupopup onpopupshowing="eventReceived('popupshowing'); return false;" /> + </toolbarbutton> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + +<script type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +SimpleTest.waitForFocus(test); + +// Tests that mouse events are correctly dispatched to <toolbarbutton type="menu"/> +// This used to test menu buttons, and was updated when this button type was removed. +function test() { + disableNonTestMouseEvents(true); + + let button = $("button"); + let rightEdge = button.getBoundingClientRect().width - 2; + let centerX = button.getBoundingClientRect().width / 2; + let centerY = button.getBoundingClientRect().height / 2; + + synthesizeMouse(button, rightEdge, centerY, {}, window); + synthesizeMouse(button, centerX, centerY, {}, window); + + synthesizeMouse(document.getElementsByTagName("body")[0], 0, 0, {}, window); + + disableNonTestMouseEvents(false); + SimpleTest.executeSoon(finishTest); + +} + +function finishTest() { + is(eventCount.command, 0, "Correct number of command events received"); + is(eventCount.popupshowing, 2, "Correct number of popupshowing events received"); + is(eventCount.click, 2, "Correct number of click events received"); + + SimpleTest.finish(); +} + +let eventCount = { + command: 0, + popupshowing: 0, + click: 0, +}; + +function eventReceived(eventName) { + eventCount[eventName]++; +} + +]]> +</script> +</window> diff --git a/toolkit/content/tests/chrome/test_bug562554.xhtml b/toolkit/content/tests/chrome/test_bug562554.xhtml new file mode 100644 index 0000000000..a822bf59dd --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug562554.xhtml @@ -0,0 +1,81 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for bug 562554 + --> +<window title="Bug 562554" width="400" height="400" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <toolbarbutton type="menu" id="toolbarmenu" style="height: 200px; justify-content: flex-start; align-items: flex-start"> + <menupopup id="menupopup" onpopupshowing="eventReceived('popupshowing'); return false;"/> + <stack style="pointer-events: none"> + <button style="pointer-events: auto; width: 100px; height: 30px; margin-left: 0; margin-top: 0;" allowevents="true" + onclick="eventReceived('clickbutton1'); return false;"/> + <button style="width: 100px; height: 30px; margin-left: 70px; margin-top: 0;" + onclick="eventReceived('clickbutton2'); return false;"/> + </stack> + </toolbarbutton> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + +<script type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); +SimpleTest.waitForFocus(test); + +// Tests that mouse events are correctly dispatched to <toolbarbutton type="menu"/> +function test() { + disableNonTestMouseEvents(true); + nextTest(); +} + +let tests = [ + // Click on the toolbarbutton itself - should call popupshowing + () => synthesizeMouse($("toolbarmenu"), 10, 50, {}, window), + () => is(eventCount.popupshowing, 1, "Got first popupshowing event"), + + // Click on button1 which has allowevents="true" - should call clickbutton1 + () => synthesizeMouse($("toolbarmenu"), 10, 15, {}, window), + () => is(eventCount.clickbutton1, 1, "Button 1 clicked"), + + // Click on button2 outside of intersection - should call popupshowing + () => synthesizeMouse($("toolbarmenu"), 150, 15, {}, window) +]; + +function nextTest() { + if (tests.length) { + let func = tests.shift(); + func(); + SimpleTest.executeSoon(nextTest); + } else { + disableNonTestMouseEvents(false); + SimpleTest.executeSoon(finishTest); + } +} + +function finishTest() { + is(eventCount.clickbutton1, 1, "Correct number of clicks on button 1"); + is(eventCount.clickbutton2, 0, "Correct number of clicks on button 2"); + is(eventCount.popupshowing, 2, "Correct number of popupshowing events received"); + + SimpleTest.finish(); +} + +let eventCount = { + popupshowing: 0, + clickbutton1: 0, + clickbutton2: 0 +}; + +function eventReceived(eventName) { + eventCount[eventName]++; +} + +]]> +</script> +</window> diff --git a/toolkit/content/tests/chrome/test_bug624329.xhtml b/toolkit/content/tests/chrome/test_bug624329.xhtml new file mode 100644 index 0000000000..26b7d2ea53 --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug624329.xhtml @@ -0,0 +1,169 @@ +<?xml version="1.0"?> +<?xml-stylesheet type="text/css" href="chrome://global/skin"?> +<?xml-stylesheet type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=624329 +--> +<window title="Mozilla Bug 624329 context menu position" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" + onload="beginTest()"> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=624329" + target="_blank">Mozilla Bug 624329</a> + </body> + + <!-- test code goes here --> + <script type="application/javascript"> + <![CDATA[ + /** Test for Bug 624329 **/ + +SimpleTest.waitForExplicitFinish(); + +var win; +var timeoutID; +var menu; + +const {AppConstants} = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +function beginTest() { + if (AppConstants.platform == "macosx" && SpecialPowers.getBoolPref("widget.macos.native-context-menus", false)) { + // This test does not apply with native context menus. + ok(true, "macOS positions native menus, so we don't need to test this behaviour."); + SimpleTest.finish(); + return; + } + + openTestWindow(); +} + +function openTestWindow() { + win = window.browsingContext.topChromeWindow.openDialog("bug624329_window.xhtml", "_blank", "width=300,resizable=yes,chrome", window); + // Close our window if the test times out so that it doesn't interfere + // with later tests. + timeoutID = setTimeout(function () { + ok(false, "Test timed out."); + // Provide some time for a screenshot + setTimeout(finish, 1000); + }, 20000); +} + +function listenOnce(event, callback) { + win.addEventListener(event, function listener() { + callback(); + }, { once: true}); +} + +function childFocused() { + // maximizing the window is a simple way to ensure that the menu is near + // the right edge of the screen. + + listenOnce("resize", childResized); + win.maximize(); +} + +function childResized() { + const isOSXMavericks = navigator.userAgent.includes("Mac OS X 10.9"); + const isOSXYosemite = navigator.userAgent.includes("Mac OS X 10.10"); + if (isOSXMavericks || isOSXYosemite) { + todo_is(win.windowState, win.STATE_MAXIMIZED, + "A resize before being maximized breaks this test on 10.9 and 10.10"); + finish(); + return; + } + + is(win.windowState, win.STATE_MAXIMIZED, + "window should be maximized"); + + isnot(win.innerWidth, 300, + "window inner width should have changed"); + + openContextMenu(); +} + +function openContextMenu() { + var mouseX = win.innerWidth - 10; + var mouseY = 10; + + menu = win.document.getElementById("menu"); + var screenX = menu.screenX; + var screenY = menu.screenY; + var utils = win.windowUtils; + + utils.sendMouseEvent("contextmenu", mouseX, mouseY, 2, 0, 0); + + var interval = setInterval(checkMoved, 200); + function checkMoved() { + if (menu.screenX != screenX || + menu.screenY != screenY) { + clearInterval(interval); + // Wait further to check that the window does not move again. + setTimeout(checkPosition, 1000); + } + } + + function checkPosition() { + var rootElement = win.document.documentElement; + var platformIsMac = navigator.userAgent.indexOf("Mac") > -1; + + var x = menu.screenX - rootElement.screenX - parseFloat(getComputedStyle(menu).marginLeft); + var y = menu.screenY - rootElement.screenY - parseFloat(getComputedStyle(menu).marginTop); + + if (platformIsMac) + { + // This check is alterered slightly for OSX which adds padding to the top + // and bottom of its context menus. The menu position calculation must + // be changed to allow for the pointer to be outside this padding + // when the menu opens. + // (Bug 1075089) + ok(y + 6 >= mouseY, + "menu top " + (y + 6) + " should be below click point " + mouseY); + } + else + { + ok(y >= mouseY, + "menu top " + y + " should be below click point " + mouseY); + } + + ok(y <= mouseY + 20, + "menu top " + y + " should not be too far below click point " + mouseY); + + ok(x < mouseX, + "menu left " + x + " should be left of click point " + mouseX); + var right = x + menu.getBoundingClientRect().width; + + if (platformIsMac) { + // Rather than be constrained by the right hand screen edge, OSX menus flip + // horizontally and appear to the left of the mouse pointer + ok(right < mouseX, + "menu right " + right + " should be left of click point " + mouseX); + } + else { + ok(right > mouseX, + "menu right " + right + " should be right of click point " + mouseX); + } + + clearTimeout(timeoutID); + finish(); + } + +} + +function finish() { + if (menu && navigator.platform.includes("Win")) { + todo(false, "Should not have to hide popup before closing its window"); + // This avoids mochitest "Unable to restore focus" errors (bug 670053). + menu.hidePopup(); + } + win.close(); + SimpleTest.finish(); +} + + ]]> + </script> +</window> diff --git a/toolkit/content/tests/chrome/test_bug792324.xhtml b/toolkit/content/tests/chrome/test_bug792324.xhtml new file mode 100644 index 0000000000..ddedf5907b --- /dev/null +++ b/toolkit/content/tests/chrome/test_bug792324.xhtml @@ -0,0 +1,74 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=792324 +--> +<window title="Mozilla Bug 792324" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> +<body xmlns="http://www.w3.org/1999/xhtml"> + <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=792324">Mozilla Bug 792324</a> + + <p id="display"></p> +<div id="content" style="display: none"> +</div> +</body> + +<panel id="panel-1"> + <button label="just a normal button"/> + <button id="button-1" + accesskey="X" + oncommand="clicked(event)" + label="Button in panel 1" + /> +</panel> + +<panel id="panel-2"> + <button label="just a normal button"/> + <button id="button-2" + accesskey="X" + oncommand="clicked(event)" + label="Button in panel 2" + /> +</panel> + +<script class="testbody" type="application/javascript"><![CDATA[ + +/** Test for Bug 792324 **/ +let after_click; + +function clicked(event) { + after_click(event); +} + +function checkAccessKeyOnPanel(panelid, buttonid, cb) { + let panel = document.getElementById(panelid); + panel.addEventListener("popupshown", function onpopupshown() { + panel.firstChild.focus(); + after_click = function(event) { + is(event.target.id, buttonid, "Accesskey was directed to the button '" + buttonid + "'"); + panel.hidePopup(); + cb(); + } + sendString("X"); + }); + panel.openPopup(null, "", 100, 100, false, false); +} + +function test() { + checkAccessKeyOnPanel("panel-1", "button-1", function() { + checkAccessKeyOnPanel("panel-2", "button-2", function() { + SimpleTest.finish(); + }); + }); +} + +SimpleTest.waitForExplicitFinish(); +SimpleTest.waitForFocus(test, window); + +]]></script> + +</window> diff --git a/toolkit/content/tests/chrome/test_button.xhtml b/toolkit/content/tests/chrome/test_button.xhtml new file mode 100644 index 0000000000..fa1b7cacd0 --- /dev/null +++ b/toolkit/content/tests/chrome/test_button.xhtml @@ -0,0 +1,81 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for button + --> +<window title="Button Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<button id="one" label="One" /> +<button id="two" label="Two"/> +<hbox> + <button id="three" label="Three" open="true"/> +</hbox> +<hbox> + <button id="four" type="menu" label="Four"/> + <button id="five" label="Five"/> +</hbox> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + +<script type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +add_task(async function test_button() +{ + await SimpleTest.promiseFocus(); + + // Click on the button. + let commandPromise = new Promise(resolve => { + addEventListener("command", event => resolve(event), { once: true }); + }); + + synthesizeMouseAtCenter($("one"), {}); + let event = await commandPromise; + is(event.button, 0, "button for mouse"); + is(event.inputSource, MouseEvent.MOZ_SOURCE_MOUSE, "input source for mouse"); + + // Press space while to button is focused. + commandPromise = new Promise(resolve => { + addEventListener("command", event => resolve(event), { once: true }); + }); + + $("one").focus(); + synthesizeKey("VK_SPACE", { }); + event = await commandPromise; + is(event.button, 0, "button for keyboard"); + is(event.inputSource, MouseEvent.MOZ_SOURCE_KEYBOARD, "input source for keyboard"); + + $("two").disabled = true; + synthesizeMouseExpectEvent($("two"), 2, 2, {}, $("two"), "!command", "button press command when disabled"); + synthesizeMouseExpectEvent($("two"), 2, 2, {}, $("two"), "click", "button press click when disabled"); + + if (!navigator.platform.includes("Mac")) { + $("one").focus(); + synthesizeKey("KEY_ArrowDown"); + is(document.activeElement, $("three"), "key cursor down on button"); + + synthesizeKey("KEY_ArrowRight"); + is(document.activeElement, $("four"), "key cursor right on button"); + synthesizeKey("KEY_ArrowDown"); + is(document.activeElement, $("four"), "key cursor down on menu button"); + + $("three").focus(); + synthesizeKey("KEY_ArrowUp"); + is(document.activeElement, $("one"), "key cursor up on button"); + } + + $("two").focus(); + ok(document.activeElement != $("two"), "focus disabled button"); +}); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_chromemargin.xhtml b/toolkit/content/tests/chrome/test_chromemargin.xhtml new file mode 100644 index 0000000000..d1a6a568be --- /dev/null +++ b/toolkit/content/tests/chrome/test_chromemargin.xhtml @@ -0,0 +1,35 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Custom chrome margin tests" + onload="setTimeout(runTest, 0);" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + +<script> + +// Tests parsing of the chrome margin attrib on a window. + +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_chromemargin.xhtml", "_blank", "chrome,width=600,height=600,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_closemenu_attribute.xhtml b/toolkit/content/tests/chrome/test_closemenu_attribute.xhtml new file mode 100644 index 0000000000..7b29bd6c5d --- /dev/null +++ b/toolkit/content/tests/chrome/test_closemenu_attribute.xhtml @@ -0,0 +1,96 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu closemenu Attribute Tests" + onload="setTimeout(nextTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<button id="menu" type="menu" label="Menu" onpopuphidden="popupHidden(event)"> + <menupopup id="p1" onpopupshown="if (event.target == this) this.firstChild.open = true"> + <menu id="l1" label="One"> + <menupopup id="p2" onpopupshown="if (event.target == this) this.firstChild.open = true"> + <menu id="l2" label="Two"> + <menupopup id="p3" onpopupshown="executeMenuItem()"> + <menuitem id="l3" label="Three"/> + </menupopup> + </menu> + </menupopup> + </menu> + </menupopup> +</button> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var gExpectedId = "p3"; +var gMode = -1; +var gModes = ["", "auto", "single", "none"]; + +function nextTest() +{ + gMode++; + if (gModes[gMode] != "none") + gExpectedId = "p3"; + + if (gMode != 0) + $("l3").setAttribute("closemenu", gModes[gMode]); + if (gModes[gMode] == "none") + $("l2").open = true; + else + $("menu").open = true; +} + +function executeMenuItem() +{ + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); + // after a couple of seconds, end the test, as the 'none' closemenu value + // should not hide any popups + if (gModes[gMode] == "none") + setTimeout(function() { $("menu").open = false; }, 2000); +} + +function popupHidden(event) +{ + if (gModes[gMode] == "none") { + if (event.target.id == "p1") + SimpleTest.finish() + return; + } + + is(event.target.id, gExpectedId, + "Expected event " + gModes[gMode] + " " + gExpectedId); + + gExpectedId = ""; + if (event.target.id == "p3") { + if (gModes[gMode] == "" || gModes[gMode] == "auto") + gExpectedId = "p2"; + } + else if (event.target.id == "p2") { + if (gModes[gMode] == "" || gModes[gMode] == "auto") + gExpectedId = "p1"; + } + + if (!gExpectedId) + nextTest(); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_contextmenu_list.xhtml b/toolkit/content/tests/chrome/test_contextmenu_list.xhtml new file mode 100644 index 0000000000..d5d2b1a10b --- /dev/null +++ b/toolkit/content/tests/chrome/test_contextmenu_list.xhtml @@ -0,0 +1,296 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Context Menu on List Tests" + onload="setTimeout(startTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<spacer style="height: 5px"/> + +<hbox style="padding-left: 10px;"> + <spacer style="width: 5ps"/> + <richlistbox id="list" context="themenu" style="padding: 0;" oncontextmenu="checkContextMenu(event)"> + <richlistitem id="item1" style="padding-top: 4px; margin: 0;"><button label="One"/></richlistitem> + <richlistitem id="item2" style="height: 22px"><checkbox label="Checkbox"/></richlistitem> + <richlistitem id="item3"><button label="Three"/></richlistitem> + <richlistitem id="item4"><checkbox label="Four"/></richlistitem> + </richlistbox> + + <tree id="tree" rows="5" flex="1" context="themenu" style="-moz-appearance: none; border: 0"> + <treecols> + <treecol label="Name" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Moons"/> + </treecols> + <treechildren id="treechildren"> + <treeitem> + <treerow> + <treecell label="Mercury"/> + <treecell label="0"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Venus"/> + <treecell label="0"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Earth"/> + <treecell label="1"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Mars"/> + <treecell label="2"/> + </treerow> + </treeitem> + </treechildren> + </tree> + + <menu id="menu" label="Menu"> + <menupopup id="menupopup" onpopupshown="menuTests()" onpopuphidden="nextTest()" + oncontextmenu="checkContextMenuForMenu(event)"> + <menuitem id="menu1" label="Menu 1"/> + <menuitem id="menu2" label="Menu 2"/> + <menuitem id="menu3" label="Menu 3"/> + </menupopup> + </menu> + +</hbox> + +<menupopup id="themenu" onpopupshowing="if (gTestId == -1) event.preventDefault()" + onpopupshown="checkPopup()" onpopuphidden="setTimeout(nextTest, 0);"> + <menuitem label="Item"/> +</menupopup> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var gTestId = -1; +var gTestElement = "list"; +var gSelectionStep = 0; +var gContextMenuFired = false; + +async function startTest() +{ + // These tests check behavior of non-native menus, and use anchored and non-anchored popups + // somewhat interchangeably. So disable native context menus for this test. + // We will have separate tests for native context menu behavior, see bug 1700727. + await SpecialPowers.pushPrefEnv({ set: [["widget.macos.native-context-menus", false]] }); + + // first, check if the richlistbox selection changes on a contextmenu mouse event + var element = $("list"); + synthesizeMouse(element.getItemAtIndex(3), 7, 1, { type : "mousedown", button: 2, ctrlKey: true }); + synthesizeMouse(element, 7, 4, { type : "contextmenu", button: 2 }); + + gSelectionStep++; + synthesizeMouse(element.getItemAtIndex(1), 7, 1, { type : "mousedown", button: 2, ctrlKey: true, shiftKey: true }); + synthesizeMouse(element, 7, 4, { type : "contextmenu", button: 2 }); + + gSelectionStep++; + synthesizeMouse(element.getItemAtIndex(1), 7, 1, { type : "mousedown", button: 2 }); + synthesizeMouse(element, 7, 4, { type : "contextmenu", button: 2 }); + + $("menu").open = true; +} + +function menuTests() +{ + gSelectionStep = 0; + var element = $("menu"); + synthesizeMouse(element, 0, 0, { type : "contextmenu", button: 0 }); + is(gContextMenuFired, true, "context menu fired when menu open"); + + gSelectionStep = 1; + $("menu").activeChild = $("menu2"); + synthesizeMouse(element, 0, 0, { type : "contextmenu", button: 0 }); + + $("menu").open = false; +} + +function nextTest() +{ + gTestId++; + if (gTestId > 2) { + if (gTestElement == "list") { + gTestElement = "tree"; + gTestId = 0; + } + else { + SimpleTest.finish(); + return; + } + } + var element = $(gTestElement); + element.focus(); + if (gTestId == 0) { + if (gTestElement == "list") + element.selectedIndex = 2; + element.currentIndex = 2; + synthesizeMouse(element, 0, 0, { type : "contextmenu", button: 0 }); + } + else if (gTestId == 1) { + synthesizeMouse(element, 7, 4, { type : "contextmenu", button: 2 }); + } + else { + element.currentIndex = -1; + element.selectedIndex = -1; + synthesizeMouse(element, 0, 0, { type : "contextmenu", button: 0 }); + } +} + +// This is nasty so I'd better explain what's going on. +// The basic problem is that the synthetic mouse coordinate generated +// by DOMWindowUtils.sendMouseEvent and also the synthetic mouse coordinate +// generated internally when contextmenu events are redirected to the focused +// element are rounded to the nearest device pixel. But this rounding is done +// while the coordinates are relative to the nearest widget. When this test +// is run in the mochitest harness, the nearest widget is the main mochitest +// window, and our document can have a fractional position within that +// mochitest window. So when we round coordinates for comparison in this +// test, we need to do so very carefully, especially if the target element +// also has a fractional position within our document. +// +// For example, if the y-offset of our containing IFRAME is 100.4px, +// and the offset of our expected point is 10.3px in our document, the actual +// mouse event is dispatched to round(110.7) == 111px. This comes back +// with a clientY of round(111 - 100.4) == round(10.6) == 11. This is not +// equal to round(10.3) as you might expect. + +function isRoundedX(a, b, msg) +{ + is(Math.round(a + window.mozInnerScreenX), Math.round(b + window.mozInnerScreenX), msg); +} + +function isRoundedY(a, b, msg) +{ + is(Math.round(a + window.mozInnerScreenY), Math.round(b + window.mozInnerScreenY), msg); +} + +function checkContextMenu(event) +{ + var rect = $(gTestElement).getBoundingClientRect(); + + var frombase = (gTestId == -1 || gTestId == 1); + if (!frombase) + rect = event.originalTarget.getBoundingClientRect(); + var left = frombase ? rect.left + 7 : rect.left; + var top = frombase ? rect.top + 4 : rect.bottom; + + isRoundedX(event.clientX, left, gTestElement + " clientX " + gSelectionStep + " " + gTestId + "," + frombase); + isRoundedY(event.clientY, top, gTestElement + " clientY " + gSelectionStep + " " + gTestId); + ok(event.screenX > left, gTestElement + " screenX " + gSelectionStep + " " + gTestId); + ok(event.screenY > top, gTestElement + " screenY " + gSelectionStep + " " + gTestId); + + // context menu from mouse click + switch (gTestId) { + case -1: + // eslint-disable-next-line no-nested-ternary + var expected = gSelectionStep == 2 ? 1 : (platformIsMac() ? 3 : 0); + is($(gTestElement).selectedIndex, expected, "index after click " + gSelectionStep); + break; + case 0: + if (gTestElement == "list") + is(event.originalTarget, $("item3"), "list selection target"); + else + is(event.originalTarget, $("treechildren"), "tree selection target"); + break; + case 1: + is(event.originalTarget.id, $("item1").id, "list mouse selection target"); + break; + case 2: + is(event.originalTarget, $("list"), "list no selection target"); + break; + } +} + +function checkContextMenuForMenu(event) +{ + gContextMenuFired = true; + + var popuprect = (gSelectionStep ? $("menu2") : $("menupopup")).getBoundingClientRect(); + is(event.clientX, Math.round(popuprect.left), "menu left " + gSelectionStep); + // the clientY is off by one sometimes on Windows (when loaded in the testing iframe + // but not when loaded separately) so just check for both cases for now + ok(event.clientY == Math.round(popuprect.bottom) || + event.clientY - 1 == Math.round(popuprect.bottom), "menu top " + gSelectionStep); +} + +function checkPopup() +{ + var menurect = $("themenu").getBoundingClientRect(); + + // Context menus are offset by a number of pixels from the mouse click + // which activates them. This is so that they don't appear exactly + // under the mouse which can cause them to be mistakenly dismissed. + // The number of pixels depends on the platform and is defined in + // each platform's nsLookAndFeel + var contextMenuOffsetX = platformIsMac() ? 1 : 2; + var contextMenuOffsetY = platformIsMac() ? -6 : 2; + contextMenuOffsetY += parseFloat(getComputedStyle($("themenu")).marginTop); + contextMenuOffsetX += parseFloat(getComputedStyle($("themenu")).marginLeft); + + if (gTestId == 0) { + if (gTestElement == "list") { + var itemrect = $("item3").getBoundingClientRect(); + isRoundedX(menurect.left, itemrect.left + contextMenuOffsetX, + "list selection keyboard left"); + isRoundedY(menurect.top, itemrect.bottom + contextMenuOffsetY, + "list selection keyboard top"); + } + else { + var tree = $("tree"); + var bodyrect = $("treechildren").getBoundingClientRect(); + isRoundedX(menurect.left, bodyrect.left + contextMenuOffsetX, + "tree selection keyboard left"); + isRoundedY(menurect.top, bodyrect.top + + tree.rowHeight * 3 + contextMenuOffsetY, + "tree selection keyboard top"); + } + } + else if (gTestId == 1) { + // activating a context menu with the mouse from position (7, 4). + let elementrect = $(gTestElement).getBoundingClientRect(); + isRoundedX(menurect.left, elementrect.left + 7 + contextMenuOffsetX, + gTestElement + " mouse left"); + isRoundedY(menurect.top, elementrect.top + 4 + contextMenuOffsetY, + gTestElement + " mouse top"); + } + else { + let elementrect = $(gTestElement).getBoundingClientRect(); + isRoundedX(menurect.left, elementrect.left + contextMenuOffsetX, + gTestElement + " no selection keyboard left"); + isRoundedY(menurect.top, elementrect.bottom + contextMenuOffsetY, + gTestElement + " no selection keyboard top"); + } + + $("themenu").hidePopup(); +} + +function platformIsMac() +{ + return navigator.platform.indexOf("Mac") > -1; +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_contextmenu_rtl.xhtml b/toolkit/content/tests/chrome/test_contextmenu_rtl.xhtml new file mode 100644 index 0000000000..45649c8c2d --- /dev/null +++ b/toolkit/content/tests/chrome/test_contextmenu_rtl.xhtml @@ -0,0 +1,35 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<window title="Context Menu RTL position" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> +<menupopup id="context-menu" style="max-width: 100px; direction: rtl"> + <menuitem label="Item"/> +</menupopup> +<script> +<![CDATA[ + +add_task(async function() { + // This test checks behavior of non-native menus, + // so disable native context menus for this test. + await SpecialPowers.pushPrefEnv({ set: [["widget.macos.native-context-menus", false]] }); + const menu = document.getElementById("context-menu"); + const shown = new Promise(resolve => menu.addEventListener("popupshown", resolve, { once: true })); + const point = { + x: window.screenX + screen.width / 2, + y: window.screenY + screen.height / 2, + }; + menu.openPopupAtScreen(point.x, point.y, true, null); + await shown; + const rect = menu.getBoundingClientRect(); + const margin = parseFloat(getComputedStyle(menu).marginRight); + info(`${menu.screenX} + ${rect.width} + ${margin} < ${point.x}`); + ok(menu.screenX + rect.width + margin < point.x, "Should be right-aligned"); + menu.hidePopup(); +}); + +]]> +</script> +</window> diff --git a/toolkit/content/tests/chrome/test_cursorsnap.xhtml b/toolkit/content/tests/chrome/test_cursorsnap.xhtml new file mode 100644 index 0000000000..170e0ab378 --- /dev/null +++ b/toolkit/content/tests/chrome/test_cursorsnap.xhtml @@ -0,0 +1,123 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<window title="Cursor snapping test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js" /> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +const kMaxRetryCount = 4; +const kTimeoutTime = [ + 100, 100, 1000, 1000, 5000 +]; + +var gRetryCount; + +var gTestingCount = 0; +var gTestingIndex = -1; +var gDisable = false; +var gHidden = false; + +function canRetryTest() +{ + return gRetryCount <= kMaxRetryCount; +} + +function getTimeoutTime() +{ + return kTimeoutTime[gRetryCount]; +} + +function runNextTest() +{ + gRetryCount = 0; + gTestingIndex++; + runCurrentTest(); +} + +function retryCurrentTest() +{ + ok(canRetryTest(), "retry the current test..."); + gRetryCount++; + runCurrentTest(); +} + +function runCurrentTest() +{ + var position = "top=" + gTestingCount + ",left=" + gTestingCount + ","; + gTestingCount++; + switch (gTestingIndex) { + case 0: + gDisable = false; + gHidden = false; + window.openDialog("window_cursorsnap_dialog.xhtml", "_blank", + position + "chrome,width=100,height=100,noopener", window); + break; + case 1: + gDisable = true; + gHidden = false; + window.openDialog("window_cursorsnap_dialog.xhtml", "_blank", + position + "chrome,width=100,height=100,noopener", window); + break; + case 2: + gDisable = false; + gHidden = true; + window.openDialog("window_cursorsnap_dialog.xhtml", "_blank", + position + "chrome,width=100,height=100,noopener", window); + break; + case 3: + gDisable = false; + gHidden = false; + window.openDialog("window_cursorsnap_wizard.xhtml", "_blank", + position + "chrome,width=100,height=100,noopener", window); + break; + case 4: + gDisable = true; + gHidden = false; + window.openDialog("window_cursorsnap_wizard.xhtml", "_blank", + position + "chrome,width=100,height=100,noopener", window); + break; + case 5: + gDisable = false; + gHidden = true; + window.openDialog("window_cursorsnap_wizard.xhtml", "_blank", + position + "chrome,width=100,height=100,noopener", window); + break; + default: + SetPrefs(false); + SimpleTest.finish(); + } +} + +function SetPrefs(aSet) +{ + var prefSvc = SpecialPowers.Services.prefs; + const kPrefName = "ui.cursor_snapping.always_enabled"; + if (aSet) { + prefSvc.setBoolPref(kPrefName, true); + } else if (prefSvc.prefHasUserValue(kPrefName)) { + prefSvc.clearUserPref(kPrefName); + } +} + +SetPrefs(true); +runNextTest(); + +]]> +</script> +</window> diff --git a/toolkit/content/tests/chrome/test_custom_element_base.xhtml b/toolkit/content/tests/chrome/test_custom_element_base.xhtml new file mode 100644 index 0000000000..77cc8819a3 --- /dev/null +++ b/toolkit/content/tests/chrome/test_custom_element_base.xhtml @@ -0,0 +1,363 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Custom Element Base Class Tests" + onload="runTests();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <button id="one"/> + <simpleelement id="two" style="-moz-user-focus: normal;"/> + <simpleelement id="three" disabled="true" style="-moz-user-focus: normal;"/> + <button id="four"/> + <inherited-element-declarative foo="fuagra" empty-string=""></inherited-element-declarative> + <inherited-element-derived foo="fuagra"></inherited-element-derived> + <inherited-element-shadowdom-declarative foo="fuagra" empty-string=""></inherited-element-shadowdom-declarative> + <inherited-element-imperative foo="fuagra" empty-string=""></inherited-element-imperative> + <inherited-element-beforedomloaded foo="fuagra" empty-string=""></inherited-element-beforedomloaded> + + <!-- test code running before page load goes here --> + <script type="application/javascript"><![CDATA[ + class InheritAttrsChangeBeforDOMLoaded extends MozXULElement { + static get inheritedAttributes() { + return { + "label": "foo", + }; + } + connectedCallback() { + this.append(MozXULElement.parseXULToFragment(`<label />`)); + this.label = this.querySelector("label"); + + this.initializeAttributeInheritance(); + is(this.label.getAttribute("foo"), "fuagra", + "InheritAttrsChangeBeforDOMLoaded: attribute should be propagated #1"); + + this.setAttribute("foo", "chuk&gek"); + is(this.label.getAttribute("foo"), "chuk&gek", + "InheritAttrsChangeBeforDOMLoaded: attribute should be propagated #2"); + } + } + customElements.define("inherited-element-beforedomloaded", + InheritAttrsChangeBeforDOMLoaded); + ]]> + </script> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + ok(MozXULElement, "MozXULElement defined on the window"); + testMixin(); + testBaseControl(); + testBaseControlMixin(); + testBaseText(); + testParseXULToFragment(); + testInheritAttributes(); + await testCustomInterface(); + + let htmlWin = await new Promise(resolve => { + let htmlIframe = document.createXULElement("iframe"); + htmlIframe.src = "file_empty.xhtml"; + htmlIframe.onload = () => resolve(htmlIframe.contentWindow); + document.documentElement.appendChild(htmlIframe); + }); + + ok(htmlWin.MozXULElement, "MozXULElement defined on a chrome HTML window"); + SimpleTest.finish(); + } + + function testMixin() { + ok(MozElements.MozElementMixin, "Mixin exists"); + let MixedHTMLElement = MozElements.MozElementMixin(HTMLElement); + ok(MixedHTMLElement.insertFTLIfNeeded, "Mixed in class contains helper functions"); + } + + function testBaseControl() { + ok(MozElements.BaseControl, "BaseControl exists"); + ok("disabled" in MozElements.BaseControl.prototype, + "BaseControl prototype contains base control attributes"); + } + + function testBaseControlMixin() { + ok(MozElements.BaseControlMixin, "Mixin exists"); + let MixedHTMLSpanElement = MozElements.MozElementMixin(HTMLSpanElement); + let HTMLSpanBaseControl = MozElements.BaseControlMixin(MixedHTMLSpanElement); + ok("disabled" in HTMLSpanBaseControl.prototype, "Mixed in class prototype contains base control attributes"); + } + + function testBaseText() { + ok(MozElements.BaseText, "BaseText exists"); + ok("label" in MozElements.BaseText.prototype, + "BaseText prototype inherits BaseText attributes"); + ok("disabled" in MozElements.BaseText.prototype, + "BaseText prototype inherits BaseControl attributes"); + } + + function testParseXULToFragment() { + ok(MozXULElement.parseXULToFragment, "parseXULToFragment helper exists"); + + let frag = MozXULElement.parseXULToFragment(`<deck id='foo' />`); + ok(DocumentFragment.isInstance(frag)); + + document.documentElement.appendChild(frag); + + let deck = document.documentElement.lastChild; + ok(deck instanceof MozXULElement, "instance of MozXULElement"); + ok(XULElement.isInstance(deck), "instance of XULElement"); + is(deck.id, "foo", "attribute set"); + is(deck.selectedIndex, 0, "Custom Element is property attached"); + deck.remove(); + + info("Checking that whitespace text is removed but non-whitespace text isn't"); + let boxWithWhitespaceText = MozXULElement.parseXULToFragment(`<box> </box>`).querySelector("box"); + is(boxWithWhitespaceText.textContent, "", "Whitespace removed"); + let boxWithNonWhitespaceText = MozXULElement.parseXULToFragment(`<box>foo</box>`).querySelector("box"); + is(boxWithNonWhitespaceText.textContent, "foo", "Non-whitespace not removed"); + + try { + // we didn't encode the & as & + MozXULElement.parseXULToFragment(`<box id="foo=1&bar=2"/>`); + ok(false, "parseXULToFragment should've thrown an exception for not-well-formed XML"); + } + catch (ex) { + is(ex.message, "not well-formed XML", "parseXULToFragment threw the wrong message"); + } + } + + function testInheritAttributes() { + class InheritsElementDeclarative extends MozXULElement { + static get inheritedAttributes() { + return { + "label": "text=label,foo,empty-string,bardo=bar", + "unmatched": "foo", // Make sure we don't throw on unmatched selectors + }; + } + + connectedCallback() { + this.textContent = ""; + this.append(MozXULElement.parseXULToFragment(`<label />`)); + this.label = this.querySelector("label"); + this.initializeAttributeInheritance(); + } + } + customElements.define("inherited-element-declarative", InheritsElementDeclarative); + let declarativeEl = document.querySelector("inherited-element-declarative"); + ok(declarativeEl, "declarative inheritance element exists"); + + class InheritsElementDerived extends InheritsElementDeclarative { + static get inheritedAttributes() { + return { label: "renamedfoo=foo" }; + } + } + customElements.define("inherited-element-derived", InheritsElementDerived); + + class InheritsElementShadowDOMDeclarative extends MozXULElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + static get inheritedAttributes() { + return { + "label": "text=label,foo,empty-string,bardo=bar", + "unmatched": "foo", // Make sure we don't throw on unmatched selectors + }; + } + + connectedCallback() { + this.shadowRoot.textContent = ""; + this.shadowRoot.append(MozXULElement.parseXULToFragment(`<label />`)); + this.label = this.shadowRoot.querySelector("label"); + this.initializeAttributeInheritance(); + } + } + customElements.define("inherited-element-shadowdom-declarative", InheritsElementShadowDOMDeclarative); + let shadowDOMDeclarativeEl = document.querySelector("inherited-element-shadowdom-declarative"); + ok(shadowDOMDeclarativeEl, "declarative inheritance element with shadow DOM exists"); + + class InheritsElementImperative extends MozXULElement { + static get observedAttributes() { + return [ "label", "foo", "empty-string", "bar" ]; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (this.label && oldValue != newValue) { + this.inherit(); + } + } + + inherit() { + let map = { + "label": [[ "label", "text" ]], + "foo": [[ "label", "foo" ]], + "empty-string": [[ "label", "empty-string" ]], + "bar": [[ "label", "bardo" ]], + }; + for (let attr of InheritsElementImperative.observedAttributes) { + this.inheritAttribute(map[attr], attr); + } + } + + connectedCallback() { + // Typically `initializeAttributeInheritance` handles this for us: + this._inheritedElements = null; + + this.textContent = ""; + this.append(MozXULElement.parseXULToFragment(`<label />`)); + this.label = this.querySelector("label"); + this.inherit(); + } + } + + customElements.define("inherited-element-imperative", InheritsElementImperative); + let imperativeEl = document.querySelector("inherited-element-imperative"); + ok(imperativeEl, "imperative inheritance element exists"); + + function checkElement(el) { + is(el.label.getAttribute("foo"), "fuagra", "predefined attribute @foo"); + ok(el.label.hasAttribute("empty-string"), "predefined attribute @empty-string"); + ok(!el.label.hasAttribute("bardo"), "predefined attribute @bardo"); + ok(!el.label.textContent, "predefined attribute @label"); + + el.setAttribute("empty-string", "not-empty-anymore"); + is(el.label.getAttribute("empty-string"), "not-empty-anymore", + "attribute inheritance: empty-string"); + + el.setAttribute("label", "label-test"); + is(el.label.textContent, "label-test", + "attribute inheritance: text=label attribute change"); + + el.setAttribute("bar", "bar-test"); + is(el.label.getAttribute("bardo"), "bar-test", + "attribute inheritance: `=` mapping"); + + el.label.setAttribute("bardo", "changed-from-child"); + is(el.label.getAttribute("bardo"), "changed-from-child", + "attribute inheritance: doesn't apply when host attr hasn't changed and child attr was changed"); + + el.label.removeAttribute("bardo"); + ok(!el.label.hasAttribute("bardo"), + "attribute inheritance: doesn't apply when host attr hasn't changed and child attr was removed"); + + el.setAttribute("bar", "changed-from-host"); + is(el.label.getAttribute("bardo"), "changed-from-host", + "attribute inheritance: does apply when host attr has changed and child attr was changed"); + + el.removeAttribute("bar"); + ok(!el.label.hasAttribute("bardo"), + "attribute inheritance: does apply when host attr has been removed"); + + el.setAttribute("bar", "changed-from-host-2"); + is(el.label.getAttribute("bardo"), "changed-from-host-2", + "attribute inheritance: does apply when host attr has changed after being removed"); + + // Restore to the original state so this can be ran again with the same element: + el.removeAttribute("label"); + el.removeAttribute("bar"); + } + + for (let el of [declarativeEl, shadowDOMDeclarativeEl, imperativeEl]) { + info(`Running checks for ${el.tagName}`); + checkElement(el); + info(`Remove and re-add ${el.tagName} to make sure attribute inheritance still works`); + el.replaceWith(el); + checkElement(el); + } + + let derivedEl = document.querySelector("inherited-element-derived"); + ok(derivedEl, "derived inheritance element exists"); + ok(!derivedEl.label.hasAttribute("foo"), + "attribute inheritance: base class attribute is not applied in derived class that overrides it"); + ok(derivedEl.label.hasAttribute("renamedfoo"), + "attribute inheritance: attribute defined in derived class is present"); + } + + async function testCustomInterface() { + class SimpleElement extends MozXULElement { + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set disabled(val) { + if (val) this.setAttribute("disabled", "true"); + else this.removeAttribute("disabled"); + } + + get tabIndex() { + return parseInt(this.getAttribute("tabIndex")) || 0; + } + + set tabIndex(val) { + if (val) this.setAttribute("tabIndex", val); + else this.removeAttribute("tabIndex"); + } + } + + MozXULElement.implementCustomInterface(SimpleElement, [Ci.nsIDOMXULControlElement]); + customElements.define("simpleelement", SimpleElement); + + let twoElement = document.getElementById("two"); + + is(document.documentElement.getCustomInterfaceCallback, undefined, + "No getCustomInterfaceCallback on non-custom element"); + is(typeof twoElement.getCustomInterfaceCallback, "function", + "getCustomInterfaceCallback available on custom element when set"); + is(document.documentElement.QueryInterface, undefined, + "Non-custom element should not have a QueryInterface implementation"); + + // Try various ways to get the custom interface. + + let asControl = twoElement.getCustomInterfaceCallback(Ci.nsIDOMXULControlElement); + + // XXX: Switched to from ok() to todo_is() in Bug 1467712. Follow up in 1500967 + // Not sure if this was suppose to simply check for existence or equality? + todo_is(asControl, twoElement, "getCustomInterface returns interface implementation "); + + asControl = twoElement.QueryInterface(Ci.nsIDOMXULControlElement); + ok(asControl, "QueryInterface to nsIDOMXULControlElement"); + ok(Node.isInstance(asControl), "Control is a Node"); + + // Now make sure that the custom element handles focus/tabIndex as needed by shitfing + // focus around and enabling/disabling the simple elements. + + // Enable Full Keyboard Access emulation on Mac + await SpecialPowers.pushPrefEnv({"set": [["accessibility.tabfocus", 7]]}); + + ok(!twoElement.disabled, "two is enabled"); + ok(document.getElementById("three").disabled, "three is disabled"); + + await SimpleTest.promiseFocus(); + ok(document.hasFocus(), "has focus"); + + // This should skip the disabled simpleelement. + synthesizeKey("VK_TAB"); + is(document.activeElement.id, "one", "Tab 1"); + synthesizeKey("VK_TAB"); + is(document.activeElement.id, "two", "Tab 2"); + synthesizeKey("VK_TAB"); + is(document.activeElement.id, "four", "Tab 3"); + + twoElement.disabled = true; + is(twoElement.getAttribute("disabled"), "true", "two disabled after change"); + + synthesizeKey("VK_TAB", { shiftKey: true }); + is(document.activeElement.id, "one", "Tab 1"); + + info("Checking that interfaces get inherited automatically with implementCustomInterface"); + class ExtendedElement extends SimpleElement { } + MozXULElement.implementCustomInterface(ExtendedElement, [Ci.nsIDOMXULSelectControlElement]); + customElements.define("extendedelement", ExtendedElement); + const extendedInstance = document.createXULElement("extendedelement"); + ok(extendedInstance.QueryInterface(Ci.nsIDOMXULSelectControlElement), "interface applied"); + ok(extendedInstance.QueryInterface(Ci.nsIDOMXULControlElement), "inherited interface applied"); + } + ]]> + </script> +</window> diff --git a/toolkit/content/tests/chrome/test_custom_element_delay_connection.xhtml b/toolkit/content/tests/chrome/test_custom_element_delay_connection.xhtml new file mode 100644 index 0000000000..f40277d198 --- /dev/null +++ b/toolkit/content/tests/chrome/test_custom_element_delay_connection.xhtml @@ -0,0 +1,110 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Custom Element Base Delayed Connected" + onload="runTests();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <script type="application/javascript"><![CDATA[ + let nativeDOMContentLoadedFired = false; + document.addEventListener("DOMContentLoaded", () => { + nativeDOMContentLoadedFired = true; + }, { once: true }); + + // To test `delayConnectedCallback` and `isConnectedAndReady` we have to run this before + // DOMContentLoaded, which is why this is done in a separate script that runs + // immediately and not in `runTests`. + let delayedConnectionPromise = new Promise(resolve => { + + let numSkippedAttributeChanges = 0; + let numDelayedConnections = 0; + let numDelayedDisconnections = 0; + let finishedWaitingForDOMReady = false; + + // Register this custom element before DOMContentLoaded has fired and before it's parsed in + // the markup: + customElements.define("delayed-connection", class DelayedConnection extends MozXULElement { + static get observedAttributes() { return ["foo"]; } + attributeChangedCallback() { + ok(!this.isConnectedAndReady, + "attributeChangedCallback fires before isConnectedAndReady"); + ok(!nativeDOMContentLoadedFired, + "attributeChangedCallback fires before nativeDOMContentLoadedFired"); + numSkippedAttributeChanges++; + } + connectedCallback() { + if (this.delayConnectedCallback()) { + ok(!finishedWaitingForDOMReady, + "connectedCallback with delayConnectedCallback fires before finishedWaitingForDOMReady"); + ok(!this.isConnectedAndReady, + "connectedCallback with delayConnectedCallback fires before isConnectedAndReady"); + ok(!nativeDOMContentLoadedFired, + "connectedCallback with delayConnectedCallback fires before nativeDOMContentLoadedFired"); + numDelayedConnections++; + return; + } + + ok(!finishedWaitingForDOMReady, + "connectedCallback only fires once when DOM is ready"); + ok(this.isConnectedAndReady, + "isConnectedAndReady during connectedCallback"); + ok(!nativeDOMContentLoadedFired, + "delayed connectedCallback fires before nativeDOMContentLoadedFired"); + + is(numSkippedAttributeChanges, 2, + "Correct number of skipped attribute changes"); + is(numDelayedConnections, 2, + "Correct number of delayed connections"); + is(numDelayedDisconnections, 1, + "Correct number of delated disconnections"); + + finishedWaitingForDOMReady = true; + resolve(); + } + disconnectedCallback() { + ok(this.delayConnectedCallback(), + "disconnectedCallback while DOM not ready"); + is(numDelayedDisconnections, 0, + "disconnectedCallback fired only once"); + numDelayedDisconnections++; + } + }); + }); + + // This should be called after the element is parsed below this. + function mutateDelayedConnection() { + // Fire connectedCallback and attributeChangedCallback twice before DOMContentLoaded + // fires. The first connectedCallback is due to the parse and the second due to re-appending. + let delayedConnection = document.querySelector("delayed-connection"); + delayedConnection.setAttribute("foo", "bar"); + delayedConnection.remove(); + delayedConnection.setAttribute("foo", "bat"); + document.documentElement.append(delayedConnection); + } + ]]> + </script> + + <delayed-connection></delayed-connection> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + SimpleTest.waitForExplicitFinish(); + mutateDelayedConnection(); + + async function runTests() { + info("Waiting for delayed connection to fire"); + ok(nativeDOMContentLoadedFired, + "nativeDOMContentLoadedFired is true in runTests"); + await delayedConnectionPromise; + SimpleTest.finish(); + } + ]]> + </script> +</window>
\ No newline at end of file diff --git a/toolkit/content/tests/chrome/test_custom_element_parts.html b/toolkit/content/tests/chrome/test_custom_element_parts.html new file mode 100644 index 0000000000..e46e93e206 --- /dev/null +++ b/toolkit/content/tests/chrome/test_custom_element_parts.html @@ -0,0 +1,44 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title><!-- Shadow Parts issue with xul/xbl domparser --></title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <style>custom-button::part(foo) { background: red; }</style> + <script> + add_task(async function test() { + // A simplified version of what MozXULElement.parseXULToFragment does + let parser = new DOMParser(); + parser.forceEnableXULXBL(); + let doc = parser.parseFromString(`<box xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"><box part="foo">there</box></box>`, `application/xml`); + let range = doc.createRange(); + range.selectNodeContents(doc.querySelector("box")); + let frag = range.extractContents(); + + customElements.define("custom-button", class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: "open"}); + this.shadowRoot.appendChild(document.importNode(frag, true)); + } + }); + let button = document.createElement("custom-button"); + document.body.appendChild(button); + + let box = button.shadowRoot.querySelector("box"); + + // XXX: this fixes it + // box.removeAttribute("part"); + // box.setAttribute("part", "foo"); + + is(window.getComputedStyle(box).getPropertyValue("background-color"), "rgb(255, 0, 0)", "part applied"); + }); + </script> +</head> +<body> +<p id="display"></p> +<div id="content" style="display: none"></div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_deck.xhtml b/toolkit/content/tests/chrome/test_deck.xhtml new file mode 100644 index 0000000000..d6f824b357 --- /dev/null +++ b/toolkit/content/tests/chrome/test_deck.xhtml @@ -0,0 +1,133 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for deck + --> +<window title="Deck Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<deck id="deck1" style="padding-top: 5px; padding-bottom: 12px;"> + <button id="d1b1" label="Button One"/> + <button id="d1b2" label="Button Two is larger" style="height: 80px; margin: 1px;"/> +</deck> +<deck id="deck2" selectedIndex="1"> + <button id="d2b1" label="Button One"/> + <button id="d2b2" label="Button Two"/> +</deck> +<deck id="deck3" selectedIndex="1"> + <button id="d3b1" label="Remove me"/> + <button id="d3b2" label="Keep me selected"/> +</deck> +<deck id="deck4" selectedIndex="5"> + <button id="d4b1" label="Remove me"/> + <button id="d4b2" label="Remove me"/> + <button id="d4b3" label="Remove me"/> + <button id="d4b4" label="Button 4"/> + <button id="d4b5" label="Button 5"/> + <button id="d4b6" label="Keep me selected"/> + <button id="d4b7" label="Button 7"/> +</deck> +<deck id="deck5" selectedIndex="2"> + <button id="d5b1" label="Button 1"/> + <button id="d5b2" label="Button 2"/> + <button id="d5b3" label="Keep me selected"/> + <button id="d5b4" label="Remove me"/> + <button id="d5b5" label="Remove me"/> + <button id="d5b6" label="Remove me"/> +</deck> + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ +add_task(async function run_tests() { + test_deck(); + await test_deck_child_removal(); +}); + +function test_deck() +{ + var deck = $("deck1"); + is(deck.selectedIndex, 0, "deck one selectedIndex"); + // this size is the button height, 80, plus the button padding of 1px on each side, + // plus the deck's 5px top padding and the 12px bottom padding. + var rect = deck.getBoundingClientRect(); + is(Math.round(rect.bottom) - Math.round(rect.top), 99, "deck size of largest child"); + synthesizeMouseExpectEvent(deck, 12, 12, { }, $("d1b1"), "click", "mouse on deck one"); + + // change the selected page of the deck and ensure that the mouse click goes + // to the button on that page + deck.selectedIndex = 1; + is(deck.selectedIndex, 1, "deck one selectedIndex after change"); + synthesizeMouseExpectEvent(deck, 9, 9, { }, $("d1b2"), "click", "mouse on deck one after change"); + + deck = $("deck2"); + is(deck.selectedIndex, 1, "deck two selectedIndex"); + synthesizeMouseExpectEvent(deck, 9, 9, { }, $("d2b2"), "click", "mouse on deck two"); +} + +async function test_deck_child_removal() +{ + // Start with a simple case where we have two child nodes in a deck, with + // the second child (index 1) selected. Removing the first node should + // automatically set the selectedIndex at 0. + let deck = $("deck3"); + let child = $("d3b1"); + is(deck.selectedIndex, 1, "Should have the deck element at index 1 selected"); + + // Remove the child at the 0th index. The deck should automatically + // set the selectedIndex to "0". + child.remove(); + + await Promise.resolve(); + + is(deck.selectedIndex, 0, "Should have the deck element at index 0 selected"); + + // Now scale it up by using a deck with 7 child nodes, and remove the + // first three, making sure that the selectedIndex is decremented + // each time. + deck = $("deck4"); + let expectedIndex = 5; + is(deck.selectedIndex, expectedIndex, + "Should have the deck element at index " + expectedIndex + " selected"); + + for (let i = 0; i < 3; ++i) { + deck.firstChild.remove(); + expectedIndex--; + await Promise.resolve(); + is(deck.selectedIndex, expectedIndex, + "Should have the deck element at index " + expectedIndex + " selected"); + } + + // Check that removing the currently selected node doesn't change + // behaviour. + deck.childNodes[expectedIndex].remove(); + await Promise.resolve(); + is(deck.selectedIndex, expectedIndex, + "The selectedIndex should not change when removing the node " + + "at the selected index."); + + // Finally, make sure we haven't changed the behaviour when removing + // nodes at indexes greater than the selected node. + deck = $("deck5"); + expectedIndex = 2; + await Promise.resolve(); + is(deck.selectedIndex, expectedIndex, + "Should have the deck element at index " + expectedIndex + " selected"); + + // And then remove all of the nodes, starting from last to first, making + // sure that the selectedIndex does not change. + while (deck.lastChild) { + deck.lastChild.remove(); + await Promise.resolve(); + is(deck.selectedIndex, expectedIndex, + "Should have the deck element at index " + expectedIndex + " selected"); + } +} +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_dialog_button.xhtml b/toolkit/content/tests/chrome/test_dialog_button.xhtml new file mode 100644 index 0000000000..8c973839c1 --- /dev/null +++ b/toolkit/content/tests/chrome/test_dialog_button.xhtml @@ -0,0 +1,36 @@ +<!DOCTYPE HTML> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title><!-- Test with dialog & buttons --></title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script><![CDATA[ + add_task(async function test_dialog_button_accesskey() { + var win = window.browsingContext.topChromeWindow.openDialog( + "dialog_button.xhtml", + "_new", + "chrome,dialog" + ); + await new Promise((r) => win.addEventListener("load", r, { once: true })); + + let dialogClosed = new Promise((r) => { + win.document.addEventListener("dialogclosing", r, { once: true }); + }); + // https://bugzilla.mozilla.org/show_bug.cgi?id=1625632 + // When pressing an accesskey for a built in dialog button while a regular button is focused, + // it should forward to the dialog. + win.document.querySelector("#button").focus(); + synthesizeKey("a", {}, win); + await dialogClosed; + ok(true, "Accesskey on focused button"); + }); + ]]></script> +</head> +<body> +<p id="display"></p> +<div id="content" style="display: none"></div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_dialogfocus.xhtml b/toolkit/content/tests/chrome/test_dialogfocus.xhtml new file mode 100644 index 0000000000..10e60ac776 --- /dev/null +++ b/toolkit/content/tests/chrome/test_dialogfocus.xhtml @@ -0,0 +1,137 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window width="500" height="600" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<button id="test" label="Test"/> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +<script> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); +SimpleTest.requestCompleteLog(); + +var expected = [ "one", "_extra2", "tab", "one", "tabbutton2", "tabbutton", "two", "textbox-yes", "one", "root" ]; +// non-Mac will always focus the default button if any of the dialog buttons +// would be focused +if (!navigator.platform.includes("Mac")) + expected[1] = "_accept"; + +let extraDialog = "data:application/xhtml+xml,<window id='root'><dialog " + + "buttons='none' " + + "xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'>" + + "<button id='nonbutton' noinitialfocus='true'/></dialog></window>"; + +var step = 0; +var fullKeyboardAccess = false; + +function startTest() +{ + var testButton = document.getElementById("test"); + synthesizeKey("KEY_Tab"); + fullKeyboardAccess = (document.activeElement == testButton); + info("We " + (fullKeyboardAccess ? "have" : "don't have") + " full keyboard access"); + runTest(); +} + +function runTest() +{ + step++; + info("runTest(), step = " + step + ", expected = " + expected[step - 1]); + if (step > expected.length || (!fullKeyboardAccess && step == 2)) { + info("finishing"); + SimpleTest.finish(); + return; + } + + var expectedFocus = expected[step - 1]; + let filename = expectedFocus == "root" ? "dialog_dialogfocus2.xhtml" : "dialog_dialogfocus.xhtml"; + var win = window.browsingContext.topChromeWindow.openDialog(filename, "_new", "chrome,dialog", step); + + function checkDialogFocus(event) + { + info(`checkDialogFocus()`); + let match = false; + let activeElement = win.document.activeElement; + let dialog = win.document.getElementById("dialog-focus"); + + if (activeElement == dialog) { + let shadowActiveElement = + dialog.shadowRoot.activeElement; + if (shadowActiveElement) { + activeElement = shadowActiveElement; + } + } + // if full keyboard access is not on, just skip the tests + if (fullKeyboardAccess) { + if (!(Element.isInstance(event.target))) { + info("target not an Element"); + return; + } + + if (expectedFocus[0] == "_") + match = (activeElement.getAttribute("dlgtype") == expectedFocus.substring(1)); + else + match = (activeElement.id == expectedFocus); + info("match = " + match); + if (!match) + return; + } + else { + match = (activeElement == win.document.documentElement); + info("match = " + match); + } + + win.removeEventListener("focus", checkDialogFocusEvent, true); + dialog.shadowRoot.removeEventListener( + "focus", checkDialogFocusEvent, true); + ok(match, "focus step " + step); + + win.close(); + SimpleTest.waitForFocus(runTest, window); + } + + let finalCheckInitiated = false; + function checkDialogFocusEvent(event) { + // Delay to have time for focus/blur to occur. + if (expectedFocus == "root") { + if (!finalCheckInitiated) { + setTimeout(() => { + is(win.document.activeElement, win.document.documentElement, + "No other focus but root"); + win.close(); + SimpleTest.waitForFocus(runTest, window); + }, 0); + finalCheckInitiated = true; + } + } else { + checkDialogFocus(event); + } + } + win.addEventListener("focus", checkDialogFocusEvent, true); + win.addEventListener("load", () => { + win.document.getElementById("dialog-focus").shadowRoot.addEventListener( + "focus", checkDialogFocusEvent, true); + }); +} + +SimpleTest.waitForFocus(startTest, window); + +]]> + +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_edit_contextmenu.html b/toolkit/content/tests/chrome/test_edit_contextmenu.html new file mode 100644 index 0000000000..88140efa9c --- /dev/null +++ b/toolkit/content/tests/chrome/test_edit_contextmenu.html @@ -0,0 +1,90 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1513343 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1513343</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://global/skin"/> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTest() { + let win = window.browsingContext.topChromeWindow.open("file_edit_contextmenu.xhtml", "_blank", "chrome,width=600,height=600"); + await new Promise(r => win.addEventListener("load", r, { once: true })); + await SimpleTest.promiseFocus(win); + + const elements = [ + win.document.querySelector("textarea"), + win.document.querySelector("input"), + win.document.querySelector("search-textbox"), + win.document.querySelector("shadow-input").shadowRoot.querySelector("input"), + ]; + for (const element of elements) { + await testElement(element, win); + } + win.close(); + SimpleTest.finish(); + } + + async function testElement(element, win) { + ok(element, "element exists"); + + info("Synthesizing a key so 'Undo' will be enabled"); + element.focus(); + synthesizeKey("x", {}, win); + is(element.value, "x", "initial value"); + + element.select(); + synthesizeKey("c", { accelKey: true }, win); // copy to clipboard + synthesizeKey("KEY_ArrowRight", {}, win); // drop selection to disable cut and copy context menu items + + win.document.addEventListener("contextmenu", (e) => { + info("Calling prevent default on the first contextmenu event"); + e.preventDefault(); + }, { once: true }); + synthesizeMouseAtCenter(element, {type: "contextmenu"}, win); + ok(!win.document.getElementById("textbox-contextmenu"), "contextmenu with preventDefault() doesn't run"); + + let popupshown = new Promise(r => win.addEventListener("popupshown", r, { once: true })); + synthesizeMouseAtCenter(element, {type: "contextmenu"}, win); + let contextmenu = win.document.getElementById("textbox-contextmenu"); + ok(contextmenu, "context menu exists after right click"); + await popupshown; + + // Check that we only got the one context menu, and not two. + let outerContextmenu = win.document.getElementById("outer-context-menu"); + ok(outerContextmenu.state == "closed", "the outer context menu state is is not closed, it's: " + outerContextmenu.state); + + ok(!contextmenu.querySelector("[command=cmd_undo]").hasAttribute("disabled"), "undo enabled"); + ok(contextmenu.querySelector("[command=cmd_cut]").hasAttribute("disabled"), "cut disabled"); + ok(contextmenu.querySelector("[command=cmd_copy]").hasAttribute("disabled"), "copy disabled"); + ok(!contextmenu.querySelector("[command=cmd_paste]").hasAttribute("disabled"), "paste enabled"); + ok(contextmenu.querySelector("[command=cmd_delete]").hasAttribute("disabled"), "delete disabled"); + ok(!contextmenu.querySelector("[command=cmd_selectAll]").hasAttribute("disabled"), "select all enabled"); + + let popuphidden = new Promise(r => win.addEventListener("popuphidden", r, { once: true })); + + contextmenu.activateItem(contextmenu.querySelector("[command=cmd_undo]")); + + await popuphidden; + + is(element.value, "", "undo worked"); + contextmenu.remove(); + } + </script> +</head> +<body onload="runTest()"> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1513343">Mozilla Bug 1513343</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_editor_for_input_with_autocomplete.html b/toolkit/content/tests/chrome/test_editor_for_input_with_autocomplete.html new file mode 100644 index 0000000000..91f0f159b4 --- /dev/null +++ b/toolkit/content/tests/chrome/test_editor_for_input_with_autocomplete.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Basic editor behavior for HTML input element with autocomplete</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="file_editor_with_autocomplete.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <iframe id="formTarget" name="formTarget"></iframe> + <form action="data:text/html," target="formTarget"> + <input name="test" id="input"><input type="submit"> + </form> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> +SimpleTest.waitForExplicitFinish(); + +async function registerWord(aTarget, aAutoCompleteController) { + // Register a word to the form history. + let chromeScript = SpecialPowers.loadChromeScript(function addEntry() { +/* eslint-env mozilla/chrome-script */ + let {FormHistory} = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" + ); + FormHistory.update({ op: "add", fieldname: "test", value: "Mozilla" }); + }); + aTarget.focus(); + aTarget.value = "Mozilla"; + + await waitForCondition(() => { + if (aAutoCompleteController.searchStatus == aAutoCompleteController.STATUS_NONE || + aAutoCompleteController.searchStatus == aAutoCompleteController.STATUS_COMPLETE_NO_MATCH) { + aAutoCompleteController.startSearch("Mozilla"); + } + return aAutoCompleteController.matchCount > 0; + }); + chromeScript.destroy(); + aTarget.value = ""; + synthesizeKey("KEY_Escape"); +} + +async function runTests() { + var formFillController = + SpecialPowers.getFormFillController() + .QueryInterface(Ci.nsIAutoCompleteInput); + var originalFormFillTimeout = formFillController.timeout; + + SpecialPowers.attachFormFillControllerTo(window); + var target = document.getElementById("input"); + + // Register a word to the form history. + await registerWord(target, formFillController.controller); + + let tests1 = new nsDoTestsForEditorWithAutoComplete( + "Testing on HTML input (asynchronously search)", + window, target, formFillController.controller, is, todo_is, + function() { return target.value; }); + await tests1.run(); + + target.setAttribute("timeout", 0); + let tests2 = new nsDoTestsForEditorWithAutoComplete( + "Testing on HTML input (synchronously search)", + window, target, formFillController.controller, is, todo_is, + function() { return target.value; }); + await tests2.run(); + + formFillController.timeout = originalFormFillTimeout; + SpecialPowers.detachFormFillControllerFrom(window); + SimpleTest.finish(); +} + +SimpleTest.waitForFocus(runTests); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_findbar.xhtml b/toolkit/content/tests/chrome/test_findbar.xhtml new file mode 100644 index 0000000000..a0329adfbd --- /dev/null +++ b/toolkit/content/tests/chrome/test_findbar.xhtml @@ -0,0 +1,47 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=257061 +https://bugzilla.mozilla.org/show_bug.cgi?id=288254 +--> +<window title="Mozilla Bug 257061 and Bug 288254" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=257061">Mozilla Bug 257061</a> +<a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=288254">Mozilla Bug 288254</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +/** Test for Bug 257061 and Bug 288254 **/ +SimpleTest.waitForExplicitFinish(); + +// Since bug 978861, this pref is set to `false` on OSX. For this test, we'll +// set it `true` to disable the find clipboard on OSX, which interferes with +// our tests. +SpecialPowers.pushPrefEnv({ + set: [["accessibility.typeaheadfind.prefillwithselection", true]] +}, () => { + window.openDialog("findbar_window.xhtml", "findbartest", "chrome,width=600,height=600,noopener", window); +}); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_findbar_entireword.xhtml b/toolkit/content/tests/chrome/test_findbar_entireword.xhtml new file mode 100644 index 0000000000..641e407aa0 --- /dev/null +++ b/toolkit/content/tests/chrome/test_findbar_entireword.xhtml @@ -0,0 +1,38 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=269442 +--> +<window title="Mozilla Bug 269442" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=269442"> + Mozilla Bug 269442 + </a> + + <p id="display"></p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + + <script class="testbody" type="application/javascript"> + <![CDATA[ + + /** Test for Bug 269442 **/ + SimpleTest.waitForExplicitFinish(); + window.openDialog("findbar_entireword_window.xhtml", "269442test", + "chrome,width=600,height=600,noopener", window); + + ]]> + </script> + +</window> diff --git a/toolkit/content/tests/chrome/test_findbar_events.xhtml b/toolkit/content/tests/chrome/test_findbar_events.xhtml new file mode 100644 index 0000000000..f8e96d8ba8 --- /dev/null +++ b/toolkit/content/tests/chrome/test_findbar_events.xhtml @@ -0,0 +1,38 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet + href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=793275 +--> +<window title="Mozilla Bug 793275" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <a target="_blank" + href="https://bugzilla.mozilla.org/show_bug.cgi?id=793275"> + Mozilla Bug 793275 + </a> + + <p id="display"></p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + + <script class="testbody" type="application/javascript"> + <![CDATA[ + + /** Test for Bug 793275 **/ + SimpleTest.waitForExplicitFinish(); + window.openDialog("findbar_events_window.xhtml", "793275test", + "chrome,width=600,height=600,noopener", window); + + ]]> + </script> + +</window> diff --git a/toolkit/content/tests/chrome/test_frames.xhtml b/toolkit/content/tests/chrome/test_frames.xhtml new file mode 100644 index 0000000000..a849b1d6b0 --- /dev/null +++ b/toolkit/content/tests/chrome/test_frames.xhtml @@ -0,0 +1,61 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + + <script><![CDATA[ +SimpleTest.waitForExplicitFinish(); + +function runTest() { + for (let i = 1; i <= 3; i++) { + let frame = document.getElementById("frame" + i); + ok(XULFrameElement.isInstance(frame), "XULFrameElement " + i); + + // Check the various fields to ensure that they have the correct type. + ok(frame.docShell instanceof Ci.nsIDocShell, "docShell " + i); + ok(frame.webNavigation instanceof Ci.nsIWebNavigation, "webNavigation " + i); + + let contentWindow = frame.contentWindow; + let contentDocument = frame.contentDocument; + ok(Window.isInstance(contentWindow), "contentWindow " + i); + ok(Document.isInstance(contentDocument), "contentDocument " + i); + is(contentDocument.body.id, "thechildbody" + i, "right document body " + i); + + // These fields should all be read-only. + frame.docShell = null; + ok(frame.docShell instanceof Ci.nsIDocShell, "docShell after set " + i); + frame.webNavigation = null; + ok(frame.webNavigation instanceof Ci.nsIWebNavigation, "webNavigation after set " + i); + frame.contentWindow = window; + is(frame.contentWindow, contentWindow, "contentWindow after set " + i); + frame.contentDocument = document; + is(frame.contentDocument, contentDocument, "contentDocument after set " + i); + } + + // A non-frame element should not have these fields. + let button = document.getElementById("nonframe"); + ok(!(XULFrameElement.isInstance(button)), "XULFrameElement non frame"); + is(button.docShell, undefined, "docShell non frame"); + is(button.webNavigation, undefined, "webNavigation non frame"); + is(button.contentWindow, undefined, "contentWindow non frame"); + is(button.contentDocument, undefined, "contentDocument non frame"); + + SimpleTest.finish(); +} +]]> +</script> + +<iframe id="frame1" src="data:text/html,<body id='thechildbody1'>"/> +<browser id="frame2" src="data:text/html,<body id='thechildbody2'>"/> +<editor id="frame3" src="data:text/html,<body id='thechildbody3'>"/> +<button id="nonframe"/> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<div id="content" style="display: none"></div> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_hiddenitems.xhtml b/toolkit/content/tests/chrome/test_hiddenitems.xhtml new file mode 100644 index 0000000000..79f06c8890 --- /dev/null +++ b/toolkit/content/tests/chrome/test_hiddenitems.xhtml @@ -0,0 +1,76 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=317422 +--> +<window title="Mozilla Bug 317422" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=317422" + target="_blank">Mozilla Bug 317422</a> + </body> + + <richlistbox id="richlistbox" seltype="multiple"> + <richlistitem id="richlistbox_item1"><label value="Item 1"/></richlistitem> + <richlistitem id="richlistbox_item2"><label value="Item 2"/></richlistitem> + <richlistitem id="richlistbox_item3" hidden="true"><label value="Item 3"/></richlistitem> + <richlistitem id="richlistbox_item4"><label value="Item 4"/></richlistitem> + <richlistitem id="richlistbox_item5" collapsed="true"><label value="Item 5"/></richlistitem> + <richlistitem id="richlistbox_item6"><label value="Item 6"/></richlistitem> + <richlistitem id="richlistbox_item7" hidden="true"><label value="Item 7"/></richlistitem> + </richlistbox> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +/** Test for Bug 317422 **/ +SimpleTest.waitForExplicitFinish(); + +function testListbox(id) +{ + var listbox = document.getElementById(id); + listbox.focus(); + is(listbox.getRowCount(), 7, id + ": Returned the wrong number of rows"); + is(listbox.getItemAtIndex(2).id, id + "_item3", id + ": Should still return hidden items"); + listbox.selectedIndex = 0; + is(listbox.selectedItem.id, id + "_item1", id + ": First item was not selected"); + sendKey("DOWN"); + is(listbox.selectedItem.id, id + "_item2", id + ": Down didn't move to second item"); + sendKey("DOWN"); + is(listbox.selectedItem.id, id + "_item4", id + ": Down didn't skip hidden item"); + sendKey("DOWN"); + is(listbox.selectedItem.id, id + "_item6", id + ": Down didn't skip collapsed item"); + sendKey("UP"); + is(listbox.selectedItem.id, id + "_item4", id + ": Up didn't skip collapsed item"); + sendKey("UP"); + is(listbox.selectedItem.id, id + "_item2", id + ": Up didn't skip hidden item"); + listbox.selectAll(); + is(listbox.selectedItems.length, 7, id + ": Should have still selected all items"); + listbox.selectedIndex = 2; + ok(listbox.selectedItem == listbox.getItemAtIndex(2), id + ": Should have selected the hidden item"); + listbox.selectedIndex = 0; + sendKey("END"); + is(listbox.selectedItem.id, id + "_item6", id + ": Should have moved to the last unhidden item"); + sendMouseEvent({type: 'click'}, id + "_item1"); + ok(listbox.selectedItem == listbox.getItemAtIndex(0), id + ": Should have selected the first item"); + is(listbox.selectedItems.length, 1, id + ": Should only be one selected item"); + sendMouseEvent({type: 'click', shiftKey: true}, id + "_item6"); + is(listbox.selectedItems.length, 4, id + ": Should have selected all visible items"); + listbox.selectedIndex = 0; + sendKey("PAGE_DOWN"); + is(listbox.selectedItem.id, id + "_item6", id + ": Page down should go to the last visible item"); + sendKey("PAGE_UP"); + is(listbox.selectedItem.id, id + "_item1", id + ": Page up should go to the first visible item"); +} + +window.onload = function runTests() { + testListbox("richlistbox"); + SimpleTest.finish(); +}; + ]]></script> +</window> diff --git a/toolkit/content/tests/chrome/test_hiddenpaging.xhtml b/toolkit/content/tests/chrome/test_hiddenpaging.xhtml new file mode 100644 index 0000000000..59bcd0e432 --- /dev/null +++ b/toolkit/content/tests/chrome/test_hiddenpaging.xhtml @@ -0,0 +1,83 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=317422 +--> +<window title="Mozilla Bug 317422" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <style xmlns="http://www.w3.org/1999/xhtml"> + /* This makes the richlistbox about 4.5 rows high */ + richlistitem:not([collapsed]) { + min-height: 30px; + } + richlistbox { + height: 135px; + } + </style> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=317422" + target="_blank">Mozilla Bug 317422</a> + </body> + + <richlistbox id="richlistbox" seltype="multiple"> + <richlistitem id="richlistbox_item1"><label value="Item 1"/></richlistitem> + <richlistitem id="richlistbox_item2"><label value="Item 2"/></richlistitem> + <richlistitem id="richlistbox_item3" hidden="true"><label value="Item 3"/></richlistitem> + <richlistitem id="richlistbox_item4"><label value="Item 4"/></richlistitem> + <richlistitem id="richlistbox_item5" collapsed="true"><label value="Item 5"/></richlistitem> + <richlistitem id="richlistbox_item6"><label value="Item 6"/></richlistitem> + <richlistitem id="richlistbox_item7"><label value="Item 7"/></richlistitem> + <richlistitem id="richlistbox_item8"><label value="Item 8"/></richlistitem> + <richlistitem id="richlistbox_item9"><label value="Item 9"/></richlistitem> + <richlistitem id="richlistbox_item10"><label value="Item 10"/></richlistitem> + <richlistitem id="richlistbox_item11"><label value="Item 11"/></richlistitem> + <richlistitem id="richlistbox_item12"><label value="Item 12"/></richlistitem> + <richlistitem id="richlistbox_item13"><label value="Item 13"/></richlistitem> + <richlistitem id="richlistbox_item14"><label value="Item 14"/></richlistitem> + <richlistitem id="richlistbox_item15" hidden="true"><label value="Item 15"/></richlistitem> + </richlistbox> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +/** Test for Bug 317422 **/ +SimpleTest.waitForExplicitFinish(); + +function testRichlistbox() +{ + var id = "richlistbox"; + var listbox = document.getElementById(id); + listbox.focus(); + listbox.selectedIndex = 0; + sendKey("PAGE_DOWN"); + is(listbox.selectedItem.id, id + "_item7", id + ": Page down should go to the item one visible page away"); + is(listbox.getIndexOfFirstVisibleRow(), 6, id + ": Page down should have scrolled down a visible page"); + sendKey("PAGE_DOWN"); + is(listbox.selectedItem.id, id + "_item11", id + ": Second page down should go to the item two visible pages away"); + is(listbox.getIndexOfFirstVisibleRow(), 9, id + ": Second page down should not scroll beyond the end"); + sendKey("PAGE_DOWN"); + is(listbox.selectedItem.id, id + "_item14", id + ": Third page down should go to the last visible item"); + is(listbox.getIndexOfFirstVisibleRow(), 9, id + ": Third page down should not have scrolled at all"); + sendKey("PAGE_UP"); + is(listbox.selectedItem.id, id + "_item10", id + ": Page up should go to the item one visible page away"); + is(listbox.getIndexOfFirstVisibleRow(), 5, id + ": Page up should scroll up a visible page"); + sendKey("PAGE_UP"); + is(listbox.selectedItem.id, id + "_item6", id + ": Second page up should go to the item two visible pages away"); + is(listbox.getIndexOfFirstVisibleRow(), 0, id + ": Second page up should not scroll beyond the start"); + sendKey("PAGE_UP"); + is(listbox.selectedItem.id, id + "_item1", id + ": Third page up should return to the first visible item"); + is(listbox.getIndexOfFirstVisibleRow(), 0, id + ": Third page up should not have scrolled at all"); +} + +window.onload = function runTests() { + testRichlistbox(); + SimpleTest.finish(); +}; + ]]></script> +</window> diff --git a/toolkit/content/tests/chrome/test_keys.xhtml b/toolkit/content/tests/chrome/test_keys.xhtml new file mode 100644 index 0000000000..4d4c1ae8f0 --- /dev/null +++ b/toolkit/content/tests/chrome/test_keys.xhtml @@ -0,0 +1,28 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Keys Test" + onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + +<script> +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_keys.xhtml", "_blank", "chrome,width=200,height=200,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_labelcontrol.xhtml b/toolkit/content/tests/chrome/test_labelcontrol.xhtml new file mode 100644 index 0000000000..06e7f96105 --- /dev/null +++ b/toolkit/content/tests/chrome/test_labelcontrol.xhtml @@ -0,0 +1,54 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for label control="value" + --> +<window title="tabindex" width="500" height="600" + onload="runTests()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<label id="textbox-label" control="textbox" /> +<html:input id="textbox" value="Test"/> +<label id="checkbox-label" control="checkbox" /> +<checkbox id="checkbox" value="Checkbox"/> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +<script> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function runTests() +{ + let textbox = $("textbox"); + let textboxLabel = $("textbox-label"); + is(textboxLabel.control, "textbox", "textbox control"); + is(textboxLabel.labeledControlElement, textbox, "textbox labeledControlElement"); + + let checkbox = $("checkbox"); + let checkboxLabel = $("checkbox-label"); + is(checkboxLabel.control, "checkbox", "checkbox control"); + is(checkboxLabel.labeledControlElement, checkbox, "checkbox labeledControlElement"); + is(checkbox.accessKey, "", "checkbox accessKey not set"); + checkboxLabel.accessKey = "C"; + is(checkbox.accessKey, "C", "checkbox accessKey set"); + + SimpleTest.finish(); +} + +]]> + +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_largemenu.html b/toolkit/content/tests/chrome/test_largemenu.html new file mode 100644 index 0000000000..eb6b6eff8e --- /dev/null +++ b/toolkit/content/tests/chrome/test_largemenu.html @@ -0,0 +1,26 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Large Menu Tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script> + SimpleTest.waitForExplicitFinish(); + async function runTest() { + // This test exercises non-native menu code. So disable native context menus for this test. + // If we ever get to a point where we don't use any non-native menus on macOS any more, we can + // disable this test on macOS. + await SpecialPowers.pushPrefEnv({ set: [["widget.macos.native-context-menus", false]] }); + + window.openDialog("window_largemenu.xhtml", "_blank", "chrome,width=200,height=200,noopener", window); + } + </script> +</head> +<body onload="setTimeout(runTest, 0);"> +<p id="display"></p> +<div id="content" style="display: none"></div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_maximized_persist.xhtml b/toolkit/content/tests/chrome/test_maximized_persist.xhtml new file mode 100644 index 0000000000..1e2e648e4d --- /dev/null +++ b/toolkit/content/tests/chrome/test_maximized_persist.xhtml @@ -0,0 +1,18 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<window title="Window Open Test" + onload="runTest('window_maximized_persist.xhtml')" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> +<script src="file_maximized_persist.js"/> +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> +</window> diff --git a/toolkit/content/tests/chrome/test_maximized_persist_with_no_titlebar.xhtml b/toolkit/content/tests/chrome/test_maximized_persist_with_no_titlebar.xhtml new file mode 100644 index 0000000000..3bc36c8e82 --- /dev/null +++ b/toolkit/content/tests/chrome/test_maximized_persist_with_no_titlebar.xhtml @@ -0,0 +1,18 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<window title="Window Open Test" + onload="runTest('window_maximized_persist_with_no_titlebar.xhtml')" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> +<script src="file_maximized_persist.js"/> +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> +</window> diff --git a/toolkit/content/tests/chrome/test_menu.xhtml b/toolkit/content/tests/chrome/test_menu.xhtml new file mode 100644 index 0000000000..8ef5c4ca15 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menu.xhtml @@ -0,0 +1,75 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu Destruction Test" + onload="runTests();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <menubar> + <menu label="top" id="top"> + <menupopup> + <menuitem label="top item"/> + + <menu label="hello" id="nested"> + <menupopup> + <menuitem label="item1"/> + <menuitem label="item2" id="item2"/> + </menupopup> + </menu> + </menupopup> + </menu> + </menubar> + + <script class="testbody" type="application/javascript"> + <![CDATA[ + + SimpleTest.waitForExplicitFinish(); + + function runTests() + { + var menu = document.getElementById("nested"); + + // nsIDOMXULContainerElement::getIndexOfItem(); + var item = document.getElementById("item2"); + is(menu.getIndexOfItem(item), 1, + "nsIDOMXULContainerElement::getIndexOfItem() failed."); + + // nsIDOMXULContainerElement::getItemAtIndex(); + var itemAtIdx = menu.getItemAtIndex(1); + is(itemAtIdx, item, + "nsIDOMXULContainerElement::getItemAtIndex() failed."); + + // nsIDOMXULContainerElement::itemCount + is(menu.itemCount, 2, "nsIDOMXULContainerElement::itemCount failed."); + + // nsIDOMXULContainerElement::parentContainer + var topmenu = document.getElementById("top"); + is(menu.parentContainer, topmenu, + "nsIDOMXULContainerElement::parentContainer failed."); + + // nsIDOMXULContainerElement::appendItem(); + item = menu.appendItem("item3"); + is(menu.getIndexOfItem(item), 2, + "nsIDOMXULContainerElement::appendItem() failed."); + + SimpleTest.finish(); + } + + ]]> + </script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <p id="display"> + </p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + +</window> + diff --git a/toolkit/content/tests/chrome/test_menu_activateitem.xhtml b/toolkit/content/tests/chrome/test_menu_activateitem.xhtml new file mode 100644 index 0000000000..8b4bff89d6 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menu_activateitem.xhtml @@ -0,0 +1,169 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu Activation Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <label id="label" value="label" context="contextmenu"/> + <menupopup id="contextmenu"> + <menuitem id="item1" label="Item 1"/> + <menu label="Submenu"> + <menupopup id="submenu"> + <menuitem id="item2" label="Item 2"/> + <menuitem id="item4" label="Item 4" hidden="true"/> + <menu label="Inner Submenu"> + <menupopup id="innersubmenu"> + <menuitem id="item3" label="Item 3"/> + </menupopup> + </menu> + </menupopup> + </menu> + </menupopup> + + <script class="testbody" type="application/javascript"> + <![CDATA[ + +function waitForEvent(subject, eventName) { + return new Promise(resolve => { + subject.addEventListener(eventName, function listener(event) { + if (event.target == subject) { + subject.removeEventListener(eventName, listener); + resolve(); + } + }); + }); +} + +let doNothing = () => {}; + +function checkActivate(desc, menu, item, expectedResult) +{ + desc = desc + " for " + menu.id + ".activateItem(" + item.id + ")"; + try { + menu.activateItem(item); + ok(expectedResult, desc); + } catch (ex) { + ok(!expectedResult, `${desc}: ${ex}`); + } +} + +async function checkActivateItems(desc, openFn, expectedResults) +{ + // Iterate over each menu/submenu and try activating the item from that menu. + // This should only pass when the item is a descendant of that menu and the + // menu is open. + let menus = [ "contextmenu", "submenu", "innersubmenu" ]; + let items = [ "item1", "item2", "item3", "item4" ]; + + let needToOpen = true; + + let contextmenu = document.getElementById("contextmenu"); + + for (let m = 0; m < menus.length; m++) { + let menu = document.getElementById(menus[m]); + + for (let i = 0; i < items.length; i++) { + if (needToOpen) { + await openFn(); + } + + // If an activation is expected, the popup will hide. Wait for it to + // hide after calling checkActivate. If the popup is hidden, it will + // need to be reopened for the next step, so set needToOpen accordingly. + let popupHiddenPromise; + needToOpen = expectedResults[i]; + if (expectedResults[i]) { + popupHiddenPromise = waitForEvent(contextmenu, "popuphidden"); + } + + checkActivate(desc, menu, document.getElementById(items[i]), expectedResults[i]); + + await popupHiddenPromise; + } + + // For the next iteration, we will be using the next submenu. The item + // in the first menu will never be activatable since it is in a higher + // part of the menu hierarchy. + expectedResults[m] = false; + } + + if (!needToOpen && openFn != doNothing) { + // Hide the popup if it is still expected to be open. If doNothing is + // the opening function, then the popup would never have been opened. + let popupHiddenPromise = waitForEvent(contextmenu, "popuphidden"); + contextmenu.hidePopup(); + await popupHiddenPromise; + } +} + +add_task(async function () { + await checkActivateItems("expected failure when no menus open", + doNothing, [false, false, false, false]); + + let openContextMenu = async () => { + // Open the first level of the context menu + let contextmenu = document.getElementById("contextmenu"); + let popupShownPromise = waitForEvent(contextmenu, "popupshown"); + synthesizeMouseAtCenter(document.getElementById("label"), { + type: "contextmenu", + button: 2, + }); + await popupShownPromise; + } + + await checkActivateItems("one menu open", openContextMenu, [true, false, false, false]); + + // Open the second level of the context menu + let openSubmenu = async () => { + await openContextMenu(); + let submenu = document.getElementById("submenu"); + let popupShownPromise = waitForEvent(submenu, "popupshown"); + submenu.parentNode.openMenu(true); + await popupShownPromise; + } + + await checkActivateItems("submenu menu open", openSubmenu, [true, true, false, false]); + + // Open the last level of the context menu + let openInnerSubmenu = async () => { + await openSubmenu(); + + let innersubmenu = document.getElementById("innersubmenu"); + let popupShownPromise = waitForEvent(innersubmenu, "popupshown"); + innersubmenu.parentNode.openMenu(true); + await popupShownPromise; + }; + await checkActivateItems("inner submenu menu open", openInnerSubmenu, [true, true, true, false]); + + // Check that an item not in the menu is not valid. + await openInnerSubmenu(); + + let innersubmenu = document.getElementById("innersubmenu"); + await checkActivate("item not in menu", innersubmenu, document.getElementById("item1"), false); + + // Now check with all menus closed again + let contextmenu = document.getElementById("contextmenu"); + let popupHiddenPromise = waitForEvent(contextmenu, "popuphidden"); + contextmenu.hidePopup(); + await popupHiddenPromise; + + await checkActivateItems("all menus closed", doNothing, [false, false, false, false]); +}); + + ]]> + </script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <p id="display"> + </p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + </pre> + </body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menu_hide.xhtml b/toolkit/content/tests/chrome/test_menu_hide.xhtml new file mode 100644 index 0000000000..c8698bf572 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menu_hide.xhtml @@ -0,0 +1,80 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu Destruction Test" + onload="setTimeout(runTests, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<menu id="menu"> + <menupopup onpopuphidden="if (event.target == this) done()"> + <menu id="submenu" label="One"> + <menupopup onpopupshown="submenuOpened();"> + <menuitem label="Two"/> + </menupopup> + </menu> + </menupopup> +</menu> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function runTests() +{ + let menu = $("menu"); + let menuitemAddedWhileHidden = menu.appendItem("Added while hidden"); + ok(!menuitemAddedWhileHidden.querySelector(".menu-text"), "hidden menuitem hasn't rendered yet."); + + menu.menupopup.addEventListener("popupshown", () => { + is(menuitemAddedWhileHidden.querySelector(".menu-text").value, "Added while hidden", + "menuitemAddedWhileHidden item has rendered after shown."); + let menuitemAddedWhileShown = menu.appendItem("Added while shown"); + is(menuitemAddedWhileShown.querySelector(".menu-text").value, "Added while shown", + "menuitemAddedWhileShown item has eagerly rendered."); + + let submenu = $("submenu"); + is(submenu.querySelector(".menu-text").value, "One", "submenu has rendered."); + + let submenuDynamic = document.createXULElement("menu"); + submenuDynamic.setAttribute("label", "Dynamic"); + ok(!submenuDynamic.querySelector(".menu-text"), "dynamic submenu hasn't rendered yet."); + menu.menupopup.append(submenuDynamic); + is(submenuDynamic.querySelector(".menu-text").value, "Dynamic", "dynamic submenu has rendered."); + + menu.menupopup.firstElementChild.open = true; + }, { once: true }); + menu.open = true; +} + +function submenuOpened() +{ + let submenu = $("submenu"); + is(submenu.getAttribute('_moz-menuactive'), "true", "menu highlighted"); + submenu.hidden = true; + $("menu").open = false; +} + +function done() +{ + ok($("submenu").hasAttribute('_moz-menuactive'), "menu still highlighted"); + SimpleTest.finish(); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menu_mouse_menuactive.xhtml b/toolkit/content/tests/chrome/test_menu_mouse_menuactive.xhtml new file mode 100644 index 0000000000..edd7f582b5 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menu_mouse_menuactive.xhtml @@ -0,0 +1,91 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu Activation Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <label id="label" value="label" context="contextmenu"/> + <menupopup id="contextmenu"> + <menuitem id="item1" label="Item 1"/> + <menuitem id="item2" label="Item 2"/> + </menupopup> + <script><![CDATA[ + +function waitForEvent(subject, eventName) { + return new Promise(resolve => { + subject.addEventListener(eventName, function listener(event) { + if (event.target == subject) { + subject.removeEventListener(eventName, listener); + resolve(); + } + }); + }); +} + +const menu = document.getElementById("contextmenu"); +const label = document.getElementById("label"); +const item1 = document.getElementById("item1"); +const item2 = document.getElementById("item2"); + +function openContextMenu() { + // Open the first level of the context menu + let promise = waitForEvent(menu, "popupshown"); + synthesizeMouseAtCenter(label, { + type: "contextmenu", + button: 2, + }); + return promise; +} + +function isActive(item) { + return item.hasAttribute("_moz-menuactive"); +} + +async function activateItem(item) { + info(`Activating ${item.id}`); + ok(!isActive(item), "Shouldn't be already active"); + let activated = waitForEvent(item, "DOMMenuItemActive"); + synthesizeMouse(item, 5, 5, { type: "mousemove" }); + await new Promise(r => setTimeout(r, 0)); + synthesizeMouse(item, 7, 7, { type: "mousemove" }); + await activated; +} + +add_task(async function() { + // Disable macOS native context menus, since we can't control activation of those here. + await SpecialPowers.pushPrefEnv({ set: [["widget.macos.native-context-menus", false]] }); + info(`Opening context-menu`); + await openContextMenu(); + is(menu.state, "open", "Menu should be open"); + + await activateItem(item1); + ok(isActive(item1), "item1 should be active"); + ok(!isActive(item2), "item2 should be inactive"); + + await activateItem(item2); + ok(isActive(item2), "item2 should be active"); + ok(!isActive(item1), "item1 should be inactive"); + + await activateItem(item1); + ok(isActive(item1), "item1 should be active"); + ok(!isActive(item2), "item2 should be inactive"); + + info(`Leaving context-menu`); + + let deactivated = waitForEvent(item1, "DOMMenuItemInactive"); + synthesizeMouse(label, 0, 0, { type: "mousemove" }); + + await deactivated; + ok(!isActive(item1), "item1 should be inactive"); + ok(!isActive(item2), "item2 should be inactive"); + + is(menu.state, "open", "Menu should still be open"); + menu.hidePopup(); +}); + + ]]></script> +</window> diff --git a/toolkit/content/tests/chrome/test_menu_withcapture.xhtml b/toolkit/content/tests/chrome/test_menu_withcapture.xhtml new file mode 100644 index 0000000000..fe90e3201c --- /dev/null +++ b/toolkit/content/tests/chrome/test_menu_withcapture.xhtml @@ -0,0 +1,61 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu with Mouse Capture" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <menulist id="menulist"> + <menupopup onpopupshown="shown(this)" onpopuphidden="done();"> + <menuitem id="menuitem" label="Menu Item" + onmousemove="moveHappened = true;" + onmouseup="upHappened = true;"/> + </menupopup> + </menulist> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); +SimpleTest.waitForFocus(startTest); + +let moveHappened = false, upHappened = false; + +function startTest() { + disableNonTestMouseEvents(true); + document.getElementById("menulist"). open = true; +} + +function shown(popup) +{ + popup.setCaptureAlways(); + setTimeout(function() { + synthesizeMouseAtCenter(document.getElementById("menuitem"), { type: "mousemove" }); + synthesizeMouseAtCenter(document.getElementById("menuitem"), { type: "mouseup" }); + }, 200); +} + +function done() +{ + ok(moveHappened, "mousemove happened"); + ok(upHappened, "mouseup happened"); + disableNonTestMouseEvents(false); + SimpleTest.finish(); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menuchecks.xhtml b/toolkit/content/tests/chrome/test_menuchecks.xhtml new file mode 100644 index 0000000000..8e67c9bb10 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menuchecks.xhtml @@ -0,0 +1,155 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu Checkbox and Radio Tests" + onload="runTest()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <hbox> + <button id="menu" type="menu" label="View"> + <menupopup id="popup" onpopupshown="popupShown()" onpopuphidden="popupHidden()"> + <menuitem id="toolbar" label="Show Toolbar" type="checkbox"/> + <menuitem id="statusbar" label="Show Status Bar" type="checkbox" checked="true"/> + <menuitem id="bookmarks" label="Show Bookmarks" type="checkbox" autocheck="false"/> + <menuitem id="history" label="Show History" type="checkbox" autocheck="false" checked="true"/> + <menuseparator/> + <menuitem id="byname" label="By Name" type="radio" name="sort"/> + <menuitem id="bydate" label="By Date" type="radio" name="sort" checked="true"/> + <menuseparator/> + <menuitem id="ascending" label="Ascending" type="radio" name="order" checked="true"/> + <menuitem id="descending" label="Descending" type="radio" name="order" autocheck="false"/> + <menuitem id="bysubject" label="By Subject" type="radio" name="sort"/> + </menupopup> + </button> + + </hbox> + + <!-- + This test checks that checkbox and radio menu items work properly + --> + <script class="testbody" type="application/javascript"> + <![CDATA[ + + SimpleTest.waitForExplicitFinish(); + var gTestIndex = 0; + + // tests to perform + var tests = [ + { + testname: "select unchecked checkbox", + item: "toolbar", + checked: ["toolbar", "statusbar", "history", "bydate", "ascending"] + }, + { + testname: "select checked checkbox", + item: "statusbar", + checked: ["toolbar", "history", "bydate", "ascending"] + }, + { + testname: "select unchecked autocheck checkbox", + item: "bookmarks", + checked: ["toolbar", "history", "bydate", "ascending"] + }, + { + testname: "select checked autocheck checkbox", + item: "history", + checked: ["toolbar", "history", "bydate", "ascending"] + }, + { + testname: "select unchecked radio", + item: "byname", + checked: ["toolbar", "history", "byname", "ascending"] + }, + { + testname: "select checked radio", + item: "byname", + checked: ["toolbar", "history", "byname", "ascending"] + }, + { + testname: "select out of order checked radio", + item: "bysubject", + scroll: true, + checked: ["toolbar", "history", "bysubject", "ascending"] + }, + { + testname: "select first radio again", + item: "byname", + checked: ["toolbar", "history", "byname", "ascending"] + }, + { + testname: "select autocheck radio", + item: "descending", + checked: ["toolbar", "history", "byname", "ascending"] + } + ]; + + function runTest() + { + checkMenus(["statusbar", "history", "bydate", "ascending"], "initial"); + document.getElementById("menu").open = true; + } + + function checkMenus(checkedItems, testname) + { + var isok = true; + var children = document.getElementById("popup").childNodes; + for (var c = 0; c < children.length; c++) { + var child = children[c]; + if ((checkedItems.includes(child.id) && child.getAttribute("checked") != "true") || + (!checkedItems.includes(child.id) && child.hasAttribute("checked"))) { + isok = false; + break; + } + } + + ok(isok, testname); + } + + function popupShown() + { + var test = tests[gTestIndex]; + + if (test.scroll) { + // On Windows 10, the menu is larger than the test frame. Scroll the later + // items into view. Since we are just testing the checked state of the items, + // and not their positions, this doesn't affect the behaviour of the test. + document.getElementById(test.item).scrollIntoView({ block: 'nearest' }); + } + synthesizeMouse(document.getElementById(test.item), 4, 4, { }); + } + + function popupHidden() + { + if (gTestIndex < tests.length) { + var test = tests[gTestIndex]; + checkMenus(test.checked, test.testname); + gTestIndex++; + if (gTestIndex < tests.length) { + document.getElementById("menu").open = true; + } + else { + // manually setting the checkbox should also update the radio state + document.getElementById("bydate").setAttribute("checked", "true"); + checkMenus(["toolbar", "history", "bydate", "ascending"], "set checked attribute on radio"); + SimpleTest.finish(); + } + } + } + + ]]> + </script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menuitem_blink.xhtml b/toolkit/content/tests/chrome/test_menuitem_blink.xhtml new file mode 100644 index 0000000000..700a8a7465 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menuitem_blink.xhtml @@ -0,0 +1,76 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Blinking Context Menu Item Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <menulist id="menulist"> + <menupopup id="menupopup"> + <menuitem label="Menu Item" id="menuitem"/> + </menupopup> + </menulist> +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); +SimpleTest.waitForFocus(startTest); + +function startTest() { + if (!/Mac/.test(navigator.platform)) { + ok(true, "Nothing to test on non-Mac."); + SimpleTest.finish(); + return; + } + test_crash(); +} + +function test_crash() { + var menupopup = document.getElementById("menupopup"); + var menuitem = document.getElementById("menuitem"); + menupopup.addEventListener("popupshown", function () { + menuitem.addEventListener("mouseup", function (e) { + const observer = new MutationObserver((aMutationList, aObserver) => { + for (const mutation of aMutationList) { + if (mutation.attributeName != "_moz-menuactive") { + continue; + } + menuitem.hidden = true; + menuitem.getBoundingClientRect(); + ok(true, "Didn't crash on _moz-menuactive mutation") + menuitem.hidden = false; + aObserver.disconnect(); + SimpleTest.executeSoon(function () { + menupopup.hidePopup(); + }); + } + }); + observer.observe(menuitem, { attributes: true }); + }, {once: true}); + menupopup.addEventListener("popuphidden", SimpleTest.finish, {once: true}); + synthesizeMouse(menuitem, 10, 5, { type : "mousemove" }); + synthesizeMouse(menuitem, 10, 5, { type : "mousemove" }); + synthesizeMouse(menuitem, 10, 5, { type : "mousedown" }); + SimpleTest.executeSoon(function () { + synthesizeMouse(menuitem, 10, 5, { type : "mouseup" }); + }); + }, {once: true}); + document.getElementById("menulist").open = true; +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menuitem_commands.xhtml b/toolkit/content/tests/chrome/test_menuitem_commands.xhtml new file mode 100644 index 0000000000..60a35b36c5 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menuitem_commands.xhtml @@ -0,0 +1,102 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menuitem Commands Test" + onload="runOrOpen()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + +<script> +<![CDATA[ +SimpleTest.waitForExplicitFinish(); + +function checkAttributes(elem, label, accesskey, disabled, hidden, isAfter) +{ + var is = window.arguments[0].SimpleTest.is; + + is(elem.getAttribute("label"), label, elem.id + " label " + (isAfter ? "after" : "before") + " open"); + is(elem.getAttribute("accesskey"), accesskey, elem.id + " accesskey " + (isAfter ? "after" : "before") + " open"); + is(elem.getAttribute("disabled"), disabled, elem.id + " disabled " + (isAfter ? "after" : "before") + " open"); + is(elem.hidden, hidden, elem.id + " hidden " + (isAfter ? "after" : "before") + " open"); +} + +function runOrOpen() +{ + if (window.arguments) { + SimpleTest.waitForFocus(runTest); + } + else { + window.openDialog("test_menuitem_commands.xhtml", "", "chrome,noopener", window); + } +} + +function runTest() +{ + runTestSet(""); + runTestSet("bar"); + window.close(); + window.arguments[0].SimpleTest.finish(); +} + +function runTestSet(suffix) +{ + var isMac = (navigator.platform.includes("Mac")); + + var one = $("one" + suffix); + var two = $("two" + suffix); + var three = $("three" + suffix); + var four = $("four" + suffix); + + checkAttributes(one, "One", "", "", true, false); + checkAttributes(two, "", "", "false", false, false); + checkAttributes(three, "Three", "T", "true", false, false); + checkAttributes(four, "Four", "F", "", false, false); + + if (isMac && suffix) { + var utils = window.windowUtils; + utils.forceUpdateNativeMenuAt("0"); + } + else { + $("menu" + suffix).open = true; + } + + checkAttributes(one, "One", "", "", false, true); + checkAttributes(two, "Cat", "C", "", false, true); + checkAttributes(three, "Dog", "D", "false", true, true); + checkAttributes(four, "Four", "F", "true", false, true); + + $("menu" + suffix).open = false; +} +]]> +</script> + +<command id="cmd_one" hidden="false"/> +<command id="cmd_two" label="Cat" accesskey="C"/> +<command id="cmd_three" label="Dog" accesskey="D" disabled="false" hidden="true"/> +<command id="cmd_four" disabled="true"/> + +<button id="menu" type="menu"> + <menupopup> + <menuitem id="one" label="One" hidden="true" command="cmd_one"/> + <menuitem id="two" disabled="false" command="cmd_two"/> + <menuitem id="three" label="Three" accesskey="T" disabled="true" command="cmd_three"/> + <menuitem id="four" label="Four" accesskey="F" command="cmd_four"/> + </menupopup> +</button> + +<menubar> + <menu id="menubar" label="Sample"> + <menupopup> + <menuitem id="onebar" label="One" hidden="true" command="cmd_one"/> + <menuitem id="twobar" disabled="false" command="cmd_two"/> + <menuitem id="threebar" label="Three" accesskey="T" disabled="true" command="cmd_three"/> + <menuitem id="fourbar" label="Four" accesskey="F" command="cmd_four"/> + </menupopup> + </menu> +</menubar> + +<body xmlns="http://www.w3.org/1999/xhtml"><p id="display"/></body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menulist.xhtml b/toolkit/content/tests/chrome/test_menulist.xhtml new file mode 100644 index 0000000000..5917785b56 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menulist.xhtml @@ -0,0 +1,350 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menulist Tests" + onload="setTimeout(testtag_menulists, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="application/javascript" src="xul_selectcontrol.js"></script> + +<vbox id="scroller" style="overflow: auto; height: 60px"> + <menulist id="menulist" onpopupshown="test_menulist_open(this, this.parentNode)" + onpopuphidden="$('menulist-in-listbox').open = true;"> + <menupopup id="menulist-popup"/> + </menulist> + <button label="Two"/> + <button label="Three"/> +</vbox> +<richlistbox id="scroller-in-listbox" style="overflow: auto; height: 60px"> + <richlistitem allowevents="true"> + <menulist id="menulist-in-listbox" onpopupshown="test_menulist_open(this, this.parentNode.parentNode)" + onpopuphidden="SimpleTest.executeSoon(() => checkScrollAndFinish().catch(ex => ok(false, ex)));"> + <menupopup id="menulist-in-listbox-popup"> + <menuitem label="One" value="one"/> + <menuitem label="Two" value="two"/> + </menupopup> + </menulist> + </richlistitem> + <richlistitem><label value="Two"/></richlistitem> + <richlistitem><label value="Three"/></richlistitem> + <richlistitem><label value="Four"/></richlistitem> + <richlistitem><label value="Five"/></richlistitem> + <richlistitem><label value="Six"/></richlistitem> +</richlistbox> + +<hbox> + <menulist id="menulist-size"> + <menupopup> + <menuitem label="Menuitem Label" style="width: 200px"/> + </menupopup> + </menulist> +</hbox> + +<menulist id="menulist-initwithvalue" value="two"> + <menupopup> + <menuitem label="One" value="one"/> + <menuitem label="Two" value="two"/> + <menuitem label="Three" value="three"/> + </menupopup> +</menulist> +<menulist id="menulist-initwithselected" value="two"> + <menupopup> + <menuitem label="One" value="one"/> + <menuitem label="Two" value="two"/> + <menuitem label="Three" value="three" selected="true"/> + </menupopup> +</menulist> + +<menulist id="menulist-clipped"> + <menupopup style="height: 65px"> + <menuitem label="One" value="one"/> + <menuitem label="Two" value="two"/> + <menuitem label="Three" value="three"/> + <menuitem label="Four" value="four"/> + <menuitem label="Five" value="five" selected="true"/> + <menuitem label="Six" value="six"/> + <menuitem label="Seven" value="seven"/> + <menuitem label="Eight" value="eight"/> + </menupopup> +</menulist> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +function waitForEvent(subject, eventName, checkFn) { + return new Promise(resolve => { + subject.addEventListener(eventName, function listener(event) { + if (checkFn && !checkFn(event)) { + return; + } + subject.removeEventListener(eventName, listener); + SimpleTest.executeSoon(() => resolve(event)); + }); + }); +} + +SimpleTest.waitForExplicitFinish(); + +function testtag_menulists() +{ + testtag_menulist_UI_start($("menulist"), false); +} + +function testtag_menulist_UI_start(element) +{ + // check the menupopup property + var popup = element.menupopup; + ok(popup && popup.localName == "menupopup" && + popup.parentNode == element, "menupopup"); + + // test the interfaces that menulist implements + test_nsIDOMXULMenuListElement(element); +} + +function testtag_menulist_UI_finish(element) +{ + element.value = ""; + + test_nsIDOMXULSelectControlElement(element, "menuitem", null); + + $("menulist").open = true; +} + +function test_nsIDOMXULMenuListElement(element) +{ + is(element.open, false, "open"); + + element.appendItem("Item One", "one"); + var seconditem = element.appendItem("Item Two", "two"); + var thirditem = element.appendItem("Item Three", "three"); + element.appendItem("Item Four", "four"); + + seconditem.image = "happy.png"; + seconditem.setAttribute("description", "This is the second description"); + thirditem.image = "happy.png"; + thirditem.setAttribute("description", "This is the third description"); + + // check the image and description properties + element.selectedIndex = 1; + is(element.image, "happy.png", "image set to selected"); + is(element.description, "This is the second description", "description set to selected"); + element.selectedIndex = -1; + is(element.image, "", "image set when none selected"); + is(element.description, "", "description set when none selected"); + element.selectedIndex = 2; + is(element.image, "happy.png", "image set to selected again"); + is(element.description, "This is the third description", "description set to selected again"); + + // check that changing the properties of the selected item changes the menulist's properties + let properties = [{attr: "label", value: "Item Number Three"}, + {attr: "value", value: "item-three"}, + {attr: "image", value: "smile.png"}, + {attr: "description", value: "Changed description"}]; + test_nsIDOMXULMenuListElement_properties(element, thirditem, properties); +} + +function test_nsIDOMXULMenuListElement_properties(element, thirditem, properties) +{ + let {attr, value} = properties.shift(); + let last = !properties.length; + + let mutObserver = new MutationObserver(() => { + is(element.getAttribute(attr), value, `${attr} modified`); + done(); + }); + mutObserver.observe(element, { attributeFilter: [attr] }); + + let failureTimeout = setTimeout(() => { + ok(false, `${attr} should have updated`); + done(); + }, 2000); + + function done() + { + clearTimeout(failureTimeout); + mutObserver.disconnect(); + if (!last) { + test_nsIDOMXULMenuListElement_properties(element, thirditem, properties); + } + else { + test_nsIDOMXULMenuListElement_unselected(element, thirditem); + } + } + + thirditem.setAttribute(attr, value) +} + +function test_nsIDOMXULMenuListElement_unselected(element, thirditem) +{ + let seconditem = thirditem.previousElementSibling; + seconditem.label = "Changed Label 2"; + is(element.label, "Item Number Three", "label of another item modified"); + + element.selectedIndex = 0; + is(element.image, "", "image set to selected with no image"); + is(element.description, "", "description set to selected with no description"); + test_nsIDOMXULMenuListElement_finish(element); +} + +function test_nsIDOMXULMenuListElement_finish(element) +{ + // check the removeAllItems method + element.appendItem("An Item", "anitem"); + element.appendItem("Another Item", "anotheritem"); + element.removeAllItems(); + is(element.itemCount, 0, "removeAllItems"); + + testtag_menulist_UI_finish(element); +} + +function test_menulist_open(element, scroller) +{ + element.appendItem("Scroll Item 1", "scrollitem1"); + element.appendItem("Scroll Item 2", "scrollitem2"); + element.focus(); + element.selectedIndex = 0; + +/* + // bug 530504, mousewheel while menulist is open should not scroll menulist + // items or parent + var scrolled = false; + var mouseScrolled = function (event) { scrolled = true; } + window.addEventListener("DOMMouseScroll", mouseScrolled, false); + synthesizeWheel(element, 2, 2, { deltaY: 10, + deltaMode: WheelEvent.DOM_DELTA_LINE }); + is(scrolled, true, "mousescroll " + element.id); + is(scroller.scrollTop, 0, "scroll position on mousescroll " + element.id); + window.removeEventListener("DOMMouseScroll", mouseScrolled, false); +*/ + + // bug 543065, hovering the mouse over an item should highlight it, not + // scroll the parent, and not change the selected index. + var item = element.menupopup.childNodes[1]; + + synthesizeMouse(element.menupopup.childNodes[1], 2, 2, { type: "mousemove" }); + synthesizeMouse(element.menupopup.childNodes[1], 6, 6, { type: "mousemove" }); + is(element.activeChild, item, "activeChild after menu highlight " + element.id); + is(element.selectedIndex, 0, "selectedIndex after menu highlight " + element.id); + is(scroller.scrollTop, 0, "scroll position after menu highlight " + element.id); + + element.open = false; +} + +async function checkScrollAndFinish() +{ + is($("scroller").scrollTop, 0, "mousewheel on menulist does not scroll vbox parent"); + is($("scroller-in-listbox").scrollTop, 0, "mousewheel on menulist does not scroll listbox parent"); + + let menulist = $("menulist-size"); + let shownPromise = waitForEvent(menulist, "popupshown"); + menulist.open = true; + await shownPromise; + + sendKey("ALT"); + is(menulist.menupopup.state, "open", "alt doesn't close menulist"); + menulist.open = false; + + await dragScroll(); +} + +async function dragScroll() +{ + let menulist = $("menulist-clipped"); + + let shownPromise = waitForEvent(menulist, "popupshown"); + menulist.open = true; + await shownPromise; + + let popup = menulist.menupopup; + let getScrollPos = () => popup.scrollBox.scrollbox.scrollTop; + let scrollPos = getScrollPos(); + let popupRect = popup.getBoundingClientRect(); + + // First, check that scrolling does not occur when the mouse is moved over the + // anchor button but not the popup yet. + synthesizeMouseAtPoint(popupRect.left + 5, popupRect.top - 10, { type: "mousemove" }); + is(getScrollPos(), scrollPos, "scroll position after mousemove over button should not change"); + + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.top + 10, { type: "mousemove" }); + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.top + 10, { type: "mousedown", buttons: 1 }); + + // Dragging above the popup scrolls it up. + let scrolledPromise = waitForEvent(popup, "scroll", false, + () => getScrollPos() < scrollPos - 5); + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.top - 20, { type: "mousemove", buttons: 1 }); + await scrolledPromise; + ok(true, "scroll position at drag up"); + + // Dragging below the popup scrolls it down. + scrollPos = getScrollPos(); + scrolledPromise = waitForEvent(popup, "scroll", false, + () => getScrollPos() > scrollPos + 5); + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 20, { type: "mousemove", buttons: 1 }); + await scrolledPromise; + ok(true, "scroll position at drag down"); + + // Releasing the mouse button and moving the mouse does not change the scroll position. + scrollPos = getScrollPos(); + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 25, { type: "mouseup" }); + is(getScrollPos(), scrollPos, "scroll position at mouseup should not change"); + + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 20, { type: "mousemove" }); + is(getScrollPos(), scrollPos, "scroll position at mousemove after mouseup should not change"); + + // Now check dragging with a mousedown on an item. Make sure the element is + // visible, as the asynchronous scrolling may have moved it out of view. + popup.childNodes[4].scrollIntoView({ block: "nearest", behavior: "instant" }); + let menuRect = popup.childNodes[4].getBoundingClientRect(); + synthesizeMouseAtPoint(menuRect.left + 5, menuRect.top + 5, { type: "mousedown", buttons: 1 }); + + // Dragging below the popup scrolls it down. + scrolledPromise = waitForEvent(popup, "scroll", false, + () => getScrollPos() > scrollPos + 5); + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 20, { type: "mousemove", buttons: 1 }); + await scrolledPromise; + ok(true, "scroll position at drag down from item"); + + // Dragging above the popup scrolls it up. + scrollPos = getScrollPos(); + scrolledPromise = waitForEvent(popup, "scroll", false, + () => getScrollPos() < scrollPos - 5); + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.top - 20, { type: "mousemove", buttons: 1 }); + await scrolledPromise; + ok(true, "scroll position at drag up from item"); + + scrollPos = getScrollPos(); + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 25, { type: "mouseup" }); + is(getScrollPos(), scrollPos, "scroll position at mouseup should not change"); + + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 20, { type: "mousemove" }); + is(getScrollPos(), scrollPos, "scroll position at mousemove after mouseup should not change"); + + menulist.open = false; + + let mouseMoveTarget = null; + popup.childNodes[4].click(); + addEventListener("mousemove", function checkMouseMove(event) { + mouseMoveTarget = event.target; + }, {once: true}); + synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 20, { type: "mousemove" }); + isnot(mouseMoveTarget, popup, "clicking on item when popup closed doesn't start dragging"); + + SimpleTest.finish(); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menulist_in_popup.xhtml b/toolkit/content/tests/chrome/test_menulist_in_popup.xhtml new file mode 100644 index 0000000000..971fe90322 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menulist_in_popup.xhtml @@ -0,0 +1,57 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menulist position Test" + onload="setTimeout(runTest, 0)" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> +<script> +<![CDATA[ +SimpleTest.waitForExplicitFinish(); + +async function runTest() { + let panel = document.querySelector("panel"); + let menulist = document.getElementById("menulist"); + let menulistPopup = document.getElementById("menulistpopup"); + + menulistPopup.addEventListener("popupshown", function(e) { + ok(false, "Menulist popup shown"); + }); + + let panelShown = new Promise(r => panel.addEventListener("popupshown", r, { once: true })); + info("opening panel"); + panel.openPopup(null, { x: 0, y: 0 }); + await panelShown; + info("panel opened"); + + info("hovering menulist"); + synthesizeMouseAtCenter(menulist, { type: "mousemove" }); + info("waiting for a bit"); + await new Promise(r => setTimeout(r, 500)); + + isnot(menulist.open, "menulist should not be open on hover when inside a popup"); + + SimpleTest.finish(); +} + +]]> +</script> + +<panel style="width: 500px; height: 500px"> + <menulist style="width: 200px" id="menulist"> + <menupopup style="max-height: 90px;" id="menulistpopup"> + <menuitem label="One"/> + <menuitem label="Two"/> + <menuitem label="Three"/> + <menuitem label="Four"/> + <menuitem label="Five"/> + <menuitem label="Six"/> + <menuitem label="Seven"/> + </menupopup> + </menulist> +</panel> + +</window> diff --git a/toolkit/content/tests/chrome/test_menulist_keynav.xhtml b/toolkit/content/tests/chrome/test_menulist_keynav.xhtml new file mode 100644 index 0000000000..86e86b6510 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menulist_keynav.xhtml @@ -0,0 +1,316 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menulist Key Navigation Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<button id="button1" label="One"/> +<menulist id="list"> + <menupopup id="popup" onpopupshowing="return gShowPopup;"> + <menuitem id="i1" label="One"/> + <menuitem id="i2" label="Two"/> + <menuitem id="i2b" disabled="true" label="Two and a Half"/> + <menuitem id="i3" label="Three"/> + <menuitem id="i4" label="Four"/> + </menupopup> +</menulist> +<button id="button2" label="Two"/> +<menulist id="list2"> + <menupopup id="popup" onpopupshown="checkCursorNavigation();"> + <menuitem id="b1" label="One"/> + <menuitem id="b2" label="Two" selected="true"/> + <menuitem id="b3" label="Three"/> + <menuitem id="b4" label="Four"/> + </menupopup> +</menulist> +<menulist id="list3" sizetopopup="none"> + <menupopup> + <menuitem id="s1" label="One"/> + <menuitem id="s2" label="Two"/> + <menuitem id="s3" label="Three"/> + <menuitem id="s4" label="Four"/> + </menupopup> +</menulist> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var gShowPopup = false; +var gModifiers = 0; +var gOpenPhase = false; + +var list = $("list"); +let expectCommandEvent; + +var iswin = (navigator.platform.indexOf("Win") == 0); +var ismac = (navigator.platform.indexOf("Mac") == 0); + +function runTests() +{ + list.focus(); + + // on Mac, up and cursor keys open the menu, but on other platforms, the + // cursor keys navigate between items without opening the menu + if (!ismac) { + expectCommandEvent = true; + keyCheck(list, "KEY_ArrowDown", 2, 1, "cursor down"); + keyCheck(list, "KEY_ArrowDown", 3, 1, "cursor down skip disabled"); + keyCheck(list, "KEY_ArrowUp", 2, 1, "cursor up skip disabled"); + keyCheck(list, "KEY_ArrowUp", 1, 1, "cursor up"); + + // On Windows, wrapping doesn't occur. + expectCommandEvent = !iswin; + keyCheck(list, "KEY_ArrowUp", iswin ? 1 : 4, 1, "cursor up wrap"); + + list.selectedIndex = 4; + list.activeChild = list.selectedItem; + keyCheck(list, "KEY_ArrowDown", iswin ? 4 : 1, 4, "cursor down wrap"); + + list.selectedIndex = 0; + list.activeChild = list.selectedItem; + } + + // check that attempting to open the menulist does not change the selection + synthesizeKey("KEY_ArrowDown", {altKey: !ismac}); + is(list.selectedItem, $("i1"), "open menulist down selectedItem"); + synthesizeKey("KEY_ArrowUp", {altKey: !ismac}); + is(list.selectedItem, $("i1"), "open menulist up selectedItem"); + + list.selectedItem = $("i1"); + + pressLetter(); +} + +function pressLetter() +{ + // A command event should be fired only if the menulist is closed. + expectCommandEvent = !gOpenPhase || iswin; + + sendString("G"); + is(list.selectedItem, $("i1"), "letter pressed not found selectedItem"); + + keyCheck(list, "T", 2, 1, "letter pressed"); + + setTimeout(pressedAgain, 1200); +} + +function pressedAgain() +{ + keyCheck(list, "T", 3, 1, "letter pressed again"); + SpecialPowers.setIntPref("ui.menu.incremental_search.timeout", 0); // prevent to timeout + keyCheck(list, "W", 2, 1, "second letter pressed"); + SpecialPowers.clearUserPref("ui.menu.incremental_search.timeout"); + setTimeout(differentPressed, 1200); +} + +function differentPressed() +{ + keyCheck(list, "O", 1, 1, "different letter pressed"); + + if (gOpenPhase) { + list.open = false; + tabAndScroll(); + } + else { + // Run the letter tests again with the popup open + info("list open phase"); + + list.selectedItem = $("i1"); + + // Hide and show the list to avoid using any existing incremental key state. + list.hidden = true; + list.clientWidth; + list.hidden = false; + + gShowPopup = true; + gOpenPhase = true; + + list.addEventListener("popupshown", function popupShownListener() { + pressLetter(); + }, { once: true}); + + list.open = true; + } +} + +function inputMargin(el) { + let cs = getComputedStyle(el); + // XXX Internal properties are not exposed in getComputedStyle, so we have to + // use margin and rely on our knowledge of them matching negative margins + // where appropriate. + // return parseFloat(cs.getPropertyValue("-moz-window-input-region-margin")); + return ismac ? 0 : Math.max(-parseFloat(cs.marginLeft), 0); +} + +function tabAndScroll() +{ + list = $("list"); + + if (!ismac) { + $("button1").focus(); + synthesizeKeyExpectEvent("KEY_Tab", {}, list, "focus", "focus to menulist"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("button2"), "focus", "focus to button"); + is(document.activeElement, $("button2"), "tab from menulist focused button"); + } + + // now make sure that using a key scrolls the menu correctly + + for (let i = 0; i < 65; i++) { + list.appendItem("Item" + i, "item" + i); + } + list.open = true; + is(list.getBoundingClientRect().width, list.menupopup.getBoundingClientRect().width - 2 * inputMargin(list.menupopup), + "menu and popup width match"); + var minScrollbarWidth = window.matchMedia("(-moz-overlay-scrollbars)").matches ? 0 : 3; + ok(list.getBoundingClientRect().width >= list.getItemAtIndex(0).getBoundingClientRect().width + minScrollbarWidth, + "menuitem width accounts for scrollbar"); + list.open = false; + + list.menupopup.style.maxHeight = "100px"; + list.open = true; + + var rowdiff = list.getItemAtIndex(1).getBoundingClientRect().top - + list.getItemAtIndex(0).getBoundingClientRect().top; + + var item = list.getItemAtIndex(10); + var originalPosition = item.getBoundingClientRect().top; + + list.activeChild = item; + ok(item.getBoundingClientRect().top < originalPosition, + "position of item 1: " + item.getBoundingClientRect().top + " -> " + originalPosition); + + originalPosition = item.getBoundingClientRect().top; + + synthesizeKey("KEY_ArrowDown"); + is(item.getBoundingClientRect().top, originalPosition - rowdiff, "position of item 10"); + + list.open = false; + + checkEnter(); +} + +function keyCheck(list, key, index, defaultindex, testname) +{ + info(`keyCheck(${index}, ${key}, ${index}, ${defaultindex}, ${testname}, ${expectCommandEvent})`); + var item = $("i" + index); + var defaultitem = $("i" + defaultindex || 1); + + synthesizeKeyExpectEvent(key, { }, item, expectCommandEvent ? "command" : "!command", testname); + is(list.selectedItem, expectCommandEvent ? item : defaultitem, testname + " selectedItem----" + list.selectedItem.id); +} + +function checkModifiers(event) +{ + var expectedModifiers = (gModifiers == 1); + is(event.shiftKey, expectedModifiers, "shift key pressed"); + is(event.ctrlKey, expectedModifiers, "ctrl key pressed"); + is(event.altKey, expectedModifiers, "alt key pressed"); + is(event.metaKey, expectedModifiers, "meta key pressed"); + gModifiers++; +} + +function checkEnter() +{ + list.addEventListener("popuphidden", checkEnterWithModifiers); + list.addEventListener("command", checkModifiers); + list.open = true; + synthesizeKey("KEY_Enter"); +} + +function checkEnterWithModifiers() +{ + is(gModifiers, 1, "modifiers checked when not set"); + + ok(!list.open, "list closed on enter press"); + list.removeEventListener("popuphidden", checkEnterWithModifiers); + + list.addEventListener("popuphidden", verifyPopupOnClose); + list.open = true; + + synthesizeKey("KEY_Enter", {shiftKey: true, ctrlKey: true, altKey: true, metaKey: true}); +} + +function verifyPopupOnClose() +{ + is(gModifiers, 2, "modifiers checked when set"); + + ok(!list.open, "list closed on enter press with modifiers"); + list.removeEventListener("popuphidden", verifyPopupOnClose); + + list = $("list2"); + list.focus(); + list.open = true; +} + +function checkCursorNavigation() +{ + var commandEventsCount = 0; + list.addEventListener("command", event => { + is(event.target, list.selectedItem, "command event fired on selected item"); + commandEventsCount++; + }); + + is(list.selectedIndex, 1, "selectedIndex before cursor down"); + synthesizeKey("KEY_ArrowDown"); + is(list.selectedIndex, iswin ? 2 : 1, "selectedIndex after cursor down"); + is(commandEventsCount, iswin ? 1 : 0, "selectedIndex after cursor down command event"); + is(list.menupopup.state, "open", "cursor down popup state"); + synthesizeKey("KEY_PageDown"); + is(list.selectedIndex, iswin ? 2 : 1, "selectedIndex after page down"); + is(commandEventsCount, iswin ? 1 : 0, "selectedIndex after page down command event"); + is(list.menupopup.state, "open", "page down popup state"); + + // Check whether cursor up and down wraps. + list.selectedIndex = 0; + list.activeChild = list.selectedItem; + synthesizeKey("KEY_ArrowUp"); + is(list.activeChild, + document.getElementById(iswin || ismac ? "b1" : "b4"), "cursor up wrap while open"); + + list.selectedIndex = 3; + list.activeChild = list.selectedItem; + synthesizeKey("KEY_ArrowDown"); + is(list.activeChild, + document.getElementById(iswin || ismac ? "b4" : "b1"), "cursor down wrap while open"); + + synthesizeKey("KEY_ArrowUp", {altKey: true}); + is(list.open, ismac, "alt+up closes popup"); + + if (ismac) { + list.open = false; + } + + // Finally, test a menulist with sizetopopup="none" to ensure keyboard navigation + // still works when the popup has not been opened. + if (!ismac) { + let unsizedMenulist = document.getElementById("list3"); + unsizedMenulist.focus(); + synthesizeKey("KEY_ArrowDown"); + is(unsizedMenulist.selectedIndex, 1, "correct menulist index on keydown"); + is(unsizedMenulist.label, "Two", "correct menulist label on keydown"); + } + + SimpleTest.finish(); +} + +SimpleTest.waitForFocus(runTests); + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menulist_null_value.xhtml b/toolkit/content/tests/chrome/test_menulist_null_value.xhtml new file mode 100644 index 0000000000..9312c236dc --- /dev/null +++ b/toolkit/content/tests/chrome/test_menulist_null_value.xhtml @@ -0,0 +1,96 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menulist value property" + onload="setTimeout(runTests, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<menulist id="list"> + <menupopup> + <menuitem id="i0" label="Zero" value="0"/> + <menuitem id="i1" label="One" value="item1"/> + <menuitem id="i2" label="Two" value="item2"/> + <menuitem id="ifalse" label="False" value="false"/> + <menuitem id="iempty" label="Empty" value=""/> + </menupopup> +</menulist> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function runTests() +{ + var list = document.getElementById("list"); + + list.value = "item2"; + is(list.value, "item2", "Check list value after setting value"); + is(list.getAttribute("label"), "Two", "Check list label after setting value"); + + list.selectedItem = null; + is(list.value, "", "Check list value after setting selectedItem to null"); + is(list.getAttribute("label"), "", "Check list label after setting selectedItem to null"); + + // select something again to make sure the label is not already empty + list.selectedIndex = 1; + is(list.value, "item1", "Check list value after setting selectedIndex"); + is(list.getAttribute("label"), "One", "Check list label after setting selectedIndex"); + + // check that an item can have the "false" value + list.value = false; + is(list.value, "false", "Check list value after setting it to false"); + is(list.getAttribute("label"), "False", "Check list labem after setting value to false"); + + // check that an item can have the "0" value + list.value = 0; + is(list.value, "0", "Check list value after setting it to 0"); + is(list.getAttribute("label"), "Zero", "Check list label after setting value to 0"); + + // check that an item can have the empty string value. + list.value = ""; + is(list.value, "", "Check list value after setting it to an empty string"); + is(list.getAttribute("label"), "Empty", "Check list label after setting value to an empty string"); + + // select something again to make sure the label is not already empty + list.selectedIndex = 1; + // set the value to null and test it (bug 408940) + list.value = null; + is(list.value, "", "Check list value after setting value to null"); + is(list.getAttribute("label"), "", "Check list label after setting value to null"); + + // select something again to make sure the label is not already empty + list.selectedIndex = 1; + // set the value to undefined and test it (bug 408940) + list.value = undefined; + is(list.value, "", "Check list value after setting value to undefined"); + is(list.getAttribute("label"), "", "Check list label after setting value to undefined"); + + // select something again to make sure the label is not already empty + list.selectedIndex = 1; + // set the value to something that does not exist in any menuitem of the list + // and make sure the previous label is removed + list.value = "this does not exist"; + is(list.value, "this does not exist", "Check the list value after setting it to something not associated witn an existing menuitem"); + is(list.getAttribute("label"), "", "Check that the list label is empty after selecting a nonexistent item"); + + SimpleTest.finish(); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menulist_paging.xhtml b/toolkit/content/tests/chrome/test_menulist_paging.xhtml new file mode 100644 index 0000000000..7a0c6f3b5d --- /dev/null +++ b/toolkit/content/tests/chrome/test_menulist_paging.xhtml @@ -0,0 +1,178 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menulist Tests" + onload="setTimeout(runTest, 0);" + onpopupshown="menulistShown()" onpopuphidden="runTest()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<menulist id="menulist1"> + <menupopup id="menulist-popup1"> + <menuitem label="One"/> + <menuitem label="Two"/> + <menuitem label="Three"/> + <menuitem label="Four"/> + <menuitem label="Five"/> + <menuitem label="Six"/> + <menuitem label="Seven"/> + <menuitem label="Eight"/> + <menuitem label="Nine"/> + <menuitem label="Ten"/> + </menupopup> +</menulist> + +<menulist id="menulist2"> + <menupopup id="menulist-popup2"> + <menuitem label="One" disabled="true"/> + <menuitem label="Two" selected="true"/> + <menuitem label="Three"/> + <menuitem label="Four"/> + <menuitem label="Five"/> + <menuitem label="Six"/> + <menuitem label="Seven"/> + <menuitem label="Eight"/> + <menuitem label="Nine"/> + <menuitem label="Ten" disabled="true"/> + </menupopup> +</menulist> + +<menulist id="menulist3"> + <menupopup id="menulist-popup3"> + <label value="One"/> + <menuitem label="Two" selected="true"/> + <menuitem label="Three"/> + <menuitem label="Four"/> + <menuitem label="Five" disabled="true"/> + <menuitem label="Six" disabled="true"/> + <menuitem label="Seven"/> + <menuitem label="Eight"/> + <menuitem label="Nine"/> + <label value="Ten"/> + </menupopup> +</menulist> + +<menulist id="menulist4"> + <menupopup id="menulist-popup4"> + <label value="One"/> + <menuitem label="Two"/> + <menuitem label="Three"/> + <menuitem label="Four"/> + <menuitem label="Five"/> + <menuitem label="Six" selected="true"/> + <menuitem label="Seven"/> + <menuitem label="Eight"/> + <menuitem label="Nine"/> + <label value="Ten"/> + </menupopup> +</menulist> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +let test; + +// Fields: +// list - menulist id +// initial - initial selected index +// scroll - index of item at top of the visible scrolled area, -1 to skip this test +// downs - array of indicies that will be selected when pressing down in sequence +// ups - array of indicies that will be selected when pressing up in sequence +let tests = [ + { list: "menulist1", initial: 0, scroll: 0, downs: [3, 6, 9, 9], + ups: [6, 3, 0, 0] }, + { list: "menulist2", initial: 1, scroll: 0, downs: [4, 7, 8, 8], + ups: [5, 2, 1] }, + { list: "menulist3", initial: 1, scroll: -1, downs: [3, 6, 8, 8], + ups: [6, 3, 1] }, + { list: "menulist4", initial: 5, scroll: 2, downs: [], ups: [] } +]; + +let gMeasured = false; +function measureMenuItemHeightIfNeeded() { + if (gMeasured) { + return; + } + gMeasured = true; + + let popup = document.getElementById("menulist-popup1"); + let menuitemHeight = popup.firstChild.getBoundingClientRect().height; + + let cs = window.getComputedStyle(popup); + let csArrow = window.getComputedStyle(popup.scrollBox); + let bpmTop = parseFloat(cs.paddingTop) + parseFloat(cs.borderTopWidth) + + parseFloat(csArrow.paddingTop) + parseFloat(csArrow.borderTopWidth) + + parseFloat(csArrow.marginTop); + let bpmBottom = parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth) + + parseFloat(csArrow.paddingBottom) + parseFloat(csArrow.borderBottomWidth) + + parseFloat(csArrow.marginBottom); + + // First, set the height of each popup to the height of four menuitems plus + // any padding / border / margin on the menupopup. + let height = menuitemHeight * 4 + bpmTop + bpmBottom; + + popup.style.height = height + "px"; + document.getElementById("menulist-popup2").style.height = height + "px"; + document.getElementById("menulist-popup3").style.height = height + "px"; + document.getElementById("menulist-popup4").style.height = height + "px"; +} + +function runTest() { + if (!tests.length) { + SimpleTest.finish(); + return; + } + test = tests.shift(); + document.getElementById(test.list).open = true; +} + +function menulistShown() +{ + measureMenuItemHeightIfNeeded(); + + let menulist = document.getElementById(test.list); + is(menulist.activeChild.label, menulist.getItemAtIndex(test.initial).label, test.list + " initial selection"); + + let cs = window.getComputedStyle(menulist.menupopup); + let csArrow = window.getComputedStyle(menulist.menupopup.scrollBox); + let bpmTop = parseFloat(cs.paddingTop) + parseFloat(cs.borderTopWidth) + + parseFloat(csArrow.paddingTop) + parseFloat(csArrow.borderTopWidth) + + parseFloat(csArrow.marginTop); + + // Skip menulist3 as it has a label that scrolling doesn't need normally deal with. + if (test.scroll >= 0) { + is(menulist.menupopup.childNodes[test.scroll].getBoundingClientRect().top, + menulist.menupopup.getBoundingClientRect().top + bpmTop, + "Popup scroll at correct position"); + } + + for (let i = 0; i < test.downs.length; i++) { + sendKey("PAGE_DOWN"); + is(menulist.activeChild.label, menulist.getItemAtIndex(test.downs[i]).label, test.list + " page down " + i); + } + + for (let i = 0; i < test.ups.length; i++) { + sendKey("PAGE_UP"); + is(menulist.activeChild.label, menulist.getItemAtIndex(test.ups[i]).label, test.list + " page up " + i); + } + + menulist.open = false; +} +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_menulist_position.xhtml b/toolkit/content/tests/chrome/test_menulist_position.xhtml new file mode 100644 index 0000000000..b055c1cdb4 --- /dev/null +++ b/toolkit/content/tests/chrome/test_menulist_position.xhtml @@ -0,0 +1,118 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menulist position Test" + onload="setTimeout(init, 0)" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<!-- + This test checks the position of a menulist's popup. + --> + +<script> +<![CDATA[ +SimpleTest.waitForExplicitFinish(); + +var menulist; + +function init() +{ + menulist = document.getElementById("menulist"); + menulist.open = true; +} + +function isWithinHalfPixel(a, b) +{ + return Math.abs(a - b) <= 0.5; +} + +const ismac = navigator.platform.indexOf("Mac") == 0; +function inputMargin(el) { + let cs = getComputedStyle(el); + // XXX Internal properties are not exposed in getComputedStyle, so we have to + // use margin and rely on our knowledge of them matching negative margins + // where appropriate. + // return parseFloat(cs.getPropertyValue("-moz-window-input-region-margin")); + return ismac ? 0 : Math.max(-parseFloat(cs.marginLeft), 0); +} + +function popupShown() +{ + var menurect = menulist.getBoundingClientRect(); + var popuprect = menulist.menupopup.getBoundingClientRect(); + + let marginLeft = parseFloat(getComputedStyle(menulist.menupopup).marginLeft); + ok(isWithinHalfPixel(menurect.left + marginLeft, popuprect.left), `left position: ${menurect.left}, ${popuprect.left}`); + ok(isWithinHalfPixel(menurect.right + marginLeft + 2 * inputMargin(menulist.menupopup), popuprect.right), `right position: ${menurect.right}, ${popuprect.right}`); + + let index = menulist.selectedIndex; + if (menulist.selectedItem && navigator.platform.includes("Mac")) { + let menulistlabelrect = menulist.shadowRoot.getElementById("label").getBoundingClientRect(); + let mitemlabelrect = menulist.selectedItem.querySelector(".menu-iconic-text").getBoundingClientRect(); + + ok(isWithinHalfPixel(menulistlabelrect.top, mitemlabelrect.top), + `Labels vertically aligned for ${index} : ${menulistlabelrect.top} vs. ${mitemlabelrect.top}`); + + // Store the current value and reset it afterwards. + let current = menulist.selectedIndex; + + // Cycle through the items to ensure that the popup doesn't move when the selection changes. + for (let i = 0; i < menulist.itemCount; i++) { + menulist.selectedIndex = i; + + let newpopuprect = menulist.menupopup.getBoundingClientRect(); + is(newpopuprect.x, popuprect.x, "Popup remained horizontally for index " + i + " starting at " + current); + is(newpopuprect.y, popuprect.y, "Popup remained vertically for index " + i + " starting at " + current); + } + menulist.selectedIndex = current; + } + else { + let marginTop = parseFloat(getComputedStyle(menulist.menupopup).marginTop); + ok(isWithinHalfPixel(menurect.bottom + marginTop, popuprect.top), + "Vertical alignment with no selection for index " + index); + } + + menulist.open = false; +} + +function popupHidden() +{ + if (!menulist.selectedItem) { + SimpleTest.finish(); + } + else { + menulist.selectedItem = menulist.selectedItem.nextSibling; + menulist.open = true; + } +} +]]> +</script> + +<hbox align="center" pack="center" style="margin-top: 140px;"> + <menulist style="width: 200px" id="menulist" onpopupshown="popupShown();" onpopuphidden="popupHidden();" native="true"> + <menupopup style="max-height: 90px;"> + <menuitem label="One"/> + <menuitem label="Two"/> + <menuitem label="Three"/> + <menuitem label="Four"/> + <menuitem label="Five"/> + <menuitem label="Six"/> + <menuitem label="Seven"/> + </menupopup> + </menulist> +</hbox> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_mousescroll.xhtml b/toolkit/content/tests/chrome/test_mousescroll.xhtml new file mode 100644 index 0000000000..875ca4ac98 --- /dev/null +++ b/toolkit/content/tests/chrome/test_mousescroll.xhtml @@ -0,0 +1,291 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=378028 +--> +<window title="Mozilla Bug 378028" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/paint_listener.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=378028" + target="_blank">Mozilla Bug 378028</a> + </body> + + <!-- richlistbox currently has no way of giving us a defined number of + rows, so we just choose an arbitrary height limit that should give + us plenty of vertical scrollability --> + <richlistbox id="richlistbox" style="height:50px;"> + <richlistitem id="richlistbox_item0" hidden="true"><label value="Item 0"/></richlistitem> + <richlistitem id="richlistbox_item1"><label value="Item 1"/></richlistitem> + <richlistitem id="richlistbox_item2"><label value="Item 2"/></richlistitem> + <richlistitem id="richlistbox_item3"><label value="Item 3"/></richlistitem> + <richlistitem id="richlistbox_item4"><label value="Item 4"/></richlistitem> + <richlistitem id="richlistbox_item5"><label value="Item 5"/></richlistitem> + <richlistitem id="richlistbox_item6"><label value="Item 6"/></richlistitem> + <richlistitem id="richlistbox_item7"><label value="Item 7"/></richlistitem> + <richlistitem id="richlistbox_item8"><label value="Item 8"/></richlistitem> + </richlistbox> + + <box orient="horizontal"> + <arrowscrollbox id="hscrollbox" clicktoscroll="true" orient="horizontal" + smoothscroll="false" style="max-width:80px;" flex="1"> + <hbox style="min-width:40px; min-height:20px; background:black;" hidden="true"/> + <hbox style="min-width:40px; min-height:20px; background:white;"/> + <hbox style="min-width:40px; min-height:20px; background:black;"/> + <hbox style="min-width:40px; min-height:20px; background:white;"/> + <hbox style="min-width:40px; min-height:20px; background:black;"/> + <hbox style="min-width:40px; min-height:20px; background:white;"/> + <hbox style="min-width:40px; min-height:20px; background:black;"/> + <hbox style="min-width:40px; min-height:20px; background:white;"/> + <hbox style="min-width:40px; min-height:20px; background:black;"/> + </arrowscrollbox> + </box> + + <arrowscrollbox id="vscrollbox" clicktoscroll="true" orient="vertical" + smoothscroll="false" style="max-height:80px;" flex="1"> + <vbox style="min-width:100px; min-height:40px; background:black;" hidden="true"/> + <vbox style="min-width:100px; min-height:40px; background:white;"/> + <vbox style="min-width:100px; min-height:40px; background:black;"/> + <vbox style="min-width:100px; min-height:40px; background:white;"/> + <vbox style="min-width:100px; min-height:40px; background:black;"/> + <vbox style="min-width:100px; min-height:40px; background:white;"/> + <vbox style="min-width:100px; min-height:40px; background:black;"/> + <vbox style="min-width:100px; min-height:40px; background:white;"/> + <vbox style="min-width:100px; min-height:40px; background:black;"/> + <vbox style="min-width:100px; min-height:40px; background:white;"/> + <vbox style="min-width:100px; min-height:40px; background:black;"/> + </arrowscrollbox> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +/** Test for Bug 378028 **/ +/* and for Bug 350471 **/ +SimpleTest.waitForExplicitFinish(); +SimpleTest.waitForFocus(prepareRunningTests); + +// Some tests need to wait until stopping scroll completely. At this time, +// setTimeout() will retry to check up to MAX_RETRY_COUNT times. +const MAX_RETRY_COUNT = 5; + +const deltaModes = [ + WheelEvent.DOM_DELTA_PIXEL, // 0 + WheelEvent.DOM_DELTA_LINE, // 1 + WheelEvent.DOM_DELTA_PAGE // 2 +]; + +function sendWheelAndWait(aScrollTaget, aX, aY, aEvent, aChecker) +{ + function continueTestsIfScrolledAsExpected() { + if (!aChecker()) + SimpleTest.executeSoon(()=>{ continueTestsIfScrolledAsExpected(aChecker) }); + else + runTests(); + } + + sendWheelAndPaint(aScrollTaget, aX, aY, aEvent, ()=>{ + // sendWheelAndPaint may wait not enough for <scrollbox>. + // Let's check the position before using is() for avoiding random orange. + // So, this test may detect regressions with timeout. + continueTestsIfScrolledAsExpected(aChecker); + }); +} + +function* testRichListbox(id) +{ + var listbox = document.getElementById(id); + + function* helper(aStart, aDelta, aIntDelta, aDeltaMode) { + listbox.ensureElementIsVisible(listbox.getItemAtIndex(aStart),true); + + let event = { + deltaMode: aDeltaMode, + deltaY: aDelta, + lineOrPageDeltaY: aIntDelta + }; + // We don't need to wait for finishing the scroll in this test. + yield sendWheelAndWait(listbox, 10, 10, event, ()=>{ return true; }); + var change = listbox.getIndexOfFirstVisibleRow() - aStart; + var direction = (change > 0) - (change < 0); + var expected = (aDelta > 0) - (aDelta < 0); + is(direction, expected, + "testRichListbox(" + id + "): vertical, starting " + aStart + + " delta " + aDelta + " lineOrPageDeltaY " + aIntDelta + + " aDeltaMode " + aDeltaMode); + + // Check that horizontal scrolling has no effect + event = { + deltaMode: aDeltaMode, + deltaX: aDelta, + lineOrPageDeltaX: aIntDelta + }; + + listbox.ensureElementIsVisible(listbox.getItemAtIndex(aStart),true); + yield sendWheelAndWait(listbox, 10, 10, event, ()=>{ return true; }); + is(listbox.getIndexOfFirstVisibleRow(), aStart, + "testRichListbox(" + id + "): horizontal, starting " + aStart + + " delta " + aDelta + " lineOrPageDeltaX " + aIntDelta + + " aDeltaMode " + aDeltaMode); + } + + // richlistbox currently uses native XUL scrolling, so the "line" + // amounts don't necessarily correspond 1-to-1 with listbox items. So + // we just check that scrolling up/down scrolls in the right direction. + for (let i = 0; i < deltaModes.length; i++) { + let delta = (deltaModes[i] == WheelEvent.DOM_DELTA_PIXEL) ? 32.0 : 2.0; + yield* helper(5, -delta, -1, deltaModes[i]); + yield* helper(5, -delta, 0, deltaModes[i]); + yield* helper(5, delta, 1, deltaModes[i]); + yield* helper(5, delta, 0, deltaModes[i]); + } +} + +function* testArrowScrollbox(id) +{ + var arrowscrollbox = document.getElementById(id); + var scrollbox = arrowscrollbox.scrollbox; + var orient = scrollbox.getAttribute("orient"); + var orientIsHorizontal = (orient == "horizontal"); + + function* helper(aStart, aDelta, aDeltaMode, aExpected) + { + var lineOrPageDelta = (aDeltaMode == WheelEvent.DOM_DELTA_PIXEL) ? aDelta / 10 : aDelta; + + scrollbox.scrollTo(aStart, aStart); + for (let i = orientIsHorizontal ? 2 : 0; i >= 0; i--) { + // Note, vertical mouse scrolling is allowed to scroll horizontal + // arrowscrollboxes, because many users have no horizontal mouse scroll + // capability + let expected = !i ? aExpected : aStart; + let getPos = ()=>{ + return orientIsHorizontal ? scrollbox.scrollLeft : + scrollbox.scrollTop; + }; + let oldPos = -1; + let retry = 0; + yield sendWheelAndWait(scrollbox, 5, 5, + { deltaMode: aDeltaMode, deltaY: aDelta, + lineOrPageDeltaY: lineOrPageDelta }, + ()=>{ + if (getPos() == expected) { + return true; + } + if (oldPos == getPos()) { + // If scroll stopped completely, let's continue the test. + return ++retry == MAX_RETRY_COUNT; + } + oldPos = getPos(); + retry = 0; + return false; + }); + is(getPos(), expected, + "testArrowScrollbox(" + id + "): vertical, starting " + aStart + + " delta " + aDelta + " lineOrPageDelta " + lineOrPageDelta + + " aDeltaMode " + aDeltaMode); + } + + scrollbox.scrollTo(aStart, aStart); + for (let i = orientIsHorizontal ? 2 : 0; i >= 0; i--) { + // horizontal mouse scrolling is never allowed to scroll vertical + // arrowscrollboxes + let expected = (!i && orientIsHorizontal) ? aExpected : aStart; + let getPos = ()=>{ + return orientIsHorizontal ? scrollbox.scrollLeft : + scrollbox.scrollTop; + }; + let oldPos = -1; + let retry = 0; + yield sendWheelAndWait(scrollbox, 5, 5, + { deltaMode: aDeltaMode, deltaX: aDelta, + lineOrPageDeltaX: lineOrPageDelta }, + ()=>{ + if (getPos() == expected) { + return true; + } + if (oldPos == getPos()) { + // If scroll stopped completely, let's continue the test. + return ++retry == MAX_RETRY_COUNT; + } + oldPos = getPos(); + retry = 0; + return false; + }); + is(getPos(), expected, + "testArrowScrollbox(" + id + "): horizontal, starting " + aStart + + " delta " + aDelta + " lineOrPageDelta " + lineOrPageDelta + + " aDeltaMode " + aDeltaMode); + } + } + + var line = arrowscrollbox.lineScrollAmount; + var scrolledWidth = scrollbox.scrollWidth; + var scrolledHeight = scrollbox.scrollHeight; + var scrollMaxX = scrolledWidth - scrollbox.getBoundingClientRect().width; + var scrollMaxY = scrolledHeight - scrollbox.getBoundingClientRect().height; + var scrollMax = orientIsHorizontal ? scrollMaxX : scrollMaxY; + + for (let deltaMode of deltaModes) { + const start = 50; + const delta = 1000; + let expectedNegative = 0; + let expectedPositive = scrollMax; + if (deltaMode == WheelEvent.DOM_DELTA_LINE) { + let maxDelta = Math.floor(Math.max(1, arrowscrollbox.scrollClientSize / line)) * line; + expectedNegative = Math.max(0, start - maxDelta); + expectedPositive = Math.min(scrollMax, start + maxDelta); + } + yield* helper(start, -delta, deltaMode, expectedNegative); + yield* helper(start, delta, deltaMode, expectedPositive); + } +} + +var gTestContinuation = null; + +function runTests() +{ + if (!gTestContinuation) { + gTestContinuation = testBody(); + } + var ret = gTestContinuation.next(); + if (ret.done) { + var winUtils = SpecialPowers.getDOMWindowUtils(window); + winUtils.restoreNormalRefresh(); + SimpleTest.finish(); + } +} + +async function prepareRunningTests() +{ + // Before actually running tests, we disable auto-dir scrolling, becasue the + // horizontal scrolling tests in this file are mostly meant to ensure that the + // tested controls in the default style should only have one scrollbar and it + // must always be in the block-flow direction so they are not really meant to + // test default actions for wheel events, so we simply disabled auto-dir + // scrolling, which are well tested in + // dom/events/test/window_wheel_default_action.html. + await SpecialPowers.pushPrefEnv({"set": [["mousewheel.autodir.enabled", + false]]}); + + runTests(); +} + +function* testBody() +{ + yield* testRichListbox("richlistbox"); + + // Perform a mousedown to ensure the wheel transaction from the previous test + // does not impact the next test. + synthesizeMouse(document.scrollingElement, 0, 0, {type: "mousedown"}, window); + yield* testArrowScrollbox("hscrollbox"); + + synthesizeMouse(document.scrollingElement, -1, -1, {type: "mousedown"}, window); + yield* testArrowScrollbox("vscrollbox"); +} + + ]]></script> +</window> diff --git a/toolkit/content/tests/chrome/test_mozinputbox_dictionary.xhtml b/toolkit/content/tests/chrome/test_mozinputbox_dictionary.xhtml new file mode 100644 index 0000000000..c2ed8a4787 --- /dev/null +++ b/toolkit/content/tests/chrome/test_mozinputbox_dictionary.xhtml @@ -0,0 +1,100 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for textbox with Add and Undo Add to Dictionary + --> +<window title="Textbox Add and Undo Add to Dictionary Test" width="500" height="600" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <hbox> + <moz-input-box id="t1" oncontextmenu="runContextMenuTest()" spellcheck="true"> + <html:input class="textbox-input" value="Hellop" spellcheck="true"/> + </moz-input-box> + </hbox> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var inputBox; +var testNum; + +function bringUpContextMenu(element) +{ + synthesizeMouseAtCenter(element, { type: "contextmenu", button: 2}); +} + +function leftClickElement(element) +{ + synthesizeMouseAtCenter(element, { button: 0 }); +} + +var onSpellCheck; +function startTests() +{ + inputBox = document.getElementById("t1"); + inputBox._input.focus(); + testNum = 0; + + ({onSpellCheck} = ChromeUtils.importESModule( + "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs")); + onSpellCheck(inputBox._input, function () { + bringUpContextMenu(inputBox); + }); +} + +function runContextMenuTest() +{ + SimpleTest.executeSoon(function() { + var contextMenu = inputBox.menupopup; + + switch(testNum) + { + case 0: // "Add to Dictionary" button + var addToDict = inputBox.getMenuItem("spell-add-to-dictionary"); + ok(!addToDict.hidden, "Is Add to Dictionary visible?"); + + var separator = inputBox.getMenuItem("spell-suggestions-separator"); + ok(!separator.hidden, "Is separator visible?"); + + addToDict.doCommand(); + + contextMenu.hidePopup(); + testNum++; + + onSpellCheck(inputBox._input, function () { + bringUpContextMenu(inputBox); + }); + break; + + case 1: // "Undo Add to Dictionary" button + var undoAddDict = inputBox.getMenuItem("spell-undo-add-to-dictionary"); + ok(!undoAddDict.hidden, "Is Undo Add to Dictioanry visible?"); + + separator = inputBox.getMenuItem("spell-suggestions-separator"); + ok(!separator.hidden, "Is separator hidden?"); + + undoAddDict.doCommand(); + + contextMenu.hidePopup(); + onSpellCheck(inputBox._input, function () { + SimpleTest.finish(); + }); + break; + } + }); +} + +SimpleTest.waitForFocus(startTests); + + ]]></script> + +</window> diff --git a/toolkit/content/tests/chrome/test_named_deck.html b/toolkit/content/tests/chrome/test_named_deck.html new file mode 100644 index 0000000000..3445ef05c7 --- /dev/null +++ b/toolkit/content/tests/chrome/test_named_deck.html @@ -0,0 +1,251 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <meta charset="utf-8"> + <title><!-- Shadow Parts issue with xul/xbl domparser --></title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script> +const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" +); +const DEFAULT_SECTION_NAMES = ["one", "two", "three"]; + +function makeButton({ name, deckId }) { + let button = document.createElement("button", { is: "named-deck-button" }); + button.setAttribute("name", name); + button.deckId = deckId; + button.textContent = name.toUpperCase(); + return button; +} + +function makeSection({ name }) { + let view = document.createElement("section"); + view.setAttribute("name", name); + view.textContent = name + name; + return view; +} + +function addSection({ name, deck, buttons }) { + let button = makeButton({ name, deckId: deck.id }); + buttons.appendChild(button); + let view = makeSection({ name }); + deck.appendChild(view); + return { button, view }; +} + +async function runTests({ deck, buttons }) { + const selectedSlot = deck.shadowRoot.querySelector('slot[name="selected"]'); + const getButtonByName = name => buttons.querySelector(`[name="${name}"]`); + + function checkState(name, count, empty = false) { + // Check that the right view is selected. + is(deck.selectedViewName, name, "The right view is selected"); + + // Verify there's one element in the slot. + let slottedEls = selectedSlot.assignedElements(); + if (empty) { + is(slottedEls.length, 0, "The deck is empty"); + } else { + is(slottedEls.length, 1, "There's one visible view"); + is( + slottedEls[0].getAttribute("name"), + name, + "The correct view is in the slot" + ); + } + + // Check that the hidden properties are set. + let sections = deck.querySelectorAll("section"); + is(sections.length, count, "There are the right number of sections"); + for (let section of sections) { + let sectionName = section.getAttribute("name"); + if (sectionName == name) { + is(section.slot, "selected", `${sectionName} is visible`); + } else { + is(section.slot, "", `${sectionName} is hidden`); + } + } + + // Check the right button is selected. + is(buttons.children.length, count, "There are the right number of buttons"); + for (let button of buttons.children) { + let buttonName = button.getAttribute("name"); + let selected = buttonName == name; + is( + button.hasAttribute("selected"), + selected, + `${buttonName} is ${selected ? "selected" : "not selected"}` + ); + } + } + + // Check that the first view is selected by default. + checkState("one", 3); + + // Switch to the third view. + info("Switch to section three"); + getButtonByName("three").click(); + checkState("three", 3); + + // Add a new section, nothing changes. + info("Add section last"); + let last = addSection({ name: "last", deck, buttons }); + checkState("three", 4); + + // We can switch to the new section. + last.button.click(); + info("Switch to section last"); + checkState("last", 4); + + info("Switch view with selectedViewName"); + let shown = BrowserTestUtils.waitForEvent(deck, "view-changed"); + deck.selectedViewName = "two"; + await shown; + checkState("two", 4); + + info("Switch back to the last view to test removing selected view"); + shown = BrowserTestUtils.waitForEvent(deck, "view-changed"); + deck.setAttribute("selected-view", "last"); + await shown; + checkState("last", 4); + + // Removing the selected section leaves the selected slot empty. + info("Remove section last"); + last.button.remove(); + last.view.remove(); + + info("Should not have any selected views"); + checkState("last", 3, true); + + // Setting a missing view will give a "view-changed" event. + info("Set view to a missing name"); + let hidden = BrowserTestUtils.waitForEvent(deck, "view-changed"); + deck.selectedViewName = "missing"; + await hidden; + checkState("missing", 3, true); + + // Adding the view won't trigger "view-changed", but the view will slotted. + info("Add the missing view, it should be shown"); + shown = BrowserTestUtils.waitForEvent(selectedSlot, "slotchange"); + let viewChangedEvent = false; + let viewChangedFn = () => { + viewChangedEvent = true; + }; + deck.addEventListener("view-changed", viewChangedFn); + addSection({ name: "missing", deck, buttons }); + await shown; + deck.removeEventListener("view-changed", viewChangedFn); + ok(!viewChangedEvent, "The view-changed event didn't fire"); + checkState("missing", 4); +} + +async function setup({ beAsync, first, deckId }) { + // Make the deck and buttons. + const deck = document.createElement("named-deck"); + deck.id = deckId; + for (let name of DEFAULT_SECTION_NAMES) { + deck.appendChild(makeSection({ name })); + } + const buttons = document.createElement("button-group"); + for (let name of DEFAULT_SECTION_NAMES) { + buttons.appendChild(makeButton({ name, deckId })); + } + + let ordered; + if (first == "deck") { + ordered = [deck, buttons]; + } else if (first == "buttons") { + ordered = [buttons, deck]; + } else { + throw new Error("Invalid order"); + } + + // Insert them in the specified order, possibly async. + document.body.appendChild(ordered.shift()); + if (beAsync) { + await new Promise(resolve => requestAnimationFrame(resolve)); + } + document.body.appendChild(ordered.shift()); + + return { deck, buttons }; +} + +add_task(async function testNamedDeckAndButtons() { + // Check adding the deck first. + dump("Running deck first tests synchronously"); + await runTests(await setup({ beAsync: false, first: "deck", deckId: "deck-sync" })); + dump("Running deck first tests asynchronously"); + await runTests(await setup({ beAsync: true, first: "deck", deckId: "deck-async" })); + + // Check adding the buttons first. + dump("Running buttons first tests synchronously"); + await runTests(await setup({ beAsync: false, first: "buttons", deckId: "buttons-sync" })); + dump("Running buttons first tests asynchronously"); + await runTests(await setup({ beAsync: true, first: "buttons", deckId: "buttons-async" })); +}); + +add_task(async function testFocusAndClickMixing() { + const waitForAnimationFrame = () => + new Promise(r => requestAnimationFrame(r)); + const sendTab = (e = {}) => { + synthesizeKey("VK_TAB", e); + return waitForAnimationFrame(); + }; + + const firstButton = document.createElement("button"); + document.body.append(firstButton); + + const { deck, buttons: buttonGroup } = await setup({ + beAsync: false, + first: "buttons", + deckId: "focus-click-mixing", + }); + const buttons = buttonGroup.children; + firstButton.focus(); + const secondButton = document.createElement("button"); + document.body.append(secondButton); + + await sendTab(); + is(document.activeElement, buttons[0], "first deck button is focused"); + is(deck.selectedViewName, "one", "first view is shown"); + + await sendTab(); + is(document.activeElement, secondButton, "focus moves out of group"); + + await sendTab({ shiftKey: true }); + is(document.activeElement, buttons[0], "focus moves back to first button"); + + // Click on another tab button, this should make it the focusable button. + synthesizeMouseAtCenter(buttons[1], {}); + await waitForAnimationFrame(); + + is(deck.selectedViewName, "two", "second view is shown"); + + if (document.activeElement != buttons[1]) { + // On Mac the button isn't focused on click, but it is on Windows/Linux. + await sendTab(); + } + is(document.activeElement, buttons[1], "second deck button is focusable"); + + await sendTab(); + is(document.activeElement, secondButton, "focus moved to second plain button"); + + await sendTab({ shiftKey: true }); + is(document.activeElement, buttons[1], "second deck button is focusable"); + + await sendTab({ shiftKey: true }); + is( + document.activeElement, + firstButton, + "next shift-tab moves out of button group" + ); +}); + </script> +</head> +<body> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_navigate_persist.html b/toolkit/content/tests/chrome/test_navigate_persist.html new file mode 100644 index 0000000000..2ef84ddb15 --- /dev/null +++ b/toolkit/content/tests/chrome/test_navigate_persist.html @@ -0,0 +1,87 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1460639 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1460639</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://global/skin"/> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + function navigateWindowTo(win, url) { + return new Promise(resolve => { + Services.obs.addObserver(function listener(document) { + Services.obs.removeObserver(listener, "document-element-inserted"); + document.addEventListener("DOMContentLoaded", () => { + resolve(); + }, { once: true } ); + }, "document-element-inserted"); + win.location = url; + }); + } + + function promiseMaybeResizeEvent(win, expectedSize) { + return new Promise(resolve => { + // If the size is already as expected, then there may be no resize + // event. + if (win.outerWidth === expectedSize + && win.outerHeight === expectedSize) { + resolve(); + } + win.addEventListener("resize", () => { + resolve(); + }, {once: true}); + }); + } + + function resize(win, size) { + const resizePromise = promiseMaybeResizeEvent(win, size); + win.resizeTo(size, size); + return resizePromise; + } + + async function runTest() { + // Test that persisted window attributes are loaded when a top level + // window is navigated. This mimics the behavior of early first paint by + // first loading about:blank and then navigating to window_navigate_persist.html. + const PERSIST_SIZE = 200; + // First, load the document and resize it so the size is persisted. + let win = window.browsingContext.topChromeWindow + .openDialog("window_navigate_persist.html", "_blank", `chrome,all,dialog=no`); + await SimpleTest.promiseFocus(win); + await resize(win, PERSIST_SIZE); + is(win.outerWidth, PERSIST_SIZE, "Window is resized to desired width"); + is(win.outerHeight, PERSIST_SIZE, "Window is resized to desired height"); + win.close(); + + // Now mimic early first paint. + win = window.browsingContext.topChromeWindow + .openDialog("about:blank", "_blank", `chrome,all,dialog=no`); + await SimpleTest.promiseFocus(win, true); + isnot(win.outerWidth, PERSIST_SIZE, "Initial window width is not the persisted size"); + isnot(win.outerHeight, PERSIST_SIZE, "Initial window height is not the persisted size"); + + await navigateWindowTo(win, "window_navigate_persist.html"); + await promiseMaybeResizeEvent(win, PERSIST_SIZE); + is(win.outerWidth, PERSIST_SIZE, "Window width is persisted"); + is(win.outerHeight, PERSIST_SIZE, "Window height is persisted"); + win.close(); + SimpleTest.finish(); + } + + </script> +</head> +<body onload="runTest()"> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1460639">Mozilla Bug 1460639</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_notificationbox.xhtml b/toolkit/content/tests/chrome/test_notificationbox.xhtml new file mode 100644 index 0000000000..8de985175a --- /dev/null +++ b/toolkit/content/tests/chrome/test_notificationbox.xhtml @@ -0,0 +1,731 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for notificationbox + --> +<window title="Notification Box" width="500" height="600" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <vbox id="nb"/> + <menupopup id="menupopup" onpopupshown="this.hidePopup()" onpopuphidden="checkPopupClosed()"> + <menuitem label="One"/> + </menupopup> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ +const NOTIFICATION_LOCAL_NAME = "notification-message" +SimpleTest.waitForExplicitFinish(); + +var testtag_notificationbox_buttons = [ + { + label: "Button 1", + accesskey: "u", + callback: testtag_notificationbox_buttonpressed, + popup: "menupopup" + } +]; + +var testtag_notificationbox_buttons_nopopup = [ + { + label: "Button 1 No Popup", + accesskey: "u", + callback: testtag_notificationbox_button1pressed, + }, + { + label: "Button 2 No Popup", + accesskey: "u", + callback: testtag_notificationbox_button2pressed, + } +]; + +let testtag_notificationbox_button_l10n = [ + { + "l10n-id": "test-id" + } +]; + +var testtag_notificationbox_links = [ + { + label: "Link 1", + callback: testtag_notificationbox_buttonpressed, + link: "about:mozilla" + }, + { + label: "Button 2", + accesskey: "u", + callback: testtag_notificationbox_buttonpressed, + } +]; + +var testtag_notificationbox_supportpage = [ + { + supportPage: "test1", + }, + { + label: "This is an existing label", + supportPage: "test2", + }, + { + supportPage: "test3", + "l10n-id": "more-specific-id", + }, + { + supportPage: "test4", + label: "legacy label call", + "l10n-id": "modern-fluent-id" + } +]; + +function testtag_notificationbox_buttonpressed(notification, button) +{ + SimpleTest.is(button.localName, "button"); + return false; +} + +let buttonsPressedLog = ""; +function testtag_notificationbox_button1pressed(notification, button) { buttonsPressedLog += "button1"; return true; } +function testtag_notificationbox_button2pressed(notification, button) { buttonsPressedLog += "button2"; return true; } + +function testtag_notificationbox(nb) +{ + testtag_notificationbox_State(nb, "initial", null, 0); + + SimpleTest.is(nb.removeAllNotifications(false), undefined, "initial removeAllNotifications"); + testtag_notificationbox_State(nb, "initial removeAllNotifications", null, 0); + SimpleTest.is(nb.removeAllNotifications(true), undefined, "initial removeAllNotifications immediate"); + testtag_notificationbox_State(nb, "initial removeAllNotifications immediate", null, 0); + + runTimedTests(tests, -1, nb, null); +} + +var notification_last_events = []; +function notification_eventCallback(event) +{ + notification_last_events.push({ actualEvent: event , item: this }); +} + +/** + * For any notifications that have the notification_eventCallback on + * them, we will have recorded instances of those callbacks firing + * and stored them. This checks to see that the expected event types + * are being fired in order, and targeting the right item. + * + * @param {Array<string>} expectedEvents + * The list of event types, in order, that we expect to have been + * fired on the item. + * @param {<xul:notification>} ntf + * The notification we expect the callback to have been fired from. + * @param {string} testName + * The name of the current test, for logging. + */ +function testtag_notification_eventCallback(expectedEvents, ntf, testName) +{ + for (let i = 0; i < expectedEvents; ++i) { + let expected = expectedEvents[i]; + let { actualEvent, item } = notification_last_events[i]; + SimpleTest.is(actualEvent, expected, testName + ": event name"); + SimpleTest.is(item, ntf, testName + ": event item"); + } + notification_last_events = []; +} + +var tests = +[ + { + async test(nb, ntf) { + ntf = await nb.appendNotification("mutable", { + label: "Original", + priority: nb.PRIORITY_INFO_LOW, + }, testtag_notificationbox_buttons); + + ntf.label = "Changed string"; + await ntf.updateComplete; + SimpleTest.is(ntf.messageText.textContent.trim(), "Changed string", "set notification label with string"); + return ntf; + }, + result(nb, ntf) { + nb.removeNotification(ntf); + testtag_notificationbox_State(nb, "set notification label", null, 0); + } + }, + /* + Ensures that buttons created with the "label" parameter have their + label attribute set correctly. + */ + { + async test(nb, ntf) { + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + }, testtag_notificationbox_buttons); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append notification"); + const button = ntf.buttonContainer.querySelector("button"); + SimpleTest.is(button.label, "Button 1", "set button label with the 'label' parameter"); + return ntf; + }, + result(nb, ntf) { + nb.removeNotification(ntf); + testtag_notificationbox_State(nb, "set notification label", null, 0); + } + }, + { + /* + Ensures that buttons created with the "l10n-id" parameter have + their "l10n-id" assigned correctly. + */ + async test(nb, ntf) { + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + }, testtag_notificationbox_button_l10n); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append notification"); + const button = ntf.buttonContainer.querySelector("button"); + SimpleTest.is(button.dataset.l10nId, "test-id", "create notification button with correctly assigned l10n id"); + return ntf; + }, + result(nb, ntf) { + nb.removeNotification(ntf); + testtag_notificationbox_State(nb, "set notification label", null, 0); + } + }, + { + async test(nb, ntf) { + // append a new notification + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + }, testtag_notificationbox_buttons); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append notification"); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "append", ntf, 1); + testtag_notification_State(nb, ntf, "append", "Notification", "note", + "happy.png", nb.PRIORITY_INFO_LOW); + + // check the getNotificationWithValue method + var ntf_found = nb.getNotificationWithValue("note"); + SimpleTest.is(ntf, ntf_found, "getNotificationWithValue note"); + + var none_found = nb.getNotificationWithValue("notenone"); + SimpleTest.is(none_found, null, "getNotificationWithValue null"); + return ntf; + } + }, + { + test(nb, ntf) { + // check that notifications can be removed properly + nb.removeNotification(ntf); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "removeNotification", null, 0); + } + }, + { + async test(nb, ntf) { + // append a new notification, but now with an event callback + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + eventCallback: notification_eventCallback, + }, testtag_notificationbox_buttons); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append notification with callback"); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "append with callback", ntf, 1); + return ntf; + } + }, + { + test(nb, ntf) { + nb.removeNotification(ntf); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "removeNotification with callback", + null, 0); + + testtag_notification_eventCallback(["removed"], ntf, "removeNotification()"); + return ntf; + } + }, + { + async test(nb, ntf) { + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_MEDIUM, + eventCallback: notification_eventCallback, + }, testtag_notificationbox_buttons); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append notification with object"); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "append with callback", ntf, 1); + testtag_notificationbox_State(nb, "append using object", ntf, 1); + testtag_notification_State(nb, ntf, "append object", "Notification", "note", + "happy.png", nb.PRIORITY_INFO_MEDIUM); + return ntf; + } + }, + { + test(rb, ntf) { + // Dismissing the notification instead of removing it should + // fire a dismissed "event" on the callback, followed by + // a removed "event". + ntf.dismiss(); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "called dismiss()", null, 0); + testtag_notification_eventCallback(["dismissed", "removed"], ntf, + "dismiss()"); + return ntf; + } + }, + { + async test(nb, ntf) { + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_WARNING_LOW, + eventCallback: notification_eventCallback, + }, [{ + label: "Button", + }]); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "append", ntf, 1); + testtag_notification_State(nb, ntf, "append", "Notification", "note", + "happy.png", nb.PRIORITY_WARNING_LOW); + nb.removeNotification(ntf); + + return [1, null]; + } + }, + { + repeat: true, + async test(nb, arr) { + var idx = arr[0]; + var ntf = arr[1]; + switch (idx) { + case 1: + // append a new notification + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + }, testtag_notificationbox_buttons); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append notification"); + + // Test persistence + ntf.persistence++; + + return [idx, ntf]; + case 2: + case 3: + nb.removeTransientNotifications(); + + return [idx, ntf]; + } + return ntf; + }, + result(nb, arr) { + var idx = arr[0]; + var ntf = arr[1]; + switch (idx) { + case 1: + testtag_notificationbox_State(nb, "notification added", ntf, 1); + testtag_notification_State(nb, ntf, "append", "Notification", "note", + "happy.png", nb.PRIORITY_INFO_LOW); + SimpleTest.is(ntf.persistence, 1, "persistence is 1"); + + return [++idx, ntf]; + case 2: + testtag_notificationbox_State(nb, "first removeTransientNotifications", ntf, 1); + testtag_notification_State(nb, ntf, "append", "Notification", "note", + "happy.png", nb.PRIORITY_INFO_LOW); + SimpleTest.is(ntf.persistence, 0, "persistence is now 0"); + + return [++idx, ntf]; + case 3: + testtag_notificationbox_State(nb, "second removeTransientNotifications", null, 0); + + this.repeat = false; + } + return ntf; + } + }, + { + async test(nb, ntf) { + // append another notification + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_MEDIUM, + }, testtag_notificationbox_buttons); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append notification again"); + return ntf; + }, + result(nb, ntf) { + // check that appending a second notification after removing the first one works + testtag_notificationbox_State(nb, "append again", ntf, 1); + testtag_notification_State(nb, ntf, "append again", "Notification", "note", + "happy.png", nb.PRIORITY_INFO_MEDIUM); + return ntf; + } + }, + { + test(nb, ntf) { + // check the removeCurrentNotification method + nb.removeCurrentNotification(); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "removeCurrentNotification", null, 0); + } + }, + { + async test(nb, ntf) { + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_HIGH, + }, testtag_notificationbox_buttons); + return ntf; + }, + result(nb, ntf) { + // test the removeAllNotifications method + testtag_notificationbox_State(nb, "append info_high", ntf, 1); + SimpleTest.is(ntf.priority, nb.PRIORITY_INFO_HIGH, + "notification.priority " + nb.PRIORITY_INFO_HIGH); + SimpleTest.is(nb.removeAllNotifications(false), undefined, "removeAllNotifications"); + } + }, + { + async test(nb, ntf) { + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + eventCallback: notification_eventCallback, + }, testtag_notificationbox_links); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append link notification with callback"); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "append link with callback", ntf, 1); + + let buttonContainer = ntf.buttonContainer; + let button = buttonContainer.lastElementChild; + SimpleTest.is(button.localName, "button", "button is a button"); + SimpleTest.ok(!button.href, "button href is not set"); + + let link = ntf.querySelector(".notification-link"); + SimpleTest.is(link.localName, "label", "link is a label"); + SimpleTest.is(link.href, "about:mozilla", "link href is correct"); + + SimpleTest.is(nb.removeAllNotifications(false), undefined, "removeAllNotifications"); + } + }, + { + async test(nb, ntf) { + // append a new notification + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + }, testtag_notificationbox_buttons_nopopup); + return ntf; + }, + result(nb, ntf) { + let buttons = nb.currentNotification.buttonContainer.querySelectorAll("* button"); + + buttons[0].focus(); + synthesizeKey(" ", {}); + SimpleTest.is(buttonsPressedLog, "button1", "button 1 with keyboard"); + buttons[1].focus(); + synthesizeKey(" ", {}); + SimpleTest.is(buttonsPressedLog, "button1button2", "button 2 with keyboard"); + + synthesizeMouseAtCenter(buttons[0], {}); + SimpleTest.is(buttonsPressedLog, "button1button2button1", "button 1 with mouse"); + synthesizeMouseAtCenter(buttons[1], {}); + SimpleTest.is(buttonsPressedLog, "button1button2button1button2", "button 2 with mouse"); + + nb.removeAllNotifications(true); + } + }, + { + async test(nb, ntf) { + ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + eventCallback: notification_eventCallback, + }, testtag_notificationbox_supportpage); + await ntf.updateComplete; + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append support page notification"); + return ntf; + }, + result(nb, ntf) { + testtag_notificationbox_State(nb, "append link with callback", ntf, 1); + + let link = ntf.querySelector(".notification-link"); + SimpleTest.is(link.localName, "a", "link 1 is an anchor"); + SimpleTest.is(link.dataset.l10nId, "moz-support-link-text", "link 1 Fluent ID is set"); + SimpleTest.ok(link.href.endsWith("/test1"), "link 1 href is set"); + + link = link.nextElementSibling; + SimpleTest.is(link.localName, "a", "link 2 is an anchor"); + SimpleTest.is(link.dataset.l10nId, "moz-support-link-text", "link 2 Fluent ID is set"); + SimpleTest.ok(!link.value, "label is not assigned to value when using supportPage"); + SimpleTest.ok(link.href.endsWith("/test2"), "link 2 href is set"); + + link = link.nextElementSibling; + SimpleTest.is(link.localName, "a", "link 3 is an anchor"); + SimpleTest.is(link.dataset.l10nId, "more-specific-id", "link 3 Fluent ID is the passed l10n-id"); + SimpleTest.ok(link.href.endsWith("/test3"), "link 3 href is set"); + + link = link.nextElementSibling; + SimpleTest.is(link.localName, "a", "link 4 is an anchor"); + SimpleTest.is(link.dataset.l10nId, "modern-fluent-id", "link 4 Fluent ID is the passed l10n-id"); + SimpleTest.ok(!link.value, "label is not assigned to value when using supportPage"); + SimpleTest.ok(link.href.endsWith("/test4"), "link 4 href is set"); + + SimpleTest.is(nb.removeAllNotifications(false), undefined, "removeAllNotifications"); + } + }, + { + async test(nb, unused) { + // add a number of notifications and check that they are added in order + await nb.appendNotification("4", { label: "Four", priority: nb.PRIORITY_INFO_HIGH }, + testtag_notificationbox_buttons); + await nb.appendNotification("7", { label: "Seven", priority: nb.PRIORITY_WARNING_HIGH }, + testtag_notificationbox_buttons); + await nb.appendNotification("2", { label: "Two", priority: nb.PRIORITY_INFO_LOW }); + await nb.appendNotification("8", { label: "Eight", priority: nb.PRIORITY_CRITICAL_LOW }); + await nb.appendNotification("5", { label: "Five", priority: nb.PRIORITY_WARNING_LOW }); + await nb.appendNotification("6", { label: "Six", priority: nb.PRIORITY_WARNING_HIGH }); + await nb.appendNotification("1", { label: "One", priority: nb.PRIORITY_INFO_LOW }); + await nb.appendNotification("9", { label: "Nine", priority: nb.PRIORITY_CRITICAL_MEDIUM }); + let ntf = await nb.appendNotification("10", { label: "Ten", priority: nb.PRIORITY_CRITICAL_HIGH }); + await nb.appendNotification("3", { label: "Three", priority: nb.PRIORITY_INFO_MEDIUM }); + return ntf; + }, + result(nb, ntf) { + let expectedValue = "3"; + ntf = nb.getNotificationWithValue(expectedValue); + is(nb.currentNotification, ntf, "appendNotification last notification"); + is(nb.currentNotification.getAttribute("value"), expectedValue, "appendNotification order"); + return 1; + } + }, + { + // test closing notifications to make sure that the current notification is still set properly + repeat: true, + test(nb, testidx) { + this.repeat = false; + return undefined; + }, + result(nb, arr) { + let notificationOrder = [4, 7, 2, 8, 5, 6, 1, 9, 10, 3]; + let allNotificationValues = [...nb.stack.children].map(n => n.getAttribute("value")); + is(allNotificationValues.length, notificationOrder.length, "Expected number of notifications"); + for (let i = 0; i < allNotificationValues.length; i++) { + is( + allNotificationValues[i], + notificationOrder[i].toString(), + `Notification ${i} matches` + ); + } + return undefined; + } + }, + { + async test(nb, ntf) { + var exh = false; + try { + await nb.appendNotification("no", { label: "no", priority: -1 }); + } catch (ex) { exh = true; } + SimpleTest.is(exh, true, "appendNotification priority too low"); + + exh = false; + try { + await nb.appendNotification("no", { label: "no", priority: 11 }); + } catch (ex) { exh = true; } + SimpleTest.is(exh, true, "appendNotification priority too high"); + + // check that the other priority types work properly + runTimedTests(appendPriorityTests, -1, nb, nb.PRIORITY_WARNING_LOW); + } + } +]; + +var appendPriorityTests = [ + { + async test(nb, priority) { + let ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority, + }, testtag_notificationbox_buttons); + SimpleTest.is(ntf && ntf.localName == NOTIFICATION_LOCAL_NAME, true, "append notification " + priority); + return [ntf, priority]; + }, + result(nb, obj) { + SimpleTest.is(obj[0].priority, obj[1], "notification.priority " + obj[1]); + return obj[1]; + } + }, + { + test(nb, priority) { + nb.removeCurrentNotification(); + return priority; + }, + async result(nb, priority) { + if (priority == nb.PRIORITY_CRITICAL_HIGH) { + let ntf = await nb.appendNotification("note", { + label: "Notification", + image: "happy.png", + priority: nb.PRIORITY_INFO_LOW, + }, testtag_notificationbox_buttons); + setTimeout(checkPopupTest, 50, nb, ntf); + } + else { + runTimedTests(appendPriorityTests, -1, nb, ++priority); + } + } + }, +]; + +function testtag_notificationbox_State(nb, testid, expecteditem, expectedcount) +{ + SimpleTest.is(nb.currentNotification, expecteditem, testid + " currentNotification"); + SimpleTest.is(nb.allNotifications ? nb.allNotifications.length : "no value", + expectedcount, testid + " allNotifications"); +} + +function testtag_notification_State(nb, ntf, testid, label, value, image, priority) +{ + is(ntf.messageText.textContent.trim(), label, testid + " notification label"); + is(ntf.getAttribute("value"), value, testid + " notification value"); + is(ntf.priority, priority, testid + " notification priority"); + + var type; + switch (priority) { + case nb.PRIORITY_INFO_LOW: + case nb.PRIORITY_INFO_MEDIUM: + case nb.PRIORITY_INFO_HIGH: + type = "info"; + break; + case nb.PRIORITY_WARNING_LOW: + case nb.PRIORITY_WARNING_MEDIUM: + case nb.PRIORITY_WARNING_HIGH: + type = "warning"; + break; + case nb.PRIORITY_CRITICAL_LOW: + case nb.PRIORITY_CRITICAL_MEDIUM: + case nb.PRIORITY_CRITICAL_HIGH: + type = "critical"; + break; + } + + is(ntf.getAttribute("type"), type, testid + " notification type"); + + let icons = { + info: "chrome://global/skin/icons/info-filled.svg", + warning: "chrome://global/skin/icons/warning.svg", + critical: "chrome://global/skin/icons/error.svg", + }; + let icon = icons[type]; + is(ntf.messageImage.src, icon, "notification image is set"); +} + +function checkPopupTest(nb, ntf) +{ + if (nb._animating) { + setTimeout(checkPopupTest, 50, nb, ntf); + } else { + var evt = new Event(""); + ntf.dispatchEvent(evt); + evt.target.buttonInfo = testtag_notificationbox_buttons[0]; + ntf.handleEvent(evt); + } +} + +function checkPopupClosed() +{ + SimpleTest.finish(); +} + +/** + * run one or more tests which perform a test operation, wait for a delay, + * then perform a result operation. + * + * tests - array of objects where each object is : + * { + * test: test function, + * result: result function + * repeat: true to repeat the test + * } + * idx - starting index in tests + * element - element to run tests on + * arg - argument to pass between test functions + * + * If, after executing the result part, the repeat property of the test is + * true, then the test is repeated. If the repeat property is not true, + * continue on to the next test. + * + * The test and result functions take two arguments, the element and the arg. + * The test function may return a value which will passed to the result + * function as its arg. The result function may also return a value which + * will be passed to the next repetition or the next test in the array. + */ +async function runTimedTests(tests, idx, element, arg) +{ + if (idx >= 0 && "result" in tests[idx]) + arg = tests[idx].result(element, arg); + + // if not repeating, move on to the next test + if (idx == -1 || !tests[idx].repeat) + idx++; + + if (idx < tests.length) { + let result = await tests[idx].test(element, arg); + setTimeout(runTimedTestsWait, 50, tests, idx, element, result); + } +} + +function runTimedTestsWait(tests, idx, element, arg) +{ + // use this secret property to check if the animation is still running. If it + // is, then the notification hasn't fully opened or closed yet + if (element._animating) + setTimeout(runTimedTestsWait, 50, tests, idx, element, arg); + else + runTimedTests(tests, idx, element, arg); +} + +setTimeout(() => { + testtag_notificationbox(new MozElements.NotificationBox(e => { + document.getElementById("nb").appendChild(e); + })); +}, 0); +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_panel.xhtml b/toolkit/content/tests/chrome/test_panel.xhtml new file mode 100644 index 0000000000..66cba3f232 --- /dev/null +++ b/toolkit/content/tests/chrome/test_panel.xhtml @@ -0,0 +1,29 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Panel Tests" + onload="runTest()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<script> +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_panel.xhtml", "_blank", "chrome,left=200,top=200,width=200,height=200,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_panel_anchoradjust.xhtml b/toolkit/content/tests/chrome/test_panel_anchoradjust.xhtml new file mode 100644 index 0000000000..4bcf3292e7 --- /dev/null +++ b/toolkit/content/tests/chrome/test_panel_anchoradjust.xhtml @@ -0,0 +1,29 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Test Panel Position When Anchor Changes" + onload="runTest()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<script> +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_panel_anchoradjust.xhtml", "_blank", "chrome,left=200,top=200,width=200,height=200,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_panel_focus.xhtml b/toolkit/content/tests/chrome/test_panel_focus.xhtml new file mode 100644 index 0000000000..87dc6ec140 --- /dev/null +++ b/toolkit/content/tests/chrome/test_panel_focus.xhtml @@ -0,0 +1,36 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Panel Focus Tests" + onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<script> +// use a chrome window for this test as the focus in content windows can be +// adjusted by the current selection position + +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + // move the mouse so any tooltips that might be open go away, otherwise this + // test can fail on Mac + synthesizeMouse(document.documentElement, 1, 1, { type: "mousemove" }); + + window.openDialog("window_panel_focus.xhtml", "_blank", "chrome,width=600,height=600,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_panel_hover_menu.xhtml b/toolkit/content/tests/chrome/test_panel_hover_menu.xhtml new file mode 100644 index 0000000000..4c5d8589cb --- /dev/null +++ b/toolkit/content/tests/chrome/test_panel_hover_menu.xhtml @@ -0,0 +1,46 @@ +<?xml version="1.0"?> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<head> + <meta charset="utf-8" /> + <title><!-- Test with dialog & buttons --></title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + <link rel="stylesheet" href="chrome://global/skin"/> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script><![CDATA[ + add_task(async function test_panel_submenu_hover() { + let panel = document.getElementById("panel"); + let menu = document.getElementById("menu"); + let menupopup = document.getElementById("menupopup"); + + let panelShown = new Promise(r => panel.addEventListener("popupshown", r, { once: true })); + info("opening panel"); + panel.openPopupAtScreen(window.screenX, window.screenY); + await panelShown; + info("panel shown"); + + info("hovering menu button"); + synthesizeMouseAtCenter(menu, { type: "mousemove" }); + // Wait for at least the submenu delay. + await new Promise(r => setTimeout(r, 1000)); + is(menupopup.state, "closed", "menu shouldn't have opened"); + + info("clicking menu button"); + let menupopupShown = new Promise(r => menupopup.addEventListener("popupshown", r, { once: true })); + synthesizeMouseAtCenter(menu, {}); + await menupopupShown; + + ok(true, "Menupopup was shown on click"); + }); + ]]></script> +</head> +<body> + <xul:panel id="panel"> + <xul:button type="menu" id="menu" label="Open menu"> + <xul:menupopup id="menupopup"> + <xul:menuitem label="foo"/> + </xul:menupopup> + </xul:button> + </xul:panel> +</body> +</html> diff --git a/toolkit/content/tests/chrome/test_panel_open.xhtml b/toolkit/content/tests/chrome/test_panel_open.xhtml new file mode 100644 index 0000000000..27707a4f34 --- /dev/null +++ b/toolkit/content/tests/chrome/test_panel_open.xhtml @@ -0,0 +1,83 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + Test for panel 'open' state on the anchor. + --> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<label id="outerlabel" value="Label"/> +<panel id="panel" type="arrow"> + <label id="innerlabel" value="Inner" context="menupopup"/> + <menupopup id="menupopup"> + <menuitem label="One"/> + </menupopup> +</panel> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +add_task(async () => { + // Open a panel and check the open state. The open state should only be assigned + // for arrow panels and not the context menu. + + let panel = document.getElementById("panel"); + let menupopup = document.getElementById("menupopup"); + let innerlabel = document.getElementById("innerlabel"); + let outerlabel = document.getElementById("outerlabel"); + + // Two iterations are used, one with type="arrow" and the second without. + for (let iter = 0; iter < 2; iter++) { + await new Promise(resolve => { + panel.addEventListener("popupshown", resolve, { once: true }); + panel.openPopup(outerlabel, "after_start"); + }); + + // The open state should only be set for arrow panels. + if (panel.getAttribute("type") == "arrow") { + is(outerlabel.getAttribute("open"), "true", "outer label open state when panel opened"); + } else { + ok(!outerlabel.hasAttribute("open"), "outer label open state when panel opened"); + } + ok(!innerlabel.hasAttribute("open"), "inner label open state when panel opened"); + + await new Promise(resolve => { + menupopup.addEventListener("popupshown", resolve, { once: true }); + synthesizeMouse(innerlabel, 4, 4, { type: "contextmenu", button: 2 }); + }); + + // The open state should only be set for arrow panels. + if (panel.getAttribute("type") == "arrow") { + is(outerlabel.getAttribute("open"), "true", "outer label open state when context menu opened"); + } else { + ok(!outerlabel.hasAttribute("open"), "outer label open state when context menu opened"); + } + ok(!innerlabel.hasAttribute("open"), "inner label open state when context menu opened"); + + await new Promise(resolve => { + menupopup.addEventListener("popuphidden", resolve, { once: true }); + menupopup.hidePopup(); + }); + + await new Promise(resolve => { + panel.addEventListener("popuphidden", resolve, { once: true }); + panel.hidePopup(); + }); + + ok(!outerlabel.hasAttribute("open"), "outer label open state when panel closed"); + ok(!innerlabel.hasAttribute("open"), "inner label open state when panel closed"); + + // Clear the type attribute for the second iteration. + panel.removeAttribute("type"); + } +}); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_panelfrommenu.xhtml b/toolkit/content/tests/chrome/test_panelfrommenu.xhtml new file mode 100644 index 0000000000..4a00dc58ab --- /dev/null +++ b/toolkit/content/tests/chrome/test_panelfrommenu.xhtml @@ -0,0 +1,118 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Open panel from menuitem" + onload="setTimeout(runTests, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<!-- + This test does the following: + 1. Opens the menu, causing the popupshown event to fire, which will call menuOpened. + 2. Keyboard events are fired to cause the first item on the menu to be executed. + 3. The command event handler for the first menuitem opens the panel. + 4. As a menuitem was executed, the menu will roll up, hiding it. + 5. The popuphidden event for the menu calls menuClosed which tests the popup states. + 6. The panelOpened function tests the popup states again and hides the popup. + 7. Once the panel's popuphidden event fires, tests are performed to see if + panels inside buttons and toolbarbuttons work. Each is opened and the closed. + --> + +<menu id="menu" onpopupshown="menuOpened()" onpopuphidden="menuClosed();"> + <menupopup> + <menuitem id="i1" label="One" oncommand="$('panel').openPopup($('menu'), 'after_start');"/> + <menuitem id="i2" label="Two"/> + </menupopup> +</menu> + +<panel id="hiddenpanel" hidden="true"/> + +<panel id="panel" onpopupshown="panelOpened()" + onpopuphidden="$('button').focus(); $('button').open = true"> + <html:input/> +</panel> + +<button id="button" type="menu" label="Button"> + <panel onpopupshown="panelOnButtonOpened(this)" + onpopuphidden="$('tbutton').open = true;"> + <button label="OK" oncommand="this.parentNode.parentNode.open = false"/> + </panel> +</button> + +<toolbarbutton id="tbutton" type="menu" label="Toolbarbutton"> + <panel onpopupshown="panelOnToolbarbuttonOpened(this)" + onpopuphidden="SimpleTest.finish()"> + <html:input/> + </panel> +</toolbarbutton> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function runTests() +{ + is($("hiddenpanel").state, "closed", "hidden popup is closed"); + + var menu = $("menu"); + menu.open = true; +} + +function menuOpened() +{ + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); +} + +function menuClosed() +{ + // the panel will be open at this point, but the popupshown event + // still needs to fire + is($("panel").state, "showing", "panel is open after menu hide"); + is($("menu").menupopup.state, "closed", "menu is closed after menu hide"); +} + +function panelOpened() +{ + is($("panel").state, "open", "panel is open"); + is($("menu").menupopup.state, "closed", "menu is closed"); + $("panel").hidePopup(); +} + +function panelOnButtonOpened(panel) +{ + is(panel.state, 'open', 'button panel is open'); + is(document.activeElement, document.documentElement, "focus blurred on panel from button open"); + synthesizeKey("KEY_ArrowDown"); + is(document.activeElement, document.documentElement, "focus not modified on cursor down from button"); + panel.firstChild.doCommand() +} + +function panelOnToolbarbuttonOpened(panel) +{ + is(panel.state, 'open', 'toolbarbutton panel is open'); + is(document.activeElement, document.documentElement, "focus blurred on panel from toolbarbutton open"); + panel.firstChild.focus(); + synthesizeKey("KEY_ArrowDown"); + is(document.activeElement, panel.firstChild, "focus not modified on cursor down from toolbarbutton"); + panel.parentNode.open = false; +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_anchor.xhtml b/toolkit/content/tests/chrome/test_popup_anchor.xhtml new file mode 100644 index 0000000000..8825e8fd14 --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_anchor.xhtml @@ -0,0 +1,29 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Anchor Tests" + onload="setTimeout(runTest, 0);" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + +<script> +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_popup_anchor.xhtml", "_blank", "chrome,width=600,height=600,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_anchoratrect.xhtml b/toolkit/content/tests/chrome/test_popup_anchoratrect.xhtml new file mode 100644 index 0000000000..cc5141fa0b --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_anchoratrect.xhtml @@ -0,0 +1,28 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu Button Popup Tests" + onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<script> +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_popup_anchoratrect.xhtml", "_blank", "chrome,width=200,height=200,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_attribute.xhtml b/toolkit/content/tests/chrome/test_popup_attribute.xhtml new file mode 100644 index 0000000000..bda23a930c --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_attribute.xhtml @@ -0,0 +1,33 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Attribute Tests" + onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<script> +SimpleTest.waitForExplicitFinish(); +async function runTest() +{ + // This test exercises non-native menu code. So disable native context menus for this test. + // If we ever get to a point where we don't use any non-native menus on macOS any more, we can + // disable this test on macOS. + await SpecialPowers.pushPrefEnv({ set: [["widget.macos.native-context-menus", false]] }); + + window.open("window_popup_attribute.xhtml", "_blank", "width=600,height=800"); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_button.xhtml b/toolkit/content/tests/chrome/test_popup_button.xhtml new file mode 100644 index 0000000000..d6d77a82da --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_button.xhtml @@ -0,0 +1,33 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu Button Popup Tests" + onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<script> +SimpleTest.waitForExplicitFinish(); +async function runTest() +{ + // This test exercises non-native menu code. So disable native context menus for this test. + // If we ever get to a point where we don't use any non-native menus on macOS any more, we can + // disable this test on macOS. + await SpecialPowers.pushPrefEnv({ set: [["widget.macos.native-context-menus", false]] }); + + window.open("window_popup_button.xhtml", "_blank", "width=700,height=800"); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_coords.xhtml b/toolkit/content/tests/chrome/test_popup_coords.xhtml new file mode 100644 index 0000000000..bc9e042cc5 --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_coords.xhtml @@ -0,0 +1,92 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Coordinate Tests" + onload="setTimeout(openThePopup, 0, 'outer');" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<deck style="margin-top: 5px; padding-top: 5px;"> + <label id="outer" style="display: block" popup="outerpopup" value="Popup"/> +</deck> + +<panel id="outerpopup" + onpopupshowing="popupShowingEventOccurred(event);" + onpopupshown="eventOccurred(event); openThePopup('inner')" + onpopuphiding="eventOccurred(event);" + onpopuphidden="eventOccurred(event); SimpleTest.finish();"> + <button id="item1" label="First"/> + <label id="inner" value="Second" popup="innerpopup"/> + <button id="item2" label="Third"/> +</panel> + +<menupopup id="innerpopup" + onpopupshowing="popupShowingEventOccurred(event);" + onpopupshown="eventOccurred(event); event.target.hidePopup();" + onpopuphiding="eventOccurred(event);" + onpopuphidden="eventOccurred(event); document.getElementById('outerpopup').hidePopup();"> + <menuitem id="inner1" label="Inner First"/> + <menuitem id="inner2" label="Inner Second"/> +</menupopup> + +<script> +SimpleTest.waitForExplicitFinish(); + +function openThePopup(id) +{ + if (id == "inner") + document.getElementById("item1").focus(); + + var trigger = document.getElementById(id); + synthesizeMouse(trigger, 4, 5, { }); +} + +function eventOccurred(event) +{ + var testname = event.type + " on " + event.target.id + " "; + ok(MouseEvent.isInstance(event), testname + "is a mouse event"); + is(event.clientX, 0, testname + "clientX"); + is(event.clientY, 0, testname + "clientY"); + is(event.rangeParent, null, testname + "rangeParent"); + is(event.rangeOffset, 0, testname + "rangeOffset"); +} + +function popupShowingEventOccurred(event) +{ + // the popupshowing event should have the event coordinates and + // range position filled in. + var testname = "popupshowing on " + event.target.id + " "; + ok(MouseEvent.isInstance(event), testname + "is a mouse event"); + + var trigger = document.getElementById(event.target.id == "outerpopup" ? "outer" : "inner"); + var rect = trigger.getBoundingClientRect(); + is(event.clientX, Math.round(rect.left + 4), testname + "clientX"); + is(event.clientY, Math.round(rect.top + 5), testname + "clientY"); + // rangeOffset should be just at the trigger element, since they are labels + // they don't have any childrens so the offset should be zero. + is(event.rangeParent, trigger, testname + "rangeParent"); + is(event.rangeOffset, 0, testname + "rangeOffset"); + + var popuprect = event.target.getBoundingClientRect(); + var marginLeft = parseFloat(getComputedStyle(event.target).marginLeft); + var marginTop = parseFloat(getComputedStyle(event.target).marginTop); + is(Math.round(popuprect.left - marginLeft), Math.round(rect.left + 4), "popup left"); + is(Math.round(popuprect.top - marginTop), Math.round(rect.top + 5), "popup top"); + ok(popuprect.width > 0, "popup width"); + ok(popuprect.height > 0, "popup height"); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_keys.xhtml b/toolkit/content/tests/chrome/test_popup_keys.xhtml new file mode 100644 index 0000000000..6b8dd31143 --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_keys.xhtml @@ -0,0 +1,167 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menu ignorekeys Test" + onkeydown="keyDown()" onkeypress="gKeyPressCount++; event.stopPropagation(); event.preventDefault();" + onload="runTests();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<!-- + This test checks that the ignorekeys attribute can be used on a menu to + disable key navigation. The test is performed twice by opening the menu, + simulating a cursor down key, and closing the popup. When keys are enabled, + the first item on the menu should be highlighted, otherwise the first item + should not be highlighted. + --> + +<menupopup id="popup"> + <menuitem id="i1" label="One"/> + <menuitem id="i2" label="Two"/> + <menuitem id="i3" label="Three"/> + <menuitem id="i4" label="Four"/> +</menupopup> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +let gIgnoreKeys = false; +let gKeyPressCount = 0; +let gLastFirstMenuActiveValue = null; + + +function waitForEvent(target, eventName) { + return new Promise(resolve => { + target.addEventListener(eventName, function eventOccurred(event) { + resolve(); + }, { once: true}); + }); +} + +function runTests() +{ + function promiseFlushingMutationObserver() { + return new Promise(SimpleTest.executeSoon); + } + + (async function() { + const observer = new MutationObserver(checkIfFirstMenuItemActive); + observer.observe($("i1"), { attributes: true }); + + var popup = $("popup"); + is(popup.hasAttribute("ignorekeys"), false, "keys enabled"); + + let popupShownPromise = waitForEvent(popup, "popupshown"); + popup.openPopup(null, "after_start"); + await popupShownPromise; + + let popupHiddenPromise = waitForEvent(popup, "popuphidden"); + info("Synthesizing ArrowDown (no ignorekeys)..."); + synthesizeKey("KEY_ArrowDown"); + await popupHiddenPromise; + + is(gKeyPressCount, 0, "keypresses with ignorekeys='false'"); + + gIgnoreKeys = true; + popup.setAttribute("ignorekeys", "true"); + // clear this first to avoid confusion + observer.disconnect(); + $("i1").removeAttribute("_moz-menuactive") + await promiseFlushingMutationObserver(); + observer.observe($("i1"), { attributes: true }); + + popupShownPromise = waitForEvent(popup, "popupshown"); + popup.openPopup(null, "after_start"); + await popupShownPromise; + + info("Synthesizing ArrowDown (ignorekeys=\"true\")..."); + synthesizeKey("KEY_ArrowDown"); + + await new Promise(resolve => setTimeout(() => resolve(), 1000)); + popupHiddenPromise = waitForEvent(popup, "popuphidden"); + popup.hidePopup(); + await popupHiddenPromise; + + is(gKeyPressCount, 1, "keypresses with ignorekeys='true'"); + + popup.setAttribute("ignorekeys", "shortcuts"); + // clear this first to avoid confusion + observer.disconnect(); + $("i1").removeAttribute("_moz-menuactive") + await promiseFlushingMutationObserver(); + observer.observe($("i1"), { attributes: true }); + + popupShownPromise = waitForEvent(popup, "popupshown"); + popup.openPopup(null, "after_start"); + await popupShownPromise; + + // When ignorekeys="shortcuts", T should be handled but accel+T should propagate. + info("Synthesizing \"t\"..."); + sendString("t"); + is(gKeyPressCount, 1, "keypresses after t pressed with ignorekeys='shortcuts'"); + + info("Synthesizing Accel-T..."); + synthesizeKey("t", { accelKey: true }); + is(gKeyPressCount, 2, "keypresses after accel+t pressed with ignorekeys='shortcuts'"); + + popupHiddenPromise = waitForEvent(popup, "popuphidden"); + popup.hidePopup(); + await popupHiddenPromise; + + observer.disconnect(); + SimpleTest.finish(); + })(); +} + +function checkIfFirstMenuItemActive(aMutationList) { + for (const mutation of aMutationList) { + if (mutation.type != "attributes" || mutation.attributeName != "_moz-menuactive") { + continue; + } + + // the attribute should not be changed when ignorekeys is enabled + if (gIgnoreKeys) { + ok(false, "move key with keys disabled"); + return; + } + + is( + $("popup").hasAttribute("ignorekeys") + ? gLastFirstMenuActiveValue + : $("i1").getAttribute("_moz-menuactive"), + "true", + "move key with keys enabled" + ); + $("popup").hidePopup(); + gLastFirstMenuActiveValue = null; + break; + } +} + +function keyDown() { + // when keys are enabled, the menu should have stopped propagation of the + // event, so a bubbling listener for a keydown event should only occur + // when keys are disabled. + ok(gIgnoreKeys, "key listener fired with keys " + + (gIgnoreKeys ? "disabled" : "enabled")); + gLastFirstMenuActiveValue = $("i1").getAttribute("_moz-menuactive"); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_moveToAnchor.xhtml b/toolkit/content/tests/chrome/test_popup_moveToAnchor.xhtml new file mode 100644 index 0000000000..a7800caaad --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_moveToAnchor.xhtml @@ -0,0 +1,86 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<vbox align="start" style="margin-left: 80px;"> + <button id="button1" label="Button 1" style="margin-top: 80px;"/> + <button id="button2" label="Button 2" style="margin-top: 70px;"/> +</vbox> + +<menupopup id="popup" onpopupshown="popupshown()" onpopuphidden="SimpleTest.finish()"> + <menuitem label="One"/> + <menuitem label="Two"/> +</menupopup> + +<script> +SimpleTest.waitForExplicitFinish(); + +function runTest(id) +{ + $("popup").openPopup($("button1"), "after_start"); +} + +function popupshown() +{ + var popup = $("popup"); + var popupheight = popup.getBoundingClientRect().height; + var button1rect = $("button1").getBoundingClientRect(); + var button2rect = $("button2").getBoundingClientRect(); + var marginLeft = parseFloat(getComputedStyle(popup).marginLeft); + var marginTop = parseFloat(getComputedStyle(popup).marginTop); + + checkCoords(popup, button1rect.left + marginLeft, button1rect.bottom + marginTop, "initial"); + + popup.moveToAnchor($("button1"), "after_start", 0, 8); + checkCoords(popup, button1rect.left + marginLeft, button1rect.bottom + 8 + marginTop, "move anchor top + 8"); + + popup.moveToAnchor($("button1"), "after_start", 6, -10); + checkCoords(popup, button1rect.left + 6 + marginLeft, button1rect.bottom - 10 + marginTop, "move anchor left + 6, top - 10"); + + popup.moveToAnchor($("button1"), "before_start", -2, 0); + checkCoords(popup, button1rect.left - 2 + marginLeft, button1rect.top - popupheight - marginTop, "move anchor before_start"); + + popup.moveToAnchor($("button2"), "before_start"); + checkCoords(popup, button2rect.left + marginLeft, button2rect.top - popupheight - marginTop, "move button2"); + + popup.moveToAnchor($("button1"), "end_before"); + checkCoords(popup, button1rect.right + marginLeft, button1rect.top + marginTop, "move anchor end_before"); + + popup.moveToAnchor($("button2"), "after_start", 5, 4); + checkCoords(popup, button2rect.left + 5 + marginLeft, button2rect.bottom + 4 + marginTop, "move button2 left + 5, top + 4"); + + popup.moveTo($("button1").screenX + 10, $("button1").screenY + 12); + checkCoords(popup, button1rect.left + 10, button1rect.top + 12, "move to button1 screen with offset"); + + popup.moveToAnchor($("button1"), "after_start", 1, 2); + checkCoords(popup, button1rect.left + 1 + marginLeft, button1rect.bottom + 2 + marginTop, "move button2 after screen"); + + popup.hidePopup(); +} + +function checkCoords(popup, expectedx, expectedy, testid) +{ + var rect = popup.getBoundingClientRect(); + is(Math.round(rect.left), Math.round(expectedx), testid + " left"); + is(Math.round(rect.top), Math.round(expectedy), testid + " top"); +} + +SimpleTest.waitForFocus(runTest); + +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_preventdefault.xhtml b/toolkit/content/tests/chrome/test_popup_preventdefault.xhtml new file mode 100644 index 0000000000..916fb7eb86 --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_preventdefault.xhtml @@ -0,0 +1,76 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Prevent Default Tests" + onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<!-- + This tests checks that preventDefault can be called on a popupshowing + event and that preventDefault has no effect for the popuphiding event. + --> + +<script> +SimpleTest.waitForExplicitFinish(); + +var gBlockShowing = true; +var gShownNotAllowed = true; + +function runTest() +{ + document.getElementById("menu").open = true; +} + +function popupShowing(event) +{ + if (gBlockShowing) { + event.preventDefault(); + gBlockShowing = false; + setTimeout(function() { + gShownNotAllowed = false; + document.getElementById("menu").open = true; + }, 3000, true); + } +} + +function popupShown() +{ + ok(!gShownNotAllowed, "popupshowing preventDefault"); + document.getElementById("menu").open = false; +} + +function popupHiding(event) +{ + // since this is a content test, preventDefault should have no effect + event.preventDefault(); +} + +function popupHidden() +{ + ok(true, "popuphiding preventDefault not allowed"); + SimpleTest.finish(); +} +</script> + +<button id="menu" type="menu" label="Menu"> + <menupopup onpopupshowing="popupShowing(event);" + onpopupshown="popupShown();" + onpopuphiding="popupHiding(event);" + onpopuphidden="popupHidden();"> + <menuitem label="Item"/> + </menupopup> +</button> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_preventdefault_chrome.xhtml b/toolkit/content/tests/chrome/test_popup_preventdefault_chrome.xhtml new file mode 100644 index 0000000000..d3e62d05c7 --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_preventdefault_chrome.xhtml @@ -0,0 +1,28 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Attribute Tests" + onload="setTimeout(runTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + +<script> +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_popup_preventdefault_chrome.xhtml", "_blank", "chrome,width=600,height=600,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_recreate.xhtml b/toolkit/content/tests/chrome/test_popup_recreate.xhtml new file mode 100644 index 0000000000..b1922ea51b --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_recreate.xhtml @@ -0,0 +1,93 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Recreate Test" + onload="setTimeout(init, 0)" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<!-- + This is a test for bug 388361. + + This test checks that a menulist's popup is properly created and sized when + the popup node is removed and another added in its place. + + --> + +<script> +<![CDATA[ +SimpleTest.waitForExplicitFinish(); + +var gState = "before"; + +function init() +{ + document.getElementById("menulist").open = true; +} + +function isWithinHalfPixel(a, b) +{ + return Math.abs(a - b) <= 0.5; +} + +const ismac = navigator.platform.indexOf("Mac") == 0; +function inputMargin(el) { + let cs = getComputedStyle(el); + // XXX Internal properties are not exposed in getComputedStyle, so we have to + // use margin and rely on our knowledge of them matching negative margins + // where appropriate. + // return parseFloat(cs.getPropertyValue("-moz-window-input-region-margin")); + return ismac ? 0 : Math.max(-parseFloat(cs.marginLeft), 0); +} + +function recreate() +{ + if (gState == "before") { + var element = document.getElementById("menulist"); + while (element.hasChildNodes()) + element.firstChild.remove(); + element.appendItem("Cat"); + gState = "after"; + document.getElementById("menulist").open = true; + } + else { + SimpleTest.finish(); + } +} + +function checkSize() +{ + var menulist = document.getElementById("menulist"); + var menurect = menulist.getBoundingClientRect(); + var popuprect = menulist.menupopup.getBoundingClientRect(); + + let marginLeft = parseFloat(getComputedStyle(menulist.menupopup).marginLeft); + ok(isWithinHalfPixel(menurect.left + marginLeft, popuprect.left), "left position " + gState); + ok(isWithinHalfPixel(menurect.right + marginLeft + 2 * inputMargin(menulist.menupopup), popuprect.right), "right position " + gState); + ok(Math.round(popuprect.right) - Math.round(popuprect.left) > 0, "height " + gState) + document.getElementById("menulist").open = false; +} +]]> +</script> + +<hbox align="center" pack="center"> + <menulist id="menulist" onpopupshown="checkSize();" onpopuphidden="recreate();" style="width: 200px"> + <menupopup position="after_start"> + <menuitem label="Cat"/> + </menupopup> + </menulist> +</hbox> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_scaled.xhtml b/toolkit/content/tests/chrome/test_popup_scaled.xhtml new file mode 100644 index 0000000000..eda4d7231e --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_scaled.xhtml @@ -0,0 +1,98 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popups in Scaled Content" + onload="setTimeout(runTests, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<!-- This test checks that the position is correct in two cases: + - a popup anchored at an element in a scaled document + - a popup opened at a screen coordinate in a scaled window + --> + +<iframe id="frame" width="60" height="140" + src="data:text/html,<html><body><input size='4' id='one'><input size='4' id='two'></body></html>"/> + +<menupopup id="popup" onpopupshown="shown()" onpopuphidden="nextTest()"> + <menuitem label="One"/> +</menupopup> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +var screenTest = false; +var screenx = -1, screeny = -1; + +SimpleTest.waitForExplicitFinish(); + +function runTests() +{ + setScale($("frame").contentWindow, 2); + + var anchor = $("frame").contentDocument.getElementById("two"); + anchor.getBoundingClientRect(); // flush to update display after scale change + $("popup").openPopup(anchor, "after_start"); +} + +function setScale(win, scale) +{ + SpecialPowers.setFullZoom(win, scale); +} + +function shown() +{ + var popup = $("popup"); + var marginLeft = parseFloat(getComputedStyle(popup).marginLeft); + var marginTop = parseFloat(getComputedStyle(popup).marginTop); + if (screenTest) { + is(popup.screenX - marginLeft, screenx, "screen left position"); + is(popup.screenY - marginTop, screeny, "screen top position"); + } else { + var anchor = $("frame").contentDocument.getElementById("two"); + is(Math.round(anchor.getBoundingClientRect().left * 2), + Math.round(popup.getBoundingClientRect().left - marginLeft), "anchored left position"); + is(Math.round(anchor.getBoundingClientRect().bottom * 2), + Math.round(popup.getBoundingClientRect().top - marginTop), "anchored top position"); + } + + popup.hidePopup(); +} + +function nextTest() +{ + if (screenTest) { + setScale(window, 1); + SimpleTest.finish(); + } + else { + screenTest = true; + var rootElement = document.documentElement; + + setScale(window, 2); + // - the iframe will be at 4×, but out here css pixels are only 2× device pixels + + requestAnimationFrame(() => requestAnimationFrame(() => { + screenx = rootElement.screenX + 20; + screeny = rootElement.screenY + 20; + $("popup").openPopupAtScreen(screenx, screeny); + })); + } +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popup_tree.xhtml b/toolkit/content/tests/chrome/test_popup_tree.xhtml new file mode 100644 index 0000000000..23a37e339d --- /dev/null +++ b/toolkit/content/tests/chrome/test_popup_tree.xhtml @@ -0,0 +1,72 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Tree in Popup Test" + onload="setTimeout(runTests, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<panel id="panel" onpopupshown="treeClick()" onpopuphidden="SimpleTest.finish()"> + <tree id="tree" width="350" rows="5"> + <treecols> + <treecol id="name" label="Name" flex="1"/> + <treecol id="address" label="Street" flex="1"/> + </treecols> + <treechildren id="treechildren"> + <treeitem> + <treerow> + <treecell label="Justin Thyme"/> + <treecell label="800 Bay Street"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Mary Goround"/> + <treecell label="47 University Avenue"/> + </treerow> + </treeitem> + </treechildren> + </tree> +</panel> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function runTests() +{ + $("panel").openPopup(null, "overlap", 2, 2); +} + +function treeClick() +{ + var tree = $("tree"); + is(tree.currentIndex, -1, "selectedIndex before click"); + synthesizeMouseExpectEvent($("treechildren"), 2, 2, { }, $("treechildren"), "click", ""); + is(tree.currentIndex, 0, "selectedIndex after click"); + + var rect = tree.getCoordsForCellItem(1, tree.columns.address, ""); + synthesizeMouseExpectEvent($("treechildren"), rect.x, rect.y + 2, + { }, $("treechildren"), "click", ""); + is(tree.currentIndex, 1, "selectedIndex after second click " + rect.x + "," + rect.y); + + $("panel").hidePopup(); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popuphidden.xhtml b/toolkit/content/tests/chrome/test_popuphidden.xhtml new file mode 100644 index 0000000000..aa4b5aff1e --- /dev/null +++ b/toolkit/content/tests/chrome/test_popuphidden.xhtml @@ -0,0 +1,86 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Hidden Popup Test" + onload="setTimeout(runTests, 0, $('popup'));" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<menupopup id="popup" hidden="true" onpopupshown="ok(true, 'popupshown'); this.hidePopup()" + onpopuphidden="$('popup-hideonshow').openPopup(null, 'after_start')"> + <menuitem id="i1" label="One"/> + <menuitem id="i2" label="Two"/> +</menupopup> + +<menupopup id="popup-hideonshow" onpopupshowing="hidePopupWhileShowing(this)" + onpopupshown="ok(false, 'popupshown when hidden')"> + <menuitem id="i1" label="One"/> + <menuitem id="i2" label="Two"/> +</menupopup> + +<button id="button" type="menu" label="Menu"> + <menupopup id="popupinbutton" hidden="true" + onpopupshown="ok(true, 'popupshown'); ok($('button').open, 'open'); this.hidden = true;"> + <menuitem id="i1" label="One"/> + <menuitem id="i2" label="Two"/> + </menupopup> +</button> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function runTests(popup) +{ + const observer = new MutationObserver(checkEndTest); + observer.observe($("button"), { attributes: true }); + popup.hidden = false; + popup.openPopup(null, "after_start"); +} + +function hidePopupWhileShowing(popup) +{ + popup.hidden = true; + popup.clientWidth; // flush layout + is(popup.state, 'closed', 'popupshowing hidden'); + SimpleTest.executeSoon(() => runTests($('popupinbutton'))); +} + +let finished = false; +function checkEndTest(aMutationList, aObserver) +{ + if (finished) { + return; // XXX I don't know why this is necessary. + } + const button = $("button"); + for (const mutation of aMutationList) { + if (mutation.attributeName != "open" || button.hasAttribute("open")) { + continue; + } + + ok($("popupinbutton").hidden, "popup hidden"); + is($("popupinbutton").state, "closed", "popup state"); + ok(!button.open, "not open after hidden"); + aObserver.disconnect(); + SimpleTest.finish(); + finished = true; + return; + } +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popupincontent.xhtml b/toolkit/content/tests/chrome/test_popupincontent.xhtml new file mode 100644 index 0000000000..9721566372 --- /dev/null +++ b/toolkit/content/tests/chrome/test_popupincontent.xhtml @@ -0,0 +1,137 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup in Content Positioning Tests" + onload="setTimeout(nextTest, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<!-- + This test checks that popups in content areas don't extend past the content area. + --> + +<hbox> + <spacer width="100"/> + <menu id="menu" label="Menu"> + <menupopup style="margin:10px;-moz-window-input-region-margin:0;" id="popup" onpopupshown="popupShown()" onpopuphidden="nextTest()"> + <menuitem label="One"/> + <menuitem label="Two"/> + <menuitem label="Three"/> + <menuitem label="A final longer label that is actually quite long. Very long indeed."/> + </menupopup> + </menu> +</hbox> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var step = ""; +var originalHeight = -1; + +function nextTest() +{ + // there are five tests here: + // openPopupAtScreen - checks that opening a popup using openPopupAtScreen + // constrains the popup to the content area + // left and top - check with the left and top attributes set + // open near bottom - open the menu near the bottom of the window + // large menu - try with a menu that is very large and should be scaled + // shorter menu again - try with a menu that is shorter again. It should have + // the same height as the 'left and top' test + var popup = $("popup"); + var menu = $("menu"); + switch (step) { + case "": + step = "openPopupAtScreen"; + popup.openPopupAtScreen(1000, 1200); + break; + case "openPopupAtScreen": + step = "left and top"; + popup.setAttribute("left", "800"); + popup.setAttribute("top", "2900"); + synthesizeMouse(menu, 2, 2, { }); + break; + case "left and top": + step = "open near bottom"; + // request that the menu be opened with a target point near the bottom of the window, + // so that the menu's top margin will push it completely outside the window. + popup.setAttribute("top", document.documentElement.screenY + window.innerHeight - 5); + synthesizeMouse(menu, 2, 2, { }); + break; + case "open near bottom": + step = "large menu"; + popup.removeAttribute("left"); + popup.removeAttribute("top"); + for (let i = 0; i < 80; i++) + menu.appendItem("Test", ""); + synthesizeMouse(menu, 2, 2, { }); + break; + case "large menu": + step = "shorter menu again"; + for (let i = 0; i < 80; i++) + popup.lastChild.remove(); + synthesizeMouse(menu, 2, 2, { }); + break; + case "shorter menu again": + SimpleTest.finish(); + break; + } +} + +async function popupShown() +{ + // Popup may have wrong initial size in non e10s mode tests, because + // layout is not yet ready for popup content lazy population on + // popupshowing event. + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(r)) + ); + + var windowrect = document.documentElement.getBoundingClientRect(); + var popuprect = $("popup").getBoundingClientRect(); + + // subtract one off the edge due to a rounding issue + ok(popuprect.left >= windowrect.left, step + " left"); + ok(popuprect.right - 1 <= windowrect.right, step + " right"); + + if (step == "left and top") { + originalHeight = popuprect.bottom - popuprect.top; + } + else if (step == "open near bottom") { + // check that the menu flipped up so it's above our requested point + ok(popuprect.bottom - 1 <= windowrect.bottom - 5, step + " bottom"); + } + else if (step == "large menu") { + // add 10 to account for the margin + is(popuprect.top, $("menu").getBoundingClientRect().bottom + 10, step + " top"); + ok(popuprect.bottom == windowrect.bottom || + popuprect.bottom - 1 == windowrect.bottom, step + " bottom"); + } + else { + ok(popuprect.top >= windowrect.top, step + " top"); + ok(popuprect.bottom - 1 <= windowrect.bottom, step + " bottom"); + if (step == "shorter menu again") + is(popuprect.bottom - popuprect.top, originalHeight, step + " height shortened"); + } + + $("menu").open = false; +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_popupremoving.xhtml b/toolkit/content/tests/chrome/test_popupremoving.xhtml new file mode 100644 index 0000000000..babd62c18b --- /dev/null +++ b/toolkit/content/tests/chrome/test_popupremoving.xhtml @@ -0,0 +1,180 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Removing Tests" + onload="setTimeout(nextTest, 0)" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<!-- + This test checks that popup elements can be removed in various ways without + crashing. It tests two situations, one with menus that are 'separate', and + one with menus that are 'nested'. In each case, there are four levels of menu. + + The nextTest function starts the process by opening the first menu. A set of + popupshown event listeners are used to open the next menu until all four are + showing. This last one calls removePopup to remove the menu node from the + tree. This should hide the popups as they are no longer in a document. + + A mutation listener is triggered when the fourth menu closes by having its + open attribute cleared. This listener hides the third popup which causes + its frame to be removed. Naturally, we want to ensure that this doesn't + crash when the third menu is removed. + --> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<hbox> + +<menu id="nestedmenu1" label="1"> + <menupopup id="nestedpopup1" onpopupshown="if (event.target == this) this.firstChild.open = true"> + <menu id="nestedmenu2" label="2"> + <menupopup id="nestedpopup2" onpopupshown="if (event.target == this) this.firstChild.open = true"> + <menu id="nestedmenu3" label="3"> + <menupopup id="nestedpopup3" onpopupshown="if (event.target == this) this.firstChild.open = true"> + <menu id="nestedmenu4" label="4"> + <menupopup id="nestedpopup4"> + <menuitem label="Nested 1"/> + <menuitem label="Nested 2"/> + <menuitem label="Nested 3"/> + </menupopup> + </menu> + </menupopup> + </menu> + </menupopup> + </menu> + </menupopup> +</menu> + +<menu id="separatemenu1" label="1"> + <menupopup id="separatepopup1" onpopupshown="$('separatemenu2').open = true"> + <menuitem label="L1 One"/> + <menuitem label="L1 Two"/> + <menuitem label="L1 Three"/> + </menupopup> +</menu> + +<menu id="separatemenu2" label="2"> + <menupopup id="separatepopup2" onpopupshown="$('separatemenu3').open = true" + onpopuphidden="popup2Hidden()"> + <menuitem label="L2 One"/> + <menuitem label="L2 Two"/> + <menuitem label="L2 Three"/> + </menupopup> +</menu> + +<menu id="separatemenu3" label="3" onpopupshown="$('separatemenu4').open = true"> + <menupopup id="separatepopup3"> + <menuitem label="L3 One"/> + <menuitem label="L3 Two"/> + <menuitem label="L3 Three"/> + </menupopup> +</menu> + +<menu id="separatemenu4" label="4" onpopuphidden="$('separatemenu2').open = false"> + <menupopup id="separatepopup3"> + <menuitem label="L4 One"/> + <menuitem label="L4 Two"/> + <menuitem label="L4 Three"/> + </menupopup> +</menu> + +</hbox> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +let gKey = ""; +let gTriggerMutation = null; +let gChangeMutation = null; +let gResolveRemovePopups = null; +let gResolvePopup2Hidden = null; + +function nextTest() +{ + let promiseRemovePopups, promisePopup2Hidden; + if (gKey == "") { + gKey = "separate"; + $("separatemenu4").addEventListener("popupshown", removePopups); + promiseRemovePopups = new Promise(resolve => gResolveRemovePopups = resolve); + promisePopup2Hidden = new Promise(resolve => gResolvePopup2Hidden = resolve); + } + else if (gKey == "separate") { + gKey = "nested"; + $("nestedmenu4").addEventListener("popupshown", removePopups); + promiseRemovePopups = new Promise(resolve => gResolveRemovePopups = resolve); + gResolvePopup2Hidden = null; + } + else { + SimpleTest.finish(); + return; + } + + SimpleTest.executeSoon(async () => { + $(gKey + "menu1").open = true; + await Promise.all([promiseRemovePopups, promisePopup2Hidden]); + nextTest(); + }); +} + +function modified(aMutationList, aObserver) { + // use this mutation listener to hide the third popup, destroying its frame. + // It gets triggered when the open attribute is cleared on the fourth menu. + for (const mutation of aMutationList) { + if (mutation.attributeName != "open") { + continue; + } + gChangeMutation.hidden = true; + // force a layout flush + document.documentElement.clientWidth; + gChangeMutation = null; + aObserver.disconnect(); + break; + } +} + +async function removePopups() +{ + var menu2 = $(gKey + "menu2"); + var menu3 = $(gKey + "menu3"); + is(menu2.getAttribute("open"), "true", gKey + " menu 2 open before"); + is(menu3.getAttribute("open"), "true", gKey + " menu 3 open before"); + + const observer = new MutationObserver(modified); + observer.observe(menu3, { attributes: true }); + gChangeMutation = $(gKey + "menu4"); + var menu = $(gKey + "menu1"); + menu.remove(); + const key = gKey; + await new Promise (SimpleTest.executeSoon); + if (key == "nested") { + // the 'separate' test checks this during the popup2 hidden event handler + is(menu2.hasAttribute("open"), false, gKey + " menu 2 open after"); + is(menu3.hasAttribute("open"), false, gKey + " menu 3 open after"); + } + gResolveRemovePopups(); +} + +function popup2Hidden() { + is($(gKey + "menu2").hasAttribute("open"), false, gKey + " menu 2 open after"); + if (gResolvePopup2Hidden) { + gResolvePopup2Hidden(); + } +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_position.xhtml b/toolkit/content/tests/chrome/test_position.xhtml new file mode 100644 index 0000000000..8c9a0e1b61 --- /dev/null +++ b/toolkit/content/tests/chrome/test_position.xhtml @@ -0,0 +1,130 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for positioning + --> +<window title="position" width="500" height="600" + onload="setTimeout(runTest, 0);" + style="margin: 0; border: 0; padding; 0;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + +<hbox id="box1"> + <button label="0" style="width: 100px; height: 40px; margin: 3px;"/> +</hbox> +<scrollbox id="box2" orient="vertical" align="start" + style="height: 50px; overflow: hidden; margin-left: 2px; padding: 1px;"> + <deck> + <scrollbox id="box3" orient="vertical" align="start" + style="height: 100px; overflow: scroll; margin: 1px; padding: 0;"> + <vbox id="innerscroll" style="width: 200px" align="start"> + <button id="button1" label="1" style="width: 90px; max-width: 100px; min-width: 80px; min-height: 25px; height: 35px; max-height: 50px; margin: 5px; border: 4px; padding: 7px; appearance: none;"/> + <menu id="menu"> + <menupopup id="popup" style="appearance: none; margin:0; border: 0; padding: 0;" + onpopupshown="menuOpened()" + onpopuphidden="if (event.target == this) SimpleTest.finish()"> + <menuitem label="One"/> + <menu id="submenu" label="Three"> + <menupopup id="subpopup" style="appearance: none; margin:0; border: 0; padding: 0;" + onpopupshown="submenuOpened()"> + <menuitem label="Four"/> + </menupopup> + </menu> + </menupopup> + </menu> + <button label="2" style="max-width: 100px; max-height: 20px; margin: 5px;"/> + <button label="3" style="max-width: 100px; max-height: 20px; margin: 5px;"/> + <button label="4" style="max-width: 100px; max-height: 20px; margin: 5px;"/> + </vbox> + <box style="height: 200px"/> + </scrollbox> + </deck> +</scrollbox> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +<script> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function runTest() +{ + var winwidth = document.documentElement.getBoundingClientRect().width; + + var box1 = $("box1"); + checkPosition("box1", box1, 0, 0, winwidth, 46); + + var box2 = $("box2"); + checkPosition("box2", box2, 2, 46, winwidth, 96); + + // height is height(box1) = 46 + margin-top(box3) = 1 + margin-top(button1) = 5 + var button1 = $("button1"); + checkPosition("button1", button1, 9, 53, 99, 88); + + box2.scrollTo(7, 16); + + // clientRect height is offset from root so is 16 pixels vertically less + checkPosition("button1 scrolled", button1, 9, 37, 99, 72); + + var box3 = $("box3"); + box3.scrollTo(1, 2); + + checkPosition("button1 scrolled", button1, 9, 35, 99, 70); + + $("menu").open = true; +} + +function menuOpened() +{ + $("submenu").open = true; +} + +function submenuOpened() +{ + var menu = $("menu"); + var menuleft = Math.round(menu.getBoundingClientRect().left); + var menubottom = Math.round(menu.getBoundingClientRect().bottom); + + var submenu = $("submenu"); + var submenutop = Math.round(submenu.getBoundingClientRect().top); + var submenuright = Math.round(submenu.getBoundingClientRect().right); + + checkPosition("popup", $("popup"), menuleft, menubottom, -1, -1); + checkPosition("subpopup", $("subpopup"), submenuright, submenutop, -1, -1); + + menu.open = false; +} + +function checkPosition(testid, elem, cleft, ctop, cright, cbottom) +{ + // -1 for right or bottom means that the exact size should not be + // checked, just ensure it is larger then the left or top position + var rect = elem.getBoundingClientRect(); + is(Math.round(rect.left), cleft, testid + " client rect left"); + if (testid != "popup") + is(Math.round(rect.top), ctop, testid + " client rect top"); + if (cright >= 0) + is(Math.round(rect.right), cright, testid + " client rect right"); + else + ok(rect.right - rect.left > 20, testid + " client rect right"); + if (cbottom >= 0) + is(Math.round(rect.bottom), cbottom, testid + " client rect bottom"); + else + ok(rect.bottom - rect.top > 15, testid + " client rect bottom"); +} + +]]> + +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_preferences.xhtml b/toolkit/content/tests/chrome/test_preferences.xhtml new file mode 100644 index 0000000000..0b3ddf88be --- /dev/null +++ b/toolkit/content/tests/chrome/test_preferences.xhtml @@ -0,0 +1,525 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Preferences Window Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="RunTest();"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + + <script type="application/javascript"> + <![CDATA[ + SimpleTest.waitForExplicitFinish(); + + const kPref = SpecialPowers.Services.prefs; + + // preference values, set 1 + const kPrefValueSet1 = { + int: 23, + bool: true, + string: "rheeet!", + unichar: "äöüßÄÖÜ", + wstring_data: "日本語", + file_data: "/", + + wstring: Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ), + file: Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile), + }; + kPrefValueSet1.wstring.data = kPrefValueSet1.wstring_data; + SafeFileInit(kPrefValueSet1.file, kPrefValueSet1.file_data); + + // preference values, set 2 + const kPrefValueSet2 = { + int: 42, + bool: false, + string: "Mozilla", + unichar: "áôùšŽ", + wstring_data: "헤드라인A", + file_data: "/home", + + wstring: Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ), + file: Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile), + }; + kPrefValueSet2.wstring.data = kPrefValueSet2.wstring_data; + SafeFileInit(kPrefValueSet2.file, kPrefValueSet2.file_data); + + function SafeFileInit(aFile, aPath) { + // set file path without dying for exceptions + try { + aFile.initWithPath(aPath); + } catch {} + } + + function CreateEmptyPrefValueSet() { + var result = { + int: undefined, + bool: undefined, + string: undefined, + unichar: undefined, + wstring_data: undefined, + file_data: undefined, + wstring: undefined, + file: undefined, + }; + return result; + } + + function WritePrefsToSystem(aPrefValueSet) { + // write preference data via XPCOM + kPref.setIntPref("tests.static_preference_int", aPrefValueSet.int); + kPref.setBoolPref("tests.static_preference_bool", aPrefValueSet.bool); + kPref.setCharPref("tests.static_preference_string", aPrefValueSet.string); + kPref.setStringPref("tests.static_preference_unichar", aPrefValueSet.unichar); + kPref.setComplexValue( + "tests.static_preference_wstring", + Ci.nsIPrefLocalizedString, + aPrefValueSet.wstring + ); + kPref.setComplexValue( + "tests.static_preference_file", + Ci.nsIFile, + aPrefValueSet.file + ); + } + + function ReadPrefsFromSystem() { + // read preference data via XPCOM + var result = CreateEmptyPrefValueSet(); + // eslint-disable-next-line mozilla/use-default-preference-values + try { + result.int = kPref.getIntPref("tests.static_preference_int"); + } catch {} + // eslint-disable-next-line mozilla/use-default-preference-values + try { + result.bool = kPref.getBoolPref("tests.static_preference_bool"); + } catch {} + // eslint-disable-next-line mozilla/use-default-preference-values + try { + result.string = kPref.getCharPref("tests.static_preference_string"); + } catch {} + try { + result.unichar = kPref.getStringPref("tests.static_preference_unichar"); + } catch {} + try { + result.wstring = kPref.getComplexValue( + "tests.static_preference_wstring", + Ci.nsIPrefLocalizedString + ); + result.wstring_data = result.wstring.data; + } catch {} + try { + result.file = kPref.getComplexValue( + "tests.static_preference_file", + Ci.nsIFile + ); + result.file_data = result.file.data; + } catch {} + return result; + } + + function GetXULElement(aPrefWindow, aID) { + return aPrefWindow.document.getElementById(aID); + } + + function GetPreference(aPrefWindow, aID) { + return aPrefWindow.Preferences.get(aID); + } + + function WritePrefsToPreferences(aPrefWindow, aPrefValueSet) { + // write preference data into Preference instances + GetPreference(aPrefWindow, "tests.static_preference_int").value = + aPrefValueSet.int; + GetPreference(aPrefWindow, "tests.static_preference_bool").value = + aPrefValueSet.bool; + GetPreference(aPrefWindow, "tests.static_preference_string").value = + aPrefValueSet.string; + GetPreference(aPrefWindow, "tests.static_preference_unichar").value = + aPrefValueSet.unichar; + GetPreference(aPrefWindow, "tests.static_preference_wstring").value = + aPrefValueSet.wstring_data; + GetPreference(aPrefWindow, "tests.static_preference_file").value = + aPrefValueSet.file_data; + } + + function ReadPrefsFromPreferences(aPrefWindow) { + // read preference data from Preference instances + var result = { + int: GetPreference(aPrefWindow, "tests.static_preference_int").value, + bool: GetPreference(aPrefWindow, "tests.static_preference_bool").value, + string: GetPreference(aPrefWindow, "tests.static_preference_string").value, + unichar: GetPreference(aPrefWindow, "tests.static_preference_unichar") + .value, + wstring_data: GetPreference(aPrefWindow, "tests.static_preference_wstring") + .value, + file_data: GetPreference(aPrefWindow, "tests.static_preference_file").value, + wstring: Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ), + file: Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile), + }; + result.wstring.data = result.wstring_data; + SafeFileInit(result.file, result.file_data); + return result; + } + + function WritePrefsToUI(aPrefWindow, aPrefValueSet) { + // write preference data into UI elements + GetXULElement(aPrefWindow, "static_element_int").value = aPrefValueSet.int; + GetXULElement(aPrefWindow, "static_element_bool").checked = + aPrefValueSet.bool; + GetXULElement(aPrefWindow, "static_element_string").value = + aPrefValueSet.string; + GetXULElement(aPrefWindow, "static_element_unichar").value = + aPrefValueSet.unichar; + GetXULElement(aPrefWindow, "static_element_wstring").value = + aPrefValueSet.wstring_data; + GetXULElement(aPrefWindow, "static_element_file").value = + aPrefValueSet.file_data; + } + + function ReadPrefsFromUI(aPrefWindow) { + // read preference data from Preference instances + var result = { + int: GetXULElement(aPrefWindow, "static_element_int").value, + bool: GetXULElement(aPrefWindow, "static_element_bool").checked, + string: GetXULElement(aPrefWindow, "static_element_string").value, + unichar: GetXULElement(aPrefWindow, "static_element_unichar").value, + wstring_data: GetXULElement(aPrefWindow, "static_element_wstring").value, + file_data: GetXULElement(aPrefWindow, "static_element_file").value, + wstring: Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ), + file: Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile), + }; + result.wstring.data = result.wstring_data; + SafeFileInit(result.file, result.file_data); + return result; + } + + function RunInstantPrefTest(aPrefWindow) { + // remark: there's currently no UI element binding for files + + // were all Preference instances correctly initialized? + var expected = kPrefValueSet1; + var found = ReadPrefsFromPreferences(aPrefWindow); + ok(found.int === expected.int, "instant pref init int"); + ok(found.bool === expected.bool, "instant pref init bool"); + ok(found.string === expected.string, "instant pref init string"); + ok(found.unichar === expected.unichar, "instant pref init unichar"); + ok(found.wstring_data === expected.wstring_data, "instant pref init wstring"); + todo(found.file_data === expected.file_data, "instant pref init file"); + + // were all elements correctly initialized? (loose check) + found = ReadPrefsFromUI(aPrefWindow); + is(found.int, "" + expected.int, "instant element init int"); + is(found.bool, expected.bool, "instant element init bool"); + is(found.string, expected.string, "instant element init string"); + is(found.unichar, expected.unichar, "instant element init unichar"); + is( + found.wstring_data, expected.wstring_data, + "instant element init wstring" + ); + todo_is(found.file_data == expected.file_data, "instant element init file"); + + // do some changes in the UI + expected = kPrefValueSet2; + WritePrefsToUI(aPrefWindow, expected); + + // UI changes should get passed to the Preference instances, + // but currently they aren't if the changes are made programmatically + // (the handlers preference.change/prefpane.input and prefpane.change + // are called for manual changes, though). + found = ReadPrefsFromPreferences(aPrefWindow); + todo(found.int === expected.int, "instant change pref int"); + todo(found.bool === expected.bool, "instant change pref bool"); + todo(found.string === expected.string, "instant change pref string"); + todo(found.unichar === expected.unichar, "instant change pref unichar"); + todo( + found.wstring_data === expected.wstring_data, + "instant change pref wstring" + ); + todo(found.file_data === expected.file_data, "instant change pref file"); + + // and these changes should get passed to the system instantly + // (which obviously can't pass with the above failing) + found = ReadPrefsFromSystem(); + todo(found.int === expected.int, "instant change element int"); + todo(found.bool === expected.bool, "instant change element bool"); + todo(found.string === expected.string, "instant change element string"); + todo(found.unichar === expected.unichar, "instant change element unichar"); + todo( + found.wstring_data === expected.wstring_data, + "instant change element wstring" + ); + todo(found.file_data === expected.file_data, "instant change element file"); + + // try resetting the prefs to default values (which should be empty here) + GetPreference(aPrefWindow, "tests.static_preference_int").reset(); + GetPreference(aPrefWindow, "tests.static_preference_bool").reset(); + GetPreference(aPrefWindow, "tests.static_preference_string").reset(); + GetPreference(aPrefWindow, "tests.static_preference_unichar").reset(); + GetPreference(aPrefWindow, "tests.static_preference_wstring").reset(); + GetPreference(aPrefWindow, "tests.static_preference_file").reset(); + + // check system + expected = CreateEmptyPrefValueSet(); + found = ReadPrefsFromSystem(); + ok(found.int === expected.int, "instant reset system int"); + ok(found.bool === expected.bool, "instant reset system bool"); + ok(found.string === expected.string, "instant reset system string"); + ok(found.unichar === expected.unichar, "instant reset system unichar"); + ok( + found.wstring_data === expected.wstring_data, + "instant reset system wstring" + ); + ok(found.file_data === expected.file_data, "instant reset system file"); + + // check UI + expected = { + // alas, we don't have XUL elements with typeof(value) == int :( + // int: 0, + int: "", + bool: false, + string: "", + unichar: "", + wstring_data: "", + file_data: "", + wstring: {}, + file: {}, + }; + found = ReadPrefsFromUI(aPrefWindow); + ok(found.int === expected.int, "instant reset element int"); + ok(found.bool === expected.bool, "instant reset element bool"); + ok(found.string === expected.string, "instant reset element string"); + ok(found.unichar === expected.unichar, "instant reset element unichar"); + ok( + found.wstring_data === expected.wstring_data, + "instant reset element wstring" + ); + ok(found.file_data === expected.file_data, "instant reset element file"); + + // check hasUserValue + ok( + !GetPreference(aPrefWindow, "tests.static_preference_int").hasUserValue, + "instant reset hasUserValue int" + ); + ok( + !GetPreference(aPrefWindow, "tests.static_preference_bool").hasUserValue, + "instant reset hasUserValue bool" + ); + ok( + !GetPreference(aPrefWindow, "tests.static_preference_string").hasUserValue, + "instant reset hasUserValue string" + ); + ok( + !GetPreference(aPrefWindow, "tests.static_preference_unichar").hasUserValue, + "instant reset hasUserValue unichar" + ); + ok( + !GetPreference(aPrefWindow, "tests.static_preference_wstring").hasUserValue, + "instant reset hasUserValue wstring" + ); + ok( + !GetPreference(aPrefWindow, "tests.static_preference_file").hasUserValue, + "instant reset hasUserValue file" + ); + } + + function RunCheckCommandRedirect(aPrefWindow) { + ok( + GetPreference(aPrefWindow, "tests.static_preference_bool").value, + "redirected command bool" + ); + GetXULElement(aPrefWindow, "checkbox").click(); + ok( + !GetPreference(aPrefWindow, "tests.static_preference_bool").value, + "redirected command bool" + ); + GetXULElement(aPrefWindow, "checkbox").click(); + ok( + GetPreference(aPrefWindow, "tests.static_preference_bool").value, + "redirected command bool" + ); + } + + function RunCheckDisabled(aPrefWindow) { + ok( + !GetXULElement(aPrefWindow, "disabled_checkbox").disabled, + "Checkbox should be enabled" + ); + GetPreference( + aPrefWindow, + "tests.disabled_preference_bool" + ).updateControlDisabledState(true); + ok( + GetXULElement(aPrefWindow, "disabled_checkbox").disabled, + "Checkbox should be disabled" + ); + GetPreference( + aPrefWindow, + "tests.locked_preference_bool" + ).updateControlDisabledState(false); + ok( + GetXULElement(aPrefWindow, "locked_checkbox").disabled, + "Locked checkbox should stay disabled" + ); + SimpleTest.finish(); + } + + function RunResetPrefTest(aPrefWindow) { + // try resetting the prefs to default values + GetPreference(aPrefWindow, "tests.static_preference_int").reset(); + GetPreference(aPrefWindow, "tests.static_preference_bool").reset(); + GetPreference(aPrefWindow, "tests.static_preference_string").reset(); + GetPreference(aPrefWindow, "tests.static_preference_unichar").reset(); + GetPreference(aPrefWindow, "tests.static_preference_wstring").reset(); + GetPreference(aPrefWindow, "tests.static_preference_file").reset(); + } + + function RunTestApplyPref() { + // Test in parent window. + WritePrefsToSystem(kPrefValueSet1); + window.browsingContext.topChromeWindow.openDialog( + "window_preferences.xhtml", + "", + "modal", + RunInstantPrefTest, + false + ); + + // Test deferred reset in child window. + WritePrefsToSystem(kPrefValueSet1); + window.browsingContext.topChromeWindow.openDialog( + "window_preferences2.xhtml", + "", + "modal", + RunResetPrefTest, + false + ); + let expected = kPrefValueSet1; + let found = ReadPrefsFromSystem(); + is(found.int, expected.int, "instant reset deferred int"); + is(found.bool, expected.bool, "instant reset deferred bool"); + is(found.string, expected.string, "instant reset deferred string"); + is(found.unichar, expected.unichar, "instant reset deferred unichar"); + is( + found.wstring_data, expected.wstring_data, + "instant reset deferred wstring" + ); + todo_is(found.file_data, expected.file_data, "instant reset deferred file"); + + // Test cancel in child window. + WritePrefsToSystem(kPrefValueSet1); + window.browsingContext.topChromeWindow.openDialog( + "window_preferences2.xhtml", + "", + "modal", + aPrefWindow => WritePrefsToPreferences(aPrefWindow, kPrefValueSet2), + false + ); + expected = kPrefValueSet1; + found = ReadPrefsFromSystem(); + ok(found.int === expected.int, "non-instant cancel system int"); + ok(found.bool === expected.bool, "non-instant cancel system bool"); + ok(found.string === expected.string, "non-instant cancel system string"); + ok(found.unichar === expected.unichar, "non-instant cancel system unichar"); + ok( + found.wstring_data === expected.wstring_data, + "non-instant cancel system wstring" + ); + todo( + found.file_data === expected.file_data, + "non-instant cancel system file" + ); + + // Test accept in child window. + WritePrefsToSystem(kPrefValueSet1); + window.browsingContext.topChromeWindow.openDialog( + "window_preferences2.xhtml", + "", + "modal", + aPrefWindow => WritePrefsToPreferences(aPrefWindow, kPrefValueSet2), + true + ); + expected = kPrefValueSet2; + found = ReadPrefsFromSystem(); + ok(found.int === expected.int, "non-instant accept system int"); + ok(found.bool === expected.bool, "non-instant accept system bool"); + ok(found.string === expected.string, "non-instant accept system string"); + ok(found.unichar === expected.unichar, "non-instant accept system unichar"); + ok( + found.wstring_data === expected.wstring_data, + "non-instant accept system wstring" + ); + todo( + found.file_data === expected.file_data, + "non-instant accept system file" + ); + + // Test deferred reset in child window. + WritePrefsToSystem(kPrefValueSet1); + window.browsingContext.topChromeWindow.openDialog( + "window_preferences2.xhtml", + "", + "modal", + RunResetPrefTest, + true + ); + expected = CreateEmptyPrefValueSet(); + found = ReadPrefsFromSystem(); + ok(found.int === expected.int, "non-instant reset deferred int"); + ok(found.bool === expected.bool, "non-instant reset deferred bool"); + ok(found.string === expected.string, "non-instant reset deferred string"); + ok(found.unichar === expected.unichar, "non-instant reset deferred unichar"); + ok( + found.wstring_data === expected.wstring_data, + "non-instant reset deferred wstring" + ); + ok(found.file_data === expected.file_data, "non-instant reset deferred file"); + } + + function RunTestCommandRedirect() { + WritePrefsToSystem(kPrefValueSet1); + window.browsingContext.topChromeWindow.openDialog( + "window_preferences_commandretarget.xhtml", + "", + "modal", + RunCheckCommandRedirect, + true + ); + } + + function RunTestDisabled() { + // Because this pref is on the default branch and locked, we need to set it before opening the dialog. + const defaultBranch = kPref.getDefaultBranch(""); + defaultBranch.setBoolPref("tests.locked_preference_bool", true); + defaultBranch.lockPref("tests.locked_preference_bool"); + window.browsingContext.topChromeWindow.openDialog( + "window_preferences_disabled.xhtml", + "", + "modal", + RunCheckDisabled, + true + ); + } + + function RunTest() { + RunTestApplyPref(); + RunTestCommandRedirect(); + RunTestDisabled(); + } + ]]> + </script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <p id="display"></p> + <div id="content" style="display: none"></div> + <pre id="test"></pre> + </body> + +</window> diff --git a/toolkit/content/tests/chrome/test_preferences_beforeaccept.xhtml b/toolkit/content/tests/chrome/test_preferences_beforeaccept.xhtml new file mode 100644 index 0000000000..cde1982c0d --- /dev/null +++ b/toolkit/content/tests/chrome/test_preferences_beforeaccept.xhtml @@ -0,0 +1,65 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Preferences Window beforeaccept Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + + <script type="application/javascript"> + <![CDATA[ + SimpleTest.waitForExplicitFinish(); + SimpleTest.registerCleanupFunction(() => { + SpecialPowers.clearUserPref("tests.beforeaccept.dialogShown"); + SpecialPowers.clearUserPref("tests.beforeaccept.called"); + }); + + // No instant-apply for this test because type="child". + var prefWindow = window.browsingContext.topChromeWindow.openDialog( + "window_preferences_beforeaccept.xhtml", + "", + "", + windowOnload + ); + + function windowOnload() { + var dialogShown = prefWindow.Preferences.get( + "tests.beforeaccept.dialogShown" + ); + var called = prefWindow.Preferences.get("tests.beforeaccept.called"); + is(dialogShown.value, true, "dialog opened, shown pref set"); + is(dialogShown.valueFromPreferences, null, "shown pref not committed"); + is(called.value, null, "beforeaccept not yet called"); + is( + called.valueFromPreferences, + null, + "beforeaccept not yet called, pref not committed" + ); + + // try to accept the dialog, should fail the first time + prefWindow.document.getElementById("beforeaccept_dialog").acceptDialog(); + is(prefWindow.closed, false, "window not closed"); + is(dialogShown.value, true, "shown pref still set"); + is(dialogShown.valueFromPreferences, null, "shown pref still not committed"); + is(called.value, true, "beforeaccept called"); + is(called.valueFromPreferences, null, "called pref not committed"); + + // try again, this one should succeed + prefWindow.document.getElementById("beforeaccept_dialog").acceptDialog(); + is(prefWindow.closed, true, "window now closed"); + is(dialogShown.valueFromPreferences, true, "shown pref committed"); + is(called.valueFromPreferences, true, "called pref committed"); + + SimpleTest.finish(); + } + ]]> + </script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <p id="display"></p> + <div id="content" style="display: none"></div> + <pre id="test"></pre> + </body> + +</window> diff --git a/toolkit/content/tests/chrome/test_preferences_onsyncfrompreference.xhtml b/toolkit/content/tests/chrome/test_preferences_onsyncfrompreference.xhtml new file mode 100644 index 0000000000..b3c618eac0 --- /dev/null +++ b/toolkit/content/tests/chrome/test_preferences_onsyncfrompreference.xhtml @@ -0,0 +1,59 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- 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/. --> +<window title="Preferences Window beforeaccept Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + + <script type="application/javascript"> + <![CDATA[ + const PREFS = ['tests.onsyncfrompreference.pref1', + 'tests.onsyncfrompreference.pref2', + 'tests.onsyncfrompreference.pref3']; + + SimpleTest.waitForExplicitFinish(); + + for (let pref of PREFS) { + Services.prefs.setIntPref(pref, 1); + } + + let counter = 0; + let prefWindow = window.browsingContext.topChromeWindow.openDialog("window_preferences_onsyncfrompreference.xhtml", "", "", onSync); + + SimpleTest.registerCleanupFunction(() => { + for (let pref of PREFS) { + Services.prefs.clearUserPref(pref); + } + prefWindow.close(); + }); + + // Onsyncfrompreference handler for the prefs + function onSync() { + for (let pref of PREFS) { + // The `value` field of each <preference> element should be initialized by now. + + is(Services.prefs.getIntPref(pref), prefWindow.Preferences.get(pref).value, + "Pref constructor was called correctly") + } + + counter++; + + if (counter == PREFS.length) { + SimpleTest.finish(); + } + return true; + } + ]]> + </script> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <p id="display"></p> + <div id="content" style="display: none"></div> + <pre id="test"></pre> + </body> + +</window> diff --git a/toolkit/content/tests/chrome/test_props.xhtml b/toolkit/content/tests/chrome/test_props.xhtml new file mode 100644 index 0000000000..6fcee90270 --- /dev/null +++ b/toolkit/content/tests/chrome/test_props.xhtml @@ -0,0 +1,87 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for basic properties - this test checks that the basic + properties defined in general.js and inherited by a number of elements + work properly. + --> +<window title="Basic Properties Test" + onload="setTimeout(test_props, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<command id="cmd_nothing"/> +<command id="cmd_action"/> + +<button id="button" label="Button" accesskey="B" + crop="end" image="happy.png" command="cmd_nothing"/> +<checkbox id="checkbox" label="Checkbox" accesskey="B" + crop="end" image="happy.png" command="cmd_nothing"/> +<radiogroup> + <radio id="radio" label="Radio Button" value="rb1" accesskey="B" + crop="end" image="happy.png" command="cmd_nothing"/> +</radiogroup> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function test_props() +{ + test_props_forelement($("button"), "Button", null); + test_props_forelement($("checkbox"), "Checkbox", null); + test_props_forelement($("radio"), "Radio Button", "rb1"); + + SimpleTest.finish(); +} + +function test_props_forelement(element, label, value) +{ + // check the initial values + is(element.label, label, "element label"); + if (value) + is(element.value, value, "element value"); + is(element.accessKey, "B", "element accessKey"); + is(element.image, "happy.png", "element image"); + is(element.command, "cmd_nothing", "element command"); + ok(element.tabIndex === 0, "element tabIndex"); + + synthesizeMouseExpectEvent(element, 4, 4, { }, $("cmd_nothing"), "command", "element"); + + // make sure that setters return the value + is(element.label = "Label", "Label", "element label setter return"); + if (value) + is(element.value = "lb", "lb", "element value setter return"); + is(element.accessKey = "L", "L", "element accessKey setter return"); + is(element.image = "sad.png", "sad.png", "element image setter return"); + is(element.command = "cmd_action", "cmd_action", "element command setter return"); + + // check the value after it was changed + is(element.label, "Label", "element label after set"); + if (value) + is(element.value, "lb", "element value after set"); + is(element.accessKey, "L", "element accessKey after set"); + is(element.image, "sad.png", "element image after set"); + is(element.command, "cmd_action", "element command after set"); + + synthesizeMouseExpectEvent(element, 4, 4, { }, $("cmd_action"), "command", "element"); + + // check that clicks on disabled items don't fire a command event + // eslint-disable-next-line no-constant-binary-expression + ok((element.disabled = true) === true, "element disabled setter return"); + ok(element.disabled === true, "element disabled after set"); + synthesizeMouseExpectEvent(element, 4, 4, { }, $("cmd_action"), "!command", "element"); + + element.disabled = false; +} + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_radio.xhtml b/toolkit/content/tests/chrome/test_radio.xhtml new file mode 100644 index 0000000000..3f222a8daa --- /dev/null +++ b/toolkit/content/tests/chrome/test_radio.xhtml @@ -0,0 +1,83 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for radio buttons + --> +<window title="Radio Buttons" width="500" height="600" + onload="setTimeout(test_radio, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="application/javascript" src="xul_selectcontrol.js"/> + +<radiogroup id="radiogroup"/> + +<radiogroup id="radiogroup-initwithvalue" value="two"> + <radio label="One" value="one"/> + <radio label="Two" value="two"/> + <radio label="Three" value="three"/> +</radiogroup> +<radiogroup id="radiogroup-initwithselected" value="two"> + <radio id="one" label="One" value="one" accesskey="o"/> + <radio id="two" label="Two" value="two" accesskey="t"/> + <radio label="Three" value="three" selected="true"/> +</radiogroup> + +<radiogroup id="radio-creation" hidden="true" /> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +async function test_radio() +{ + var element = document.getElementById("radiogroup"); + test_nsIDOMXULSelectControlElement(element, "radio", null); + test_nsIDOMXULSelectControlElement_UI(element, null); + + window.blur(); + + var accessKeyDetails = (navigator.platform.includes("Mac")) ? + { altKey : true, ctrlKey : true } : + { altKey : true, shiftKey: true }; + synthesizeKey("t", accessKeyDetails); + + var radiogroup = $("radiogroup-initwithselected"); + is(document.activeElement, radiogroup, "accesskey focuses radiogroup"); + is(radiogroup.selectedItem, $("two"), "accesskey selects radio"); + + $("radiogroup-initwithvalue").focus(); + + $("one").disabled = true; + synthesizeKey("o", accessKeyDetails); + + is(document.activeElement, $("radiogroup-initwithvalue"), "accesskey on disabled radio doesn't focus"); + is(radiogroup.selectedItem, $("two"), "accesskey on disabled radio doesn't change selection"); + + info("Testing appending child"); + var dynamicRadiogroup = document.querySelector("#radio-creation"); + var radio = document.createXULElement("radio"); + radio.setAttribute("selected", "true"); + radio.setAttribute("label", "one"); + radio.setAttribute("value", "one"); + dynamicRadiogroup.appendChild(radio); + dynamicRadiogroup.appendChild(document.createXULElement("radio")); + + dynamicRadiogroup.hidden = false; + info("Waiting for condition"); + await SimpleTest.promiseWaitForCondition(() => dynamicRadiogroup.value == "one", + "Value gets set once child is constructed"); + is(dynamicRadiogroup._radioChildren.length, 2, "Correct number of children"); + + SimpleTest.finish(); +} + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_richlistbox.xhtml b/toolkit/content/tests/chrome/test_richlistbox.xhtml new file mode 100644 index 0000000000..48303e0172 --- /dev/null +++ b/toolkit/content/tests/chrome/test_richlistbox.xhtml @@ -0,0 +1,117 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for listbox direction + --> +<window title="Listbox direction test" + onload="test_richlistbox()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + + <richlistbox seltype="multiple" id="richlistbox" flex="1" style="min-height: 80px; max-height: 80px; height: 80px"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + +<script type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var richListBox = document.getElementById("richlistbox"); + +function getScrollIndexAmount(aDirection) { + return (4 * aDirection + richListBox.currentIndex); +} + +function test_richlistbox() +{ + var height = richListBox.clientHeight; + var item; + do { + item = richListBox.appendItem("Test", ""); + item.style.height = item.style.minHeight = item.style.maxHeight = Math.floor(height / 4) + "px"; + } while (item.getBoundingClientRect().bottom < (height * 2)) + richListBox.appendItem("Test", ""); + richListBox.firstChild.nextSibling.id = "list-box-first"; + richListBox.lastChild.previousSibling.id = "list-box-last"; + + var count = richListBox.itemCount; + richListBox.focus(); + + // Test that dir="reverse" is ignored and behaves the same as dir="normal". + for (let dir of ["reverse", "normal"]) { + richListBox.style.MozBoxDirection = dir; + richListBox.selectedIndex = 0; + sendKey("DOWN"); + is(richListBox.currentIndex, 1, "Selection should move to the next item"); + sendKey("UP"); + is(richListBox.currentIndex, 0, "Selection should move to the previous item"); + sendKey("END"); + is(richListBox.currentIndex, count - 1, "Selection should move to the last item"); + sendKey("HOME"); + is(richListBox.currentIndex, 0, "Selection should move to the first item"); + var currentIndex = richListBox.currentIndex; + var index = richListBox.scrollOnePage(1); + sendKey("PAGE_DOWN"); + is(richListBox.currentIndex, index, "Selection should move to one page down"); + ok(richListBox.currentIndex > currentIndex, "Selection should move downwards"); + sendKey("END"); + currentIndex = richListBox.currentIndex; + index = richListBox.scrollOnePage(-1) + richListBox.currentIndex; + sendKey("PAGE_UP"); + is(richListBox.currentIndex, index, "Selection should move to one page up"); + ok(richListBox.currentIndex < currentIndex, "Selection should move upwards"); + richListBox.selectedItem = richListBox.firstChild; + richListBox.focus(); + synthesizeKey("KEY_ArrowDown", {shiftKey: true}, window); + let items = [richListBox.selectedItems[0], + richListBox.selectedItems[1]]; + is(items[0], richListBox.firstChild, "The last element should still be selected"); + is(items[1], richListBox.firstChild.nextSibling, "Both elements should now be selected"); + richListBox.clearSelection(); + richListBox.selectedItem = richListBox.firstChild; + sendMouseEvent({type: "click", shiftKey: true, clickCount: 1}, + "list-box-first", + window); + items = [richListBox.selectedItems[0], + richListBox.selectedItems[1]]; + is(items[0], richListBox.firstChild, "The last element should still be selected"); + is(items[1], richListBox.firstChild.nextSibling, "Both elements should now be selected"); + richListBox.addEventListener("keypress", function(aEvent) { + aEvent.preventDefault(); + }, { useCapture: true, once: true }); + richListBox.selectedIndex = 1; + sendKey("HOME"); + is(richListBox.selectedIndex, 1, "A stopped event should return indexing to normal"); + } + + // Test attempting to select a disabled item. + richListBox.clearSelection(); + richListBox.selectedItem = richListBox.firstChild; + richListBox.firstChild.nextSibling.setAttribute("disabled", true); + richListBox.focus(); + synthesizeKey("KEY_ArrowDown", {}, window); + is(richListBox.selectedItems.length, 1, "one item selected"); + is(richListBox.selectedItems[0], richListBox.firstChild, "first item selected"); + + // Selected item re-insertion should keep the item selected. + richListBox.clearSelection(); + item = richListBox.firstElementChild; + richListBox.selectedItem = item; + is(richListBox.selectedItems.length, 1, "one item selected"); + is(richListBox.selectedItems[0], item, "first item selected"); + item.remove(); + richListBox.append(item); + is(richListBox.selectedItems.length, 1, "one item selected"); + is(richListBox.selectedItems[0], item, "last (previosly first) item selected"); + + SimpleTest.finish(); +} + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_screenPersistence.xhtml b/toolkit/content/tests/chrome/test_screenPersistence.xhtml new file mode 100644 index 0000000000..667dcf9590 --- /dev/null +++ b/toolkit/content/tests/chrome/test_screenPersistence.xhtml @@ -0,0 +1,62 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Window Open Test" + onload="runTest()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<script class="testbody" type="application/javascript"><![CDATA[ + SimpleTest.waitForExplicitFinish(); + let win; + var left = 60 + screen.availLeft; + var upper = 60 + screen.availTop; + + function runTest() { + win = window.browsingContext.topChromeWindow + .openDialog("window_screenPosSize.xhtml", + "_blank", + "chrome,dialog=no,all,screenX=" + left + ",screenY=" + upper + ",outerHeight=200,outerWidth=200"); + SimpleTest.waitForFocus(checkTest, win); + } + function checkTest() { + is(win.screenX, left, "The window should be placed now at x=" + left + "px"); + is(win.screenY, upper, "The window should be placed now at y=" + upper + "px"); + is(win.outerHeight, 200, "The window size should be height=200px"); + is(win.outerWidth, 200, "The window size should be width=200px"); + runTest2(); + } + function runTest2() { + win.close(); + win = window.browsingContext.topChromeWindow + .openDialog("window_screenPosSize.xhtml", + "_blank", + "chrome,dialog=no,all"); + SimpleTest.waitForFocus(checkTest2, win); + } + function checkTest2() { + let runTime = SpecialPowers.Services.appinfo; + if (runTime.OS != "Linux") { + is(win.screenX, 80, "The window should be placed now at x=80px"); + is(win.screenY, 80, "The window should be placed now at y=80px"); + } + is(win.innerHeight, 300, "The window size should be height=300px"); + is(win.innerWidth, 300, "The window size should be width=300px"); + win.close(); + SimpleTest.finish(); + } +]]></script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_scrollbar.xhtml b/toolkit/content/tests/chrome/test_scrollbar.xhtml new file mode 100644 index 0000000000..c16e9f2980 --- /dev/null +++ b/toolkit/content/tests/chrome/test_scrollbar.xhtml @@ -0,0 +1,131 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for scrollbars + --> +<window title="Scrollbar" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"/> + + <hbox> + <scrollbar orient="horizontal" + id="scroller" + curpos="0" + maxpos="600" + pageincrement="400" + style="width: 500px; margin: 0"/> + </hbox> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +/** Test for Scrollbar **/ +var scrollbarTester = { + scrollbar: null, + middlePref: false, + startTest() { + this.scrollbar = $("scroller"); + this.middlePref = this.getMiddlePref(); + var self = this; + [0, 1, 2].map(function(button) { + [false, true].map(function(alt) { + [false, true].map(function(shift) { + self.testThumbDragging(button, alt, shift); + }) + }) + }); + SimpleTest.finish(); + }, + testThumbDragging(button, withAlt, withShift) { + this.reset(); + var x = 160; // on the right half of the thumb + var y = 5; + + var isMac = navigator.platform.includes("Mac"); + let runtime = SpecialPowers.Services.appinfo; + var isGtk = runtime.widgetToolkit.includes("gtk"); + + // Start the drag. + this.mousedown(x, y, button, withAlt, withShift); + var newPos = this.getPos(); + var scrollToClick = (newPos != 0); + if (isMac || isGtk) { + ok(!scrollToClick, "On Linux and Mac OS X, clicking the scrollbar thumb "+ + "should never move it."); + } else if (button == 0 && withShift) { + ok(scrollToClick, "On platforms other than Linux and Mac OS X, holding "+ + "shift should enable scroll-to-click on the scrollbar thumb."); + } else if (button == 1 && this.middlePref) { + ok(scrollToClick, "When middlemouse.scrollbarPosition is on, clicking the "+ + "thumb with the middle mouse button should center it "+ + "around the cursor.") + } + + // Move one pixel to the right. + this.mousemove(x+1, y, button, withAlt, withShift); + var newPos2 = this.getPos(); + if (newPos2 != newPos) { + ok(newPos2 > newPos, "Scrollbar thumb should follow the mouse when dragged."); + ok(newPos2 - newPos < 3, "Scrollbar shouldn't move further than the mouse when dragged."); + ok(button == 0 || (button == 1 && this.middlePref) || (button == 2 && isGtk), + "Dragging the scrollbar should only be possible with the left mouse button."); + } else if (button == 0) { + // Dragging had no effect. + ok(false, "Dragging the scrollbar thumb should work."); + } else if (button == 1 && this.middlePref && (!isGtk && !isMac)) { + ok(false, "When middlemouse.scrollbarPosition is on, dragging the "+ + "scrollbar thumb should be possible using the middle mouse button."); + } else { + ok(true, "Dragging works correctly."); + } + + // Release the mouse button. + this.mouseup(x+1, y, button, withAlt, withShift); + var newPos3 = this.getPos(); + ok(newPos3 == newPos2, + "Releasing the mouse button after dragging the thumb shouldn't move it."); + }, + getMiddlePref() { + // It would be better to test with different middlePref settings, + // but the setting is only queried once, at browser startup, so + // changing it here wouldn't have any effect + var mouseBranch = SpecialPowers.Services.prefs.getBranch("middlemouse."); + return mouseBranch.getBoolPref("scrollbarPosition"); + }, + setPos(pos) { + this.scrollbar.setAttribute("curpos", pos); + }, + getPos() { + return this.scrollbar.getAttribute("curpos"); + }, + reset() { + this.setPos(0); + }, + mousedown(x, y, button, alt, shift) { + synthesizeMouse(this.scrollbar, x, y, { type: "mousedown", 'button': button, + altKey: alt, shiftKey: shift }); + }, + mousemove(x, y, button, alt, shift) { + synthesizeMouse(this.scrollbar, x, y, { type: "mousemove", 'button': button, + altKey: alt, shiftKey: shift }); + }, + mouseup(x, y, button, alt, shift) { + synthesizeMouse(this.scrollbar, x, y, { type: "mouseup", 'button': button, + altKey: alt, shiftKey: shift }); + } +} + +function doTest() { + setTimeout(function() { scrollbarTester.startTest(); }, 0); +} + +SimpleTest.waitForExplicitFinish(); +addLoadEvent(doTest); + +]]></script> +</window> diff --git a/toolkit/content/tests/chrome/test_showcaret.xhtml b/toolkit/content/tests/chrome/test_showcaret.xhtml new file mode 100644 index 0000000000..eb344589fc --- /dev/null +++ b/toolkit/content/tests/chrome/test_showcaret.xhtml @@ -0,0 +1,99 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Show Caret Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + +<iframe id="f1" width="100" height="100" onload="frameLoaded()" + src="data:text/html,%3Cbody%20style='height:%208000px'%3E%3Cp%3EHello%3C/p%3EGoodbye%3C/body%3E"/> +<!-- <body style='height: 8000px'><p>Hello</p><span id='s'>Goodbye<span></body> --> +<iframe id="f2" type="content" showcaret="true" width="100" height="100" onload="frameLoaded()" + src="data:text/html,%3Cbody%20style%3D%27height%3A%208000px%27%3E%3Cp%3EHello%3C%2Fp%3E%3Cspan%20id%3D%27s%27%3EGoodbye%3Cspan%3E%3C%2Fbody%3E"/> + +<script> +<![CDATA[ + +var framesLoaded = 0; +var otherWindow = null; + +function frameLoaded() { if (++framesLoaded == 2) SimpleTest.waitForFocus(runTest); } + +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + var sel1 = frames[0].getSelection(); + sel1.collapse(frames[0].document.body, 0); + + var sel2 = frames[1].getSelection(); + sel2.collapse(frames[1].document.body, 0); + window.frames[0].focus(); + document.commandDispatcher.getControllerForCommand("cmd_moveBottom").doCommand("cmd_moveBottom"); + + var listener = function() { + if (!(frames[0].scrollY > 0)) { + window.content.removeEventListener("scroll", listener); + } + } + window.frames[0].addEventListener("scroll", listener); + + sel1 = frames[0].getSelection(); + sel1.collapse(frames[0].document.body, 0); + + sel2 = frames[1].getSelection(); + sel2.collapse(frames[1].document.body, 0); + + window.frames[0].focus(); + document.commandDispatcher.getControllerForCommand("cmd_moveBottom").doCommand("cmd_moveBottom"); + is(sel1.focusNode, frames[0].document.body, "focusNode for non-showcaret"); + is(sel1.focusOffset, 0, "focusOffset for non-showcaret"); + + window.frames[1].focus(); + document.commandDispatcher.getControllerForCommand("cmd_moveBottom").doCommand("cmd_moveBottom"); + + ok(frames[1].scrollY < + frames[1].document.getElementById('s').getBoundingClientRect().top, + "scrollY for showcaret"); + isnot(sel2.focusNode, frames[1].document.body, "focusNode for showcaret"); + ok(sel2.anchorOffset > 0, "focusOffset for showcaret"); + + otherWindow = window.browsingContext.topChromeWindow.open("window_showcaret.xhtml", "_blank", "chrome,width=400,height=200"); + otherWindow.addEventListener("focus", otherWindowFocused); +} + +function otherWindowFocused() +{ + otherWindow.removeEventListener("focus", otherWindowFocused); + + // enable caret browsing temporarily to test caret movement + let prefs = SpecialPowers.Services.prefs; + prefs.setBoolPref("accessibility.browsewithcaret", true); + + var hbox = otherWindow.document.documentElement.firstChild; + hbox.focus(); + is(otherWindow.document.activeElement, hbox, "hbox in other window is focused"); + + document.commandDispatcher.getControllerForCommand("cmd_lineNext").doCommand("cmd_lineNext"); + is(otherWindow.document.activeElement, hbox, "hbox still focused in other window after down movement"); + + prefs.setBoolPref("accessibility.browsewithcaret", false); + + otherWindow.close(); + SimpleTest.finish(); +} + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_subframe_origin.xhtml b/toolkit/content/tests/chrome/test_subframe_origin.xhtml new file mode 100644 index 0000000000..65b7190baf --- /dev/null +++ b/toolkit/content/tests/chrome/test_subframe_origin.xhtml @@ -0,0 +1,36 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Subframe Event Tests" + onload="setTimeout(runTest, 0);" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + +<script> + +// Added after content child widgets were removed from ui windows. Tests sub frame +// event client coordinate offsets. + +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_subframe_origin.xhtml", "_blank", "chrome,width=600,height=600,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_tabbox.xhtml b/toolkit/content/tests/chrome/test_tabbox.xhtml new file mode 100644 index 0000000000..bca919f8c5 --- /dev/null +++ b/toolkit/content/tests/chrome/test_tabbox.xhtml @@ -0,0 +1,223 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for tabboxes + --> +<window title="Tabbox Test" width="500" height="600" + onload="setTimeout(test_tabbox, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="application/javascript" src="xul_selectcontrol.js"/> + +<vbox id="tabboxes"> + +<tabbox id="tabbox"> + <tabs id="tabs"> + <tab id="tab1" label="Tab 1"/> + <tab id="tab2" label="Tab 2"/> + </tabs> + <tabpanels id="tabpanels"> + <button id="panel1" label="Panel 1"/> + <button id="panel2" label="Panel 2"/> + </tabpanels> +</tabbox> + +<tabbox id="tabbox-initwithvalue"> + <tabs id="tabs-initwithvalue" value="two"> + <tab label="Tab 1" value="one"/> + <tab label="Tab 2" value="two"/> + <tab label="Tab 3" value="three"/> + </tabs> + <tabpanels id="tabpanels-initwithvalue"> + <button label="Panel 1"/> + <button label="Panel 2"/> + <button label="Panel 3"/> + </tabpanels> +</tabbox> + +<tabbox id="tabbox-initwithselected"> + <tabs id="tabs-initwithselected" value="two"> + <tab label="Tab 1" value="one"/> + <tab label="Tab 2" value="two"/> + <tab label="Tab 3" value="three" selected="true"/> + </tabs> + <tabpanels id="tabpanels-initwithselected"> + <button label="Panel 1"/> + <button label="Panel 2"/> + <button label="Panel 3"/> + </tabpanels> +</tabbox> + +</vbox> + +<tabbox id="tabbox-nofocus"> + <html:input id="textbox-extra" hidden="true"/> + <tabs> + <tab label="Tab 1" value="one"/> + <tab id="tab-nofocus" label="Tab 2" value="two"/> + </tabs> + <tabpanels> + <tabpanel> + <button id="tab-nofocus-button" label="Label"/> + </tabpanel> + <tabpanel id="tabpanel-nofocusinpaneltab"> + <label id="tablabel" value="Label"/> + </tabpanel> + </tabpanels> +</tabbox> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function test_tabbox() +{ + var tabbox = document.getElementById("tabbox"); + var tabs = document.getElementById("tabs"); + var tabpanels = document.getElementById("tabpanels"); + + test_tabbox_State(tabbox, "tabbox initial", 0, tabs.allTabs[0], tabpanels.firstChild); + + // check the selectedIndex property + tabbox.selectedIndex = 1; + test_tabbox_State(tabbox, "tabbox selectedIndex 1", 1, tabs.allTabs[tabs.allTabs.length - 1], tabpanels.lastChild); + + tabbox.selectedIndex = 2; + test_tabbox_State(tabbox, "tabbox selectedIndex 2", 1, tabs.allTabs[tabs.allTabs.length - 1], tabpanels.lastChild); + + // tabbox must have a selection, so setting to -1 should do nothing + tabbox.selectedIndex = -1; + test_tabbox_State(tabbox, "tabbox selectedIndex -1", 1, tabs.allTabs[tabs.allTabs.length - 1], tabpanels.lastChild); + + // check the selectedTab property + tabbox.selectedTab = tabs.allTabs[0]; + test_tabbox_State(tabbox, "tabbox selected", 0, tabs.allTabs[0], tabpanels.firstChild); + + // setting selectedTab to null should not do anything + tabbox.selectedTab = null; + test_tabbox_State(tabbox, "tabbox selectedTab null", 0, tabs.allTabs[0], tabpanels.firstChild); + + // check the selectedPanel property + tabbox.selectedPanel = tabpanels.lastChild; + test_tabbox_State(tabbox, "tabbox selectedPanel", 0, tabs.allTabs[0], tabpanels.lastChild); + + // setting selectedPanel to null should not do anything + tabbox.selectedPanel = null; + test_tabbox_State(tabbox, "tabbox selectedPanel null", 0, tabs.allTabs[0], tabpanels.lastChild); + + tabbox.selectedIndex = 0; + test_tabpanels(tabpanels, tabbox); + + tabs.firstChild.remove(); + tabs.firstChild.remove(); + + test_tabs(tabs); + + test_tabbox_focus(); +} + +function test_tabpanels(tabpanels, tabbox) +{ + var tab = tabbox.selectedTab; + + // changing the selection on the tabpanels should not affect the tabbox + // or tabs within + // check the selectedIndex property + tabpanels.selectedIndex = 1; + test_tabbox_State(tabbox, "tabpanels tabbox selectedIndex 1", 0, tab, tabpanels.lastChild); + test_tabpanels_State(tabpanels, "tabpanels selectedIndex 1", 1, tabpanels.lastChild); + + tabpanels.selectedIndex = 0; + test_tabbox_State(tabbox, "tabpanels tabbox selectedIndex 2", 0, tab, tabpanels.firstChild); + test_tabpanels_State(tabpanels, "tabpanels selectedIndex 2", 0, tabpanels.firstChild); + + // setting selectedIndex to -1 should do nothing + tabpanels.selectedIndex = 1; + tabpanels.selectedIndex = -1; + test_tabbox_State(tabbox, "tabpanels tabbox selectedIndex -1", 0, tab, tabpanels.lastChild); + test_tabpanels_State(tabpanels, "tabpanels selectedIndex -1", 1, tabpanels.lastChild); + + // check the tabpanels.selectedPanel property + tabpanels.selectedPanel = tabpanels.lastChild; + test_tabbox_State(tabbox, "tabpanels tabbox selectedPanel", 0, tab, tabpanels.lastChild); + test_tabpanels_State(tabpanels, "tabpanels selectedPanel", 1, tabpanels.lastChild); + + // check setting the tabpanels.selectedPanel property to null + tabpanels.selectedPanel = null; + test_tabbox_State(tabbox, "tabpanels selectedPanel null", 0, tab, tabpanels.lastChild); +} + +function test_tabs(tabs) +{ + test_nsIDOMXULSelectControlElement(tabs, "tab", "tabs"); + // XXXndeakin would test the UI aspect of tabs, but the mouse + // events on tabs are fired in a timeout causing the generic + // test_nsIDOMXULSelectControlElement_UI method not to work + // test_nsIDOMXULSelectControlElement_UI(tabs, null); +} + +function test_tabbox_State(tabbox, testid, index, tab, panel) +{ + is(tabbox.selectedIndex, index, testid + " selectedIndex"); + is(tabbox.selectedTab, tab, testid + " selectedTab"); + is(tabbox.selectedPanel, panel, testid + " selectedPanel"); +} + +function test_tabpanels_State(tabpanels, testid, index, panel) +{ + is(tabpanels.selectedIndex, index, testid + " selectedIndex"); + is(tabpanels.selectedPanel, panel, testid + " selectedPanel"); +} + +function test_tabbox_focus() +{ + $("tabboxes").hidden = true; + $(document.activeElement).blur(); + + var tabbox = $("tabbox-nofocus"); + var tab = $("tab-nofocus"); + + when_tab_focused(tab, function () { + is(document.activeElement, tab, "focus in tab with no focusable elements"); + + tabbox.selectedIndex = 0; + $("tab-nofocus-button").focus(); + + when_tab_focused(tab, function () { + is(document.activeElement, tab, "focus in tab with no focusable elements, but with something in another tab focused"); + + var textboxExtra = $("textbox-extra"); + textboxExtra.addEventListener("focus", function () { + is(document.activeElement, textboxExtra, "focus in tab with focus currently in textbox that is sibling of tabs"); + + SimpleTest.finish(); + }, {once: true}); + + tabbox.selectedIndex = 0; + textboxExtra.hidden = false; + synthesizeMouseAtCenter(tab, { }); + }); + + synthesizeMouseAtCenter(tab, { }); + }); + + synthesizeMouseAtCenter(tab, { }); +} + +function when_tab_focused(tab, callback) { + tab.addEventListener("focus", function onFocused() { + SimpleTest.executeSoon(callback); + }, {once: true}); +} + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_tabindex.xhtml b/toolkit/content/tests/chrome/test_tabindex.xhtml new file mode 100644 index 0000000000..9828728c63 --- /dev/null +++ b/toolkit/content/tests/chrome/test_tabindex.xhtml @@ -0,0 +1,136 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for tabindex + --> +<window title="tabindex" width="500" height="600" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<!-- + Elements are navigated in the following order: + 1. tabindex > 0 in tree order + 2. tabindex = 0 in tree order + Elements with tabindex = -1 are focusable, but not in the tab order + --> +<hbox> + <button id="t7" label="One"/> + <checkbox id="f1" label="Two" tabindex="-1"/> + <button id="t8" label="Three" tabindex="0"/> + <checkbox id="t1" label="Four" tabindex="1"/> +</hbox> +<hbox> + <html:input id="t9" idmod="t5" size="3"/> + <html:input id="f2" size="3" tabindex="-1"/> + <html:input id="t10" idmod="t6" size="3" tabindex="0"/> + <html:input id="t2" idmod="t1" size="3" tabindex="1"/> +</hbox> +<hbox> + <button id="n1" style="-moz-user-focus: ignore;" label="One"/> + <checkbox id="f3" style="-moz-user-focus: ignore;" label="Two" tabindex="-1"/> + <button id="t11" style="-moz-user-focus: ignore;" label="Three" tabindex="0"/> + <checkbox id="t3" style="-moz-user-focus: ignore;" label="Four" tabindex="1"/> +</hbox> +<hbox> + <html:input id="t12" idmod="t7" style="-moz-user-focus: ignore;" size="3"/> + <html:input id="f4" style="-moz-user-focus: ignore;" size="3" tabindex="-1"/> + <html:input id="t13" idmod="t8" style="-moz-user-focus: ignore;" size="3" tabindex="0"/> + <html:input id="t4" idmod="t2" style="-moz-user-focus: ignore;" size="3" tabindex="1"/> +</hbox> +<richlistbox id="t14" idmod="t9"> + <richlistitem><label value="Item One"/></richlistitem> +</richlistbox> + +<hbox> + <!-- the tabindex attribute applies to non-controls as well. They are not + focusable unless tabindex is explicitly specified. + --> + <dropmarker id="n2"/> + <dropmarker id="f5" tabindex="-1"/> + <dropmarker id="t15" tabindex="0"/> + <dropmarker id="t5" idmod="t3" tabindex="1"/> + <dropmarker id="t16" style="-moz-user-focus: normal;"/> + <dropmarker id="f6" style="-moz-user-focus: normal;" tabindex="-1"/> + <dropmarker id="t17" style="-moz-user-focus: normal;" tabindex="0"/> + <dropmarker id="t6" idmod="t4" style="-moz-user-focus: normal;" tabindex="1"/> +</hbox> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +<script> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +function checkFocusability(aId, aFocusable) +{ + document.activeElement.blur(); + let testNode = document.getElementById(aId); + testNode.focus(); + let newFocus = document.activeElement; + let check = aFocusable ? is : isnot; + let focusableText = aFocusable ? "focusable " : "unfocusable "; + check(newFocus, testNode, + ".focus() call on " + focusableText + aId); +} + +var gAdjustedTabFocusModel = false; +var gTestCount = 17; +var gTestsOccurred = 0; +let gFocusableNotTabbableCount = 6; +let gNotFocusableCount = 2; + +function runTests() +{ + var t; + function onFocus(event) { + if (t == 1 && event.target.id == "t2") { + // looks to be using the MacOSX Full Keyboard Access set to Textboxes + // and lists only so use the idmod attribute instead + gAdjustedTabFocusModel = true; + gTestCount = 9; + } + + var attrcompare = gAdjustedTabFocusModel ? "idmod" : "id"; + + // check for the last test which should wrap aorund to the first item + // consider the focus event on the inner input of textboxes instead + is(event.target.getAttribute(attrcompare), "t" + t, + "tab " + t + " to " + event.target.localName); + gTestsOccurred++; + } + window.addEventListener("focus", onFocus, true); + + for (t = 1; t <= gTestCount; t++) + synthesizeKey("KEY_Tab"); + + is(gTestsOccurred, gTestCount, "test count"); + window.removeEventListener("focus", onFocus, true); + + for (let i = 1; i <= gFocusableNotTabbableCount; ++i) { + checkFocusability("f" + i, true); + } + + for (let i = 1; i <= gNotFocusableCount; ++i) { + checkFocusability("n" + i, false); + } + + SimpleTest.finish(); +} + +SimpleTest.waitForFocus(runTests); + +]]> + +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_textbox_search.xhtml b/toolkit/content/tests/chrome/test_textbox_search.xhtml new file mode 100644 index 0000000000..216caae6f4 --- /dev/null +++ b/toolkit/content/tests/chrome/test_textbox_search.xhtml @@ -0,0 +1,175 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for search textbox + --> +<window title="Search textbox test" width="500" height="600" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <hbox id="searchbox-container"> + <search-textbox id="searchbox" + oncommand="doSearch(this.value);" + placeholder="random placeholder" + timeout="1"/> + </hbox> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var gExpectedValue; +var gLastTest; + +function doTests() { + var textbox = $("searchbox"); + + // Reconnect the searchbox to ensure connectedCallback only runs once + // (bug 1650486). + textbox.remove(); + $("searchbox-container").append(textbox); + is(textbox.shadowRoot.querySelectorAll(".textbox-search-sign").length, 1, + "only one search icon in the search box"); + + var icons = textbox._searchIcons; + var searchIcon = icons.querySelector(".textbox-search-icon"); + var clearIcon = icons.querySelector(".textbox-search-clear"); + + ok(icons, "icon deck found"); + ok(searchIcon, "search icon found"); + ok(clearIcon, "clear icon found"); + is(icons.selectedPanel, searchIcon, "search icon is displayed"); + + is(textbox.placeholder, "random placeholder", "search textbox supports placeholder"); + is(textbox.value, "", "placeholder doesn't interfere with the real value"); + is(textbox.shadowRoot.querySelector("input").getAttribute("inputmode"), "search", "inputmode of search textbox is search by default"); + + function iconClick(aIcon) { + is(icons.selectedPanel, aIcon, aIcon.className + " icon must be displayed in order to be clickable"); + + //XXX synthesizeMouse worked on Linux but failed on Windows an Mac + // for unknown reasons. Manually dispatch the event for now. + //synthesizeMouse(aIcon, 0, 0, {}); + + var event = document.createEvent("MouseEvent"); + event.initMouseEvent("click", true, true, window, 1, + 0, 0, 0, 0, + false, false, false, false, + 0, null); + aIcon.dispatchEvent(event); + } + + iconClick(searchIcon); + is(textbox.getAttribute("focused"), "true", "clicking the search icon focuses the textbox"); + + textbox.value = "foo"; + is(icons.selectedPanel, clearIcon, "clear icon is displayed when setting a value"); + + textbox.value = ""; + is(textbox.defaultValue, "", "defaultValue is empty"); + is(textbox.value, "", "reset method clears the textbox"); + is(icons.selectedPanel, searchIcon, "search icon is displayed after clearing value"); + + textbox.value = "foo"; + gExpectedValue = ""; + iconClick(clearIcon); + is(textbox.value, "", "clicking the clear icon clears the textbox"); + ok(gExpectedValue == null, "search triggered when clearing the textbox with the clear icon"); + + textbox.value = "foo"; + gExpectedValue = ""; + synthesizeKey("KEY_Escape"); + is(textbox.value, "", "escape key clears the textbox"); + ok(gExpectedValue == null, "search triggered when clearing the textbox with the escape key"); + + textbox.value = "bar"; + gExpectedValue = "bar"; + textbox.doCommand(); + ok(gExpectedValue == null, "search triggered with doCommand"); + + gExpectedValue = "bar"; + synthesizeKey("KEY_Enter"); + ok(gExpectedValue == null, "search triggered with enter key"); + + textbox.value = ""; + textbox.searchButton = true; + is(textbox.getAttribute("searchbutton"), "true", "searchbutton attribute set on the textbox"); + + textbox.value = "foo"; + is(icons.selectedPanel, searchIcon, "search icon displayed in search button mode if there's a value"); + + gExpectedValue = "foo"; + iconClick(searchIcon); + ok(gExpectedValue == null, "search triggered when clicking the search icon in search button mode"); + is(icons.selectedPanel, clearIcon, "clear icon displayed in search button mode after submitting"); + + sendString("o"); + is(icons.selectedPanel, searchIcon, "search icon displayed in search button mode when typing a key"); + + gExpectedValue = "fooo"; + iconClick(searchIcon); // display the clear icon (tested above) + + textbox.value = "foo"; + is(icons.selectedPanel, searchIcon, "search icon displayed in search button mode when the value is changed"); + + gExpectedValue = "foo"; + synthesizeKey("KEY_Enter"); + ok(gExpectedValue == null, "search triggered with enter key in search button mode"); + is(icons.selectedPanel, clearIcon, "clear icon displayed in search button mode after submitting with enter key"); + + textbox.value = "x"; + synthesizeKey("KEY_Backspace"); + is(icons.selectedPanel, searchIcon, "search icon displayed in search button mode when deleting the value with the backspace key"); + + gExpectedValue = ""; + synthesizeKey("KEY_Enter"); + ok(gExpectedValue == null, "search triggered with enter key in search button mode"); + is(icons.selectedPanel, searchIcon, "search icon displayed in search button mode after submitting an empty string"); + + textbox.readOnly = true; + gExpectedValue = "foo"; + textbox.value = "foo"; + iconClick(searchIcon); + ok(gExpectedValue == null, "search triggered when clicking the search icon in search button mode while the textbox is read-only"); + is(icons.selectedPanel, searchIcon, "search icon persists in search button mode after submitting while the textbox is read-only"); + textbox.readOnly = false; + + textbox.disabled = true; + is(searchIcon.getAttribute("disabled"), "true", "disabled attribute inherited to the search icon"); + is(clearIcon.getAttribute("disabled"), "true", "disabled attribute inherited to the clear icon"); + gExpectedValue = false; + textbox.value = "foo"; + iconClick(searchIcon); + ok(!gExpectedValue, "search *not* triggered when clicking the search icon in search button mode while the textbox is disabled"); + is(icons.selectedPanel, searchIcon, "search icon persists in search button mode when trying to submit while the textbox is disabled"); + textbox.disabled = false; + ok(!searchIcon.hasAttribute("disabled"), "disabled attribute removed from the search icon"); + ok(!clearIcon.hasAttribute("disabled"), "disabled attribute removed from the clear icon"); + + textbox.searchButton = false; + ok(!textbox.hasAttribute("searchbutton"), "searchbutton attribute removed from the textbox"); + + gLastTest = true; + gExpectedValue = "123"; + textbox.value = "1"; + sendString("23"); +} + +function doSearch(aValue) { + is(aValue, gExpectedValue, "search triggered with expected value"); + gExpectedValue = null; + if (gLastTest) + SimpleTest.finish(); +} + +SimpleTest.waitForFocus(doTests); + + ]]></script> + +</window> diff --git a/toolkit/content/tests/chrome/test_tooltip.xhtml b/toolkit/content/tests/chrome/test_tooltip.xhtml new file mode 100644 index 0000000000..ec4855131d --- /dev/null +++ b/toolkit/content/tests/chrome/test_tooltip.xhtml @@ -0,0 +1,28 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Tooltip Tests" + onload="setTimeout(runTest, 0)" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<script> +SimpleTest.waitForExplicitFinish(); +function runTest() +{ + window.openDialog("window_tooltip.xhtml", "_blank", "chrome,width=700,height=700,noopener", window); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_tooltip_noautohide.xhtml b/toolkit/content/tests/chrome/test_tooltip_noautohide.xhtml new file mode 100644 index 0000000000..99216809bf --- /dev/null +++ b/toolkit/content/tests/chrome/test_tooltip_noautohide.xhtml @@ -0,0 +1,56 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Tooltip Noautohide Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<tooltip id="thetooltip" noautohide="true" + onpopupshown="setTimeout(tooltipStillShown, 6000)" + onpopuphidden="ok(gChecked, 'tooltip did not hide'); SimpleTest.finish()"> + <label id="label" value="This is a tooltip"/> +</tooltip> + +<button id="button" label="Tooltip Text" tooltip="thetooltip"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +var gChecked = false; + +function runTests() +{ + var button = document.getElementById("button"); + var windowUtils = window.windowUtils; + windowUtils.disableNonTestMouseEvents(true); + synthesizeMouse(button, 2, 2, { type: "mouseover" }); + synthesizeMouse(button, 4, 4, { type: "mousemove" }); + synthesizeMouse(button, 6, 6, { type: "mousemove" }); + windowUtils.disableNonTestMouseEvents(false); +} + +function tooltipStillShown() +{ + gChecked = true; + document.getElementById("thetooltip").hidePopup(); +} + +SimpleTest.waitForExplicitFinish(); +SimpleTest.waitForFocus(runTests); + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/test_tree.xhtml b/toolkit/content/tests/chrome/test_tree.xhtml new file mode 100644 index 0000000000..d35d6354ab --- /dev/null +++ b/toolkit/content/tests/chrome/test_tree.xhtml @@ -0,0 +1,84 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for tree using multiple row selection + --> +<window title="Tree" width="500" height="600" + onload="setTimeout(testtag_tree, 0, 'tree-simple', 'treechildren-simple', 'multiple', 'simple', 'tree');" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<script src="tree_shared.js"/> + +<tree id="tree-simple" rows="4"> + <treecols> + <treecol id="name" label="Name" sort="label" properties="one two" flex="1"/> + <treecol id="address" label="Address" flex="1"/> + </treecols> + <treechildren id="treechildren-simple"> + <treeitem> + <treerow> + <treecell label="Mary"/> + <treecell label="206 Garden Avenue"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Chris"/> + <treecell label="19 Marion Street"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Sarah"/> + <treecell label="702 Fern Avenue" editable="false"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="John"/> + <treecell label="99 Westminster Avenue"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Mary"/> + <treecell label="206 Garden Avenue"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Chris"/> + <treecell label="19 Marion Street"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Sarah"/> + <treecell label="702 Fern Avenue"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="John"/> + <treecell label="99 Westminster Avenue"/> + </treerow> + </treeitem> + </treechildren> +</tree> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +]]> +</script> + +</window> + diff --git a/toolkit/content/tests/chrome/test_tree_hier.xhtml b/toolkit/content/tests/chrome/test_tree_hier.xhtml new file mode 100644 index 0000000000..fd4198c804 --- /dev/null +++ b/toolkit/content/tests/chrome/test_tree_hier.xhtml @@ -0,0 +1,136 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for hierarchical tree + --> +<window title="Hierarchical Tree" width="500" height="600" + onload="setTimeout(testtag_tree, 0, 'tree-hier', 'treechildren-hier', 'multiple', '', 'hierarchical tree');" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<script src="tree_shared.js"/> + +<tree id="tree-hier" rows="4"> + <treecols> + <treecol id="name" label="Name" primary="true" + sort="label" properties="one two" flex="1"/> + <treecol id="address" label="Address" style="flex-grow: 2; flex-shrink: 2"/> + <treecol id="planet" label="Planet" flex="1"/> + <treecol id="gender" label="Gender" flex="1" cycler="true"/> + </treecols> + <treechildren id="treechildren-hier"> + <treeitem> + <treerow properties="firstrow"> + <treecell label="Mary" value="mary" properties="firstname"/> + <treecell label="206 Garden Avenue" value="206ga"/> + <treecell label="Earth"/> + <treecell label="Female" value="f"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell/> + <treecell value="19ms"/> + <treecell label="Earth"/> + <treecell label="Male" value="m"/> + </treerow> + </treeitem> + <treeitem container="true"> + <treerow> + <treecell label="Sarah"/> + <treecell label="702 Fern Avenue" editable="false"/> + <treecell label="Saturn"/> + <treecell label="Female" value="f"/> + </treerow> + <treechildren> + <treeitem> + <treerow> + <treecell label="Mary"/> + <treecell label="206 Garden Avenue"/> + <treecell label="Female" value="f"/> + <treecell label="Neptune"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Chris"/> + <treecell label="19 Marion Street"/> + <treecell label="Omicron Persei 8"/> + <treecell label="Male" value="m"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Sarah"/> + <treecell label="702 Fern Avenue" editable="false"/> + <treecell label="Earth"/> + <treecell label="Female" value="f"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="John"/> + <treecell label="99 Westminster Avenue"/> + <treecell label="Neptune"/> + <treecell label="Male" value="m"/> + </treerow> + </treeitem> + </treechildren> + </treeitem> + <treeitem> + <treerow> + <treecell label="John"/> + <treecell label="99 Westminster Avenue"/> + <treecell/> + <treecell label="Male" value="m"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Mary"/> + <treecell label="206 Garden Avenue" selectable="false"/> + <treecell label=""/> + <treecell label="Female" value="f"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Chris"/> + <treecell label="19 Marion Street"/> + <treecell label="Neptune"/> + <treecell label="Male" value="m"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Sarah"/> + <treecell label="702 Fern Avenue"/> + <treecell label="Earth"/> + <treecell label="Female" value="f"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="John"/> + <treecell label="99 Westminster Avenue"/> + <treecell label="Mars"/> + <treecell label="Male" value="m"/> + </treerow> + </treeitem> + </treechildren> +</tree> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_tree_scroll.xhtml b/toolkit/content/tests/chrome/test_tree_scroll.xhtml new file mode 100644 index 0000000000..08b6d9141a --- /dev/null +++ b/toolkit/content/tests/chrome/test_tree_scroll.xhtml @@ -0,0 +1,93 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for scrolling behavior of tree +--> +<window title="Tree" width="500" height="600" + onload="setTimeout(testtag_tree_scroll);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/paint_listener.js"></script> + + <script src="chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_utils.js"></script> + <script src="chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_native_event_utils.js"></script> + <script src="tree_shared.js"/> + +<html:div style="height: 200px; overflow-y: scroll;"> + <html:div id="top" style="height: 50px; background: cyan;"></html:div> + <tree rows="3"> + <treecols> + <treecol id="name" label="label" sort="label" flex="1"/> + </treecols> + <treechildren> + <treeitem> + <treerow> + <treecell label="0"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="1"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="2"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="3"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="4"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="5"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="6"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="7"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="8"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="9"/> + </treerow> + </treeitem> + </treechildren> + </tree> + <html:div id="bottom" style="height: 150px; background: orange;"></html:div> +</html:div> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_tree_single.xhtml b/toolkit/content/tests/chrome/test_tree_single.xhtml new file mode 100644 index 0000000000..d5bae73ccb --- /dev/null +++ b/toolkit/content/tests/chrome/test_tree_single.xhtml @@ -0,0 +1,110 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for single selection tree + --> +<window title="Single Selection Tree" width="500" height="600" + onload="setTimeout(testtag_tree, 0, 'tree-single', 'treechildren-single', + 'single', 'simple', 'single selection tree');" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<script src="tree_shared.js"/> + +<tree id="tree-single" rows="4" seltype="single"> + <treecols> + <treecol id="name" label="Name" sort="label" properties="one two" flex="1"/> + <treecol id="address" label="Address" flex="1"/> + </treecols> + <treechildren id="treechildren-single"> + <treeitem> + <treerow properties="firstrow"> + <treecell label="Mary" value="mary" properties="firstname"/> + <treecell label="206 Garden Avenue" value="206ga"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell/> + <treecell value="19ms"/> + </treerow> + </treeitem> + <treeitem container="true"> + <treerow> + <treecell label="Sarah"/> + <treecell label="702 Fern Avenue" editable="false"/> + </treerow> + <treechildren> + <treeitem> + <treerow> + <treecell label="Mary"/> + <treecell label="206 Garden Avenue"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Chris"/> + <treecell label="19 Marion Street"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Sarah"/> + <treecell label="702 Fern Avenue" editable="false"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="John"/> + <treecell label="99 Westminster Avenue"/> + </treerow> + </treeitem> + </treechildren> + </treeitem> + <treeitem> + <treerow> + <treecell label="John"/> + <treecell label="99 Westminster Avenue"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Mary"/> + <treecell label="206 Garden Avenue" selectable="false"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Chris"/> + <treecell label="19 Marion Street"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="Sarah"/> + <treecell label="702 Fern Avenue"/> + </treerow> + </treeitem> + <treeitem> + <treerow> + <treecell label="John"/> + <treecell label="99 Westminster Avenue"/> + </treerow> + </treeitem> + </treechildren> +</tree> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_tree_view.xhtml b/toolkit/content/tests/chrome/test_tree_view.xhtml new file mode 100644 index 0000000000..5e45161c6c --- /dev/null +++ b/toolkit/content/tests/chrome/test_tree_view.xhtml @@ -0,0 +1,113 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for tree using a custom nsITreeView + --> +<window title="Tree" onload="init()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<script src="tree_shared.js"/> + +<script> +<![CDATA[ +/* import-globals-from ../widgets/tree_shared.js */ +// This is our custom view, based on the treeview interface +var view = +{ + treeData: [["Mary", "206 Garden Avenue"], + ["Chris", "19 Marion Street"], + ["Sarah", "702 Fern Avenue"], + ["John", "99 Westminster Avenue"]], + value: "", + rowCount: 8, + getCellText(row, column) { return this.treeData[row % 4][column.index]; }, + getCellValue(row, column) { return this.value; }, + setCellText(row, column, val) { this.treeData[row % 4][column.index] = val; }, + setCellValue(row, column, val) { this.value = val; }, + setTree(tree) { this.tree = tree; }, + isContainer(row) { return false; }, + isContainerOpen(row) { return false; }, + isContainerEmpty(row) { return false; }, + isSeparator(row) { return false; }, + isSorted(row) { return false; }, + isEditable(row, column) { return row != 2 || column.index != 1; }, + getParentIndex(row, column) { return -1; }, + getLevel(row) { return 0; }, + hasNextSibling(row, column) { return row != this.rowCount - 1; }, + getImageSrc(row, column) { return ""; }, + cycleHeader(column) { }, + getRowProperties(row) { return ""; }, + getCellProperties(row, column) { return ""; }, + getColumnProperties(column) + { + if (!column.index) { + return "one two"; + } + + return ""; + } +} + +function getCustomTreeViewCellInfo() +{ + var obj = { rows: [] }; + + for (var row = 0; row < view.rowCount; row++) { + var cellInfo = [ ]; + for (var column = 0; column < 1; column++) { + cellInfo.push({ label: "" + view.treeData[row % 4][column], + value: "", + properties: "", + editable: row != 2 || column.index != 1, + selectable: true, + image: "" }); + } + + obj.rows.push({ cells: cellInfo, + properties: "", + container: false, + separator: false, + children: null, + level: 0, + parent: -1 }); + } + + return obj; +} + +function init() +{ + var tree = document.getElementById("tree-view"); + tree.view = view; + tree.ensureRowIsVisible(0); + is(tree.getFirstVisibleRow(), 0, "first visible after ensureRowIsVisible on load"); + + setTimeout(testtag_tree, 0, "tree-view", "treechildren-view", "multiple", "simple", "tree view"); +} + +]]> +</script> + +<tree id="tree-view" rows="4"> + <treecols> + <treecol id="name" label="Name" sort="label" flex="1"/> + <treecol id="address" label="Address" flex="1"/> + </treecols> + <treechildren id="treechildren-view"/> +</tree> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/test_window_intrinsic_size.xhtml b/toolkit/content/tests/chrome/test_window_intrinsic_size.xhtml new file mode 100644 index 0000000000..451d2f0cce --- /dev/null +++ b/toolkit/content/tests/chrome/test_window_intrinsic_size.xhtml @@ -0,0 +1,43 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<window title="Window Open Test" + onload="runTest()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> +<script class="testbody" type="application/javascript"><![CDATA[ + SimpleTest.waitForExplicitFinish(); + + function openWindow(features = "") { + return window.browsingContext.topChromeWindow + .openDialog("window_intrinsic_size.xhtml", + "", "chrome,dialog=no,all," + features); + } + + function checkWindowSize(win, width, height, msg) { + is(win.innerWidth, width, "width should match " + msg); + is(win.innerHeight, height, "height should match " + msg); + } + + async function runTest() { + let win = openWindow(); + await SimpleTest.promiseFocus(win); + checkWindowSize(win, 300, 500, "with width attribute specified"); + + win = openWindow("width=400"); + await SimpleTest.promiseFocus(win); + checkWindowSize(win, 400, 500, "with width feature specified"); + + SimpleTest.finish(); + } +]]></script> +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> +</window> diff --git a/toolkit/content/tests/chrome/window_browser_drop.xhtml b/toolkit/content/tests/chrome/window_browser_drop.xhtml new file mode 100644 index 0000000000..cb23ef74de --- /dev/null +++ b/toolkit/content/tests/chrome/window_browser_drop.xhtml @@ -0,0 +1,249 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Browser Drop Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<script> +<![CDATA[ + +const { ContentTask } = ChromeUtils.importESModule( + "resource://testing-common/ContentTask.sys.mjs" +); + +function dropOnRemoteBrowserAsync(browser, data, shouldExpectStateChange) { + ContentTask.setTestScope(window); // Need this so is/isnot/ok are available inside the contenttask + return ContentTask.spawn(browser, {data, shouldExpectStateChange}, async function({data, shouldExpectStateChange}) { + if (!content.document.documentElement) { + // Wait until the testing document gets loaded. + await new Promise(resolve => { + let onload = function() { + content.window.removeEventListener("load", onload); + resolve(); + }; + content.window.addEventListener("load", onload); + }); + } + + let dataTransfer = new content.DataTransfer(); + for (let i = 0; i < data.length; i++) { + let types = data[i]; + for (let j = 0; j < types.length; j++) { + dataTransfer.mozSetDataAt(types[j].type, types[j].data, i); + } + } + let event = content.document.createEvent("DragEvent"); + event.initDragEvent("drop", true, true, content, 0, 0, 0, 0, 0, + false, false, false, false, 0, null, dataTransfer); + content.document.body.dispatchEvent(event); + + let links = []; + try { + links = Services.droppedLinkHandler.dropLinks(event, true); + } catch (ex) { + if (shouldExpectStateChange) { + ok(false, "Should not have gotten an exception from the dropped link handler, but got: " + ex); + console.error(ex); + } + } + + return links; + }); +} + +async function expectLink(browser, expectedLinks, data, testid, onbody=false) { + let lastLinks = []; + let lastLinksPromise = new Promise(resolve => { + browser.droppedLinkHandler = function(event, links) { + info(`droppedLinkHandler called, received links ${JSON.stringify(links)}`); + if (!expectedLinks.length) { + ok(false, `droppedLinkHandler called for ${JSON.stringify(links)} which we didn't expect.`); + } + lastLinks = links; + resolve(links); + }; + }); + + function dropOnBrowserSync() { + let dropEl = onbody ? browser.contentDocument.body : browser; + synthesizeDrop(dropEl, dropEl, data, null, dropEl.ownerGlobal); + } + let links; + if (browser.isRemoteBrowser) { + let remoteLinks = await dropOnRemoteBrowserAsync(browser, data, expectedLinks.length); + is(remoteLinks.length, expectedLinks.length, testid + " remote links length"); + for (let i = 0, length = remoteLinks.length; i < length; i++) { + is(remoteLinks[i].url, expectedLinks[i].url, testid + "[" + i + "] remote link"); + is(remoteLinks[i].name, expectedLinks[i].name, testid + "[" + i + "] remote name"); + } + + if (!expectedLinks.length) { + // There is no way to check if nothing happens asynchronously. + return; + } + + links = await lastLinksPromise; + } else { + dropOnBrowserSync(); + links = lastLinks; + } + + is(links.length, expectedLinks.length, testid + " links length"); + for (let i = 0, length = links.length; i < length; i++) { + is(links[i].url, expectedLinks[i].url, testid + "[" + i + "] link"); + is(links[i].name, expectedLinks[i].name, testid + "[" + i + "] name"); + } +}; + +async function dropLinksOnBrowser(browser, type) { + // Dropping single text/plain item with single link should open single + // page. + await expectLink(browser, + [ { url: "http://www.mozilla.org/", + name: "http://www.mozilla.org/" } ], + [ [ { type: "text/plain", + data: "http://www.mozilla.org/" } ] ], + "text/plain drop on browser " + type); + + // Dropping single text/plain item with multiple links should open + // multiple pages. + await expectLink(browser, + [ { url: "http://www.mozilla.org/", + name: "http://www.mozilla.org/" }, + { url: "http://www.example.com/", + name: "http://www.example.com/" } ], + [ [ { type: "text/plain", + data: "http://www.mozilla.org/\nhttp://www.example.com/" } ] ], + "text/plain with 2 URLs drop on browser " + type); + + // Dropping sinlge unsupported type item should not open anything. + await expectLink(browser, + [], + [ [ { type: "text/link", + data: "http://www.mozilla.org/" } ] ], + "text/link drop on browser " + type); + + // Dropping single text/uri-list item with single link should open single + // page. + await expectLink(browser, + [ { url: "http://www.example.com/", + name: "http://www.example.com/" } ], + [ [ { type: "text/uri-list", + data: "http://www.example.com/" } ] ], + "text/uri-list drop on browser " + type); + + // Dropping single text/uri-list item with multiple links should open + // multiple pages. + await expectLink(browser, + [ { url: "http://www.example.com/", + name: "http://www.example.com/" }, + { url: "http://www.mozilla.org/", + name: "http://www.mozilla.org/" }], + [ [ { type: "text/uri-list", + data: "http://www.example.com/\nhttp://www.mozilla.org/" } ] ], + "text/uri-list with 2 URLs drop on browser " + type); + + // Name in text/x-moz-url should be handled. + await expectLink(browser, + [ { url: "http://www.example.com/", + name: "Example.com" } ], + [ [ { type: "text/x-moz-url", + data: "http://www.example.com/\nExample.com" } ] ], + "text/x-moz-url drop on browser " + type); + + await expectLink(browser, + [ { url: "http://www.mozilla.org/", + name: "Mozilla.org" }, + { url: "http://www.example.com/", + name: "Example.com" } ], + [ [ { type: "text/x-moz-url", + data: "http://www.mozilla.org/\nMozilla.org\nhttp://www.example.com/\nExample.com" } ] ], + "text/x-moz-url with 2 URLs drop on browser " + type); + + // Dropping single item with multiple types should open single page. + await expectLink(browser, + [ { url: "http://www.example.org/", + name: "Example.com" } ], + [ [ { type: "text/plain", + data: "http://www.mozilla.org/" }, + { type: "text/x-moz-url", + data: "http://www.example.org/\nExample.com" } ] ], + "text/plain and text/x-moz-url drop on browser " + type); + + // Dropping javascript or data: URLs should fail: + await expectLink(browser, + [], + [ [ { type: "text/plain", + data: "javascript:'bad'" } ] ], + "text/plain javascript url drop on browser " + type); + await expectLink(browser, + [], + [ [ { type: "text/plain", + data: "jAvascript:'also bad'" } ] ], + "text/plain mixed-case javascript url drop on browser " + type); + await expectLink(browser, + [], + [ [ { type: "text/plain", + data: "data:text/html,bad" } ] ], + "text/plain data url drop on browser " + type); + + // Dropping a chrome url should fail as we don't have a source node set, + // defaulting to a source of file:/// + await expectLink(browser, + [], + [ [ { type: "text/x-moz-url", + data: "chrome://browser/content/browser.xhtml" } ] ], + "text/x-moz-url chrome url drop on browser " + type); + + if (browser.type == "content") { + await SpecialPowers.spawn(browser, [], function() { + content.window.stopMode = true; + }); + + // stopPropagation should not prevent the browser link handling from occuring + await expectLink(browser, + [ { url: "http://www.mozilla.org/", + name: "http://www.mozilla.org/" } ], + [ [ { type: "text/uri-list", + data: "http://www.mozilla.org/" } ] ], + "text/x-moz-url drop on browser with stopPropagation drop event", true); + + await SpecialPowers.spawn(browser, [], function() { + content.window.cancelMode = true; + }); + + // Canceling the event, however, should prevent the link from being handled. + await expectLink(browser, + [], + [ [ { type: "text/uri-list", data: "http://www.mozilla.org/" } ] ], + "text/x-moz-url drop on browser with cancelled drop event", true); + } +} + +function info(msg) { window.arguments[0].SimpleTest.info(msg); } +function is(l, r, n) { window.arguments[0].SimpleTest.is(l,r,n); } +function ok(v, n) { window.arguments[0].SimpleTest.ok(v,n); } + +]]> +</script> + +<html:style> + /* FIXME: This is just preserving behavior from before bug 1656081. + * Without this there's one subtest that fails, but the browser + * elements were zero-sized before so... */ + browser { + min-width: 0; + min-height: 0; + } +</html:style> + +<browser id="chromechild" src="about:blank"/> +<browser id="contentchild" type="content" style="width: 100px; height: 100px" + src="data:text/html,<html draggable='true'><body draggable='true' style='width: 100px; height: 100px;' ondragover='event.preventDefault()' ondrop='if (window.stopMode) event.stopPropagation(); if (window.cancelMode) event.preventDefault();'></body></html>"/> +<browser id="remote-contentchild" type="content" remote="true" style="width: 100px; height: 100px" + src="data:text/html,<html draggable='true'><body draggable='true' style='width: 100px; height: 100px;' ondragover='event.preventDefault()' ondrop='if (window.stopMode) event.stopPropagation(); if (window.cancelMode) event.preventDefault();'></body></html>"/> +</window> diff --git a/toolkit/content/tests/chrome/window_chromemargin.xhtml b/toolkit/content/tests/chrome/window_chromemargin.xhtml new file mode 100644 index 0000000000..81bcba62fe --- /dev/null +++ b/toolkit/content/tests/chrome/window_chromemargin.xhtml @@ -0,0 +1,69 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window id="window" title="Subframe Origin Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> +chrome margins rock! +<script> + +// Tests parsing of the chrome margin attrib on a window. + +function ok(condition, message) { + window.arguments[0].SimpleTest.ok(condition, message); +} + +function doSingleTest(param) +{ + var exception = null; + try { + document.documentElement.removeAttribute("chromemargin"); + document.documentElement.setAttribute("chromemargin", param); + ok(document. + documentElement. + getAttribute("chromemargin") == param, "couldn't set/get chromemargin?"); + } catch (ex) { + exception = ex; + } + ok(!exception, "failed for param:'" + param + "'"); + return true; +} + +function runTests() +{ + var doc = document.documentElement; + + // make sure we can set and get + doc.setAttribute("chromemargin", "0,0,0,0"); + ok(doc.getAttribute("chromemargin") == "0,0,0,0", "couldn't set/get chromemargin?"); + doc.setAttribute("chromemargin", "-1,-1,-1,-1"); + ok(doc.getAttribute("chromemargin") == "-1,-1,-1,-1", "couldn't set/get chromemargin?"); + + // test remove + doc.removeAttribute("chromemargin"); + ok(doc.getAttribute("chromemargin") == "", "couldn't remove chromemargin?"); + + // we already test these really well in a c++ test in widget + doSingleTest("1,2,3,4"); + doSingleTest("-2,-2,-2,-2"); + doSingleTest("1,1,1,1"); + doSingleTest(""); + doSingleTest("12123123"); + doSingleTest("0,-1,-1,-1"); + doSingleTest("-1,0,-1,-1"); + doSingleTest("-1,-1,0,-1"); + doSingleTest("-1,-1,-1,0"); + doSingleTest("1234567890,1234567890,1234567890,1234567890"); + doSingleTest("-1,-1,-1,-1"); + + window.arguments[0].SimpleTest.finish(); + window.close(); +} + +window.arguments[0].SimpleTest.waitForFocus(runTests, window); + +</script> +</window> diff --git a/toolkit/content/tests/chrome/window_cursorsnap_dialog.xhtml b/toolkit/content/tests/chrome/window_cursorsnap_dialog.xhtml new file mode 100644 index 0000000000..d5c0e2753e --- /dev/null +++ b/toolkit/content/tests/chrome/window_cursorsnap_dialog.xhtml @@ -0,0 +1,104 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<window title="Cursor snapping test" + width="600" height="600" + onload="onload();" + onunload="onunload();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<dialog id="dialog" + buttons="accept,cancel"> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +function ok(aCondition, aMessage) +{ + window.arguments[0].SimpleTest.ok(aCondition, aMessage); +} + +function is(aLeft, aRight, aMessage) +{ + window.arguments[0].SimpleTest.is(aLeft, aRight, aMessage); +} + +function isnot(aLeft, aRight, aMessage) +{ + window.arguments[0].SimpleTest.isnot(aLeft, aRight, aMessage); +} + +function canRetryTest() +{ + return window.arguments[0].canRetryTest(); +} + +function getTimeoutTime() +{ + return window.arguments[0].getTimeoutTime(); +} + +var gTimer; +var gRetry; + +function finishByTimeout() +{ + var button = document.getElementById("dialog").getButton("accept"); + if (button.disabled) { + ok(true, "cursor is NOT snapped to the disabled button (dialog)"); + } else if (button.hidden) { + ok(true, "cursor is NOT snapped to the hidden button (dialog)"); + } else if (!canRetryTest()) { + ok(false, "cursor is NOT snapped to the default button (dialog)"); + } else { + // otherwise, this may be unexpected timeout, we should retry the test. + gRetry = true; + } + finish(); +} + +function finish() +{ + window.close(); +} + +function onMouseMove(aEvent) +{ + var button = document.getElementById("dialog").getButton("accept"); + if (button.disabled) + ok(false, "cursor IS snapped to the disabled button (dialog)"); + else if (button.hidden) + ok(false, "cursor IS snapped to the hidden button (dialog)"); + else + ok(true, "cursor IS snapped to the default button (dialog)"); + clearTimeout(gTimer); + finish(); +} + +function onload() +{ + var button = document.getElementById("dialog").getButton("accept"); + button.addEventListener("mousemove", onMouseMove); + + if (window.arguments[0].gDisable) { + button.disabled = true; + } + if (window.arguments[0].gHidden) { + button.hidden = true; + } + gRetry = false; + gTimer = setTimeout(finishByTimeout, getTimeoutTime()); +} + +function onunload() +{ + if (gRetry) { + window.arguments[0].retryCurrentTest(); + } else { + window.arguments[0].runNextTest(); + } +} + +]]> +</script> + +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/window_cursorsnap_wizard.xhtml b/toolkit/content/tests/chrome/window_cursorsnap_wizard.xhtml new file mode 100644 index 0000000000..661e9c4e65 --- /dev/null +++ b/toolkit/content/tests/chrome/window_cursorsnap_wizard.xhtml @@ -0,0 +1,109 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<wizard title="Cursor snapping test" id="wizard" + width="600" height="600" + onload="onload();" + onunload="onunload();" + buttons="accept,cancel" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <wizardpage> + <label value="first page"/> + </wizardpage> + + <wizardpage> + <label value="second page"/> + </wizardpage> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +function ok(aCondition, aMessage) +{ + window.opener.wrappedJSObject.SimpleTest.ok(aCondition, aMessage); +} + +function is(aLeft, aRight, aMessage) +{ + window.opener.wrappedJSObject.SimpleTest.is(aLeft, aRight, aMessage); +} + +function isnot(aLeft, aRight, aMessage) +{ + window.opener.wrappedJSObject.SimpleTest.isnot(aLeft, aRight, aMessage); +} + +function canRetryTest() +{ + return window.opener.wrappedJSObject.canRetryTest(); +} + +function getTimeoutTime() +{ + return window.opener.wrappedJSObject.getTimeoutTime(); +} + +var gTimer; +var gRetry = false; + +function finishByTimeout() +{ + var button = document.getElementById("wizard").getButton("next"); + if (button.disabled) { + ok(true, "cursor is NOT snapped to the disabled button (wizard)"); + } else if (button.hidden) { + ok(true, "cursor is NOT snapped to the hidden button (wizard)"); + } else if (!canRetryTest()) { + ok(false, "cursor is NOT snapped to the default button (wizard)"); + } else { + // otherwise, this may be unexpected timeout, we should retry the test. + gRetry = true; + } + finish(); +} + +function finish() +{ + window.close(); +} + +function onMouseMove() +{ + var button = document.getElementById("wizard").getButton("next"); + if (button.disabled) + ok(false, "cursor IS snapped to the disabled button (wizard)"); + else if (button.hidden) + ok(false, "cursor IS snapped to the hidden button (wizard)"); + else + ok(true, "cursor IS snapped to the default button (wizard)"); + clearTimeout(gTimer); + finish(); +} + +function onload() +{ + var button = document.getElementById("wizard").getButton("next"); + button.addEventListener("mousemove", onMouseMove); + + if (window.opener.wrappedJSObject.gDisable) { + button.disabled = true; + } + if (window.opener.wrappedJSObject.gHidden) { + button.hidden = true; + } + gTimer = setTimeout(finishByTimeout, getTimeoutTime()); +} + +function onunload() +{ + if (gRetry) { + window.opener.wrappedJSObject.retryCurrentTest(); + } else { + window.opener.wrappedJSObject.runNextTest(); + } +} + +]]> +</script> + +</wizard> diff --git a/toolkit/content/tests/chrome/window_intrinsic_size.xhtml b/toolkit/content/tests/chrome/window_intrinsic_size.xhtml new file mode 100644 index 0000000000..cae3d78594 --- /dev/null +++ b/toolkit/content/tests/chrome/window_intrinsic_size.xhtml @@ -0,0 +1,7 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<window title="Window Open Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="300" + style="min-height: 500px"> +</window> diff --git a/toolkit/content/tests/chrome/window_keys.xhtml b/toolkit/content/tests/chrome/window_keys.xhtml new file mode 100644 index 0000000000..77a098ef0b --- /dev/null +++ b/toolkit/content/tests/chrome/window_keys.xhtml @@ -0,0 +1,205 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Key Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<script> +<![CDATA[ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +SimpleTest.waitForExplicitFinish(); + +var gExpected = null; + + +const kIsWin = AppConstants.platform == "win"; +const kIsMac = AppConstants.platform == "macosx"; + +// Only on Windows, metaKey state is ignored when there is no shortcut key handler +// which exactly matches with metaKey state. Therefore the following tests +// checks kIsWin in some cases which has metaKey. +var keysToTest = [ + ["k-v", "V", { } ], + ["", "V", { shiftKey: true } ], + ["k-v-scy", "V", { ctrlKey: true } ], + ["", "V", { altKey: true } ], + [kIsWin ? "k-v" : "", "V", { metaKey: true } ], + ["k-v-scy", "V", { shiftKey: true, ctrlKey: true } ], + ["", "V", { shiftKey: true, ctrlKey: true, altKey: true } ], + ["k-e-y", "E", { } ], + ["", "E", { shiftKey: true } ], + ["", "E", { ctrlKey: true } ], + ["", "E", { altKey: true } ], + [kIsWin ? "k-e-y" : "", "E", { metaKey: true } ], + ["k-d-a", "D", { altKey: true } ], + ["k-8-m", "8", { metaKey: true } ], + ["", "B", {} ], + ["k-c-scaym", "C", { metaKey: true } ], + ["k-c-scaym", "C", { shiftKey: true, ctrlKey: true, altKey: true, metaKey: true } ], + ["", "V", { shiftKey: true, ctrlKey: true, altKey: true } ], + ["k-h-l", "H", { accelKey: true } ], + [kIsWin || kIsMac ? "k-h-l" : "", "H", { accelKey: true, metaKey: true } ], +// ["k-j-s", "J", { accessKey: true } ], + ["", "T", { } ], + ["k-g-c", "G", { ctrlKey: true } ], + ["k-g-cm", "G", { ctrlKey: true, metaKey: true } ], + ["scommand", "Y", { } ], + ["", "U", { } ], + ["k-z-c", "Z", { ctrlKey: true } ], +]; + +function runTest() +{ + let nonInlineKeyFired = false; + document.getElementById("k-z-c").addEventListener("command", event => { + nonInlineKeyFired = true; + checkKey(event); + }); + + iterateKeys(true, "normal"); + + ok(nonInlineKeyFired, "non-inline command listener fired"); + + var keyset = document.getElementById("keyset"); + keyset.setAttribute("disabled", "true"); + iterateKeys(false, "disabled"); + + keyset = document.getElementById("keyset"); + keyset.removeAttribute("disabled"); + iterateKeys(true, "reenabled"); + + keyset.remove(); + iterateKeys(false, "removed"); + + document.documentElement.appendChild(keyset); + iterateKeys(true, "appended"); + + var accelText = menuitem => menuitem.getAttribute("acceltext").toLowerCase(); + + $("menubutton").open = true; + + // now check if a menu updates its accelerator text when a key attribute is changed + var menuitem1 = $("menuitem1"); + ok(accelText(menuitem1).includes("d"), "menuitem1 accelText before"); + if (kIsWin) { + ok(accelText(menuitem1).includes("alt"), "menuitem1 accelText modifier before"); + } + + menuitem1.setAttribute("key", "k-s-c"); + ok(accelText(menuitem1).includes("s"), "menuitem1 accelText after"); + if (kIsWin) { + ok(accelText(menuitem1).includes("ctrl"), "menuitem1 accelText modifier after"); + } + + menuitem1.setAttribute("acceltext", "custom"); + is(accelText(menuitem1), "custom", "menuitem1 accelText set custom"); + menuitem1.removeAttribute("acceltext"); + ok(accelText(menuitem1).includes("s"), "menuitem1 accelText remove"); + if (kIsWin) { + ok(accelText(menuitem1).includes("ctrl"), "menuitem1 accelText modifier remove"); + } + + var menuitem2 = $("menuitem2"); + is(accelText(menuitem2), "", "menuitem2 accelText before"); + menuitem2.setAttribute("key", "k-s-c"); + ok(accelText(menuitem2).includes("s"), "menuitem2 accelText before"); + if (kIsWin) { + ok(accelText(menuitem2).includes("ctrl"), "menuitem2 accelText modifier before"); + } + + menuitem2.setAttribute("key", "k-h-l"); + ok(accelText(menuitem2).includes("h"), "menuitem2 accelText after"); + if (kIsWin) { + ok(accelText(menuitem2).includes("ctrl"), "menuitem2 accelText modifier after"); + } + + menuitem2.removeAttribute("key"); + is(accelText(menuitem2), "", "menuitem2 accelText after remove"); + + $("menubutton").open = false; + + window.close(); + window.arguments[0].SimpleTest.finish(); +} + +function iterateKeys(enabled, testid) +{ + for (var k = 0; k < keysToTest.length; k++) { + gExpected = keysToTest[k]; + var expectedKey = gExpected[0]; + if (!gExpected[2].accessKey || !navigator.platform.includes("Mac")) { + synthesizeKey(gExpected[1], gExpected[2]); + ok((enabled && expectedKey) || expectedKey == "k-d-a" ? + !gExpected : gExpected, testid + " key step " + (k + 1)); + } + } +} + +function checkKey(event) +{ + // the first element of the gExpected array holds the id of the <key> element + // that was expected. If this is empty, a handler wasn't expected to be called + if (gExpected[0]) + is(event.originalTarget.id, gExpected[0], "key " + gExpected[1]); + else + is("key " + event.originalTarget.id + " was activated", "", "key " + gExpected[1]); + gExpected = null; +} + +function is(l, r, n) { window.arguments[0].SimpleTest.is(l,r,n); } +function ok(v, n) { window.arguments[0].SimpleTest.ok(v,n); } + +SimpleTest.waitForFocus(runTest); + +]]> +</script> + +<command id="scommand" oncommand="checkKey(event)"/> +<command id="scommand-disabled" disabled="true"/> + +<keyset id="keyset"> + <key id="k-v" key="v" oncommand="checkKey(event)"/> + <key id="k-v-scy" key="v" modifiers="shift any control" oncommand="checkKey(event)"/> + <key id="k-e-y" key="e" modifiers="any" oncommand="checkKey(event)"/> + <key id="k-8-m" key="8" modifiers="meta" oncommand="checkKey(event)"/> + <key id="k-c-scaym" key="c" modifiers="shift control alt any meta" oncommand="checkKey(event)"/> + <key id="k-h-l" key="h" modifiers="accel" oncommand="checkKey(event)"/> + <key id="k-j-s" key="j" modifiers="access" oncommand="checkKey(event)"/> + <key id="k-t-y" disabled="true" key="t" oncommand="checkKey(event)"/> + <key id="k-g-c" key="g" modifiers="control" oncommand="checkKey(event)"/> + <key id="k-g-cm" key="g" modifiers="control meta" oncommand="checkKey(event)"/> + <key id="k-y" key="y" command="scommand"/> + <key id="k-u" key="u" command="scommand-disabled"/> + <key id="k-z-c" key="z" modifiers="control"/> +</keyset> + +<keyset id="keyset2"> + <key id="k-d-a" key="d" modifiers="alt" oncommand="checkKey(event)"/> + <key id="k-s-c" key="s" modifiers="control" oncommand="checkKey(event)"/> +</keyset> + +<button id="menubutton" label="Menu" type="menu"> + <menupopup> + <menuitem id="menuitem1" label="Item 1" key="k-d-a"/> + <menuitem id="menuitem2" label="Item 2"/> + </menupopup> +</button> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/window_largemenu.xhtml b/toolkit/content/tests/chrome/window_largemenu.xhtml new file mode 100644 index 0000000000..d84b045e78 --- /dev/null +++ b/toolkit/content/tests/chrome/window_largemenu.xhtml @@ -0,0 +1,430 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Large Menu Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<!-- + This test checks that a large menu is displayed with arrow buttons + and is on the screen. + --> + +<script> +<![CDATA[ + +var gOverflowed = false, gUnderflowed = false; +var gContextMenuTests = false; +var gScreenY = -1; +var gTestIndex = 0; +var gTests = ["open normal", "open when bottom would overlap", "open with scrolling", + "open after scrolling", "open small again", + "menu movement", "panel movement", + "context menu enough space below", + "context menu more space above", + "context menu too big either side", + "context menu larger than screen", + "context menu flips horizontally on osx"]; +function getScreenXY(element) +{ + var screenX, screenY; + var mouseFn = function(event) { + screenX = event.screenX - 1; + screenY = event.screenY - 1; + } + + // a hacky way to get the screen position of an element without using the box object + window.addEventListener("mousedown", mouseFn); + synthesizeMouse(element, 1, 1, { }); + window.removeEventListener("mousedown", mouseFn); + + return [screenX, screenY]; +} + +function hidePopup() { + window.requestAnimationFrame( + function() { + setTimeout( + function() { + document.getElementById("popup").hidePopup(); + }, 0); + }); +} + +function runTests() +{ + [, gScreenY] = getScreenXY(document.documentElement); + nextTest(); +} + +function nextTest() +{ + gOverflowed = false; gUnderflowed = false; + + var y = screen.height; + if (gTestIndex == 1) // open with bottom overlap test: + y -= 100; + else + y /= 2; + + var popup = document.getElementById("popup"); + if (gTestIndex == 2) { + // add some more menuitems so that scrolling will be necessary + var moreItemCount = Math.round(screen.height / popup.firstChild.getBoundingClientRect().height); + for (var t = 1; t <= moreItemCount; t++) { + var menu = document.createXULElement("menuitem"); + menu.setAttribute("label", "More" + t); + popup.appendChild(menu); + } + } + else if (gTestIndex == 4) { + // remove the items added in test 2 above + while (popup.childNodes.length > 15) + popup.removeChild(popup.lastChild); + } + + window.requestAnimationFrame(function() { + setTimeout( + function() { + popup.openPopupAtScreen(100, y, false); + }, 0); + }); +} + +function popupShown() +{ + if (gTests[gTestIndex] == "menu movement") + return testPopupMovement(); + + if (gContextMenuTests) + return contextMenuPopupShown(); + + var popup = document.getElementById("popup"); + var rect = popup.getBoundingClientRect(); + var marginTop = parseFloat(getComputedStyle(popup).marginTop); + var marginBottom = parseFloat(getComputedStyle(popup).marginBottom); + var scrollbox = popup.scrollBox.scrollbox; + var expectedScrollPos = 0; + + info(`${gTests[gTestIndex]}: ${JSON.stringify(rect)} | ${screen.width}x${screen.height} | ${gScreenY}`); + if (gTestIndex == 0) { + // the popup should be in the center of the screen + // note that if the height is odd, the y-offset will have been rounded + // down when we pass the fractional value to openPopupAtScreen above. + is(Math.round(rect.top - marginTop) + gScreenY, Math.floor(screen.height / 2), + gTests[gTestIndex] + " top"); + ok(Math.round(rect.bottom - marginBottom) + gScreenY < screen.height, + gTests[gTestIndex] + " bottom"); + ok(!gOverflowed && !gUnderflowed, gTests[gTestIndex] + " overflow") + } + else if (gTestIndex == 1) { + // the popup was supposed to open 100 pixels from the bottom, but that + // would put it off screen so ... + if (platformIsMac()) { + // On OSX the popup is constrained so it remains within the + // bounds of the screen + ok(Math.round(rect.top) + gScreenY >= screen.top, gTests[gTestIndex] + " top"); + is(Math.round(rect.bottom) + gScreenY, screen.availTop + screen.availHeight, gTests[gTestIndex] + " bottom"); + ok(!gOverflowed && !gUnderflowed, gTests[gTestIndex] + " overflow"); + } + else { + // On other platforms the menu should be flipped to have its bottom + // edge 100 pixels from the bottom + ok(Math.round(rect.top - marginTop) + gScreenY >= screen.top, gTests[gTestIndex] + " top"); + is(Math.round(rect.bottom + marginBottom) + gScreenY, screen.height - 100, + gTests[gTestIndex] + " bottom"); + ok(!gOverflowed && !gUnderflowed, gTests[gTestIndex] + " overflow"); + } + } + else if (gTestIndex == 2) { + // the popup is too large so ensure that it is on screen + ok(Math.round(rect.top - marginTop) + gScreenY >= screen.top, gTests[gTestIndex] + " top"); + ok(Math.round(rect.bottom + marginBottom) + gScreenY <= screen.height, gTests[gTestIndex] + " bottom"); + ok(gOverflowed && !gUnderflowed, gTests[gTestIndex] + " overflow") + + scrollbox.scrollTo(0, 40); + expectedScrollPos = 40; + } + else if (gTestIndex == 3) { + expectedScrollPos = 40; + if (scrollbox.scrollTop != expectedScrollPos) { + // TODO(bug 1815608): This never worked on Wayland, but regressed with flexbox emulation. + todo_is(scrollbox.scrollTop, expectedScrollPos, "menu scroll position after reopening large menu should not reset"); + expectedScrollPos = 0; + } + } + else if (gTestIndex == 4) { + // note that if the height is odd, the y-offset will have been rounded + // down when we pass the fractional value to openPopupAtScreen above. + is(Math.round(rect.top - marginTop) + gScreenY, Math.floor(screen.height / 2), + gTests[gTestIndex] + " top"); + ok(Math.round(rect.bottom) + gScreenY < screen.height, + gTests[gTestIndex] + " bottom"); + ok(!gOverflowed && gUnderflowed, gTests[gTestIndex] + " overflow"); + } + + is(scrollbox.scrollTop, expectedScrollPos, "menu scroll position " + gTests[gTestIndex]) + + return hidePopup(); +} + +function is(l, r, n) { window.arguments[0].SimpleTest.is(l,r,n); } +function ok(v, n) { window.arguments[0].SimpleTest.ok(v,n); } + +var oldx, oldy, waitSteps = 0; +function moveWindowTo(x, y, callback, arg) +{ + if (!waitSteps) { + oldx = window.screenX; + oldy = window.screenY; + window.moveTo(x, y); + + waitSteps++; + setTimeout(moveWindowTo, 100, x, y, callback, arg); + return; + } + + if (window.screenX == oldx && window.screenY == oldy) { + if (waitSteps++ > 10) { + ok(false, "Window never moved properly to " + x + "," + y); + window.arguments[0].SimpleTest.finish(); + window.close(); + } + + setTimeout(moveWindowTo, 100, x, y, callback, arg); + } + else { + waitSteps = 0; + callback(arg); + } +} + +function popupHidden() +{ + gTestIndex++; + if (gTestIndex == gTests.length) { + window.arguments[0].SimpleTest.finish(); + window.close(); + } + else if (gTests[gTestIndex] == "context menu enough space below") { + gContextMenuTests = true; + moveWindowTo(window.screenX, screen.availTop + 10, + () => synthesizeMouse(document.getElementById("label"), 4, 4, { type: "contextmenu", button: 2 })); + } + else if (gTests[gTestIndex] == "menu movement") { + document.getElementById("popup").openPopup( + document.getElementById("label"), "after_start", 0, 0, false, false); + } + else if (gTests[gTestIndex] == "panel movement") { + document.getElementById("panel").openPopup( + document.getElementById("label"), "after_start", 0, 0, false, false); + } + else if (gContextMenuTests) { + contextMenuPopupHidden(); + } + else { + nextTest(); + } +} + +function contextMenuPopupShown() +{ + var popup = document.getElementById("popup"); + var rect = popup.getBoundingClientRect(); + var marginTop = parseFloat(getComputedStyle(popup).marginTop); + var marginLeft = parseFloat(getComputedStyle(popup).marginLeft); + var labelrect = document.getElementById("label").getBoundingClientRect(); + + // Click to open popup in popupHidden() occurs at (4,4) in label's coordinate space + var clickX = 4; + var clickY = 4; + + info(`${gTests[gTestIndex]}: ${JSON.stringify(rect)}`); + + var testPopupAppearedRightOfCursor = true; + switch (gTests[gTestIndex]) { + case "context menu enough space below": + is(rect.top - marginTop, labelrect.top + clickY + (platformIsMac() ? -6 : 2), gTests[gTestIndex] + " top"); + break; + case "context menu more space above": + if (platformIsMac()) { + let screenY; + [, screenY] = getScreenXY(popup); + // Macs constrain their popup menus vertically rather than flip them. + is(screenY, screen.availTop + screen.availHeight - rect.height, gTests[gTestIndex] + " top"); + } else { + is(rect.top + marginTop, labelrect.top + clickY - rect.height - 2, gTests[gTestIndex] + " top"); + } + + break; + case "context menu too big either side": + [, gScreenY] = getScreenXY(document.documentElement); + // compare against the available size as well as the total size, as some + // platforms allow the menu to overlap os chrome and others do not + var pos = (screen.availTop + screen.availHeight - rect.height) - gScreenY - marginTop; + var availPos = (screen.top + screen.height - rect.height) - gScreenY - marginTop; + ok(rect.top == pos || rect.top == availPos, + gTests[gTestIndex] + ` top (${pos}/${availPos})`); + break; + case "context menu larger than screen": + ok(rect.top == -(gScreenY - screen.availTop) + marginTop || rect.top == -(gScreenY - screen.top) + marginTop, + `${gTests[gTestIndex]} top (top = ${rect.top} screenY = ${gScreenY} screenAvailTop = ${screen.availTop} screenTop = ${screen.top})`); + break; + case "context menu flips horizontally on osx": + testPopupAppearedRightOfCursor = false; + if (platformIsMac()) { + is(Math.round(rect.right), labelrect.left + clickX - 1, gTests[gTestIndex] + " right"); + } + break; + } + + if (testPopupAppearedRightOfCursor) { + is(rect.left - marginLeft, labelrect.left + clickX + (platformIsMac() ? 1 : 2), gTests[gTestIndex] + " left"); + } + + hidePopup(); +} + +function contextMenuPopupHidden() +{ + var screenAvailBottom = screen.availTop + screen.availHeight; + + if (gTests[gTestIndex] == "context menu more space above") { + moveWindowTo(window.screenX, screenAvailBottom - 80, nextContextMenuTest, -1); + } + else if (gTests[gTestIndex] == "context menu too big either side") { + moveWindowTo(window.screenX, screenAvailBottom / 2 - 80, nextContextMenuTest, screenAvailBottom / 2 + 120); + } + else if (gTests[gTestIndex] == "context menu larger than screen") { + nextContextMenuTest(screen.availHeight + 80); + } + else if (gTests[gTestIndex] == "context menu flips horizontally on osx") { + var popup = document.getElementById("popup"); + var popupWidth = popup.getBoundingClientRect().width; + moveWindowTo(screen.availLeft + screen.availWidth - popupWidth, 100, nextContextMenuTest, -1); + } +} + +function nextContextMenuTest(desiredHeight) +{ + if (desiredHeight >= 0) { + var popup = document.getElementById("popup"); + var height = popup.getBoundingClientRect().height; + var itemheight = document.getElementById("firstitem").getBoundingClientRect().height; + while (height < desiredHeight) { + var menu = document.createXULElement("menuitem"); + menu.setAttribute("label", "Item"); + popup.appendChild(menu); + height += itemheight; + } + } + + synthesizeMouse(document.getElementById("label"), 4, 4, { type: "contextmenu", button: 2 }); +} + +function testPopupMovement() +{ + var isPanelTest = (gTests[gTestIndex] == "panel movement"); + var popup = document.getElementById(isPanelTest ? "panel" : "popup"); + + var screenX, screenY; + var rect = popup.getBoundingClientRect(); + var marginBottom = parseFloat(getComputedStyle(popup).marginBottom); + var marginLeft = parseFloat(getComputedStyle(popup).marginLeft); + var marginTop = parseFloat(getComputedStyle(popup).marginTop); + + var panelIsTop = SpecialPowers.getBoolPref("ui.panel.default_level_parent"); + var overlapOSChrome = !platformIsMac() && (!isPanelTest || panelIsTop); + popup.moveTo(1, 1); + [screenX, screenY] = getScreenXY(popup); + + var expectedx = 1, expectedy = 1; + if (!overlapOSChrome) { + if (screen.availLeft >= 1) expectedx = screen.availLeft; + if (screen.availTop >= 1) expectedy = screen.availTop; + } + is(screenX, expectedx, gTests[gTestIndex] + " (1, 1) x"); + is(screenY, expectedy, gTests[gTestIndex] + " (1, 1) y"); + + popup.moveTo(100, 8000); + expectedy = (overlapOSChrome ? screen.height + screen.top : screen.availHeight + screen.availTop) - + Math.round(rect.height) - marginBottom; + [screenX, screenY] = getScreenXY(popup); + is(screenX, 100, gTests[gTestIndex] + " (100, 8000) x"); + is(screenY, expectedy, gTests[gTestIndex] + " (100, 8000) y"); + + popup.moveTo(6000, 100); + + expectedx = (overlapOSChrome ? screen.width + screen.left : screen.availWidth + screen.availLeft) - + Math.round(rect.width) - marginLeft; + [screenX, screenY] = getScreenXY(popup); + is(screenX, expectedx, gTests[gTestIndex] + " (6000, 100) x"); + is(screenY, 100, gTests[gTestIndex] + " (6000, 100) y"); + + is(popup.getAttribute("left"), "", gTests[gTestIndex] + " left is empty after moving"); + is(popup.getAttribute("top"), "", gTests[gTestIndex] + " top is empty after moving"); + popup.setAttribute("left", "80"); + popup.setAttribute("top", "82"); + [screenX, screenY] = getScreenXY(popup); + is(screenX, 80, gTests[gTestIndex] + " set left and top x"); + is(screenY, 82, gTests[gTestIndex] + " set left and top y"); + popup.moveTo(95, 98); + [screenX, screenY] = getScreenXY(popup); + is(screenX, 95, gTests[gTestIndex] + " move after set left and top x"); + is(screenY, 98, gTests[gTestIndex] + " move after set left and top y"); + is(popup.getAttribute("left"), "95", gTests[gTestIndex] + " left is set after moving"); + is(popup.getAttribute("top"), "98", gTests[gTestIndex] + " top is set after moving"); + popup.removeAttribute("left"); + popup.removeAttribute("top"); + + popup.moveTo(-1 + marginLeft, -1 + marginTop); + [screenX, screenY] = getScreenXY(popup); + + expectedx = (overlapOSChrome ? screen.left : screen.availLeft) + marginLeft; + expectedy = (overlapOSChrome ? screen.top : screen.availTop) + marginTop; + + is(screenX, expectedx, gTests[gTestIndex] + " move after set left and top x to -1"); + is(screenY, expectedy, gTests[gTestIndex] + " move after set left and top y to -1"); + is(popup.getAttribute("left"), "", gTests[gTestIndex] + " left is not set after moving to -1"); + is(popup.getAttribute("top"), "", gTests[gTestIndex] + " top is not set after moving to -1"); + + popup.hidePopup(); +} + +function platformIsMac() +{ + return navigator.platform.indexOf("Mac") > -1; +} + +window.arguments[0].SimpleTest.waitForFocus(runTests, window); + +]]> +</script> + +<button id="label" label="OK" context="popup"/> +<menupopup id="popup" onpopupshown="popupShown();" onpopuphidden="popupHidden();" + onoverflow="gOverflowed = true" onunderflow="gUnderflowed = true;"> + <menuitem id="firstitem" label="1"/> + <menuitem label="2"/> + <menuitem label="3"/> + <menuitem label="4"/> + <menuitem label="5"/> + <menuitem label="6"/> + <menuitem label="7"/> + <menuitem label="8"/> + <menuitem label="9"/> + <menuitem label="10"/> + <menuitem label="11"/> + <menuitem label="12"/> + <menuitem label="13"/> + <menuitem label="14"/> + <menuitem label="15"/> +</menupopup> + +<panel id="panel" onpopupshown="testPopupMovement();" onpopuphidden="popupHidden();" style="margin: 0; -moz-window-input-region-margin: 0;"> + <button label="OK"/> +</panel> + +</window> diff --git a/toolkit/content/tests/chrome/window_maximized_persist.xhtml b/toolkit/content/tests/chrome/window_maximized_persist.xhtml new file mode 100644 index 0000000000..f7eb695f0f --- /dev/null +++ b/toolkit/content/tests/chrome/window_maximized_persist.xhtml @@ -0,0 +1,15 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<window title="Window Open Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + height="300" + width="300" + sizemode="normal" + id="window" + persist="height width sizemode"> +<script type="application/javascript"><![CDATA[ + window.addEventListener("sizemodechange", evt => { + window.arguments[0].postMessage("sizemodechange", "*"); + }); +]]></script> +</window> diff --git a/toolkit/content/tests/chrome/window_maximized_persist_with_no_titlebar.xhtml b/toolkit/content/tests/chrome/window_maximized_persist_with_no_titlebar.xhtml new file mode 100644 index 0000000000..83fede7fae --- /dev/null +++ b/toolkit/content/tests/chrome/window_maximized_persist_with_no_titlebar.xhtml @@ -0,0 +1,16 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<window title="Window Open Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + height="300" + width="300" + sizemode="normal" + chromemargin="0,2,2,2" + id="window" + persist="height width sizemode"> +<script type="application/javascript"><![CDATA[ + window.addEventListener("sizemodechange", evt => { + window.arguments[0].postMessage("sizemodechange", "*"); + }); +]]></script> +</window> diff --git a/toolkit/content/tests/chrome/window_navigate_persist.html b/toolkit/content/tests/chrome/window_navigate_persist.html new file mode 100644 index 0000000000..8b45212bed --- /dev/null +++ b/toolkit/content/tests/chrome/window_navigate_persist.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html dir="" + id="persist-window" + width="300" height="300" + persist="screenX screenY width height sizemode"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> + </head> + <body> + </body> +</html> diff --git a/toolkit/content/tests/chrome/window_panel.xhtml b/toolkit/content/tests/chrome/window_panel.xhtml new file mode 100644 index 0000000000..e13d15b390 --- /dev/null +++ b/toolkit/content/tests/chrome/window_panel.xhtml @@ -0,0 +1,294 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> +<!-- + XUL Widget Test for panels + --> +<window title="Titlebar" width="200" height="200" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<html:style> +<![CDATA[ + panel, panel::part(content) { + border: 0; + margin: 0; + padding: 0; + } +]]> +</html:style> + +<tree id="tree" seltype="single" width="100" height="100"> + <treecols> + <treecol flex="1"/> + <treecol flex="1"/> + </treecols> + <treechildren id="treechildren"> + <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem> + <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem> + <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem> + <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem> + <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem> + <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem> + </treechildren> +</tree> + + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var currentTest = null; + +function ok(condition, message) { + window.arguments[0].SimpleTest.ok(condition, message); +} + +function is(left, right, message) { + window.arguments[0].SimpleTest.is(left, right, message); +} + +function test_panels() +{ + checkTreeCoords(); + + addEventListener("popupshowing", popupShowing, false); + addEventListener("popupshown", popupShown, false); + addEventListener("popuphidden", nextTest, false); + nextTest(); +} + +function nextTest() +{ + if (!tests.length) { + window.close(); + window.arguments[0].SimpleTest.finish(); + return; + } + + currentTest = tests.shift(); + var panel = createPanel(currentTest.attrs); + currentTest.test(panel); +} + +function popupShowing(event) +{ + var rect = event.target.getOuterScreenRect(); + ok(!rect.left && !rect.top && !rect.width && !rect.height, + currentTest.testname + " empty rectangle during popupshowing"); +} + +var waitSteps = 0; +function popupShown(event) +{ + var panel = event.target; + + if (waitSteps > 0 && navigator.platform.includes("Linux") && + panel.screenY == 210) { + waitSteps--; + setTimeout(popupShown, 10, event); + return; + } + + currentTest.result(currentTest.testname + " ", panel); + panel.hidePopup(); +} + +function createPanel(attrs) +{ + var panel = document.createXULElement("panel"); + for (var a in attrs) { + panel.setAttribute(a, attrs[a]); + } + + var button = document.createXULElement("button"); + panel.appendChild(button); + button.label = "OK"; + button.setAttribute("style", "appearance: none; border: 0; margin: 0; width: 120px; height: 40px;"); + panel.setAttribute("style", "appearance: none; border: 0; margin: 0;"); + return document.documentElement.appendChild(panel); +} + +function checkTreeCoords() +{ + var tree = $("tree"); + var treechildren = $("treechildren"); + tree.currentIndex = 0; + tree.scrollToRow(0); + synthesizeMouse(treechildren, 10, tree.rowHeight + 2, { }); + is(tree.currentIndex, 1, "tree selection"); + + tree.scrollToRow(2); + synthesizeMouse(treechildren, 10, tree.rowHeight + 2, { }); + is(tree.currentIndex, 3, "tree selection after scroll"); +} + +var tests = [ + { + testname: "normal panel", + attrs: { }, + test(panel) { + var screenRect = panel.getOuterScreenRect(); + is(screenRect.left, 0, this.testname + " screen left before open"); + is(screenRect.top, 0, this.testname + " screen top before open"); + is(screenRect.width, 0, this.testname + " screen width before open"); + is(screenRect.height, 0, this.testname + " screen height before open"); + + panel.openPopupAtScreen(200, 210); + }, + result(testname, panel) { + var panelrect = panel.getBoundingClientRect(); + is(panelrect.left, 200 - window.mozInnerScreenX, testname + "left"); + is(panelrect.top, 210 - window.mozInnerScreenY, testname + "top"); + is(panelrect.width, 120, testname + "width"); + is(panelrect.height, 40, testname + "height"); + + var screenRect = panel.getOuterScreenRect(); + is(screenRect.left, 200, testname + " screen left"); + is(screenRect.top, 210, testname + " screen top"); + is(screenRect.width, 120, testname + " screen width"); + is(screenRect.height, 40, testname + " screen height"); + } + }, + { + // only noautohide panels support titlebars, so one shouldn't be shown here + testname: "autohide panel with titlebar", + attrs: { titlebar: "normal" }, + test(panel) { + var screenRect = panel.getOuterScreenRect(); + is(screenRect.left, 0, this.testname + " screen left before open"); + is(screenRect.top, 0, this.testname + " screen top before open"); + is(screenRect.width, 0, this.testname + " screen width before open"); + is(screenRect.height, 0, this.testname + " screen height before open"); + + panel.openPopupAtScreen(200, 210); + }, + result(testname, panel) { + var panelrect = panel.getBoundingClientRect(); + is(panelrect.left, 200 - window.mozInnerScreenX, testname + "left"); + is(panelrect.top, 210 - window.mozInnerScreenY, testname + "top"); + is(panelrect.width, 120, testname + "width"); + is(panelrect.height, 40, testname + "height"); + + var screenRect = panel.getOuterScreenRect(); + is(screenRect.left, 200, testname + " screen left"); + is(screenRect.top, 210, testname + " screen top"); + is(screenRect.width, 120, testname + " screen width"); + is(screenRect.height, 40, testname + " screen height"); + } + }, + { + testname: "noautohide panel with titlebar", + attrs: { noautohide: true, titlebar: "normal" }, + test(panel) { + waitSteps = 25; + + var screenRect = panel.getOuterScreenRect(); + is(screenRect.left, 0, this.testname + " screen left before open"); + is(screenRect.top, 0, this.testname + " screen top before open"); + is(screenRect.width, 0, this.testname + " screen width before open"); + is(screenRect.height, 0, this.testname + " screen height before open"); + + panel.openPopupAtScreen(200, 210); + }, + result(testname, panel) { + var panelrect = panel.getBoundingClientRect(); + ok(panelrect.left >= 200 - window.mozInnerScreenX, testname + "left"); + if (!navigator.platform.includes("Linux")) { + ok(panelrect.top >= 210 - window.mozInnerScreenY, testname + "top greater " + panelrect.top + " " + window.mozInnerScreenY); + } + ok(panelrect.top <= 210 - window.mozInnerScreenY + 36, testname + "top less"); + is(panelrect.width, 120, testname + "width"); + is(panelrect.height, 40, testname + "height"); + + var screenRect = panel.getOuterScreenRect(); + if (!navigator.platform.includes("Linux")) { + is(screenRect.left, 200, testname + " screen left"); + is(screenRect.top, 210, testname + " screen top"); + } + ok(screenRect.width >= 120 && screenRect.width <= 140, testname + " screen width"); + ok(screenRect.height >= 40 && screenRect.height <= 118, testname + " screen height"); + + var gotMouseEvent = false; + function mouseMoved(event) + { + is(event.clientY, panelrect.top + 10, + "popup clientY"); + is(event.screenY, panel.screenY + 10, + "popup screenY"); + is(event.originalTarget, panel.firstChild, "popup target"); + gotMouseEvent = true; + } + + panel.addEventListener("mousemove", mouseMoved, true); + synthesizeMouse(panel, 10, 10, { type: "mousemove" }); + ok(gotMouseEvent, "mouse event on panel"); + panel.removeEventListener("mousemove", mouseMoved, true); + + var tree = $("tree"); + tree.currentIndex = 0; + panel.appendChild(tree); + checkTreeCoords(); + } + }, + { + // The panel should be allowed to appear and remain offscreen + testname: "normal panel with flip='none' off-screen", + attrs: { "flip": "none" }, + test(panel) { + panel.openPopup(document.documentElement, "", -100 - window.mozInnerScreenX, -100 - window.mozInnerScreenY, false, false, null); + }, + result(testname, panel) { + var panelrect = panel.getBoundingClientRect(); + is(panelrect.left, -100 - window.mozInnerScreenX, testname + "left"); + is(panelrect.top, -100 - window.mozInnerScreenY, testname + "top"); + is(panelrect.width, 120, testname + "width"); + is(panelrect.height, 40, testname + "height"); + + var screenRect = panel.getOuterScreenRect(); + is(screenRect.left, -100, testname + " screen left"); + is(screenRect.top, -100, testname + " screen top"); + is(screenRect.width, 120, testname + " screen width"); + is(screenRect.height, 40, testname + " screen height"); + } + }, + { + // The panel should be allowed to remain offscreen after moving and it should follow the anchor + testname: "normal panel with flip='none' moved off-screen", + attrs: { "flip": "none" }, + test(panel) { + panel.openPopup(document.documentElement, "", -100 - window.mozInnerScreenX, -100 - window.mozInnerScreenY, false, false, null); + window.moveBy(-50, -50); + }, + result(testname, panel) { + if (navigator.platform.includes("Linux")) { + // The window position doesn't get updated immediately on Linux. + return; + } + var panelrect = panel.getBoundingClientRect(); + is(panelrect.left, -150 - window.mozInnerScreenX, testname + "left"); + is(panelrect.top, -150 - window.mozInnerScreenY, testname + "top"); + is(panelrect.width, 120, testname + "width"); + is(panelrect.height, 40, testname + "height"); + + var screenRect = panel.getOuterScreenRect(); + is(screenRect.left, -150, testname + " screen left"); + is(screenRect.top, -150, testname + " screen top"); + is(screenRect.width, 120, testname + " screen width"); + is(screenRect.height, 40, testname + " screen height"); + } + }, +]; + +window.arguments[0].SimpleTest.waitForFocus(test_panels, window); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/window_panel_anchoradjust.xhtml b/toolkit/content/tests/chrome/window_panel_anchoradjust.xhtml new file mode 100644 index 0000000000..735ecf26b6 --- /dev/null +++ b/toolkit/content/tests/chrome/window_panel_anchoradjust.xhtml @@ -0,0 +1,193 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window width="200" height="200" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<deck id="deck"> + <hbox id="container"> + <button id="anchor" label="Anchor"/> + </hbox> + <button id="anchor3" label="Anchor3"/> +</deck> + +<hbox id="container2"> + <button id="anchor2" label="Anchor2"/> +</hbox> + +<button id="anchor4" label="Anchor4"/> + +<panel id="panel" type="arrow"> + <button label="OK"/> +</panel> + +<menupopup id="menupopup"> + <menuitem label="One"/> + <menuitem id="menuanchor" label="Two"/> + <menuitem label="Three"/> +</menupopup> + +<script><![CDATA[ + + +SimpleTest.waitForExplicitFinish(); + +function next() +{ + return new Promise(r => { + requestAnimationFrame(() => requestAnimationFrame(r)); + }) +} + +function waitForPanel(panel, event) +{ + return new Promise(resolve => { + panel.addEventListener(event, () => { resolve(); }, { once: true }); + }); +} + +function isWithinHalfPixel(a, b, message) +{ + ok(Math.abs(a - b) <= 0.5, `${message}: ${a}, ${b}`); +} + +function getPanelPos(panel) { + let {left, top, bottom, right} = panel.getBoundingClientRect(); + left -= parseFloat(getComputedStyle(panel).marginLeft); + top -= parseFloat(getComputedStyle(panel).marginTop); + bottom += parseFloat(getComputedStyle(panel).marginBottom); + right += parseFloat(getComputedStyle(panel).marginRight); + return {left, top, bottom, right}; +} + +async function runTests() { + let panel = document.getElementById("panel"); + let anchor = document.getElementById("anchor"); + + let popupshown = waitForPanel(panel, "popupshown"); + panel.openPopup(anchor, "after_start"); + info("popupshown"); + await popupshown; + + let anchorrect = anchor.getBoundingClientRect(); + let panelpos = getPanelPos(panel); + let xarrowdiff = panelpos.left - anchorrect.left; + + // When the anchor is moved in some manner, the panel should be adjusted + let popuppositioned = waitForPanel(panel, "popuppositioned"); + document.getElementById("anchor").style.marginLeft = "50px" + info("before popuppositioned"); + await popuppositioned; + info("after popuppositioned"); + + anchorrect = anchor.getBoundingClientRect(); + panelpos = getPanelPos(panel); + isWithinHalfPixel(anchorrect.left, panelpos.left - xarrowdiff, "anchor moved x"); + isWithinHalfPixel(anchorrect.bottom, panelpos.top, "anchor moved y"); + + // moveToAnchor is used to change the anchor + let anchor2 = document.getElementById("anchor2"); + popuppositioned = waitForPanel(panel, "popuppositioned"); + panel.moveToAnchor(anchor2, "after_end"); + info("before popuppositioned 2"); + await popuppositioned; + info("after popuppositioned 2"); + + let anchor2rect = anchor2.getBoundingClientRect(); + panelpos = getPanelPos(panel); + isWithinHalfPixel(anchor2rect.right, panelpos.right + xarrowdiff, "new anchor x"); + isWithinHalfPixel(anchor2rect.bottom, panelpos.top, "new anchor y"); + + // moveToAnchor is used to change the anchor with an x and y offset + popuppositioned = waitForPanel(panel, "popuppositioned"); + panel.moveToAnchor(anchor2, "after_end", 7, 9); + await popuppositioned; + + anchor2rect = anchor2.getBoundingClientRect(); + panelpos = getPanelPos(panel); + isWithinHalfPixel(anchor2rect.right + 7, panelpos.right + xarrowdiff, "new anchor with offset x"); + isWithinHalfPixel(anchor2rect.bottom + 9, panelpos.top, "new anchor with offset y"); + + // When the container of the anchor is collapsed, the panel should be hidden. + let popuphidden = waitForPanel(panel, "popuphidden"); + anchor2.parentNode.collapsed = true; + await popuphidden; + + popupshown = waitForPanel(panel, "popupshown"); + panel.openPopup(anchor, "after_start"); + await popupshown; + + // When the deck containing the anchor changes to a different page, the panel should be hidden. + popuphidden = waitForPanel(panel, "popuphidden"); + document.getElementById("deck").selectedIndex = 1; + await popuphidden; + + let anchor3 = document.getElementById("anchor3"); + popupshown = waitForPanel(panel, "popupshown"); + panel.openPopup(anchor3, "after_start"); + await popupshown; + + // When the anchor is hidden; the panel should be hidden. + popuphidden = waitForPanel(panel, "popuphidden"); + anchor3.parentNode.hidden = true; + await popuphidden; + + // When the panel is anchored to an element in a popup, the panel should + // also be hidden when that popup is hidden. + let menupopup = document.getElementById("menupopup"); + popupshown = waitForPanel(menupopup, "popupshown"); + menupopup.openPopupAtScreen(200, 200); + await popupshown; + + popupshown = waitForPanel(panel, "popupshown"); + panel.openPopup(document.getElementById("menuanchor"), "after_start"); + await popupshown; + + popuphidden = waitForPanel(panel, "popuphidden"); + let menupopuphidden = waitForPanel(menupopup, "popuphidden"); + menupopup.hidePopup(); + await popuphidden; + await menupopuphidden; + + // The panel should no longer follow anchors. + panel.setAttribute("followanchor", "false"); + + let anchor4 = document.getElementById("anchor4"); + popupshown = waitForPanel(panel, "popupshown"); + panel.openPopup(anchor4, "after_start"); + await popupshown; + + let anchor4rect = anchor4.getBoundingClientRect(); + + anchor4.style.marginLeft = "50px" + await next(); + + panelpos = getPanelPos(panel); + isWithinHalfPixel(anchor4rect.left, panelpos.left - xarrowdiff, "no follow anchor x"); + isWithinHalfPixel(anchor4rect.bottom, panelpos.top, "no follow anchor y"); + + popuphidden = waitForPanel(panel, "popuphidden"); + panel.hidePopup(); + await popuphidden; + + window.close(); + window.arguments[0].SimpleTest.finish(); +} + +function ok(condition, message) { + window.arguments[0].SimpleTest.ok(condition, message); +} + +function is(left, right, message) { + window.arguments[0].SimpleTest.is(left, right, message); +} + +window.arguments[0].SimpleTest.waitForFocus(runTests, window); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/chrome/window_panel_focus.xhtml b/toolkit/content/tests/chrome/window_panel_focus.xhtml new file mode 100644 index 0000000000..962e43db58 --- /dev/null +++ b/toolkit/content/tests/chrome/window_panel_focus.xhtml @@ -0,0 +1,132 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Panel Focus Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<checkbox id="b1" label="Item 1"/> + +<!-- Focus should be in this order: 2 6 3 8 1 4 5 7 9 --> +<panel id="panel" norestorefocus="true" onpopupshown="panelShown()" onpopuphidden="panelHidden()"> + <button id="t1" label="Button One"/> + <button id="t2" tabindex="1" label="Button Two" onblur="gButtonBlur++;"/> + <button id="t3" tabindex="2" label="Button Three"/> + <button id="t4" tabindex="0" label="Button Four"/> + <button id="t5" label="Button Five"/> + <button id="t6" tabindex="1" label="Button Six"/> + <button id="t7" label="Button Seven"/> + <button id="t8" tabindex="4" label="Button Eight"/> + <button id="t9" label="Button Nine"/> +</panel> + +<panel id="noautofocusPanel" noautofocus="true" + onpopupshown="noautofocusPanelShown()" onpopuphidden="noautofocusPanelHidden()"> + <html:input id="tb3"/> +</panel> + +<checkbox id="b2" label="Item 2" popup="panel" onblur="gButtonBlur++;"/> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +var gButtonBlur = 0; + +function showPanel() +{ + // click on the document so that the window has focus + synthesizeMouse(document.documentElement, 1, 1, { }); + + // focus the button + synthesizeKeyExpectEvent("KEY_Tab", {}, $("b1"), "focus", "button focus"); + // tabbing again should skip the popup + synthesizeKeyExpectEvent("KEY_Tab", {}, $("b2"), "focus", "popup skipped in focus navigation"); + + $("panel").openPopup(null, "", 10, 10, false, false); +} + +function panelShown() +{ + // the focus on the button should have been removed when the popup was opened + is(gButtonBlur, 1, "focus removed when popup opened"); + + // press tab numerous times to cycle through the buttons. The t2 button will + // be blurred twice, so gButtonBlur will be 3 afterwards. + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t2"), "focus", "tabindex 1"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t6"), "focus", "tabindex 2"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t3"), "focus", "tabindex 3"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t8"), "focus", "tabindex 4"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t1"), "focus", "tabindex 5"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t4"), "focus", "tabindex 6"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t5"), "focus", "tabindex 7"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t7"), "focus", "tabindex 8"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t9"), "focus", "tabindex 9"); + synthesizeKeyExpectEvent("KEY_Tab", {}, $("t2"), "focus", "tabindex 10"); + + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t9"), "focus", "back tabindex 1"); + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t7"), "focus", "back tabindex 2"); + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t5"), "focus", "back tabindex 3"); + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t4"), "focus", "back tabindex 4"); + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t1"), "focus", "back tabindex 5"); + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t8"), "focus", "back tabindex 6"); + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t3"), "focus", "back tabindex 7"); + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t6"), "focus", "back tabindex 8"); + synthesizeKeyExpectEvent("KEY_Tab", {shiftKey: true}, $("t2"), "focus", "back tabindex 9"); + + is(gButtonBlur, 3, "blur events fired within popup"); + + synthesizeKey("KEY_Escape"); +} + +function ok(condition, message) { + window.arguments[0].SimpleTest.ok(condition, message); +} + +function is(left, right, message) { + window.arguments[0].SimpleTest.is(left, right, message); +} + +function panelHidden() +{ + // closing the popup should have blurred the focused element + is(gButtonBlur, 4, "focus removed when popup closed"); + + // now that the panel is hidden, pressing tab should focus the elements in + // the main window again + synthesizeKeyExpectEvent("KEY_Tab", {}, $("b1"), "focus", "focus after popup closed"); + + $("noautofocusPanel").openPopup(null, "", 10, 10, false, false); +} + +function noautofocusPanelShown() +{ + // with noautofocus="true", the focus should not be removed when the panel is + // opened, so key events should still be fired at the checkbox. + synthesizeKeyExpectEvent(" ", {}, $("b1"), "command", "noautofocus"); + $("noautofocusPanel").hidePopup(); +} + +function noautofocusPanelHidden() +{ + window.close(); + window.arguments[0].SimpleTest.finish(); +} + +window.arguments[0].SimpleTest.waitForFocus(showPanel, window); + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/window_popup_anchor.xhtml b/toolkit/content/tests/chrome/window_popup_anchor.xhtml new file mode 100644 index 0000000000..4f8e88035d --- /dev/null +++ b/toolkit/content/tests/chrome/window_popup_anchor.xhtml @@ -0,0 +1,28 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Popup Anchor Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<script> +function runTests() +{ + frames[0].openPopup(); +} + +window.arguments[0].SimpleTest.waitForFocus(runTests, window); +</script> + +<spacer style="height: 13px"/> +<button id="outerbutton" label="Button One" style="margin-left: 6px; -moz-appearance: none;"/> +<hbox> + <spacer style="width: 20px"/> + <deck> + <vbox> + <iframe id="frame" style="margin-left: 60px; margin-top: 10px; border-left: 17px solid red; padding-left: 0 !important; padding-top: 3px; width: 250px; height: 80px" + src="frame_popup_anchor.xhtml"/> + </vbox> + </deck> +</hbox> + +</window> diff --git a/toolkit/content/tests/chrome/window_popup_anchoratrect.xhtml b/toolkit/content/tests/chrome/window_popup_anchoratrect.xhtml new file mode 100644 index 0000000000..524a95b643 --- /dev/null +++ b/toolkit/content/tests/chrome/window_popup_anchoratrect.xhtml @@ -0,0 +1,130 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onpopupshown="popupshown(event.target)" onpopuphidden="nextTest()"> + +<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<label value="Popup Test"/> + +<menupopup id="popup"> + <menuitem label="One"/> + <menuitem label="Two"/> +</menupopup> + +<panel id="panel" noautohide="true" height="20"> + <label value="OK"/> +</panel> + +<script> +<![CDATA[ + +let menupopup; +function margins(popup) { + let ret = {}; + let cs = getComputedStyle(popup); + for (let side of ["top", "right", "bottom", "left"]) { + ret[side] = parseFloat(cs.getPropertyValue("margin-" + side)); + } + return ret; +} + +let tests = [ + { + test: () => menupopup.openPopupAtScreenRect("after_start", 150, 250, 30, 40), + verify: popup => { + let rect = popup.getOuterScreenRect(); + let margin = margins(popup); + is(rect.left - margin.left, 150, "popup at screen position x"); + is(rect.top - margin.top, 290, "popup at screen position y"); + } + }, + { + test: () => menupopup.openPopupAtScreenRect("after_start", 150, 350, 30, 9000), + verify: popup => { + let rect = popup.getOuterScreenRect(); + let margin = margins(popup); + is(rect.left - margin.left, 150, "flipped popup at screen position x"); + is(rect.bottom + margin.bottom, 350, "flipped popup at screen position y"); + } + }, + { + test: () => menupopup.openPopupAtScreenRect("end_before", 150, 250, 30, 40), + verify: popup => { + let rect = popup.getOuterScreenRect(); + let margin = margins(popup); + is(rect.left - margin.left, 180, "popup at end_before screen position x"); + is(rect.top - margin.top, 250, "popup at end_before screen position y"); + } + }, + { + test: () => $("panel").openPopupAtScreenRect("after_start", 150, 250, 30, 40), + verify: popup => { + let rect = popup.getOuterScreenRect(); + let margin = margins(popup); + is(rect.left - margin.left, 150, "panel at screen position x"); + is(rect.top - margin.top, 290, "panel at screen position y"); + } + }, + { + test: () => $("panel").openPopupAtScreenRect("before_start", 150, 250, 30, 40), + verify: popup => { + let rect = popup.getOuterScreenRect(); + let margin = margins(popup); + is(rect.left - margin.left, 150, "panel at before_start screen position x"); + is(rect.bottom + margin.bottom, 250, "panel at before_start screen position y"); + } + }, +]; + +function runTest(id) +{ + menupopup = $("popup"); + nextTest(); +} + +function nextTest() +{ + if (!tests.length) { + window.close(); + window.arguments[0].SimpleTest.finish(); + return; + } + + tests[0].test(); +} + +function popupshown(popup) +{ + tests[0].verify(popup); + tests.shift(); + popup.hidePopup(); +} + +function is(left, right, message) +{ + window.arguments[0].SimpleTest.is(left, right, message); +} + +function ok(value, message) +{ + window.arguments[0].SimpleTest.ok(value, message); +} + +window.arguments[0].SimpleTest.waitForFocus(runTest, window); + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/window_popup_attribute.xhtml b/toolkit/content/tests/chrome/window_popup_attribute.xhtml new file mode 100644 index 0000000000..00c8e5a721 --- /dev/null +++ b/toolkit/content/tests/chrome/window_popup_attribute.xhtml @@ -0,0 +1,45 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Popup Attribute Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script src="popup_shared.js"></script> + <script src="popup_trigger.js"></script> + +<html:style> + menupopup { + margin: 0; + --panel-padding: 0; + -moz-window-input-region-margin: 0; + } +</html:style> + +<script> +window.opener.SimpleTest.waitForFocus(runTests, window); +</script> + +<hbox style="margin-left: 200px; margin-top: 340px;"> + <label id="trigger" popup="thepopup" value="Popup" height="60"/> +</hbox> + +<menupopup id="thepopup"> + <menuitem id="item1" label="First"/> + <menuitem id="item2" label="Main Item"/> + <menuitem id="amenu" label="A Menu" accesskey="M"/> + <menuitem id="item3" label="Third"/> + <menuitem id="one" label="One"/> + <menuitem id="fancier" label="Fancier Menu"/> + <menu id="submenu" label="Only Menu"> + <menupopup id="submenupopup"> + <menuitem id="submenuitem" label="Test Submenu"/> + </menupopup> + </menu> + <menuitem id="other" disabled="true" label="Other Menu"/> + <menuitem id="secondlast" label="Second Last Menu" accesskey="T"/> + <menuitem id="last" label="One Other Menu"/> +</menupopup> + +</window> diff --git a/toolkit/content/tests/chrome/window_popup_button.xhtml b/toolkit/content/tests/chrome/window_popup_button.xhtml new file mode 100644 index 0000000000..f2456ca5a9 --- /dev/null +++ b/toolkit/content/tests/chrome/window_popup_button.xhtml @@ -0,0 +1,45 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Popup Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> +<script src="popup_shared.js"></script> +<script src="popup_trigger.js"></script> + +<html:style> + menupopup { + margin: 0; + --panel-padding: 0; + -moz-window-input-region-margin: 0; + } +</html:style> + +<script> +window.opener.SimpleTest.waitForFocus(runTests, window); +</script> + +<hbox style="margin-left: 200px; margin-top: 340px;"> + <button id="trigger" type="menu" label="Popup" width="100" height="50"> + <menupopup id="thepopup"> + <menuitem id="item1" label="First"/> + <menuitem id="item2" label="Main Item"/> + <menuitem id="amenu" label="A Menu" accesskey="M"/> + <menuitem id="item3" label="Third"/> + <menuitem id="one" label="One"/> + <menuitem id="fancier" label="Fancier Menu"/> + <menu id="submenu" label="Only Menu"> + <menupopup id="submenupopup"> + <menuitem id="submenuitem" label="Test Submenu"/> + </menupopup> + </menu> + <menuitem id="other" disabled="true" label="Other Menu"/> + <menuitem id="secondlast" label="Second Last Menu" accesskey="T"/> + <menuitem id="last" label="One Other Menu"/> + </menupopup> + </button> +</hbox> + +</window> diff --git a/toolkit/content/tests/chrome/window_popup_preventdefault_chrome.xhtml b/toolkit/content/tests/chrome/window_popup_preventdefault_chrome.xhtml new file mode 100644 index 0000000000..43dffc2a76 --- /dev/null +++ b/toolkit/content/tests/chrome/window_popup_preventdefault_chrome.xhtml @@ -0,0 +1,126 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Popup Prevent Default Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<!-- + This tests checks that preventDefault can be called on a popupshowing + event or popuphiding event to prevent the default behaviour. + --> + +<script> + +var gBlockShowing = true; +var gBlockHiding = true; +var gShownNotAllowed = true; +var gHiddenNotAllowed = true; + +var fm = Services.focus; + +var is = function(l, r, v) { window.arguments[0].SimpleTest.is(l, r, v); } +var isnot = function(l, r, v) { window.arguments[0].SimpleTest.isnot(l, r, v); } + +const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" +); + +async function runTest() +{ + var menu = document.getElementById("menu"); + + is(fm.activeWindow, window, "active window at start"); + is(fm.focusedWindow, window, "focused window at start"); + + is(window.windowState, window.STATE_NORMAL, "window is normal"); + // the minimizing test sometimes fails on Linux so don't test it there + if (navigator.platform.indexOf("Lin") == 0) { + menu.open = true; + return; + } + let promiseSizeModeChange = BrowserTestUtils.waitForEvent( + window, + "sizemodechange" + ); + window.minimize(); + await promiseSizeModeChange; + is(window.windowState, window.STATE_MINIMIZED, "window is minimized"); + + isnot(fm.activeWindow, window, "active window after minimize"); + isnot(fm.focusedWindow, window, "focused window after minimize"); + + menu.open = true; + + setTimeout(runTestAfterMinimize, 0); +} + +async function runTestAfterMinimize() +{ + var menu = document.getElementById("menu"); + is(menu.firstChild.state, "closed", "popup not opened when window minimized"); + + let promiseSizeModeChange = BrowserTestUtils.waitForEvent( + window, + "sizemodechange" + ); + window.restore(); + await promiseSizeModeChange; + is(window.windowState, window.STATE_NORMAL, "window is restored"); + + is(fm.activeWindow, window, "active window after restore"); + is(fm.focusedWindow, window, "focused window after restore"); + + menu.open = true; +} + +function popupShowing(event) +{ + if (gBlockShowing) { + event.preventDefault(); + gBlockShowing = false; + setTimeout(function() { + gShownNotAllowed = false; + document.getElementById("menu").open = true; + }, 3000, true); + } +} + +function popupShown() +{ + window.arguments[0].SimpleTest.ok(!gShownNotAllowed, "popupshowing preventDefault"); + document.getElementById("menu").open = false; +} + +function popupHiding(event) +{ + if (gBlockHiding) { + event.preventDefault(); + gBlockHiding = false; + setTimeout(function() { + gHiddenNotAllowed = false; + document.getElementById("menu").open = false; + }, 3000, true); + } +} + +function popupHidden() +{ + window.arguments[0].SimpleTest.ok(!gHiddenNotAllowed, "popuphiding preventDefault"); + window.arguments[0].SimpleTest.finish(); + window.close(); +} + +window.arguments[0].SimpleTest.waitForFocus(runTest, window); +</script> + +<button id="menu" type="menu" label="Menu"> + <menupopup onpopupshowing="popupShowing(event);" + onpopupshown="popupShown();" + onpopuphiding="popupHiding(event);" + onpopuphidden="popupHidden();"> + <menuitem label="Item"/> + </menupopup> +</button> + + +</window> diff --git a/toolkit/content/tests/chrome/window_preferences.xhtml b/toolkit/content/tests/chrome/window_preferences.xhtml new file mode 100644 index 0000000000..273bd7060e --- /dev/null +++ b/toolkit/content/tests/chrome/window_preferences.xhtml @@ -0,0 +1,70 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<!-- + XUL Widget Test for preferences window +--> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + class="prefwindow" + title="preferences window" + windowtype="test:preferences" + onload="RunTest(window.arguments)"> +<dialog id="window_preferences_dialog" + buttons="accept,cancel"> + <script type="application/javascript" src="chrome://global/content/preferencesBindings.js"/> + <script type="application/javascript"> + <![CDATA[ + /* import-globals-from ../../preferencesBindings.js */ + function RunTest(aArgs) + { + setTimeout(() => { + // run test + aArgs[0](this); + // close dialog + let dialog = document.getElementById("window_preferences_dialog"); + dialog[aArgs[1] ? "acceptDialog" : "cancelDialog"](); + }); + } + + Preferences.addAll([ + // one of each type known to Preference.valueFromPreferences + { id: "tests.static_preference_int", type: "int" }, + { id: "tests.static_preference_bool", type: "bool" }, + { id: "tests.static_preference_string", type: "string" }, + { id: "tests.static_preference_wstring", type: "wstring" }, + { id: "tests.static_preference_unichar", type: "unichar" }, + { id: "tests.static_preference_file", type: "file" }, + ]); + ]]> + </script> + + <vbox id="sample_pane" class="prefpane" label="Sample Prefpane"> + + <!-- one element for each preference type above --> + <hbox> + <label flex="1" value="int"/> + <html:input id="static_element_int" preference="tests.static_preference_int"/> + </hbox> + <hbox> + <label flex="1" value="bool"/> + <checkbox id="static_element_bool" preference="tests.static_preference_bool"/> + </hbox> + <hbox> + <label flex="1" value="string"/> + <html:input id="static_element_string" preference="tests.static_preference_string"/> + </hbox> + <hbox> + <label flex="1" value="wstring"/> + <html:input id="static_element_wstring" preference="tests.static_preference_wstring"/> + </hbox> + <hbox> + <label flex="1" value="unichar"/> + <html:input id="static_element_unichar" preference="tests.static_preference_unichar"/> + </hbox> + <hbox> + <label flex="1" value="file"/> + <html:input id="static_element_file" preference="tests.static_preference_file"/> + </hbox> + </vbox> +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/window_preferences2.xhtml b/toolkit/content/tests/chrome/window_preferences2.xhtml new file mode 100644 index 0000000000..af51f5df34 --- /dev/null +++ b/toolkit/content/tests/chrome/window_preferences2.xhtml @@ -0,0 +1,29 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<!-- + XUL Widget Test for preferences window +--> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="prefwindow" + title="pw 2" + windowtype="test:preferences2" + onload="RunTest(window.arguments)"> +<dialog id="window_preferences2_dialog" + buttons="accept,cancel"> + <script type="application/javascript" src="chrome://global/content/preferencesBindings.js"/> + <script type="application/javascript"> + <![CDATA[ + function RunTest(aArgs) + { + // open child + openDialog("window_preferences3.xhtml", "", "modal,centerscreen,resizable=no", {test: aArgs[0], accept: aArgs[1]}); + // close dialog + let dialog = document.getElementById("window_preferences2_dialog"); + dialog[aArgs[1] ? "acceptDialog" : "cancelDialog"](); + } + ]]> + </script> + + <vbox id="sample_pane" class="prefpane" label="Sample Prefpane"/> +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/window_preferences3.xhtml b/toolkit/content/tests/chrome/window_preferences3.xhtml new file mode 100644 index 0000000000..719c66a737 --- /dev/null +++ b/toolkit/content/tests/chrome/window_preferences3.xhtml @@ -0,0 +1,68 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<!-- + XUL Widget Test for preferences window +--> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + class="prefwindow" + title="pw 3" + windowtype="test:preferences3" + onload="RunTest(window.arguments)" + type="child"> +<dialog id="window_preferences3_dialog" + buttons="accept,cancel"> + <script type="application/javascript" src="chrome://global/content/preferencesBindings.js"/> + <script type="application/javascript"> + <![CDATA[ + /* import-globals-from ../../preferencesBindings.js */ + function RunTest(aArgs) + { + // run test + aArgs[0].test(this); + // close dialog + let dialog = document.getElementById("window_preferences3_dialog"); + dialog[aArgs[0].accept ? "acceptDialog" : "cancelDialog"](); + } + + Preferences.addAll([ + // one of each type known to Preference.valueFromPreferences + { id: "tests.static_preference_int", type: "int" }, + { id: "tests.static_preference_bool", type: "bool" }, + { id: "tests.static_preference_string", type: "string" }, + { id: "tests.static_preference_wstring", type: "wstring" }, + { id: "tests.static_preference_unichar", type: "unichar" }, + { id: "tests.static_preference_file", type: "file" }, + ]); + ]]> + </script> + + <vbox id="sample_pane" class="prefpane" label="Sample Prefpane"> + <!-- one element for each preference type above --> + <hbox> + <label flex="1" value="int"/> + <html:input id="static_element_int" preference="tests.static_preference_int"/> + </hbox> + <hbox> + <label flex="1" value="bool"/> + <checkbox id="static_element_bool" preference="tests.static_preference_bool"/> + </hbox> + <hbox> + <label flex="1" value="string"/> + <html:input id="static_element_string" preference="tests.static_preference_string"/> + </hbox> + <hbox> + <label flex="1" value="wstring"/> + <html:input id="static_element_wstring" preference="tests.static_preference_wstring"/> + </hbox> + <hbox> + <label flex="1" value="unichar"/> + <html:input id="static_element_unichar" preference="tests.static_preference_unichar"/> + </hbox> + <hbox> + <label flex="1" value="file"/> + <html:input id="static_element_file" preference="tests.static_preference_file"/> + </hbox> + </vbox> +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/window_preferences_beforeaccept.xhtml b/toolkit/content/tests/chrome/window_preferences_beforeaccept.xhtml new file mode 100644 index 0000000000..8d2e54d54d --- /dev/null +++ b/toolkit/content/tests/chrome/window_preferences_beforeaccept.xhtml @@ -0,0 +1,49 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<!-- + XUL Widget Test for preferences window with beforeaccept +--> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="prefwindow" + title="pw beforeaccept" + width="300" height="300" + windowtype="test:preferences" + type="child" + onload="onDialogLoad();"> +<dialog id="beforeaccept_dialog" + buttons="accept,cancel"> + <script type="application/javascript" src="chrome://global/content/preferencesBindings.js"/> + <script type="application/javascript"> + <![CDATA[ + /* import-globals-from ../../preferencesBindings.js */ + function onDialogLoad() { + document.addEventListener("beforeaccept", beforeAccept); + var pref = Preferences.get("tests.beforeaccept.dialogShown"); + pref.value = true; + + // call the onload handler we were passed + window.arguments[0](); + } + + function beforeAccept(event) { + var beforeAcceptPref = window.Preferences.get("tests.beforeaccept.called"); + var oldValue = beforeAcceptPref.value; + beforeAcceptPref.value = true; + + if (!oldValue) { + event.preventDefault(); + } + } + + Preferences.addAll([ + { id: "tests.beforeaccept.called", type: "bool" }, + { id: "tests.beforeaccept.dialogShown", type: "bool" }, + ]); + ]]> + </script> + + <vbox id="sample_pane" class="prefpane" label="Sample Prefpane"> + </vbox> + <label>Test Prefpane</label> +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/window_preferences_commandretarget.xhtml b/toolkit/content/tests/chrome/window_preferences_commandretarget.xhtml new file mode 100644 index 0000000000..52942d9da1 --- /dev/null +++ b/toolkit/content/tests/chrome/window_preferences_commandretarget.xhtml @@ -0,0 +1,40 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<!-- + XUL Widget Test for preferences window. This particular test ensures that + a checkbox with a command attribute properly updates even though the command + event gets retargeted. +--> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="prefwindow" + title="pw commandretarget" + windowtype="test:preferences" + onload="RunTest(window.arguments)"> +<dialog id="commandretarget_dialog" + buttons="accept,cancel"> + <script type="application/javascript" src="chrome://global/content/preferencesBindings.js"/> + <script type="application/javascript"> + <![CDATA[ + /* import-globals-from ../../preferencesBindings.js */ + function RunTest(aArgs) + { + aArgs[0](this); + let dialog = document.getElementById("commandretarget_dialog"); + dialog.cancelDialog(); + } + + Preferences.addAll([ + { id: "tests.static_preference_bool", type: "bool" }, + ]); + ]]> + </script> + + <vbox id="sample_pane" class="prefpane" label="Sample Prefpane"> + <commandset> + <command id="cmd_test" preference="tests.static_preference_bool"/> + </commandset> + + <checkbox id="checkbox" label="Enable Option" preference="tests.static_preference_bool" command="cmd_test"/> + </vbox> +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/window_preferences_disabled.xhtml b/toolkit/content/tests/chrome/window_preferences_disabled.xhtml new file mode 100644 index 0000000000..4bb17bbeac --- /dev/null +++ b/toolkit/content/tests/chrome/window_preferences_disabled.xhtml @@ -0,0 +1,40 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<!-- + XUL Widget Test for preferences window. This particular test ensures that + when a preference is disabled, the checkbox disabled and when a preference + is locked, the checkbox can't be enabled. +--> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="prefwindow" + title="pw disabled" + windowtype="test:preferences" + onload="RunTest(window.arguments)"> +<dialog id="disabled_dialog" + buttons="accept,cancel"> + <script type="application/javascript" src="chrome://global/content/preferencesBindings.js"/> + <script type="application/javascript"> + <![CDATA[ + /* import-globals-from ../../preferencesBindings.js */ + function RunTest(aArgs) + { + setTimeout(() => { + aArgs[0](this); + let dialog = document.getElementById("disabled_dialog"); + dialog.cancelDialog(); + }); + } + + Preferences.addAll([ + { id: "tests.disabled_preference_bool", type: "bool" }, + { id: "tests.locked_preference_bool", type: "bool" }, + ]); + ]]> + </script> + + <vbox id="sample_pane" class="prefpane" label="Sample Prefpane"> + <checkbox id="disabled_checkbox" label="Disabled" preference="tests.disabled_preference_bool"/> + <checkbox id="locked_checkbox" label="Locked" preference="tests.locked_preference_bool"/> + </vbox> +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/window_preferences_onsyncfrompreference.xhtml b/toolkit/content/tests/chrome/window_preferences_onsyncfrompreference.xhtml new file mode 100644 index 0000000000..6ff07e1620 --- /dev/null +++ b/toolkit/content/tests/chrome/window_preferences_onsyncfrompreference.xhtml @@ -0,0 +1,55 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<!-- 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/. --> +<!-- + XUL Widget Test for preferences window with onsyncfrompreference + This test ensures that onsyncfrompreference handlers are called after all the + values of the corresponding preference element have been set correctly +--> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="prefwindow" + title="pw onsyncfrompreference" + width="300" height="300" + windowtype="test:preferences" + onload="onLoad()"> +<dialog> + + <script type="application/javascript" src="chrome://global/content/preferencesBindings.js"/> + <script type="application/javascript"> + <![CDATA[ + /* import-globals-from ../../preferencesBindings.js */ + Preferences.addAll([ + { id: "tests.onsyncfrompreference.pref1", type: "int" }, + { id: "tests.onsyncfrompreference.pref2", type: "int" }, + { id: "tests.onsyncfrompreference.pref3", type: "int" }, + ]); + + function onLoad() { + Preferences.addSyncFromPrefListener(document.getElementById("check1"), + () => window.arguments[0]()); + Preferences.addSyncFromPrefListener(document.getElementById("check2"), + () => window.arguments[0]()); + Preferences.addSyncFromPrefListener(document.getElementById("check3"), + () => window.arguments[0]()); + Preferences.addSyncToPrefListener(document.getElementById("check1"), + () => 1); + Preferences.addSyncToPrefListener(document.getElementById("check2"), + () => 1); + Preferences.addSyncToPrefListener(document.getElementById("check3"), + () => 1); + } + ]]> + </script> + <vbox id="sample_pane" class="prefpane" label="Sample Prefpane"> + </vbox> + <label>Test Prefpane</label> + <checkbox id="check1" label="Label1" + preference="tests.onsyncfrompreference.pref1"/> + <checkbox id="check2" label="Label2" + preference="tests.onsyncfrompreference.pref2"/> + <checkbox id="check3" label="Label3" + preference="tests.onsyncfrompreference.pref3"/> +</dialog> +</window> diff --git a/toolkit/content/tests/chrome/window_screenPosSize.xhtml b/toolkit/content/tests/chrome/window_screenPosSize.xhtml new file mode 100644 index 0000000000..accc10d8f1 --- /dev/null +++ b/toolkit/content/tests/chrome/window_screenPosSize.xhtml @@ -0,0 +1,17 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Window Open Test" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + screenX="80" + screenY="80" + height="300" + width="300" + persist="screenX screenY height width"> + +<body xmlns="http://www.w3.org/1999/xhtml"> + +</body> + +</window> diff --git a/toolkit/content/tests/chrome/window_showcaret.xhtml b/toolkit/content/tests/chrome/window_showcaret.xhtml new file mode 100644 index 0000000000..4c508d52a0 --- /dev/null +++ b/toolkit/content/tests/chrome/window_showcaret.xhtml @@ -0,0 +1,11 @@ +<?xml version='1.0'?> + +<?xml-stylesheet href='chrome://global/skin' type='text/css'?> + +<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul' + xmlns:html="http://www.w3.org/1999/xhtml"> + +<hbox style='-moz-user-focus: normal;' width='20' height='20'/> +<html:input/> + +</window> diff --git a/toolkit/content/tests/chrome/window_subframe_origin.xhtml b/toolkit/content/tests/chrome/window_subframe_origin.xhtml new file mode 100644 index 0000000000..65d0a043fd --- /dev/null +++ b/toolkit/content/tests/chrome/window_subframe_origin.xhtml @@ -0,0 +1,40 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window id="window" title="Subframe Origin Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<iframe + style="margin-left:20px; margin-top:20px; min-height:300px; max-width:300px; max-height:300px; border:solid 1px black;" + src="frame_subframe_origin_subframe1.xhtml"></iframe> +<caption id="parentcap" label=""/> + +<script> + +// Fire a mouse move event aimed at this window, and check to be +// sure the client coords translate from widget to the dom correctly. + +function runTests() +{ + synthesizeMouse(document.getElementById("window"), 1, 2, { type: "mousemove" }); +} + +window.arguments[0].SimpleTest.waitForFocus(runTests, window); + +function mouseMove(e) { + var el = document.getElementById("parentcap"); + el.label = "client: (" + e.clientX + "," + e.clientY + ")"; + window.arguments[0].SimpleTest.is(e.clientX, 1, "mouse event clientX"); + window.arguments[0].SimpleTest.is(e.clientY, 2, "mouse event clientY"); + // fire the next test on the sub frame + frames[0].runTests(); +} + +window.addEventListener("mousemove",mouseMove); + +</script> +</window> diff --git a/toolkit/content/tests/chrome/window_tooltip.xhtml b/toolkit/content/tests/chrome/window_tooltip.xhtml new file mode 100644 index 0000000000..6a573f0bd9 --- /dev/null +++ b/toolkit/content/tests/chrome/window_tooltip.xhtml @@ -0,0 +1,375 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Tooltip Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="application/javascript" src="popup_shared.js"></script> + +<tooltip id="thetooltip"> + <label id="label" class="tooltip-label" value="This is a tooltip"/> +</tooltip> + +<box id="parent" tooltiptext="Box Tooltip" style="margin: 10px"> + <button id="withtext" label="Tooltip Text" tooltiptext="Button Tooltip" + style="-moz-appearance: none; padding: 0;"/> + <button id="without" label="No Tooltip" style="-moz-appearance: none; padding: 0;"/> + <!-- remove the native theme and borders to avoid some platform + specific sizing differences --> + <button id="withtooltip" label="Tooltip Element" tooltip="thetooltip" + class="plain" style="-moz-appearance: none; padding: 0;"/> +</box> + +<script class="testbody" type="application/javascript"> +<![CDATA[ +/* import-globals-from ../widgets/popup_shared.js */ +var gOriginalWidth = -1; +var gOriginalHeight = -1; +var gButton = null; + +function runTest() +{ + startPopupTests(popupTests); +} + +function checkCoords(event) +{ + // all but one test open the tooltip at the button location offset by 6 + // in each direction. Test 5 opens it at 4 in each direction. + var mod = (gTestIndex == 5) ? 4 : 6; + + var rect = gButton.getBoundingClientRect(); + is(event.clientX, Math.round(rect.left + mod), + "step " + (gTestIndex + 1) + " clientX"); + is(event.clientY, Math.round(rect.top + mod), + "step " + (gTestIndex + 1) + " clientY"); + ok(event.screenX > 0, "step " + (gTestIndex + 1) + " screenX"); + ok(event.screenY > 0, "step " + (gTestIndex + 1) + " screenY"); +} + +var popupTests = [ +{ + testname: "hover tooltiptext attribute", + events: [ "popupshowing #tooltip", "popupshown #tooltip" ], + test() { + gButton = document.getElementById("withtext"); + disableNonTestMouse(true); + synthesizeMouse(gButton, 2, 2, { type: "mouseover" }); + synthesizeMouse(gButton, 4, 4, { type: "mousemove" }); + synthesizeMouse(gButton, 6, 6, { type: "mousemove" }); + disableNonTestMouse(false); + } +}, +{ + testname: "close tooltip", + events: [ "popuphiding #tooltip", "popuphidden #tooltip", + "DOMMenuInactive #tooltip" ], + test() { + disableNonTestMouse(true); + synthesizeMouse(document.documentElement, 2, 2, { type: "mousemove" }); + disableNonTestMouse(false); + } +}, +{ + testname: "hover inherited tooltip", + events: [ "popupshowing #tooltip", "popupshown #tooltip" ], + test() { + gButton = document.getElementById("without"); + disableNonTestMouse(true); + synthesizeMouse(gButton, 2, 2, { type: "mouseover" }); + synthesizeMouse(gButton, 4, 4, { type: "mousemove" }); + synthesizeMouse(gButton, 6, 6, { type: "mousemove" }); + disableNonTestMouse(false); + } +}, +{ + testname: "hover tooltip attribute", + events: [ "popuphiding #tooltip", "popuphidden #tooltip", + "DOMMenuInactive #tooltip", + "popupshowing thetooltip", "popupshown thetooltip" ], + test() { + gButton = document.getElementById("withtooltip"); + gExpectedTriggerNode = gButton; + disableNonTestMouse(true); + synthesizeMouse(gButton, 2, 2, { type: "mouseover" }); + synthesizeMouse(gButton, 4, 4, { type: "mousemove" }); + synthesizeMouse(gButton, 6, 6, { type: "mousemove" }); + disableNonTestMouse(false); + }, + result(testname) { + var tooltip = document.getElementById("thetooltip"); + gExpectedTriggerNode = null; + is(tooltip.triggerNode, gButton, testname + " triggerNode"); + + var buttonrect = document.getElementById("withtooltip").getBoundingClientRect(); + var rect = tooltip.getBoundingClientRect(); + var popupstyle = window.getComputedStyle(document.getElementById("thetooltip")); + + is(Math.round(rect.left), + Math.round(buttonrect.left + parseFloat(popupstyle.marginLeft) + 6), + testname + " left position of tooltip"); + is(Math.round(rect.top), + Math.round(buttonrect.top + parseFloat(popupstyle.marginTop) + 6), + testname + " top position of tooltip"); + + var labelrect = document.getElementById("label").getBoundingClientRect(); + ok(labelrect.right < rect.right, testname + " tooltip width"); + ok(labelrect.bottom < rect.bottom, testname + " tooltip height"); + + gOriginalWidth = rect.right - rect.left; + gOriginalHeight = rect.bottom - rect.top; + } +}, +{ + testname: "click to close tooltip", + events: [ "popuphiding thetooltip", "popuphidden thetooltip", + "command withtooltip", "DOMMenuInactive thetooltip" ], + test() { + gButton = document.getElementById("withtooltip"); + synthesizeMouse(gButton, 2, 2, { }); + }, + result(testname) { + var tooltip = document.getElementById("thetooltip"); + is(tooltip.triggerNode, null, testname + " triggerNode"); + } +}, +{ + testname: "hover tooltip after size increased", + events: [ "popupshowing thetooltip", "popupshown thetooltip" ], + test() { + var label = document.getElementById("label"); + label.removeAttribute("value"); + label.textContent = "This is a longer tooltip than before\nIt has multiple lines\nIt is testing tooltip sizing\n"; + gButton = document.getElementById("withtooltip"); + disableNonTestMouse(true); + synthesizeMouse(gButton, 2, 2, { type: "mouseover" }); + synthesizeMouse(gButton, 6, 6, { type: "mousemove" }); + synthesizeMouse(gButton, 4, 4, { type: "mousemove" }); + disableNonTestMouse(false); + }, + result(testname) { + var buttonrect = document.getElementById("withtooltip").getBoundingClientRect(); + var rect = document.getElementById("thetooltip").getBoundingClientRect(); + var popupstyle = window.getComputedStyle(document.getElementById("thetooltip")); + + is(Math.round(rect.left), + Math.round(buttonrect.left + parseFloat(popupstyle.marginLeft) + 4), + testname + " left position of tooltip"); + is(Math.round(rect.top), + Math.round(buttonrect.top + parseFloat(popupstyle.marginTop) + 4), + testname + " top position of tooltip"); + + var labelrect = document.getElementById("label").getBoundingClientRect(); + ok(labelrect.right < rect.right, testname + " tooltip width"); + ok(labelrect.bottom < rect.bottom, testname + " tooltip height"); + + // make sure that the tooltip is larger than it was before by just + // checking against the original height plus an arbitrary 15 pixels + ok(gOriginalWidth + 15 < rect.right - rect.left, testname + " tooltip is wider"); + ok(gOriginalHeight + 15 < rect.bottom - rect.top, testname + " tooltip is taller"); + } +}, +{ + testname: "close tooltip with hidePopup", + events: [ "popuphiding thetooltip", "popuphidden thetooltip", + "DOMMenuInactive thetooltip" ], + test() { + document.getElementById("thetooltip").hidePopup(); + }, +}, +{ + testname: "hover tooltip after size decreased", + events: [ "popupshowing thetooltip", "popupshown thetooltip" ], + autohide: "thetooltip", + test() { + var label = document.getElementById("label"); + label.value = "This is a tooltip"; + label.textContent = ""; + gButton = document.getElementById("withtooltip"); + disableNonTestMouse(true); + synthesizeMouse(gButton, 2, 2, { type: "mouseover" }); + synthesizeMouse(gButton, 4, 4, { type: "mousemove" }); + synthesizeMouse(gButton, 6, 6, { type: "mousemove" }); + disableNonTestMouse(false); + }, + result(testname) { + var buttonrect = document.getElementById("withtooltip").getBoundingClientRect(); + var rect = document.getElementById("thetooltip").getBoundingClientRect(); + var popupstyle = window.getComputedStyle(document.getElementById("thetooltip")); + + is(Math.round(rect.left), + Math.round(buttonrect.left + parseFloat(popupstyle.marginLeft) + 6), + testname + " left position of tooltip"); + is(Math.round(rect.top), + Math.round(buttonrect.top + parseFloat(popupstyle.marginTop) + 6), + testname + " top position of tooltip"); + + var labelrect = document.getElementById("label").getBoundingClientRect(); + ok(labelrect.right < rect.right, testname + " tooltip width"); + ok(labelrect.bottom < rect.bottom, testname + " tooltip height"); + + is(gOriginalWidth, rect.right - rect.left, testname + " tooltip is original width"); + is(gOriginalHeight, rect.bottom - rect.top, testname + " tooltip is original height"); + } +}, +{ + testname: "hover tooltip at bottom edge of screen", + events: [ "popupshowing thetooltip", "popupshown thetooltip" ], + autohide: "thetooltip", + condition() { + // Only checking OSX here because on other platforms popups and tooltips behave the same way + // when there's not enough space to show them below (by flipping vertically) + // However, on OSX most popups are not flipped but tooltips are. + return navigator.platform.indexOf("Mac") > -1; + }, + test() { + var buttonRect = document.getElementById("withtext").getBoundingClientRect(); + var windowY = screen.height - + (window.mozInnerScreenY - window.screenY ) - buttonRect.bottom; + + moveWindowTo(window.screenX, windowY, function() { + gButton = document.getElementById("withtooltip"); + disableNonTestMouse(true); + synthesizeMouse(gButton, 2, 2, { type: "mouseover" }); + synthesizeMouse(gButton, 6, 6, { type: "mousemove" }); + synthesizeMouse(gButton, 4, 4, { type: "mousemove" }); + disableNonTestMouse(false); + }); + }, + result(testname) { + var buttonrect = document.getElementById("withtooltip").getBoundingClientRect(); + var rect = document.getElementById("thetooltip").getBoundingClientRect(); + var popupstyle = window.getComputedStyle(document.getElementById("thetooltip")); + + is(Math.round(rect.y + rect.height), + Math.round(buttonrect.top + 4 - parseFloat(popupstyle.marginTop)), + testname + " position of tooltip above button"); + } +}, +{ + testname: "open tooltip for keyclose", + events: [ "popupshowing thetooltip", "popupshown thetooltip" ], + test() { + gButton = document.getElementById("withtooltip"); + gExpectedTriggerNode = gButton; + disableNonTestMouse(true); + synthesizeMouse(gButton, 2, 2, { type: "mouseover" }); + synthesizeMouse(gButton, 4, 4, { type: "mousemove" }); + synthesizeMouse(gButton, 6, 6, { type: "mousemove" }); + disableNonTestMouse(false); + }, +}, +{ + testname: "close tooltip with modifiers", + test() { + // Press all of the modifiers; the tooltip should remain open on all platforms. + synthesizeKey("KEY_Shift"); + synthesizeKey("KEY_Control"); + synthesizeKey("KEY_Alt"); + synthesizeKey("KEY_Alt"); + }, + result() { + is(document.getElementById("thetooltip").state, "open", "tooltip still open after modifiers pressed") + } +}, +{ + testname: "close tooltip with key", + events() { + if (navigator.platform.indexOf("Win") > -1) { + return []; + } + return [ "popuphiding thetooltip", "popuphidden thetooltip", + "DOMMenuInactive thetooltip" ]; + }, + test() { + sendString("a"); + }, + result() { + let expectedState = (navigator.platform.indexOf("Win") > -1) ? "open" : "closed"; + is(document.getElementById("thetooltip").state, expectedState, "tooltip closed after key pressed") + } +}, +{ + testname: "close tooltip with hidePopup again", + condition() { return navigator.platform.indexOf("Win") > -1; }, + events: [ "popuphiding thetooltip", "popuphidden thetooltip", + "DOMMenuInactive thetooltip" ], + test() { + document.getElementById("thetooltip").hidePopup(); + }, +}, +{ + testname: "hover tooltip with long unbreakable text", + events: [ "popupshowing thetooltip", "popupshown thetooltip" ], + autohide: "thetooltip", + test() { + var label = document.getElementById("label"); + label.removeAttribute("value"); + label.textContent = "This_tooltip_contains_no_whitespace_and_is_longer_than_the_maximum_tooltip_width_It_should_wrap_onto_a_second_line_instead_of_being_truncated"; + gButton = document.getElementById("withtooltip"); + disableNonTestMouse(true); + synthesizeMouse(gButton, 2, 2, { type: "mouseover" }); + synthesizeMouse(gButton, 4, 4, { type: "mousemove" }); + synthesizeMouse(gButton, 6, 6, { type: "mousemove" }); + disableNonTestMouse(false); + }, + result(testname) { + var rect = document.getElementById("thetooltip").getBoundingClientRect(); + + var labelrect = document.getElementById("label").getBoundingClientRect(); + ok(labelrect.right < rect.right, testname + " tooltip width"); + ok(labelrect.bottom < rect.bottom, testname + " tooltip height"); + + // make sure that the tooltip contains more than one line of text, by checking + // that the original height has increased by at least 10 pixels + ok(gOriginalHeight + 10 < rect.bottom - rect.top, testname + " tooltip is wrapped"); + } +} +]; + +var waitSteps = 0; +var oldx, oldy; +function moveWindowTo(x, y, callback, arg) +{ + if (!waitSteps) { + oldx = window.screenX; + oldy = window.screenY; + window.moveTo(x, y); + + waitSteps++; + setTimeout(moveWindowTo, 100, x, y, callback, arg); + return; + } + + if (window.screenX == oldx && window.screenY == oldy) { + if (waitSteps++ > 10) { + ok(false, "Window never moved properly to " + x + "," + y); + window.arguments[0].SimpleTest.finish(); + window.close(); + } + + setTimeout(moveWindowTo, 100, x, y, callback, arg); + } + else { + waitSteps = 0; + callback(arg); + } +} + +window.arguments[0].SimpleTest.waitForFocus(runTest, window); +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/chrome/xul_selectcontrol.js b/toolkit/content/tests/chrome/xul_selectcontrol.js new file mode 100644 index 0000000000..310ab5c3fa --- /dev/null +++ b/toolkit/content/tests/chrome/xul_selectcontrol.js @@ -0,0 +1,689 @@ +// This script is used to test elements that implement +// nsIDOMXULSelectControlElement. This currently is the following elements: +// listbox, menulist, radiogroup, richlistbox, tabs +// +// flag behaviours that differ for certain elements +// allow-other-value - alternate values for the value property may be used +// besides those in the list +// other-value-clears-selection - alternative values for the value property +// clears the selected item +// selection-required - an item must be selected in the list, unless there +// aren't any to select +// activate-disabled-menuitem - disabled menuitems can be highlighted +// select-keynav-wraps - key navigation over a selectable list wraps +// select-extended-keynav - home, end, page up and page down keys work to +// navigate over a selectable list +// keynav-leftright - key navigation is left/right rather than up/down +// The win:, mac: and gtk: or other prefixes may be used for platform specific behaviour +var behaviours = { + menu: "win:activate-disabled-menuitem activate-disabled-menuitem-mousemove select-keynav-wraps select-extended-keynav", + menulist: "allow-other-value other-value-clears-selection", + listbox: "select-extended-keynav", + richlistbox: "select-extended-keynav", + radiogroup: "select-keynav-wraps dont-select-disabled allow-other-value", + tabs: "select-extended-keynav mac:select-keynav-wraps allow-other-value selection-required keynav-leftright", +}; + +function behaviourContains(tag, behaviour) { + var platform = "none:"; + if (navigator.platform.includes("Mac")) { + platform = "mac:"; + } else if (navigator.platform.includes("Win")) { + platform = "win:"; + } else if (navigator.platform.includes("X")) { + platform = "gtk:"; + } + + var re = new RegExp( + "\\s" + platform + behaviour + "\\s|\\s" + behaviour + "\\s" + ); + return re.test(" " + behaviours[tag] + " "); +} + +function test_nsIDOMXULSelectControlElement(element, childtag, testprefix) { + var testid = testprefix ? testprefix + " " : ""; + testid += element.localName + " nsIDOMXULSelectControlElement "; + + // 'initial' - check if the initial state of the element is correct + test_nsIDOMXULSelectControlElement_States( + element, + testid + "initial", + 0, + null, + -1, + "" + ); + + test_nsIDOMXULSelectControlElement_init(element, testid); + + // 'appendItem' - check if appendItem works to add a new item + var firstitem = element.appendItem("First Item", "first"); + is( + firstitem.localName, + childtag, + testid + "appendItem - first item is " + childtag + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "appendItem", + 1, + null, + -1, + "" + ); + + is(firstitem.control, element, testid + "control"); + + // 'selectedIndex' - check if an item may be selected + element.selectedIndex = 0; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "selectedIndex", + 1, + firstitem, + 0, + "first" + ); + + // 'appendItem 2' - check if a second item may be added + var seconditem = element.appendItem("Second Item", "second"); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "appendItem 2", + 2, + firstitem, + 0, + "first" + ); + + // 'selectedItem' - check if the second item may be selected + element.selectedItem = seconditem; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "selectedItem", + 2, + seconditem, + 1, + "second" + ); + + // 'selectedIndex 2' - check if selectedIndex may be set to -1 to deselect items + var selectionRequired = behaviourContains( + element.localName, + "selection-required" + ); + element.selectedIndex = -1; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "selectedIndex 2", + 2, + selectionRequired ? seconditem : null, + selectionRequired ? 1 : -1, + selectionRequired ? "second" : "" + ); + + // 'selectedItem 2' - check if the selectedItem property may be set to null + element.selectedIndex = 1; + element.selectedItem = null; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "selectedItem 2", + 2, + selectionRequired ? seconditem : null, + selectionRequired ? 1 : -1, + selectionRequired ? "second" : "" + ); + + // 'getIndexOfItem' - check if getIndexOfItem returns the right index + is( + element.getIndexOfItem(firstitem), + 0, + testid + "getIndexOfItem - first item at index 0" + ); + is( + element.getIndexOfItem(seconditem), + 1, + testid + "getIndexOfItem - second item at index 1" + ); + + var otheritem = element.ownerDocument.createXULElement(childtag); + is( + element.getIndexOfItem(otheritem), + -1, + testid + "getIndexOfItem - other item not found" + ); + + // 'getItemAtIndex' - check if getItemAtIndex returns the right item + is( + element.getItemAtIndex(0), + firstitem, + testid + "getItemAtIndex - index 0 is first item" + ); + is( + element.getItemAtIndex(1), + seconditem, + testid + "getItemAtIndex - index 0 is second item" + ); + is( + element.getItemAtIndex(-1), + null, + testid + "getItemAtIndex - index -1 is null" + ); + is( + element.getItemAtIndex(2), + null, + testid + "getItemAtIndex - index 2 is null" + ); + + // check if setting the value changes the selection + element.value = "first"; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "set value 1", + 2, + firstitem, + 0, + "first" + ); + element.value = "second"; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "set value 2", + 2, + seconditem, + 1, + "second" + ); + // setting the value attribute to one not in the list doesn't change the selection. + // The value is only changed for elements which support having a value other than the + // selection. + element.value = "other"; + var allowOtherValue = behaviourContains( + element.localName, + "allow-other-value" + ); + var otherValueClearsSelection = behaviourContains( + element.localName, + "other-value-clears-selection" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "set value other", + 2, + otherValueClearsSelection ? null : seconditem, + otherValueClearsSelection ? -1 : 1, + allowOtherValue ? "other" : "second" + ); + if (allowOtherValue) { + element.value = ""; + } + + var fourthitem = element.appendItem("Fourth Item", "fourth"); + element.selectedIndex = 0; + fourthitem.disabled = true; + element.selectedIndex = 2; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "selectedIndex disabled", + 3, + fourthitem, + 2, + "fourth" + ); + + element.selectedIndex = 0; + element.selectedItem = fourthitem; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "selectedItem disabled", + 3, + fourthitem, + 2, + "fourth" + ); + + if (element.menupopup) { + element.menupopup.textContent = ""; + } else { + element.textContent = ""; + } +} + +function test_nsIDOMXULSelectControlElement_init(element, testprefix) { + var id = element.id; + element = document.getElementById(id + "-initwithvalue"); + if (element) { + var seconditem = element.getItemAtIndex(1); + test_nsIDOMXULSelectControlElement_States( + element, + testprefix + " value initialization", + 3, + seconditem, + 1, + seconditem.value + ); + } + + element = document.getElementById(id + "-initwithselected"); + if (element) { + var thirditem = element.getItemAtIndex(2); + test_nsIDOMXULSelectControlElement_States( + element, + testprefix + " selected initialization", + 3, + thirditem, + 2, + thirditem.value + ); + } +} + +function test_nsIDOMXULSelectControlElement_States( + element, + testid, + expectedcount, + expecteditem, + expectedindex, + expectedvalue +) { + // need an itemCount property here + var count = element.itemCount; + is(count, expectedcount, testid + " item count"); + is(element.selectedItem, expecteditem, testid + " selectedItem"); + is(element.selectedIndex, expectedindex, testid + " selectedIndex"); + is(element.value, expectedvalue, testid + " value"); + if (element.selectedItem) { + is( + element.selectedItem.selected, + true, + testid + " selectedItem marked as selected" + ); + } +} + +/** test_nsIDOMXULSelectControlElement_UI + * + * Test the UI aspects of an element which implements nsIDOMXULSelectControlElement + * + * Parameters: + * element - element to test + */ +function test_nsIDOMXULSelectControlElement_UI(element, testprefix) { + var testid = testprefix ? testprefix + " " : ""; + testid += element.localName + " nsIDOMXULSelectControlElement UI "; + + if (element.menupopup) { + element.menupopup.textContent = ""; + } else { + element.textContent = ""; + } + + var firstitem = element.appendItem("First Item", "first"); + var seconditem = element.appendItem("Second Item", "second"); + + // 'mouse select' - check if clicking an item selects it + synthesizeMouseExpectEvent( + firstitem, + 2, + 2, + {}, + element, + "select", + testid + "mouse select" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "mouse select", + 2, + firstitem, + 0, + "first" + ); + + synthesizeMouseExpectEvent( + seconditem, + 2, + 2, + {}, + element, + "select", + testid + "mouse select 2" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "mouse select 2", + 2, + seconditem, + 1, + "second" + ); + + // make sure the element is focused so keyboard navigation will apply + element.selectedIndex = 1; + element.focus(); + + var navLeftRight = behaviourContains(element.localName, "keynav-leftright"); + var backKey = navLeftRight ? "VK_LEFT" : "VK_UP"; + var forwardKey = navLeftRight ? "VK_RIGHT" : "VK_DOWN"; + + // 'key select' - check if keypresses move between items + synthesizeKeyExpectEvent(backKey, {}, element, "select", testid + "key up"); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key up", + 2, + firstitem, + 0, + "first" + ); + + var keyWrap = behaviourContains(element.localName, "select-keynav-wraps"); + + var expectedItem = keyWrap ? seconditem : firstitem; + var expectedIndex = keyWrap ? 1 : 0; + var expectedValue = keyWrap ? "second" : "first"; + synthesizeKeyExpectEvent( + backKey, + {}, + keyWrap ? element : null, + "select", + testid + "key up 2" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key up 2", + 2, + expectedItem, + expectedIndex, + expectedValue + ); + + element.selectedIndex = 0; + synthesizeKeyExpectEvent( + forwardKey, + {}, + element, + "select", + testid + "key down" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key down", + 2, + seconditem, + 1, + "second" + ); + + expectedItem = keyWrap ? firstitem : seconditem; + expectedIndex = keyWrap ? 0 : 1; + expectedValue = keyWrap ? "first" : "second"; + synthesizeKeyExpectEvent( + forwardKey, + {}, + keyWrap ? element : null, + "select", + testid + "key down 2" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key down 2", + 2, + expectedItem, + expectedIndex, + expectedValue + ); + + var thirditem = element.appendItem("Third Item", "third"); + var fourthitem = element.appendItem("Fourth Item", "fourth"); + if (behaviourContains(element.localName, "select-extended-keynav")) { + element.appendItem("Fifth Item", "fifth"); + var sixthitem = element.appendItem("Sixth Item", "sixth"); + + synthesizeKeyExpectEvent( + "VK_END", + {}, + element, + "select", + testid + "key end" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key end", + 6, + sixthitem, + 5, + "sixth" + ); + + synthesizeKeyExpectEvent( + "VK_HOME", + {}, + element, + "select", + testid + "key home" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key home", + 6, + firstitem, + 0, + "first" + ); + + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + {}, + element, + "select", + testid + "key page down" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key page down", + 6, + fourthitem, + 3, + "fourth" + ); + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + {}, + element, + "select", + testid + "key page down to end" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key page down to end", + 6, + sixthitem, + 5, + "sixth" + ); + + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + {}, + element, + "select", + testid + "key page up" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key page up", + 6, + thirditem, + 2, + "third" + ); + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + {}, + element, + "select", + testid + "key page up to start" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key page up to start", + 6, + firstitem, + 0, + "first" + ); + + element.getItemAtIndex(5).remove(); + element.getItemAtIndex(4).remove(); + } + + // now test whether a disabled item works. + element.selectedIndex = 0; + seconditem.disabled = true; + + var dontSelectDisabled = behaviourContains( + element.localName, + "dont-select-disabled" + ); + + // 'mouse select' - check if clicking an item selects it + synthesizeMouseExpectEvent( + seconditem, + 2, + 2, + {}, + element, + dontSelectDisabled ? "!select" : "select", + testid + "mouse select disabled" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "mouse select disabled", + 4, + dontSelectDisabled ? firstitem : seconditem, + dontSelectDisabled ? 0 : 1, + dontSelectDisabled ? "first" : "second" + ); + + if (dontSelectDisabled) { + // test whether disabling an item won't allow it to be selected + synthesizeKeyExpectEvent( + forwardKey, + {}, + element, + "select", + testid + "key down disabled" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key down disabled", + 4, + thirditem, + 2, + "third" + ); + + synthesizeKeyExpectEvent( + backKey, + {}, + element, + "select", + testid + "key up disabled" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key up disabled", + 4, + firstitem, + 0, + "first" + ); + + element.selectedIndex = 2; + firstitem.disabled = true; + + synthesizeKeyExpectEvent( + backKey, + {}, + keyWrap ? element : null, + "select", + testid + "key up disabled 2" + ); + expectedItem = keyWrap ? fourthitem : thirditem; + expectedIndex = keyWrap ? 3 : 2; + expectedValue = keyWrap ? "fourth" : "third"; + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key up disabled 2", + 4, + expectedItem, + expectedIndex, + expectedValue + ); + } else { + // in this case, disabled items should behave the same as non-disabled items. + element.selectedIndex = 0; + synthesizeKeyExpectEvent( + forwardKey, + {}, + element, + "select", + testid + "key down disabled" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key down disabled", + 4, + seconditem, + 1, + "second" + ); + synthesizeKeyExpectEvent( + forwardKey, + {}, + element, + "select", + testid + "key down disabled again" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key down disabled again", + 4, + thirditem, + 2, + "third" + ); + + synthesizeKeyExpectEvent( + backKey, + {}, + element, + "select", + testid + "key up disabled" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key up disabled", + 4, + seconditem, + 1, + "second" + ); + synthesizeKeyExpectEvent( + backKey, + {}, + element, + "select", + testid + "key up disabled again" + ); + test_nsIDOMXULSelectControlElement_States( + element, + testid + "key up disabled again", + 4, + firstitem, + 0, + "first" + ); + } +} diff --git a/toolkit/content/tests/mochitest/file_mousecapture.html b/toolkit/content/tests/mochitest/file_mousecapture.html new file mode 100644 index 0000000000..bc3d1869f6 --- /dev/null +++ b/toolkit/content/tests/mochitest/file_mousecapture.html @@ -0,0 +1 @@ +<html><p>One</p><p style='margin-top: 200px;'>Two</p><p style='margin-top: 4000px'>This is some text</p></html> diff --git a/toolkit/content/tests/mochitest/file_mousecapture2.html b/toolkit/content/tests/mochitest/file_mousecapture2.html new file mode 100644 index 0000000000..e746cf7807 --- /dev/null +++ b/toolkit/content/tests/mochitest/file_mousecapture2.html @@ -0,0 +1,8 @@ +<html><p>One</p> +<p id="myStyle">Two</p> +<p style='margin-top: 4000px'>This is some text</p> +</html> + +<script type="application/javascript"> +document.getElementById("myStyle").style.marginTop = new URL(location.href).searchParams.get("topPos"); +</script> diff --git a/toolkit/content/tests/mochitest/file_mousecapture3.html b/toolkit/content/tests/mochitest/file_mousecapture3.html new file mode 100644 index 0000000000..ee3fad455a --- /dev/null +++ b/toolkit/content/tests/mochitest/file_mousecapture3.html @@ -0,0 +1 @@ +<body style='font-size: 40pt;'>.<b id='b'>This</b> is some text<div id='fixed' style='position: fixed; left: 55px; top: 5px; width: 10px; height: 10px'>.</div></body> diff --git a/toolkit/content/tests/mochitest/file_mousecapture4.html b/toolkit/content/tests/mochitest/file_mousecapture4.html new file mode 100644 index 0000000000..fbf30589c1 --- /dev/null +++ b/toolkit/content/tests/mochitest/file_mousecapture4.html @@ -0,0 +1 @@ +<frameset cols='50%, 50%'><frame src='about:blank'><frame src='about:blank'></frameset> diff --git a/toolkit/content/tests/mochitest/file_mousecapture5.html b/toolkit/content/tests/mochitest/file_mousecapture5.html new file mode 100644 index 0000000000..bc632b6156 --- /dev/null +++ b/toolkit/content/tests/mochitest/file_mousecapture5.html @@ -0,0 +1 @@ +<input id='input' onfocus='this.style.display = "none"' style='float: left;'> diff --git a/toolkit/content/tests/mochitest/gizmo.mp4 b/toolkit/content/tests/mochitest/gizmo.mp4 Binary files differnew file mode 100644 index 0000000000..87efad5ade --- /dev/null +++ b/toolkit/content/tests/mochitest/gizmo.mp4 diff --git a/toolkit/content/tests/mochitest/mochitest.toml b/toolkit/content/tests/mochitest/mochitest.toml new file mode 100644 index 0000000000..627c9dfbd0 --- /dev/null +++ b/toolkit/content/tests/mochitest/mochitest.toml @@ -0,0 +1,26 @@ +[DEFAULT] + +["test_autocomplete_change_after_focus.html"] +skip-if = ["os == 'android'"] + +["test_bug1407085.html"] + +["test_mousecapture.xhtml"] +allow_xul_xbl = true +support-files = [ + "file_mousecapture.html", + "file_mousecapture2.html", + "file_mousecapture3.html", + "file_mousecapture4.html", + "file_mousecapture5.html", +] +skip-if = [ + "os == 'android'", + "xorigin", # SecurityError: Permission denied to access property "scrollX" on cross-origin object at runTests@http://mochi.test:8888/tests/toolkit/content/tests/mochitest/test_mousecapture.xhtml:170:17, inconsistent fail/pass + "http3", + "http2", +] + +["test_video_control_no_control_overlay.html"] +support-files = ["gizmo.mp4"] +run-if = ["os == 'android'"] diff --git a/toolkit/content/tests/mochitest/test_autocomplete_change_after_focus.html b/toolkit/content/tests/mochitest/test_autocomplete_change_after_focus.html new file mode 100644 index 0000000000..fe4a6ee67c --- /dev/null +++ b/toolkit/content/tests/mochitest/test_autocomplete_change_after_focus.html @@ -0,0 +1,104 @@ +<!DOCTYPE html> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=998893 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 998893 - Ensure that input.value changes affect autocomplete</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> + <script type="text/javascript"> + /** Test for Bug 998893 **/ + add_task(async function waitForFocus() { + await new Promise(resolve => SimpleTest.waitForFocus(resolve)); + }); + + add_task(async function setup() { + await new Promise(resolve => { + let chromeScript = SpecialPowers.loadChromeScript(function() { + /* eslint-env mozilla/chrome-script */ + const {FormHistory} = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" + ); + FormHistory.update([ + { op: "bump", fieldname: "field1", value: "Default text option" }, + { op: "bump", fieldname: "field1", value: "New value option" }, + ]).then(() => { + sendAsyncMessage("Test:Resume"); + }); + }); + + chromeScript.addMessageListener("Test:Resume", function resumeListener() { + chromeScript.removeMessageListener("Test:Resume", resumeListener); + chromeScript.destroy(); + resolve(); + }); + }); + }); + + add_task(async function runTest() { + let promisePopupShown = new Promise(resolve => { + let chromeScript = SpecialPowers.loadChromeScript(function() { + /* eslint-env mozilla/chrome-script */ + let window = Services.wm.getMostRecentWindow("navigator:browser"); + let popup = window.document.getElementById("PopupAutoComplete"); + popup.addEventListener("popupshown", function() { + sendAsyncMessage("Test:Resume"); + }, {once: true}); + }); + + chromeScript.addMessageListener("Test:Resume", function resumeListener() { + chromeScript.removeMessageListener("Test:Resume", resumeListener); + chromeScript.destroy(); + resolve(); + }); + }); + + let field = document.getElementById("field1"); + + let promiseFieldFocus = new Promise(resolve => { + field.addEventListener("focus", function onFocus() { + info("field focused"); + field.value = "New value"; + sendKey("DOWN"); + resolve(); + }); + }); + + let handleEnterPromise = new Promise(resolve => { + function handleEnter(evt) { + if (evt.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + info("RETURN received for phase: " + evt.eventPhase); + is(evt.target.value, "New value option", "Check that the correct autocomplete entry was used"); + resolve(); + } + + SpecialPowers.addSystemEventListener(field, "keypress", handleEnter, true); + }); + + field.focus(); + + await promiseFieldFocus; + + await promisePopupShown; + + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); + synthesizeKey("KEY_Enter"); + + await handleEnterPromise; + }); + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=998893">Mozilla Bug 998893</a> +<p id="display"><input id="field1" value="Default text"></p> +<div id="content" style="display: none"></div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/mochitest/test_bug1407085.html b/toolkit/content/tests/mochitest/test_bug1407085.html new file mode 100644 index 0000000000..17fc2f8a3b --- /dev/null +++ b/toolkit/content/tests/mochitest/test_bug1407085.html @@ -0,0 +1,38 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Bug 1407085</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <input id="input" value="original value"> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +SimpleTest.waitForExplicitFinish(); +SimpleTest.waitForFocus(runTests); + +function runTests() { + let input = document.getElementById("input"); + input.focus(); + input.addEventListener("keydown", () => { + input.value = "new value"; + }, { once: true }); + synthesizeKey("KEY_Escape"); + is(input.value, "new value", + "New <input> value changed by an Escape key event listener shouldn't be " + + "overwritten by original value even if Escape key is pressed"); + SimpleTest.finish(); +} + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/mochitest/test_mousecapture.xhtml b/toolkit/content/tests/mochitest/test_mousecapture.xhtml new file mode 100644 index 0000000000..15576ab45b --- /dev/null +++ b/toolkit/content/tests/mochitest/test_mousecapture.xhtml @@ -0,0 +1,351 @@ +<?xml version="1.0"?> +<!DOCTYPE HTML> +<html xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title>Mouse Capture Tests</title> + <link rel="stylesheet" href="chrome://global/skin/global.css" type="text/css"/> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> +</head> +<body id="body" xmlns="http://www.w3.org/1999/xhtml"> + <p id="display"/><div id="content" style="display: none"/><pre id="test"/> + +<script><![CDATA[ + +SimpleTest.expectAssertions(6, 12); + +SimpleTest.waitForExplicitFinish(); + +const TEST_PATH = location.href.replace("test_mousecapture.xhtml", ""); + +var captureRetargetMode = false; +var cachedMouseDown = null; +var previousWidth = 0, originalWidth = 0; +var loadInWindow = false; + +/** + * We'll make sure to suppress any dragstart events and prevent them + * from reaching the widget layer to avoid this assertion: + * https://searchfox.org/mozilla-central/rev/47aea2f603cc18144afcedbd604a418f11e90f9b/widget/nsBaseDragService.cpp#341-355 + */ +addEventListener("dragstart", e => { + e.preventDefault(); + e.stopPropagation(); +}); + +function splitterCallback(adjustment) { + var newWidth = parseInt($("leftbox").style.width); // getBoundingClientRect().width; + var expectedWidth = previousWidth + adjustment; + if (expectedWidth > $("splitterbox").getBoundingClientRect().width) + expectedWidth = $("splitterbox").getBoundingClientRect().width - $("splitter").getBoundingClientRect().width; + is(newWidth, expectedWidth, "splitter left box size (" + adjustment + ")"); + previousWidth = newWidth; +} + +function selectionCallback(adjustment) { + if (adjustment == 4000) { + is(frames[0].getSelection().toString(), "This is some text", "selection after drag (" + adjustment + ")"); + ok(frames[0].scrollY > 40, "selection caused scroll down (" + adjustment + ")"); + } else { + if (adjustment == 0) { + is(frames[0].getSelection().toString(), ".", "selection after drag (" + adjustment + ")"); + } + is(frames[0].scrollY, 0, "selection scrollY (" + adjustment + ")"); + } +} + +function framesetCallback(adjustment) { + var newWidth = frames[1].frames[0].document.documentElement.clientWidth; + var expectedWidth = originalWidth + adjustment; + if (adjustment == 0) + expectedWidth = originalWidth - 12; + else if (expectedWidth >= 4000) + expectedWidth = originalWidth * 2 - 2; + + ok(Math.abs(newWidth - expectedWidth) <= 1, "frameset after drag (" + adjustment + "), new width " + newWidth + ", expected " + expectedWidth); +} + +var otherWindow = null; + +function selectionScrollCheck() { + var element = otherWindow.document.documentElement; + + var count = 0; + let previousScrollPosition = 0; + + function selectionScrollDone() { + // Don't count unchanged scroll position. + // There's a bug that scroll events get fired during reconstructing scroll + // frames even if the scroll position is unchanged (bug 1825470). We need to + // ignore the unchanged scroll events here. + if (otherWindow.scrollY == previousScrollPosition) { + return; + } + previousScrollPosition = otherWindow.scrollY; + + // wait for 6 scroll events to occur + if (count++ < 6) { + return; + } + + otherWindow.removeEventListener("scroll", selectionScrollDone); + + var selectedText = otherWindow.getSelection().toString().replace(/\r/g, ""); + // We trim the end of the string because, per the below comment, we might + // select additional newlines, and that's OK. + is(selectedText.trimEnd(), "One\n\nTwo", "text is selected"); + + // should have scrolled 20 pixels from the mousemove above and at least 6 + // extra 20-pixel increments from the selection scroll timer. "At least 6" + // because we waited for 6 scroll events but multiple scrolls could get + // coalesced into a single scroll event, and paints could be delayed when + // the window loads when the compositor is busy. As a result, we have no + // real guarantees about the upper bound here, and as the upper bound is + // not important for what we're testing here, we don't check it. + var scrollY = otherWindow.scrollY; + info(`Scrolled ${scrollY} pixels`); + ok(scrollY >= 140, "selection scroll position after timer is at least 140"); + ok((scrollY % 20) == 0, "selection scroll position after timer is multiple of 20"); + + synthesizeMouse(element, 4, otherWindow.innerHeight + 25, { type: "mouseup" }, otherWindow); + disableNonTestMouseEvents(false); + otherWindow.close(); + + if (loadInWindow) { + SimpleTest.finish(); + } else { + // now try again, but open the page in a new window + loadInWindow = true; + synthesizeMouse(document.getElementById("custom"), 2, 2, { type: "mousedown" }); + + // check to ensure that selection dragging scrolls the right scrollable area + otherWindow = window.open(TEST_PATH + "file_mousecapture.html", "_blank", "width=200,height=200,scrollbars=yes"); + SimpleTest.waitForFocus(selectionScrollCheck, otherWindow); + } + } + + SimpleTest.executeSoon(function() { + disableNonTestMouseEvents(true); + synthesizeMouse(element, 2, 2, { type: "mousedown" }, otherWindow); + synthesizeMouse(element, 100, otherWindow.innerHeight + 20, { type: "mousemove" }, otherWindow); + otherWindow.addEventListener("scroll", selectionScrollDone); + }); +} + +function runTests() { + previousWidth = $("leftbox").getBoundingClientRect().width; + runCaptureTest($("splitter"), splitterCallback); + + var custom = document.getElementById("custom"); + runCaptureTest(custom); + + synthesizeMouseExpectEvent($("rightbox"), 2, 2, { type: "mousemove" }, + $("rightbox"), "mousemove", "setCapture and releaseCapture"); + + custom.setCapture(); + synthesizeMouseExpectEvent($("leftbox"), 2, 2, { type: "mousemove" }, + $("leftbox"), "mousemove", "setCapture fails on non mousedown"); + + var custom2 = document.getElementById("custom2"); + synthesizeMouse(custom2, 2, 2, { type: "mousedown" }); + synthesizeMouseExpectEvent($("leftbox"), 2, 2, { type: "mousemove" }, + $("leftbox"), "mousemove", "document.releaseCapture releases capture"); + + var custom3 = document.getElementById("custom3"); + synthesizeMouse(custom3, 2, 2, { type: "mousedown" }); + synthesizeMouseExpectEvent($("leftbox"), 2, 2, { type: "mousemove" }, + $("leftbox"), "mousemove", "element.releaseCapture releases capture"); + + var custom4 = document.getElementById("custom4"); + synthesizeMouse(custom4, 2, 2, { type: "mousedown" }); + synthesizeMouseExpectEvent($("leftbox"), 2, 2, { type: "mousemove" }, + custom4, "mousemove", "element.releaseCapture during mousemove before releaseCapture"); + synthesizeMouseExpectEvent($("leftbox"), 2, 2, { type: "mousemove" }, + $("leftbox"), "mousemove", "element.releaseCapture during mousemove after releaseCapture"); + + var custom5 = document.getElementById("custom5"); + runCaptureTest(custom5); + captureRetargetMode = true; + runCaptureTest(custom5); + captureRetargetMode = false; + + var custom6 = document.getElementById("custom6"); + synthesizeMouse(custom6, 2, 2, { type: "mousedown" }); + synthesizeMouseExpectEvent($("leftbox"), 2, 2, { type: "mousemove" }, + $("leftbox"), "mousemove", "setCapture only works on elements in documents"); + synthesizeMouse(custom6, 2, 2, { type: "mouseup" }); + + // test that mousedown on an image with setCapture followed by a big enough + // mouse move does not start a drag (bug 517737) + var image = document.getElementById("image"); + image.scrollIntoView(); + synthesizeMouse(image, 2, 2, { type: "mousedown" }); + synthesizeMouseExpectEvent($("leftbox"), 2, 2, { type: "mousemove" }, + image, "mousemove", "setCapture works on images"); + synthesizeMouse(image, 2, 2, { type: "mouseup" }); + + window.scroll(0, 0); + + // save scroll + var scrollX = parent ? parent.scrollX : 0; + var scrollY = parent ? parent.scrollY : 0; + + // restore scroll + if (parent) parent.scroll(scrollX, scrollY); + +// frames[0].getSelection().collapseToStart(); + + var body = frames[0].document.body; + var fixed = frames[0].document.getElementById("fixed"); + function captureOnBody() { body.setCapture(); } + body.addEventListener("mousedown", captureOnBody, true); + synthesizeMouse(body, 8, 8, { type: "mousedown" }, frames[0]); + body.removeEventListener("mousedown", captureOnBody, true); + synthesizeMouseExpectEvent(fixed, 2, 2, { type: "mousemove" }, + fixed, "mousemove", "setCapture on body retargets to root node", frames[0]); + synthesizeMouse(body, 8, 8, { type: "mouseup" }, frames[0]); + + previousWidth = frames[1].frames[0].document.documentElement.clientWidth; + originalWidth = previousWidth; + runCaptureTest(frames[1].document.documentElement.lastElementChild, framesetCallback); + + // ensure that clicking on an element where the frame disappears doesn't crash + synthesizeMouse(frames[2].document.getElementById("input"), 8, 8, { type: "mousedown" }, frames[2]); + synthesizeMouse(frames[2].document.getElementById("input"), 8, 8, { type: "mouseup" }, frames[2]); + + var select = document.getElementById("select"); + select.scrollIntoView(); + + synthesizeMouse(document.getElementById("option3"), 2, 2, { type: "mousedown" }); + synthesizeMouse(document.getElementById("option3"), 2, 1000, { type: "mousemove" }); + is(select.selectedIndex, 2, "scroll select"); + synthesizeMouse(document.getElementById("select"), 2, 2, { type: "mouseup" }); + window.scroll(0, 0); + + synthesizeMouse(custom, 2, 2, { type: "mousedown" }); + + // check to ensure that selection dragging scrolls the right scrollable area. + // This should open the page in a new tab. + + var topPos = window.innerHeight; + otherWindow = window.open(TEST_PATH + "file_mousecapture2.html?topPos=" + topPos, "_blank"); + SimpleTest.waitForFocus(selectionScrollCheck, otherWindow); +} + +function runCaptureTest(element, callback) { + var expectedTarget = null; + + // ownerGlobal doesn't exist in content privileged windows. + // eslint-disable-next-line mozilla/use-ownerGlobal + var win = element.ownerDocument.defaultView; + + function mouseMoved(event) { + is(event.originalTarget, expectedTarget, + expectedTarget.id + " target for point " + event.clientX + "," + event.clientY); + } + win.addEventListener("mousemove", mouseMoved); + + expectedTarget = element; + + var basepoint = element.localName == "frameset" ? 50 : 2; + synthesizeMouse(element, basepoint, basepoint, { type: "mousedown" }, win); + + // in setCapture(true) mode, all events should fire on custom5. In + // setCapture(false) mode, events can fire at a descendant + if (expectedTarget == $("custom5") && !captureRetargetMode) + expectedTarget = $("custom5spacer"); + + // releaseCapture should do nothing for an element which isn't capturing + $("splitterbox").releaseCapture(); + + synthesizeMouse(element, basepoint + 2, basepoint + 2, { type: "mousemove" }, win); + if (callback) + callback(2); + + if (expectedTarget == $("custom5spacer") && !captureRetargetMode) + expectedTarget = $("custom5inner"); + + if (element.id == "b") { + var tooltip = document.getElementById("tooltip"); + tooltip.openPopup(); + tooltip.hidePopup(); + } + + synthesizeMouse(element, basepoint + 25, basepoint + 25, { type: "mousemove" }, win); + if (callback) + callback(25); + + expectedTarget = element.localName == "b" ? win.document.documentElement : element; + synthesizeMouse(element, basepoint + 4000, basepoint + 4000, { type: "mousemove" }, win); + if (callback) + callback(4000); + synthesizeMouse(element, basepoint - 12, basepoint - 12, { type: "mousemove" }, win); + if (callback) + callback(-12); + + expectedTarget = element.localName == "frameset" ? element : win.document.documentElement; + synthesizeMouse(element, basepoint + 30, basepoint + 30, { type: "mouseup" }, win); + synthesizeMouse(win.document.documentElement, 2, 2, { type: "mousemove" }, win); + if (callback) + callback(0); + + win.removeEventListener("mousemove", mouseMoved); +} + +SimpleTest.waitForFocus(runTests); + +]]> +</script> + +<xul:vbox xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" align="start"> + <tooltip id="tooltip"> + <label value="Test"/> + </tooltip> + + <hbox id="splitterbox" style="margin-top: 5px;" onmousedown="this.setCapture()"> + <hbox id="leftbox" style="width: 100px; flex: 1 auto"/> + <splitter id="splitter" style="width: 5px; height: 5px"/> + <hbox id="rightbox" style="width: 100px; flex: 1 auto"/> + </hbox> + + <vbox id="custom" style="width: 10px; height: 10px" onmousedown="this.setCapture(); cachedMouseDown = event;"/> + <vbox id="custom2" style="width: 10px; height: 10px" onmousedown="this.setCapture(); document.releaseCapture();"/> + <vbox id="custom3" style="width: 10px; height: 10px" onmousedown="this.setCapture(); this.releaseCapture();"/> + <vbox id="custom4" style="width: 10px; height: 10px" onmousedown="this.setCapture();" + onmousemove="this.releaseCapture();"/> + <hbox id="custom5" style="width: 40px; height: 40px" + onmousedown="this.setCapture(captureRetargetMode);"> + <spacer id="custom5spacer" style="width: 5px"/> + <hbox id="custom5inner" style="width: 35px; height: 35px"/> + </hbox> + <vbox id="custom6" style="width: 10px; height: 10px" + onmousedown="document.createElement('hbox').setCapture();"/> +</xul:vbox> + + <iframe style="width: 100px; height: 100px" src="file_mousecapture3.html"/> + <iframe style="width: 100px; height: 100px" src="file_mousecapture4.html"/> + <iframe style="width: 100px; height: 100px" src="file_mousecapture5.html"/> + + <select id="select" xmlns="http://www.w3.org/1999/xhtml" size="4"> + <option id="option1">One</option> + <option id="option2">Two</option> + <option id="option3">Three</option> + <option id="option4">Four</option> + <option id="option5">Five</option> + <option id="option6">Six</option> + <option id="option7">Seven</option> + <option id="option8">Eight</option> + <option id="option9">Nine</option> + <option id="option10">Ten</option> + </select> + + <img id="image" xmlns="http://www.w3.org/1999/xhtml" + onmousedown="this.setCapture();" onmouseup="this.releaseCapture();" + ondragstart="ok(false, 'should not get a drag when a setCapture is active');" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAAG0lEQVR42mP8z0A%2BYKJA76jmUc2jmkc1U0EzACKcASfOgGoMAAAAAElFTkSuQmCC"/> + +</body> + +</html> diff --git a/toolkit/content/tests/mochitest/test_video_control_no_control_overlay.html b/toolkit/content/tests/mochitest/test_video_control_no_control_overlay.html new file mode 100644 index 0000000000..6a079d77dc --- /dev/null +++ b/toolkit/content/tests/mochitest/test_video_control_no_control_overlay.html @@ -0,0 +1,35 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Show 'click-to-play' icon on blocked autoplay media</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> + <script type="application/javascript"> +/** + * This test is used to check whether 'click-to-play' icon would be showed + * correctly when autoplay media is blocked. + */ +add_task(async function testShowClickToPlayWhenAutoplayMediaGetsBlocked() { + info(`setting testing pref`); + await SpecialPowers.pushPrefEnv( + {"set": [["media.autoplay.default", 1 /* BLOCKED */]]} + ); + + info(`create video and load resource`); + let video = document.createElement('video'); + video.src = "gizmo.mp4"; + document.body.appendChild(video); + + info(`blocking autoplay would reject media to play`); + ok(await video.play().then(_ => false, _ => true), "Play got rejected"); + + info(`'click-to-play' should display when autoplay media is blocked`); + const button = SpecialPowers.wrap(video).openOrClosedShadowRoot.querySelector(".clickToPlay"); + ok(!button.hidden, "Click-to-play button is not hidden"); +}); + +</script> +</head> +<body> +</body> +</html> diff --git a/toolkit/content/tests/moz.build b/toolkit/content/tests/moz.build new file mode 100644 index 0000000000..682ca7e3e8 --- /dev/null +++ b/toolkit/content/tests/moz.build @@ -0,0 +1,22 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XPCSHELL_TESTS_MANIFESTS += ["unit/xpcshell.toml"] + +BROWSER_CHROME_MANIFESTS += [ + "browser/browser.toml", + "browser/datetime/browser.toml", +] + +MOCHITEST_CHROME_MANIFESTS += [ + "chrome/chrome.toml", + "widgets/chrome.toml", +] + +MOCHITEST_MANIFESTS += [ + "mochitest/mochitest.toml", + "widgets/mochitest.toml", +] diff --git a/toolkit/content/tests/reftests/audio-dynamically-change-small-width-ref.html b/toolkit/content/tests/reftests/audio-dynamically-change-small-width-ref.html new file mode 100644 index 0000000000..4bcac35d84 --- /dev/null +++ b/toolkit/content/tests/reftests/audio-dynamically-change-small-width-ref.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<head> +<style> + html, body { + margin: 0; + padding: 0; + } +</style> +</head> +<body> + <audio controls></audio> +</body> diff --git a/toolkit/content/tests/reftests/audio-dynamically-change-small-width.html b/toolkit/content/tests/reftests/audio-dynamically-change-small-width.html new file mode 100644 index 0000000000..0e9059541a --- /dev/null +++ b/toolkit/content/tests/reftests/audio-dynamically-change-small-width.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> +<style> + html, body { + margin: 0; + padding: 0; + } +</style> +</head> +<body> + <audio id="tweakme" controls></audio> + + <script> + function doTest() { + setTimeout(() => { + let tweakme = document.getElementById("tweakme"); + + // Make the audio element extremely skinny, flush layout, and then revert + // that change: + tweakme.style.width = "1px"; + tweakme.offsetHeight; // flush layout + tweakme.style.width = ""; + tweakme.offsetHeight; // flush layout + + document.documentElement.removeAttribute("class"); + }, 300); + } + + window.addEventListener("MozReftestInvalidate", doTest); + </script> +</body> diff --git a/toolkit/content/tests/reftests/audio-with-bogus-url-ref.html b/toolkit/content/tests/reftests/audio-with-bogus-url-ref.html new file mode 100644 index 0000000000..7731e5ced6 --- /dev/null +++ b/toolkit/content/tests/reftests/audio-with-bogus-url-ref.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> +<head> +<style> + html, body { + margin: 0; + padding: 0; + } +</style> +</head> +<body> + <audio controls></audio> +</body> +</html> diff --git a/toolkit/content/tests/reftests/audio-with-bogus-url.html b/toolkit/content/tests/reftests/audio-with-bogus-url.html new file mode 100644 index 0000000000..d6bf7ff861 --- /dev/null +++ b/toolkit/content/tests/reftests/audio-with-bogus-url.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> +<head> +<style> + html, body { + margin: 0; + padding: 0; + } +</style> +</head> +<body> + <audio src="bogus.mp3" controls></audio> +</body> +</html> diff --git a/toolkit/content/tests/reftests/audio-with-padding-ref.html b/toolkit/content/tests/reftests/audio-with-padding-ref.html new file mode 100644 index 0000000000..b0fe3fa6e5 --- /dev/null +++ b/toolkit/content/tests/reftests/audio-with-padding-ref.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> +<head> +<style> + html, body { + margin: 0; + padding: 0; + } + + #wrapper { + padding: 20px; + } +</style> +</head> +<body> + <div id="wrapper"> + <audio controls></audio> + <div> +</body> diff --git a/toolkit/content/tests/reftests/audio-with-padding.html b/toolkit/content/tests/reftests/audio-with-padding.html new file mode 100644 index 0000000000..c18b746374 --- /dev/null +++ b/toolkit/content/tests/reftests/audio-with-padding.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> +<head> +<style> + html, body { + margin: 0; + padding: 0; + } + + audio { + padding: 20px; + } +</style> +</head> +<body> + <audio controls></audio> +</body> diff --git a/toolkit/content/tests/reftests/foo.vtt b/toolkit/content/tests/reftests/foo.vtt new file mode 100644 index 0000000000..b533895c60 --- /dev/null +++ b/toolkit/content/tests/reftests/foo.vtt @@ -0,0 +1,4 @@ +WEBVTT + +00:00:00.000 --> 00:00:05.000 +Foo diff --git a/toolkit/content/tests/reftests/reftest.list b/toolkit/content/tests/reftests/reftest.list new file mode 100644 index 0000000000..d1e8ce3dbd --- /dev/null +++ b/toolkit/content/tests/reftests/reftest.list @@ -0,0 +1,4 @@ +== videocontrols-dynamically-add-cc.html videocontrols-dynamically-add-cc-ref.html +== audio-with-bogus-url.html audio-with-bogus-url-ref.html +== audio-dynamically-change-small-width.html audio-dynamically-change-small-width-ref.html +== audio-with-padding.html audio-with-padding-ref.html diff --git a/toolkit/content/tests/reftests/videocontrols-dynamically-add-cc-ref.html b/toolkit/content/tests/reftests/videocontrols-dynamically-add-cc-ref.html new file mode 100644 index 0000000000..1dcf4949a6 --- /dev/null +++ b/toolkit/content/tests/reftests/videocontrols-dynamically-add-cc-ref.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html> +<head> +<style> + html, body { + margin: 0; + padding: 0; + } + + video { + width: 320px; + height: 240px; + } + + #mask { + position: absolute; + z-index: 3; + width: 320px; + height: 200px; + background-color: green; + top: 0; + left: 0; + } +</style> +</head> +<body> + <video id="vid" controls> + <track kind="subtitles" src="foo.vtt" srclang="en"> + </video> + <div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/reftests/videocontrols-dynamically-add-cc.html b/toolkit/content/tests/reftests/videocontrols-dynamically-add-cc.html new file mode 100644 index 0000000000..e3d0912a51 --- /dev/null +++ b/toolkit/content/tests/reftests/videocontrols-dynamically-add-cc.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> +<style> + html, body { + margin: 0; + padding: 0; + } + + video { + width: 320px; + height:240px; + } + + #mask { + position: absolute; + z-index: 3; + width: 320px; + height: 200px; + background-color: green; + top: 0; + left: 0; + } + + #vid-preload-image { + visibility: hidden; + } +</style> +<script> + function addCCToVid(videoElem) { + videoElem.addTextTrack("subtitles", "English", "en"); + } +</script> +</head> +<body> + <video id="vid" controls></video> + <div id="mask"></div> + <!-- Create a hidden video with CC button displayed upfront to decode image + earlier, so that the CC image will be ready to paint once the track added. --> + <video id="vid-preload-image" controls> + <track kind="subtitles" src="foo.vtt" srclang="en"> + </video> + + <script> + function doTest() { + var vid = document.getElementById("vid"); + + // Videocontrols binding's "addtrack" handler synchronously fires + // "adjustControlSize()" first, and then the layout is ready for + // the reftest snapshot. + vid.textTracks.addEventListener("addtrack", function() { + document.documentElement.removeAttribute("class"); + }); + + addCCToVid(vid); + } + + window.addEventListener("MozReftestInvalidate", doTest); + </script> +</body> +</html> diff --git a/toolkit/content/tests/unit/test_contentAreaUtils.js b/toolkit/content/tests/unit/test_contentAreaUtils.js new file mode 100644 index 0000000000..6ea83db251 --- /dev/null +++ b/toolkit/content/tests/unit/test_contentAreaUtils.js @@ -0,0 +1,80 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +function loadUtilsScript() { + /* import-globals-from ../../contentAreaUtils.js */ + Services.scriptloader.loadSubScript( + "chrome://global/content/contentAreaUtils.js" + ); +} + +function test_urlSecurityCheck() { + var nullPrincipal = Services.scriptSecurityManager.createNullPrincipal({}); + + const HTTP_URI = "http://www.mozilla.org/"; + const CHROME_URI = "chrome://browser/content/browser.xhtml"; + const DISALLOW_INHERIT_PRINCIPAL = + Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL; + + try { + urlSecurityCheck( + makeURI(HTTP_URI), + nullPrincipal, + DISALLOW_INHERIT_PRINCIPAL + ); + } catch (ex) { + do_throw( + "urlSecurityCheck should not throw when linking to a http uri with a null principal" + ); + } + + // urlSecurityCheck also supports passing the url as a string + try { + urlSecurityCheck(HTTP_URI, nullPrincipal, DISALLOW_INHERIT_PRINCIPAL); + } catch (ex) { + do_throw( + "urlSecurityCheck failed to handle the http URI as a string (uri spec)" + ); + } + + let shouldThrow = true; + try { + urlSecurityCheck(CHROME_URI, nullPrincipal, DISALLOW_INHERIT_PRINCIPAL); + } catch (ex) { + shouldThrow = false; + } + if (shouldThrow) { + do_throw( + "urlSecurityCheck should throw when linking to a chrome uri with a null principal" + ); + } +} + +function test_stringBundle() { + // This test verifies that the elements that can be used as file picker title + // keys in the save* functions are actually present in the string bundle. + // These keys are part of the contentAreaUtils.js public API. + var validFilePickerTitleKeys = [ + "SaveImageTitle", + "SaveVideoTitle", + "SaveAudioTitle", + "SaveLinkTitle", + ]; + + for (let filePickerTitleKey of validFilePickerTitleKeys) { + // Just check that the string exists + try { + ContentAreaUtils.stringBundle.GetStringFromName(filePickerTitleKey); + } catch (e) { + do_throw("Error accessing file picker title key: " + filePickerTitleKey); + } + } +} + +function run_test() { + loadUtilsScript(); + test_urlSecurityCheck(); + test_stringBundle(); +} diff --git a/toolkit/content/tests/unit/xpcshell.toml b/toolkit/content/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..237183037e --- /dev/null +++ b/toolkit/content/tests/unit/xpcshell.toml @@ -0,0 +1,4 @@ +[DEFAULT] +head = "" + +["test_contentAreaUtils.js"] diff --git a/toolkit/content/tests/widgets/audio.ogg b/toolkit/content/tests/widgets/audio.ogg Binary files differnew file mode 100644 index 0000000000..a553c23e73 --- /dev/null +++ b/toolkit/content/tests/widgets/audio.ogg diff --git a/toolkit/content/tests/widgets/audio.wav b/toolkit/content/tests/widgets/audio.wav Binary files differnew file mode 100644 index 0000000000..c6fd5cb869 --- /dev/null +++ b/toolkit/content/tests/widgets/audio.wav diff --git a/toolkit/content/tests/widgets/chrome.toml b/toolkit/content/tests/widgets/chrome.toml new file mode 100644 index 0000000000..af2c778947 --- /dev/null +++ b/toolkit/content/tests/widgets/chrome.toml @@ -0,0 +1,68 @@ +[DEFAULT] +skip-if = ["os == 'android'"] +support-files = [ + "tree_shared.js", + "popup_shared.js", + "window_label_checkbox.xhtml", + "window_menubar.xhtml", + "seek_with_sound.ogg", +] +prefs = ["app.support.baseURL='https://support.mozilla.org/'"] + +["test_contextmenu_menugroup.xhtml"] +skip-if = ["os == 'linux'"] # Bug 1115088 + +["test_contextmenu_nested.xhtml"] +skip-if = ["os == 'linux'"] # Bug 1116215 + +["test_editor_currentURI.xhtml"] + +["test_label_checkbox.xhtml"] + +["test_menubar.xhtml"] +skip-if = ["os == 'mac'"] + +["test_moz_button_group.html"] + +["test_moz_card.html"] + +["test_moz_five_star.html"] + +["test_moz_label.html"] + +["test_moz_message_bar.html"] + +["test_moz_support_link.html"] + +["test_moz_toggle.html"] + +["test_panel_item_accesskey.html"] + +["test_panel_list_accessibility.html"] + +["test_panel_list_anchoring.html"] + +["test_panel_list_in_xul_panel.html"] + +["test_panel_list_min_width_from_anchor.html"] + +["test_popupanchor.xhtml"] +skip-if = [ + "os == 'linux' && os_version == '18.04'", # Bug 1335894 perma-fail on linux 16.04 + "verify && os == 'win'", +] + +["test_popupreflows.xhtml"] + +["test_tree_column_reorder.xhtml"] + +["test_videocontrols_focus.html"] +support-files = [ + "head.js", + "video.ogg", +] +skip-if = [ + "os == 'android'", + "os == 'linux' && debug", # Bug 1765783 +] +["test_videocontrols_onclickplay.html"] diff --git a/toolkit/content/tests/widgets/file_videocontrols_jsdisabled.html b/toolkit/content/tests/widgets/file_videocontrols_jsdisabled.html new file mode 100644 index 0000000000..56917b69ac --- /dev/null +++ b/toolkit/content/tests/widgets/file_videocontrols_jsdisabled.html @@ -0,0 +1,2 @@ +<video src="seek_with_sound.ogg" controls autoplay=true></video> +<script>window.testExpando = true;</script> diff --git a/toolkit/content/tests/widgets/head.js b/toolkit/content/tests/widgets/head.js new file mode 100644 index 0000000000..d7473fa92d --- /dev/null +++ b/toolkit/content/tests/widgets/head.js @@ -0,0 +1,67 @@ +"use strict"; + +const InspectorUtils = SpecialPowers.InspectorUtils; + +var tests = []; + +function waitForCondition(condition, nextTest, errorMsg) { + var tries = 0; + var interval = setInterval(function () { + if (tries >= 30) { + ok(false, errorMsg); + moveOn(); + } + var conditionPassed; + try { + conditionPassed = condition(); + } catch (e) { + ok(false, e + "\n" + e.stack); + conditionPassed = false; + } + if (conditionPassed) { + moveOn(); + } + tries++; + }, 100); + var moveOn = function () { + clearInterval(interval); + nextTest(); + }; +} + +function getElementWithinVideo(video, aValue) { + const shadowRoot = SpecialPowers.wrap(video).openOrClosedShadowRoot; + return shadowRoot.getElementById(aValue); +} + +/** + * Runs querySelectorAll on an element's shadow root. + * @param {Element} element + * @param {string} selector + */ +function shadowRootQuerySelectorAll(element, selector) { + const shadowRoot = SpecialPowers.wrap(element).openOrClosedShadowRoot; + return shadowRoot?.querySelectorAll(selector); +} + +function executeTests() { + return tests + .map(fn => () => new Promise(fn)) + .reduce((promise, task) => promise.then(task), Promise.resolve()); +} + +function once(target, name, cb) { + let p = new Promise(function (resolve, reject) { + target.addEventListener( + name, + function () { + resolve(); + }, + { once: true } + ); + }); + if (cb) { + p.then(cb); + } + return p; +} diff --git a/toolkit/content/tests/widgets/image-zh.png b/toolkit/content/tests/widgets/image-zh.png Binary files differnew file mode 100644 index 0000000000..944a12d39e --- /dev/null +++ b/toolkit/content/tests/widgets/image-zh.png diff --git a/toolkit/content/tests/widgets/image.png b/toolkit/content/tests/widgets/image.png Binary files differnew file mode 100644 index 0000000000..3faa11b221 --- /dev/null +++ b/toolkit/content/tests/widgets/image.png diff --git a/toolkit/content/tests/widgets/mochitest.toml b/toolkit/content/tests/widgets/mochitest.toml new file mode 100644 index 0000000000..7e20352256 --- /dev/null +++ b/toolkit/content/tests/widgets/mochitest.toml @@ -0,0 +1,110 @@ +[DEFAULT] +support-files = [ + "audio.wav", + "audio.ogg", + "file_videocontrols_jsdisabled.html", + "image.png", + "image-zh.png", + "seek_with_sound.ogg", + "video.ogg", + "head.js", + "tree_shared.js", + "test-webvtt-1.vtt", + "test-webvtt-2.vtt", + "videocontrols_direction-1-ref.html", + "videocontrols_direction-1a.html", + "videocontrols_direction-1b.html", + "videocontrols_direction-1c.html", + "videocontrols_direction-1d.html", + "videocontrols_direction-1e.html", + "videocontrols_direction-2-ref.html", + "videocontrols_direction-2a.html", + "videocontrols_direction-2b.html", + "videocontrols_direction-2c.html", + "videocontrols_direction-2d.html", + "videocontrols_direction-2e.html", + "videocontrols_direction_test.js", + "videomask.css", +] + +["test_audiocontrols_dimensions.html"] + +["test_audiocontrols_fullscreen.html"] + +["test_bug898940.html"] + +["test_bug1654500.html"] + +["test_image_recognition.html"] +run-if = ["os == 'mac'"] # Mac only feature. + +["test_image_recognition_unsupported.html"] +skip-if = ["os == 'mac'"] + +["test_image_recognition_zh.html"] +run-if = ["os == 'mac' && os_version != '10.15'"] # Mac only feature, requires > 10.15 to support multilingual results. + +["test_mousecapture_area.html"] + +["test_nac_mutations.html"] + +["test_panel_list_shadow_node_anchor.html"] +support-files = [ + "../../widgets/panel-list/panel-item.css", + "../../widgets/panel-list/panel-list.js", + "../../widgets/panel-list/panel-list.css", +] + +["test_ua_widget_elementFromPoint.html"] + +["test_ua_widget_sandbox.html"] + +["test_ua_widget_unbind.html"] + +["test_videocontrols.html"] +tags = "fullscreen" +skip-if = [ + "os == 'android'", #TIMED_OUT #Bug 1484210 + "os == 'linux'", #TIMED_OUT #Bug 1511256 +] + +["test_videocontrols_audio.html"] + +["test_videocontrols_audio_direction.html"] +skip-if = ["xorigin"] # Rendering of reftest videocontrols_direction-2a.html should not be different to the reference, fails/passes inconsistently + +["test_videocontrols_clickToPlay_ariaLabel.html"] + +["test_videocontrols_closed_caption_menu.html"] + +["test_videocontrols_error.html"] + +["test_videocontrols_iframe_fullscreen.html"] + +["test_videocontrols_jsdisabled.html"] +skip-if = ["os == 'android'"] # bug 1272646 + +["test_videocontrols_keyhandler.html"] +skip-if = [ + "os == 'android'", #Bug 1366957 + "os == 'linux'", #Bug 1366957 +] + +["test_videocontrols_scrubber_position.html"] + +["test_videocontrols_scrubber_position_nopreload.html"] + +["test_videocontrols_size.html"] + +["test_videocontrols_standalone.html"] +skip-if = ["os == 'android'"] # bug 1075573 + +["test_videocontrols_video_direction.html"] +skip-if = [ + "os == 'win'", + "xorigin", # Rendering of reftest videocontrols_direction-2a.html should not be different to the reference, fails/passes inconsistently +] + +["test_videocontrols_video_noaudio.html"] + +["test_videocontrols_vtt.html"] diff --git a/toolkit/content/tests/widgets/popup_shared.js b/toolkit/content/tests/widgets/popup_shared.js new file mode 100644 index 0000000000..0f093193c9 --- /dev/null +++ b/toolkit/content/tests/widgets/popup_shared.js @@ -0,0 +1,602 @@ +/* + * This script is used for menu and popup tests. Call startPopupTests to start + * the tests, passing an array of tests as an argument. Each test is an object + * with the following properties: + * testname - name of the test + * test - function to call to perform the test + * events - a list of events that are expected to be fired in sequence + * as a result of calling the 'test' function. This list should be + * an array of strings of the form "eventtype targetid" where + * 'eventtype' is the event type and 'targetid' is the id of + * target of the event. This function will be passed two + * arguments, the testname and the step argument. + * Alternatively, events may be a function which returns the array + * of events. This can be used when the events vary per platform. + * result - function to call after all the events have fired to check + * for additional results. May be null. This function will be + * passed two arguments, the testname and the step argument. + * steps - optional array of values. The test will be repeated for + * each step, passing each successive value within the array to + * the test and result functions + * autohide - if set, should be set to the id of a popup to hide after + * the test is complete. This is a convenience for some tests. + * condition - an optional function which, if it returns false, causes the + * test to be skipped. + * end - used for debugging. Set to true to stop the tests after running + * this one. + */ + +const menuactiveAttribute = "_moz-menuactive"; + +var gPopupTests = null; +var gTestIndex = -1; +var gTestStepIndex = 0; +var gTestEventIndex = 0; +var gActualEvents = []; +var gAutoHide = false; +var gExpectedEventDetails = null; +var gExpectedTriggerNode = null; +var gWindowUtils; +var gPopupWidth = -1, + gPopupHeight = -1; + +function startPopupTests(tests) { + document.addEventListener("popupshowing", eventOccurred); + document.addEventListener("popupshown", eventOccurred); + document.addEventListener("popuphiding", eventOccurred); + document.addEventListener("popuphidden", eventOccurred); + document.addEventListener("command", eventOccurred); + document.addEventListener("DOMMenuItemActive", eventOccurred); + document.addEventListener("DOMMenuItemInactive", eventOccurred); + document.addEventListener("DOMMenuInactive", eventOccurred); + document.addEventListener("DOMMenuBarActive", eventOccurred); + document.addEventListener("DOMMenuBarInactive", eventOccurred); + + // This is useful to explicitly finish a test that shouldn't trigger events. + document.addEventListener("TestDone", eventOccurred); + + gPopupTests = tests; + gWindowUtils = SpecialPowers.getDOMWindowUtils(window); + + goNext(); +} + +if (!window.opener && window.arguments) { + window.opener = window.arguments[0]; +} + +function finish() { + if (window.opener) { + window.close(); + window.opener.SimpleTest.finish(); + return; + } + SimpleTest.finish(); +} + +function ok(condition, message) { + if (window.opener) { + window.opener.SimpleTest.ok(condition, message); + } else { + SimpleTest.ok(condition, message); + } +} + +function info(message) { + if (window.opener) { + window.opener.SimpleTest.info(message); + } else { + SimpleTest.info(message); + } +} + +function is(left, right, message) { + if (window.opener) { + window.opener.SimpleTest.is(left, right, message); + } else { + SimpleTest.is(left, right, message); + } +} + +function disableNonTestMouse(aDisable) { + gWindowUtils.disableNonTestMouseEvents(aDisable); +} + +function eventOccurred(event) { + if (gPopupTests.length <= gTestIndex) { + ok(false, "Extra " + event.type + " event fired"); + return; + } + + var test = gPopupTests[gTestIndex]; + if ("autohide" in test && gAutoHide) { + if (event.type == "DOMMenuInactive") { + gAutoHide = false; + setTimeout(goNextStep, 0); + } + return; + } + + var events = test.events; + if (typeof events == "function") { + events = events(); + } + if (events) { + if (events.length <= gTestEventIndex) { + ok( + false, + "Extra " + + event.type + + " event fired for " + + event.target.id + + " " + + gPopupTests[gTestIndex].testname + ); + return; + } + + gActualEvents.push(`${event.type} ${event.target.id}`); + + var eventitem = events[gTestEventIndex].split(" "); + var matches; + if (eventitem[1] == "#tooltip") { + is( + event.originalTarget.localName, + "tooltip", + test.testname + " event.originalTarget.localName is 'tooltip'" + ); + is( + event.originalTarget.getAttribute("default"), + "true", + test.testname + " event.originalTarget default attribute is 'true'" + ); + matches = + event.originalTarget.localName == "tooltip" && + event.originalTarget.getAttribute("default") == "true"; + } else { + is( + event.type, + eventitem[0], + test.testname + " event type " + event.type + " fired" + ); + is( + event.target.id, + eventitem[1], + test.testname + " event target ID " + event.target.id + ); + matches = eventitem[0] == event.type && eventitem[1] == event.target.id; + } + + var modifiersMask = eventitem[2]; + if (modifiersMask) { + var m = ""; + m += event.altKey ? "1" : "0"; + m += event.ctrlKey ? "1" : "0"; + m += event.shiftKey ? "1" : "0"; + m += event.metaKey ? "1" : "0"; + is(m, modifiersMask, test.testname + " modifiers mask matches"); + } + + var expectedState; + switch (event.type) { + case "popupshowing": + expectedState = "showing"; + break; + case "popupshown": + expectedState = "open"; + break; + case "popuphiding": + expectedState = "hiding"; + break; + case "popuphidden": + expectedState = "closed"; + break; + } + + if (gExpectedTriggerNode && event.type == "popupshowing") { + if (gExpectedTriggerNode == "notset") { + // check against null instead + gExpectedTriggerNode = null; + } + + is( + event.originalTarget.triggerNode, + gExpectedTriggerNode, + test.testname + " popupshowing triggerNode" + ); + } + + if (expectedState) { + is( + event.originalTarget.state, + expectedState, + test.testname + " " + event.type + " state" + ); + } + + if (matches) { + gTestEventIndex++; + if (events.length <= gTestEventIndex) { + setTimeout(checkResult, 0); + } + } else { + info(`Actual events so far: ${JSON.stringify(gActualEvents)}`); + } + } +} + +async function checkResult() { + var step = null; + var test = gPopupTests[gTestIndex]; + if ("steps" in test) { + step = test.steps[gTestStepIndex]; + } + + if ("result" in test) { + await test.result(test.testname, step); + } + + if ("autohide" in test) { + gAutoHide = true; + document.getElementById(test.autohide).hidePopup(); + return; + } + + goNextStep(); +} + +function goNextStep() { + info(`events: ${JSON.stringify(gActualEvents)}`); + gTestEventIndex = 0; + gActualEvents = []; + + var step = null; + var test = gPopupTests[gTestIndex]; + if ("steps" in test) { + gTestStepIndex++; + step = test.steps[gTestStepIndex]; + if (gTestStepIndex < test.steps.length) { + test.test(test.testname, step); + return; + } + } + + goNext(); +} + +function goNext() { + // We want to continue after the next animation frame so that + // we're in a stable state and don't get spurious mouse events at unexpected targets. + window.requestAnimationFrame(function () { + setTimeout(goNextStepSync, 0); + }); +} + +function goNextStepSync() { + if ( + gTestIndex >= 0 && + "end" in gPopupTests[gTestIndex] && + gPopupTests[gTestIndex].end + ) { + finish(); + return; + } + + gTestIndex++; + gTestStepIndex = 0; + if (gTestIndex < gPopupTests.length) { + var test = gPopupTests[gTestIndex]; + // Set the location hash so it's easy to see which test is running + document.location.hash = test.testname; + info("Starting " + test.testname); + + // skip the test if the condition returns false + if ("condition" in test && !test.condition()) { + goNext(); + return; + } + + // start with the first step if there are any + var step = null; + if ("steps" in test) { + step = test.steps[gTestStepIndex]; + } + + test.test(test.testname, step); + + // no events to check for so just check the result + if (!("events" in test)) { + checkResult(); + } else if (typeof test.events == "function" && !test.events().length) { + checkResult(); + } + } else { + finish(); + } +} + +function openMenu(menu) { + if ("open" in menu) { + menu.open = true; + } else if (menu.hasMenu()) { + menu.openMenu(true); + } else { + synthesizeMouse(menu, 4, 4, {}); + } +} + +function closeMenu(menu, popup) { + if ("open" in menu) { + menu.open = false; + } else if (menu.hasMenu()) { + menu.openMenu(false); + } else { + popup.hidePopup(); + } +} + +function checkActive(popup, id, testname) { + var activeok = true; + var children = popup.childNodes; + for (var c = 0; c < children.length; c++) { + var child = children[c]; + if ( + (id == child.id && child.getAttribute(menuactiveAttribute) != "true") || + (id != child.id && child.hasAttribute(menuactiveAttribute) != "") + ) { + activeok = false; + break; + } + } + ok(activeok, testname + " item " + (id ? id : "none") + " active"); +} + +function checkOpen(menuid, testname) { + var menu = document.getElementById(menuid); + if ("open" in menu) { + ok(menu.open, testname + " " + menuid + " menu is open"); + } else if (menu.hasMenu()) { + ok( + menu.getAttribute("open") == "true", + testname + " " + menuid + " menu is open" + ); + } +} + +function checkClosed(menuid, testname) { + var menu = document.getElementById(menuid); + if ("open" in menu) { + ok(!menu.open, testname + " " + menuid + " menu is open"); + } else if (menu.hasMenu()) { + ok(!menu.hasAttribute("open"), testname + " " + menuid + " menu is closed"); + } +} + +function convertPosition(anchor, align) { + if (anchor == "topleft" && align == "topleft") { + return "overlap"; + } + if (anchor == "topleft" && align == "topright") { + return "start_before"; + } + if (anchor == "topleft" && align == "bottomleft") { + return "before_start"; + } + if (anchor == "topright" && align == "topleft") { + return "end_before"; + } + if (anchor == "topright" && align == "bottomright") { + return "before_end"; + } + if (anchor == "bottomleft" && align == "bottomright") { + return "start_after"; + } + if (anchor == "bottomleft" && align == "topleft") { + return "after_start"; + } + if (anchor == "bottomright" && align == "bottomleft") { + return "end_after"; + } + if (anchor == "bottomright" && align == "topright") { + return "after_end"; + } + return ""; +} + +/* + * When checking position of the bottom or right edge of the popup's rect, + * use this instead of strict equality check of rounded values, + * because we snap the top/left edges to pixel boundaries, + * which can shift the bottom/right up to 0.5px from its "ideal" location, + * and could cause it to round differently. (See bug 622507.) + */ +function isWithinHalfPixel(a, b, message) { + ok(Math.abs(a - b) <= 0.5, `${message}: ${a}, ${b}`); +} + +function compareEdge(anchor, popup, edge, offsetX, offsetY, testname) { + testname += " " + edge; + + checkOpen(anchor.id, testname); + + var anchorrect = anchor.getBoundingClientRect(); + var popuprect = popup.getBoundingClientRect(); + + if (gPopupWidth == -1) { + ok( + Math.round(popuprect.right) - Math.round(popuprect.left) && + Math.round(popuprect.bottom) - Math.round(popuprect.top), + testname + " size" + ); + } else { + is(Math.round(popuprect.width), gPopupWidth, testname + " width"); + is(Math.round(popuprect.height), gPopupHeight, testname + " height"); + } + + var spaceIdx = edge.indexOf(" "); + if (spaceIdx > 0) { + let cornerX, cornerY; + let [position, align] = edge.split(" "); + switch (position) { + case "topleft": + cornerX = anchorrect.left; + cornerY = anchorrect.top; + break; + case "topcenter": + cornerX = anchorrect.left + anchorrect.width / 2; + cornerY = anchorrect.top; + break; + case "topright": + cornerX = anchorrect.right; + cornerY = anchorrect.top; + break; + case "leftcenter": + cornerX = anchorrect.left; + cornerY = anchorrect.top + anchorrect.height / 2; + break; + case "rightcenter": + cornerX = anchorrect.right; + cornerY = anchorrect.top + anchorrect.height / 2; + break; + case "bottomleft": + cornerX = anchorrect.left; + cornerY = anchorrect.bottom; + break; + case "bottomcenter": + cornerX = anchorrect.left + anchorrect.width / 2; + cornerY = anchorrect.bottom; + break; + case "bottomright": + cornerX = anchorrect.right; + cornerY = anchorrect.bottom; + break; + } + + switch (align) { + case "topleft": + cornerX += offsetX; + cornerY += offsetY; + break; + case "topcenter": + cornerX += -popuprect.width / 2 + offsetX; + cornerY += offsetY; + break; + case "topright": + cornerX += -popuprect.width + offsetX; + cornerY += offsetY; + break; + case "leftcenter": + cornerX += offsetX; + cornerY += -popuprect.height / 2 + offsetY; + break; + case "rightcenter": + cornerX += -popuprect.width + offsetX; + cornerY += -popuprect.height / 2 + offsetY; + break; + case "bottomleft": + cornerX += offsetX; + cornerY += -popuprect.height + offsetY; + break; + case "bottomcenter": + cornerX += -popuprect.width / 2 + offsetX; + cornerY += -popuprect.height + offsetY; + break; + case "bottomright": + cornerX += -popuprect.width + offsetX; + cornerY += -popuprect.height + offsetY; + break; + } + + is( + Math.round(popuprect.left), + Math.round(cornerX), + testname + " x position" + ); + is( + Math.round(popuprect.top), + Math.round(cornerY), + testname + " y position" + ); + return; + } + + if (edge == "after_pointer") { + is( + Math.round(popuprect.left), + Math.round(anchorrect.left) + offsetX, + testname + " x position" + ); + is( + Math.round(popuprect.top), + Math.round(anchorrect.top) + offsetY + 21, + testname + " y position" + ); + return; + } + + if (edge == "overlap") { + is( + Math.round(anchorrect.left) + offsetY, + Math.round(popuprect.left), + testname + " position1" + ); + is( + Math.round(anchorrect.top) + offsetY, + Math.round(popuprect.top), + testname + " position2" + ); + return; + } + + if (edge.indexOf("before") == 0) { + isWithinHalfPixel( + anchorrect.top + offsetY, + popuprect.bottom, + testname + " position1" + ); + } else if (edge.indexOf("after") == 0) { + is( + Math.round(anchorrect.bottom) + offsetY, + Math.round(popuprect.top), + testname + " position1" + ); + } else if (edge.indexOf("start") == 0) { + isWithinHalfPixel( + anchorrect.left + offsetX, + popuprect.right, + testname + " position1" + ); + } else if (edge.indexOf("end") == 0) { + is( + Math.round(anchorrect.right) + offsetX, + Math.round(popuprect.left), + testname + " position1" + ); + } + + if (0 < edge.indexOf("before")) { + is( + Math.round(anchorrect.top) + offsetY, + Math.round(popuprect.top), + testname + " position2" + ); + } else if (0 < edge.indexOf("after")) { + isWithinHalfPixel( + anchorrect.bottom + offsetY, + popuprect.bottom, + testname + " position2" + ); + } else if (0 < edge.indexOf("start")) { + is( + Math.round(anchorrect.left) + offsetX, + Math.round(popuprect.left), + testname + " position2" + ); + } else if (0 < edge.indexOf("end")) { + isWithinHalfPixel( + anchorrect.right + offsetX, + popuprect.right, + testname + " position2" + ); + } +} diff --git a/toolkit/content/tests/widgets/seek_with_sound.ogg b/toolkit/content/tests/widgets/seek_with_sound.ogg Binary files differnew file mode 100644 index 0000000000..c86d9946bd --- /dev/null +++ b/toolkit/content/tests/widgets/seek_with_sound.ogg diff --git a/toolkit/content/tests/widgets/test-webvtt-1.vtt b/toolkit/content/tests/widgets/test-webvtt-1.vtt new file mode 100644 index 0000000000..20c9fc94cc --- /dev/null +++ b/toolkit/content/tests/widgets/test-webvtt-1.vtt @@ -0,0 +1,10 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:02.000 +This is a one line text track + +2 +00:00:05.000 --> 00:00:07.000 +- <b>This is a new text track but bolded</b> +- <i>This line should be italicized<i> diff --git a/toolkit/content/tests/widgets/test-webvtt-2.vtt b/toolkit/content/tests/widgets/test-webvtt-2.vtt new file mode 100644 index 0000000000..48dd63a9ee --- /dev/null +++ b/toolkit/content/tests/widgets/test-webvtt-2.vtt @@ -0,0 +1,10 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:02.000 +Voici un text track en une ligne + +2 +00:00:05.000 --> 00:00:07.000 +- <b>Voici un nouveau text track en gras</b> +- <i>Cette ligne devrait être en italiques<i> diff --git a/toolkit/content/tests/widgets/test_audiocontrols_dimensions.html b/toolkit/content/tests/widgets/test_audiocontrols_dimensions.html new file mode 100644 index 0000000000..2d29fe9be4 --- /dev/null +++ b/toolkit/content/tests/widgets/test_audiocontrols_dimensions.html @@ -0,0 +1,66 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Audio controls test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <audio id="audio" controls preload="auto"></audio> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + const audio = document.getElementById("audio"); + const controlBar = getElementWithinVideo(audio, "controlBar"); + + add_task(async function setup() { + await SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}); + await new Promise(resolve => { + audio.addEventListener("loadedmetadata", resolve, {once: true}); + audio.src = "audio.wav"; + }); + }); + + add_task(async function check_audio_height() { + is(audio.clientHeight, 40, "checking height of audio element"); + }); + + add_task(async function check_controlbar_width() { + const originalControlBarWidth = controlBar.clientWidth; + + isnot(originalControlBarWidth, 400, "the default audio width is not 400px"); + + audio.style.width = "400px"; + audio.offsetWidth; // force reflow + + isnot(controlBar.clientWidth, originalControlBarWidth, "new width should differ from the origianl width"); + is(controlBar.clientWidth, 400, "controlbar's width should grow with audio width"); + }); + + add_task(function check_audio_height_construction_sync() { + let el = new Audio(); + el.src = "audio.wav"; + el.controls = true; + document.body.appendChild(el); + is(el.clientHeight, 40, "Height of audio element with controls"); + document.body.removeChild(el); + }); + + add_task(function check_audio_height_add_control_sync() { + let el = new Audio(); + el.src = "audio.wav"; + document.body.appendChild(el); + is(el.clientHeight, 0, "Height of audio element without controls"); + el.controls = true; + is(el.clientHeight, 40, "Height of audio element with controls"); + document.body.removeChild(el); + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_audiocontrols_fullscreen.html b/toolkit/content/tests/widgets/test_audiocontrols_fullscreen.html new file mode 100644 index 0000000000..6963c3da1e --- /dev/null +++ b/toolkit/content/tests/widgets/test_audiocontrols_fullscreen.html @@ -0,0 +1,61 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Audio controls test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <audio id="audio" controls preload="auto"></audio> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + const audio = document.getElementById("audio"); + const controlBar = getElementWithinVideo(audio, "controlBar"); + + add_setup(async function setup() { + await new Promise(resolve => { + audio.addEventListener("loadedmetadata", resolve, {once: true}); + audio.src = "audio.wav"; + }); + }); + + add_task(async function test_double_click_does_not_fullscreen() { + SimpleTest.requestCompleteLog(); + SimpleTest.requestFlakyTimeout("Waiting to ensure that fullscreen event does not fire"); + const { x, y } = audio.getBoundingClientRect(); + const endedPromise = new Promise(resolve => { + audio.addEventListener("ended", () => { + info('Audio ended event fired!'); + resolve(); + }, { once: true }); + setTimeout( () => { + info('Audio ran out of time before ended event fired!'); + resolve(); + }, audio.duration * 1000); + }); + let noFullscreenEvent = true; + document.addEventListener("mozfullscreenchange", () => { + noFullscreenEvent = false; + }, { once: true }); + info("Simulate double click on media player."); + synthesizeMouse(audio, x, y, { clickCount: 2 }); + info("Waiting for video to end..."); + await endedPromise; + // By this point, if the double click was going to trigger fullscreen then + // it should have happened by now. + ok( + noFullscreenEvent, + "Double clicking should not trigger fullscreen event" + ); + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_bug1654500.html b/toolkit/content/tests/widgets/test_bug1654500.html new file mode 100644 index 0000000000..9bb05ba9c8 --- /dev/null +++ b/toolkit/content/tests/widgets/test_bug1654500.html @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Clear disabled/readonly datetime inputs</title> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<script src="/tests/SimpleTest/EventUtils.js"></script> +<p>Disabled inputs should be able to be cleared just as they can be set with real values</p> +<input type="date" id="date1" value="2020-08-11" disabled> +<input type="date" id="date2" value="2020-08-11" readonly> +<input type="time" id="time1" value="07:01" disabled> +<input type="time" id="time2" value="07:01" readonly> +<script> +/* global date1, date2, time1, time2 */ +function querySelectorAllShadow(parent, selector) { + const shadowRoot = SpecialPowers.wrap(parent).openOrClosedShadowRoot; + return shadowRoot.querySelectorAll(selector); +} +date1.value = date2.value = ""; +time1.value = time2.value = ""; +for (const date of [date1, date2]) { + const fields = [...querySelectorAllShadow(date, ".datetime-edit-field")]; + is(fields.length, 3, "Three numeric fields are expected"); + for (const field of fields) { + is(field.getAttribute("value"), "", "All fields should be cleared"); + } +} +for (const time of [time1, time2]) { + const fields = [...querySelectorAllShadow(time, ".datetime-edit-field")]; + ok(fields.length >= 2, "At least two numeric fields are expected"); + for (const field of fields) { + is(field.getAttribute("value"), "", "All fields should be cleared"); + } +} +</script> diff --git a/toolkit/content/tests/widgets/test_bug898940.html b/toolkit/content/tests/widgets/test_bug898940.html new file mode 100644 index 0000000000..f2349c9a4c --- /dev/null +++ b/toolkit/content/tests/widgets/test_bug898940.html @@ -0,0 +1,31 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test that an audio element that's already playing when controls are attached displays the controls</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <audio id="audio" controls src="audio.ogg"></audio> +</div> + +<pre id="test"> +<script class="testbody"> + var audio = document.getElementById("audio"); + audio.play(); + audio.ontimeupdate = function doTest() { + ok(audio.getBoundingClientRect().height > 0, + "checking audio element height is greater than zero"); + audio.ontimeupdate = null; + SimpleTest.finish(); + }; + + SimpleTest.waitForExplicitFinish(); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_contextmenu_menugroup.xhtml b/toolkit/content/tests/widgets/test_contextmenu_menugroup.xhtml new file mode 100644 index 0000000000..88511001b7 --- /dev/null +++ b/toolkit/content/tests/widgets/test_contextmenu_menugroup.xhtml @@ -0,0 +1,102 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Context menugroup Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="application/javascript" src="popup_shared.js"></script> + +<menupopup id="context"> + <menugroup> + <menuitem id="a"/> + <menuitem id="b"/> + </menugroup> + <menuitem id="c" label="c"/> + <menugroup/> +</menupopup> + +<button label="Check"/> + +<vbox id="popuparea" popup="context" style="width: 20px; height: 20px"/> + +<script type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var gMenuPopup = $("context"); +ok(gMenuPopup, "Got the reference to the context menu"); + +var popupTests = [ +{ + testname: "one-down-key", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "popupshowing context", "popupshown context", "DOMMenuItemActive a" ], + test() { + synthesizeMouse($("popuparea"), 4, 4, {}); + synthesizeKey("KEY_ArrowDown"); + }, + result(testname) { + checkActive(gMenuPopup, "a", testname); + } +}, +{ + testname: "two-down-keys", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "DOMMenuItemInactive a", "DOMMenuItemActive b" ], + test: () => synthesizeKey("KEY_ArrowDown"), + result(testname) { + checkActive(gMenuPopup, "b", testname); + } +}, +{ + testname: "three-down-keys", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "DOMMenuItemInactive b", "DOMMenuItemActive c" ], + test: () => synthesizeKey("KEY_ArrowDown"), + result(testname) { + checkActive(gMenuPopup, "c", testname); + } +}, +{ + testname: "three-down-keys-one-up-key", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "DOMMenuItemInactive c", "DOMMenuItemActive b" ], + test: () => synthesizeKey("KEY_ArrowUp"), + result (testname) { + checkActive(gMenuPopup, "b", testname); + } +}, +{ + testname: "three-down-keys-two-up-keys", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "DOMMenuItemInactive b", "DOMMenuItemActive a" ], + test: () => synthesizeKey("KEY_ArrowUp"), + result(testname) { + checkActive(gMenuPopup, "a", testname); + } +}, +{ + testname: "three-down-keys-three-up-key", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "DOMMenuItemInactive a", "DOMMenuItemActive c" ], + test: () => synthesizeKey("KEY_ArrowUp"), + result(testname) { + checkActive(gMenuPopup, "c", testname); + } +}, +]; + +SimpleTest.waitForFocus(function runTest() { + startPopupTests(popupTests); +}); + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"><p id="display"/></body> + +</window> diff --git a/toolkit/content/tests/widgets/test_contextmenu_nested.xhtml b/toolkit/content/tests/widgets/test_contextmenu_nested.xhtml new file mode 100644 index 0000000000..883e8e4d98 --- /dev/null +++ b/toolkit/content/tests/widgets/test_contextmenu_nested.xhtml @@ -0,0 +1,132 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Nested Context Menu Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="application/javascript" src="popup_shared.js"></script> + +<menupopup id="outercontext"> + <menuitem label="Context One"/> + <menu id="outercontextmenu" label="Sub"> + <menupopup id="innercontext"> + <menuitem id="innercontextmenu" label="Sub Context One"/> + </menupopup> + </menu> +</menupopup> + +<menupopup id="outermain"> + <menuitem label="One"/> + <menu id="outermenu" label="Sub"> + <menupopup id="innermain"> + <menuitem id="innermenu" label="Sub One" context="outercontext"/> + </menupopup> + </menu> +</menupopup> + +<button label="Check"/> + +<vbox id="popuparea" popup="outermain" style="width: 20px; height: 20px"/> + +<script type="application/javascript"> +<![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +var popupTests = [ +{ + testname: "open outer popup", + events: [ "popupshowing outermain", "popupshown outermain" ], + test: () => synthesizeMouse($("popuparea"), 4, 4, {}), + result (testname) { + is($("outermain").triggerNode, $("popuparea"), testname); + } +}, +{ + testname: "open inner popup", + events: [ "DOMMenuItemActive outermenu", "popupshowing innermain", "popupshown innermain" ], + test () { + synthesizeMouse($("outermenu"), 4, 4, { type: "mousemove" }); + synthesizeMouse($("outermenu"), 2, 2, { type: "mousemove" }); + }, + result (testname) { + is($("outermain").triggerNode, $("popuparea"), testname + " outer"); + is($("innermain").triggerNode, $("popuparea"), testname + " inner"); + is($("outercontext").triggerNode, null, testname + " outer context"); + } +}, +{ + testname: "open outer context", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "popupshowing outercontext", "popupshown outercontext" ], + test: () => synthesizeMouse($("innermenu"), 4, 4, { type: "contextmenu", button: 2 }), + result (testname) { + is($("outermain").triggerNode, $("popuparea"), testname + " outer"); + is($("innermain").triggerNode, $("popuparea"), testname + " inner"); + is($("outercontext").triggerNode, $("innermenu"), testname + " outer context"); + } +}, +{ + testname: "open inner context", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "DOMMenuItemActive outercontextmenu", "popupshowing innercontext", "popupshown innercontext" ], + test () { + synthesizeMouse($("outercontextmenu"), 4, 4, { type: "mousemove" }); + setTimeout(function() { + synthesizeMouse($("outercontextmenu"), 2, 2, { type: "mousemove" }); + }, 1000); + }, + result (testname) { + is($("outermain").triggerNode, $("popuparea"), testname + " outer"); + is($("innermain").triggerNode, $("popuparea"), testname + " inner"); + is($("outercontext").triggerNode, $("innermenu"), testname + " outer context"); + is($("innercontext").triggerNode, $("innermenu"), testname + " inner context"); + } +}, +{ + testname: "close context", + condition() { return (!navigator.platform.includes("Mac")); }, + events: [ "popuphiding innercontext", "popuphidden innercontext", + "popuphiding outercontext", "popuphidden outercontext", + "DOMMenuInactive innercontext", + "DOMMenuItemInactive outercontextmenu", + "DOMMenuInactive outercontext" ], + test: () => $("outercontext").hidePopup(), + result (testname) { + is($("outermain").triggerNode, $("popuparea"), testname + " outer"); + is($("innermain").triggerNode, $("popuparea"), testname + " inner"); + is($("outercontext").triggerNode, null, testname + " outer context"); + is($("innercontext").triggerNode, null, testname + " inner context"); + } +}, +{ + testname: "hide menus", + events: [ "popuphiding innermain", "popuphidden innermain", + "popuphiding outermain", "popuphidden outermain", + "DOMMenuInactive innermain", + "DOMMenuItemInactive outermenu", + "DOMMenuInactive outermain" ], + + test: () => $("outermain").hidePopup(), + result (testname) { + is($("outermain").triggerNode, null, testname + " outer"); + is($("innermain").triggerNode, null, testname + " inner"); + is($("outercontext").triggerNode, null, testname + " outer context"); + is($("innercontext").triggerNode, null, testname + " inner context"); + } +} +]; + +SimpleTest.waitForFocus(function runTest() { + return startPopupTests(popupTests); +}); + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"><p id="display"/></body> + +</window> diff --git a/toolkit/content/tests/widgets/test_editor_currentURI.xhtml b/toolkit/content/tests/widgets/test_editor_currentURI.xhtml new file mode 100644 index 0000000000..bbb1f33623 --- /dev/null +++ b/toolkit/content/tests/widgets/test_editor_currentURI.xhtml @@ -0,0 +1,37 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" + type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Editor currentURI Tests" onload="runTest();"> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <p/> + <editor xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + id="editor" + type="content" + editortype="html" + style="width: 400px; height: 100px;"/> + <p/> + <pre id="test"> + </pre> + </body> + <script class="testbody" type="application/javascript"> + <![CDATA[ + + SimpleTest.waitForExplicitFinish(); + + function runTest() { + var editor = document.getElementById("editor"); + // Check that currentURI is a property of editor. + var result = "currentURI" in editor; + is(result, true, "currentURI is a property of editor"); + is(editor.currentURI.spec, "about:blank", "currentURI.spec is about:blank"); + SimpleTest.finish(); + } +]]> +</script> +</window> diff --git a/toolkit/content/tests/widgets/test_image_recognition.html b/toolkit/content/tests/widgets/test_image_recognition.html new file mode 100644 index 0000000000..d4dfc6216e --- /dev/null +++ b/toolkit/content/tests/widgets/test_image_recognition.html @@ -0,0 +1,67 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Image recognition test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <img src="image.png" /> +</div> + +<pre id="test"> +<script class="testbody"> + const { TestUtils } = SpecialPowers.ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" + ); + + function pushPref(preferenceName, value) { + return new Promise(resolve => { + const options = {"set": [[preferenceName, value]]}; + SpecialPowers.pushPrefEnv(options, resolve); + }); + } + + add_task(async () => { + // Performing text recognition in CI can take some time, and test verify runs have + // timed out. + SimpleTest.requestLongerTimeout(2); + + await pushPref("dom.text-recognition.shadow-dom-enabled", true); + const img = document.querySelector("#content img"); + + info("Recognizing the image text"); + const result = await SpecialPowers.wrap(img).recognizeCurrentImageText(); + is(result.length, 2, "Two words were found."); + const mozilla = result.find(r => r.string === "Mozilla"); + const firefox = result.find(r => r.string === "Firefox"); + + ok(mozilla, "The word Mozilla was found."); + ok(firefox, "The word Firefox was found."); + + ok(mozilla.quad.p1.x < firefox.quad.p2.x, "The Mozilla text is left of Firefox"); + ok(mozilla.quad.p1.y > firefox.quad.p2.y, "The Mozilla text is above Firefox"); + + const spans = await TestUtils.waitForCondition( + () => shadowRootQuerySelectorAll(img, "span"), + "Attempting to get image recognition spans." + ); + + const mozillaSpan = [...spans].find(s => s.innerText === "Mozilla"); + const firefoxSpan = [...spans].find(s => s.innerText === "Firefox"); + + ok(mozillaSpan, "The word Mozilla span was found."); + ok(firefoxSpan, "The word Firefox span was found."); + + ok(mozillaSpan.style.transform.startsWith("matrix3d("), "A matrix transform was applied"); + ok(firefoxSpan.style.transform.startsWith("matrix3d("), "A matrix transform was applied"); + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_image_recognition_unsupported.html b/toolkit/content/tests/widgets/test_image_recognition_unsupported.html new file mode 100644 index 0000000000..f67ea8eb00 --- /dev/null +++ b/toolkit/content/tests/widgets/test_image_recognition_unsupported.html @@ -0,0 +1,36 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Image recognition unsupported</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <img src="image.png" /> +</div> + +<pre id="test"> +<script class="testbody"> + /** + * This test is for platforms that do not support text recognition. + */ + add_task(async () => { + const img = document.querySelector("#content img"); + + info("Recognizing the current image text is not supported on this platform."); + try { + await SpecialPowers.wrap(img).recognizeCurrentImageText(); + ok(false, "Recognizing the text should not be supported."); + } catch (error) { + ok(error, "Expected unsupported message: " + error.message); + } + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_image_recognition_zh.html b/toolkit/content/tests/widgets/test_image_recognition_zh.html new file mode 100644 index 0000000000..ba8f3c94e1 --- /dev/null +++ b/toolkit/content/tests/widgets/test_image_recognition_zh.html @@ -0,0 +1,53 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Image recognition test for Chinese</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <img src="image-zh.png" /> +</div> + +<pre id="test"> +<script class="testbody"> + // Performing text recognition in CI can take some time, and test verify runs have + // timed out. + SimpleTest.requestLongerTimeout(2); + + /** + * This test exercises the code path where the image recognition APIs detect the + * document language and use it to choose the language. + */ + add_task(async () => { + const img = document.querySelector("#content img"); + + info("Recognizing the image text, but not as Chinese"); + { + const result = await SpecialPowers.wrap(img).recognizeCurrentImageText(); + for (const { string } of result) { + isnot(string, "火狐", 'The results are (as expected) incorrect, as Chinese was not set as the language.'); + } + } + + info("Setting the document to Chinese."); + document.documentElement.setAttribute("lang", "zh-Hans-CN"); + + info("Recognizing the image text"); + { + const result = await SpecialPowers.wrap(img).recognizeCurrentImageText(); + is(result.length, 1, "One word was found."); + is(result[0].string, "火狐", "The Chinese characters for Firefox are found."); + } + + document.documentElement.setAttribute("lang", "en-US"); + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_label_checkbox.xhtml b/toolkit/content/tests/widgets/test_label_checkbox.xhtml new file mode 100644 index 0000000000..da9d588409 --- /dev/null +++ b/toolkit/content/tests/widgets/test_label_checkbox.xhtml @@ -0,0 +1,40 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Label Checkbox Tests" + onload="onLoad()" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <title>Label Checkbox Tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<script> +SimpleTest.waitForExplicitFinish(); +function onLoad() +{ + runTest(); +} + +function runTest() +{ + window.open("window_label_checkbox.xhtml", "_blank", "width=600,height=600"); +} + +onmessage = function onMessage() +{ + SimpleTest.finish(); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/widgets/test_menubar.xhtml b/toolkit/content/tests/widgets/test_menubar.xhtml new file mode 100644 index 0000000000..16fae52b03 --- /dev/null +++ b/toolkit/content/tests/widgets/test_menubar.xhtml @@ -0,0 +1,30 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Menubar Popup Tests" + onload="setTimeout(onLoad, 0);" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <title>Menubar Popup Tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<script> +SimpleTest.waitForExplicitFinish(); +function onLoad() +{ + window.open("window_menubar.xhtml", "_blank", "width=600,height=600"); +} +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<p id="display"> +</p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +</body> + +</window> diff --git a/toolkit/content/tests/widgets/test_mousecapture_area.html b/toolkit/content/tests/widgets/test_mousecapture_area.html new file mode 100644 index 0000000000..7217d971eb --- /dev/null +++ b/toolkit/content/tests/widgets/test_mousecapture_area.html @@ -0,0 +1,106 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Mouse capture on area elements tests</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <!-- The border="0" on the images is needed so that when we use + synthesizeMouse we don't accidentally target the border of the image and + miss the area because synthesizeMouse gets the rect of the primary frame + of the target (the area), which is the image due to bug 135040, which + includes the border, but the events targetted at the border aren't + targeted at the area. --> + + <!-- 20x20 of red --> + <img id="image" border="0" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAAG0lEQVR42mP8z0A%2BYKJA76jmUc2jmkc1U0EzACKcASfOgGoMAAAAAElFTkSuQmCC" + usemap="#Map"/> + + <map name="Map"> + <!-- area over the whole image --> + <area id="area" onmousedown="this.setCapture();" onmouseup="this.releaseCapture();" + shape="poly" coords="0,0, 0,20, 20,20, 20,0" href="javascript:void(0);"/> + </map> + + + <!-- 20x20 of red --> + <img id="img1" border="0" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAAG0lEQVR42mP8z0A%2BYKJA76jmUc2jmkc1U0EzACKcASfOgGoMAAAAAElFTkSuQmCC" + usemap="#sharedMap"/> + + <!-- 20x20 of red --> + <img id="img2" border="0" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAAG0lEQVR42mP8z0A%2BYKJA76jmUc2jmkc1U0EzACKcASfOgGoMAAAAAElFTkSuQmCC" + usemap="#sharedMap"/> + + <map name="sharedMap"> + <!-- area over the whole image --> + <area id="sharedarea" onmousedown="this.setCapture();" onmouseup="this.releaseCapture();" + shape="poly" coords="0,0, 0,20, 20,20, 20,0" href="javascript:void(0);"/> + </map> + + + <div id="otherelement" style="width: 100px; height: 100px;"></div> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +SimpleTest.waitForExplicitFinish(); + +function runTests() { + // XXX We send a useless click to each image to force it to setup its image + // map, because flushing layout won't do it. Hopefully bug 135040 will make + // this not suck. + synthesizeMouse($("image"), 5, 5, { type: "mousedown" }); + synthesizeMouse($("image"), 5, 5, { type: "mouseup" }); + synthesizeMouse($("img1"), 5, 5, { type: "mousedown" }); + synthesizeMouse($("img1"), 5, 5, { type: "mouseup" }); + synthesizeMouse($("img2"), 5, 5, { type: "mousedown" }); + synthesizeMouse($("img2"), 5, 5, { type: "mouseup" }); + + + // test that setCapture works on an area element (bug 517737) + var area = document.getElementById("area"); + synthesizeMouse(area, 5, 5, { type: "mousedown" }); + synthesizeMouseExpectEvent($("otherelement"), 5, 5, { type: "mousemove" }, + area, "mousemove", "setCapture works on areas"); + synthesizeMouse(area, 5, 5, { type: "mouseup" }); + + // test that setCapture works on an area element when it is part of an image + // map that is used by two images + + var img1 = document.getElementById("img1"); + var sharedarea = document.getElementById("sharedarea"); + // synthesizeMouse just sends the event by coordinates, so this is really a click on the area + synthesizeMouse(img1, 5, 5, { type: "mousedown" }); + synthesizeMouseExpectEvent($("otherelement"), 5, 5, { type: "mousemove" }, + sharedarea, "mousemove", "setCapture works on areas with multiple images"); + synthesizeMouse(img1, 5, 5, { type: "mouseup" }); + + var img2 = document.getElementById("img2"); + // synthesizeMouse just sends the event by coordinates, so this is really a click on the area + synthesizeMouse(img2, 5, 5, { type: "mousedown" }); + synthesizeMouseExpectEvent($("otherelement"), 5, 5, { type: "mousemove" }, + sharedarea, "mousemove", "setCapture works on areas with multiple images"); + synthesizeMouse(img2, 5, 5, { type: "mouseup" }); + + // Bug 862673 - nuke all content so assertions in this test are attributed to + // this test rather than the one which happens to follow. + var content = document.getElementById("content"); + content.remove(); + SimpleTest.finish(); +} + +SimpleTest.waitForFocus(runTests); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_moz_button_group.html b/toolkit/content/tests/widgets/test_moz_button_group.html new file mode 100644 index 0000000000..92fd7776fa --- /dev/null +++ b/toolkit/content/tests/widgets/test_moz_button_group.html @@ -0,0 +1,235 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf-8"> + <title>moz-button-group tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <!-- TODO: Bug 1798404 - in-content/common.css can be removed once we have a better + solution for token variables for the new widgets --> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <script type="module" src="chrome://global/content/elements/moz-button-group.mjs"></script> + </head> + <body> + <p id="display"></p> + <div id="content"> + <button id="before-button">Before</button> + <div id="render"></div> + <button id="after-button">After</button> + </div> + <!-- This is here to ensure the stylesheet is loaded. It gets removed in setup. --> + <moz-button-group></moz-button-group> + <pre id="test"> + </pre> + + <script> + let html; + let render; + + let renderArea = document.getElementById("render"); + let beforeButton = document.getElementById("before-button"); + let afterButton = document.getElementById("after-button"); + + async function checkButtons(...buttons) { + const checkOrder = (a, b) => { + let firstBounds = a.getBoundingClientRect(); + let secondBounds = b.getBoundingClientRect(); + + ok(firstBounds.right < secondBounds.left, `First button comes first`); + let locationDiff = Math.abs(secondBounds.left - firstBounds.right - 8); + ok(locationDiff < 1, `Second button is 8px after first (${locationDiff}, ${firstBounds.right}, ${secondBounds.left})`); + }; + + ok(buttons.length >= 2, "There are at least 2 buttons to check"); + + // Verify tab order is correct. + beforeButton.focus(); + is(document.activeElement, beforeButton, "Before button is focused"); + + synthesizeKey("VK_TAB"); + is(document.activeElement, buttons[0], "Next button is focused"); + + for (let i = 1; i < buttons.length; i++) { + // Confirm button order in DOM + checkOrder(buttons[i - 1], buttons[i]); + + synthesizeKey("VK_TAB"); + is(document.activeElement, buttons[i], "Next button is focused"); + } + + synthesizeKey("VK_TAB"); + is(document.activeElement, afterButton, "After button is at the end in tab order"); + + // Verify light DOM order is correct, in case of manual tab management in JS. + let { parentElement } = buttons[0]; + let parentChildren = parentElement.children; + is(parentChildren.length, buttons.length, "Expected number of children"); + for (let i = 0; i < parentChildren.length; i++) { + is(parentChildren[i], buttons[i], `Button ${i} is in correct light DOM spot`); + } + } + + + add_setup(async function setup() { + ({ html, render} = await import("chrome://global/content/vendor/lit.all.mjs")); + document.querySelector("moz-button-group").remove(); + }); + + add_task(async function testButtonOrderingSlot() { + render( + html` + <moz-button-group> + <button slot="primary" id="primary-button">Primary</button> + <button id="secondary-button">Secondary</button> + </moz-button-group> + `, + renderArea + ); + + let buttonGroup = document.querySelector("moz-button-group"); + let primaryButton = document.getElementById("primary-button"); + let secondaryButton = document.getElementById("secondary-button"); + + buttonGroup.platform = "win"; + await buttonGroup.updateComplete; + await checkButtons(primaryButton, secondaryButton); + + buttonGroup.platform = "macosx"; + await buttonGroup.updateComplete; + await checkButtons(secondaryButton, primaryButton); + }); + + add_task(async function testPrimaryButtonAutoSlotting() { + render( + html` + <moz-button-group> + <button class="primary">Primary</button> + <button class="secondary">Secondary</button> + </moz-button-group> + `, + renderArea + ); + + let buttonGroup = document.querySelector("moz-button-group"); + let primaryButton = buttonGroup.querySelector(".primary"); + let secondaryButton = buttonGroup.querySelector(".secondary"); + buttonGroup.platform = "win"; + await buttonGroup.updateComplete; + is(primaryButton.slot, "primary", "primary button was auto-slotted") + await checkButtons(primaryButton, secondaryButton); + + buttonGroup.platform = "macosx"; + await buttonGroup.updateComplete; + await checkButtons(secondaryButton, primaryButton); + }); + + add_task(async function testSubmitButtonAutoSlotting() { + render( + html` + <moz-button-group> + <button type="submit">Submit</button> + <button class="secondary">Secondary</button> + </moz-button-group> + `, + renderArea + ); + + let buttonGroup = document.querySelector("moz-button-group"); + let submitButton = buttonGroup.querySelector("[type=submit]"); + let secondaryButton = buttonGroup.querySelector(".secondary"); + buttonGroup.platform = "win"; + await buttonGroup.updateComplete; + is(submitButton.slot, "primary", "submit button was auto-slotted") + await checkButtons(submitButton, secondaryButton); + + buttonGroup.platform = "macosx"; + await buttonGroup.updateComplete; + await checkButtons(secondaryButton, submitButton); + }); + + add_task(async function testAutofocusButtonAutoSlotting() { + render( + html` + <moz-button-group> + <button autofocus>First</button> + <button class="secondary">Secondary</button> + </moz-button-group> + `, + renderArea + ); + + let buttonGroup = document.querySelector("moz-button-group"); + let autofocusButton = buttonGroup.querySelector("[autofocus]"); + let secondaryButton = buttonGroup.querySelector(".secondary"); + buttonGroup.platform = "win"; + await buttonGroup.updateComplete; + is(autofocusButton.slot, "primary", "autofocus button was auto-slotted") + await checkButtons(autofocusButton, secondaryButton); + + buttonGroup.platform = "macosx"; + await buttonGroup.updateComplete; + await checkButtons(secondaryButton, autofocusButton); + }); + + add_task(async function testDefaultButtonAutoSlotting() { + render( + html` + <moz-button-group> + <button default>First</button> + <button class="secondary">Secondary</button> + </moz-button-group> + `, + renderArea + ); + + let buttonGroup = document.querySelector("moz-button-group"); + let defaultButton = buttonGroup.querySelector("[default]"); + let secondaryButton = buttonGroup.querySelector(".secondary"); + buttonGroup.platform = "win"; + await buttonGroup.updateComplete; + is(defaultButton.slot, "primary", "default button was auto-slotted") + await checkButtons(defaultButton, secondaryButton); + + buttonGroup.platform = "macosx"; + await buttonGroup.updateComplete; + await checkButtons(secondaryButton, defaultButton); + }); + + add_task(async function testInitialButtonLightDomReordering() { + const renderPlatform = platform => render( + html` + <moz-button-group .platform=${platform}> + <button class="primary">First</button> + <button class="secondary">Secondary</button> + <button default>Default</button> + </moz-button-group> + `, + renderArea + ); + + renderPlatform("win"); + let buttonGroup = document.querySelector("moz-button-group"); + await buttonGroup.updateComplete; + let primaryButton = buttonGroup.querySelector(".primary"); + let defaultButton = buttonGroup.querySelector("[default]"); + let secondaryButton = buttonGroup.querySelector(".secondary"); + + is(primaryButton.slot, "primary", "primary button was auto-slotted"); + is(defaultButton.slot, "primary", "default button was auto-slotted"); + await checkButtons(primaryButton, defaultButton, secondaryButton); + + renderPlatform("macosx"); + buttonGroup = document.querySelector("moz-button-group"); + await buttonGroup.updateComplete; + primaryButton = buttonGroup.querySelector(".primary"); + defaultButton = buttonGroup.querySelector("[default]"); + secondaryButton = buttonGroup.querySelector(".secondary"); + + is(primaryButton.slot, "primary", "primary button was auto-slotted"); + is(defaultButton.slot, "primary", "default button was auto-slotted"); + await checkButtons(secondaryButton, primaryButton, defaultButton); + }); + </script> + </body> +</html> diff --git a/toolkit/content/tests/widgets/test_moz_card.html b/toolkit/content/tests/widgets/test_moz_card.html new file mode 100644 index 0000000000..ef4e67d0fa --- /dev/null +++ b/toolkit/content/tests/widgets/test_moz_card.html @@ -0,0 +1,158 @@ +<!DOCTYPE HTML> +<html> + +<head> + <meta charset="utf-8"> + <title>moz-card tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> + <script type="module" src="chrome://global/content/elements/moz-card.mjs"></script> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> +</head> + +<body> + <p id="display"></p> + <div id="content"> + <moz-card id="default-card" data-l10n-id="test-id-1" data-l10n-attrs="heading"> + <div>TEST</div> + </moz-card> + <hr /> + + <moz-card id="accordion-card" data-l10n-id="test-id-2" data-l10n-attrs="heading" heading="accordion heading" + type="accordion"> + <div>accordion test content</div> + </moz-card> + <hr /> + + </div> + <pre id="test"></pre> + <script> + let generatedSlotText = "generated slotted element"; + let testHeading = "test heading"; + + function assertBasicProperties(card, expectedValues) { + info(`Testing card with ID: ${card.id}`); + ok(card, "The card element should exist"); + is(card.localName, "moz-card", "The card should have the correct tag"); + let l10nId = card.getAttribute("data-l10n-id"); + let l10nAttrs = card.getAttribute("data-l10n-attrs"); + if (expectedValues["data-l10n-id"]) { + is(l10nId, expectedValues["data-l10n-id"], "l10n id should be unchanged"); + } + if (expectedValues["data-l10n-attrs"]) { + is(l10nAttrs, expectedValues["data-l10n-attrs"], "l10n attrs should be unchanged"); + } + let cardContent = card.firstElementChild; + ok(cardContent, "The content should exist"); + is(cardContent.textContent, expectedValues.contentText, "The content should be unchanged"); + is(card.contentSlotEl.id, "content", "The content container should have the correct ID"); + if (card.type != "accordion") { + is(card.contentSlotEl.getAttribute("aria-describedby"), "content", "The content container should be described by the 'content' slot"); + } + + if (expectedValues.headingText) { + ok(card.headingEl, "Heading should exist"); + is(card.headingEl.textContent, expectedValues.headingText, "Heading should match the 'heading' attribute value"); + } + + } + + function assertAccordionCardProperties(card, expectedValues) { + ok(card.detailsEl, "The details element should exist"); + ok(card.detailsEl.querySelector("summary"), "There should be a summary element within the details element"); + ok(card.detailsEl.querySelector("summary").querySelector(".chevron-icon"), "There should be a chevron icon div within the summary element"); + } + + async function generateCard(values) { + let card = document.createElement("moz-card"); + for (let [key, value] of Object.entries(values)) { + card.setAttribute(key, value); + } + let div = document.createElement("div"); + div.innerText = generatedSlotText; + card.appendChild(div); + document.body.appendChild(card); + await card.updateComplete; + document.body.appendChild(document.createElement("hr")); + return card; + } + + add_task(async function testDefaultCard() { + assertBasicProperties(document.getElementById("default-card"), + { + "data-l10n-id": "test-id-1", + "data-l10n-attrs": "heading", + contentText: "TEST" + } + ); + + let defaultCard = await generateCard( + { + "data-l10n-id": "generated-id-1", + "data-l10n-attrs": "heading", + heading: testHeading, + id: "generated-default-card" + } + ); + + assertBasicProperties(defaultCard, + { + "data-l10n-id": "generated-id-1", + "data-l10n-attrs": "heading", + contentText: generatedSlotText, + heading: testHeading + } + ); + }); + + add_task(async function testAccordionCard() { + assertBasicProperties(document.getElementById("accordion-card"), + { + "data-l10n-id": "test-id-2", + "data-l10n-attrs": "heading", + contentText: "accordion test content", + headingText: "accordion heading", + } + ); + assertAccordionCardProperties(document.getElementById("accordion-card"), + { + "data-l10n-id": "test-id-2", + "data-l10n-attrs": "heading", + contentText: "accordion test content", + headingText: "accordion heading", + } + ); + + let accordionCard = await generateCard( + { + type: "accordion", + id: "generated-accordion-card", + "data-l10n-id": "generated-id-2", + "data-l10n-attrs": "heading", + heading: testHeading + } + ); + + assertBasicProperties(accordionCard, + { + "data-l10n-id": "generated-id-2", + "data-l10n-attrs": "heading", + headingText: testHeading, + contentText: generatedSlotText, + } + ); + assertAccordionCardProperties(accordionCard, + { + "data-l10n-id": "generated-id-2", + "data-l10n-attrs": "heading", + headingText: testHeading, + contentText: generatedSlotText, + } + ); + }); + + </script> +</body> + +</html> diff --git a/toolkit/content/tests/widgets/test_moz_five_star.html b/toolkit/content/tests/widgets/test_moz_five_star.html new file mode 100644 index 0000000000..593028f2c9 --- /dev/null +++ b/toolkit/content/tests/widgets/test_moz_five_star.html @@ -0,0 +1,82 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>MozFiveStar Tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script type="module" src="chrome://global/content/elements/moz-five-star.mjs"></script> +</head> +<body> +<p id="display"></p> +<div id="content" style="max-width: fit-content"> + <moz-five-star label="Label" rating="2.5"></moz-five-star> +</div> +<pre id="test"> + <script class="testbody" type="application/javascript"> + const { BrowserTestUtils } = ChromeUtils.importESModule("resource://testing-common/BrowserTestUtils.sys.mjs"); + add_task(async function testMozFiveStar() { + const mozFiveStar = document.querySelector("moz-five-star"); + ok(mozFiveStar, "moz-five-star is rendered"); + + const stars = mozFiveStar.starEls; + ok(stars, "moz-five-star has stars"); + is(stars.length, 5, "moz-five-star stars count is 5"); + + const rating = mozFiveStar.rating; + ok(rating, "moz-five-star has a rating"); + is(rating, 2.5, "moz-five-star rating is 2.5"); + }); + + add_task(async function testMozFiveStarsDisplay() { + const mozFiveStar = document.querySelector("moz-five-star"); + ok(mozFiveStar, "moz-five-star is rendered"); + + async function testRating(rating, ratingRounded, expectation) { + mozFiveStar.rating = rating; + await mozFiveStar.updateComplete; + if (mozFiveStar.ownerDocument.hasPendingL10nMutations) { + await BrowserTestUtils.waitForEvent( + mozFiveStar.ownerDocument, + "L10nMutationsFinished" + ); + } + let starsString = Array.from(mozFiveStar.starEls) + .map(star => star.getAttribute("fill")) + .join(","); + is(starsString, expectation, `Rendering of rating ${rating}`); + + is( + mozFiveStar.starsWrapperEl.title, + `Rated ${ratingRounded} out of 5`, + "Rendered title must contain at most one fractional digit" + ); + + let isImage = mozFiveStar.starsWrapperEl.getAttribute("role") == "img" + || mozFiveStar.starsWrapperEl.getAttribute("role") == "image"; + + ok( + isImage, + "Rating element is an image for the title to be announced" + ); + } + + await testRating(0.0, "0", "empty,empty,empty,empty,empty"); + await testRating(0.249, "0.2", "empty,empty,empty,empty,empty"); + await testRating(0.25, "0.3", "half,empty,empty,empty,empty"); + await testRating(0.749, "0.7", "half,empty,empty,empty,empty"); + await testRating(0.99, "1", "full,empty,empty,empty,empty"); + await testRating(1.0, "1", "full,empty,empty,empty,empty"); + await testRating(2, "2", "full,full,empty,empty,empty"); + await testRating(3.0, "3", "full,full,full,empty,empty"); + await testRating(4.001, "4", "full,full,full,full,empty"); + await testRating(4.249, "4.2", "full,full,full,full,empty"); + await testRating(4.25, "4.3", "full,full,full,full,half"); + await testRating(4.749, "4.7", "full,full,full,full,half"); + await testRating(4.89, "4.9", "full,full,full,full,full"); + await testRating(5.0, "5", "full,full,full,full,full"); + }); + </script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_moz_label.html b/toolkit/content/tests/widgets/test_moz_label.html new file mode 100644 index 0000000000..80a9600930 --- /dev/null +++ b/toolkit/content/tests/widgets/test_moz_label.html @@ -0,0 +1,142 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>MozLabel tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <script type="module" src="chrome://global/content/elements/moz-label.mjs"></script> +</head> +<body> +<p id="display"></p> +<div id="content"> + <label is="moz-label" for="checkbox" accesskey="c">For the checkbox:</label> + <input type="checkbox" id="checkbox" /> + + <label is="moz-label" accesskey="n"> + For the nested checkbox: + <input type="checkbox" /> + </label> + + <label is="moz-label" for="radio" accesskey="r">For the radio:</label> + <input type="radio" id="radio" /> + + <label is="moz-label" accesskey="F"> + For the nested radio: + <input type="radio" /> + </label> + + <label is="moz-label" for="button" accesskey="b">For the button:</label> + <button id="button">Click me</button> + + <label is="moz-label" accesskey="u"> + For the nested button: + <button>Click me too</button> + </label> +</div> +<pre id="test"> +<script class="testbody" type="application/javascript"> + let labels = document.querySelectorAll("label[is='moz-label']"); + let isMac = navigator.platform.includes("Mac"); + + function performAccessKey(key) { + synthesizeKey( + key, + navigator.platform.includes("Mac") + ? { altKey: true, ctrlKey: true } + : { altKey: true, shiftKey: true } + ); + } + + // Accesskey underlining is disabled by default on Mac. + // Reload the window and wait for load to ensure pref is applied. + add_setup(async function setup() { + if (isMac && !SpecialPowers.getIntPref("ui.key.menuAccessKey")) { + await SpecialPowers.pushPrefEnv( + { set: [["ui.key.menuAccessKey", 1]] }, + async () => { + window.location.reload(); + await new Promise(resolve => { + addEventListener("load", resolve, { once: true }); + }); + } + ); + } + }); + + add_task(async function testAccesskeyUnderlined() { + labels.forEach(label => { + let accessKey = label.getAttribute("accesskey"); + let wrapper = label.querySelector(".accesskey"); + is(wrapper.textContent, accessKey, "The accesskey character is wrapped.") + + let textDecoration = getComputedStyle(wrapper)["text-decoration"] + ok(textDecoration.includes("underline"), "The accesskey character is underlined.") + }) + }); + + add_task(async function testAccesskeyFocus() { + labels.forEach(label => { + let accessKey = label.getAttribute("accesskey"); + // Find the labelled element via the "for" attr if there's an ID + // association, or select the lastElementChild for nested elements + let element = document.getElementById(label.getAttribute("for")) || label.lastElementChild; + + isnot(document.activeElement, element, "Focus is not on the associated element."); + + performAccessKey(accessKey); + + is(document.activeElement, element, "Focus moved to the associated element.") + }) + }); + + add_task(async function testAccesskeyChange() { + let label = labels[0]; + let nextAccesskey = "x"; + let originalAccesskey = label.getAttribute("accesskey"); + let getWrapper = () => label.querySelector(".accesskey"); + is(getWrapper().textContent, originalAccesskey, "Original accesskey character is wrapped.") + + label.setAttribute("accesskey", nextAccesskey); + is(getWrapper().textContent, nextAccesskey, "New accesskey character is wrapped.") + + let elementId = label.getAttribute("for"); + let focusedEl = document.getElementById(elementId); + + performAccessKey(originalAccesskey); + isnot(document.activeElement.id, focusedEl.id, "Focus has not moved to the associated element.") + + performAccessKey(nextAccesskey); + is(document.activeElement.id, focusedEl.id, "Focus moved to the associated element.") + }); + + add_task(async function testAccesskeyAppended() { + let label = labels[0]; + let originalText = label.textContent; + let accesskey = "z"; // Letter not included in the label text. + label.setAttribute("accesskey", accesskey); + + let expectedText = `${originalText} (Z):`; + is(label.textContent, expectedText, "Access key is appended when not included in label text.") + }); + + add_task(async function testLabelClick() { + let label = labels[0]; + let input = document.getElementById(label.getAttribute("for")); + is(input.checked, false, "The associated input is not checked.") + + // Input state changes on label click. + synthesizeMouseAtCenter(label, {}); + ok(input.checked, "The associated input is checked.") + + // Input state doesn't change on label click when input is disabled. + input.disabled = true; + synthesizeMouseAtCenter(label, {}); + ok(input.checked, "The associated input is still checked.") + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_moz_message_bar.html b/toolkit/content/tests/widgets/test_moz_message_bar.html new file mode 100644 index 0000000000..7ee6825ef3 --- /dev/null +++ b/toolkit/content/tests/widgets/test_moz_message_bar.html @@ -0,0 +1,92 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>MozMessageBar tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script type="module" src="chrome://global/content/elements/moz-message-bar.mjs"></script> +</head> +<body> +<p id="display"></p> +<div id="content"> + <moz-message-bar id="infoMessage" heading="Heading" message="Test message"></moz-message-bar> + <moz-message-bar id="infoMessage2" dismissable message="Test message"></moz-message-bar> + <moz-message-bar id="warningMessage" type="warning" message="Test message"></moz-message-bar> + <moz-message-bar id="successMessage" type="success" message="Test message"></moz-message-bar> + <moz-message-bar id="errorMessage" type="error" message="Test message"></moz-message-bar> +</div> +<pre id="test"> + <script class="testbody" type="application/javascript"> + add_task(async function test_component_declaration() { + const mozMessageBar = document.querySelector("#infoMessage"); + ok(mozMessageBar, "moz-message-bar component is rendered."); + + const icon = mozMessageBar.shadowRoot.querySelector(".icon"); + const iconUrl = icon.src; + ok(iconUrl.includes("info-filled.svg"), "Info icon is showing up."); + const iconAlt = icon.alt; + is(iconAlt, "Info", "Alternate text for the info icon is present."); + + const heading = mozMessageBar.shadowRoot.querySelector(".heading"); + is(heading.textContent.trim(), "Heading", "Heading is showing up."); + const message = mozMessageBar.shadowRoot.querySelector(".message"); + is(message.textContent.trim(), "Test message", "Message is showing up."); + }); + + add_task(async function test_heading_display() { + const mozMessageBar = document.querySelector("#infoMessage2"); + let heading = mozMessageBar.shadowRoot.querySelector(".heading"); + ok(!heading, "The heading element isn't displayed if it hasn't been initialized."); + + mozMessageBar.heading = "Now there's a heading"; + await mozMessageBar.updateComplete; + heading = mozMessageBar.renderRoot.querySelector(".heading"); + is(heading.textContent.trim(), "Now there's a heading", "New heading element is displayed."); + }); + + add_task(async function test_close_button() { + const notDismissableComponent = document.querySelector("#infoMessage"); + let closeButton = notDismissableComponent.closeButtonEl; + ok(!closeButton, "Close button doesn't show when the message bar isn't dismissable."); + + let dismissableComponent = document.querySelector("#infoMessage2"); + closeButton = dismissableComponent.closeButtonEl; + ok(closeButton, "Close button is shown when the message bar is dismissable."); + + closeButton.click(); + dismissableComponent = document.querySelector("#infoMessage2"); + is(dismissableComponent, null, "Clicking on the close button removes the message bar element."); + }); + + add_task(async function test_warning_message_component() { + const mozMessageBar = document.querySelector("#warningMessage"); + const icon = mozMessageBar.shadowRoot.querySelector(".icon"); + const iconUrl = icon.src; + ok(iconUrl.includes("warning.svg"), "Warning icon is showing up."); + const iconAlt = icon.alt; + is(iconAlt, "Warning", "Alternate text for the warning icon is present."); + }); + + add_task(async function test_success_message_component() { + const mozMessageBar = document.querySelector("#successMessage"); + const icon = mozMessageBar.shadowRoot.querySelector(".icon"); + const iconUrl = icon.src; + ok(iconUrl.includes("check-filled.svg"), "Success icon is showing up."); + const iconAlt = icon.alt; + is(iconAlt, "Success", "Alternate text for the success icon is present."); + }); + + add_task(async function test_error_message_component() { + const mozMessageBar = document.querySelector("#errorMessage"); + const icon = mozMessageBar.shadowRoot.querySelector(".icon"); + const iconUrl = icon.src; + ok(iconUrl.includes("error.svg"), "Error icon is showing up."); + const iconAlt = icon.alt; + is(iconAlt, "Error", "Alternate text for the error icon is present."); + }); + </script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_moz_support_link.html b/toolkit/content/tests/widgets/test_moz_support_link.html new file mode 100644 index 0000000000..3f523c04a6 --- /dev/null +++ b/toolkit/content/tests/widgets/test_moz_support_link.html @@ -0,0 +1,151 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>MozSupportLink tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <!-- TODO: Bug 1798404 - in-content/common.css can be removed once we have a better + solution for token variables for the new widgets --> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <script type="module" src="chrome://global/content/elements/moz-support-link.mjs"></script> +</head> +<body> +<p id="display"></p> +<div id="content"> + <a + id="testElement" + is="moz-support-link" + data-l10n-id="test" + support-page="support-test" + >testElement</a> + + <a + id="testElement2" + is="moz-support-link" + data-l10n-id="test2" + support-page="support-test" + utm-content="utmcontent-test" + >testElement2</a> + + <a + id="testElement3" + is="moz-support-link" + data-l10n-name="name" + support-page="support-test" + ></a> + + <a + id="testElement4" + is="moz-support-link" + data-l10n-id="test4" + data-l10n-name="name" + support-page="support-test" +></a> + + <a + id="testElement5" + is="moz-support-link" + support-page="support-test" + ></a> + +</div> +<pre id="test"></pre> +<script> + function assertBasicProperties(link, { l10nId, l10nName, supportPage, supportUrl, utmContent }) { + is(link.localName, "a", "Check that it is an anchor"); + is(link.constructor.name, "MozSupportLink", "Element should be a 'moz-support-link'"); + if (l10nId) { + is(link.getAttribute("data-l10n-id"), l10nId, "Check data-l10n-id is correct"); + } + if (l10nName) { + is(link.getAttribute("data-l10n-name"), l10nName, "Check data-l10n-name is correct"); + } + if (supportPage && utmContent) { + is(link.getAttribute("utm-content"), utmContent, "Check utm-correct is correct"); + is(link.getAttribute("support-page"), supportPage, "Check support-page is correct"); + is(link.target, "_blank", "support link should open a new window"); + let expectedHref = `${supportUrl}${supportPage}?utm_source=firefox-browser&utm_medium=firefox-browser&utm_content=${utmContent}`; + is(link.href, expectedHref, "href should be generated correctly when using utm-content"); + } else if (supportPage) { + is(link.getAttribute("support-page"), supportPage, "Check support-page is correct"); + is(link.target, "_blank", "support link should open a new window"); + is(link.href, `${supportUrl}${supportPage}`, `href should be generated SUPPORT_URL plus ${supportPage}`); + } + } + add_task(async function test_component_declaration() { + let mozSupportLink = customElements.get("moz-support-link"); + let supportUrl = mozSupportLink.SUPPORT_URL; + let supportPage = "support-test"; + + // Ensure all the semantics of the primary link are present + let supportLink = document.getElementById("testElement"); + assertBasicProperties(supportLink, {l10nId: "test", supportPage, supportUrl}); + + // Ensure AMO support link has the correct values + let supportLinkAMO = document.getElementById("testElement2"); + assertBasicProperties(supportLinkAMO, { + l10nId: "test2", + supportPage, + supportUrl, + utmContent:"utmcontent-test" + }); + + // Ensure data-l10n-name is not overwritten by the component + let supportLinkL10nName = document.getElementById("testElement3"); + assertBasicProperties(supportLinkL10nName, { + l10nId: null, + l10nName: "name", + supportPage, + supportUrl + }); + + // Ensure data-l10n-id and data-l10n-name are not overwritten by the component + let linkWithNameAndId = document.getElementById("testElement4"); + assertBasicProperties(linkWithNameAndId, { + l10nId: "test4", + l10nName: "name", + supportPage, + supportUrl + }); + + // Ensure moz-support-link without assigned data-l10n-id gets the default id + let defaultLink = document.getElementById("testElement5"); + assertBasicProperties(defaultLink, { + l10nId: "moz-support-link-text", + supportPage, + supportUrl + }); + }); + + add_task(async function test_creating_component() { + // Ensure created support link behaves as expected + let mozSupportLink = customElements.get("moz-support-link"); + let supportUrl = mozSupportLink.SUPPORT_URL; + let l10nId = "constructedElement"; + let content = document.getElementById("content"); + let utmSupportLink = document.createElement("a", { is: "moz-support-link" }); + utmSupportLink.id = l10nId; + utmSupportLink.innerText = l10nId; + document.l10n.setAttributes(utmSupportLink, l10nId); + content.appendChild(utmSupportLink); + assertBasicProperties(utmSupportLink, { supportUrl, l10nId }); + + // Set href via "support-page" after creating the element + utmSupportLink.setAttribute("support-page", "created-page"); + assertBasicProperties(utmSupportLink, { + supportUrl, + supportPage: "created-page" + }); + + // Set href via "utm-content" + utmSupportLink.setAttribute("utm-content", "created-content"); + assertBasicProperties(utmSupportLink, { + supportUrl, + supportPage: "created-page", + utmContent: "created-content" + }); + }); +</script> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_moz_toggle.html b/toolkit/content/tests/widgets/test_moz_toggle.html new file mode 100644 index 0000000000..62f248c599 --- /dev/null +++ b/toolkit/content/tests/widgets/test_moz_toggle.html @@ -0,0 +1,85 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>MozToggle tests</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <!-- TODO: Bug 1798404 - in-content/common.css can be removed once we have a better + solution for token variables for the new widgets --> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"> + <script type="module" src="chrome://global/content/elements/moz-toggle.mjs"></script> +</head> +<body> +<p id="display"></p> +<div id="content" style="max-width: fit-content"> + <moz-toggle label="Label" description="Description" pressed="true"></moz-toggle> +</div> +<pre id="test"> +<script class="testbody" type="application/javascript"> + add_task(async function testMozToggleDisplay() { + const mozToggle = document.querySelector("moz-toggle"); + ok(mozToggle, "moz-toggle is rendered"); + + const label = mozToggle.labelEl; + ok(label, "moz-toggle contains a label"); + ok(label.textContent.includes("Label"), "The expected label text is shown"); + + const description = mozToggle.descriptionEl; + ok(description, "moz-toggle contains a description"); + ok(description.textContent.includes("Description"), "The expected description text is shown"); + + const button = mozToggle.buttonEl; + ok(button, "moz-toggle contains a button"); + is(button.getAttribute("aria-pressed"), "true", "The button is pressed"); + }); + + add_task(async function testMozToggleInteraction() { + const mozToggle = document.querySelector("moz-toggle"); + const button = mozToggle.buttonEl; + is(mozToggle.pressed, true, "moz-toggle is pressed initially"); + is(button.getAttribute("aria-pressed"), "true", "aria-pressed reflects the pressed state"); + + synthesizeMouseAtCenter(button, {}); + await mozToggle.updateComplete; + + is(mozToggle.pressed, false, "The toggle pressed state changes on click"); + is(button.getAttribute("aria-pressed"), "false", "aria-pressed reflects this change"); + + synthesizeMouseAtCenter(mozToggle.labelEl, {}); + await mozToggle.updateComplete; + + is(mozToggle.pressed, true, "The toggle pressed state changes on label click"); + is(button.getAttribute("aria-pressed"), "true", "aria-pressed reflects this change"); + + mozToggle.focus(); + synthesizeKey(" ", {}); + await mozToggle.updateComplete; + + is(mozToggle.pressed, false, "The toggle pressed state can be changed via space bar"); + is(button.getAttribute("aria-pressed"), "false", "aria-pressed reflects this change"); + }); + + add_task(async function testSupportsAccesskey() { + const mozToggle = document.querySelector("moz-toggle"); + let nextAccesskey = "l"; + mozToggle.setAttribute("accesskey", nextAccesskey); + + synthesizeKey( + nextAccesskey, + navigator.platform.includes("Mac") + ? { altKey: true, ctrlKey: true } + : { altKey: true, shiftKey: true } + ); + + is( + mozToggle.shadowRoot.activeElement, + mozToggle.buttonEl, + "Focus has moved to the toggle button." + ); + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_nac_mutations.html b/toolkit/content/tests/widgets/test_nac_mutations.html new file mode 100644 index 0000000000..3e4896bec2 --- /dev/null +++ b/toolkit/content/tests/widgets/test_nac_mutations.html @@ -0,0 +1,65 @@ +<!DOCTYPE HTML> +<title>UA Widget mutation observer test</title> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<script src="/tests/SimpleTest/EventUtils.js"></script> +<link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +<video controls id="video"></video> +<div style="overflow: scroll; width: 100px; height: 100px" id="scroller"></div> +<script> +const video = document.getElementById("video"); +const scroller = document.getElementById("scroller"); + +async function test_mutations_internal(observedNode, elementToMutate, expectMutations) { + let resolveMutations; + let mutations = new Promise(r => { + resolveMutations = r; + }); + + let observer = new MutationObserver(function(m) { + ok(expectMutations, "Mutations should be expected"); + resolveMutations(m) + }); + + SpecialPowers.wrap(observer).observe(observedNode, { + subtree: true, + attributes: true, + chromeOnlyNodes: expectMutations, + }); + + elementToMutate.setAttribute("unlikely", `value-${expectMutations}`); + + if (expectMutations) { + await mutations; + } else { + await new Promise(r => SimpleTest.executeSoon(r)); + } + + observer.disconnect(); +} + +async function test_mutations(observedNode, elementToMutate) { + for (let chromeOnlyNodes of [true, false]) { + info(`Testing chromeOnlyNodes: ${chromeOnlyNodes}`); + await test_mutations_internal(observedNode, elementToMutate, chromeOnlyNodes); + } +} + +add_task(async function test_ua_mutations() { + let shadow = SpecialPowers.wrap(video).openOrClosedShadowRoot; + ok(!!shadow, "UA Widget ShadowRoot exists"); + + await test_mutations(shadow, shadow.querySelector("*")); +}); + +add_task(async function test_scrollbar_mutations_same_anon_tree() { + let scrollbar = SpecialPowers.wrap(window).InspectorUtils.getChildrenForNode(scroller, true, false)[0]; + is(scrollbar.tagName, "scrollbar", "should find a scrollbar"); + await test_mutations(scrollbar, scrollbar); +}); + +add_task(async function test_scrollbar_mutations_same_tree() { + let scrollbar = SpecialPowers.wrap(window).InspectorUtils.getChildrenForNode(scroller, true, false)[0]; + is(scrollbar.tagName, "scrollbar", "should find a scrollbar"); + await test_mutations(scroller, scrollbar); +}); +</script> diff --git a/toolkit/content/tests/widgets/test_panel_item_accesskey.html b/toolkit/content/tests/widgets/test_panel_item_accesskey.html new file mode 100644 index 0000000000..a35e94d456 --- /dev/null +++ b/toolkit/content/tests/widgets/test_panel_item_accesskey.html @@ -0,0 +1,105 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>Test Panel Item Accesskey Support</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <panel-list> + <panel-item accesskey="F">First item</panel-item> + <panel-item accesskey="S">Second item</panel-item> + <panel-item>Third item</panel-item> + </panel-list> +</div> + +<pre id="test"> +<script class="testbody" type="application/javascript"> +const {BrowserTestUtils} = ChromeUtils.importESModule("resource://testing-common/BrowserTestUtils.sys.mjs"); + +add_task(async function testAccessKey() { + function assertAccessKeys(items, keys, { checkLabels = false } = {}) { + is(items.length, keys.length, "Got the same number of items and keys"); + for (let i = 0; i < items.length; i++) { + is(items[i].accessKey, keys[i], `Item ${i} has the right key`); + if (checkLabels) { + let label = items[i].shadowRoot.querySelector("label"); + is(label.accessKey, keys[i] || null, `Label ${i} has the right key`); + } + } + } + + let panelList = document.querySelector("panel-list"); + let panelItems = [...panelList.children]; + + info("Accesskeys should be removed when closed"); + assertAccessKeys(panelItems, ["", "", ""]); + + info("Accesskeys should be set when open"); + let panelShown = BrowserTestUtils.waitForEvent(panelList, "shown"); + panelList.show(); + await panelShown; + assertAccessKeys(panelItems, ["F", "S", ""], { checkLabels: true }); + + info("Changing accesskeys should happen right away"); + panelItems[1].accessKey = "c"; + panelItems[2].accessKey = "T"; + assertAccessKeys(panelItems, ["F", "c", "T"], { checkLabels: true }); + + info("Accesskeys are removed again on hide"); + let panelHidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + panelList.hide(); + await panelHidden; + assertAccessKeys(panelItems, ["", "", ""]); + + info("Accesskeys are set again on show"); + panelItems[0].removeAttribute("accesskey"); + panelShown = BrowserTestUtils.waitForEvent(panelList, "shown"); + panelList.show(); + await panelShown; + assertAccessKeys(panelItems, ["", "c", "T"], { checkLabels: true }); + + info("Check that accesskeys can be used without the modifier when open"); + let secondClickCount = 0; + let thirdClickCount = 0; + panelItems[1].addEventListener("click", () => secondClickCount++); + panelItems[2].addEventListener("click", () => thirdClickCount++); + + // Make sure the focus is in the window. + panelItems[0].focus(); + + panelHidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + synthesizeKey("c", {}); + await panelHidden; + + is(secondClickCount, 1, "The accesskey worked unmodified"); + is(thirdClickCount, 0, "The other listener wasn't fired"); + + synthesizeKey("c", {}); + synthesizeKey("t", {}); + + is(secondClickCount, 1, "The key doesn't trigger when closed"); + is(thirdClickCount, 0, "The key doesn't trigger when closed"); + + panelShown = BrowserTestUtils.waitForEvent(panelList, "shown"); + panelList.show(); + await panelShown; + + panelHidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + synthesizeKey("t", {}); + await panelHidden; + + is(secondClickCount, 1, "The other listener wasn't fired"); + is(thirdClickCount, 1, "The accesskey worked unmodified"); +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_panel_list_accessibility.html b/toolkit/content/tests/widgets/test_panel_list_accessibility.html new file mode 100644 index 0000000000..c53b48e0a9 --- /dev/null +++ b/toolkit/content/tests/widgets/test_panel_list_accessibility.html @@ -0,0 +1,79 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>Test Panel List Accessibility</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <button id="anchor-button">Open</button> + <panel-list id="panel-list"> + <panel-item>one</panel-item> + <panel-item>two</panel-item> + <panel-item>three</panel-item> + </panel-list> +</div> + +<pre id="test"> +<script class="testbody" type="application/javascript"> +const {BrowserTestUtils} = ChromeUtils.importESModule("resource://testing-common/BrowserTestUtils.sys.mjs"); +let anchorButton, panelList, panelItems; + + +add_setup(function setup() { + // Clear out the other elements so only our test content is on the page. + panelList = document.getElementById("panel-list"); + panelItems = [...panelList.children]; + anchorButton = document.getElementById("anchor-button"); + anchorButton.addEventListener("click", e => panelList.toggle(e)); +}); + +add_task(async function testItemFocusOnOpen() { + ok(document.activeElement, "There is an active element"); + ok(!document.activeElement.closest("panel-list"), "Focus isn't in the list"); + + let shown = BrowserTestUtils.waitForEvent(panelList, "shown"); + synthesizeMouseAtCenter(anchorButton, {}); + await shown; + + let hidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + synthesizeKey("Escape", {}); + await hidden; + + // Focus shouldn't enter the list on a click. + ok(!document.activeElement.closest("panel-list"), "Focus isn't in the list"); + + shown = BrowserTestUtils.waitForEvent(panelList, "shown"); + anchorButton.focus(); + synthesizeKey(" ", {}); + await shown; + + // Focus enters list with keyboard interaction. + is(document.activeElement, panelItems[0], "The first item is focused"); + + hidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + synthesizeKey("Escape", {}); + await hidden; + + is(document.activeElement, anchorButton, "The anchor is focused again on close"); +}); + +add_task(async function testAriaAttributes() { + is(panelList.getAttribute("role"), "menu", "The panel is a menu"); + + is(panelItems.length, 3, "There are 3 items"); + for (let item of panelItems) { + is(item.button.getAttribute("role"), "menuitem", "button is a menuitem"); + } +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_panel_list_anchoring.html b/toolkit/content/tests/widgets/test_panel_list_anchoring.html new file mode 100644 index 0000000000..b1e11d3e4d --- /dev/null +++ b/toolkit/content/tests/widgets/test_panel_list_anchoring.html @@ -0,0 +1,121 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test Panel List Anchoring</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> +<p id="display"></p> +<div id="content"> + <button id="anchor-button">Open</button> + <panel-list id="panel-list"> + <panel-item>one</panel-item> + <panel-item>two</panel-item> + <panel-item>three</panel-item> + </panel-list> +</div> +<pre id="test"> +<script class="testbody" type="application/javascript"> + const {BrowserTestUtils} = ChromeUtils.importESModule("resource://testing-common/BrowserTestUtils.sys.mjs"); + let anchorButton, panelList, panelItems; + + add_setup(function setup() { + panelList = document.getElementById("panel-list"); + anchorButton = document.getElementById("anchor-button"); + anchorButton.addEventListener("click", e => panelList.toggle(e)); + }); + + add_task(async function checkAlignment() { + async function getBounds() { + await new Promise(resolve => requestAnimationFrame(resolve)); + let button = anchorButton.getBoundingClientRect(); + let menu = panelList.getBoundingClientRect(); + return { + button: { + top: Math.round(button.top), + right: Math.round(button.right), + bottom: Math.round(button.bottom), + left: Math.round(button.left), + }, + menu: { + top: Math.round(menu.top), + right: Math.round(menu.right), + bottom: Math.round(menu.bottom), + left: Math.round(menu.left), + }, + } + }; + + async function showPanel() { + let shown = BrowserTestUtils.waitForEvent(panelList, "shown"); + anchorButton.click(); + await shown; + } + + async function hidePanel() { + let hidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + panelList.hide(); + await hidden; + } + + anchorButton.style.position = "fixed"; + + info("Verify menu alignment in the top left corner of the browser window"); + + anchorButton.style.top = "32px"; + anchorButton.style.right = "unset"; + anchorButton.style.bottom = "unset"; + anchorButton.style.left = "32px"; + + await showPanel(); + let bounds = await getBounds(); + is(bounds.menu.top, bounds.button.bottom, "Button's bottom matches Menu's top"); + is(bounds.menu.left, bounds.button.left, "Button and Menu have the same left value"); + await hidePanel(); + + info("Verify menu alignment in the top right corner of the browser window"); + + anchorButton.style.top = "32px"; + anchorButton.style.right = "32px"; + anchorButton.style.bottom = "unset"; + anchorButton.style.left = "unset"; + + await showPanel(); + bounds = await getBounds(); + is(bounds.menu.top, bounds.button.bottom, "Button's bottom matches Menu's top"); + is(bounds.menu.right, bounds.button.right, "Button and Menu have the same right value"); + await hidePanel(); + + info("Verify menu alignment in the bottom right corner of the browser window"); + + anchorButton.style.top = "unset"; + anchorButton.style.right = "32px"; + anchorButton.style.bottom = "32px"; + anchorButton.style.left = "unset"; + + await showPanel(); + bounds = await getBounds(); + is(bounds.menu.bottom, bounds.button.top, "Button's top matches Menu's bottom"); + is(bounds.menu.right, bounds.button.right, "Button and Menu have the same right value"); + await hidePanel(); + + info("Verify menu alignment in the bottom left corner of the browser window"); + + anchorButton.style.top = "unset"; + anchorButton.style.right = "unset"; + anchorButton.style.bottom = "32px"; + anchorButton.style.left = "32px"; + + await showPanel(); + bounds = await getBounds(); + is(bounds.menu.bottom, bounds.button.top, "Button's top matches Menu's bottom"); + is(bounds.menu.left, bounds.button.left, "Button and Menu have the same left value"); + await hidePanel(); + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_panel_list_in_xul_panel.html b/toolkit/content/tests/widgets/test_panel_list_in_xul_panel.html new file mode 100644 index 0000000000..ea5a9fc25c --- /dev/null +++ b/toolkit/content/tests/widgets/test_panel_list_in_xul_panel.html @@ -0,0 +1,87 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>Test Panel List In XUL Panel</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://global/skin/global.css" type="text/css"/> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <button id="anchor-button">Open</button> + <panel-list id="panel-list"> + <panel-item>one</panel-item> + <panel-item>two</panel-item> + <panel-item>three</panel-item> + <panel-item>four</panel-item> + <panel-item>five</panel-item> + <panel-item>six</panel-item> + </panel-list> +</div> + +<pre id="test"> +<script class="testbody" type="application/javascript"> +const {BrowserTestUtils} = ChromeUtils.importESModule("resource://testing-common/BrowserTestUtils.sys.mjs"); +let xulPanel, anchorButton, panelList; + + +add_setup(function setup() { + // The HTML document parser doesn't let us put XUL elements in the markup, so + // we have to create the <xul:panel> programmatically with script. + let content = document.getElementById("content"); + xulPanel = document.createXULElement("panel"); + panelList = document.getElementById("panel-list"); + xulPanel.appendChild(panelList); + content.appendChild(xulPanel); + anchorButton = document.getElementById("anchor-button"); + anchorButton.addEventListener("click", e => panelList.toggle(e)); +}); + +add_task(async function testXULPanelOpenFromClicks() { + let xulPanelShown = BrowserTestUtils.waitForPopupEvent(xulPanel, "shown"); + let shown = BrowserTestUtils.waitForEvent(panelList, "shown"); + synthesizeMouseAtCenter(anchorButton, {}); + await shown; + await xulPanelShown; + + ok(panelList.hasAttribute("inxulpanel"), "Should have inxulpanel attribute set"); + + let style = window.getComputedStyle(panelList); + is(style.top, "0px", "computed top inline style should be 0px."); + is(style.left, "0px", "computed left inline style should be 0px."); + + let xulPanelHidden = BrowserTestUtils.waitForPopupEvent(xulPanel, "hidden"); + let hidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + synthesizeKey("Escape", {}); + await hidden; + await xulPanelHidden; +}); + +add_task(async function testXULPanelOpenProgrammatically() { + let xulPanelShown = BrowserTestUtils.waitForPopupEvent(xulPanel, "shown"); + let shown = BrowserTestUtils.waitForEvent(panelList, "shown"); + panelList.show(); + await shown; + await xulPanelShown; + + ok(panelList.hasAttribute("inxulpanel"), "Should have inxulpanel attribute set"); + let style = window.getComputedStyle(panelList); + is(style.top, "0px", "computed top inline style should be 0px."); + is(style.left, "0px", "computed left inline style should be 0px."); + + let xulPanelHidden = BrowserTestUtils.waitForPopupEvent(xulPanel, "hidden"); + let hidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + panelList.hide(); + await hidden; + await xulPanelHidden; +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_panel_list_min_width_from_anchor.html b/toolkit/content/tests/widgets/test_panel_list_min_width_from_anchor.html new file mode 100644 index 0000000000..0708174a88 --- /dev/null +++ b/toolkit/content/tests/widgets/test_panel_list_min_width_from_anchor.html @@ -0,0 +1,70 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>Test Panel List Min-width From Anchor</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://global/skin/global.css" type="text/css"/> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <button id="anchor-button">This is a button with a long string to act as a wide anchor.</button> + <panel-list id="panel-list"> + <panel-item>one</panel-item> + <panel-item>two</panel-item> + <panel-item>three</panel-item> + <panel-item>four</panel-item> + <panel-item>five</panel-item> + <panel-item>six</panel-item> + </panel-list> +</div> + +<pre id="test"> + +<script class="testbody" type="application/javascript"> +const {BrowserTestUtils} = ChromeUtils.importESModule("resource://testing-common/BrowserTestUtils.sys.mjs"); +let anchorButton, panelList; + +add_setup(function setup() { + panelList = document.getElementById("panel-list"); + anchorButton = document.getElementById("anchor-button"); + anchorButton.addEventListener("click", e => panelList.toggle(e)); +}); + +add_task(async function minWidthFromAnchor() { + let anchorWidth = anchorButton.getBoundingClientRect().width; + let shown = BrowserTestUtils.waitForEvent(panelList, "shown"); + synthesizeMouseAtCenter(anchorButton, {}); + await shown; + + let panelWidth = panelList.getBoundingClientRect().width; + isnot(anchorWidth, panelWidth, "Without min-width-from-anchor, panel should not have anchor width."); + + let hidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + synthesizeKey("Escape", {}); + await hidden; + + panelList.toggleAttribute("min-width-from-anchor", true); + + shown = BrowserTestUtils.waitForEvent(panelList, "shown"); + synthesizeMouseAtCenter(anchorButton, {}); + await shown; + + panelWidth = panelList.getBoundingClientRect().width; + is(anchorWidth, panelWidth, "With min-width-from-anchor, panel should have anchor width."); + + hidden = BrowserTestUtils.waitForEvent(panelList, "hidden"); + synthesizeKey("Escape", {}); + await hidden; +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_panel_list_shadow_node_anchor.html b/toolkit/content/tests/widgets/test_panel_list_shadow_node_anchor.html new file mode 100644 index 0000000000..9f265d4cf9 --- /dev/null +++ b/toolkit/content/tests/widgets/test_panel_list_shadow_node_anchor.html @@ -0,0 +1,96 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Bug 1802215 - Allow <panel-list> to be anchored to shadow DOM nodes</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="./panel-list.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> + <link rel="stylesheet" href="./panel-list.css"/> + <link rel="stylesheet" href="./panel-item.css"/> + <script> + /** + * Define a simple custom element that includes a <button> in its + * shadow DOM to anchor a panel-list on. The TestElement is slotted, + * putting any direct descendents right after a 400px tall <div> + * with a red border. + */ + class TestElement extends HTMLElement { + static get fragment() { + const MARKUP = ` + <template> + <div style="height: 100px; border: 1px solid red;"> + <button id="anchor">Anchor</button> + </div> + <slot></slot> + </template> + `; + + let parser = new DOMParser(); + let doc = parser.parseFromString(MARKUP, "text/html"); + TestElement.template = document.importNode( + doc.querySelector("template"), + true + ); + let fragment = TestElement.template.content.cloneNode(true); + return fragment; + } + + connectedCallback() { + this.shadow = this.attachShadow({ mode: "closed" }); + this.shadow.appendChild(TestElement.fragment); + this.anchor = this.shadow.querySelector("#anchor"); + this.anchor.addEventListener("click", event => { + let panelList = this.querySelector("panel-list"); + panelList.toggle(event); + }); + } + + doClick() { + this.anchor.click(); + } + } + + customElements.define("test-element", TestElement); + + /** + * Tests that if a <panel-list> is anchored on a node within a custom + * element shadow DOM, that it anchors properly to that shadow node. + */ + add_task(async function test_panel_list_anchor_on_shadow_node() { + let testElement = document.getElementById("test-element"); + let panelList = document.getElementById("test-list"); + + let openPromise = new Promise(resolve => { + panelList.addEventListener("shown", resolve, { once: true }); + }); + testElement.doClick(); + await openPromise; + + let panelRect = panelList.getBoundingClientRect(); + let anchorRect = testElement.anchor.getBoundingClientRect(); + // Recalculate the <panel-list> rect top value relative to the top-left + // of the anchorRect. We expect the <panel-list> to be tightly anchored + // to the bottom of the <button>, so we expect this new value to be 0. + let panelTopLeftRelativeToAnchorTopLeft = panelRect.top - anchorRect.top - anchorRect.height; + is( + panelTopLeftRelativeToAnchorTopLeft, + 0, + "Panel should be tightly anchored to the bottom of the button shadow node." + ); + }); + </script> +</head> +<body> +<p id="display"></p> +<div id="content"> + <test-element id="test-element"> + <panel-list id="test-list"> + <panel-item>An item</panel-item> + <panel-item>Another item</panel-item> + </panel-list> + </test-element> +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_popupanchor.xhtml b/toolkit/content/tests/widgets/test_popupanchor.xhtml new file mode 100644 index 0000000000..5c78cf62fc --- /dev/null +++ b/toolkit/content/tests/widgets/test_popupanchor.xhtml @@ -0,0 +1,387 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Anchor Tests" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <panel id="testPanel" + type="arrow" + animate="false" + noautohide="true"> + </panel> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<script> +<![CDATA[ +var anchor, panel; + +function is_close(got, exp, msg) { + // on some platforms we see differences of a fraction of a pixel - so + // allow any difference of < 1 pixels as being OK. + ok(Math.abs(got - exp) < 1, msg + ": " + got + " should be equal(-ish) to " + exp); +} + +function margins(popup) { + let ret = {}; + let cs = getComputedStyle(popup); + for (let side of ["top", "right", "bottom", "left"]) { + ret[side] = parseFloat(cs.getPropertyValue("margin-" + side)); + } + return ret; +} + +function checkPositionRelativeToAnchor(side) { + var panelRect = panel.getBoundingClientRect(); + var anchorRect = anchor.getBoundingClientRect(); + switch (side) { + case "left": + case "right": + is_close(panelRect.top - margins(panel).top, anchorRect.bottom, "top of panel should be at bottom of anchor"); + break; + case "top": + case "bottom": + is_close(panelRect.right + margins(panel).left, anchorRect.left, "right of panel should be left of anchor"); + break; + default: + ok(false, "unknown side " + side); + break; + } +} + +function openSlidingPopup(position, callback) { + panel.setAttribute("flip", "slide"); + _openPopup(position, callback); +} + +function openPopup(position, callback) { + panel.setAttribute("flip", "both"); + _openPopup(position, callback); +} + +async function waitForPopupPositioned(actionFn, callback) +{ + info("waitForPopupPositioned"); + let a = new Promise(resolve => { + panel.addEventListener("popuppositioned", () => resolve(true), { once: true }); + }); + + actionFn(); + + // Ensure we get at least one event, but we might get more than one, so wait for them as needed. + // + // The double-raf ensures layout runs once. setTimeout is needed because that's how popuppositioned is scheduled. + let b = new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => { + setTimeout(() => resolve(false), 0); + })); + }); + + let gotEvent = await Promise.race([a, b]); + info("got event: " + gotEvent); + if (gotEvent) { + waitForPopupPositioned(() => {}, callback); + } else { + SimpleTest.executeSoon(callback); + } +} + +function _openPopup(position, callback) { + panel.setAttribute("side", "noside"); + panel.addEventListener("popupshown", callback, {once: true}); + panel.openPopup(anchor, position); +} + +var tests = [ + // A panel with the anchor after_end - the anchor should not move on resize + ['simpleResizeHorizontal', 'middle', function(next) { + openPopup("after_end", function() { + checkPositionRelativeToAnchor("right"); + var origPanelRect = panel.getBoundingClientRect(); + panel.sizeTo(100, 100); + checkPositionRelativeToAnchor("right"); // should not have flipped, so still "right" + panel.sizeTo(origPanelRect.width, origPanelRect.height); + checkPositionRelativeToAnchor("right"); // should not have flipped, so still "right" + next(); + }); + }], + + ['simpleResizeVertical', 'middle', function(next) { + openPopup("start_after", function() { + checkPositionRelativeToAnchor("bottom"); + var origPanelRect = panel.getBoundingClientRect(); + panel.sizeTo(100, 100); + checkPositionRelativeToAnchor("bottom"); // should not have flipped. + panel.sizeTo(origPanelRect.width, origPanelRect.height); + checkPositionRelativeToAnchor("bottom"); // should not have flipped. + next(); + }); + }], + ['flippingResizeHorizontal', 'middle', function(next) { + openPopup("after_end", function() { + checkPositionRelativeToAnchor("right"); + waitForPopupPositioned( + () => { panel.sizeTo(anchor.getBoundingClientRect().left + 50, 50); }, + () => { + checkPositionRelativeToAnchor("left"); // check it flipped. + next(); + }); + }); + }], + ['flippingResizeVertical', 'middle', function(next) { + openPopup("start_after", function() { + checkPositionRelativeToAnchor("bottom"); + waitForPopupPositioned( + () => { panel.sizeTo(50, anchor.getBoundingClientRect().top + 50); }, + () => { + checkPositionRelativeToAnchor("top"); // check it flipped. + next(); + }); + }); + }], + + ['simpleMoveToAnchorHorizontal', 'middle', function(next) { + openPopup("after_end", function() { + checkPositionRelativeToAnchor("right"); + waitForPopupPositioned( + () => { panel.moveToAnchor(anchor, "after_end", 20, 0); }, + () => { + // the anchor and the panel should have moved 20px right without flipping. + checkPositionRelativeToAnchor("right"); + waitForPopupPositioned( + () => { panel.moveToAnchor(anchor, "after_end", -20, 0); }, + () => { + // the anchor and the panel should have moved 20px left without flipping. + checkPositionRelativeToAnchor("right"); + next(); + }); + }); + }); + }], + + ['simpleMoveToAnchorVertical', 'middle', function(next) { + openPopup("start_after", function() { + checkPositionRelativeToAnchor("bottom"); + waitForPopupPositioned( + () => { panel.moveToAnchor(anchor, "start_after", 0, 20); }, + () => { + // the anchor and the panel should have moved 20px down without flipping. + checkPositionRelativeToAnchor("bottom"); + waitForPopupPositioned( + () => { panel.moveToAnchor(anchor, "start_after", 0, -20) }, + () => { + // the anchor and the panel should have moved 20px up without flipping. + checkPositionRelativeToAnchor("bottom"); + next(); + }); + }); + }); + }], + + // Do a moveToAnchor that causes the panel to flip horizontally + ['flippingMoveToAnchorHorizontal', 'middle', function(next) { + var anchorRight = anchor.getBoundingClientRect().right; + // Size the panel such that it only just fits from the left-hand side of + // the window to the right of the anchor - thus, it will fit when + // anchored to the right-hand side of the anchor. + panel.sizeTo(anchorRight - 10, 100); + openPopup("after_end", function() { + checkPositionRelativeToAnchor("right"); + // Ask for it to be anchored 1/2 way between the left edge of the window + // and the anchor right - it can't fit with the panel on the left/arrow + // on the right, so it must flip (arrow on the left, panel on the right) + var offset = Math.floor(-anchorRight / 2); + + waitForPopupPositioned( + () => panel.moveToAnchor(anchor, "after_end", offset, 0), + () => { + checkPositionRelativeToAnchor("left"); + // resize back to original and move to a zero offset - it should flip back. + + panel.sizeTo(anchorRight - 10, 100); + waitForPopupPositioned( + () => panel.moveToAnchor(anchor, "after_end", 0, 0), + () => { + checkPositionRelativeToAnchor("right"); // should have flipped back. + next(); + }); + }); + }); + }], + + // Do a moveToAnchor that causes the panel to flip vertically + ['flippingMoveToAnchorVertical', 'middle', function(next) { + var anchorBottom = anchor.getBoundingClientRect().bottom; + // See comments above in flippingMoveToAnchorHorizontal, but read + // "top/bottom" instead of "left/right" + panel.sizeTo(100, anchorBottom - 10); + openPopup("start_after", function() { + checkPositionRelativeToAnchor("bottom"); + var offset = Math.floor(-anchorBottom / 2); + + waitForPopupPositioned( + () => panel.moveToAnchor(anchor, "start_after", 0, offset), + () => { + checkPositionRelativeToAnchor("top"); + panel.sizeTo(100, anchorBottom - 10); + + waitForPopupPositioned( + () => panel.moveToAnchor(anchor, "start_after", 0, 0), + () => { + checkPositionRelativeToAnchor("bottom"); + next(); + }); + }); + }); + }], + + ['veryWidePanel-after_end', 'middle', function(next) { + openSlidingPopup("after_end", function() { + waitForPopupPositioned( + () => { panel.sizeTo(window.innerWidth - 10, 60); }, + () => { + is(panel.getBoundingClientRect().width, window.innerWidth - 10, "width is what we requested.") + next(); + }); + }); + }], + + ['veryWidePanel-before_start', 'middle', function(next) { + openSlidingPopup("before_start", function() { + waitForPopupPositioned( + () => { panel.sizeTo(window.innerWidth - 10, 60); }, + () => { + is(panel.getBoundingClientRect().width, window.innerWidth - 10, "width is what we requested") + next(); + }); + }); + }], + + ['veryTallPanel-start_after', 'middle', function(next) { + openSlidingPopup("start_after", function() { + waitForPopupPositioned( + () => { panel.sizeTo(100, window.innerHeight - 10); }, + () => { + is(panel.getBoundingClientRect().height, window.innerHeight - 10, "height is what we requested.") + next(); + }); + }); + }], + + ['veryTallPanel-start_before', 'middle', function(next) { + openSlidingPopup("start_before", function() { + waitForPopupPositioned( + () => { panel.sizeTo(100, window.innerHeight - 10); }, + () => { + is(panel.getBoundingClientRect().height, window.innerHeight - 10, "height is what we requested") + next(); + }); + }); + }], + + // Tests against the anchor at the right-hand side of the window + ['afterend', 'right', function(next) { + openPopup("after_end", function() { + // when we request too far to the right/bottom, the panel gets shrunk + // and moved. The amount it is shrunk by is how far it is moved. + checkPositionRelativeToAnchor("right"); + next(); + }); + }], + + ['after_start', 'right', function(next) { + openPopup("after_start", function() { + // See above - we are still too far to the right, but the anchor is + // on the other side. + checkPositionRelativeToAnchor("right"); + next(); + }); + }], + + // Tests against the anchor at the left-hand side of the window + ['after_start', 'left', function(next) { + openPopup("after_start", function() { + var panelRect = panel.getBoundingClientRect(); + ok(panelRect.left - margins(panel).left >= 0, "panel remains within the screen"); + checkPositionRelativeToAnchor("left"); + next(); + }); + }], +] + +function runTests() { + function runNextTest() { + let result = tests.shift(); + if (!result) { + // out of tests + panel.hidePopup(); + SimpleTest.finish(); + return; + } + let [name, anchorPos, test] = result; + SimpleTest.info("sub-test " + anchorPos + "." + name + " starting"); + // first arrange for the anchor to be where the test requires it. + panel.hidePopup(); + panel.sizeTo(100, 50); + // hide all the anchors here, then later we make one of them visible. + document.getElementById("anchor-left-wrapper").style.display = "none"; + document.getElementById("anchor-middle-wrapper").style.display = "none"; + document.getElementById("anchor-right-wrapper").style.display = "none"; + switch(anchorPos) { + case 'middle': + anchor = document.getElementById("anchor-middle"); + document.getElementById("anchor-middle-wrapper").style.display = "block"; + break; + case 'left': + anchor = document.getElementById("anchor-left"); + document.getElementById("anchor-left-wrapper").style.display = "block"; + break; + case 'right': + anchor = document.getElementById("anchor-right"); + document.getElementById("anchor-right-wrapper").style.display = "block"; + break; + default: + SimpleTest.ok(false, "Bad anchorPos: " + anchorPos); + runNextTest(); + return; + } + try { + test(runNextTest); + } catch (ex) { + SimpleTest.ok(false, "sub-test " + anchorPos + "." + name + " failed: " + ex.toString() + "\n" + ex.stack); + runNextTest(); + } + } + runNextTest(); +} + +SimpleTest.waitForExplicitFinish(); + +addEventListener("load", function() { + // anchor is set by the test runner above + panel = document.getElementById("testPanel"); + + runTests(); +}); + +]]> +</script> + +<body xmlns="http://www.w3.org/1999/xhtml"> +<!-- Our tests assume at least 100px around the anchor on all sides, else the + panel may flip when we don't expect it to +--> +<div id="anchor-middle-wrapper" style="margin: 100px 100px 100px 100px;"> + <p>The anchor --> <span id="anchor-middle">v</span> <--</p> +</div> +<div id="anchor-left-wrapper" style="text-align: left; display: none;"> + <p><span id="anchor-left">v</span> <-- The anchor;</p> +</div> +<div id="anchor-right-wrapper" style="text-align: right; display: none;"> + <p>The anchor --> <span id="anchor-right">v</span></p> +</div> +</body> + +</window> diff --git a/toolkit/content/tests/widgets/test_popupreflows.xhtml b/toolkit/content/tests/widgets/test_popupreflows.xhtml new file mode 100644 index 0000000000..c3f8068779 --- /dev/null +++ b/toolkit/content/tests/widgets/test_popupreflows.xhtml @@ -0,0 +1,96 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?> + +<window title="Popup Reflow Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <panel id="testPanel" + type="arrow" + noautohide="true"> + </panel> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + +<script> +<![CDATA[ +let panel, anchor; + +// A reflow observer - it just remembers the stack trace of all sync reflows +// done by the panel. +let observer = { + reflows: [], + reflow (start, end) { + // Ignore reflows triggered by native code + // (Reflows from native code only have an empty stack after the first frame) + var path = (new Error().stack).split("\n").slice(1).join(""); + if (path === "") { + return; + } + + this.reflows.push(new Error().stack); + }, + + reflowInterruptible (start, end) { + // We're not interested in interruptible reflows. Why, you ask? Because + // we've simply cargo-culted this test from browser_tabopen_reflows.js! + }, + + QueryInterface: ChromeUtils.generateQI(["nsIReflowObserver", + "nsISupportsWeakReference"]) +}; + +// A test utility that counts the reflows caused by a test function. If the +// count of reflows isn't what is expected, it causes a test failure and logs +// the stack trace of all seen reflows. +function countReflows(testfn, expected) { + return new Promise(resolve => { + observer.reflows = []; + let docShell = panel.ownerGlobal.docShell; + docShell.addWeakReflowObserver(observer); + testfn().then(() => { + docShell.removeWeakReflowObserver(observer); + SimpleTest.is(observer.reflows.length, expected, "correct number of reflows"); + if (observer.reflows.length != expected) { + SimpleTest.info("stack traces of reflows:\n" + observer.reflows.join("\n") + "\n"); + } + resolve(); + }); + }); +} + +function openPopup() { + return new Promise(resolve => { + panel.addEventListener("popupshown", function popupshown() { + resolve(); + }, {once: true}); + panel.openPopup(anchor, "before_start"); + }); +} + +// ******************** +// The actual tests... +// We only have one atm - simply open a popup. +// +function testSimplePanel() { + return openPopup(); +} + +// ******************** +// The test harness... +// +SimpleTest.waitForExplicitFinish(); + +addEventListener("load", function() { + anchor = document.getElementById("anchor"); + panel = document.getElementById("testPanel"); + + // and off we go... + countReflows(testSimplePanel, 0).then(SimpleTest.finish); +}); +]]> +</script> +<body xmlns="http://www.w3.org/1999/xhtml"> + <p>The anchor --> <span id="anchor">v</span> <--</p> +</body> +</window> diff --git a/toolkit/content/tests/widgets/test_tree_column_reorder.xhtml b/toolkit/content/tests/widgets/test_tree_column_reorder.xhtml new file mode 100644 index 0000000000..d1dc9c1c20 --- /dev/null +++ b/toolkit/content/tests/widgets/test_tree_column_reorder.xhtml @@ -0,0 +1,75 @@ +<?xml version="1.0"?> +<?xml-stylesheet type="text/css" href="chrome://global/skin"?> +<?xml-stylesheet type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"?> +<!-- + XUL Widget Test for reordering tree columns + --> +<window title="Tree" width="500" height="600" + onload="setTimeout(testtag_tree_column_reorder, 0);" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + +<script src="tree_shared.js"/> + +<tree id="tree-column-reorder" rows="1" enableColumnDrag="true"> + <treecols> + <treecol id="col_0" label="col_0" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_1" label="col_1" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_2" label="col_2" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_3" label="col_3" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_4" label="col_4" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_5" label="col_5" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_6" label="col_6" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_7" label="col_7" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_8" label="col_8" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_9" label="col_9" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_10" label="col_10" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_11" label="col_11" flex="1"/> + <splitter class="tree-splitter"/> + <treecol id="col_12" label="col_12" flex="1"/> + </treecols> + <treechildren id="treechildren-column-reorder"> + <treeitem> + <treerow> + <treecell label="col_0"/> + <treecell label="col_1"/> + <treecell label="col_2"/> + <treecell label="col_3"/> + <treecell label="col_4"/> + <treecell label="col_5"/> + <treecell label="col_6"/> + <treecell label="col_7"/> + <treecell label="col_8"/> + <treecell label="col_9"/> + <treecell label="col_10"/> + <treecell label="col_11"/> + <treecell label="col_12"/> + </treerow> + </treeitem> + </treechildren> +</tree> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/> + + <!-- test code goes here --> + <script type="application/javascript"><![CDATA[ + +SimpleTest.waitForExplicitFinish(); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/widgets/test_ua_widget_elementFromPoint.html b/toolkit/content/tests/widgets/test_ua_widget_elementFromPoint.html new file mode 100644 index 0000000000..d52ec48f22 --- /dev/null +++ b/toolkit/content/tests/widgets/test_ua_widget_elementFromPoint.html @@ -0,0 +1,22 @@ +<!doctype html> +<title>UA Widget getElementFromPoint</title> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<script src="/tests/SimpleTest/EventUtils.js"></script> +<link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +<div id="host" style="width: 100px; height: 100px;"></div> +<script> +const host = document.getElementById("host"); +SpecialPowers.wrap(host.attachShadow({ mode: "open"})).setIsUAWidget(); +host.shadowRoot.innerHTML = ` + <div style="width: 100px; height: 100px; background-color: green;"></div> +`; +let hostRect = host.getBoundingClientRect(); +let point = { + x: hostRect.x + 50, + y: hostRect.y + 50, +}; +is(document.elementFromPoint(point.x, point.y), host, + "Host should be found from the document"); +is(host.shadowRoot.elementFromPoint(point.x, point.y), host.shadowRoot.firstElementChild, + "Should not have retargeted UA widget content to host unnecessarily"); +</script> diff --git a/toolkit/content/tests/widgets/test_ua_widget_sandbox.html b/toolkit/content/tests/widgets/test_ua_widget_sandbox.html new file mode 100644 index 0000000000..cc53e1c6d9 --- /dev/null +++ b/toolkit/content/tests/widgets/test_ua_widget_sandbox.html @@ -0,0 +1,101 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>UA Widget sandbox test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +const content = document.getElementById("content"); + +const div = content.appendChild(document.createElement("div")); +div.attachShadow({ mode: "open"}); +SpecialPowers.wrap(div.shadowRoot).setIsUAWidget(); + +const sandbox = SpecialPowers.Cu.getUAWidgetScope(SpecialPowers.wrap(div).nodePrincipal); + +SpecialPowers.setWrapped(sandbox, "info", SpecialPowers.wrapFor(info, sandbox)); +SpecialPowers.setWrapped(sandbox, "is", SpecialPowers.wrapFor(is, sandbox)); +SpecialPowers.setWrapped(sandbox, "ok", SpecialPowers.wrapFor(ok, sandbox)); + +const sandboxScript = function(shadowRoot) { + info("UA Widget scope tests"); + is(typeof window, "undefined", "The sandbox has no window"); + is(typeof document, "undefined", "The sandbox has no document"); + + let element = shadowRoot.host; + let doc = element.ownerDocument; + let win = doc.defaultView; + + ok(win.ShadowRoot.isInstance(shadowRoot), "shadowRoot is a ShadowRoot"); + ok(win.HTMLDivElement.isInstance(element), "Element is a <div>"); + + is("createElement" in doc, false, "No document.createElement"); + is("createElementNS" in doc, false, "No document.createElementNS"); + is("createTextNode" in doc, false, "No document.createTextNode"); + is("createComment" in doc, false, "No document.createComment"); + is("importNode" in doc, false, "No document.importNode"); + is("adoptNode" in doc, false, "No document.adoptNode"); + + is("insertBefore" in element, false, "No element.insertBefore"); + is("appendChild" in element, false, "No element.appendChild"); + is("replaceChild" in element, false, "No element.replaceChild"); + is("cloneNode" in element, false, "No element.cloneNode"); + + ok("importNodeAndAppendChildAt" in shadowRoot, "shadowRoot.importNodeAndAppendChildAt"); + ok("createElementAndAppendChildAt" in shadowRoot, "shadowRoot.createElementAndAppendChildAt"); + + info("UA Widget special methods tests"); + + const span = shadowRoot.createElementAndAppendChildAt(shadowRoot, "span"); + span.textContent = "Hello from <span>!"; + + is(shadowRoot.lastChild, span, "<span> inserted"); + + const parser = new win.DOMParser(); + let parserDoc = parser.parseFromString( + `<div xmlns="http://www.w3.org/1999/xhtml">Hello from DOMParser!</div>`, "application/xml"); + shadowRoot.importNodeAndAppendChildAt(shadowRoot, parserDoc.documentElement, true); + + ok(win.HTMLDivElement.isInstance(shadowRoot.lastChild), "<div> inserted"); + is(shadowRoot.lastChild.textContent, "Hello from DOMParser!", "Deep import node worked"); + + info("UA Widget reflectors tests"); + + win.wrappedJSObject.spanElementFromUAWidget = span; + win.wrappedJSObject.divElementFromUAWidget = shadowRoot.lastChild; +}; +SpecialPowers.Cu.evalInSandbox("this.script = " + sandboxScript.toString(), sandbox); +sandbox.script(div.shadowRoot); + +ok(SpecialPowers.wrap(HTMLSpanElement).isInstance(window.spanElementFromUAWidget), "<span> exposed"); +ok(SpecialPowers.wrap(HTMLDivElement).isInstance(window.divElementFromUAWidget), "<div> exposed"); + +try { + window.spanElementFromUAWidget.textContent; + ok(false, "Should throw."); +} catch (err) { + ok(/denied/.test(err), "Permission denied to access <span>"); +} + +try { + window.divElementFromUAWidget.textContent; + ok(false, "Should throw."); +} catch (err) { + ok(/denied/.test(err), "Permission denied to access <div>"); +} + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_ua_widget_unbind.html b/toolkit/content/tests/widgets/test_ua_widget_unbind.html new file mode 100644 index 0000000000..89ae6c0f00 --- /dev/null +++ b/toolkit/content/tests/widgets/test_ua_widget_unbind.html @@ -0,0 +1,56 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>UA Widget unbind test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> +</div> + +<pre id="test"> +<script class="testbody"> + +const content = document.getElementById("content"); + +add_task(function() { + const video = document.createElement("video"); + video.controls = true; + ok(!SpecialPowers.wrap(video).openOrClosedShadowRoot, "UA Widget Shadow Root is not created"); + content.appendChild(video); + ok(!!SpecialPowers.wrap(video).openOrClosedShadowRoot, "UA Widget Shadow Root is created"); + ok(!!SpecialPowers.wrap(video).openOrClosedShadowRoot.firstChild, "Widget is constructed"); + content.removeChild(video); + ok(!SpecialPowers.wrap(video).openOrClosedShadowRoot, "UA Widget Shadow Root is removed"); +}); + +add_task(function() { + const marquee = document.createElement("marquee"); + ok(!SpecialPowers.wrap(marquee).openOrClosedShadowRoot, "UA Widget Shadow Root is not created"); + content.appendChild(marquee); + ok(!!SpecialPowers.wrap(marquee).openOrClosedShadowRoot, "UA Widget Shadow Root is created"); + ok(!!SpecialPowers.wrap(marquee).openOrClosedShadowRoot.firstChild, "Widget is constructed"); + content.removeChild(marquee); + ok(SpecialPowers.wrap(marquee).openOrClosedShadowRoot, "UA Widget Shadow Root is not removed for marquee"); +}); + +add_task(function() { + const input = document.createElement("input"); + input.type = "date"; + ok(!SpecialPowers.wrap(input).openOrClosedShadowRoot, "UA Widget Shadow Root is not created"); + content.appendChild(input); + ok(!!SpecialPowers.wrap(input).openOrClosedShadowRoot, "UA Widget Shadow Root is created"); + ok(!!SpecialPowers.wrap(input).openOrClosedShadowRoot.firstChild, "Widget is constructed"); + content.removeChild(input); + ok(!SpecialPowers.wrap(input).openOrClosedShadowRoot, "UA Widget Shadow Root is removed"); +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols.html b/toolkit/content/tests/widgets/test_videocontrols.html new file mode 100644 index 0000000000..32cd23df6a --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols.html @@ -0,0 +1,564 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video width="320" height="240" id="video" controls mozNoDynamicControls preload="auto"></video> +</div> + +<div id="host"></div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +/* + * Positions of the UI elements, relative to the upper-left corner of the + * <video> box. + */ +const videoWidth = 320; +const videoHeight = 240; +const videoDuration = 3.8329999446868896; + +const controlBarMargin = 9; + +const playButtonWidth = 30; +const playButtonHeight = 40; +const muteButtonWidth = 30; +const muteButtonHeight = 40; +const positionAndDurationWidth = 75; +const fullscreenButtonWidth = 30; +const fullscreenButtonHeight = 40; +const volumeSliderWidth = 48; +const volumeSliderMarginStart = 4; +const volumeSliderMarginEnd = 6; +const scrubberMargin = 9; +const scrubberWidth = videoWidth - controlBarMargin - playButtonWidth - scrubberMargin * 2 - positionAndDurationWidth - muteButtonWidth - volumeSliderMarginStart - volumeSliderWidth - volumeSliderMarginEnd - fullscreenButtonWidth - controlBarMargin; +const scrubberHeight = 40; + +// Play button is on the bottom-left +const playButtonCenterX = 0 + Math.round(playButtonWidth / 2); +const playButtonCenterY = videoHeight - Math.round(playButtonHeight / 2); +// Mute button is on the bottom-right before the full screen button and volume slider +const muteButtonCenterX = videoWidth - Math.round(muteButtonWidth / 2) - volumeSliderWidth - fullscreenButtonWidth - controlBarMargin; +const muteButtonCenterY = videoHeight - Math.round(muteButtonHeight / 2); +// Fullscreen button is on the bottom-right at the far end +const fullscreenButtonCenterX = videoWidth - Math.round(fullscreenButtonWidth / 2) - controlBarMargin; +const fullscreenButtonCenterY = videoHeight - Math.round(fullscreenButtonHeight / 2); +// Scrubber bar is between the play and mute buttons. We don't need it's +// X center, just the offset of its box. +const scrubberOffsetX = controlBarMargin + playButtonWidth + scrubberMargin; +const scrubberCenterY = videoHeight - Math.round(scrubberHeight / 2); + +const video = document.getElementById("video"); + +let requiredEvents = []; +let forbiddenEvents = []; +let receivedEvents = []; +let expectingEventPromise; + +async function isMuteButtonMuted() { + const muteButton = getElementWithinVideo(video, "muteButton"); + await new Promise(SimpleTest.executeSoon); + return muteButton.getAttribute("muted") === "true"; +} + +async function isVolumeSliderShowingCorrectVolume(expectedVolume) { + const volumeControl = getElementWithinVideo(video, "volumeControl"); + await new Promise(SimpleTest.executeSoon); + is(+volumeControl.value, expectedVolume * 100, "volume slider should match expected volume"); +} + +function forceReframe() { + // Setting display then getting the bounding rect to force a frame + // reconstruction on the video element. + video.style.display = "block"; + video.getBoundingClientRect(); + video.style.display = ""; + video.getBoundingClientRect(); +} + +function captureEventThenCheck(event) { + if (event) { + info(`Received event ${event.type}.`); + receivedEvents.push(event.type); + } + + const cleanupExpectations = () => { + requiredEvents.length = 0; + forbiddenEvents.length = 0; + receivedEvents.length = 0; + } + + // If receivedEvents contains any of the forbiddenEvents, reject the expectingEventPromise. + for (const forbidden of forbiddenEvents) { + if (receivedEvents.includes(forbidden)) { + // Capture list of requiredEvents for later reporting. + const oldRequiredEvents = requiredEvents.slice(); + cleanupExpectations(); + expectingEventPromise.reject(new Error(`Got forbidden event ${forbidden} while expecting ${oldRequiredEvents}`)); + return; + } + } + + if (!requiredEvents.length) { + // We might be getting an event before we started waiting for it. That's fine, + // just early exit. + return; + } + + // We are expecting at least one event. If receivedEvents is lacking one of the + // requiredEvents, exit. + for (const required of requiredEvents) { + if (!receivedEvents.includes(required)) { + return; + } + } + + // We've received all the events we required. Resolve the expectingEventPromise. + info(`No longer waiting for expected event(s) ${requiredEvents}.`); + cleanupExpectations(); + + // Don't resolve this right away, because this is called from within event handlers and + // we want all other event handlers to have a chance to respond to this event before we + // proceed with the test. This solves problems with things like a play-pause-play, where + // some of the actions will be discarded if the video controls themselves aren't in the + // expected state. + SimpleTest.executeSoon(expectingEventPromise.resolve); +} + +function waitForEvent(required, forbidden) { + return new Promise((resolve, reject) => { + expectingEventPromise = {resolve, reject}; + + info(`Waiting for ${required}` + (forbidden ? ` but not ${forbidden}...` : `...`)); + if (Array.isArray(required)) { + requiredEvents.push(...required); + } else { + requiredEvents.push(required); + } + if (forbidden) { + if (Array.isArray(forbidden)) { + forbiddenEvents.push(...forbidden); + } else { + forbiddenEvents.push(forbidden); + } + } + + // Immediately check the received events, since the calling pattern used in this test is + // calling this method *after* the events could have been triggered. + captureEventThenCheck(); + }); +} + +async function repeatUntilSuccessful(f) { + let successful = false; + do { + try { + // Delay one event loop. + await new Promise(r => SimpleTest.executeSoon(r)); + await f(); + successful = true; + } catch (error) { + info(`repeatUntilSuccessful: error ${error}.`); + } + } while(!successful); +} + +add_task(async function setup() { + SimpleTest.requestCompleteLog(); + await SpecialPowers.pushPrefEnv({ + "set": [ + ["media.cache_size", 40000], + ["full-screen-api.enabled", true], + ["full-screen-api.allow-trusted-requests-only", false], + ["full-screen-api.transition-duration.enter", "0 0"], + ["full-screen-api.transition-duration.leave", "0 0"], + ]}); + await new Promise(resolve => { + video.addEventListener("canplaythrough", resolve, {once: true}); + video.src = "seek_with_sound.ogg"; + }); + + video.addEventListener("play", captureEventThenCheck); + video.addEventListener("pause", captureEventThenCheck); + video.addEventListener("volumechange", captureEventThenCheck); + video.addEventListener("seeking", captureEventThenCheck); + video.addEventListener("seeked", captureEventThenCheck); + document.addEventListener("mozfullscreenchange", captureEventThenCheck); + document.addEventListener("fullscreenerror", captureEventThenCheck); + + ["mousedown", "mouseup", "dblclick", "click"] + .forEach((eventType) => { + window.addEventListener(eventType, (evt) => { + // Prevent default action of leaked events and make the tests fail. + evt.preventDefault(); + ok(false, "Event " + eventType + " in videocontrol should not leak to content;" + + "the event was dispatched from the " + evt.target.tagName.toLowerCase() + " element."); + }); + }); + + // Check initial state upon load + is(video.paused, true, "checking video play state"); + is(video.muted, false, "checking video mute state"); +}); + +add_task(async function click_playbutton() { + synthesizeMouse(video, playButtonCenterX, playButtonCenterY, {}); + await waitForEvent("play"); + is(video.paused, false, "checking video play state"); + is(video.muted, false, "checking video mute state"); +}); + +add_task(async function click_pausebutton() { + synthesizeMouse(video, playButtonCenterX, playButtonCenterY, {}); + await waitForEvent("pause"); + is(video.paused, true, "checking video play state"); + is(video.muted, false, "checking video mute state"); +}); + +add_task(async function mute_volume() { + synthesizeMouse(video, muteButtonCenterX, muteButtonCenterY, {}); + await waitForEvent("volumechange"); + is(video.paused, true, "checking video play state"); + is(video.muted, true, "checking video mute state"); +}); + +add_task(async function unmute_volume() { + synthesizeMouse(video, muteButtonCenterX, muteButtonCenterY, {}); + await waitForEvent("volumechange"); + is(video.paused, true, "checking video play state"); + is(video.muted, false, "checking video mute state"); +}); + +/* + * Bug 470596: Make sure that having CSS border or padding doesn't + * break the controls (though it should move them) + */ +add_task(async function styled_video() { + video.style.border = "medium solid purple"; + video.style.borderWidth = "30px 40px 50px 60px"; + video.style.padding = "10px 20px 30px 40px"; + // totals: top: 40px, right: 60px, bottom: 80px, left: 100px + + // Click the play button + synthesizeMouse(video, 100 + playButtonCenterX, 40 + playButtonCenterY, { }); + await waitForEvent("play"); + is(video.paused, false, "checking video play state"); + is(video.muted, false, "checking video mute state"); + + // Pause the video + video.pause(); + await waitForEvent("pause"); + is(video.paused, true, "checking video play state"); + is(video.muted, false, "checking video mute state"); + + // Click the mute button + synthesizeMouse(video, 100 + muteButtonCenterX, 40 + muteButtonCenterY, {}); + await waitForEvent("volumechange"); + is(video.paused, true, "checking video play state"); + is(video.muted, true, "checking video mute state"); + + // Clear the style set + video.style.border = ""; + video.style.borderWidth = ""; + video.style.padding = ""; + + // Unmute the video + video.muted = false; + await waitForEvent("volumechange"); + is(video.paused, true, "checking video play state"); + is(video.muted, false, "checking video mute state"); +}); + +/* + * Previous tests have moved playback postion, reset it to 0. + */ +add_task(async function reset_currentTime() { + ok(true, "video position is at " + video.currentTime); + video.currentTime = 0.0; + await waitForEvent(["seeking", "seeked"]); + // Bug 477434 -- sometimes we get 0.098999 here instead of 0! + // is(video.currentTime, 0.0, "checking playback position"); + ok(true, "video position is at " + video.currentTime); +}); + +/* + * Drag the slider's thumb to the halfway point with the mouse. + */ +add_task(async function drag_slider() { + const beginDragX = scrubberOffsetX; + const endDragX = scrubberOffsetX + (scrubberWidth / 2); + const expectedTime = videoDuration / 2; + + function mousemoved(evt) { + ok(false, "Mousemove event should not be handled by content while dragging; " + + "the event was dispatched from the " + evt.target.tagName.toLowerCase() + " element."); + } + + window.addEventListener("mousemove", mousemoved); + + synthesizeMouse(video, beginDragX, scrubberCenterY, {type: "mousedown", button: 0}); + synthesizeMouse(video, endDragX, scrubberCenterY, {type: "mousemove", button: 0}); + synthesizeMouse(video, endDragX, scrubberCenterY, {type: "mouseup", button: 0}); + await waitForEvent(["seeking", "seeked"]); + ok(true, "video position is at " + video.currentTime); + // The width of srubber is not equal in every platform as we use system default font + // in duration and position box. We can not precisely drag to expected position, so + // we just make sure the difference is within 10% of video duration. + ok(Math.abs(video.currentTime - expectedTime) < videoDuration / 10, "checking expected playback position"); + + window.removeEventListener("mousemove", mousemoved); +}); + +/* + * Click the slider at the 1/4 point with the mouse (jump backwards) + */ +add_task(async function click_slider() { + synthesizeMouse(video, scrubberOffsetX + (scrubberWidth / 4), scrubberCenterY, {}); + await waitForEvent(["seeking", "seeked"]); + ok(true, "video position is at " + video.currentTime); + // The scrubber currently just jumps towards the nearest pageIncrement point, not + // precisely to the point clicked. So, expectedTime isn't (videoDuration / 4). + // We should end up at 1.733, but sometimes we end up at 1.498. I guess + // it's timing depending if the <scale> things it's click-and-hold, or a + // single click. So, just make sure we end up less that the previous + // position. + const lastPosition = (videoDuration / 2) - 0.1; + ok(video.currentTime < lastPosition, "checking expected playback position"); + + // Set volume to 0.1 so one down arrow hit will decrease it to 0. + video.volume = 0.1; + await waitForEvent("volumechange"); + is(video.volume, 0.1, "Volume should be set."); + ok(!video.muted, "Video is not muted."); +}); + +// See bug 694696. +add_task(async function change_volume() { + video.focus(); + + synthesizeKey("KEY_ArrowDown"); + await waitForEvent("volumechange"); + is(video.volume, 0, "Volume should be 0."); + ok(!video.muted, "Video is not muted."); + ok(await isMuteButtonMuted(), "Mute button says it's muted"); + + synthesizeKey("KEY_ArrowUp"); + await waitForEvent("volumechange"); + is(video.volume, 0.1, "Volume is increased."); + ok(!video.muted, "Video is not muted."); + ok(!(await isMuteButtonMuted()), "Mute button says it's not muted"); + + synthesizeKey("KEY_ArrowDown"); + await waitForEvent("volumechange"); + is(video.volume, 0, "Volume should be 0."); + ok(!video.muted, "Video is not muted."); + ok(await isMuteButtonMuted(), "Mute button says it's muted"); + + synthesizeMouse(video, muteButtonCenterX, muteButtonCenterY, {}); + await waitForEvent("volumechange"); + is(video.volume, 0.5, "Volume should be 0.5."); + ok(!video.muted, "Video is not muted."); + + synthesizeKey("KEY_ArrowUp"); + await waitForEvent("volumechange"); + is(video.volume, 0.6, "Volume should be 0.6."); + ok(!video.muted, "Video is not muted."); + + synthesizeMouse(video, muteButtonCenterX, muteButtonCenterY, {}); + await waitForEvent("volumechange"); + is(video.volume, 0.6, "Volume should be 0.6."); + ok(video.muted, "Video is muted."); + ok(await isMuteButtonMuted(), "Mute button says it's muted"); + + synthesizeMouse(video, muteButtonCenterX, muteButtonCenterY, {}); + await waitForEvent("volumechange"); + is(video.volume, 0.6, "Volume should be 0.6."); + ok(!video.muted, "Video is not muted."); + ok(!(await isMuteButtonMuted()), "Mute button says it's not muted"); + + await repeatUntilSuccessful(async () => { + synthesizeMouse(video, fullscreenButtonCenterX, fullscreenButtonCenterY, {}); + await waitForEvent("mozfullscreenchange", "fullscreenerror"); + }); + + is(video.volume, 0.6, "Volume should still be 0.6"); + await isVolumeSliderShowingCorrectVolume(video.volume); + + await repeatUntilSuccessful(async () => { + video.focus(); + synthesizeKey("KEY_Escape"); + await waitForEvent("mozfullscreenchange", "fullscreenerror"); + }); + + is(video.volume, 0.6, "Volume should still be 0.6"); + await isVolumeSliderShowingCorrectVolume(video.volume); + forceReframe(); + + video.focus(); + synthesizeKey("KEY_ArrowDown"); + await waitForEvent("volumechange"); + is(video.volume, 0.5, "Volume should be decreased by 0.1"); + await isVolumeSliderShowingCorrectVolume(video.volume); +}); + +add_task(async function whitespace_pause_video() { + synthesizeMouse(video, playButtonCenterX, playButtonCenterY, {}); + await waitForEvent("play"); + + video.focus(); + sendString(" "); + await waitForEvent("pause"); + + synthesizeMouse(video, playButtonCenterX, playButtonCenterY, {}); + await waitForEvent("play"); +}); + +/* + * Bug 1352724: Click and hold on timeline should pause video immediately. + */ +add_task(async function click_and_hold_slider() { + synthesizeMouse(video, scrubberOffsetX + 10, scrubberCenterY, {type: "mousedown", button: 0}); + await waitForEvent(["pause", "seeking", "seeked"]); + + synthesizeMouse(video, scrubberOffsetX + 10, scrubberCenterY, {}); + await waitForEvent("play"); +}); + +/* + * Bug 1402877: Don't let click event dispatch through media controls to video element. + */ +add_task(async function click_event_dispatch() { + const clientScriptClickHandler = (e) => { + ok(false, "Should not receive the event"); + }; + video.addEventListener("click", clientScriptClickHandler); + + video.pause(); + await waitForEvent("pause"); + video.currentTime = 0.0; + await waitForEvent(["seeking", "seeked"]); + is(video.paused, true, "checking video play state"); + synthesizeMouse(video, scrubberOffsetX + 10, scrubberCenterY, {}); + await waitForEvent(["seeking", "seeked"]); + + video.removeEventListener("click", clientScriptClickHandler); +}); + +// Bug 1367194: Always ensure video is paused before finishing the test. +add_task(async function ensure_video_pause() { + if (!video.paused) { + video.pause(); + await waitForEvent("pause"); + } +}); + +// Bug 1452342: Make sure the cursor hides and shows on full screen mode. +add_task(async function ensure_fullscreen_cursor() { + video.removeAttribute("mozNoDynamicControls"); + video.play(); + await waitForEvent("play"); + + await repeatUntilSuccessful(async () => { + video.focus(); + await video.mozRequestFullScreen(); + await waitForEvent("mozfullscreenchange", "fullscreenerror"); + }); + + const controlsSpacer = getElementWithinVideo(video, "controlsSpacer"); + is(controlsSpacer.hasAttribute("hideCursor"), true, "Cursor is hidden"); + + let delta = 1; + await SimpleTest.promiseWaitForCondition(() => { + // Wiggle the mouse a bit + synthesizeMouse(video, playButtonCenterX + delta, playButtonCenterY + delta, { type: "mousemove" }); + delta = !delta; + return !controlsSpacer.hasAttribute("hideCursor"); + }, "Waiting for hideCursor attribute to disappear"); + is(controlsSpacer.hasAttribute("hideCursor"), false, "Cursor is shown"); + + // Restore + video.setAttribute("mozNoDynamicControls", ""); + + await repeatUntilSuccessful(async () => { + await document.mozCancelFullScreen(); + await waitForEvent("mozfullscreenchange", "fullscreenerror"); + }); + + if (!video.paused) { + video.pause(); + await waitForEvent("pause"); + } +}); + +// Bug 1505547: Make sure the fullscreen button works if the video element is in shadow tree. +add_task(async function ensure_fullscreen_button() { + video.removeAttribute("mozNoDynamicControls"); + let host = document.getElementById("host"); + let root = host.attachShadow({ mode: "open" }); + root.appendChild(video); + forceReframe(); + + await repeatUntilSuccessful(async () => { + await video.mozRequestFullScreen(); + await waitForEvent("mozfullscreenchange", "fullscreenerror"); + }); + + await repeatUntilSuccessful(async () => { + // Compute the location to click on to hit the fullscreen button. + // Use the video size instead of the screen size here, because mozfullscreenchange + // does not guarantee that our document covers the screen, see bug 1575630. + const r = video.getBoundingClientRect(); + const buttonCenterX = r.right - fullscreenButtonWidth / 2 - controlBarMargin; + const buttonCenterY = r.bottom - fullscreenButtonHeight / 2; + + // Though the video no longer has mozNoDynamicControls, it sometimes appears + // in the shadow DOM without visible controls. This might happen because + // toggling the attribute doesn't force the controls to appear or disappear; + // it just affects the timed fadeout behavior. So we wiggle the mouse here + // as if we were still using dynamic controls. + synthesizeMouse(video, buttonCenterX, buttonCenterY, { type: "mousemove" }); + + info(`Clicking at ${buttonCenterX}, ${buttonCenterY}.`); + synthesizeMouse(video, buttonCenterX, buttonCenterY, {}); + await waitForEvent("mozfullscreenchange", ["fullscreenerror", "play", "pause"]); + }); + + // Restore + video.setAttribute("mozNoDynamicControls", ""); + document.getElementById("content").appendChild(video); + forceReframe(); +}); + +add_task(async function ensure_doubleclick_triggers_fullscreen() { + const { x, y } = video.getBoundingClientRect(); + info("Simulate double click on media player."); + + await repeatUntilSuccessful(async () => { + synthesizeMouse(video, x, y, { clickCount: 2 }); + // TODO: A double-click for fullscreen should *not* cause the video to play, + // but it does. Adding the "play" event to the forbidden events makes the + // test timeout. + await waitForEvent("mozfullscreenchange", "fullscreenerror"); + }); + + ok(true, "Double clicking should trigger fullscreen event"); + + await repeatUntilSuccessful(async () => { + await document.mozCancelFullScreen(); + await waitForEvent("mozfullscreenchange", "fullscreenerror"); + }); +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_audio.html b/toolkit/content/tests/widgets/test_videocontrols_audio.html new file mode 100644 index 0000000000..ad528f4c27 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_audio.html @@ -0,0 +1,40 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls with Audio file test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video id="video" controls preload="metadata"></video> +</div> + +<pre id="test"> +<script> + + const video = document.getElementById("video"); + function loadedmetadata(event) { + SimpleTest.executeSoon(function() { + const controlBar = SpecialPowers.wrap(video).openOrClosedShadowRoot.querySelector(".controlBar"); + is(controlBar.getAttribute("fullscreen-unavailable"), "true", "Fullscreen button is hidden"); + SimpleTest.finish(); + }); + } + + SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}, startTest); + function startTest() { + // Kick off test once audio has loaded. + video.addEventListener("loadedmetadata", loadedmetadata, { once: true }); + video.src = "audio.ogg"; + } + + SimpleTest.waitForExplicitFinish(); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_audio_direction.html b/toolkit/content/tests/widgets/test_videocontrols_audio_direction.html new file mode 100644 index 0000000000..b656de0103 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_audio_direction.html @@ -0,0 +1,31 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls directionality test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/WindowSnapshot.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +var tests = [ + {op: "==", test: "videocontrols_direction-2a.html", ref: "videocontrols_direction-2-ref.html"}, + {op: "==", test: "videocontrols_direction-2b.html", ref: "videocontrols_direction-2-ref.html"}, + {op: "==", test: "videocontrols_direction-2c.html", ref: "videocontrols_direction-2-ref.html"}, + {op: "==", test: "videocontrols_direction-2d.html", ref: "videocontrols_direction-2-ref.html"}, + {op: "==", test: "videocontrols_direction-2e.html", ref: "videocontrols_direction-2-ref.html"}, +]; + +</script> +<script type="text/javascript" src="videocontrols_direction_test.js"></script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_clickToPlay_ariaLabel.html b/toolkit/content/tests/widgets/test_videocontrols_clickToPlay_ariaLabel.html new file mode 100644 index 0000000000..a787358394 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_clickToPlay_ariaLabel.html @@ -0,0 +1,56 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test - clickToPlayAriaLabel</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<div id="content"> + <video controls preload="auto" width="480" height="320"></video> +</div> + +<pre id="test"> +<script clas="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + const videoElems = [...document.getElementsByTagName("video")]; + const testCases = []; + + function testUI(video) { + const clickToPlay = getElementWithinVideo(video, "clickToPlay"); + ok(!!clickToPlay.getAttribute("aria-label"), "clickToPlay has aria-label attribute"); + }; + + videoElems.forEach(video => { + testCases.push(() => new Promise(resolve => { + SimpleTest.executeSoon(async () => { + const { widget } = SpecialPowers.wrap(window) + .windowGlobalChild.getActor("UAWidgets") + .widgets.get(video); + await widget.impl.Utils.l10n.translateRoots(); + testUI(video); + resolve(); + }); + })); + }); + + function executeTasks(tasks) { + return tasks.reduce((promise, task) => promise.then(task), Promise.resolve()); + } + + function start() { + executeTasks(testCases).then(SimpleTest.finish); + } + + function loadevent() { + SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}, start); + } + + window.addEventListener("load", loadevent); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_closed_caption_menu.html b/toolkit/content/tests/widgets/test_videocontrols_closed_caption_menu.html new file mode 100644 index 0000000000..39d6ff494f --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_closed_caption_menu.html @@ -0,0 +1,144 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test - KeyHandler</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video id="video" controls preload="auto"> + <track + id="track1" + kind="subtitles" + label="[test] en" + srclang="en" + src="test-webvtt-1.vtt" + /> + <track + id="track2" + kind="subtitles" + label="[test] fr" + srclang="fr" + src="test-webvtt-2.vtt" + /> + </video> +</div> + +<pre id="test"> +<script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + const video = document.getElementById("video"); + const closedCaptionButton = getElementWithinVideo(video, "closedCaptionButton"); + const fullscreenButton = getElementWithinVideo(video, "fullscreenButton"); + const textTrackList = getElementWithinVideo(video, "textTrackList"); + const textTrackListContainer = getElementWithinVideo(video, "textTrackListContainer"); + + function isClosedCaptionVisible() { + return !textTrackListContainer.hidden; + } + + // Setup video + tests.push(done => { + SpecialPowers.pushPrefEnv({"set": [ + ["media.cache_size", 40000], + ["media.videocontrols.keyboard-tab-to-all-controls", true], + ]}, done); + }, done => { + video.src = "seek_with_sound.ogg"; + video.addEventListener("loadedmetadata", done); + }, cleanup); + + tests.push(done => { + info("Opening the CC menu should focus the first item in the menu"); + info("Focusing and clicking the closed caption button"); + closedCaptionButton.focus(); + synthesizeKey(" "); + ok(isClosedCaptionVisible(), "The CC menu is visible"); + ok(textTrackList.firstChild.matches(":focus"), "The first item in CC menu should be in focus"); + done(); + }); + + tests.push(done => { + info("aria-expanded should be reflected whether the CC menu is open or not"); + ok(closedCaptionButton.getAttribute("aria-expanded") === "false", "Closed CC menu has aria-expanded set to false"); + info("Focusing and clicking the closed caption button"); + closedCaptionButton.focus(); + synthesizeKey(" "); + ok(isClosedCaptionVisible(), "The CC menu is visible"); + ok(closedCaptionButton.getAttribute("aria-expanded") === "true", "Open CC menu has aria-expanded set to true"); + done(); + }); + + tests.push(done => { + info("If CC menu is open, then arrow keys should navigate menu"); + info("Opening the CC menu"); + closedCaptionButton.focus(); + synthesizeKey(" "); + ok(textTrackList.firstChild.matches(":focus"), "The first item in CC menu should be in focus first"); + info("Pressing down arrow"); + synthesizeKey("KEY_ArrowDown"); + ok(textTrackList.children[1].matches(":focus"), "The second item in CC menu should now be in focus"); + info("Pressing up arrow"); + synthesizeKey("KEY_ArrowUp"); + ok(textTrackList.firstChild.matches(":focus"), "The first item in CC menu should be back in focus again"); + done(); + }); + + tests.push(done => { + info("Escape should close the CC menu"); + info("Opening the CC menu"); + closedCaptionButton.focus(); + synthesizeKey(" "); + info("Pressing Escape key"); + synthesizeKey("KEY_Escape"); + ok(closedCaptionButton.matches(":focus"), "The CC button should be in focus"); + ok(!isClosedCaptionVisible(), "The CC menu should be closed"); + done(); + }); + + tests.push(done => { + info("Tabbing away should close the CC menu"); + info("Opening the CC menu"); + closedCaptionButton.focus(); + synthesizeKey(" "); + info("Pressing Tab key 3x"); + synthesizeKey("KEY_Tab"); + synthesizeKey("KEY_Tab"); + synthesizeKey("KEY_Tab"); + ok(fullscreenButton.matches(":focus"), "The fullscreen button should be in focus"); + ok(!isClosedCaptionVisible(), "The CC menu should be closed"); + done(); + }); + + tests.push(done => { + info("Shift + Tabbing away should close the CC menu"); + info("Opening the CC menu"); + closedCaptionButton.focus(); + synthesizeKey(" "); + info("Pressing Shift + Tab key"); + synthesizeKey("KEY_Tab", { shiftKey: true }); + ok(closedCaptionButton.matches(":focus"), "The CC button should be in focus"); + ok(!isClosedCaptionVisible(), "The CC menu should be closed"); + done(); + }); + + function cleanup(done) { + if (isClosedCaptionVisible()) { + closedCaptionButton.click(); + } + done(); + } + // add cleanup after every test + tests = tests.reduce((a, v) => a.concat([v, cleanup]), []); + + tests.push(SimpleTest.finish); + window.addEventListener("load", executeTests); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_error.html b/toolkit/content/tests/widgets/test_videocontrols_error.html new file mode 100644 index 0000000000..af90a4672a --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_error.html @@ -0,0 +1,60 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test - Error</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video id="video" controls preload="auto"></video> +</div> + +<pre id="test"> +<script clas="testbody" type="application/javascript"> + const video = document.getElementById("video"); + const statusOverlay = getElementWithinVideo(video, "statusOverlay"); + const statusIcon = getElementWithinVideo(video, "statusIcon"); + const statusLabelErrorNoSource = getElementWithinVideo(video, "errorNoSource"); + + add_task(async function setup() { + await SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}); + }); + + add_task(async function check_normal_status() { + await new Promise(resolve => { + video.src = "seek_with_sound.ogg"; + video.addEventListener("loadedmetadata", () => SimpleTest.executeSoon(resolve)); + }); + + // Wait for the fade out transition to complete in case the throbber + // shows up on slower platforms. + await SimpleTest.promiseWaitForCondition(() => statusOverlay.hidden, + "statusOverlay should not present without error"); + + ok(!statusOverlay.hasAttribute("status"), "statusOverlay should not be showing a state message."); + isnot(statusIcon.getAttribute("type"), "error", "should not show error icon"); + }); + + add_task(async function invalid_source() { + const errorType = "errorNoSource"; + + await new Promise(resolve => { + video.src = "invalid_source.ogg"; + video.addEventListener("error", () => SimpleTest.executeSoon(resolve)); + }); + + ok(!statusOverlay.hidden, `statusOverlay should show when ${errorType}`); + is(statusOverlay.getAttribute("status"), errorType, `statusOverlay should have correct error state: ${errorType}`); + is(statusIcon.getAttribute("type"), "error", `should show error icon when ${errorType}`); + isnot(statusLabelErrorNoSource.getBoundingClientRect().height, 0, + "errorNoSource status label should be visible."); + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_focus.html b/toolkit/content/tests/widgets/test_videocontrols_focus.html new file mode 100644 index 0000000000..0982947ffe --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_focus.html @@ -0,0 +1,113 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>Video controls test - Focus</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> +</div> + +<pre id="test"> +<script class="testbody" type="application/javascript"> +const {BrowserTestUtils} = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" +); + +let video, controlBar, playButton; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({"set": [ + ["media.cache_size", 40000], + ["media.videocontrols.keyboard-tab-to-all-controls", true], + ]}); + + // We must create the video after the keyboard-tab-to-all-controls pref is + // set. Otherwise, the tabindex won't be set correctly. + video = document.createElement("video"); + video.id = "video"; + video.controls = true; + video.preload = "auto"; + video.loop = true; + video.src = "video.ogg"; + const caption = video.addTextTrack("captions", "English", "en"); + caption.mode = "showing"; + const content = document.getElementById("content"); + content.append(video); + controlBar = getElementWithinVideo(video, "controlBar"); + playButton = getElementWithinVideo(video, "playButton"); + info("Waiting for video to load"); + // We must wait for loadeddata here, not loadedmetadata, as the first frame + // isn't shown until loadeddata occurs and the controls won't hide until the + // first frame is shown. + await BrowserTestUtils.waitForEvent(video, "loadeddata"); + + // Play and mouseout to hide the controls. + info("Playing video"); + const playing = BrowserTestUtils.waitForEvent(video, "play"); + video.play(); + await playing; + // controlBar.hidden returns true while the animation is happening. We use + // the controlbarchange event to know when it's fully hidden. Aside from + // avoiding waitForCondition, this is necessary to avoid racing with the + // animation. + const hidden = BrowserTestUtils.waitForEvent(video, "controlbarchange"); + sendMouseEvent({type: "mouseout"}, controlBar); + info("Waiting for controls to hide"); + await hidden; +}); + +add_task(async function testShowControlsOnFocus() { + ok(controlBar.hidden, "Controls initially hidden"); + const shown = BrowserTestUtils.waitForEvent(video, "controlbarchange"); + info("Focusing play button"); + playButton.focus(); + await shown; + ok(!controlBar.hidden, "Controls shown after focus"); + await BrowserTestUtils.waitForEvent(video, "controlbarchange"); + ok(controlBar.hidden, "Controls hidden after timeout"); +}); + +add_task(async function testCcMenuStaysVisible() { + ok(controlBar.hidden, "Controls initially hidden"); + const shown = BrowserTestUtils.waitForEvent(video, "controlbarchange"); + info("Focusing CC button"); + const ccButton = getElementWithinVideo(video, "closedCaptionButton"); + ccButton.focus(); + await shown; + ok(!controlBar.hidden, "Controls shown after focus"); + // Checking this using an implementation detail is ugly, but there's no other + // way to do it without fragile timing. + const { widget } = window.windowGlobalChild.getActor("UAWidgets").widgets.get( + video); + ok(widget.impl.Utils._hideControlsTimeout, "Hide timeout set"); + const ttList = getElementWithinVideo(video, "textTrackListContainer"); + ok(ttList.hidden, "Text track list initially hidden"); + + synthesizeKey(" "); + ok(!ttList.hidden, "Text track list shown after space"); + ok( + !widget.impl.Utils._hideControlsTimeout, + "Hide timeout cleared (controls won't hide)" + ); + const ccOff = ttList.querySelector("button"); + ccOff.focus(); + synthesizeKey(" "); + ok(ttList.hidden, "Text track list hidden after activating Off button"); + ok(!controlBar.hidden, "Controls still shown"); + ok(widget.impl.Utils._hideControlsTimeout, "Hide timeout set"); + + await BrowserTestUtils.waitForEvent(video, "controlbarchange"); + ok(controlBar.hidden, "Controls hidden after timeout"); +}); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_iframe_fullscreen.html b/toolkit/content/tests/widgets/test_videocontrols_iframe_fullscreen.html new file mode 100644 index 0000000000..0a74b25609 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_iframe_fullscreen.html @@ -0,0 +1,60 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test - iframe</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> +<iframe id="ifr1"></iframe> +<iframe id="ifr2" allowfullscreen></iframe> +<iframe id="ifr1" allow="fullscreen 'none'"></iframe> +</div> + +<pre id="test"> +<script clas="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + const testCases = []; + + function checkIframeFullscreenAvailable(ifr) { + let video; + + return () => new Promise(resolve => { + ifr.srcdoc = `<video id="video" controls preload="auto"></video>`; + ifr.addEventListener("load", resolve); + }).then(() => new Promise(resolve => { + video = ifr.contentDocument.getElementById("video"); + video.src = "seek_with_sound.ogg"; + video.addEventListener("loadedmetadata", resolve); + })).then(() => new Promise(resolve => { + const available = video.ownerDocument.fullscreenEnabled; + const controlBar = getElementWithinVideo(video, "controlBar"); + + is(controlBar.getAttribute("fullscreen-unavailable") == "true", !available, "The controlbar should have an attribute marking whether fullscreen is available that corresponds to if the iframe has the allowfullscreen attribute."); + resolve(); + })); + } + + function start() { + testCases.reduce((promise, task) => promise.then(task), Promise.resolve()); + } + + function load() { + SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}, start); + } + + for (let iframe of document.querySelectorAll("iframe")) + testCases.push(checkIframeFullscreenAvailable(iframe)); + testCases.push(SimpleTest.finish); + + window.addEventListener("load", load); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_jsdisabled.html b/toolkit/content/tests/widgets/test_videocontrols_jsdisabled.html new file mode 100644 index 0000000000..f3fdecc47f --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_jsdisabled.html @@ -0,0 +1,69 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +function runTest(event) { + info(true, "----- test #" + testnum + " -----"); + + switch (testnum) { + case 1: + is(event.type, "timeupdate", "checking event type"); + is(video.paused, false, "checking video play state"); + video.removeEventListener("timeupdate", runTest); + + // Click to toggle play/pause (now pausing) + synthesizeMouseAtCenter(video, {}, win); + break; + + case 2: + is(event.type, "pause", "checking event type"); + is(video.paused, true, "checking video play state"); + win.close(); + + SimpleTest.finish(); + break; + + default: + ok(false, "unexpected test #" + testnum + " w/ event " + event.type); + throw new Error(`unexpected test #${testnum} w/ event ${event.type}`); + } + + testnum++; +} + +SpecialPowers.pushPrefEnv({"set": [["javascript.enabled", false]]}, startTest); + +var testnum = 1; + +var video; +function loadevent(event) { + is(win.testExpando, undefined, "expando shouldn't exist because js is disabled"); + video = win.document.querySelector("video"); + // Other events expected by the test. + video.addEventListener("timeupdate", runTest); + video.addEventListener("pause", runTest); +} + +var win; +function startTest() { + const TEST_FILE = location.href.replace("test_videocontrols_jsdisabled.html", + "file_videocontrols_jsdisabled.html"); + win = window.open(TEST_FILE); + win.addEventListener("load", loadevent); +} + +SimpleTest.waitForExplicitFinish(); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_keyhandler.html b/toolkit/content/tests/widgets/test_videocontrols_keyhandler.html new file mode 100644 index 0000000000..5b771fc745 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_keyhandler.html @@ -0,0 +1,150 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test - KeyHandler</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video id="video" controls preload="auto"></video> +</div> + +<pre id="test"> +<script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + const video = document.getElementById("video"); + + const playButton = getElementWithinVideo(video, "playButton"); + const scrubber = getElementWithinVideo(video, "scrubber"); + const volumeControl = getElementWithinVideo(video, "volumeControl"); + const muteButton = getElementWithinVideo(video, "muteButton"); + + // Setup video + tests.push(done => { + SpecialPowers.pushPrefEnv({"set": [ + ["media.cache_size", 40000], + ["media.videocontrols.keyboard-tab-to-all-controls", true], + ]}, done); + }, done => { + video.src = "seek_with_sound.ogg"; + video.addEventListener("loadedmetadata", done); + }); + + // Bug 1350191, video should not seek while changing volume by + // pressing up/down arrow key after clicking the scrubber. + tests.push(done => { + video.addEventListener("play", done, { once: true }); + synthesizeMouseAtCenter(playButton, {}); + }, done => { + video.addEventListener("seeked", done, { once: true }); + synthesizeMouseAtCenter(scrubber, {}); + }, done => { + let counter = 0; + let keys = ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowUp", "KEY_ArrowDown", "KEY_ArrowUp", "KEY_ArrowUp"]; + + const onSeeked = () => ok(false, "should not trigger seeked event"); + video.addEventListener("seeked", onSeeked); + const onVolumeChange = () => { + if (++counter === keys.length) { + ok(true, "change volume by up/down arrow key without trigger 'seeked' event"); + video.removeEventListener("seeked", onSeeked); + video.removeEventListener("volumechange", onVolumeChange); + done(); + } + + if (counter > keys.length) { + ok(false, "trigger too much volumechange events"); + } + }; + video.addEventListener("volumechange", onVolumeChange); + + for (let key of keys) { + synthesizeKey(key); + } + }); + + // However, if the scrubber is *focused* (e.g. by keyboard), it should handle + // up/down arrow keys. + tests.push(done => { + info("Focusing the scrubber"); + scrubber.focus(); + video.addEventListener("seeked", () => { + ok(true, "DownArrow seeked the video"); + done(); + }, { once: true }); + synthesizeKey("KEY_ArrowDown"); + }, done => { + video.addEventListener("seeked", () => { + ok(true, "UpArrow seeked the video"); + done(); + }, { once: true }); + synthesizeKey("KEY_ArrowUp"); + }); + + // Similarly, if the volume control is focused, left/right arrows should + // adjust the volume. + tests.push(done => { + info("Focusing the volume control"); + volumeControl.focus(); + video.addEventListener("volumechange", () => { + ok(true, "LeftArrow changed the volume"); + done(); + }, { once: true }); + synthesizeKey("KEY_ArrowLeft"); + }, done => { + video.addEventListener("volumechange", () => { + ok(true, "RightArrow changed the volume"); + done(); + }, { once: true }); + synthesizeKey("KEY_ArrowRight"); + }); + + // If something other than a button has focus, space should pause/play. + tests.push(done => { + ok(volumeControl.matches(":focus"), "Volume control still has focus"); + video.addEventListener("pause", () => { + ok(true, "Space paused the video"); + done(); + }, {once: true}); + synthesizeKey(" "); + }, done => { + video.addEventListener("play", () => { + ok(true, "Space played the video"); + done(); + }, {once: true}); + synthesizeKey(" "); + }); + + // If a button has focus, space should activate it, *not* pause/play. + tests.push(done => { + info("Focusing the mute button"); + muteButton.focus(); + const onPause = () => ok(false, "Shouldn't pause the video"); + video.addEventListener("pause", onPause); + let volChanges = 0; + const onVolChange = () => { + if (++volChanges == 2) { + ok(true, "Space twice muted then unmuted the video"); + video.removeEventListener("pause", onPause); + video.removeEventListener("volumechange", onVolChange); + done(); + } + }; + video.addEventListener("volumechange", onVolChange); + // Press space twice. The first time should mute, the second should unmute. + synthesizeKey(" "); + synthesizeKey(" "); + }); + + tests.push(SimpleTest.finish); + + window.addEventListener("load", executeTests); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_onclickplay.html b/toolkit/content/tests/widgets/test_videocontrols_onclickplay.html new file mode 100644 index 0000000000..9023512ab7 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_onclickplay.html @@ -0,0 +1,74 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video id="video" controls mozNoDynamicControls preload="auto"></video> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +SimpleTest.waitForExplicitFinish(); +var video = document.getElementById("video"); + +function startMediaLoad() { + // Kick off test once video has loaded, in its canplaythrough event handler. + video.src = "seek_with_sound.ogg"; + video.addEventListener("canplaythrough", runTest); +} + +function loadevent(event) { + SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}, startMediaLoad); +} + +window.addEventListener("load", loadevent); + +function runTest() { + video.addEventListener("click", function() { + this.play(); + }); + ok(video.paused, "video should be paused initially"); + + new Promise(resolve => { + let timeupdates = 0; + video.addEventListener("timeupdate", function timeupdate() { + ok(!video.paused, "video should not get paused after clicking in middle"); + + if (++timeupdates == 3) { + video.removeEventListener("timeupdate", timeupdate); + resolve(); + } + }); + + synthesizeMouseAtCenter(video, {}, window); + }).then(function() { + new Promise(resolve => { + video.addEventListener("pause", function onpause() { + setTimeout(() => { + ok(video.paused, "video should still be paused 200ms after pause request"); + // When the video reaches the end of playback it is automatically paused. + // Check during the pause event that the video has not reachd the end + // of playback. + ok(!video.ended, "video should not have paused due to playback ending"); + resolve(); + }, 200); + }); + + synthesizeMouse(video, 10, video.clientHeight - 10, {}, window); + }).then(SimpleTest.finish); + }); +} + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_scrubber_position.html b/toolkit/content/tests/widgets/test_videocontrols_scrubber_position.html new file mode 100644 index 0000000000..b1d2ab9e74 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_scrubber_position.html @@ -0,0 +1,51 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + - https://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>Video controls test - Initial scrubber position</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video width="320" height="240" id="video" mozNoDynamicControls preload="auto"></video> +</div> + +<div id="host"></div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +const video = document.getElementById("video"); + +add_task(async function setup() { + await new Promise(resolve => { + video.addEventListener("canplaythrough", resolve, {once: true}); + video.src = "seek_with_sound.ogg"; + }); + + // Check initial state upon load + is(video.paused, true, "checking video play state"); +}); + +add_task(function test_initial_scrubber_position() { + // When the controls are shown after the initial video frame, + // reflowedDimensions might not be set... + video.setAttribute("controls", "true"); + + // ... but we still want to ensure the initial scrubber position + // is reasonable. + const scrubber = getElementWithinVideo(video, "scrubber"); + ok(scrubber.max, "The max value should be set on the scrubber"); + is(parseInt(scrubber.value), 0, "The initial position should be 0"); +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_scrubber_position_nopreload.html b/toolkit/content/tests/widgets/test_videocontrols_scrubber_position_nopreload.html new file mode 100644 index 0000000000..9fbb6fbcb5 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_scrubber_position_nopreload.html @@ -0,0 +1,123 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + - https://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <title>Video controls test - Initial scrubber position when preload is turned off</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video width="320" height="240" id="video" mozNoDynamicControls controls="true" preload="none" src="seek_with_sound.ogg"></video> +</div> + +<div id="host"></div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +const video = document.getElementById("video"); + +add_task(function test_initial_scrubber_position() { + // Check initial state upon load + is(video.paused, true, "checking video play state"); + + const scrubber = getElementWithinVideo(video, "scrubber"); + ok(scrubber.max, "The max value should be set on the scrubber"); + is(parseInt(scrubber.value), 0, "The initial position should be 0"); +}); + +add_task(async function test_scrubber_after_manual_move() { + // Kick-start the video before trying to change the scrubber. + let loadedPromise = video.readyState == video.HAVE_ENOUGH_DATA ? + Promise.resolve() : + new Promise(r => { + video.addEventListener("canplaythrough", r, {once: true}); + }); + video.play(); + await loadedPromise; + video.pause(); + const scrubber = getElementWithinVideo(video, "scrubber"); + // Click the middle of the scrubber: + synthesizeMouseAtCenter(scrubber, {}); + // Expect that the progress updates, too: + + const progress = getElementWithinVideo(video, "progressBar"); + is( + // toFixed(2) takes care of rounding issues here. + (progress.value / progress.max).toFixed(2), + (scrubber.value / scrubber.max).toFixed(2), + "Should have updated progress bar." + ); +}); + +add_task(async function test_progress_and_scrubber_once_fullscreened() { + // loop to ensure we can always get 4 timeupdate events. + video.loop = true; + video.currentTime = video.duration / 2; + info("Setting max width"); + video.style.maxWidth = "200px"; + info( + "Current video progress = " + + (video.currentTime / video.duration).toFixed(2) + ); + // Wait for a flush so the scrubber has been hidden. + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + info("Hid progress and scrubber."); + // Then full screen. + await SpecialPowers.wrap(video).requestFullscreen(); + info("Gone into fullscreen."); + // Then wait for the video to play a bit (4 events is pretty arbitrary) + let updateCounter = 4; + let playABitPromise = new Promise(resolve => { + let handler = () => { + info("timeupdate event, counter left: " + updateCounter); + if (--updateCounter <= 0) { + video.removeEventListener("timeupdate", handler); + video.addEventListener("pause", resolve, { once: true }); + video.pause(); + } + }; + video.addEventListener("timeupdate", handler); + }); + video.play(); + await playABitPromise; + + const scrubber = getElementWithinVideo(video, "scrubber"); + let videoProgress = video.currentTime / video.duration; + let fuzzFactor = video.duration * 0.01; + info("Video progress: " + videoProgress.toFixed(3)); + let scrubberProgress = scrubber.value / scrubber.max; + info("Scrubber : " + scrubberProgress.toFixed(3)); + ok( + scrubberProgress <= videoProgress + fuzzFactor, + "Scrubber should match actual video point in time." + ); + ok( + scrubberProgress >= videoProgress - fuzzFactor, + "Scrubber should match actual video point in time." + ); + // Expect that the progress matches the scrubber: + const progress = getElementWithinVideo(video, "progressBar"); + let progressProgress = progress.value / progress.max; + info("Progress bar : " + progressProgress.toFixed(3)); + ok( + progressProgress <= videoProgress + fuzzFactor, + "Progress bar should match actual video point in time." + ); + ok( + progressProgress >= videoProgress - fuzzFactor, + "Progress bar should match actual video point in time." + ); + await SpecialPowers.wrap(document).exitFullscreen(); +}); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_size.html b/toolkit/content/tests/widgets/test_videocontrols_size.html new file mode 100644 index 0000000000..559cc66e86 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_size.html @@ -0,0 +1,179 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test - Size</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video controls preload="auto" width="480" height="320"></video> + <video controls preload="auto" width="320" height="320"></video> + <video controls preload="auto" width="280" height="320"></video> + <video controls preload="auto" width="240" height="320"></video> + <video controls preload="auto" width="180" height="320"></video> + <video controls preload="auto" width="120" height="320"></video> + <video controls preload="auto" width="60" height="320"></video> + <video controls preload="auto" width="48" height="320"></video> + <video controls preload="auto" width="20" height="320"></video> + + <video controls preload="auto" width="480" height="240"></video> + <video controls preload="auto" width="480" height="120"></video> + <video controls preload="auto" width="480" height="39"></video> +</div> + +<pre id="test"> +<script clas="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + const videoElems = [...document.getElementsByTagName("video")]; + const testCases = []; + + const isTouchControl = navigator.appVersion.includes("Android"); + + const buttonWidth = isTouchControl ? 40 : 30; + const minSrubberWidth = isTouchControl ? 64 : 48; + const minControlBarHeight = isTouchControl ? 52 : 40; + const minControlBarWidth = isTouchControl ? 58 : 48; + const minClickToPlaySize = isTouchControl ? 64 : 48; + + function getElementName(elem) { + return elem.getAttribute("anonid") || elem.getAttribute("class"); + } + + function testButton(btn) { + if (btn.hidden) return; + + const rect = btn.getBoundingClientRect(); + const name = getElementName(btn); + + is(rect.width, buttonWidth, `${name} should have correct width`); + is(rect.height, minControlBarHeight, `${name} should have correct height`); + } + + function testScrubber(scrubber) { + if (scrubber.hidden) return; + + const rect = scrubber.getBoundingClientRect(); + const name = getElementName(scrubber); + + ok(rect.width >= minSrubberWidth, `${name} should longer than ${minSrubberWidth}`); + } + + function testUI(video) { + video.style.display = "block"; + video.getBoundingClientRect(); + video.style.display = ""; + + const videoRect = video.getBoundingClientRect(); + + const videoHeight = video.clientHeight; + const videoWidth = video.clientWidth; + + const videoSizeMsg = `size:${videoRect.width}x${videoRect.height} -`; + const controlBar = getElementWithinVideo(video, "controlBar"); + const playBtn = getElementWithinVideo(video, "playButton"); + const scrubber = getElementWithinVideo(video, "scrubberStack"); + const positionDurationBox = getElementWithinVideo(video, "positionDurationBox"); + const durationLabel = positionDurationBox.getElementsByTagName("span")[0]; + const muteBtn = getElementWithinVideo(video, "muteButton"); + const volumeStack = getElementWithinVideo(video, "volumeStack"); + const fullscreenBtn = getElementWithinVideo(video, "fullscreenButton"); + const clickToPlay = getElementWithinVideo(video, "clickToPlay"); + + + // Controls should show/hide according to the priority + const prioritizedControls = [ + playBtn, + muteBtn, + fullscreenBtn, + positionDurationBox, + scrubber, + durationLabel, + volumeStack, + ]; + + let stopAppend = false; + prioritizedControls.forEach(control => { + is(control.hidden, stopAppend = stopAppend || control.hidden, + `${videoSizeMsg} ${getElementName(control)} should ${stopAppend ? "hide" : "show"}`); + }); + + + // All controls should fit in control bar container + const controls = [ + playBtn, + scrubber, + positionDurationBox, + muteBtn, + volumeStack, + fullscreenBtn, + ]; + + let widthSum = 0; + controls.forEach(control => { + widthSum += control.clientWidth; + }); + ok(videoWidth >= widthSum, + `${videoSizeMsg} controlBar fit in video's width`); + + + // Control bar should show/hide according to video's dimensions + const shouldHideControlBar = videoHeight <= minControlBarHeight || + videoWidth < minControlBarWidth; + is(controlBar.hidden, shouldHideControlBar, `${videoSizeMsg} controlBar show/hide`); + + if (!shouldHideControlBar) { + is(controlBar.clientWidth, videoWidth, `control bar width should equal to video width`); + + // Check all controls' dimensions + testButton(playBtn); + testButton(muteBtn); + testButton(fullscreenBtn); + testScrubber(scrubber); + testScrubber(volumeStack); + } + + + // ClickToPlay button should show if min size can fit in + const shouldHideClickToPlay = videoWidth <= minClickToPlaySize || + (videoHeight - minClickToPlaySize) / 2 <= minControlBarHeight; + is(clickToPlay.hidden, shouldHideClickToPlay, `${videoSizeMsg} clickToPlay show/hide`); + } + + + testCases.push(() => Promise.all(videoElems.map(video => new Promise(resolve => { + video.addEventListener("loadedmetadata", resolve); + video.src = "seek_with_sound.ogg"; + })))); + + videoElems.forEach(video => { + testCases.push(() => new Promise(resolve => { + SimpleTest.executeSoon(() => { + testUI(video); + resolve(); + }); + })); + }); + + function executeTasks(tasks) { + return tasks.reduce((promise, task) => promise.then(task), Promise.resolve()); + } + + function start() { + executeTasks(testCases).then(SimpleTest.finish); + } + + function loadevent() { + SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}, start); + } + + window.addEventListener("load", loadevent); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_standalone.html b/toolkit/content/tests/widgets/test_videocontrols_standalone.html new file mode 100644 index 0000000000..14208923dd --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_standalone.html @@ -0,0 +1,131 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/NativeKeyCodes.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + SimpleTest.expectAssertions(0, 1); + +const videoWidth = 320; +const videoHeight = 240; + +function getMediaElement(aWindow) { + return aWindow.document.getElementsByTagName("video")[0]; +} + +var popup = window.open("seek_with_sound.ogg"); +popup.addEventListener("load", function() { + var video = getMediaElement(popup); + + is(popup.document.activeElement, video, "Document should load with focus moved to the video element."); + + if (!video.paused) { + runTestVideo(video); + } else { + video.addEventListener("play", function() { + runTestVideo(video); + }, {once: true}); + } +}, {once: true}); + +function runTestVideo(aVideo) { + var condition = function() { + var boundingRect = aVideo.getBoundingClientRect(); + return boundingRect.width == videoWidth && + boundingRect.height == videoHeight; + }; + waitForCondition(condition, function() { + var boundingRect = aVideo.getBoundingClientRect(); + is(boundingRect.width, videoWidth, "Width of the video should match expectation"); + is(boundingRect.height, videoHeight, "Height of video should match expectation"); + popup.close(); + runTestAudioPre(); + }, "The media element should eventually be resized to match the intrinsic size of the video."); +} + +function runTestAudioPre() { + popup = window.open("audio.ogg"); + popup.addEventListener("load", function() { + var audio = getMediaElement(popup); + + is(popup.document.activeElement, audio, "Document should load with focus moved to the video element."); + + if (!audio.paused) { + runTestAudio(audio); + } else { + audio.addEventListener("play", function() { + runTestAudio(audio); + }, {once: true}); + } + }, {once: true}); +} + +function runTestAudio(aAudio) { + info("User agent (help diagnose bug #943556): " + navigator.userAgent); + var isAndroid = navigator.userAgent.includes("Android"); + var expectedHeight = isAndroid ? 103 : 40; + var condition = function() { + var boundingRect = aAudio.getBoundingClientRect(); + return boundingRect.height == expectedHeight; + }; + waitForCondition(condition, function() { + var boundingRect = aAudio.getBoundingClientRect(); + is(boundingRect.height, expectedHeight, + "Height of audio element should be " + expectedHeight + ", which is equal to the controls bar."); + ok(!aAudio.paused, "Should be playing"); + testPauseByKeyboard(aAudio); + }, "The media element should eventually be resized to match the height of the audio controls."); +} + +function testPauseByKeyboard(aAudio) { + aAudio.addEventListener("pause", function() { + afterKeyPause(aAudio); + }, {once: true}); + // Press spacebar, which means play/pause. + synthesizeKey(" ", {}, popup); +} + +function afterKeyPause(aAudio) { + ok(true, "successfully caused audio to pause"); + waitForCondition(function() { + return aAudio.paused; + }, + function() { + // Click outside of the controls area. (Hopefully this has no effect.) + synthesizeMouseAtPoint(5, 5, { type: 'mousedown' }, popup); + synthesizeMouseAtPoint(5, 5, { type: 'mouseup' }, popup); + setTimeout(function() { + testPlayByKeyboard(aAudio); + }, 0); + }); +} + +function testPlayByKeyboard(aAudio) { + aAudio.addEventListener("play", function() { + ok(true, "successfully caused audio to play"); + finishAudio(); + }, {once: true}); + // Press spacebar, which means play/pause. + synthesizeKey(" ", {}, popup); +} + +function finishAudio() { + popup.close(); + SimpleTest.finish(); +} + +SimpleTest.waitForExplicitFinish(); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_video_direction.html b/toolkit/content/tests/widgets/test_videocontrols_video_direction.html new file mode 100644 index 0000000000..45cf7f6363 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_video_direction.html @@ -0,0 +1,31 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls directionality test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/WindowSnapshot.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> +</div> + +<pre id="test"> +<script class="testbody" type="text/javascript"> + +var tests = [ + {op: "==", test: "videocontrols_direction-1a.html", ref: "videocontrols_direction-1-ref.html"}, + {op: "==", test: "videocontrols_direction-1b.html", ref: "videocontrols_direction-1-ref.html"}, + {op: "==", test: "videocontrols_direction-1c.html", ref: "videocontrols_direction-1-ref.html"}, + {op: "==", test: "videocontrols_direction-1d.html", ref: "videocontrols_direction-1-ref.html"}, + {op: "==", test: "videocontrols_direction-1e.html", ref: "videocontrols_direction-1-ref.html"}, +]; + +</script> +<script type="text/javascript" src="videocontrols_direction_test.js"></script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_video_noaudio.html b/toolkit/content/tests/widgets/test_videocontrols_video_noaudio.html new file mode 100644 index 0000000000..bfc8018466 --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_video_noaudio.html @@ -0,0 +1,42 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video id="video" controls preload="auto"></video> +</div> + +<pre id="test"> +<script clas="testbody" type="application/javascript"> + const video = document.getElementById("video"); + const muteButton = getElementWithinVideo(video, "muteButton"); + const volumeStack = getElementWithinVideo(video, "volumeStack"); + + add_task(async function setup() { + await SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}); + await new Promise(resolve => { + video.src = "video.ogg"; + video.addEventListener("loadedmetadata", () => SimpleTest.executeSoon(resolve)); + }); + }); + + add_task(async function mute_button_icon() { + is(muteButton.getAttribute("noAudio"), "true"); + ok(muteButton.hasAttribute("disabled"), "Mute button should be disabled"); + + if (volumeStack) { + ok(volumeStack.hidden); + } + }); +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/test_videocontrols_vtt.html b/toolkit/content/tests/widgets/test_videocontrols_vtt.html new file mode 100644 index 0000000000..2f8d70f35a --- /dev/null +++ b/toolkit/content/tests/widgets/test_videocontrols_vtt.html @@ -0,0 +1,112 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Video controls test - VTT</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <video id="video" controls preload="auto"></video> +</div> + +<pre id="test"> +<script clas="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + const video = document.getElementById("video"); + const ccBtn = getElementWithinVideo(video, "closedCaptionButton"); + const ttList = getElementWithinVideo(video, "textTrackList"); + const ttListContainer = getElementWithinVideo(video, "textTrackListContainer"); + + add_task(async function wait_for_media_ready() { + await SpecialPowers.pushPrefEnv({"set": [["media.cache_size", 40000]]}); + await new Promise(resolve => { + video.src = "seek_with_sound.ogg"; + video.addEventListener("loadedmetadata", resolve); + }); + }); + + add_task(async function check_inital_state() { + ok(ccBtn.hidden, "CC button should hide"); + }); + + add_task(async function check_unsupported_type_added() { + video.addTextTrack("descriptions", "English", "en"); + video.addTextTrack("chapters", "English", "en"); + video.addTextTrack("metadata", "English", "en"); + + await new Promise(SimpleTest.executeSoon); + ok(ccBtn.hidden, "CC button should hide if no supported tracks provided"); + }); + + add_task(async function check_cc_button_present() { + const sub = video.addTextTrack("subtitles", "English", "en"); + sub.mode = "disabled"; + + await new Promise(SimpleTest.executeSoon); + ok(!ccBtn.hidden, "CC button should show"); + is(ccBtn.hasAttribute("enabled"), false, "CC button should be disabled"); + }); + + add_task(async function check_cc_button_be_enabled() { + const subtitle = video.addTextTrack("subtitles", "English", "en"); + subtitle.mode = "showing"; + + await new Promise(SimpleTest.executeSoon); + ok(ccBtn.hasAttribute("enabled"), "CC button should be enabled"); + subtitle.mode = "disabled"; + }); + + add_task(async function check_cpations_type() { + const caption = video.addTextTrack("captions", "English", "en"); + caption.mode = "showing"; + + await new Promise(SimpleTest.executeSoon); + ok(ccBtn.hasAttribute("enabled"), "CC button should be enabled"); + }); + + add_task(async function check_track_ui_state() { + synthesizeMouseAtCenter(ccBtn, {}); + + await new Promise(SimpleTest.executeSoon); + ok(!ttListContainer.hidden, "Texttrack menu should show up"); + ok(ttList.lastChild.getAttribute("aria-checked") === "true", "The last added item should be highlighted"); + }); + + add_task(async function check_select_texttrack() { + const tt = ttList.children[1]; + + ok(tt.getAttribute("aria-checked") === "false", "Item should be off before click"); + synthesizeMouseAtCenter(tt, {}); + + await once(video.textTracks, "change"); + await new Promise(SimpleTest.executeSoon); + ok(tt.getAttribute("aria-checked") === "true", "Selected item should be enabled"); + ok(ttListContainer.hidden, "Should hide texttrack menu once clicked on an item"); + }); + + add_task(async function check_change_texttrack_mode() { + const tts = [...video.textTracks]; + + tts.forEach(tt => tt.mode = "hidden"); + await once(video.textTracks, "change"); + await new Promise(SimpleTest.executeSoon); + ok(!ccBtn.hasAttribute("enabled"), "CC button should be disabled"); + + // enable the last text track. + tts[tts.length - 1].mode = "showing"; + await once(video.textTracks, "change"); + await new Promise(SimpleTest.executeSoon); + ok(ccBtn.hasAttribute("enabled"), "CC button should be enabled"); + ok(ttList.lastChild.getAttribute("aria-checked") === "true", "The last item should be highlighted"); + }); + +</script> +</pre> +</body> +</html> diff --git a/toolkit/content/tests/widgets/tree_shared.js b/toolkit/content/tests/widgets/tree_shared.js new file mode 100644 index 0000000000..ba52bf828e --- /dev/null +++ b/toolkit/content/tests/widgets/tree_shared.js @@ -0,0 +1,2184 @@ +// This file expects the following globals to be defined at various times. +/* globals getCustomTreeViewCellInfo */ + +// This files relies on these specific Chrome/XBL globals +/* globals TreeColumns, TreeColumn */ + +var columns_simpletree = [ + { name: "name", label: "Name", key: true, properties: "one two" }, + { name: "address", label: "Address" }, +]; + +var columns_hiertree = [ + { + name: "name", + label: "Name", + primary: true, + key: true, + properties: "one two", + }, + { name: "address", label: "Address" }, + { name: "planet", label: "Planet" }, + { name: "gender", label: "Gender", cycler: true }, +]; + +// XXXndeakin still to add some tests for: +// cycler columns, checkbox cells + +// this test function expects a tree to have 8 rows in it when it isn't +// expanded. The tree should only display four rows at a time. If editable, +// the cell at row 1 and column 0 must be editable, and the cell at row 2 and +// column 1 must not be editable. +async function testtag_tree( + treeid, + treerowinfoid, + seltype, + columnstype, + testid +) { + // Stop keystrokes that aren't handled by the tree from leaking out and + // scrolling the main Mochitests window! + function preventDefault(event) { + event.preventDefault(); + } + document.addEventListener("keypress", preventDefault); + + var multiple = seltype == "multiple"; + + var tree = document.getElementById(treeid); + var treerowinfo = document.getElementById(treerowinfoid); + var rowInfo; + if (testid == "tree view") { + rowInfo = getCustomTreeViewCellInfo(); + } else { + rowInfo = convertDOMtoTreeRowInfo(treerowinfo, 0, { value: -1 }); + } + var columnInfo = + columnstype == "simple" ? columns_simpletree : columns_hiertree; + + is(tree.selType, seltype == "multiple" ? "" : seltype, testid + " seltype"); + + // note: the functions below should be in this order due to changes made in later tests + + await testtag_tree_treecolpicker(tree, columnInfo, testid); + testtag_tree_columns(tree, columnInfo, testid); + testtag_tree_TreeSelection(tree, testid, multiple); + testtag_tree_TreeSelection_UI(tree, testid, multiple); + testtag_tree_TreeView(tree, testid, rowInfo); + + is(tree.editable, false, "tree should not be editable"); + // currently, the editable flag means that tree editing cannot be invoked + // by the user. However, editing can still be started with a script. + is(tree.editingRow, -1, testid + " initial editingRow"); + is(tree.editingColumn, null, testid + " initial editingColumn"); + + testtag_tree_UI_editing(tree, testid, rowInfo); + + is( + tree.editable, + false, + "tree should not be editable after testtag_tree_UI_editing" + ); + // currently, the editable flag means that tree editing cannot be invoked + // by the user. However, editing can still be started with a script. + is(tree.editingRow, -1, testid + " initial editingRow (continued)"); + is(tree.editingColumn, null, testid + " initial editingColumn (continued)"); + + var ecolumn = tree.columns[0]; + ok( + !tree.startEditing(1, ecolumn), + "non-editable trees shouldn't start editing" + ); + is( + tree.editingRow, + -1, + testid + " failed startEditing shouldn't set editingRow" + ); + is( + tree.editingColumn, + null, + testid + " failed startEditing shouldn't set editingColumn" + ); + + tree.editable = true; + + ok(tree.startEditing(1, ecolumn), "startEditing should have returned true"); + is(tree.editingRow, 1, testid + " startEditing editingRow"); + is(tree.editingColumn, ecolumn, testid + " startEditing editingColumn"); + is( + tree.getAttribute("editing"), + "true", + testid + " startEditing editing attribute" + ); + + tree.stopEditing(true); + is(tree.editingRow, -1, testid + " stopEditing editingRow"); + is(tree.editingColumn, null, testid + " stopEditing editingColumn"); + is( + tree.hasAttribute("editing"), + false, + testid + " stopEditing editing attribute" + ); + + tree.startEditing(-1, ecolumn); + is( + tree.editingRow == -1 && tree.editingColumn == null, + true, + testid + " startEditing -1 editingRow" + ); + tree.startEditing(15, ecolumn); + is( + tree.editingRow == -1 && tree.editingColumn == null, + true, + testid + " startEditing 15 editingRow" + ); + tree.startEditing(1, null); + is( + tree.editingRow == -1 && tree.editingColumn == null, + true, + testid + " startEditing null column editingRow" + ); + tree.startEditing(2, tree.columns[1]); + is( + tree.editingRow == -1 && tree.editingColumn == null, + true, + testid + " startEditing non editable cell editingRow" + ); + + tree.startEditing(1, ecolumn); + var inputField = tree.inputField; + is(inputField.localName, "input", testid + "inputField"); + inputField.value = "Changed Value"; + tree.stopEditing(true); + is( + tree.view.getCellText(1, ecolumn), + "Changed Value", + testid + "edit cell accept" + ); + + // this cell can be edited, but stopEditing(false) means don't accept the change. + tree.startEditing(1, ecolumn); + inputField.value = "Second Value"; + tree.stopEditing(false); + is( + tree.view.getCellText(1, ecolumn), + "Changed Value", + testid + "edit cell no accept" + ); + + tree.editable = false; + + // do the sorting tests last as it will cause the rows to rearrange + // skip them for the custom tree view + if (testid != "tree view") { + testtag_tree_TreeView_rows_sort(tree, testid, rowInfo); + } + + testtag_tree_wheel(tree); + + document.removeEventListener("keypress", preventDefault); + + SimpleTest.finish(); +} + +async function testtag_tree_treecolpicker(tree, expectedColumns, testid) { + testid += " "; + + async function showAndHideTreecolpicker() { + let treecolpicker = tree.querySelector("treecolpicker"); + let treecolpickerMenupopup = treecolpicker.querySelector("menupopup"); + await new Promise(resolve => { + treecolpickerMenupopup.addEventListener("popupshown", resolve, { + once: true, + }); + treecolpicker.querySelector("button").click(); + }); + let menuitems = treecolpicker.querySelectorAll("menuitem"); + // Ignore the last "Restore Column Order" menu in the count: + is( + menuitems.length - 1, + expectedColumns.length, + testid + "Same number of columns" + ); + for (var c = 0; c < expectedColumns.length; c++) { + is( + menuitems[c].textContent, + expectedColumns[c].label, + testid + "treecolpicker menu matches" + ); + ok( + !menuitems[c].querySelector("label").hidden, + testid + "label not hidden" + ); + } + await new Promise(resolve => { + treecolpickerMenupopup.addEventListener("popuphidden", resolve, { + once: true, + }); + treecolpickerMenupopup.hidePopup(); + }); + } + + // Regression test for Bug 1549931 (menuitem content being hidden upon second open) + await showAndHideTreecolpicker(); + await showAndHideTreecolpicker(); +} + +function testtag_tree_columns(tree, expectedColumns, testid) { + testid += " "; + + var columns = tree.columns; + + is( + TreeColumns.isInstance(columns), + true, + testid + "columns is a TreeColumns" + ); + is(columns.count, expectedColumns.length, testid + "TreeColumns count"); + is(columns.length, expectedColumns.length, testid + "TreeColumns length"); + + var treecols = tree.getElementsByTagName("treecols")[0]; + var treecol = treecols.getElementsByTagName("treecol"); + + var x = 0; + var primary = null, + sorted = null, + key = null; + for (var c = 0; c < expectedColumns.length; c++) { + var adjtestid = testid + " column " + c + " "; + var column = columns[c]; + var expectedColumn = expectedColumns[c]; + is(columns.getColumnAt(c), column, adjtestid + "getColumnAt"); + is( + columns.getNamedColumn(expectedColumn.name), + column, + adjtestid + "getNamedColumn" + ); + is(columns.getColumnFor(treecol[c]), column, adjtestid + "getColumnFor"); + if (expectedColumn.primary) { + primary = column; + } + if (expectedColumn.sorted) { + sorted = column; + } + if (expectedColumn.key) { + key = column; + } + + // XXXndeakin on Windows and Linux, some columns are one pixel to the + // left of where they should be. Could just be a rounding issue. + var adj = 1; + is( + column.x + adj >= x, + true, + adjtestid + + "position is after last column " + + column.x + + "," + + column.width + + "," + + x + ); + is(column.width > 0, true, adjtestid + "width is greater than 0"); + x = column.x + column.width; + + // now check the TreeColumn properties + is(TreeColumn.isInstance(column), true, adjtestid + "is a TreeColumn"); + is(column.element, treecol[c], adjtestid + "element is treecol"); + is(column.columns, columns, adjtestid + "columns is TreeColumns"); + is(column.id, expectedColumn.name, adjtestid + "name"); + is(column.index, c, adjtestid + "index"); + is(column.primary, primary == column, adjtestid + "column is primary"); + + is( + column.cycler, + "cycler" in expectedColumn && expectedColumn.cycler, + adjtestid + "column is cycler" + ); + is( + column.editable, + "editable" in expectedColumn && expectedColumn.editable, + adjtestid + "column is editable" + ); + + is( + column.type, + "type" in expectedColumn ? expectedColumn.type : 1, + adjtestid + "type" + ); + + is( + column.getPrevious(), + c > 0 ? columns[c - 1] : null, + adjtestid + "getPrevious" + ); + is( + column.getNext(), + c < columns.length - 1 ? columns[c + 1] : null, + adjtestid + "getNext" + ); + + // check the view's getColumnProperties method + var properties = tree.view.getColumnProperties(column); + var expectedProperties = expectedColumn.properties; + is( + properties, + expectedProperties ? expectedProperties : "", + adjtestid + "getColumnProperties" + ); + } + + is(columns.getFirstColumn(), columns[0], testid + "getFirstColumn"); + is( + columns.getLastColumn(), + columns[columns.length - 1], + testid + "getLastColumn" + ); + is(columns.getPrimaryColumn(), primary, testid + "getPrimaryColumn"); + is(columns.getSortedColumn(), sorted, testid + "getSortedColumn"); + is(columns.getKeyColumn(), key, testid + "getKeyColumn"); + + is(columns.getColumnAt(-1), null, testid + "getColumnAt under"); + is(columns.getColumnAt(columns.length), null, testid + "getColumnAt over"); + is(columns.getNamedColumn(""), null, testid + "getNamedColumn null"); + is( + columns.getNamedColumn("unknown"), + null, + testid + "getNamedColumn unknown" + ); + is(columns.getColumnFor(null), null, testid + "getColumnFor null"); + is(columns.getColumnFor(tree), null, testid + "getColumnFor other"); +} + +function testtag_tree_TreeSelection(tree, testid, multiple) { + testid += " selection "; + + var selection = tree.view.selection; + is( + selection instanceof Ci.nsITreeSelection, + true, + testid + "selection is a TreeSelection" + ); + is(selection.single, !multiple, testid + "single"); + + testtag_tree_TreeSelection_State(tree, testid + "initial", -1, []); + is(selection.shiftSelectPivot, -1, testid + "initial shiftSelectPivot"); + + selection.currentIndex = 2; + testtag_tree_TreeSelection_State(tree, testid + "set currentIndex", 2, []); + tree.currentIndex = 3; + testtag_tree_TreeSelection_State( + tree, + testid + "set tree.currentIndex", + 3, + [] + ); + + // test the select() method, which should deselect all rows and select + // a single row + selection.select(1); + testtag_tree_TreeSelection_State(tree, testid + "select 1", 1, [1]); + selection.select(3); + testtag_tree_TreeSelection_State(tree, testid + "select 2", 3, [3]); + selection.select(3); + testtag_tree_TreeSelection_State(tree, testid + "select same", 3, [3]); + + selection.currentIndex = 1; + testtag_tree_TreeSelection_State( + tree, + testid + "set currentIndex with single selection", + 1, + [3] + ); + + tree.currentIndex = 2; + testtag_tree_TreeSelection_State( + tree, + testid + "set tree.currentIndex with single selection", + 2, + [3] + ); + + // check the toggleSelect method. In single selection mode, it only toggles on when + // there isn't currently a selection. + selection.toggleSelect(2); + testtag_tree_TreeSelection_State( + tree, + testid + "toggleSelect 1", + 2, + multiple ? [2, 3] : [3] + ); + selection.toggleSelect(2); + selection.toggleSelect(3); + testtag_tree_TreeSelection_State(tree, testid + "toggleSelect 2", 3, []); + + // the current index doesn't change after a selectAll, so it should still be set to 1 + // selectAll has no effect on single selection trees + selection.currentIndex = 1; + selection.selectAll(); + testtag_tree_TreeSelection_State( + tree, + testid + "selectAll 1", + 1, + multiple ? [0, 1, 2, 3, 4, 5, 6, 7] : [] + ); + selection.toggleSelect(2); + testtag_tree_TreeSelection_State( + tree, + testid + "toggleSelect after selectAll", + 2, + multiple ? [0, 1, 3, 4, 5, 6, 7] : [2] + ); + selection.clearSelection(); + testtag_tree_TreeSelection_State(tree, testid + "clearSelection", 2, []); + selection.toggleSelect(3); + selection.toggleSelect(1); + if (multiple) { + selection.selectAll(); + testtag_tree_TreeSelection_State( + tree, + testid + "selectAll 2", + 1, + [0, 1, 2, 3, 4, 5, 6, 7] + ); + } + selection.currentIndex = 2; + selection.clearSelection(); + testtag_tree_TreeSelection_State( + tree, + testid + "clearSelection after selectAll", + 2, + [] + ); + + is(selection.shiftSelectPivot, -1, testid + "shiftSelectPivot set to -1"); + + // rangedSelect and clearRange set the currentIndex to the endIndex. The + // shiftSelectPivot property will be set to startIndex. + selection.rangedSelect(1, 3, false); + testtag_tree_TreeSelection_State( + tree, + testid + "rangedSelect no augment", + multiple ? 3 : 2, + multiple ? [1, 2, 3] : [] + ); + is( + selection.shiftSelectPivot, + multiple ? 1 : -1, + testid + "shiftSelectPivot after rangedSelect no augment" + ); + if (multiple) { + selection.select(1); + selection.rangedSelect(0, 2, true); + testtag_tree_TreeSelection_State( + tree, + testid + "rangedSelect augment", + 2, + [0, 1, 2] + ); + is( + selection.shiftSelectPivot, + 0, + testid + "shiftSelectPivot after rangedSelect augment" + ); + + selection.clearRange(1, 3); + testtag_tree_TreeSelection_State(tree, testid + "rangedSelect augment", 3, [ + 0, + ]); + + // check that rangedSelect can take a start value higher than end + selection.rangedSelect(3, 1, false); + testtag_tree_TreeSelection_State( + tree, + testid + "rangedSelect reverse", + 1, + [1, 2, 3] + ); + is( + selection.shiftSelectPivot, + 3, + testid + "shiftSelectPivot after rangedSelect reverse" + ); + + // check that setting the current index doesn't change the selection + selection.currentIndex = 0; + testtag_tree_TreeSelection_State( + tree, + testid + "currentIndex with range selection", + 0, + [1, 2, 3] + ); + } + + // both values of rangedSelect may be the same + selection.rangedSelect(2, 2, false); + testtag_tree_TreeSelection_State(tree, testid + "rangedSelect one row", 2, [ + 2, + ]); + is( + selection.shiftSelectPivot, + 2, + testid + "shiftSelectPivot after selecting one row" + ); + + if (multiple) { + selection.rangedSelect(2, 3, true); + + // a start index of -1 means from the last point + selection.rangedSelect(-1, 0, true); + testtag_tree_TreeSelection_State( + tree, + testid + "rangedSelect -1 existing selection", + 0, + [0, 1, 2, 3] + ); + is( + selection.shiftSelectPivot, + 2, + testid + "shiftSelectPivot after -1 existing selection" + ); + + selection.currentIndex = 2; + selection.rangedSelect(-1, 0, false); + testtag_tree_TreeSelection_State( + tree, + testid + "rangedSelect -1 from currentIndex", + 0, + [0, 1, 2] + ); + is( + selection.shiftSelectPivot, + 2, + testid + "shiftSelectPivot -1 from currentIndex" + ); + } + + // XXXndeakin need to test out of range values but these don't work properly + /* + selection.select(-1); + testtag_tree_TreeSelection_State(tree, testid + "rangedSelect augment -1", -1, []); + + selection.select(8); + testtag_tree_TreeSelection_State(tree, testid + "rangedSelect augment 8", 3, [0]); +*/ +} + +function testtag_tree_TreeSelection_UI(tree, testid, multiple) { + testid += " selection UI "; + + var selection = tree.view.selection; + selection.clearSelection(); + selection.currentIndex = 0; + tree.focus(); + + var keydownFired = 0; + var keypressFired = 0; + function keydownListener(event) { + keydownFired++; + } + function keypressListener(event) { + keypressFired++; + } + + // check that cursor up and down keys navigate up and down + // select event fires after a delay so don't expect it. The reason it fires after a delay + // is so that cursor navigation allows quicking skimming over a set of items without + // actually firing events in-between, improving performance. The select event will only + // be fired on the row where the cursor stops. + window.addEventListener("keydown", keydownListener); + window.addEventListener("keypress", keypressListener); + + synthesizeKeyExpectEvent("VK_DOWN", {}, tree, "!select", "key down"); + testtag_tree_TreeSelection_State(tree, testid + "key down", 1, [1], 0); + + synthesizeKeyExpectEvent("VK_UP", {}, tree, "!select", "key up"); + testtag_tree_TreeSelection_State(tree, testid + "key up", 0, [0], 0); + + synthesizeKeyExpectEvent("VK_UP", {}, tree, "!select", "key up at start"); + testtag_tree_TreeSelection_State(tree, testid + "key up at start", 0, [0], 0); + + // pressing down while the last row is selected should not fire a select event, + // as the selection won't have changed. Also the view is not scrolled in this case. + selection.select(7); + synthesizeKeyExpectEvent("VK_DOWN", {}, tree, "!select", "key down at end"); + testtag_tree_TreeSelection_State(tree, testid + "key down at end", 7, [7], 0); + + // pressing keys while at the edge of the visible rows should scroll the list + tree.scrollToRow(4); + selection.select(4); + synthesizeKeyExpectEvent("VK_UP", {}, tree, "!select", "key up with scroll"); + is(tree.getFirstVisibleRow(), 3, testid + "key up with scroll"); + + tree.scrollToRow(0); + selection.select(3); + synthesizeKeyExpectEvent( + "VK_DOWN", + {}, + tree, + "!select", + "key down with scroll" + ); + is(tree.getFirstVisibleRow(), 1, testid + "key down with scroll"); + + // accel key and cursor movement adjust currentIndex but should not change + // the selection. In single selection mode, the selection will not change, + // but instead will just scroll up or down a line + tree.scrollToRow(0); + selection.select(1); + synthesizeKeyExpectEvent( + "VK_DOWN", + { accelKey: true }, + tree, + "!select", + "key down with accel" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key down with accel", + multiple ? 2 : 1, + [1] + ); + if (!multiple) { + is(tree.getFirstVisibleRow(), 1, testid + "key down with accel and scroll"); + } + + tree.scrollToRow(4); + selection.select(4); + synthesizeKeyExpectEvent( + "VK_UP", + { accelKey: true }, + tree, + "!select", + "key up with accel" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key up with accel", + multiple ? 3 : 4, + [4] + ); + if (!multiple) { + is(tree.getFirstVisibleRow(), 3, testid + "key up with accel and scroll"); + } + + // do this three times, one for each state of pageUpOrDownMovesSelection, + // and then once with the accel key pressed + for (let t = 0; t < 3; t++) { + let testidmod = ""; + if (t == 2) { + testidmod = " with accel"; + } else if (t == 1) { + testidmod = " rev"; + } + var keymod = t == 2 ? { accelKey: true } : {}; + + var moveselection = tree.pageUpOrDownMovesSelection; + if (t == 2) { + moveselection = !moveselection; + } + + tree.scrollToRow(4); + selection.currentIndex = 6; + selection.select(6); + var expected = moveselection ? 4 : 6; + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + keymod, + tree, + "!select", + "key page up" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key page up" + testidmod, + expected, + [expected], + moveselection ? 4 : 0 + ); + + expected = moveselection ? 0 : 6; + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + keymod, + tree, + "!select", + "key page up again" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key page up again" + testidmod, + expected, + [expected], + 0 + ); + + expected = moveselection ? 0 : 6; + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + keymod, + tree, + "!select", + "key page up at start" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key page up at start" + testidmod, + expected, + [expected], + 0 + ); + + tree.scrollToRow(0); + selection.currentIndex = 1; + selection.select(1); + expected = moveselection ? 3 : 1; + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + keymod, + tree, + "!select", + "key page down" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key page down" + testidmod, + expected, + [expected], + moveselection ? 0 : 4 + ); + + expected = moveselection ? 7 : 1; + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + keymod, + tree, + "!select", + "key page down again" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key page down again" + testidmod, + expected, + [expected], + 4 + ); + + expected = moveselection ? 7 : 1; + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + keymod, + tree, + "!select", + "key page down at start" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key page down at start" + testidmod, + expected, + [expected], + 4 + ); + + if (t < 2) { + tree.pageUpOrDownMovesSelection = !tree.pageUpOrDownMovesSelection; + } + } + + tree.scrollToRow(4); + selection.select(6); + synthesizeKeyExpectEvent("VK_HOME", {}, tree, "!select", "key home"); + testtag_tree_TreeSelection_State(tree, testid + "key home", 0, [0], 0); + + tree.scrollToRow(0); + selection.select(1); + synthesizeKeyExpectEvent("VK_END", {}, tree, "!select", "key end"); + testtag_tree_TreeSelection_State(tree, testid + "key end", 7, [7], 4); + + // in single selection mode, the selection doesn't change in this case + tree.scrollToRow(4); + selection.select(6); + synthesizeKeyExpectEvent( + "VK_HOME", + { accelKey: true }, + tree, + "!select", + "key home with accel" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key home with accel", + multiple ? 0 : 6, + [6], + 0 + ); + + tree.scrollToRow(0); + selection.select(1); + synthesizeKeyExpectEvent( + "VK_END", + { accelKey: true }, + tree, + "!select", + "key end with accel" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key end with accel", + multiple ? 7 : 1, + [1], + 4 + ); + + // next, test cursor navigation with selection. Here the select event will be fired + selection.select(1); + var eventExpected = multiple ? "select" : "!select"; + synthesizeKeyExpectEvent( + "VK_DOWN", + { shiftKey: true }, + tree, + eventExpected, + "key shift down to select" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift down to select", + multiple ? 2 : 1, + multiple ? [1, 2] : [1] + ); + is( + selection.shiftSelectPivot, + multiple ? 1 : -1, + testid + "key shift down to select shiftSelectPivot" + ); + synthesizeKeyExpectEvent( + "VK_UP", + { shiftKey: true }, + tree, + eventExpected, + "key shift up to unselect" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift up to unselect", + 1, + [1] + ); + is( + selection.shiftSelectPivot, + multiple ? 1 : -1, + testid + "key shift up to unselect shiftSelectPivot" + ); + if (multiple) { + synthesizeKeyExpectEvent( + "VK_UP", + { shiftKey: true }, + tree, + "select", + "key shift up to select" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift up to select", + 0, + [0, 1] + ); + is( + selection.shiftSelectPivot, + 1, + testid + "key shift up to select shiftSelectPivot" + ); + synthesizeKeyExpectEvent( + "VK_DOWN", + { shiftKey: true }, + tree, + "select", + "key shift down to unselect" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift down to unselect", + 1, + [1] + ); + is( + selection.shiftSelectPivot, + 1, + testid + "key shift down to unselect shiftSelectPivot" + ); + } + + // do this twice, one for each state of pageUpOrDownMovesSelection, however + // when selecting with the shift key, pageUpOrDownMovesSelection is ignored + // and the selection always changes + var lastidx = tree.view.rowCount - 1; + for (let t = 0; t < 2; t++) { + let testidmod = t == 0 ? "" : " rev"; + + // If the top or bottom visible row is the current row, pressing shift and + // page down / page up selects one page up or one page down. Otherwise, the + // selection is made to the top or bottom of the visible area. + tree.scrollToRow(lastidx - 3); + selection.currentIndex = 6; + selection.select(6); + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + { shiftKey: true }, + tree, + eventExpected, + "key shift page up" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page up" + testidmod, + multiple ? 4 : 6, + multiple ? [4, 5, 6] : [6] + ); + if (multiple) { + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + { shiftKey: true }, + tree, + "select", + "key shift page up again" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page up again" + testidmod, + 0, + [0, 1, 2, 3, 4, 5, 6] + ); + // no change in the selection, so no select event should be fired + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + { shiftKey: true }, + tree, + "!select", + "key shift page up at start" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page up at start" + testidmod, + 0, + [0, 1, 2, 3, 4, 5, 6] + ); + // deselect by paging down again + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + { shiftKey: true }, + tree, + "select", + "key shift page down deselect" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page down deselect" + testidmod, + 3, + [3, 4, 5, 6] + ); + } + + tree.scrollToRow(1); + selection.currentIndex = 2; + selection.select(2); + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + { shiftKey: true }, + tree, + eventExpected, + "key shift page down" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page down" + testidmod, + multiple ? 4 : 2, + multiple ? [2, 3, 4] : [2] + ); + if (multiple) { + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + { shiftKey: true }, + tree, + "select", + "key shift page down again" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page down again" + testidmod, + 7, + [2, 3, 4, 5, 6, 7] + ); + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + { shiftKey: true }, + tree, + "!select", + "key shift page down at start" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page down at start" + testidmod, + 7, + [2, 3, 4, 5, 6, 7] + ); + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + { shiftKey: true }, + tree, + "select", + "key shift page up deselect" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page up deselect" + testidmod, + 4, + [2, 3, 4] + ); + } + + // test when page down / page up is pressed when the view is scrolled such + // that the selection is not visible + if (multiple) { + tree.scrollToRow(3); + selection.currentIndex = 1; + selection.select(1); + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + { shiftKey: true }, + tree, + eventExpected, + "key shift page down with view scrolled down" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page down with view scrolled down" + testidmod, + 6, + [1, 2, 3, 4, 5, 6], + 3 + ); + + tree.scrollToRow(2); + selection.currentIndex = 6; + selection.select(6); + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + { shiftKey: true }, + tree, + eventExpected, + "key shift page up with view scrolled up" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page up with view scrolled up" + testidmod, + 2, + [2, 3, 4, 5, 6], + 2 + ); + + tree.scrollToRow(2); + selection.currentIndex = 0; + selection.select(0); + // don't expect the select event, as the selection won't have changed + synthesizeKeyExpectEvent( + "VK_PAGE_UP", + { shiftKey: true }, + tree, + "!select", + "key shift page up at start with view scrolled down" + ); + testtag_tree_TreeSelection_State( + tree, + testid + + "key shift page up at start with view scrolled down" + + testidmod, + 0, + [0], + 0 + ); + + tree.scrollToRow(0); + selection.currentIndex = 7; + selection.select(7); + // don't expect the select event, as the selection won't have changed + synthesizeKeyExpectEvent( + "VK_PAGE_DOWN", + { shiftKey: true }, + tree, + "!select", + "key shift page down at end with view scrolled up" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift page down at end with view scrolled up" + testidmod, + 7, + [7], + 4 + ); + } + + tree.pageUpOrDownMovesSelection = !tree.pageUpOrDownMovesSelection; + } + + tree.scrollToRow(4); + selection.select(5); + synthesizeKeyExpectEvent( + "VK_HOME", + { shiftKey: true }, + tree, + eventExpected, + "key shift home" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift home", + multiple ? 0 : 5, + multiple ? [0, 1, 2, 3, 4, 5] : [5], + multiple ? 0 : 4 + ); + + tree.scrollToRow(0); + selection.select(3); + synthesizeKeyExpectEvent( + "VK_END", + { shiftKey: true }, + tree, + eventExpected, + "key shift end" + ); + testtag_tree_TreeSelection_State( + tree, + testid + "key shift end", + multiple ? 7 : 3, + multiple ? [3, 4, 5, 6, 7] : [3], + multiple ? 4 : 0 + ); + + // pressing space selects a row, pressing accel + space unselects a row + selection.select(2); + selection.currentIndex = 4; + synthesizeKeyExpectEvent(" ", {}, tree, "select", "key space on"); + // in single selection mode, space shouldn't do anything + testtag_tree_TreeSelection_State( + tree, + testid + "key space on", + 4, + multiple ? [2, 4] : [2] + ); + + if (multiple) { + synthesizeKeyExpectEvent( + " ", + { accelKey: true }, + tree, + "select", + "key space off" + ); + testtag_tree_TreeSelection_State(tree, testid + "key space off", 4, [2]); + } + + // check that clicking on a row selects it + tree.scrollToRow(0); + selection.select(2); + selection.currentIndex = 2; + if (0) { + // XXXndeakin disable these tests for now + mouseOnCell(tree, 1, tree.columns[1], "mouse on row"); + testtag_tree_TreeSelection_State( + tree, + testid + "mouse on row", + 1, + [1], + 0, + null + ); + } + + // restore the scroll position to the start of the page + sendKey("HOME"); + + window.removeEventListener("keydown", keydownListener); + window.removeEventListener("keypress", keypressListener); + is(keydownFired, multiple ? 63 : 40, "keydown event wasn't fired properly"); + is(keypressFired, multiple ? 2 : 1, "keypress event wasn't fired properly"); +} + +function testtag_tree_UI_editing(tree, testid, rowInfo) { + testid += " editing UI "; + + // check editing UI + var ecolumn = tree.columns[0]; + var rowIndex = 2; + + // temporary make the tree editable to test mouse double click + var wasEditable = tree.editable; + if (!wasEditable) { + tree.editable = true; + } + + // if this is a container save its current open status + var row = rowInfo.rows[rowIndex]; + var wasOpen = null; + if (tree.view.isContainer(row)) { + wasOpen = tree.view.isContainerOpen(row); + } + + mouseDblClickOnCell(tree, rowIndex, ecolumn, testid + "edit on double click"); + is(tree.editingColumn, ecolumn, testid + "editing column"); + is(tree.editingRow, rowIndex, testid + "editing row"); + + // ensure that we don't expand an expandable container on edit + if (wasOpen != null) { + is( + tree.view.isContainerOpen(row), + wasOpen, + testid + "opened container node on edit" + ); + } + + // ensure to restore editable attribute + if (!wasEditable) { + tree.editable = false; + } + + var ci = tree.currentIndex; + + // cursor navigation should not change the selection while editing + var testKey = function (key) { + synthesizeKeyExpectEvent( + key, + {}, + tree, + "!select", + "key " + key + " with editing" + ); + is( + tree.editingRow == rowIndex && + tree.editingColumn == ecolumn && + tree.currentIndex == ci, + true, + testid + "key " + key + " while editing" + ); + }; + + testKey("VK_DOWN"); + testKey("VK_UP"); + testKey("VK_PAGE_DOWN"); + testKey("VK_PAGE_UP"); + testKey("VK_HOME"); + testKey("VK_END"); + + // XXXndeakin figure out how to send characters to the textbox + // inputField.inputField.focus() + // synthesizeKeyExpectEvent(inputField.inputField, "b", null, ""); + // tree.stopEditing(true); + // is(tree.view.getCellText(0, ecolumn), "b", testid + "edit cell"); + + // Restore initial state. + tree.stopEditing(false); +} + +function testtag_tree_TreeView(tree, testid, rowInfo) { + testid += " view "; + + var columns = tree.columns; + var view = tree.view; + + is(view instanceof Ci.nsITreeView, true, testid + "view is a TreeView"); + is(view.rowCount, rowInfo.rows.length, testid + "rowCount"); + + testtag_tree_TreeView_rows(tree, testid, rowInfo, 0); + + // note that this will only work for content trees currently + view.setCellText(0, columns[1], "Changed Value"); + is(view.getCellText(0, columns[1]), "Changed Value", "setCellText"); + + view.setCellValue(1, columns[0], "Another Changed Value"); + is(view.getCellValue(1, columns[0]), "Another Changed Value", "setCellText"); +} + +function testtag_tree_TreeView_rows(tree, testid, rowInfo, startRow) { + var r; + var columns = tree.columns; + var view = tree.view; + var length = rowInfo.rows.length; + + // methods to test along with the functions which determine the expected value + var checkRowMethods = { + isContainer(row) { + return row.container; + }, + isContainerOpen(row) { + return false; + }, + isContainerEmpty(row) { + return row.children != null && !row.children.rows.length; + }, + isSeparator(row) { + return row.separator; + }, + getRowProperties(row) { + return row.properties; + }, + getLevel(row) { + return row.level; + }, + getParentIndex(row) { + return row.parent; + }, + hasNextSibling(row) { + return r < startRow + length - 1; + }, + }; + + var checkCellMethods = { + getCellText(row, cell) { + return cell.label; + }, + getCellValue(row, cell) { + return cell.value; + }, + getCellProperties(row, cell) { + return cell.properties; + }, + isEditable(row, cell) { + return cell.editable; + }, + getImageSrc(row, cell) { + return cell.image; + }, + }; + + var failedMethods = {}; + var checkMethod, actual, expected; + var toggleOpenStateOK = true; + + for (r = startRow; r < length; r++) { + var row = rowInfo.rows[r]; + for (var c = 0; c < row.cells.length; c++) { + var cell = row.cells[c]; + + for (checkMethod in checkCellMethods) { + expected = checkCellMethods[checkMethod](row, cell); + actual = view[checkMethod](r, columns[c]); + if (actual !== expected) { + failedMethods[checkMethod] = true; + is( + actual, + expected, + testid + + "row " + + r + + " column " + + c + + " " + + checkMethod + + " is incorrect" + ); + } + } + } + + // compare row properties + for (checkMethod in checkRowMethods) { + expected = checkRowMethods[checkMethod](row, r); + if (checkMethod == "hasNextSibling") { + actual = view[checkMethod](r, r); + } else { + actual = view[checkMethod](r); + } + if (actual !== expected) { + failedMethods[checkMethod] = true; + is( + actual, + expected, + testid + "row " + r + " " + checkMethod + " is incorrect" + ); + } + } + /* + // open and recurse into containers + if (row.container) { + view.toggleOpenState(r); + if (!view.isContainerOpen(r)) { + toggleOpenStateOK = false; + is(view.isContainerOpen(r), true, testid + "row " + r + " toggleOpenState open"); + } + testtag_tree_TreeView_rows(tree, testid + "container " + r + " ", row.children, r + 1); + view.toggleOpenState(r); + if (view.isContainerOpen(r)) { + toggleOpenStateOK = false; + is(view.isContainerOpen(r), false, testid + "row " + r + " toggleOpenState close"); + } + } +*/ + } + + for (var failedMethod in failedMethods) { + if (failedMethod in checkRowMethods) { + delete checkRowMethods[failedMethod]; + } + if (failedMethod in checkCellMethods) { + delete checkCellMethods[failedMethod]; + } + } + + for (checkMethod in checkRowMethods) { + is(checkMethod + " ok", checkMethod + " ok", testid + checkMethod); + } + for (checkMethod in checkCellMethods) { + is(checkMethod + " ok", checkMethod + " ok", testid + checkMethod); + } + if (toggleOpenStateOK) { + is("toggleOpenState ok", "toggleOpenState ok", testid + "toggleOpenState"); + } +} + +function testtag_tree_TreeView_rows_sort(tree, testid, rowInfo) { + // check if cycleHeader sorts the columns + var columnIndex = 0; + var view = tree.view; + var column = tree.columns[columnIndex]; + var columnElement = column.element; + var sortkey = columnElement.getAttribute("sort"); + if (sortkey) { + view.cycleHeader(column); + is(tree.getAttribute("sort"), sortkey, "cycleHeader sort"); + is( + tree.getAttribute("sortDirection"), + "ascending", + "cycleHeader sortDirection ascending" + ); + is( + columnElement.getAttribute("sortDirection"), + "ascending", + "cycleHeader column sortDirection" + ); + is( + columnElement.getAttribute("sortActive"), + "true", + "cycleHeader column sortActive" + ); + view.cycleHeader(column); + is( + tree.getAttribute("sortDirection"), + "descending", + "cycleHeader sortDirection descending" + ); + is( + columnElement.getAttribute("sortDirection"), + "descending", + "cycleHeader column sortDirection descending" + ); + view.cycleHeader(column); + is( + tree.getAttribute("sortDirection"), + "", + "cycleHeader sortDirection natural" + ); + is( + columnElement.getAttribute("sortDirection"), + "", + "cycleHeader column sortDirection natural" + ); + // XXXndeakin content view isSorted needs to be tested + } + + // Check that clicking on column header sorts the column. + var columns = getSortedColumnArray(tree); + is( + columnElement.getAttribute("sortDirection"), + "", + "cycleHeader column sortDirection" + ); + + // Click once on column header and check sorting has cycled once. + mouseClickOnColumnHeader(columns, columnIndex, 0, 1); + is( + columnElement.getAttribute("sortDirection"), + "ascending", + "single click cycleHeader column sortDirection ascending" + ); + + // Now simulate a double click. + mouseClickOnColumnHeader(columns, columnIndex, 0, 2); + if (navigator.platform.indexOf("Win") == 0) { + // Windows cycles only once on double click. + is( + columnElement.getAttribute("sortDirection"), + "descending", + "double click cycleHeader column sortDirection descending" + ); + // 1 single clicks should restore natural sorting. + mouseClickOnColumnHeader(columns, columnIndex, 0, 1); + } + + // Check we have gone back to natural sorting. + is( + columnElement.getAttribute("sortDirection"), + "", + "cycleHeader column sortDirection" + ); + + columnElement.setAttribute("sorthints", "twostate"); + view.cycleHeader(column); + is( + tree.getAttribute("sortDirection"), + "ascending", + "cycleHeader sortDirection ascending twostate" + ); + view.cycleHeader(column); + is( + tree.getAttribute("sortDirection"), + "descending", + "cycleHeader sortDirection ascending twostate" + ); + view.cycleHeader(column); + is( + tree.getAttribute("sortDirection"), + "ascending", + "cycleHeader sortDirection ascending twostate again" + ); + columnElement.removeAttribute("sorthints"); + view.cycleHeader(column); + view.cycleHeader(column); + + is( + columnElement.getAttribute("sortDirection"), + "", + "cycleHeader column sortDirection reset" + ); +} + +// checks if the current and selected rows are correct +// current is the index of the current row +// selected is an array of the indicies of the selected rows +// viewidx is the row that should be visible at the top of the tree +function testtag_tree_TreeSelection_State( + tree, + testid, + current, + selected, + viewidx +) { + var selection = tree.view.selection; + + is(selection.count, selected.length, testid + " count"); + is(tree.currentIndex, current, testid + " currentIndex"); + is(selection.currentIndex, current, testid + " TreeSelection currentIndex"); + if (viewidx !== null && viewidx !== undefined) { + is(tree.getFirstVisibleRow(), viewidx, testid + " first visible row"); + } + + var actualSelected = []; + var count = tree.view.rowCount; + for (var s = 0; s < count; s++) { + if (selection.isSelected(s)) { + actualSelected.push(s); + } + } + + is( + compareArrays(selected, actualSelected), + true, + testid + " selection [" + selected + "]" + ); + + actualSelected = []; + var rangecount = selection.getRangeCount(); + for (var r = 0; r < rangecount; r++) { + var start = {}, + end = {}; + selection.getRangeAt(r, start, end); + for (var rs = start.value; rs <= end.value; rs++) { + actualSelected.push(rs); + } + } + + is( + compareArrays(selected, actualSelected), + true, + testid + " range selection [" + selected + "]" + ); +} + +function testtag_tree_column_reorder() { + // Make sure the tree is scrolled into the view, otherwise the test will + // fail + var testframe = window.parent.document.getElementById("testframe"); + if (testframe) { + testframe.scrollIntoView(); + } + + var tree = document.getElementById("tree-column-reorder"); + var numColumns = tree.columns.count; + + var reference = []; + for (let i = 0; i < numColumns; i++) { + reference.push("col_" + i); + } + + // Drag the first column to each position + for (let i = 0; i < numColumns - 1; i++) { + synthesizeColumnDrag(tree, i, i + 1, true); + arrayMove(reference, i, i + 1, true); + checkColumns(tree, reference, "drag first column right"); + } + + // And back + for (let i = numColumns - 1; i >= 1; i--) { + synthesizeColumnDrag(tree, i, i - 1, false); + arrayMove(reference, i, i - 1, false); + checkColumns(tree, reference, "drag last column left"); + } + + // Drag each column one column left + for (let i = 1; i < numColumns; i++) { + synthesizeColumnDrag(tree, i, i - 1, false); + arrayMove(reference, i, i - 1, false); + checkColumns(tree, reference, "drag each column left"); + } + + // And back + for (let i = numColumns - 2; i >= 0; i--) { + synthesizeColumnDrag(tree, i, i + 1, true); + arrayMove(reference, i, i + 1, true); + checkColumns(tree, reference, "drag each column right"); + } + + // Drag each column 5 to the right + for (let i = 0; i < numColumns - 5; i++) { + synthesizeColumnDrag(tree, i, i + 5, true); + arrayMove(reference, i, i + 5, true); + checkColumns(tree, reference, "drag each column 5 to the right"); + } + + // And to the left + for (let i = numColumns - 6; i >= 5; i--) { + synthesizeColumnDrag(tree, i, i - 5, false); + arrayMove(reference, i, i - 5, false); + checkColumns(tree, reference, "drag each column 5 to the left"); + } + + // Test that moving a column after itself does not move anything + synthesizeColumnDrag(tree, 0, 0, true); + checkColumns(tree, reference, "drag to itself"); + is(document.treecolDragging, null, "drag to itself completed"); + + // XXX roc should this be here??? + SimpleTest.finish(); +} + +function testtag_tree_wheel(aTree) { + const deltaModes = [ + WheelEvent.DOM_DELTA_PIXEL, // 0 + WheelEvent.DOM_DELTA_LINE, // 1 + WheelEvent.DOM_DELTA_PAGE, // 2 + ]; + function helper(aStart, aDelta, aIntDelta, aDeltaMode) { + aTree.scrollToRow(aStart); + var expected; + if (!aIntDelta) { + expected = aStart; + } else if (aDeltaMode != WheelEvent.DOM_DELTA_PAGE) { + expected = aStart + aIntDelta; + } else if (aIntDelta > 0) { + expected = aStart + aTree.getPageLength(); + } else { + expected = aStart - aTree.getPageLength(); + } + + if (expected < 0) { + expected = 0; + } + if (expected > aTree.view.rowCount - aTree.getPageLength()) { + expected = aTree.view.rowCount - aTree.getPageLength(); + } + synthesizeWheel(aTree.body, 1, 1, { + deltaMode: aDeltaMode, + deltaY: aDelta, + lineOrPageDeltaY: aIntDelta, + }); + is( + aTree.getFirstVisibleRow(), + expected, + "testtag_tree_wheel: vertical, starting " + + aStart + + " delta " + + aDelta + + " lineOrPageDelta " + + aIntDelta + + " aDeltaMode " + + aDeltaMode + ); + + aTree.scrollToRow(aStart); + // Check that horizontal scrolling has no effect + synthesizeWheel(aTree.body, 1, 1, { + deltaMode: aDeltaMode, + deltaX: aDelta, + lineOrPageDeltaX: aIntDelta, + }); + is( + aTree.getFirstVisibleRow(), + aStart, + "testtag_tree_wheel: horizontal, starting " + + aStart + + " delta " + + aDelta + + " lineOrPageDelta " + + aIntDelta + + " aDeltaMode " + + aDeltaMode + ); + } + + var defaultPrevented = 0; + + function wheelListener(event) { + defaultPrevented++; + } + window.addEventListener("wheel", wheelListener); + + deltaModes.forEach(function (aDeltaMode) { + var delta = aDeltaMode == WheelEvent.DOM_DELTA_PIXEL ? 5.0 : 0.3; + helper(2, -delta, 0, aDeltaMode); + helper(2, -delta, -1, aDeltaMode); + helper(2, delta, 0, aDeltaMode); + helper(2, delta, 1, aDeltaMode); + helper(2, -2 * delta, 0, aDeltaMode); + helper(2, -2 * delta, -1, aDeltaMode); + helper(2, 2 * delta, 0, aDeltaMode); + helper(2, 2 * delta, 1, aDeltaMode); + }); + + window.removeEventListener("wheel", wheelListener); + is(defaultPrevented, 48, "wheel event default prevented"); +} + +async function testtag_tree_scroll() { + const tree = document.querySelector("tree"); + + info("Scroll down with the content scrollbar at the top"); + await doScrollTest({ + tree, + initialTreeScrollRow: 0, + initialContainerScrollTop: 0, + scrollDelta: 10, + isTreeScrollExpected: true, + }); + + info("Scroll down with the content scrollbar at the middle"); + await doScrollTest({ + tree, + initialTreeScrollRow: 3, + initialContainerScrollTop: 0, + scrollDelta: 10, + isTreeScrollExpected: true, + }); + + info("Scroll down with the content scrollbar at the bottom"); + await doScrollTest({ + tree, + initialTreeScrollRow: 9, + initialContainerScrollTop: 0, + scrollDelta: 10, + isTreeScrollExpected: false, + }); + + info("Scroll up with the content scrollbar at the bottom"); + await doScrollTest({ + tree, + initialTreeScrollRow: 9, + initialContainerScrollTop: 50, + scrollDelta: -10, + isTreeScrollExpected: true, + }); + + info("Scroll up with the content scrollbar at the middle"); + await doScrollTest({ + tree, + initialTreeScrollRow: 5, + initialContainerScrollTop: 50, + scrollDelta: -10, + isTreeScrollExpected: true, + }); + + info("Scroll up with the content scrollbar at the top"); + await doScrollTest({ + tree, + initialTreeScrollRow: 0, + initialContainerScrollTop: 50, + scrollDelta: -10, + isTreeScrollExpected: false, + }); + + info("Check whether the tree is not scrolled when the parent is scrolling"); + await doScrollWhileScrollingParent(tree); + + info( + "Check whether the tree component consumes wheel events even if the scroll is located at edge as long as the events are handled as the same series" + ); + await doScrollInSameSeries({ + tree, + initialTreeScrollRow: 0, + initialContainerScrollTop: 0, + scrollDelta: 10, + }); + await doScrollInSameSeries({ + tree, + initialTreeScrollRow: 9, + initialContainerScrollTop: 50, + scrollDelta: -10, + }); + + SimpleTest.finish(); +} + +async function doScrollInSameSeries({ + tree, + initialTreeScrollRow, + initialContainerScrollTop, + scrollDelta, +}) { + // Set enough value to mousewheel.scroll_series_timeout pref to ensure the wheel + // event fired as the same series. + Services.prefs.setIntPref("mousewheel.scroll_series_timeout", 1000); + + const scrollbar = tree.shadowRoot.querySelector( + "scrollbar[orient='vertical']" + ); + const parent = tree.parentElement; + + tree.scrollToRow(initialTreeScrollRow); + parent.scrollTop = initialContainerScrollTop; + + // Scroll until the scrollbar was moved to the specified amount. + await SimpleTest.promiseWaitForCondition(async () => { + await nativeScroll(tree, 10, 10, scrollDelta); + const curpos = scrollbar.getAttribute("curpos"); + return ( + (scrollDelta < 0 && curpos == 0) || + (scrollDelta > 0 && curpos == scrollbar.getAttribute("maxpos")) + ); + }); + + // More scroll as the same series. + for (let i = 0; i < 10; i++) { + await nativeScroll(tree, 10, 10, scrollDelta); + } + + is( + parent.scrollTop, + initialContainerScrollTop, + "The wheel events are condumed in tree component" + ); + const utils = SpecialPowers.getDOMWindowUtils(window); + ok(!utils.getWheelScrollTarget(), "The parent should not handle the event"); + + Services.prefs.clearUserPref("mousewheel.scroll_series_timeout"); +} + +async function doScrollWhileScrollingParent(tree) { + // Set enough value to mousewheel.scroll_series_timeout pref to ensure the wheel + // event fired as the same series. + Services.prefs.setIntPref("mousewheel.scroll_series_timeout", 1000); + + const scrollbar = tree.shadowRoot.querySelector( + "scrollbar[orient='vertical']" + ); + const parent = tree.parentElement; + + // Set initial scroll amount. + tree.scrollToRow(0); + parent.scrollTop = 0; + + const scrollAmount = scrollbar.getAttribute("curpos"); + + // Scroll parent from top to bottom. + await SimpleTest.promiseWaitForCondition(async () => { + await nativeScroll(parent, 10, 10, 10); + return parent.scrollTop === parent.scrollTopMax; + }); + + is( + scrollAmount, + scrollbar.getAttribute("curpos"), + "The tree should not be scrolled" + ); + + const utils = SpecialPowers.getDOMWindowUtils(window); + await SimpleTest.promiseWaitForCondition(() => !utils.getWheelScrollTarget()); + Services.prefs.clearUserPref("mousewheel.scroll_series_timeout"); +} + +async function doScrollTest({ + tree, + initialTreeScrollRow, + initialContainerScrollTop, + scrollDelta, + isTreeScrollExpected, +}) { + const scrollbar = tree.shadowRoot.querySelector( + "scrollbar[orient='vertical']" + ); + const container = tree.parentElement; + + // Set initial scroll amount. + tree.scrollToRow(initialTreeScrollRow); + container.scrollTop = initialContainerScrollTop; + + const treeScrollAmount = scrollbar.getAttribute("curpos"); + const containerScrollAmount = container.scrollTop; + + // Wait until changing either scroll. + await SimpleTest.promiseWaitForCondition(async () => { + await nativeScroll(tree, 10, 10, scrollDelta); + return ( + treeScrollAmount !== scrollbar.getAttribute("curpos") || + containerScrollAmount !== container.scrollTop + ); + }); + + is( + treeScrollAmount !== scrollbar.getAttribute("curpos"), + isTreeScrollExpected, + "Scroll of tree is expected" + ); + is( + containerScrollAmount !== container.scrollTop, + !isTreeScrollExpected, + "Scroll of container is expected" + ); + + // Wait until finishing wheel scroll transaction. + const utils = SpecialPowers.getDOMWindowUtils(window); + await SimpleTest.promiseWaitForCondition(() => !utils.getWheelScrollTarget()); +} + +async function nativeScroll(component, offsetX, offsetY, scrollDelta) { + const utils = SpecialPowers.getDOMWindowUtils(window); + const x = component.screenX + offsetX; + const y = component.screenY + offsetY; + + // Mouse move event. + await new Promise(resolve => { + info("waiting for mousemove"); + window.addEventListener("mousemove", resolve, { once: true }); + utils.sendNativeMouseEvent( + x * window.devicePixelRatio, + y * window.devicePixelRatio, + utils.NATIVE_MOUSE_MESSAGE_MOVE, + 0, + {}, + component + ); + }); + + // Wheel event. + await new Promise(resolve => { + info("waiting for wheel"); + window.addEventListener("wheel", resolve, { once: true }); + utils.sendNativeMouseScrollEvent( + x * window.devicePixelRatio, + y * window.devicePixelRatio, + // nativeVerticalWheelEventMsg is defined in apz_test_native_event_utils.js + // eslint-disable-next-line no-undef + nativeVerticalWheelEventMsg(), + 0, + // nativeScrollUnits is defined in apz_test_native_event_utils.js + // eslint-disable-next-line no-undef + -nativeScrollUnits(component, scrollDelta), + 0, + 0, + 0, + component + ); + }); + + info("waiting for apz"); + // promiseApzFlushedRepaints is defined in apz_test_utils.js + // eslint-disable-next-line no-undef + await promiseApzFlushedRepaints(); +} + +function synthesizeColumnDrag( + aTree, + aMouseDownColumnNumber, + aMouseUpColumnNumber, + aAfter +) { + var columns = getSortedColumnArray(aTree); + + var down = columns[aMouseDownColumnNumber].element; + var up = columns[aMouseUpColumnNumber].element; + + // Target the initial mousedown in the middle of the column header so we + // avoid the extra hit test space given to the splitter + var columnWidth = down.getBoundingClientRect().width; + var splitterHitWidth = columnWidth / 2; + synthesizeMouse(down, splitterHitWidth, 3, { type: "mousedown" }); + + var offsetX = 0; + if (aAfter) { + offsetX = columnWidth; + } + + if (aMouseUpColumnNumber > aMouseDownColumnNumber) { + for (let i = aMouseDownColumnNumber; i <= aMouseUpColumnNumber; i++) { + let move = columns[i].element; + synthesizeMouse(move, offsetX, 3, { type: "mousemove" }); + } + } else { + for (let i = aMouseDownColumnNumber; i >= aMouseUpColumnNumber; i--) { + let move = columns[i].element; + synthesizeMouse(move, offsetX, 3, { type: "mousemove" }); + } + } + + synthesizeMouse(up, offsetX, 3, { type: "mouseup" }); +} + +function arrayMove(aArray, aFrom, aTo, aAfter) { + var o = aArray.splice(aFrom, 1)[0]; + if (aTo > aFrom) { + aTo--; + } + + if (aAfter) { + aTo++; + } + + aArray.splice(aTo, 0, o); +} + +function getSortedColumnArray(aTree) { + var columns = aTree.columns; + var array = []; + for (let i = 0; i < columns.length; i++) { + array.push(columns.getColumnAt(i)); + } + + array.sort(function (a, b) { + var o1 = parseInt(a.element.style.order); + var o2 = parseInt(b.element.style.order); + return o1 - o2; + }); + return array; +} + +function checkColumns(aTree, aReference, aMessage) { + var columns = getSortedColumnArray(aTree); + var ids = []; + columns.forEach(function (e) { + ids.push(e.element.id); + }); + is(compareArrays(ids, aReference), true, aMessage); +} + +function mouseOnCell(tree, row, column, testname) { + var rect = tree.getCoordsForCellItem(row, column, "text"); + + synthesizeMouseExpectEvent( + tree.body, + rect.x, + rect.y, + {}, + tree, + "select", + testname + ); +} + +function mouseClickOnColumnHeader( + aColumns, + aColumnIndex, + aButton, + aClickCount +) { + var columnHeader = aColumns[aColumnIndex].element; + var columnHeaderRect = columnHeader.getBoundingClientRect(); + var columnWidth = columnHeaderRect.right - columnHeaderRect.left; + // For multiple click we send separate click events, with increasing + // clickCount. This simulates the common behavior of multiple clicks. + for (let i = 1; i <= aClickCount; i++) { + // Target the middle of the column header. + synthesizeMouse(columnHeader, columnWidth / 2, 3, { + button: aButton, + clickCount: i, + }); + } +} + +function mouseDblClickOnCell(tree, row, column, testname) { + // select the row we will edit + var selection = tree.view.selection; + selection.select(row); + tree.ensureRowIsVisible(row); + + // get cell coordinates + var rect = tree.getCoordsForCellItem(row, column, "text"); + + synthesizeMouse(tree.body, rect.x, rect.y, { clickCount: 2 }); +} + +function compareArrays(arr1, arr2) { + if (arr1.length != arr2.length) { + return false; + } + + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] != arr2[i]) { + return false; + } + } + + return true; +} + +function convertDOMtoTreeRowInfo(treechildren, level, rowidx) { + var obj = { rows: [] }; + + var parentidx = rowidx.value; + + treechildren = treechildren.childNodes; + for (var r = 0; r < treechildren.length; r++) { + rowidx.value++; + + var treeitem = treechildren[r]; + if (treeitem.hasChildNodes()) { + var treerow = treeitem.firstChild; + var cellInfo = []; + for (var c = 0; c < treerow.childNodes.length; c++) { + var cell = treerow.childNodes[c]; + cellInfo.push({ + label: "" + cell.getAttribute("label"), + value: cell.getAttribute("value"), + properties: cell.getAttribute("properties"), + editable: cell.getAttribute("editable") != "false", + selectable: cell.getAttribute("selectable") != "false", + image: cell.getAttribute("src"), + mode: cell.hasAttribute("mode") + ? parseInt(cell.getAttribute("mode")) + : 3, + }); + } + + var descendants = treeitem.lastChild; + var children = + treerow == descendants + ? null + : convertDOMtoTreeRowInfo(descendants, level + 1, rowidx); + obj.rows.push({ + cells: cellInfo, + properties: treerow.getAttribute("properties"), + container: treeitem.getAttribute("container") == "true", + separator: treeitem.localName == "treeseparator", + children, + level, + parent: parentidx, + }); + } + } + + return obj; +} diff --git a/toolkit/content/tests/widgets/video.ogg b/toolkit/content/tests/widgets/video.ogg Binary files differnew file mode 100644 index 0000000000..ac7ece3519 --- /dev/null +++ b/toolkit/content/tests/widgets/video.ogg diff --git a/toolkit/content/tests/widgets/videocontrols_direction-1-ref.html b/toolkit/content/tests/widgets/videocontrols_direction-1-ref.html new file mode 100644 index 0000000000..1f7e76a7d0 --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-1-ref.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body style="text-align: right;"> +<video controls preload="none" id="av" source="audio.wav"></video> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-1a.html b/toolkit/content/tests/widgets/videocontrols_direction-1a.html new file mode 100644 index 0000000000..a4d3546294 --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-1a.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html dir="rtl"> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body> +<video controls preload="none" id="av" source="audio.wav"></video> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-1b.html b/toolkit/content/tests/widgets/videocontrols_direction-1b.html new file mode 100644 index 0000000000..a14b11d5ff --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-1b.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html style="direction: rtl"> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body> +<video controls preload="none" id="av" source="audio.wav"></video> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-1c.html b/toolkit/content/tests/widgets/videocontrols_direction-1c.html new file mode 100644 index 0000000000..0885ebd893 --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-1c.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body style="direction: rtl"> +<video controls preload="none" id="av" source="audio.wav"></video> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-1d.html b/toolkit/content/tests/widgets/videocontrols_direction-1d.html new file mode 100644 index 0000000000..a39accec72 --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-1d.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body style="text-align: right;"> +<video controls preload="none" id="av" source="audio.wav" dir="rtl"></video> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-1e.html b/toolkit/content/tests/widgets/videocontrols_direction-1e.html new file mode 100644 index 0000000000..25e7c2c1f9 --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-1e.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body style="text-align: right;"> +<video controls preload="none" id="av" source="audio.wav" style="direction: rtl;"></video> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-2-ref.html b/toolkit/content/tests/widgets/videocontrols_direction-2-ref.html new file mode 100644 index 0000000000..630177883c --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-2-ref.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body style="text-align: right;"> +<audio controls preload="none" id="av" source="audio.wav"></audio> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-2a.html b/toolkit/content/tests/widgets/videocontrols_direction-2a.html new file mode 100644 index 0000000000..2e40cdc1a7 --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-2a.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html dir="rtl"> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body> +<audio controls preload="none" id="av" source="audio.wav"></audio> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-2b.html b/toolkit/content/tests/widgets/videocontrols_direction-2b.html new file mode 100644 index 0000000000..2e4dadb6ff --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-2b.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html style="direction: rtl"> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body> +<audio controls preload="none" id="av" source="audio.wav"></audio> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-2c.html b/toolkit/content/tests/widgets/videocontrols_direction-2c.html new file mode 100644 index 0000000000..a43b03e8f9 --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-2c.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body style="direction: rtl"> +<audio controls preload="none" id="av" source="audio.wav"></audio> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-2d.html b/toolkit/content/tests/widgets/videocontrols_direction-2d.html new file mode 100644 index 0000000000..52d56f1ccd --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-2d.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body style="text-align: right;"> +<audio controls preload="none" id="av" source="audio.wav" dir="rtl"></audio> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction-2e.html b/toolkit/content/tests/widgets/videocontrols_direction-2e.html new file mode 100644 index 0000000000..58bc30e2b3 --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction-2e.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> +<head> +<link rel="stylesheet" type="text/css" href="videomask.css"> +</head> +<body style="text-align: right;"> +<audio controls preload="none" id="av" source="audio.wav" style="direction: rtl;"></audio> +<div id="mask"></div> +</body> +</html> diff --git a/toolkit/content/tests/widgets/videocontrols_direction_test.js b/toolkit/content/tests/widgets/videocontrols_direction_test.js new file mode 100644 index 0000000000..e937f06b3f --- /dev/null +++ b/toolkit/content/tests/widgets/videocontrols_direction_test.js @@ -0,0 +1,119 @@ +// This file expects `tests` to have been declared in the global scope. +/* global tests */ + +var RemoteCanvas = function (url, id) { + this.url = url; + this.id = id; + this.snapshot = null; +}; + +RemoteCanvas.CANVAS_WIDTH = 200; +RemoteCanvas.CANVAS_HEIGHT = 200; + +RemoteCanvas.prototype.compare = function (otherCanvas, expected) { + return compareSnapshots(this.snapshot, otherCanvas.snapshot, expected)[0]; +}; + +RemoteCanvas.prototype.load = function (callback) { + var iframe = document.createElement("iframe"); + iframe.id = this.id + "-iframe"; + iframe.width = RemoteCanvas.CANVAS_WIDTH + "px"; + iframe.height = RemoteCanvas.CANVAS_HEIGHT + "px"; + iframe.src = this.url; + var me = this; + iframe.addEventListener("load", function () { + info("iframe loaded"); + var m = iframe.contentDocument.getElementById("av"); + m.addEventListener( + "suspend", + function (aEvent) { + setTimeout(function () { + let mediaElement = + iframe.contentDocument.querySelector("audio, video"); + const { widget } = SpecialPowers.wrap(iframe.contentWindow) + .windowGlobalChild.getActor("UAWidgets") + .widgets.get(mediaElement); + widget.impl.Utils.l10n.translateRoots().then(() => { + me.remotePageLoaded(callback); + }); + }, 0); + }, + { once: true } + ); + m.src = m.getAttribute("source"); + }); + window.document.body.appendChild(iframe); +}; + +RemoteCanvas.prototype.remotePageLoaded = function (callback) { + var ldrFrame = document.getElementById(this.id + "-iframe"); + this.snapshot = snapshotWindow(ldrFrame.contentWindow); + this.snapshot.id = this.id + "-canvas"; + window.document.body.appendChild(this.snapshot); + callback(this); +}; + +RemoteCanvas.prototype.cleanup = function () { + var iframe = document.getElementById(this.id + "-iframe"); + iframe.remove(); + var canvas = document.getElementById(this.id + "-canvas"); + canvas.remove(); +}; + +function runTest(index) { + var canvases = []; + function testCallback(canvas) { + canvases.push(canvas); + + if (canvases.length == 2) { + // when both canvases are loaded + var expectedEqual = currentTest.op == "=="; + var result = canvases[0].compare(canvases[1], expectedEqual); + ok( + result, + "Rendering of reftest " + + currentTest.test + + " should " + + (expectedEqual ? "not " : "") + + "be different to the reference" + ); + + if (result) { + canvases[0].cleanup(); + canvases[1].cleanup(); + } else { + info("Snapshot of canvas 1: " + canvases[0].snapshot.toDataURL()); + info("Snapshot of canvas 2: " + canvases[1].snapshot.toDataURL()); + } + + if (index < tests.length - 1) { + runTest(index + 1); + } else { + SimpleTest.finish(); + } + } + } + + var currentTest = tests[index]; + var testCanvas = new RemoteCanvas(currentTest.test, "test-" + index); + testCanvas.load(testCallback); + + var refCanvas = new RemoteCanvas(currentTest.ref, "ref-" + index); + refCanvas.load(testCallback); +} + +SimpleTest.waitForExplicitFinish(); +SimpleTest.requestCompleteLog(); + +window.addEventListener( + "load", + function () { + SpecialPowers.pushPrefEnv( + { set: [["media.cache_size", 40000]] }, + function () { + runTest(0); + } + ); + }, + true +); diff --git a/toolkit/content/tests/widgets/videomask.css b/toolkit/content/tests/widgets/videomask.css new file mode 100644 index 0000000000..066d441388 --- /dev/null +++ b/toolkit/content/tests/widgets/videomask.css @@ -0,0 +1,23 @@ +html, body { + margin: 0; + padding: 0; +} + +audio, video { + width: 140px; + height: 100px; + background-color: black; +} + +/** + * Create a mask for the video direction tests which covers up the throbber. + */ +#mask { + position: absolute; + z-index: 3; + width: 140px; + height: 72px; + background-color: green; + top: 0; + right: 0; +} diff --git a/toolkit/content/tests/widgets/window_label_checkbox.xhtml b/toolkit/content/tests/widgets/window_label_checkbox.xhtml new file mode 100644 index 0000000000..e1a8258b92 --- /dev/null +++ b/toolkit/content/tests/widgets/window_label_checkbox.xhtml @@ -0,0 +1,46 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<window title="Label Checkbox Tests" width="200" height="200" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + +<hbox> + <label control="checkbox" value="Label" id="label"/> + <checkbox id="checkbox"/> + <label control="radio2" value="Label" id="label2"/> + <radiogroup> + <radio/> + <radio id="radio2"/> + </radiogroup> +</hbox> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + + let SimpleTest = opener.SimpleTest; + SimpleTest.waitForFocus(() => { + let ok = SimpleTest.ok; + let label = document.getElementById("label"); + let checkbox = document.getElementById("checkbox"); + let label2 = document.getElementById("label2"); + let radio2 = document.getElementById("radio2"); + checkbox.checked = true; + radio2.selected = false; + ok(checkbox.checked, "sanity check"); + ok(!radio2.selected, "sanity check"); + setTimeout(() => { + synthesizeMouseAtCenter(label, {}); + ok(!checkbox.checked, "Checkbox should be unchecked"); + synthesizeMouseAtCenter(label2, {}); + ok(radio2.selected, "Radio2 should be selected"); + opener.postMessage("done", "*"); + window.close(); + }, 0); + }); + +]]> +</script> + +</window> diff --git a/toolkit/content/tests/widgets/window_menubar.xhtml b/toolkit/content/tests/widgets/window_menubar.xhtml new file mode 100644 index 0000000000..c4ced844ad --- /dev/null +++ b/toolkit/content/tests/widgets/window_menubar.xhtml @@ -0,0 +1,1015 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> + +<!-- the condition in the focus event handler is because pressing Tab + unfocuses and refocuses the window on Windows --> + +<window title="Popup Tests" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="application/javascript" src="popup_shared.js"></script> + +<hbox style="margin-left: 275px; margin-top: 275px;"> +<menubar id="menubar"> + <menu id="filemenu" label="File" accesskey="F"> + <menupopup id="filepopup"> + <menuitem id="item1" label="Open" accesskey="O"/> + <menuitem id="item2" label="Save" accesskey="S"/> + <menuitem id="item3" label="Close" accesskey="C"/> + </menupopup> + </menu> + <menu id="secretmenu" label="Secret Menu" accesskey="S" disabled="true"> + <menupopup> + <menuitem label="Secret Command" accesskey="S"/> + </menupopup> + </menu> + <menu id="editmenu" label="Edit" accesskey="E"> + <menupopup id="editpopup"> + <menuitem id="cut" label="Cut" accesskey="t" disabled="true"/> + <menuitem id="copy" label="Copy" accesskey="C"/> + <menuitem id="paste" label="Paste" accesskey="P"/> + </menupopup> + </menu> + <menu id="viewmenu" label="View" accesskey="V"> + <menupopup id="viewpopup"> + <menu id="toolbar" label="Toolbar" accesskey="T"> + <menupopup id="toolbarpopup"> + <menuitem id="navigation" label="Navigation" accesskey="N" disabled="true"/> + <menuitem label="Bookmarks" accesskey="B" disabled="true"/> + </menupopup> + </menu> + <menuitem label="Status Bar" accesskey="S"/> + <menu label="Sidebar" accesskey="d"> + <menupopup> + <menuitem label="Bookmarks" accesskey="B"/> + <menuitem label="History" accesskey="H"/> + </menupopup> + </menu> + </menupopup> + </menu> + <menu id="helpmenu" label="Help" accesskey="H"> + <menupopup id="helppopup" > + <label value="Unselectable"/> + <menuitem id="contents" label="Contents" accesskey="C"/> + <menuitem label="More Info" accesskey="I"/> + <menuitem id="amenu" label="A Menu" accesskey="M"/> + <menuitem label="Another Menu"/> + <menuitem id="one" label="One"/> + <menu id="only" label="Only Menu"> + <menupopup> + <menuitem label="Test Submenu"/> + </menupopup> + </menu> + <menuitem label="Second Menu"/> + <menuitem id="other" disabled="true" label="Other Menu"/> + <menuitem id="third" label="Third Menu"/> + <menuitem label="One Other Menu"/> + <label value="Unselectable"/> + <menuitem id="about" label="About" accesskey="A"/> + </menupopup> + </menu> +</menubar> +<hbox> + <description id="outside">Outside menubar</description> + <html:input id="input"/> +</hbox> +</hbox> + +<script class="testbody" type="application/javascript"> +<![CDATA[ + +async function moveMouseOver(id) { + // A single synthesized mouse move isn't always enough in some platforms. + let el = document.getElementById(id); + synthesizeMouse(el, 5, 5, { type: "mousemove" }); + await new Promise(r => setTimeout(r, 0)); + synthesizeMouse(el, 8, 8, { type: "mousemove" }); +} + +let gFilePopup; +window.opener.SimpleTest.waitForFocus(function () { + gFilePopup = document.getElementById("filepopup"); + var filemenu = document.getElementById("filemenu"); + filemenu.focus(); + is(filemenu.openedWithKey, false, "initial openedWithKey"); + startPopupTests(popupTests); +}, window); + +const kIsWindows = navigator.platform.indexOf("Win") == 0; +const kIsLinux = navigator.platform.includes("Linux"); + +// On Linux, the first menu opens when F10 is pressed, but on other platforms +// the menubar is focused but no menu is opened. This means that different events +// fire. +function pressF10Events() +{ + return kIsLinux ? + [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu", "popupshowing filepopup", "popupshown filepopup"] : + [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu" ]; +} + +function closeAfterF10Events() +{ + if (kIsLinux) { + return [ + "popuphiding filepopup", + "popuphidden filepopup", + "DOMMenuInactive filepopup", + "DOMMenuItemInactive filemenu", + "DOMMenuBarInactive menubar", + ]; + } + + return [ "DOMMenuItemInactive filemenu", "DOMMenuBarInactive menubar" ]; +} + +var popupTests = [ +{ + testname: "press on menu", + events: [ "DOMMenuBarActive menubar", + "DOMMenuItemActive filemenu", "popupshowing filepopup", "popupshown filepopup" ], + test() { synthesizeMouse(document.getElementById("filemenu"), 8, 8, { }); }, + result (testname) { + checkActive(gFilePopup, "", testname); + checkOpen("filemenu", testname); + is(document.getElementById("filemenu").openedWithKey, false, testname + " openedWithKey"); + } +}, +{ + // check that pressing cursor down while there is no selection + // highlights the first item + testname: "cursor down no selection", + events: [ "DOMMenuItemActive item1" ], + test() { synthesizeKey("KEY_ArrowDown"); }, + result(testname) { checkActive(gFilePopup, "item1", testname); } +}, +{ + // check that pressing cursor up wraps and highlights the last item + testname: "cursor up wrap", + events: [ "DOMMenuItemInactive item1", "DOMMenuItemActive item3" ], + test() { synthesizeKey("KEY_ArrowUp"); }, + result(testname) { checkActive(gFilePopup, "item3", testname); } +}, +{ + // check that pressing cursor down wraps and highlights the first item + testname: "cursor down wrap", + events: [ "DOMMenuItemInactive item3", "DOMMenuItemActive item1" ], + test() { synthesizeKey("KEY_ArrowDown"); }, + result(testname) { checkActive(gFilePopup, "item1", testname); } +}, +{ + // check that pressing cursor down highlights the second item + testname: "cursor down", + events: [ "DOMMenuItemInactive item1", "DOMMenuItemActive item2" ], + test() { synthesizeKey("KEY_ArrowDown"); }, + result(testname) { checkActive(gFilePopup, "item2", testname); } +}, +{ + // check that pressing cursor up highlights the second item + testname: "cursor up", + events: [ "DOMMenuItemInactive item2", "DOMMenuItemActive item1" ], + test() { synthesizeKey("KEY_ArrowUp"); }, + result(testname) { checkActive(gFilePopup, "item1", testname); } +}, + +{ + // cursor right should skip the disabled menu and move to the edit menu + testname: "cursor right skip disabled", + events() { + var elist = [ + // the file menu gets deactivated, the file menu gets hidden, then + // the edit menu is activated + "DOMMenuItemInactive filemenu", "DOMMenuItemActive editmenu", + "popuphiding filepopup", "popuphidden filepopup", + // the popupshowing event gets fired when showing the edit menu. + "popupshowing editpopup", + "DOMMenuItemInactive item1", + "DOMMenuInactive filepopup", + ]; + // finally, the first item is activated and popupshown is fired. + // On Windows, don't skip disabled items. + if (kIsWindows) { + elist.push("DOMMenuItemActive cut"); + } else { + elist.push("DOMMenuItemActive copy"); + } + elist.push("popupshown editpopup"); + return elist; + }, + test() { synthesizeKey("KEY_ArrowRight"); }, + result(testname) { + var expected = kIsWindows ? "cut" : "copy"; + checkActive(document.getElementById("editpopup"), expected, testname); + checkClosed("filemenu", testname); + checkOpen("editmenu", testname); + is(document.getElementById("editmenu").openedWithKey, false, testname + " openedWithKey"); + } +}, +{ + // on Windows, a disabled item is selected, so pressing RETURN should close + // the menu but not fire a command event + testname: "enter on disabled", + events() { + if (kIsWindows) { + return [ + "popuphiding editpopup", + "popuphidden editpopup", + "DOMMenuItemInactive cut", + "DOMMenuInactive editpopup", + "DOMMenuItemInactive editmenu", + "DOMMenuBarInactive menubar", + ]; + } + return [ + "DOMMenuItemInactive copy", + "DOMMenuInactive editpopup", + "DOMMenuItemInactive editmenu", + "DOMMenuBarInactive menubar", + "command copy", + "popuphiding editpopup", "popuphidden editpopup", + ]; + }, + test() { synthesizeKey("KEY_Enter"); }, + result(testname) { + checkClosed("editmenu", testname); + is(document.getElementById("editmenu").openedWithKey, false, testname + " openedWithKey"); + } +}, +{ + // pressing Alt + a key should open the corresponding menu + testname: "open with accelerator", + events() { + return [ + "DOMMenuBarActive menubar", + "DOMMenuItemActive viewmenu", + "popupshowing viewpopup", + "DOMMenuItemActive toolbar", + "popupshown viewpopup", + ]; + }, + test() { synthesizeKey("V", { altKey: true }); }, + result(testname) { + checkOpen("viewmenu", testname); + is(document.getElementById("viewmenu").openedWithKey, true, testname + " openedWithKey"); + } +}, +{ + // open the submenu with the cursor right key + testname: "open submenu with cursor right", + events() { + // on Windows, the disabled 'navigation' item can still be highlighted + if (kIsWindows) { + return ["popupshowing toolbarpopup", "DOMMenuItemActive navigation", + "popupshown toolbarpopup" ]; + } + return [ "popupshowing toolbarpopup", "popupshown toolbarpopup" ]; + }, + test() { synthesizeKey("KEY_ArrowRight"); }, + result(testname) { + checkOpen("viewmenu", testname); + checkOpen("toolbar", testname); + } +}, +{ + // close the submenu with the cursor left key + testname: "close submenu with cursor left", + events() { + if (kIsWindows) { + return [ + "popuphiding toolbarpopup", + "popuphidden toolbarpopup", + "DOMMenuItemInactive navigation", + "DOMMenuInactive toolbarpopup", + ]; + } + return [ + "popuphiding toolbarpopup", + "popuphidden toolbarpopup", + "DOMMenuInactive toolbarpopup", + ]; + }, + test() { + synthesizeKey("KEY_ArrowLeft"); + }, + result(testname) { + checkOpen("viewmenu", testname); + checkClosed("toolbar", testname); + } +}, +{ + // open the submenu with the enter key + testname: "open submenu with enter", + events() { + if (kIsWindows) { + // on Windows, the disabled 'navigation' item can stll be highlighted + return [ "popupshowing toolbarpopup", "DOMMenuItemActive navigation", + "popupshown toolbarpopup" ]; + } + return [ "popupshowing toolbarpopup", "popupshown toolbarpopup" ]; + }, + test() { synthesizeKey("KEY_Enter"); }, + result(testname) { + checkOpen("viewmenu", testname); + checkOpen("toolbar", testname); + }, +}, +{ + testname: "close submenu with escape", + events() { + if (kIsWindows) { + return [ + "popuphiding toolbarpopup", + "popuphidden toolbarpopup", + "DOMMenuItemInactive navigation", + "DOMMenuInactive toolbarpopup", + ]; + } + return [ + "popuphiding toolbarpopup", + "popuphidden toolbarpopup", + "DOMMenuInactive toolbarpopup", + ]; + }, + test() { synthesizeKey("KEY_Escape"); }, + result(testname) { + checkOpen("viewmenu", testname); + checkClosed("toolbar", testname); + }, +}, +{ + testname: "open submenu with enter again", + condition() { return kIsWindows; }, + events() { + // on Windows, the disabled 'navigation' item can stll be highlighted + if (kIsWindows) { + return [ + "popupshowing toolbarpopup", + "DOMMenuItemActive navigation", + "popupshown toolbarpopup" + ]; + } + return [ "popupshowing toolbarpopup", "popupshown toolbarpopup" ]; + }, + test() { synthesizeKey("KEY_Enter"); }, + result(testname) { + checkOpen("viewmenu", testname); + checkOpen("toolbar", testname); + }, +}, +{ + // while a submenu is open, switch to the next toplevel menu with the cursor right key + testname: "while a submenu is open, switch to the next menu with the cursor right", + condition() { return kIsWindows; }, + events: [ + "DOMMenuItemInactive viewmenu", + "DOMMenuItemActive helpmenu", + "popuphiding toolbarpopup", + "popuphidden toolbarpopup", + "popuphiding viewpopup", + "popuphidden viewpopup", + "popupshowing helppopup", + "DOMMenuItemInactive navigation", + "DOMMenuInactive toolbarpopup", + "DOMMenuItemInactive toolbar", + "DOMMenuInactive viewpopup", + "DOMMenuItemActive contents", + "popupshown helppopup" + ], + test() { synthesizeKey("KEY_ArrowRight"); }, + result(testname) { + checkOpen("helpmenu", testname); + checkClosed("toolbar", testname); + checkClosed("viewmenu", testname); + } +}, +{ + // close the main menu with the escape key + testname: "close menubar menu with escape", + condition() { return kIsWindows; }, + events: [ + "popuphiding helppopup", + "popuphidden helppopup", + "DOMMenuItemInactive contents", + "DOMMenuInactive helppopup", + ], + test() { synthesizeKey("KEY_Escape"); }, + result(testname) { + checkClosed("helpmenu", testname); + }, +}, +{ + // close the main menu with the escape key + testname: "close menubar menu with escape", + condition() { return !kIsWindows; }, + events: [ + "popuphiding viewpopup", + "popuphidden viewpopup", + "DOMMenuItemInactive toolbar", + "DOMMenuInactive viewpopup", + ], + test() { + synthesizeKey("KEY_Escape"); + }, + result(testname) { + checkClosed("viewmenu", testname); + }, +}, +{ + // Deactivate menubar with the escape key. + testname: "deactivate menubar menu with escape", + events: [ + "DOMMenuItemInactive " + (kIsWindows ? "helpmenu" : "viewmenu"), + "DOMMenuBarInactive menubar", + ], + test() { + synthesizeKey("KEY_Escape"); + }, + result(testname) { + }, +}, +{ + // Pressing Alt should highlight the first menu but not open it, + // but it should be ignored if the alt keydown event is consumed. + testname: "alt shouldn't activate menubar if keydown event is consumed", + test() { + document.addEventListener("keydown", function (aEvent) { + aEvent.preventDefault(); + }, {once: true}); + synthesizeKey("KEY_Alt"); + }, + result(testname) { + ok(!document.getElementById("filemenu").openedWithKey, testname); + checkClosed("filemenu", testname); + }, +}, +{ + // Pressing Alt should highlight the first menu but not open it, + // but it should be ignored if the alt keyup event is consumed. + testname: "alt shouldn't activate menubar if keyup event is consumed", + test() { + document.addEventListener("keyup", function (aEvent) { + aEvent.preventDefault(); + }, {once: true}); + synthesizeKey("KEY_Alt"); + }, + result(testname) { + ok(!document.getElementById("filemenu").openedWithKey, testname); + checkClosed("filemenu", testname); + }, +}, +{ + // Pressing Alt should highlight the first menu but not open it. + testname: "alt to activate menubar", + events: [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu" ], + test() { synthesizeKey("KEY_Alt"); }, + result(testname) { + is(document.getElementById("filemenu").openedWithKey, true, testname + " openedWithKey"); + checkClosed("filemenu", testname); + }, +}, +{ + // pressing cursor left should select the previous menu but not open it + testname: "cursor left on active menubar", + events: [ "DOMMenuItemInactive filemenu", "DOMMenuItemActive helpmenu" ], + test() { synthesizeKey("KEY_ArrowLeft"); }, + result(testname) { checkClosed("helpmenu", testname); }, +}, +{ + // pressing cursor right should select the previous menu but not open it + testname: "cursor right on active menubar", + events: [ "DOMMenuItemInactive helpmenu", "DOMMenuItemActive filemenu" ], + test() { synthesizeKey("KEY_ArrowRight"); }, + result(testname) { checkClosed("filemenu", testname); }, +}, +{ + // pressing a character should act as an accelerator and open the menu + testname: "accelerator on active menubar", + events: [ + "DOMMenuItemInactive filemenu", + "DOMMenuItemActive helpmenu", + "popupshowing helppopup", + "DOMMenuItemActive contents", + "popupshown helppopup", + ], + test() { sendChar("h"); }, + result(testname) { + checkOpen("helpmenu", testname); + is(document.getElementById("helpmenu").openedWithKey, true, testname + " openedWithKey"); + }, +}, +{ + // check that pressing cursor up skips non menuitems + testname: "cursor up wrap", + events: [ "DOMMenuItemInactive contents", "DOMMenuItemActive about" ], + test() { synthesizeKey("KEY_ArrowUp"); }, + result(testname) { } +}, +{ + // check that pressing cursor down skips non menuitems + testname: "cursor down wrap", + events: [ "DOMMenuItemInactive about", "DOMMenuItemActive contents" ], + test() { synthesizeKey("KEY_ArrowDown"); }, + result(testname) { } +}, +{ + // check that pressing a menuitem's accelerator selects it + testname: "menuitem accelerator", + events: [ + "DOMMenuItemInactive contents", + "DOMMenuItemActive amenu", + "DOMMenuItemInactive amenu", + "DOMMenuInactive helppopup", + "DOMMenuItemInactive helpmenu", + "DOMMenuBarInactive menubar", + "command amenu", + "popuphiding helppopup", + "popuphidden helppopup", + ], + test() { sendChar("m"); }, + result(testname) { checkClosed("helpmenu", testname); } +}, +{ + // pressing F10 should highlight the first menu. On Linux, the menu is opened. + testname: "F10 to activate menubar", + events: pressF10Events(), + test() { synthesizeKey("KEY_F10"); }, + result(testname) { + is(document.getElementById("filemenu").openedWithKey, true, testname + " openedWithKey"); + if (kIsLinux) { + checkOpen("filemenu", testname); + } else { + checkClosed("filemenu", testname); + } + }, +}, +{ + // pressing cursor left then down should open a menu + testname: "cursor down on menu", + events: kIsLinux ? + [ + "DOMMenuItemInactive filemenu", + "DOMMenuItemActive helpmenu", + // This is in a different order than the + // "accelerator on active menubar" because menus opened from a + // shortcut key are fired asynchronously + "popuphiding filepopup", + "popuphidden filepopup", + "popupshowing helppopup", + "DOMMenuInactive filepopup", + "popupshown helppopup", + "DOMMenuItemActive contents", + ] : [ + "popupshowing helppopup", + "DOMMenuItemInactive filemenu", + "DOMMenuItemActive helpmenu", + // This is in a different order than the + // "accelerator on active menubar" because menus opened from a + // shortcut key are fired asynchronously + "DOMMenuItemActive contents", + "popupshown helppopup", + ], + async test() { + if (kIsLinux) { + // On linux we need to wait so that the hiding of the file popup happens + // (and the help popup opens) to send the key down. + let helpPopupShown = new Promise(r => { + document.getElementById("helppopup").addEventListener("popupshown", r, { once: true }); + }); + synthesizeKey("KEY_ArrowLeft"); + await helpPopupShown; + synthesizeKey("KEY_ArrowDown"); + } else { + synthesizeKey("KEY_ArrowLeft"); + synthesizeKey("KEY_ArrowDown"); + } + }, + result(testname) { + is(document.getElementById("helpmenu").openedWithKey, true, testname + " openedWithKey"); + } +}, +{ + // pressing a letter that doesn't correspond to an accelerator. The menu + // should not close because there is more than one item corresponding to + // that letter + testname: "menuitem with no accelerator", + events: [ "DOMMenuItemInactive contents", "DOMMenuItemActive one" ], + test() { sendChar("o"); }, + result(testname) { checkOpen("helpmenu", testname); } +}, +{ + // pressing the letter again should select the next one that starts with + // that letter + testname: "menuitem with no accelerator again", + events: [ "DOMMenuItemInactive one", "DOMMenuItemActive only" ], + test() { sendChar("o"); }, + result(testname) { + // 'only' is a menu but it should not be open + checkOpen("helpmenu", testname); + checkClosed("only", testname); + } +}, +{ + // pressing the letter again when the next item is disabled should still + // select the disabled item + testname: "menuitem with no accelerator disabled", + condition() { return kIsWindows; }, + events: [ "DOMMenuItemInactive only", "DOMMenuItemActive other" ], + test() { sendChar("o"); }, + result(testname) { } +}, +{ + // when only one menuitem starting with that letter exists, it should be + // selected and the menu closed + testname: "menuitem with no accelerator single", + events() { + let elist = [ + "DOMMenuItemInactive other", + "DOMMenuItemActive third", + "DOMMenuItemInactive third", + "DOMMenuInactive helppopup", + "DOMMenuItemInactive helpmenu", + "DOMMenuBarInactive menubar", + "command third", + "popuphiding helppopup", + "popuphidden helppopup" + ]; + if (!kIsWindows) { + elist[0] = "DOMMenuItemInactive only"; + } + return elist; + }, + test() { sendChar("t"); }, + result(testname) { checkClosed("helpmenu", testname); } +}, +{ + // pressing F10 should highlight the first menu but not open it + testname: "F10 to activate menubar again", + condition() { return kIsWindows; }, + events: [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu" ], + test() { synthesizeKey("KEY_F10"); }, + result(testname) { checkClosed("filemenu", testname); }, +}, +{ + // pressing an accelerator for a disabled item should deactivate the menubar + testname: "accelerator for disabled menu", + condition() { return kIsWindows; }, + events: [ "DOMMenuItemInactive filemenu", "DOMMenuBarInactive menubar" ], + test() { sendChar("s"); }, + result(testname) { + checkClosed("secretmenu", testname); + is(document.getElementById("filemenu").openedWithKey, false, testname + " openedWithKey"); + }, +}, +{ + testname: "press on disabled menu", + test() { + synthesizeMouse(document.getElementById("secretmenu"), 8, 8, { }); + }, + result (testname) { + checkClosed("secretmenu", testname); + } +}, +{ + testname: "press on second menu with shift", + events: [ + "DOMMenuBarActive menubar", + "DOMMenuItemActive editmenu", + "popupshowing editpopup", + "popupshown editpopup", + ], + test() { + synthesizeMouse(document.getElementById("editmenu"), 8, 8, { shiftKey : true }); + }, + result (testname) { + checkOpen("editmenu", testname); + checkActive(document.getElementById("menubar"), "editmenu", testname); + } +}, +{ + testname: "press on disabled menuitem", + test() { + synthesizeMouse(document.getElementById("cut"), 8, 8, { }); + }, + result (testname) { + checkOpen("editmenu", testname); + } +}, +{ + testname: "press on menuitem", + events: [ + "DOMMenuInactive editpopup", + "DOMMenuItemInactive editmenu", + "DOMMenuBarInactive menubar", + "command copy", + "popuphiding editpopup", + "popuphidden editpopup", + ], + test() { + synthesizeMouse(document.getElementById("copy"), 8, 8, { }); + }, + result (testname) { + checkClosed("editmenu", testname); + } +}, +{ + // this test ensures that the menu can still be opened by clicking after selecting + // a menuitem from the menu. See bug 399350. + testname: "press on menu after menuitem selected", + events: [ + "DOMMenuBarActive menubar", + "DOMMenuItemActive editmenu", + "popupshowing editpopup", + "popupshown editpopup", + ], + test() { synthesizeMouse(document.getElementById("editmenu"), 8, 8, { }); }, + result (testname) { + checkActive(document.getElementById("editpopup"), "", testname); + checkOpen("editmenu", testname); + } +}, +{ // try selecting a different command + testname: "press on menuitem again", + events: [ + "DOMMenuInactive editpopup", + "DOMMenuItemInactive editmenu", + "DOMMenuBarInactive menubar", + "command paste", + "popuphiding editpopup", + "popuphidden editpopup", + ], + test() { + synthesizeMouse(document.getElementById("paste"), 8, 8, { }); + }, + result (testname) { + checkClosed("editmenu", testname); + } +}, +{ + testname: "F10 to activate menubar for tab deactivation", + events: pressF10Events(), + test() { synthesizeKey("KEY_F10"); }, +}, +{ + testname: "Deactivate menubar with tab key", + events: closeAfterF10Events(), + test() { synthesizeKey("KEY_Tab"); }, + result(testname) { + is(document.getElementById("filemenu").openedWithKey, false, testname + " openedWithKey"); + } +}, +{ + testname: "F10 to activate menubar for escape deactivation", + events: pressF10Events(), + test() { synthesizeKey("KEY_F10"); }, +}, +{ + testname: "Deactivate menubar with escape key", + events: closeAfterF10Events(), + test() { + synthesizeKey("KEY_Escape"); + if (kIsLinux) { + // One to close the menu, one to deactivate the menubar. + synthesizeKey("KEY_Escape"); + } + }, + result(testname) { + is(document.getElementById("filemenu").openedWithKey, false, testname + " openedWithKey"); + } +}, +{ + testname: "F10 to activate menubar for f10 deactivation", + events: pressF10Events(), + test() { synthesizeKey("KEY_F10"); }, +}, +{ + testname: "Deactivate menubar with f10 key", + events: closeAfterF10Events(), + test() { synthesizeKey("KEY_F10"); }, + result(testname) { + is(document.getElementById("filemenu").openedWithKey, false, testname + " openedWithKey"); + } +}, +{ + testname: "F10 to activate menubar for alt deactivation", + condition() { return kIsWindows; }, + events: [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu" ], + test() { synthesizeKey("KEY_F10"); }, +}, +{ + testname: "Deactivate menubar with alt key", + condition() { return kIsWindows; }, + events: [ "DOMMenuItemInactive filemenu", "DOMMenuBarInactive menubar" ], + test() { synthesizeKey("KEY_Alt"); }, + result(testname) { + is(document.getElementById("filemenu").openedWithKey, false, testname + " openedWithKey"); + } +}, +{ + testname: "Don't activate menubar with mousedown during alt key auto-repeat", + test() { + synthesizeKey("KEY_Alt", {type: "keydown"}); + synthesizeMouse(document.getElementById("menubar"), 8, -30, { type: "mousedown", altKey: true }); + synthesizeKey("KEY_Alt", {type: "keydown"}); + synthesizeMouse(document.getElementById("menubar"), 8, -30, { type: "mouseup", altKey: true }); + synthesizeKey("KEY_Alt", {type: "keydown"}); + synthesizeKey("KEY_Alt", {type: "keyup"}); + }, + result (testname) { + checkActive(document.getElementById("menubar"), "", testname); + } +}, + +{ + testname: "Open menu and press alt key by itself - open menu", + events: [ + "DOMMenuBarActive menubar", + "DOMMenuItemActive filemenu", + "popupshowing filepopup", + "DOMMenuItemActive item1", + "popupshown filepopup", + ], + test() { synthesizeKey("F", { altKey: true }); }, + result (testname) { + checkOpen("filemenu", testname); + } +}, +{ + testname: "Open menu and press alt key by itself - close menu", + events: [ + "popuphiding filepopup", + "popuphidden filepopup", + "DOMMenuItemInactive item1", + "DOMMenuInactive filepopup", + "DOMMenuItemInactive filemenu", + "DOMMenuBarInactive menubar", + ], + test() { + synthesizeKey("KEY_Alt"); + }, + result (testname) { + checkClosed("filemenu", testname); + } +}, + +// Following 4 tests are a test of bug 616797, don't insert any new tests +// between them. +{ + testname: "Open file menu by accelerator", + condition() { return kIsWindows; }, + events: [ + "DOMMenuBarActive menubar", + "DOMMenuItemActive filemenu", + "popupshowing filepopup", + "DOMMenuItemActive item1", + "popupshown filepopup" + ], + test() { + synthesizeKey("KEY_Alt", {type: "keydown"}); + synthesizeKey("f", {altKey: true}); + synthesizeKey("KEY_Alt", {type: "keyup"}); + } +}, +{ + testname: "Close file menu by click at outside of popup menu", + condition() { return kIsWindows; }, + events: [ + "popuphiding filepopup", + "popuphidden filepopup", + "DOMMenuItemInactive item1", + "DOMMenuInactive filepopup", + "DOMMenuItemInactive filemenu", + "DOMMenuBarInactive menubar", + ], + test() { + document.getElementById("filepopup").hidePopup(); + } +}, +{ + testname: "Alt keydown set focus the menubar", + condition() { return kIsWindows; }, + events: [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu" ], + test() { + synthesizeKey("KEY_Alt"); + }, + result (testname) { + checkClosed("filemenu", testname); + } +}, +{ + testname: "unset focus the menubar", + condition() { return kIsWindows; }, + events: [ "DOMMenuItemInactive filemenu", "DOMMenuBarInactive menubar" ], + test() { + synthesizeKey("KEY_Alt"); + } +}, + +// bug 1811466 +{ + testname: "Menubar activation / deactivation on mouse over", + events: [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu", "DOMMenuItemInactive filemenu", "DOMMenuBarInactive menubar" ], + async test() { + await moveMouseOver("filemenu"); + await moveMouseOver("outside"); + }, +}, + +// bug 1811466 +{ + testname: "Menubar hover in and out after key activation (part 1)", + events: [ + "DOMMenuBarActive menubar", + "DOMMenuItemActive filemenu", + /* Shouldn't deactivate the menubar nor filemenu! */ + ], + async test() { + synthesizeKey("KEY_Alt"); + await moveMouseOver("filemenu"); + await moveMouseOver("outside"); + }, +}, +{ + testname: "Deactivate the menubar by mouse", + events: [ + "DOMMenuItemInactive filemenu", + "DOMMenuBarInactive menubar", + ], + test() { + synthesizeMouse(document.getElementById("outside"), 8, 8, {}); + }, +}, + + +// bug 1818241 +{ + testname: "Shortcut navigation on mouse-activated menubar", + events: [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu", "DOMMenuItemInactive filemenu", "DOMMenuBarInactive menubar" ], + async test() { + let input = document.getElementById("input"); + input.value = ""; + input.focus(); + await moveMouseOver("filemenu"); + synthesizeKey("F"); + await moveMouseOver("outside"); + is(input.value, "F", "Key shouldn't be consumed by menubar"); + }, +}, + +// FIXME: This leaves the menubar in a state where IsActive() is false but +// IsActiveByKeyboard() is true! +{ + testname: "Trying to activate menubar without activatable items shouldn't crash", + events: [ "TestDone menubar" ], + test() { + const items = document.querySelectorAll("menubar > menu"); + let wasDisabled = {}; + for (let item of items) { + wasDisabled[item] = item.disabled; + item.disabled = true; + } + + synthesizeKey("KEY_F10"); + setTimeout(function() { + synthesizeKey("KEY_F10"); + + for (let item of items) { + item.disabled = wasDisabled[item]; + } + + document.getElementById("menubar").dispatchEvent(new CustomEvent("TestDone", { bubbles: true })); + }, 0); + } +}, + +// bug 625151 +{ + testname: "Alt key state before deactivating the window shouldn't prevent " + + "next Alt key handling", + condition() { return kIsWindows; }, + events: [ "DOMMenuBarActive menubar", "DOMMenuItemActive filemenu" ], + test() { + synthesizeKey("KEY_Alt", {type: "keydown"}); + synthesizeKey("KEY_Tab", {type: "keydown"}); // cancels the Alt key + var thisWindow = window; + var newWindow = + window.open("javascript:'<html><body>dummy</body></html>';", "_blank", "width=100,height=100"); + newWindow.addEventListener("focus", function () { + thisWindow.addEventListener("focus", function () { + setTimeout(function () { + synthesizeKey("KEY_Alt", {}, thisWindow); + }, 0); + }, {once: true}); + newWindow.close(); + thisWindow.focus(); + }, {once: true}); + } +}, + +]; + +]]> +</script> + +</window> diff --git a/toolkit/content/timepicker.xhtml b/toolkit/content/timepicker.xhtml new file mode 100644 index 0000000000..d054707cb5 --- /dev/null +++ b/toolkit/content/timepicker.xhtml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +%htmlDTD; ]> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" +> + <head> + <title>Time Picker</title> + <link + rel="stylesheet" + href="chrome://global/skin/datetimeinputpickers.css" + /> + <script src="chrome://global/content/bindings/timekeeper.js"></script> + <script src="chrome://global/content/bindings/spinner.js"></script> + <script src="chrome://global/content/bindings/timepicker.js"></script> + </head> + <body> + <div id="time-picker"></div> + <template id="spinner-template"> + <div class="spinner-container"> + <button class="up" /> + <div class="spinner"></div> + <button class="down" /> + </div> + </template> + <script> + /* import-globals-from widgets/timepicker.js */ + // Create a TimePicker instance and prepare to be + // initialized by the "TimePickerInit" event from timepicker.xml + new TimePicker(document.getElementById("time-picker")); + </script> + </body> +</html> diff --git a/toolkit/content/treeUtils.js b/toolkit/content/treeUtils.js new file mode 100644 index 0000000000..08db884d17 --- /dev/null +++ b/toolkit/content/treeUtils.js @@ -0,0 +1,87 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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/. */ + +var gTreeUtils = { + deleteAll(aTree, aView, aItems, aDeletedItems) { + for (var i = 0; i < aItems.length; ++i) { + aDeletedItems.push(aItems[i]); + } + aItems.splice(0, aItems.length); + var oldCount = aView.rowCount; + aView._rowCount = 0; + aTree.rowCountChanged(0, -oldCount); + }, + + deleteSelectedItems(aTree, aView, aItems, aDeletedItems) { + var selection = aTree.view.selection; + selection.selectEventsSuppressed = true; + + var rc = selection.getRangeCount(); + for (var i = 0; i < rc; ++i) { + var min = {}; + var max = {}; + selection.getRangeAt(i, min, max); + for (let j = min.value; j <= max.value; ++j) { + aDeletedItems.push(aItems[j]); + aItems[j] = null; + } + } + + var nextSelection = 0; + for (i = 0; i < aItems.length; ++i) { + if (!aItems[i]) { + let j = i; + while (j < aItems.length && !aItems[j]) { + ++j; + } + aItems.splice(i, j - i); + nextSelection = j < aView.rowCount ? j - 1 : j - 2; + aView._rowCount -= j - i; + aTree.rowCountChanged(i, i - j); + } + } + + if (aItems.length) { + selection.select(nextSelection); + aTree.ensureRowIsVisible(nextSelection); + aTree.focus(); + } + selection.selectEventsSuppressed = false; + }, + + sort( + aTree, + aView, + aDataSet, + aColumn, + aComparator, + aLastSortColumn, + aLastSortAscending + ) { + var ascending = aColumn == aLastSortColumn ? !aLastSortAscending : true; + if (!aDataSet.length) { + return ascending; + } + + var sortFunction = null; + if (aComparator) { + sortFunction = function (a, b) { + return aComparator(a[aColumn], b[aColumn]); + }; + } + aDataSet.sort(sortFunction); + if (!ascending) { + aDataSet.reverse(); + } + + aTree.view.selection.clearSelection(); + aTree.view.selection.select(0); + aTree.invalidate(); + aTree.ensureRowIsVisible(0); + + return ascending; + }, +}; diff --git a/toolkit/content/vendor/lit/0001-disable-terser-step.patch b/toolkit/content/vendor/lit/0001-disable-terser-step.patch new file mode 100644 index 0000000000..6bef4a33ef --- /dev/null +++ b/toolkit/content/vendor/lit/0001-disable-terser-step.patch @@ -0,0 +1,79 @@ +From d4897981e63b8be81453088cd9ab3a6edaf8fcaf Mon Sep 17 00:00:00 2001 +From: Mark Striemer <mstriemer@mozilla.com> +Date: Wed, 16 Nov 2022 22:54:20 -0600 +Subject: [PATCH 1/5] disable terser step + +--- + rollup-common.js | 14 +++++++------- + 1 file changed, 7 insertions(+), 7 deletions(-) + +diff --git a/rollup-common.js b/rollup-common.js +index b2187328..88d222d1 100644 +--- a/rollup-common.js ++++ b/rollup-common.js +@@ -5,7 +5,7 @@ + */ + + import summary from 'rollup-plugin-summary'; +-import {terser} from 'rollup-plugin-terser'; ++// import {terser} from 'rollup-plugin-terser'; + import copy from 'rollup-plugin-copy'; + import nodeResolve from '@rollup/plugin-node-resolve'; + import sourcemaps from 'rollup-plugin-sourcemaps'; +@@ -321,7 +321,7 @@ export function litProdConfig({ + (name) => `console.log(window.${name});` + ), + ].join('\n'); +- const nameCacheSeederTerserOptions = generateTerserOptions(nameCache); ++ // const nameCacheSeederTerserOptions = generateTerserOptions(nameCache); + + const terserOptions = generateTerserOptions( + nameCache, +@@ -345,7 +345,7 @@ export function litProdConfig({ + virtual({ + [nameCacheSeederInfile]: nameCacheSeederContents, + }), +- terser(nameCacheSeederTerserOptions), ++ // terser(nameCacheSeederTerserOptions), + skipBundleOutput, + ], + }, +@@ -395,7 +395,7 @@ export function litProdConfig({ + // This plugin automatically composes the existing TypeScript -> raw JS + // sourcemap with the raw JS -> minified JS one that we're generating here. + sourcemaps(), +- terser(terserOptions), ++ // terser(terserOptions), + summary({ + showBrotliSize: true, + showGzippedSize: true, +@@ -466,7 +466,7 @@ export function litProdConfig({ + // references properties from reactive-element which will + // otherwise have different names. The default export that + // lit-element will use is minified. +- terser(terserOptions), ++ // terser(terserOptions), + summary({ + showBrotliSize: true, + showGzippedSize: true, +@@ -524,7 +524,7 @@ const litMonoBundleConfig = ({ + file, + output, + name, +- terserOptions, ++ // terserOptions, + format = 'umd', + sourcemapPathTransform, + // eslint-disable-next-line no-undef +@@ -563,7 +563,7 @@ const litMonoBundleConfig = ({ + // This plugin automatically composes the existing TypeScript -> raw JS + // sourcemap with the raw JS -> minified JS one that we're generating here. + sourcemaps(), +- terser(terserOptions), ++ // terser(terserOptions), + summary({ + showBrotliSize: true, + showGzippedSize: true, +-- +2.37.1 (Apple Git-137.1) + diff --git a/toolkit/content/vendor/lit/0002-use-DOMParser-not-innerHTML.patch b/toolkit/content/vendor/lit/0002-use-DOMParser-not-innerHTML.patch new file mode 100644 index 0000000000..11afcf6328 --- /dev/null +++ b/toolkit/content/vendor/lit/0002-use-DOMParser-not-innerHTML.patch @@ -0,0 +1,43 @@ +From 0d300a2e703fdcc575ce36dfd62f6c3a9887a3ff Mon Sep 17 00:00:00 2001 +From: Mark Striemer <mstriemer@mozilla.com> +Date: Wed, 16 Nov 2022 23:07:57 -0600 +Subject: [PATCH 2/5] use DOMParser not innerHTML= + +--- + packages/lit-html/src/lit-html.ts | 13 ++++++++++--- + 1 file changed, 10 insertions(+), 3 deletions(-) + +diff --git a/packages/lit-html/src/lit-html.ts b/packages/lit-html/src/lit-html.ts +index 896d298b..994c24e2 100644 +--- a/packages/lit-html/src/lit-html.ts ++++ b/packages/lit-html/src/lit-html.ts +@@ -14,6 +14,8 @@ const NODE_MODE = false; + // Use window for browser builds because IE11 doesn't have globalThis. + const global = NODE_MODE ? globalThis : window; + ++const __moz_domParser = new DOMParser(); ++ + /** + * Contains types that are part of the unstable debug API. + * +@@ -1017,9 +1019,14 @@ class Template { + // Overridden via `litHtmlPolyfillSupport` to provide platform support. + /** @nocollapse */ + static createElement(html: TrustedHTML, _options?: RenderOptions) { +- const el = d.createElement('template'); +- el.innerHTML = html as unknown as string; +- return el; ++ const doc = __moz_domParser.parseFromString( ++ `<template>${html}</template>`, ++ 'text/html' ++ ); ++ return document.importNode( ++ doc.querySelector('template') as HTMLTemplateElement, ++ true ++ ); + } + } + +-- +2.37.1 (Apple Git-137.1) + diff --git a/toolkit/content/vendor/lit/0003-Disable-source-maps.patch b/toolkit/content/vendor/lit/0003-Disable-source-maps.patch new file mode 100644 index 0000000000..50a44aab32 --- /dev/null +++ b/toolkit/content/vendor/lit/0003-Disable-source-maps.patch @@ -0,0 +1,88 @@ +From 28ad29d3496d10193de7a52bd8e104e183f5df7c Mon Sep 17 00:00:00 2001 +From: Mark Striemer <mstriemer@mozilla.com> +Date: Tue, 22 Nov 2022 18:17:01 -0600 +Subject: [PATCH 3/5] Disable source maps + +--- + rollup-common.js | 16 ++++++++-------- + 1 file changed, 8 insertions(+), 8 deletions(-) + +diff --git a/rollup-common.js b/rollup-common.js +index 88d222d1..3f9e13b2 100644 +--- a/rollup-common.js ++++ b/rollup-common.js +@@ -8,7 +8,7 @@ import summary from 'rollup-plugin-summary'; + // import {terser} from 'rollup-plugin-terser'; + import copy from 'rollup-plugin-copy'; + import nodeResolve from '@rollup/plugin-node-resolve'; +-import sourcemaps from 'rollup-plugin-sourcemaps'; ++// import sourcemaps from 'rollup-plugin-sourcemaps'; + import replace from '@rollup/plugin-replace'; + import virtual from '@rollup/plugin-virtual'; + +@@ -358,7 +358,7 @@ export function litProdConfig({ + // Preserve existing module structure (e.g. preserve the "directives/" + // directory). + preserveModules: true, +- sourcemap: !CHECKSIZE, ++ // sourcemap: !CHECKSIZE, + }, + external, + plugins: [ +@@ -394,7 +394,7 @@ export function litProdConfig({ + }), + // This plugin automatically composes the existing TypeScript -> raw JS + // sourcemap with the raw JS -> minified JS one that we're generating here. +- sourcemaps(), ++ // sourcemaps(), + // terser(terserOptions), + summary({ + showBrotliSize: true, +@@ -434,7 +434,7 @@ export function litProdConfig({ + dir: `${outputDir}/node`, + format: 'esm', + preserveModules: true, +- sourcemap: !CHECKSIZE, ++ // sourcemap: !CHECKSIZE, + }, + external, + plugins: [ +@@ -453,7 +453,7 @@ export function litProdConfig({ + 'const ENABLE_SHADYDOM_NOPATCH = false', + }, + }), +- sourcemaps(), ++ // sourcemaps(), + // We want the production Node build to be minified because: + // + // 1. It should be very slightly faster, even in Node where bytes +@@ -496,7 +496,7 @@ export function litProdConfig({ + 'const NODE_MODE = false': 'const NODE_MODE = true', + }, + }), +- sourcemaps(), ++ // sourcemaps(), + summary({ + showBrotliSize: true, + showGzippedSize: true, +@@ -534,7 +534,7 @@ const litMonoBundleConfig = ({ + file: `${output || file}.js`, + format, + name, +- sourcemap: !CHECKSIZE, ++ // sourcemap: !CHECKSIZE, + sourcemapPathTransform, + }, + plugins: [ +@@ -562,7 +562,7 @@ const litMonoBundleConfig = ({ + }), + // This plugin automatically composes the existing TypeScript -> raw JS + // sourcemap with the raw JS -> minified JS one that we're generating here. +- sourcemaps(), ++ // sourcemaps(), + // terser(terserOptions), + summary({ + showBrotliSize: true, +-- +2.37.1 (Apple Git-137.1) + diff --git a/toolkit/content/vendor/lit/0004-Remove-the-bundled-warning.patch b/toolkit/content/vendor/lit/0004-Remove-the-bundled-warning.patch new file mode 100644 index 0000000000..0dfd669b69 --- /dev/null +++ b/toolkit/content/vendor/lit/0004-Remove-the-bundled-warning.patch @@ -0,0 +1,28 @@ +From db47e472d6f2393c58c1691d21e9bbc753b3d9da Mon Sep 17 00:00:00 2001 +From: Mark Striemer <mstriemer@mozilla.com> +Date: Tue, 22 Nov 2022 18:20:46 -0600 +Subject: [PATCH 4/5] Remove the bundled warning + +--- + packages/lit/src/index.all.ts | 8 -------- + 1 file changed, 8 deletions(-) + +diff --git a/packages/lit/src/index.all.ts b/packages/lit/src/index.all.ts +index 1f8e5499..2bccd703 100644 +--- a/packages/lit/src/index.all.ts ++++ b/packages/lit/src/index.all.ts +@@ -37,11 +37,3 @@ export { + unsafeStatic, + withStatic, + } from './static-html.js'; +- +-if (!window.litDisableBundleWarning) { +- console.warn( +- 'Lit has been loaded from a bundle that combines all core features into ' + +- 'a single file. To reduce transfer size and parsing cost, consider ' + +- 'using the `lit` npm package directly in your project.' +- ); +-} +-- +2.37.1 (Apple Git-137.1) + diff --git a/toolkit/content/vendor/lit/0005-Bug-1808741-Do-not-set-style-attributes-when-using-s.patch b/toolkit/content/vendor/lit/0005-Bug-1808741-Do-not-set-style-attributes-when-using-s.patch new file mode 100644 index 0000000000..01a3f7eb30 --- /dev/null +++ b/toolkit/content/vendor/lit/0005-Bug-1808741-Do-not-set-style-attributes-when-using-s.patch @@ -0,0 +1,109 @@ +From 5f0afd7c556938cbf2693acf31656e9bcc01a3a5 Mon Sep 17 00:00:00 2001 +From: Mark Striemer <mstriemer@mozilla.com> +Date: Wed, 14 Dec 2022 15:34:57 -0600 +Subject: [PATCH 5/5] Bug 1808741 - Do not set style attributes when using + styleMap + +--- + packages/lit-html/src/directives/style-map.ts | 21 +--------- + .../src/test/directives/style-map_test.ts | 38 +------------------ + 2 files changed, 3 insertions(+), 56 deletions(-) + +diff --git a/packages/lit-html/src/directives/style-map.ts b/packages/lit-html/src/directives/style-map.ts +index 5a77c676..cd7df040 100644 +--- a/packages/lit-html/src/directives/style-map.ts ++++ b/packages/lit-html/src/directives/style-map.ts +@@ -43,21 +43,8 @@ class StyleMapDirective extends Directive { + + render(styleInfo: Readonly<StyleInfo>) { + return Object.keys(styleInfo).reduce((style, prop) => { +- const value = styleInfo[prop]; +- if (value == null) { +- return style; +- } +- // Convert property names from camel-case to dash-case, i.e.: +- // `backgroundColor` -> `background-color` +- // Vendor-prefixed names need an extra `-` appended to front: +- // `webkitAppearance` -> `-webkit-appearance` +- // Exception is any property name containing a dash, including +- // custom properties; we assume these are already dash-cased i.e.: +- // `--my-button-color` --> `--my-button-color` +- prop = prop +- .replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g, '-$&') +- .toLowerCase(); +- return style + `${prop}:${value};`; ++ // Make sure we use `styleInfo` so the TypeScript checker is happy. This is really a no-op. ++ return style + prop.slice(0, 0); + }, ''); + } + +@@ -66,10 +53,6 @@ class StyleMapDirective extends Directive { + + if (this._previousStyleProperties === undefined) { + this._previousStyleProperties = new Set(); +- for (const name in styleInfo) { +- this._previousStyleProperties.add(name); +- } +- return this.render(styleInfo); + } + + // Remove old properties that no longer exist in styleInfo +diff --git a/packages/lit-html/src/test/directives/style-map_test.ts b/packages/lit-html/src/test/directives/style-map_test.ts +index 6f607e88..913f8a9e 100644 +--- a/packages/lit-html/src/test/directives/style-map_test.ts ++++ b/packages/lit-html/src/test/directives/style-map_test.ts +@@ -4,8 +4,7 @@ + * SPDX-License-Identifier: BSD-3-Clause + */ + +-import {AttributePart, html, render} from 'lit-html'; +-import {directive} from 'lit-html/directive.js'; ++import {html, render} from 'lit-html'; + import {StyleInfo, styleMap} from 'lit-html/directives/style-map.js'; + import {assert} from '@esm-bundle/chai'; + +@@ -33,41 +32,6 @@ suite('styleMap', () => { + container = document.createElement('div'); + }); + +- test('render() only properties', () => { +- // Get the StyleMapDirective class indirectly, since it's not exported +- const result = styleMap({}); +- // This property needs to remain unminified. +- const StyleMapDirective = result['_$litDirective$']; +- +- // Extend StyleMapDirective so we can test its render() method +- class TestStyleMapDirective extends StyleMapDirective { +- override update( +- _part: AttributePart, +- [styleInfo]: Parameters<this['render']> +- ) { +- return this.render(styleInfo); +- } +- } +- const testStyleMap = directive(TestStyleMapDirective); +- render( +- html`<div +- style=${testStyleMap({ +- color: 'red', +- backgroundColor: 'blue', +- webkitAppearance: 'none', +- ['padding-left']: '4px', +- })} +- ></div>`, +- container +- ); +- const div = container.firstElementChild as HTMLDivElement; +- const style = div.style; +- assert.equal(style.color, 'red'); +- assert.equal(style.backgroundColor, 'blue'); +- assert.include(['none', undefined], style.webkitAppearance); +- assert.equal(style.paddingLeft, '4px'); +- }); +- + test('first render skips undefined properties', () => { + renderStyleMap({marginTop: undefined, marginBottom: null}); + const el = container.firstElementChild as HTMLElement; +-- +2.37.1 (Apple Git-137.1) + diff --git a/toolkit/content/vendor/lit/LICENSE b/toolkit/content/vendor/lit/LICENSE new file mode 100644 index 0000000000..be7a97b60d --- /dev/null +++ b/toolkit/content/vendor/lit/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2017 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file diff --git a/toolkit/content/vendor/lit/README.md b/toolkit/content/vendor/lit/README.md new file mode 100644 index 0000000000..138c28a8b0 --- /dev/null +++ b/toolkit/content/vendor/lit/README.md @@ -0,0 +1,46 @@ +# Vendoring for lit + +[lit](https://lit.dev/) can be used to help create Web Components. + +## The lit.all.mjs bundle + +The lit package is imported in a vendoring step and the contents are extracted +into the lit.all.mjs file. This has some differences from using lit in a regular +npm project. Imports that would normally be into a specific file are pulled +directly from the lit.all.mjs file. + +eg. + +``` +// Standard npm package: +import { LitElement } from "lit"; +import { classMap } from "lit/directives/class-map.js"; + +// Using lit.all.mjs (pathing to lit.all.mjs may differ) +import { classMap, LitElement } from "../vendor/lit.all.mjs"; + +## To update the lit bundle + +Vendoring runs off of the latest tag in the https://github.com/lit/lit repo. If +the latest tag is a lit@ tag then running the vendor command will update to that +version. If the latest tag isn't for lit@, you may need to bundle manually. See +the moz.yaml file for instructions. + +### Using mach vendor + +``` +./mach vendor toolkit/content/vendor/lit/moz.yaml +hg ci -m "Update to lit@<version>" +``` + +### Manually updating the bundle + +To manually update, you'll need to checkout a copy of lit/lit, find the tag you +want and manually run our import commands. + + 1. Clone https://github.com/lit/lit outside of moz-central + 2. Copy *.patch from this directory into the lit repo + 3. git apply *.patch + 4. npm install && npm run build + 5. Copy packages/lit/lit-all.min.js to toolkit/content/widgets/vendor/lit.all.mjs + 6. hg ci -m "Update to lit@<version>" diff --git a/toolkit/content/vendor/lit/bundle-lit.sh b/toolkit/content/vendor/lit/bundle-lit.sh new file mode 100755 index 0000000000..532b48337f --- /dev/null +++ b/toolkit/content/vendor/lit/bundle-lit.sh @@ -0,0 +1,9 @@ +#!/bin/bash +cp *.patch lit/ +cd lit +git apply *.patch +../../../../../mach npm install +../../../../../mach npm run build +cp packages/lit/lit-all.min.js ../../../widgets/vendor/lit.all.mjs +rm -rf * .* +cp ../LICENSE . diff --git a/toolkit/content/vendor/lit/lit/LICENSE b/toolkit/content/vendor/lit/lit/LICENSE new file mode 100644 index 0000000000..be7a97b60d --- /dev/null +++ b/toolkit/content/vendor/lit/lit/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2017 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file diff --git a/toolkit/content/vendor/lit/moz.yaml b/toolkit/content/vendor/lit/moz.yaml new file mode 100644 index 0000000000..db1b7f259a --- /dev/null +++ b/toolkit/content/vendor/lit/moz.yaml @@ -0,0 +1,33 @@ +schema: 1 + +bugzilla: + product: "Toolkit" + component: "UI Widgets" + +origin: + name: "lit" + description: + "Lit is a simple library for building fast, lightweight web components." + url: "https://github.com/lit/lit" + license: "BSD-3-Clause" + release: lit@2.5.0 (2022-12-14T16:42:02-06:00). + revision: lit@2.5.0 + + +# Since this tracks the latest tag, it's possible that lit isn't the latest tag +# in lit/lit. In that case we may need to manually update lit. See README.md for +# more info. +vendoring: + url: "https://github.com/lit/lit" + source-hosting: github + tracking: tag + # lit/lit is a monorepo that publishes multiple packages. The tags for lit + # are formatted as "lit@2.5.0". + # tag-prefix: 'lit@' + + vendor-directory: toolkit/content/vendor/lit/lit + + update-actions: + - action: run-script + script: '{yaml_dir}/bundle-lit.sh' + cwd: '{yaml_dir}' diff --git a/toolkit/content/viewZoomOverlay.js b/toolkit/content/viewZoomOverlay.js new file mode 100644 index 0000000000..36142d3d51 --- /dev/null +++ b/toolkit/content/viewZoomOverlay.js @@ -0,0 +1,141 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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/. */ + +/** Document Zoom Management Code + * + * To use this, you'll need to have a global gBrowser variable + * or use the methods that accept a browser to be modified. + **/ + +var ZoomManager = { + set useFullZoom(aVal) { + Services.prefs.setBoolPref("browser.zoom.full", aVal); + }, + + get zoom() { + return this.getZoomForBrowser(gBrowser); + }, + + useFullZoomForBrowser: function ZoomManager_useFullZoomForBrowser(aBrowser) { + return this.useFullZoom || aBrowser.isSyntheticDocument; + }, + + getFullZoomForBrowser: function ZoomManager_getFullZoomForBrowser(aBrowser) { + if (!this.useFullZoomForBrowser(aBrowser)) { + return 1.0; + } + return this.getZoomForBrowser(aBrowser); + }, + + getZoomForBrowser: function ZoomManager_getZoomForBrowser(aBrowser) { + let zoom = this.useFullZoomForBrowser(aBrowser) + ? aBrowser.fullZoom + : aBrowser.textZoom; + // Round to remove any floating-point error. + return Number(zoom ? zoom.toFixed(2) : 1); + }, + + set zoom(aVal) { + this.setZoomForBrowser(gBrowser, aVal); + }, + + setZoomForBrowser: function ZoomManager_setZoomForBrowser(aBrowser, aVal) { + if (aVal < this.MIN || aVal > this.MAX) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + if (this.useFullZoomForBrowser(aBrowser)) { + aBrowser.textZoom = 1; + aBrowser.fullZoom = aVal; + } else { + aBrowser.textZoom = aVal; + aBrowser.fullZoom = 1; + } + }, + + enlarge: function ZoomManager_enlarge() { + var i = this.zoomValues.indexOf(this.snap(this.zoom)) + 1; + if (i < this.zoomValues.length) { + this.zoom = this.zoomValues[i]; + } + }, + + reduce: function ZoomManager_reduce() { + var i = this.zoomValues.indexOf(this.snap(this.zoom)) - 1; + if (i >= 0) { + this.zoom = this.zoomValues[i]; + } + }, + + reset: function ZoomManager_reset() { + this.zoom = 1; + }, + + toggleZoom: function ZoomManager_toggleZoom() { + var zoomLevel = this.zoom; + + this.useFullZoom = !this.useFullZoom; + this.zoom = zoomLevel; + }, + + snap: function ZoomManager_snap(aVal) { + var values = this.zoomValues; + for (var i = 0; i < values.length; i++) { + if (values[i] >= aVal) { + if (i > 0 && aVal - values[i - 1] < values[i] - aVal) { + i--; + } + return values[i]; + } + } + return values[i - 1]; + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + ZoomManager, + "MIN", + "zoom.minPercent", + 30, + null, + v => v / 100 +); +XPCOMUtils.defineLazyPreferenceGetter( + ZoomManager, + "MAX", + "zoom.maxPercent", + 500, + null, + v => v / 100 +); + +XPCOMUtils.defineLazyPreferenceGetter( + ZoomManager, + "zoomValues", + "toolkit.zoomManager.zoomValues", + ".3,.5,.67,.8,.9,1,1.1,1.2,1.33,1.5,1.7,2,2.4,3,4,5", + null, + zoomValues => { + zoomValues = zoomValues.split(",").map(parseFloat); + zoomValues.sort((a, b) => a - b); + + while (zoomValues[0] < this.MIN) { + zoomValues.shift(); + } + + while (zoomValues[zoomValues.length - 1] > this.MAX) { + zoomValues.pop(); + } + + return zoomValues; + } +); + +XPCOMUtils.defineLazyPreferenceGetter( + ZoomManager, + "useFullZoom", + "browser.zoom.full" +); diff --git a/toolkit/content/widgets.css b/toolkit/content/widgets.css new file mode 100644 index 0000000000..d7f1b68602 --- /dev/null +++ b/toolkit/content/widgets.css @@ -0,0 +1,25 @@ +/* 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/. */ + +/* ===== widgets.css ===================================================== + == Styles ported from XBL <resources>, loaded by "global.css". + ======================================================================= */ + +@import url("chrome://global/content/autocomplete.css"); +@import url("chrome://global/skin/autocomplete.css"); +@import url("chrome://global/skin/button.css"); +@import url("chrome://global/skin/checkbox.css"); +@import url("chrome://global/skin/findbar.css"); +@import url("chrome://global/skin/menu.css"); +@import url("chrome://global/skin/notification.css"); +@import url("chrome://global/skin/numberinput.css"); +@import url("chrome://global/skin/popup.css"); +@import url("chrome://global/skin/popupnotification.css"); +@import url("chrome://global/skin/radio.css"); +@import url("chrome://global/skin/richlistbox.css"); +@import url("chrome://global/skin/splitter.css"); +@import url("chrome://global/skin/tabbox.css"); +@import url("chrome://global/skin/toolbar.css"); +@import url("chrome://global/skin/toolbarbutton.css"); +@import url("chrome://global/skin/tree.css"); diff --git a/toolkit/content/widgets/arrowscrollbox.js b/toolkit/content/widgets/arrowscrollbox.js new file mode 100644 index 0000000000..28de96c8d7 --- /dev/null +++ b/toolkit/content/widgets/arrowscrollbox.js @@ -0,0 +1,868 @@ +/* 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. +{ + class MozArrowScrollbox extends MozElements.BaseControl { + static get inheritedAttributes() { + return { + "#scrollbutton-up": "disabled=scrolledtostart", + ".scrollbox-clip": "orient", + scrollbox: "orient,align,pack,dir,smoothscroll", + "#scrollbutton-down": "disabled=scrolledtoend", + }; + } + + get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/toolbarbutton.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/arrowscrollbox.css"/> + <toolbarbutton id="scrollbutton-up" part="scrollbutton-up" keyNav="false"/> + <spacer part="overflow-start-indicator"/> + <box class="scrollbox-clip" part="scrollbox-clip" flex="1"> + <scrollbox part="scrollbox" flex="1"> + <html:slot/> + </scrollbox> + </box> + <spacer part="overflow-end-indicator"/> + <toolbarbutton id="scrollbutton-down" part="scrollbutton-down" keyNav="false"/> + `; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.fragment); + + this.scrollbox = this.shadowRoot.querySelector("scrollbox"); + this._scrollButtonUp = this.shadowRoot.getElementById("scrollbutton-up"); + this._scrollButtonDown = + this.shadowRoot.getElementById("scrollbutton-down"); + + this._arrowScrollAnim = { + scrollbox: this, + requestHandle: 0, + /* 0 indicates there is no pending request */ + start: function arrowSmoothScroll_start() { + this.lastFrameTime = window.performance.now(); + if (!this.requestHandle) { + this.requestHandle = window.requestAnimationFrame( + this.sample.bind(this) + ); + } + }, + stop: function arrowSmoothScroll_stop() { + window.cancelAnimationFrame(this.requestHandle); + this.requestHandle = 0; + }, + sample: function arrowSmoothScroll_handleEvent(timeStamp) { + const scrollIndex = this.scrollbox._scrollIndex; + const timePassed = timeStamp - this.lastFrameTime; + this.lastFrameTime = timeStamp; + + const scrollDelta = 0.5 * timePassed * scrollIndex; + this.scrollbox.scrollByPixels(scrollDelta, true); + this.requestHandle = window.requestAnimationFrame( + this.sample.bind(this) + ); + }, + }; + + this._scrollIndex = 0; + this._scrollIncrement = null; + this._ensureElementIsVisibleAnimationFrame = 0; + this._prevMouseScrolls = [null, null]; + this._touchStart = -1; + this._scrollButtonUpdatePending = false; + this._isScrolling = false; + this._destination = 0; + this._direction = 0; + + this.addEventListener("wheel", this.on_wheel); + this.addEventListener("touchstart", this.on_touchstart); + this.addEventListener("touchmove", this.on_touchmove); + this.addEventListener("touchend", this.on_touchend); + this.shadowRoot.addEventListener("click", this.on_click.bind(this)); + this.shadowRoot.addEventListener( + "mousedown", + this.on_mousedown.bind(this) + ); + this.shadowRoot.addEventListener( + "mouseover", + this.on_mouseover.bind(this) + ); + this.shadowRoot.addEventListener("mouseup", this.on_mouseup.bind(this)); + this.shadowRoot.addEventListener("mouseout", this.on_mouseout.bind(this)); + + // These events don't get retargeted outside of the shadow root, but + // some callers like tests wait for these events. So run handlers + // and then retarget events from the scrollbox to the host. + this.scrollbox.addEventListener( + "underflow", + event => { + this.on_underflow(event); + this.dispatchEvent(new Event("underflow")); + }, + true + ); + this.scrollbox.addEventListener( + "overflow", + event => { + this.on_overflow(event); + this.dispatchEvent(new Event("overflow")); + }, + true + ); + this.scrollbox.addEventListener("scroll", event => { + this.on_scroll(event); + this.dispatchEvent(new Event("scroll")); + }); + + this.scrollbox.addEventListener("scrollend", event => { + this.on_scrollend(event); + this.dispatchEvent(new Event("scrollend")); + }); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + if (!this.hasAttribute("smoothscroll")) { + this.smoothScroll = Services.prefs.getBoolPref( + "toolkit.scrollbox.smoothScroll", + true + ); + } + + this.removeAttribute("overflowing"); + this.initializeAttributeInheritance(); + this._updateScrollButtonsDisabledState(); + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment( + this.markup + ); + } + return document.importNode(this.constructor._fragment, true); + } + + get _clickToScroll() { + return this.hasAttribute("clicktoscroll"); + } + + get _scrollDelay() { + if (this._clickToScroll) { + return Services.prefs.getIntPref( + "toolkit.scrollbox.clickToScroll.scrollDelay", + 150 + ); + } + + // Use the same REPEAT_DELAY as "nsRepeatService.h". + return /Mac/.test(navigator.platform) ? 25 : 50; + } + + get scrollIncrement() { + if (this._scrollIncrement === null) { + this._scrollIncrement = Services.prefs.getIntPref( + "toolkit.scrollbox.scrollIncrement", + 20 + ); + } + return this._scrollIncrement; + } + + set smoothScroll(val) { + this.setAttribute("smoothscroll", !!val); + } + + get smoothScroll() { + return this.getAttribute("smoothscroll") == "true"; + } + + get scrollClientRect() { + return this.scrollbox.getBoundingClientRect(); + } + + get scrollClientSize() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.clientHeight + : this.scrollbox.clientWidth; + } + + get scrollSize() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.scrollHeight + : this.scrollbox.scrollWidth; + } + + get lineScrollAmount() { + // line scroll amout should be the width (at horizontal scrollbox) or + // the height (at vertical scrollbox) of the scrolled elements. + // However, the elements may have different width or height. So, + // for consistent speed, let's use average width of the elements. + var elements = this._getScrollableElements(); + return elements.length && this.scrollSize / elements.length; + } + + get scrollPosition() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.scrollTop + : this.scrollbox.scrollLeft; + } + + get startEndProps() { + if (!this._startEndProps) { + this._startEndProps = + this.getAttribute("orient") == "vertical" + ? ["top", "bottom"] + : ["left", "right"]; + } + return this._startEndProps; + } + + get isRTLScrollbox() { + if (!this._isRTLScrollbox) { + this._isRTLScrollbox = + this.getAttribute("orient") != "vertical" && + document.defaultView.getComputedStyle(this.scrollbox).direction == + "rtl"; + } + return this._isRTLScrollbox; + } + + _onButtonClick(event) { + if (this._clickToScroll) { + this._distanceScroll(event); + } + } + + _onButtonMouseDown(event, index) { + if (this._clickToScroll && event.button == 0) { + this._startScroll(index); + } + } + + _onButtonMouseUp(event) { + if (this._clickToScroll && event.button == 0) { + this._stopScroll(); + } + } + + _onButtonMouseOver(index) { + if (this._clickToScroll) { + this._continueScroll(index); + } else { + this._startScroll(index); + } + } + + _onButtonMouseOut() { + if (this._clickToScroll) { + this._pauseScroll(); + } else { + this._stopScroll(); + } + } + + _boundsWithoutFlushing(element) { + if (!("_DOMWindowUtils" in this)) { + this._DOMWindowUtils = window.windowUtils; + } + + return this._DOMWindowUtils + ? this._DOMWindowUtils.getBoundsWithoutFlushing(element) + : element.getBoundingClientRect(); + } + + _canScrollToElement(element) { + if (element.hidden) { + return false; + } + + // See if the element is hidden via CSS without the hidden attribute. + // If we get only zeros for the client rect, this means the element + // is hidden. As a performance optimization, we don't flush layout + // here which means that on the fly changes aren't fully supported. + let rect = this._boundsWithoutFlushing(element); + return !!(rect.top || rect.left || rect.width || rect.height); + } + + ensureElementIsVisible(element, aInstant) { + if (!this._canScrollToElement(element)) { + return; + } + + if (this._ensureElementIsVisibleAnimationFrame) { + window.cancelAnimationFrame(this._ensureElementIsVisibleAnimationFrame); + } + this._ensureElementIsVisibleAnimationFrame = window.requestAnimationFrame( + () => { + element.scrollIntoView({ + block: "nearest", + behavior: aInstant ? "instant" : "auto", + }); + this._ensureElementIsVisibleAnimationFrame = 0; + } + ); + } + + scrollByIndex(index, aInstant) { + if (index == 0) { + return; + } + + var rect = this.scrollClientRect; + var [start, end] = this.startEndProps; + var x = index > 0 ? rect[end] + 1 : rect[start] - 1; + var nextElement = this._elementFromPoint(x, index); + if (!nextElement) { + return; + } + + var targetElement; + if (this.isRTLScrollbox) { + index *= -1; + } + while (index < 0 && nextElement) { + if (this._canScrollToElement(nextElement)) { + targetElement = nextElement; + } + nextElement = nextElement.previousElementSibling; + index++; + } + while (index > 0 && nextElement) { + if (this._canScrollToElement(nextElement)) { + targetElement = nextElement; + } + nextElement = nextElement.nextElementSibling; + index--; + } + if (!targetElement) { + return; + } + + this.ensureElementIsVisible(targetElement, aInstant); + } + + _getScrollableElements() { + let nodes = this.children; + if (nodes.length == 1) { + let node = nodes[0]; + if ( + node.localName == "slot" && + node.namespaceURI == "http://www.w3.org/1999/xhtml" + ) { + nodes = node.getRootNode().host.children; + } + } + return Array.prototype.filter.call(nodes, this._canScrollToElement, this); + } + + _elementFromPoint(aX, aPhysicalScrollDir) { + var elements = this._getScrollableElements(); + if (!elements.length) { + return null; + } + + if (this.isRTLScrollbox) { + elements.reverse(); + } + + var [start, end] = this.startEndProps; + var low = 0; + var high = elements.length - 1; + + if ( + aX < elements[low].getBoundingClientRect()[start] || + aX > elements[high].getBoundingClientRect()[end] + ) { + return null; + } + + var mid, rect; + while (low <= high) { + mid = Math.floor((low + high) / 2); + rect = elements[mid].getBoundingClientRect(); + if (rect[start] > aX) { + high = mid - 1; + } else if (rect[end] < aX) { + low = mid + 1; + } else { + return elements[mid]; + } + } + + // There's no element at the requested coordinate, but the algorithm + // from above yields an element next to it, in a random direction. + // The desired scrolling direction leads to the correct element. + + if (!aPhysicalScrollDir) { + return null; + } + + if (aPhysicalScrollDir < 0 && rect[start] > aX) { + mid = Math.max(mid - 1, 0); + } else if (aPhysicalScrollDir > 0 && rect[end] < aX) { + mid = Math.min(mid + 1, elements.length - 1); + } + + return elements[mid]; + } + + _startScroll(index) { + if (this.isRTLScrollbox) { + index *= -1; + } + + if (this._clickToScroll) { + this._scrollIndex = index; + this._mousedown = true; + + if (this.smoothScroll) { + this._arrowScrollAnim.start(); + return; + } + } + + if (!this._scrollTimer) { + this._scrollTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + } else { + this._scrollTimer.cancel(); + } + + let callback; + if (this._clickToScroll) { + callback = () => { + if (!document && this._scrollTimer) { + this._scrollTimer.cancel(); + } + this.scrollByIndex(this._scrollIndex); + }; + } else { + callback = () => this.scrollByPixels(this.scrollIncrement * index); + } + + this._scrollTimer.initWithCallback( + callback, + this._scrollDelay, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + + callback(); + } + + _stopScroll() { + if (this._scrollTimer) { + this._scrollTimer.cancel(); + } + + if (this._clickToScroll) { + this._mousedown = false; + if (!this._scrollIndex || !this.smoothScroll) { + return; + } + + this.scrollByIndex(this._scrollIndex); + this._scrollIndex = 0; + + this._arrowScrollAnim.stop(); + } + } + + _pauseScroll() { + if (this._mousedown) { + this._stopScroll(); + this._mousedown = true; + document.addEventListener("mouseup", this); + document.addEventListener("blur", this, true); + } + } + + _continueScroll(index) { + if (this._mousedown) { + this._startScroll(index); + } + } + + _distanceScroll(aEvent) { + if (aEvent.detail < 2 || aEvent.detail > 3) { + return; + } + + var scrollBack = aEvent.originalTarget == this._scrollButtonUp; + var scrollLeftOrUp = this.isRTLScrollbox ? !scrollBack : scrollBack; + var targetElement; + + if (aEvent.detail == 2) { + // scroll by the size of the scrollbox + let [start, end] = this.startEndProps; + let x; + if (scrollLeftOrUp) { + x = this.scrollClientRect[start] - this.scrollClientSize; + } else { + x = this.scrollClientRect[end] + this.scrollClientSize; + } + targetElement = this._elementFromPoint(x, scrollLeftOrUp ? -1 : 1); + + // the next partly-hidden element will become fully visible, + // so don't scroll too far + if (targetElement) { + targetElement = scrollBack + ? targetElement.nextElementSibling + : targetElement.previousElementSibling; + } + } + + if (!targetElement) { + // scroll to the first resp. last element + let elements = this._getScrollableElements(); + targetElement = scrollBack + ? elements[0] + : elements[elements.length - 1]; + } + + this.ensureElementIsVisible(targetElement); + } + + handleEvent(aEvent) { + if ( + aEvent.type == "mouseup" || + (aEvent.type == "blur" && aEvent.target == document) + ) { + this._mousedown = false; + document.removeEventListener("mouseup", this); + document.removeEventListener("blur", this, true); + } + } + + scrollByPixels(aPixels, aInstant) { + let scrollOptions = { behavior: aInstant ? "instant" : "auto" }; + scrollOptions[this.startEndProps[0]] = aPixels; + this.scrollbox.scrollBy(scrollOptions); + } + + _updateScrollButtonsDisabledState() { + if (!this.hasAttribute("overflowing")) { + this.setAttribute("scrolledtoend", "true"); + this.setAttribute("scrolledtostart", "true"); + return; + } + + if (this._scrollButtonUpdatePending) { + return; + } + this._scrollButtonUpdatePending = true; + + // Wait until after the next paint to get current layout data from + // getBoundsWithoutFlushing. + window.requestAnimationFrame(() => { + setTimeout(() => { + if (!this.isConnected) { + // We've been destroyed in the meantime. + return; + } + + this._scrollButtonUpdatePending = false; + + let scrolledToStart = false; + let scrolledToEnd = false; + + if (!this.hasAttribute("overflowing")) { + scrolledToStart = true; + scrolledToEnd = true; + } else { + let isAtEdge = (element, start) => { + let edge = start ? this.startEndProps[0] : this.startEndProps[1]; + let scrollEdge = this._boundsWithoutFlushing(this.scrollbox)[ + edge + ]; + let elementEdge = this._boundsWithoutFlushing(element)[edge]; + // This is enough slop (>2/3) so that no subpixel value should + // get us confused about whether we reached the end. + const EPSILON = 0.7; + if (start) { + return scrollEdge <= elementEdge + EPSILON; + } + return elementEdge <= scrollEdge + EPSILON; + }; + + let elements = this._getScrollableElements(); + let [startElement, endElement] = [ + elements[0], + elements[elements.length - 1], + ]; + if (this.isRTLScrollbox) { + [startElement, endElement] = [endElement, startElement]; + } + scrolledToStart = + startElement && isAtEdge(startElement, /* start = */ true); + scrolledToEnd = + endElement && isAtEdge(endElement, /* start = */ false); + if (this.isRTLScrollbox) { + [scrolledToStart, scrolledToEnd] = [ + scrolledToEnd, + scrolledToStart, + ]; + } + } + + if (scrolledToEnd) { + this.setAttribute("scrolledtoend", "true"); + } else { + this.removeAttribute("scrolledtoend"); + } + + if (scrolledToStart) { + this.setAttribute("scrolledtostart", "true"); + } else { + this.removeAttribute("scrolledtostart"); + } + }, 0); + }); + } + + disconnectedCallback() { + // Release timer to avoid reference cycles. + if (this._scrollTimer) { + this._scrollTimer.cancel(); + this._scrollTimer = null; + } + } + + on_wheel(event) { + // Don't consume the event if we can't scroll. + if (!this.hasAttribute("overflowing")) { + return; + } + + const { deltaMode } = event; + let doScroll = false; + let instant; + let scrollAmount = 0; + if (this.getAttribute("orient") == "vertical") { + doScroll = true; + scrollAmount = event.deltaY; + } else { + // We allow vertical scrolling to scroll a horizontal scrollbox + // because many users have a vertical scroll wheel but no + // horizontal support. + // Because of this, we need to avoid scrolling chaos on trackpads + // and mouse wheels that support simultaneous scrolling in both axes. + // We do this by scrolling only when the last two scroll events were + // on the same axis as the current scroll event. + // For diagonal scroll events we only respect the dominant axis. + let isVertical = Math.abs(event.deltaY) > Math.abs(event.deltaX); + let delta = isVertical ? event.deltaY : event.deltaX; + let scrollByDelta = isVertical && this.isRTLScrollbox ? -delta : delta; + + if (this._prevMouseScrolls.every(prev => prev == isVertical)) { + doScroll = true; + scrollAmount = scrollByDelta; + if (deltaMode == event.DOM_DELTA_PIXEL) { + instant = true; + } + } + + if (this._prevMouseScrolls.length > 1) { + this._prevMouseScrolls.shift(); + } + this._prevMouseScrolls.push(isVertical); + } + + if (doScroll) { + let direction = scrollAmount < 0 ? -1 : 1; + + if (deltaMode == event.DOM_DELTA_PAGE) { + scrollAmount *= this.scrollClientSize; + } else if (deltaMode == event.DOM_DELTA_LINE) { + // Try to not scroll by more than one page when using line scrolling, + // so that all elements are scrollable. + let lineAmount = this.lineScrollAmount; + let clientSize = this.scrollClientSize; + if (Math.abs(scrollAmount * lineAmount) > clientSize) { + // NOTE: This still tries to scroll a non-fractional amount of + // items per line scrolled. + scrollAmount = + Math.max(1, Math.floor(clientSize / lineAmount)) * direction; + } + scrollAmount *= lineAmount; + } else { + // DOM_DELTA_PIXEL, leave scrollAmount untouched. + } + let startPos = this.scrollPosition; + + if (!this._isScrolling || this._direction != direction) { + this._destination = startPos + scrollAmount; + this._direction = direction; + } else { + // We were already in the process of scrolling in this direction + this._destination = this._destination + scrollAmount; + scrollAmount = this._destination - startPos; + } + this.scrollByPixels(scrollAmount, instant); + } + + event.stopPropagation(); + event.preventDefault(); + } + + on_touchstart(event) { + if (event.touches.length > 1) { + // Multiple touch points detected, abort. In particular this aborts + // the panning gesture when the user puts a second finger down after + // already panning with one finger. Aborting at this point prevents + // the pan gesture from being resumed until all fingers are lifted + // (as opposed to when the user is back down to one finger). + this._touchStart = -1; + } else { + this._touchStart = + this.getAttribute("orient") == "vertical" + ? event.touches[0].screenY + : event.touches[0].screenX; + } + } + + on_touchmove(event) { + if (event.touches.length == 1 && this._touchStart >= 0) { + var touchPoint = + this.getAttribute("orient") == "vertical" + ? event.touches[0].screenY + : event.touches[0].screenX; + var delta = this._touchStart - touchPoint; + if (Math.abs(delta) > 0) { + this.scrollByPixels(delta, true); + this._touchStart = touchPoint; + } + event.preventDefault(); + } + } + + on_touchend(event) { + this._touchStart = -1; + } + + on_underflow(event) { + // Ignore underflow events: + // - from nested scrollable elements + // - corresponding to an overflow event that we ignored + if (event.target != this.scrollbox || !this.hasAttribute("overflowing")) { + return; + } + + // Ignore events that doesn't match our orientation. + // Scrollport event orientation: + // 0: vertical + // 1: horizontal + // 2: both + if (this.getAttribute("orient") == "vertical") { + if (event.detail == 1) { + return; + } + } else if (event.detail == 0) { + // horizontal scrollbox + return; + } + + this.removeAttribute("overflowing"); + this._updateScrollButtonsDisabledState(); + } + + on_overflow(event) { + // Ignore overflow events: + // - from nested scrollable elements + if (event.target != this.scrollbox) { + return; + } + + // Ignore events that doesn't match our orientation. + // Scrollport event orientation: + // 0: vertical + // 1: horizontal + // 2: both + if (this.getAttribute("orient") == "vertical") { + if (event.detail == 1) { + return; + } + } else if (event.detail == 0) { + // horizontal scrollbox + return; + } + + this.setAttribute("overflowing", "true"); + this._updateScrollButtonsDisabledState(); + } + + on_scroll(event) { + this._isScrolling = true; + this._updateScrollButtonsDisabledState(); + } + + on_scrollend(event) { + this._isScrolling = false; + this._destination = 0; + this._direction = 0; + } + + on_click(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonClick(event); + } + + on_mousedown(event) { + if (event.originalTarget == this._scrollButtonUp) { + this._onButtonMouseDown(event, -1); + } + if (event.originalTarget == this._scrollButtonDown) { + this._onButtonMouseDown(event, 1); + } + } + + on_mouseup(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonMouseUp(event); + } + + on_mouseover(event) { + if (event.originalTarget == this._scrollButtonUp) { + this._onButtonMouseOver(-1); + } + if (event.originalTarget == this._scrollButtonDown) { + this._onButtonMouseOver(1); + } + } + + on_mouseout(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonMouseOut(); + } + } + + customElements.define("arrowscrollbox", MozArrowScrollbox); +} diff --git a/toolkit/content/widgets/autocomplete-input.js b/toolkit/content/widgets/autocomplete-input.js new file mode 100644 index 0000000000..36105ba4d7 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-input.js @@ -0,0 +1,647 @@ +/* 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" + ); + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + class AutocompleteInput extends HTMLInputElement { + constructor() { + super(); + + this.popupSelectedIndex = -1; + + ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "disablePopupAutohide", + "ui.popup.disable_autohide", + false + ); + + this.addEventListener("input", event => { + this.onInput(event); + }); + + this.addEventListener("keydown", event => this.handleKeyDown(event)); + + this.addEventListener( + "compositionstart", + event => { + if ( + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.handleStartComposition(); + } + }, + true + ); + + this.addEventListener( + "compositionend", + event => { + if ( + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.handleEndComposition(); + } + }, + true + ); + + this.addEventListener( + "focus", + event => { + this.attachController(); + if ( + window.gBrowser && + window.gBrowser.selectedBrowser.hasAttribute("usercontextid") + ) { + this.userContextId = parseInt( + window.gBrowser.selectedBrowser.getAttribute("usercontextid") + ); + } else { + this.userContextId = 0; + } + }, + true + ); + + this.addEventListener( + "blur", + event => { + if (!this._dontBlur) { + if (this.forceComplete && this.mController.matchCount >= 1) { + // If forceComplete is requested, we need to call the enter processing + // on blur so the input will be forced to the closest match. + // Thunderbird is the only consumer of forceComplete and this is used + // to force an recipient's email to the exact address book entry. + this.mController.handleEnter(true); + } + if (!this.ignoreBlurWhileSearching) { + this._dontClosePopup = this.disablePopupAutohide; + this.detachController(); + } + } + }, + true + ); + } + + connectedCallback() { + this.setAttribute("is", "autocomplete-input"); + this.setAttribute("autocomplete", "off"); + + this.mController = Cc[ + "@mozilla.org/autocomplete/controller;1" + ].getService(Ci.nsIAutoCompleteController); + this.mSearchNames = null; + this.mIgnoreInput = false; + this.noRollupOnEmptySearch = false; + + this._popup = null; + + this.nsIAutocompleteInput = this.getCustomInterfaceCallback( + Ci.nsIAutoCompleteInput + ); + + this.valueIsTyped = false; + } + + get popup() { + // Memoize the result in a field rather than replacing this property, + // so that it can be reset along with the binding. + if (this._popup) { + return this._popup; + } + + let popup = null; + let popupId = this.getAttribute("autocompletepopup"); + if (popupId) { + popup = document.getElementById(popupId); + } + + /* This path is only used in tests, we have the <popupset> and <panel> + in document for other usages */ + if (!popup) { + popup = document.createXULElement("panel", { + is: "autocomplete-richlistbox-popup", + }); + popup.setAttribute("type", "autocomplete-richlistbox"); + popup.setAttribute("noautofocus", "true"); + + if (!this._popupset) { + this._popupset = document.createXULElement("popupset"); + document.documentElement.appendChild(this._popupset); + } + + this._popupset.appendChild(popup); + } + popup.mInput = this; + + return (this._popup = popup); + } + + get popupElement() { + return this.popup; + } + + get controller() { + return this.mController; + } + + set popupOpen(val) { + if (val) { + this.openPopup(); + } else { + this.closePopup(); + } + } + + get popupOpen() { + return this.popup.popupOpen; + } + + set disableAutoComplete(val) { + this.setAttribute("disableautocomplete", val); + } + + get disableAutoComplete() { + return this.getAttribute("disableautocomplete") == "true"; + } + + set completeDefaultIndex(val) { + this.setAttribute("completedefaultindex", val); + } + + get completeDefaultIndex() { + return this.getAttribute("completedefaultindex") == "true"; + } + + set completeSelectedIndex(val) { + this.setAttribute("completeselectedindex", val); + } + + get completeSelectedIndex() { + return this.getAttribute("completeselectedindex") == "true"; + } + + set forceComplete(val) { + this.setAttribute("forcecomplete", val); + } + + get forceComplete() { + return this.getAttribute("forcecomplete") == "true"; + } + + set minResultsForPopup(val) { + this.setAttribute("minresultsforpopup", val); + } + + get minResultsForPopup() { + var m = parseInt(this.getAttribute("minresultsforpopup")); + return isNaN(m) ? 1 : m; + } + + set timeout(val) { + this.setAttribute("timeout", val); + } + + get timeout() { + var t = parseInt(this.getAttribute("timeout")); + return isNaN(t) ? 50 : t; + } + + set searchParam(val) { + this.setAttribute("autocompletesearchparam", val); + } + + get searchParam() { + return this.getAttribute("autocompletesearchparam") || ""; + } + + get searchCount() { + this.initSearchNames(); + return this.mSearchNames.length; + } + + get inPrivateContext() { + return this.PrivateBrowsingUtils.isWindowPrivate(window); + } + + get noRollupOnCaretMove() { + return this.popup.getAttribute("norolluponanchor") == "true"; + } + + set textValue(val) { + // "input" event is automatically dispatched by the editor if + // necessary. + this._setValueInternal(val, true); + } + + get textValue() { + return this.value; + } + /** + * =================== nsIDOMXULMenuListElement =================== + */ + get editable() { + return true; + } + + set open(val) { + if (val) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + get open() { + return this.getAttribute("open") == "true"; + } + + set value(val) { + this._setValueInternal(val, false); + } + + get value() { + return super.value; + } + + get focused() { + return this === document.activeElement; + } + /** + * maximum number of rows to display at a time when opening the popup normally + * (e.g., focus element and press the down arrow) + */ + set maxRows(val) { + this.setAttribute("maxrows", val); + } + + get maxRows() { + return parseInt(this.getAttribute("maxrows")) || 0; + } + /** + * maximum number of rows to display at a time when opening the popup by + * clicking the dropmarker (for inputs that have one) + */ + set maxdropmarkerrows(val) { + this.setAttribute("maxdropmarkerrows", val); + } + + get maxdropmarkerrows() { + return parseInt(this.getAttribute("maxdropmarkerrows"), 10) || 14; + } + /** + * option to allow scrolling through the list via the tab key, rather than + * tab moving focus out of the textbox + */ + set tabScrolling(val) { + this.setAttribute("tabscrolling", val); + } + + get tabScrolling() { + return this.getAttribute("tabscrolling") == "true"; + } + /** + * option to completely ignore any blur events while searches are + * still going on. + */ + set ignoreBlurWhileSearching(val) { + this.setAttribute("ignoreblurwhilesearching", val); + } + + get ignoreBlurWhileSearching() { + return this.getAttribute("ignoreblurwhilesearching") == "true"; + } + /** + * option to highlight entries that don't have any matches + */ + set highlightNonMatches(val) { + this.setAttribute("highlightnonmatches", val); + } + + get highlightNonMatches() { + return this.getAttribute("highlightnonmatches") == "true"; + } + + getSearchAt(aIndex) { + this.initSearchNames(); + return this.mSearchNames[aIndex]; + } + + selectTextRange(aStartIndex, aEndIndex) { + super.setSelectionRange(aStartIndex, aEndIndex); + } + + onSearchBegin() { + if (this.popup && typeof this.popup.onSearchBegin == "function") { + this.popup.onSearchBegin(); + } + } + + onSearchComplete() { + if (this.mController.matchCount == 0) { + this.setAttribute("nomatch", "true"); + } else { + this.removeAttribute("nomatch"); + } + + if (this.ignoreBlurWhileSearching && !this.focused) { + this.handleEnter(); + this.detachController(); + } + } + + onTextEntered(event) { + if (this.getAttribute("notifylegacyevents") === "true") { + let e = new CustomEvent("textEntered", { + bubbles: false, + cancelable: true, + detail: { rootEvent: event }, + }); + return !this.dispatchEvent(e); + } + return false; + } + + onTextReverted(event) { + if (this.getAttribute("notifylegacyevents") === "true") { + let e = new CustomEvent("textReverted", { + bubbles: false, + cancelable: true, + detail: { rootEvent: event }, + }); + return !this.dispatchEvent(e); + } + return false; + } + + /** + * =================== PRIVATE MEMBERS =================== + */ + + /* + * ::::::::::::: autocomplete controller ::::::::::::: + */ + + attachController() { + this.mController.input = this.nsIAutocompleteInput; + } + + detachController() { + if ( + this.mController.input && + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.input = null; + } + } + + /** + * ::::::::::::: popup opening ::::::::::::: + */ + openPopup() { + if (this.focused) { + this.popup.openAutocompletePopup(this.nsIAutocompleteInput, this); + } + } + + closePopup() { + if (this._dontClosePopup) { + delete this._dontClosePopup; + return; + } + this.popup.closePopup(); + } + + showHistoryPopup() { + // Store our "normal" maxRows on the popup, so that it can reset the + // value when the popup is hidden. + this.popup._normalMaxRows = this.maxRows; + + // Temporarily change our maxRows, since we want the dropdown to be a + // different size in this case. The popup's popupshowing/popuphiding + // handlers will take care of resetting this. + this.maxRows = this.maxdropmarkerrows; + + // Ensure that we have focus. + if (!this.focused) { + this.focus(); + } + this.attachController(); + this.mController.startSearch(""); + } + + toggleHistoryPopup() { + if (!this.popup.popupOpen) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + handleKeyDown(aEvent) { + // Re: urlbarDeferred, see the comment in urlbarBindings.xml. + if (aEvent.defaultPrevented && !aEvent.urlbarDeferred) { + return false; + } + + if ( + typeof this.onBeforeHandleKeyDown == "function" && + this.onBeforeHandleKeyDown(aEvent) + ) { + return true; + } + + const isMac = AppConstants.platform == "macosx"; + var cancel = false; + + // Catch any keys that could potentially move the caret. Ctrl can be + // used in combination with these keys on Windows and Linux; and Alt + // can be used on OS X, so make sure the unused one isn't used. + let metaKey = isMac ? aEvent.ctrlKey : aEvent.altKey; + if (!metaKey) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_LEFT: + case KeyEvent.DOM_VK_RIGHT: + case KeyEvent.DOM_VK_HOME: + cancel = this.mController.handleKeyNavigation(aEvent.keyCode); + break; + } + } + + // Handle keys that are not part of a keyboard shortcut (no Ctrl or Alt) + if (!aEvent.ctrlKey && !aEvent.altKey) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_TAB: + if (this.tabScrolling && this.popup.popupOpen) { + cancel = this.mController.handleKeyNavigation( + aEvent.shiftKey ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN + ); + } else if (this.forceComplete && this.mController.matchCount >= 1) { + this.mController.handleTab(); + } + break; + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_PAGE_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + cancel = this.mController.handleKeyNavigation(aEvent.keyCode); + break; + } + } + + // Handle readline/emacs-style navigation bindings on Mac. + if ( + isMac && + this.popup.popupOpen && + aEvent.ctrlKey && + (aEvent.key === "n" || aEvent.key === "p") + ) { + const effectiveKey = + aEvent.key === "p" ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN; + cancel = this.mController.handleKeyNavigation(effectiveKey); + } + + // Handle keys we know aren't part of a shortcut, even with Alt or + // Ctrl. + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + cancel = this.mController.handleEscape(); + break; + case KeyEvent.DOM_VK_RETURN: + if (isMac) { + // Prevent the default action, since it will beep on Mac + if (aEvent.metaKey) { + aEvent.preventDefault(); + } + } + if (this.popup.selectedIndex >= 0) { + this.popupSelectedIndex = this.popup.selectedIndex; + } + cancel = this.handleEnter(aEvent); + break; + case KeyEvent.DOM_VK_DELETE: + if (isMac && !aEvent.shiftKey) { + break; + } + cancel = this.handleDelete(); + break; + case KeyEvent.DOM_VK_BACK_SPACE: + if (isMac && aEvent.shiftKey) { + cancel = this.handleDelete(); + } + break; + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_UP: + if (aEvent.altKey) { + this.toggleHistoryPopup(); + } + break; + case KeyEvent.DOM_VK_F4: + if (!isMac) { + this.toggleHistoryPopup(); + } + break; + } + + if (cancel) { + aEvent.stopPropagation(); + aEvent.preventDefault(); + } + + return true; + } + + handleEnter(event) { + return this.mController.handleEnter(false, event || null); + } + + handleDelete() { + return this.mController.handleDelete(); + } + + /** + * ::::::::::::: miscellaneous ::::::::::::: + */ + initSearchNames() { + if (!this.mSearchNames) { + var names = this.getAttribute("autocompletesearch"); + if (!names) { + this.mSearchNames = []; + } else { + this.mSearchNames = names.split(" "); + } + } + } + + _focus() { + this._dontBlur = true; + this.focus(); + this._dontBlur = false; + } + + resetActionType() { + if (this.mIgnoreInput) { + return; + } + this.removeAttribute("actiontype"); + } + + _setValueInternal(value, isUserInput) { + this.mIgnoreInput = true; + + if (typeof this.onBeforeValueSet == "function") { + value = this.onBeforeValueSet(value); + } + + this.valueIsTyped = false; + if (isUserInput) { + super.setUserInput(value); + } else { + super.value = value; + } + + this.mIgnoreInput = false; + var event = document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + super.dispatchEvent(event); + return value; + } + + onInput(aEvent) { + if ( + !this.mIgnoreInput && + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.valueIsTyped = true; + this.mController.handleText(); + } + this.resetActionType(); + } + } + + MozHTMLElement.implementCustomInterface(AutocompleteInput, [ + Ci.nsIAutoCompleteInput, + Ci.nsIDOMXULMenuListElement, + ]); + customElements.define("autocomplete-input", AutocompleteInput, { + extends: "input", + }); +} diff --git a/toolkit/content/widgets/autocomplete-popup.js b/toolkit/content/widgets/autocomplete-popup.js new file mode 100644 index 0000000000..f033511e07 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-popup.js @@ -0,0 +1,637 @@ +/* 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 MozPopupElement = MozElements.MozElementMixin(XULPopupElement); + MozElements.MozAutocompleteRichlistboxPopup = class MozAutocompleteRichlistboxPopup extends ( + MozPopupElement + ) { + constructor() { + super(); + + this.attachShadow({ mode: "open" }); + + { + let slot = document.createElement("slot"); + slot.part = "content"; + this.shadowRoot.appendChild(slot); + } + + this.mInput = null; + this.mPopupOpen = false; + this._currentIndex = 0; + this._disabledItemClicked = false; + + this.setListeners(); + } + + initialize() { + this.setAttribute("ignorekeys", "true"); + this.setAttribute("level", "top"); + this.setAttribute("consumeoutsideclicks", "never"); + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + /** + * This is the default number of rows that we give the autocomplete + * popup when the textbox doesn't have a "maxrows" attribute + * for us to use. + */ + this.defaultMaxRows = 6; + + /** + * In some cases (e.g. when the input's dropmarker button is clicked), + * the input wants to display a popup with more rows. In that case, it + * should increase its maxRows property and store the "normal" maxRows + * in this field. When the popup is hidden, we restore the input's + * maxRows to the value stored in this field. + * + * This field is set to -1 between uses so that we can tell when it's + * been set by the input and when we need to set it in the popupshowing + * handler. + */ + this._normalMaxRows = -1; + this._previousSelectedIndex = -1; + this.mLastMoveTime = Date.now(); + this.mousedOverIndex = -1; + this._richlistbox = this.querySelector(".autocomplete-richlistbox"); + + if (!this.listEvents) { + this.listEvents = { + handleEvent: event => { + if (!this.parentNode) { + return; + } + + switch (event.type) { + case "mousedown": + this._disabledItemClicked = + !!event.target.closest("richlistitem")?.disabled; + break; + case "mouseup": + // Don't call onPopupClick for the scrollbar buttons, thumb, + // slider, etc. If we hit the richlistbox and not a + // richlistitem, we ignore the event. + if ( + event.target.closest("richlistbox,richlistitem").localName == + "richlistitem" && + !this._disabledItemClicked + ) { + this.onPopupClick(event); + } + this._disabledItemClicked = false; + break; + case "mousemove": + if (Date.now() - this.mLastMoveTime <= 30) { + return; + } + + let item = event.target.closest("richlistbox,richlistitem"); + + // If we hit the richlistbox and not a richlistitem, we ignore + // the event. + if (item.localName == "richlistbox") { + return; + } + + let index = this.richlistbox.getIndexOfItem(item); + + this.mousedOverIndex = index; + + if (item.selectedByMouseOver) { + this.richlistbox.selectedIndex = index; + } + + this.mLastMoveTime = Date.now(); + break; + } + }, + }; + } + this.richlistbox.addEventListener("mousedown", this.listEvents); + this.richlistbox.addEventListener("mouseup", this.listEvents); + this.richlistbox.addEventListener("mousemove", this.listEvents); + } + + get richlistbox() { + if (!this._richlistbox) { + this.initialize(); + } + return this._richlistbox; + } + + static get markup() { + return ` + <richlistbox class="autocomplete-richlistbox"/> + `; + } + + /** + * nsIAutoCompletePopup + */ + get input() { + return this.mInput; + } + + get overrideValue() { + return null; + } + + get popupOpen() { + return this.mPopupOpen; + } + + get maxRows() { + return (this.mInput && this.mInput.maxRows) || this.defaultMaxRows; + } + + set selectedIndex(val) { + if (val != this.richlistbox.selectedIndex) { + this._previousSelectedIndex = this.richlistbox.selectedIndex; + } + this.richlistbox.selectedIndex = val; + // Since ensureElementIsVisible may cause an expensive Layout flush, + // invoke it only if there may be a scrollbar, so if we could fetch + // more results than we can show at once. + // maxResults is the maximum number of fetched results, maxRows is the + // maximum number of rows we show at once, without a scrollbar. + if (this.mPopupOpen && this.maxResults > this.maxRows) { + // when clearing the selection (val == -1, so selectedItem will be + // null), we want to scroll back to the top. see bug #406194 + this.richlistbox.ensureElementIsVisible( + this.richlistbox.selectedItem || this.richlistbox.firstElementChild + ); + } + } + + get selectedIndex() { + return this.richlistbox.selectedIndex; + } + + get maxResults() { + // This is how many richlistitems will be kept around. + // Note, this getter may be overridden, or instances + // can have the nomaxresults attribute set to have no + // limit. + if (this.getAttribute("nomaxresults") == "true") { + return Infinity; + } + return 20; + } + + get matchCount() { + return Math.min(this.mInput.controller.matchCount, this.maxResults); + } + + get overflowPadding() { + return Number(this.getAttribute("overflowpadding")); + } + + set view(val) {} + + get view() { + return this.mInput.controller; + } + + closePopup() { + if (this.mPopupOpen) { + this.hidePopup(); + this.style.removeProperty("--panel-width"); + } + } + + getNextIndex(aReverse, aAmount, aIndex, aMaxRow) { + if (aMaxRow < 0) { + return -1; + } + + var newIdx = aIndex + (aReverse ? -1 : 1) * aAmount; + if ( + (aReverse && aIndex == -1) || + (newIdx > aMaxRow && aIndex != aMaxRow) + ) { + newIdx = aMaxRow; + } else if ((!aReverse && aIndex == -1) || (newIdx < 0 && aIndex != 0)) { + newIdx = 0; + } + + if ( + (newIdx < 0 && aIndex == 0) || + (newIdx > aMaxRow && aIndex == aMaxRow) + ) { + aIndex = -1; + } else { + aIndex = newIdx; + } + + return aIndex; + } + + onPopupClick(aEvent) { + this.input.controller.handleEnter(true, aEvent); + } + + onSearchBegin() { + this.mousedOverIndex = -1; + + if (typeof this._onSearchBegin == "function") { + this._onSearchBegin(); + } + } + + openAutocompletePopup(aInput, aElement) { + // until we have "baseBinding", (see bug #373652) this allows + // us to override openAutocompletePopup(), but still call + // the method on the base class + this._openAutocompletePopup(aInput, aElement); + } + + _openAutocompletePopup(aInput, aElement) { + if (!this._richlistbox) { + this.initialize(); + } + + if (!this.mPopupOpen) { + // It's possible that the panel is hidden initially + // to avoid impacting startup / new window performance + aInput.popup.hidden = false; + + this.mInput = aInput; + // clear any previous selection, see bugs 400671 and 488357 + this.selectedIndex = -1; + + var width = aElement.getBoundingClientRect().width; + this.style.setProperty("--panel-width", Math.max(width, 100) + "px"); + // invalidate() depends on the width attribute + this._invalidate(); + + this.openPopup(aElement, "after_start", 0, 0, false, false); + } + } + + invalidate(reason) { + // Don't bother doing work if we're not even showing + if (!this.mPopupOpen) { + return; + } + + this._invalidate(reason); + } + + _invalidate(reason) { + // collapsed if no matches + this.richlistbox.collapsed = this.matchCount == 0; + + // Update the richlistbox height. + if (this._adjustHeightRAFToken) { + cancelAnimationFrame(this._adjustHeightRAFToken); + this._adjustHeightRAFToken = null; + } + + if (this.mPopupOpen) { + this._adjustHeightOnPopupShown = false; + this._adjustHeightRAFToken = requestAnimationFrame(() => + this.adjustHeight() + ); + } else { + this._adjustHeightOnPopupShown = true; + } + + this._currentIndex = 0; + if (this._appendResultTimeout) { + clearTimeout(this._appendResultTimeout); + } + this._appendCurrentResult(reason); + } + + _collapseUnusedItems() { + let existingItemsCount = this.richlistbox.children.length; + for (let i = this.matchCount; i < existingItemsCount; ++i) { + let item = this.richlistbox.children[i]; + + item.collapsed = true; + if (typeof item._onCollapse == "function") { + item._onCollapse(); + } + } + } + + adjustHeight() { + // Figure out how many rows to show + let rows = this.richlistbox.children; + let numRows = Math.min(this.matchCount, this.maxRows, rows.length); + + // Default the height to 0 if we have no rows to show + let height = 0; + if (numRows) { + let firstRowRect = rows[0].getBoundingClientRect(); + if (this._rlbPadding == undefined) { + let style = window.getComputedStyle(this.richlistbox); + let paddingTop = parseInt(style.paddingTop) || 0; + let paddingBottom = parseInt(style.paddingBottom) || 0; + this._rlbPadding = paddingTop + paddingBottom; + } + + // The class `forceHandleUnderflow` is for the item might need to + // handle OverUnderflow or Overflow when the height of an item will + // be changed dynamically. + for (let i = 0; i < numRows; i++) { + if (rows[i].classList.contains("forceHandleUnderflow")) { + rows[i].handleOverUnderflow(); + } + } + + let lastRowRect = rows[numRows - 1].getBoundingClientRect(); + // Calculate the height to have the first row to last row shown + height = lastRowRect.bottom - firstRowRect.top + this._rlbPadding; + } + + this._collapseUnusedItems(); + + // We need to get the ceiling of the calculated value to ensure that the + // box fully contains all of its contents and doesn't cause a scrollbar. + this.richlistbox.style.height = Math.ceil(height) + "px"; + } + + _appendCurrentResult(invalidateReason) { + var controller = this.mInput.controller; + var matchCount = this.matchCount; + var existingItemsCount = this.richlistbox.children.length; + + // Process maxRows per chunk to improve performance and user experience + for (let i = 0; i < this.maxRows; i++) { + if (this._currentIndex >= matchCount) { + break; + } + let item; + let itemExists = this._currentIndex < existingItemsCount; + + let originalValue, originalText, originalType; + let style = controller.getStyleAt(this._currentIndex); + let value = + style && style.includes("autofill") + ? controller.getFinalCompleteValueAt(this._currentIndex) + : controller.getValueAt(this._currentIndex); + let label = controller.getLabelAt(this._currentIndex); + let comment = controller.getCommentAt(this._currentIndex); + let image = controller.getImageAt(this._currentIndex); + // trim the leading/trailing whitespace + let trimmedSearchString = controller.searchString + .replace(/^\s+/, "") + .replace(/\s+$/, ""); + + // Generic items can pack their details as JSON inside label + try { + const details = JSON.parse(label); + if (details.title) { + value = details.title; + label = details.subtitle ?? ""; + } + } catch {} + + let reusable = false; + if (itemExists) { + item = this.richlistbox.children[this._currentIndex]; + + // Url may be a modified version of value, see _adjustAcItem(). + originalValue = + item.getAttribute("url") || item.getAttribute("ac-value"); + originalText = item.getAttribute("ac-text"); + originalType = item.getAttribute("originaltype"); + + // The styles on the list which have different <content> structure and overrided + // _adjustAcItem() are unreusable. + const UNREUSEABLE_STYLES = [ + "autofill-profile", + "autofill-footer", + "autofill-clear-button", + "autofill-insecureWarning", + "generatedPassword", + "generic", + "importableLearnMore", + "importableLogins", + "insecureWarning", + "loginsFooter", + "loginWithOrigin", + ]; + // Reuse the item when its style is exactly equal to the previous style or + // neither of their style are in the UNREUSEABLE_STYLES. + reusable = + originalType === style || + !( + UNREUSEABLE_STYLES.includes(style) || + UNREUSEABLE_STYLES.includes(originalType) + ); + } + + // If no reusable item available, then create a new item. + if (!reusable) { + let options = null; + switch (style) { + case "autofill-profile": + options = { is: "autocomplete-profile-listitem" }; + break; + case "autofill-footer": + options = { is: "autocomplete-profile-listitem-footer" }; + break; + case "autofill-clear-button": + options = { is: "autocomplete-profile-listitem-clear-button" }; + break; + case "autofill-insecureWarning": + options = { is: "autocomplete-creditcard-insecure-field" }; + break; + case "generic": + options = { is: "autocomplete-two-line-richlistitem" }; + break; + case "importableLearnMore": + options = { + is: "autocomplete-importable-learn-more-richlistitem", + }; + break; + case "importableLogins": + options = { is: "autocomplete-importable-logins-richlistitem" }; + break; + case "generatedPassword": + options = { is: "autocomplete-generated-password-richlistitem" }; + break; + case "insecureWarning": + options = { is: "autocomplete-richlistitem-insecure-warning" }; + break; + case "loginsFooter": + options = { is: "autocomplete-richlistitem-logins-footer" }; + break; + case "loginWithOrigin": + options = { is: "autocomplete-login-richlistitem" }; + break; + default: + options = { is: "autocomplete-richlistitem" }; + } + item = document.createXULElement("richlistitem", options); + item.className = "autocomplete-richlistitem"; + } + + item.setAttribute("dir", this.style.direction); + item.setAttribute("ac-image", image); + item.setAttribute("ac-value", value); + item.setAttribute("ac-label", label); + item.setAttribute("ac-comment", comment); + item.setAttribute("ac-text", trimmedSearchString); + + // Completely reuse the existing richlistitem for invalidation + // due to new results, but only when: the item is the same, *OR* + // we are about to replace the currently moused-over item, to + // avoid surprising the user. + let iface = Ci.nsIAutoCompletePopup; + if ( + reusable && + originalText == trimmedSearchString && + invalidateReason == iface.INVALIDATE_REASON_NEW_RESULT && + (originalValue == value || + this.mousedOverIndex === this._currentIndex) + ) { + // try to re-use the existing item + item._reuseAcItem(); + this._currentIndex++; + continue; + } else { + if (typeof item._cleanup == "function") { + item._cleanup(); + } + item.setAttribute("originaltype", style); + } + + if (reusable) { + // Adjust only when the result's type is reusable for existing + // item's. Otherwise, we might insensibly call old _adjustAcItem() + // as new binding has not been attached yet. + // We don't need to worry about switching to new binding, since + // _adjustAcItem() will fired by its own constructor accordingly. + item._adjustAcItem(); + item.collapsed = false; + } else if (itemExists) { + let oldItem = this.richlistbox.children[this._currentIndex]; + this.richlistbox.replaceChild(item, oldItem); + } else { + this.richlistbox.appendChild(item); + } + + this._currentIndex++; + } + + if (typeof this.onResultsAdded == "function") { + // The items bindings may not be attached yet, so we must delay this + // before we can properly handle items properly without breaking + // the richlistbox. + Services.tm.dispatchToMainThread(() => this.onResultsAdded()); + } + + if (this._currentIndex < matchCount) { + // yield after each batch of items so that typing the url bar is + // responsive + this._appendResultTimeout = setTimeout( + () => this._appendCurrentResult(), + 0 + ); + } + } + + selectBy(aReverse, aPage) { + try { + var amount = aPage ? 5 : 1; + + // because we collapsed unused items, we can't use this.richlistbox.getRowCount(), we need to use the matchCount + this.selectedIndex = this.getNextIndex( + aReverse, + amount, + this.selectedIndex, + this.matchCount - 1 + ); + if (this.selectedIndex == -1) { + this.input._focus(); + } + } catch (ex) { + // do nothing - occasionally timer-related js errors happen here + // e.g. "this.selectedIndex has no properties", when you type fast and hit a + // navigation key before this popup has opened + } + } + + disconnectedCallback() { + if (this.listEvents) { + this.richlistbox.removeEventListener("mousedown", this.listEvents); + this.richlistbox.removeEventListener("mouseup", this.listEvents); + this.richlistbox.removeEventListener("mousemove", this.listEvents); + delete this.listEvents; + } + } + + setListeners() { + this.addEventListener("popupshowing", event => { + // If normalMaxRows wasn't already set by the input, then set it here + // so that we restore the correct number when the popup is hidden. + + // Null-check this.mInput; see bug 1017914 + if (this._normalMaxRows < 0 && this.mInput) { + this._normalMaxRows = this.mInput.maxRows; + } + + this.mPopupOpen = true; + }); + + this.addEventListener("popupshown", event => { + if (this._adjustHeightOnPopupShown) { + this._adjustHeightOnPopupShown = false; + this.adjustHeight(); + } + }); + + this.addEventListener("popuphiding", event => { + var isListActive = true; + if (this.selectedIndex == -1) { + isListActive = false; + } + this.input.controller.stopSearch(); + + this.mPopupOpen = false; + + // Reset the maxRows property to the cached "normal" value (if there's + // any), and reset normalMaxRows so that we can detect whether it was set + // by the input when the popupshowing handler runs. + + // Null-check this.mInput; see bug 1017914 + if (this.mInput && this._normalMaxRows > 0) { + this.mInput.maxRows = this._normalMaxRows; + } + this._normalMaxRows = -1; + // If the list was being navigated and then closed, make sure + // we fire accessible focus event back to textbox + + // Null-check this.mInput; see bug 1017914 + if (isListActive && this.mInput) { + this.mInput.mIgnoreFocus = true; + this.mInput._focus(); + this.mInput.mIgnoreFocus = false; + } + }); + } + }; + + MozPopupElement.implementCustomInterface( + MozElements.MozAutocompleteRichlistboxPopup, + [Ci.nsIAutoCompletePopup] + ); + + customElements.define( + "autocomplete-richlistbox-popup", + MozElements.MozAutocompleteRichlistboxPopup, + { + extends: "panel", + } + ); +} diff --git a/toolkit/content/widgets/autocomplete-richlistitem.js b/toolkit/content/widgets/autocomplete-richlistitem.js new file mode 100644 index 0000000000..ccbd37e132 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-richlistitem.js @@ -0,0 +1,873 @@ +/* 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 { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" + ); + + MozElements.MozAutocompleteRichlistitem = class MozAutocompleteRichlistitem extends ( + MozElements.MozRichlistitem + ) { + constructor() { + super(); + + /** + * This overrides listitem's mousedown handler because we want to set the + * selected item even when the shift or accel keys are pressed. + */ + this.addEventListener("mousedown", event => { + // Call this.control only once since it's not a simple getter. + let control = this.control; + if (!control || control.disabled) { + return; + } + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + }); + + this.addEventListener("mouseover", event => { + // The point of implementing this handler is to allow drags to change + // the selected item. If the user mouses down on an item, it becomes + // selected. If they then drag the mouse to another item, select it. + // Handle all three primary mouse buttons: right, left, and wheel, since + // all three change the selection on mousedown. + let mouseDown = event.buttons & 0b111; + if (!mouseDown) { + return; + } + // Call this.control only once since it's not a simple getter. + let control = this.control; + if (!control || control.disabled) { + return; + } + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + }); + + this.addEventListener("overflow", () => this._onOverflow()); + this.addEventListener("underflow", () => this._onUnderflow()); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + + this._boundaryCutoff = null; + this._inOverflow = false; + + this._adjustAcItem(); + } + + static get inheritedAttributes() { + return { + ".ac-type-icon": "selected,current,type", + ".ac-site-icon": "src=image,selected,type", + ".ac-title": "selected", + ".ac-title-text": "selected", + ".ac-separator": "selected,type", + ".ac-url": "selected", + ".ac-url-text": "selected", + }; + } + + static get markup() { + return ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <hbox class="ac-title" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text"/> + </description> + </hbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center" aria-hidden="true"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + get _typeIcon() { + return this.querySelector(".ac-type-icon"); + } + + get _titleText() { + return this.querySelector(".ac-title-text"); + } + + get _separator() { + return this.querySelector(".ac-separator"); + } + + get _urlText() { + return this.querySelector(".ac-url-text"); + } + + get _stringBundle() { + if (!this.__stringBundle) { + this.__stringBundle = Services.strings.createBundle( + "chrome://global/locale/autocomplete.properties" + ); + } + return this.__stringBundle; + } + + get boundaryCutoff() { + if (!this._boundaryCutoff) { + this._boundaryCutoff = Services.prefs.getIntPref( + "toolkit.autocomplete.richBoundaryCutoff" + ); + } + return this._boundaryCutoff; + } + + _cleanup() { + this.removeAttribute("url"); + this.removeAttribute("image"); + this.removeAttribute("title"); + this.removeAttribute("text"); + } + + _onOverflow() { + this._inOverflow = true; + this._handleOverflow(); + } + + _onUnderflow() { + this._inOverflow = false; + this._handleOverflow(); + } + + _getBoundaryIndices(aText, aSearchTokens) { + // Short circuit for empty search ([""] == "") + if (aSearchTokens == "") { + return [0, aText.length]; + } + + // Find which regions of text match the search terms + let regions = []; + for (let search of Array.prototype.slice.call(aSearchTokens)) { + let matchIndex = -1; + let searchLen = search.length; + + // Find all matches of the search terms, but stop early for perf + let lowerText = aText.substr(0, this.boundaryCutoff).toLowerCase(); + while ((matchIndex = lowerText.indexOf(search, matchIndex + 1)) >= 0) { + regions.push([matchIndex, matchIndex + searchLen]); + } + } + + // Sort the regions by start position then end position + regions = regions.sort((a, b) => { + let start = a[0] - b[0]; + return start == 0 ? a[1] - b[1] : start; + }); + + // Generate the boundary indices from each region + let start = 0; + let end = 0; + let boundaries = []; + let len = regions.length; + for (let i = 0; i < len; i++) { + // We have a new boundary if the start of the next is past the end + let region = regions[i]; + if (region[0] > end) { + // First index is the beginning of match + boundaries.push(start); + // Second index is the beginning of non-match + boundaries.push(end); + + // Track the new region now that we've stored the previous one + start = region[0]; + } + + // Push back the end index for the current or new region + end = Math.max(end, region[1]); + } + + // Add the last region + boundaries.push(start); + boundaries.push(end); + + // Put on the end boundary if necessary + if (end < aText.length) { + boundaries.push(aText.length); + } + + // Skip the first item because it's always 0 + return boundaries.slice(1); + } + + _getSearchTokens(aSearch) { + let search = aSearch.toLowerCase(); + return search.split(/\s+/); + } + + _setUpDescription(aDescriptionElement, aText) { + // Get rid of all previous text + if (!aDescriptionElement) { + return; + } + while (aDescriptionElement.hasChildNodes()) { + aDescriptionElement.firstChild.remove(); + } + + // Get the indices that separate match and non-match text + let search = this.getAttribute("text"); + let tokens = this._getSearchTokens(search); + let indices = this._getBoundaryIndices(aText, tokens); + + this._appendDescriptionSpans( + indices, + aText, + aDescriptionElement, + aDescriptionElement + ); + } + + _appendDescriptionSpans( + indices, + text, + spansParentElement, + descriptionElement + ) { + let next; + let start = 0; + let len = indices.length; + // Even indexed boundaries are matches, so skip the 0th if it's empty + for (let i = indices[0] == 0 ? 1 : 0; i < len; i++) { + next = indices[i]; + let spanText = text.substr(start, next - start); + start = next; + + if (i % 2 == 0) { + // Emphasize the text for even indices + let span = spansParentElement.appendChild( + document.createElementNS("http://www.w3.org/1999/xhtml", "span") + ); + this._setUpEmphasisSpan(span, descriptionElement); + span.textContent = spanText; + } else { + // Otherwise, it's plain text + spansParentElement.appendChild(document.createTextNode(spanText)); + } + } + } + + _setUpEmphasisSpan(aSpan, aDescriptionElement) { + aSpan.classList.add("ac-emphasize-text"); + switch (aDescriptionElement) { + case this._titleText: + aSpan.classList.add("ac-emphasize-text-title"); + break; + case this._urlText: + aSpan.classList.add("ac-emphasize-text-url"); + break; + } + } + + /** + * This will generate an array of emphasis pairs for use with + * _setUpEmphasisedSections(). Each pair is a tuple (array) that + * represents a block of text - containing the text of that block, and a + * boolean for whether that block should have an emphasis styling applied + * to it. + * + * These pairs are generated by parsing a localised string (aSourceString) + * with parameters, in the format that is used by + * nsIStringBundle.formatStringFromName(): + * + * "textA %1$S textB textC %2$S" + * + * Or: + * + * "textA %S" + * + * Where "%1$S", "%2$S", and "%S" are intended to be replaced by provided + * replacement strings. These are specified an array of tuples + * (aReplacements), each containing the replacement text and a boolean for + * whether that text should have an emphasis styling applied. This is used + * as a 1-based array - ie, "%1$S" is replaced by the item in the first + * index of aReplacements, "%2$S" by the second, etc. "%S" will always + * match the first index. + */ + _generateEmphasisPairs(aSourceString, aReplacements) { + let pairs = []; + + // Split on %S, %1$S, %2$S, etc. ie: + // "textA %S" + // becomes ["textA ", "%S"] + // "textA %1$S textB textC %2$S" + // becomes ["textA ", "%1$S", " textB textC ", "%2$S"] + let parts = aSourceString.split(/(%(?:[0-9]+\$)?S)/); + + for (let part of parts) { + // The above regex will actually give us an empty string at the + // end - we don't want that, as we don't want to later generate an + // empty text node for it. + if (part.length === 0) { + continue; + } + + // Determine if this token is a replacement token or a normal text + // token. If it is a replacement token, we want to extract the + // numerical number. However, we still want to match on "$S". + let match = part.match(/^%(?:([0-9]+)\$)?S$/); + + if (match) { + // "%S" doesn't have a numerical number in it, but will always + // be assumed to be 1. Furthermore, the input string specifies + // these with a 1-based index, but we want a 0-based index. + let index = (match[1] || 1) - 1; + + if (index >= 0 && index < aReplacements.length) { + pairs.push([...aReplacements[index]]); + } + } else { + pairs.push([part]); + } + } + + return pairs; + } + + /** + * _setUpEmphasisedSections() has the same use as _setUpDescription, + * except instead of taking a string and highlighting given tokens, it takes + * an array of pairs generated by _generateEmphasisPairs(). This allows + * control over emphasising based on specific blocks of text, rather than + * search for substrings. + */ + _setUpEmphasisedSections(aDescriptionElement, aTextPairs) { + // Get rid of all previous text + while (aDescriptionElement.hasChildNodes()) { + aDescriptionElement.firstChild.remove(); + } + + for (let [text, emphasise] of aTextPairs) { + if (emphasise) { + let span = aDescriptionElement.appendChild( + document.createElementNS("http://www.w3.org/1999/xhtml", "span") + ); + span.textContent = text; + switch (emphasise) { + case "match": + this._setUpEmphasisSpan(span, aDescriptionElement); + break; + } + } else { + aDescriptionElement.appendChild(document.createTextNode(text)); + } + } + } + + _unescapeUrl(url) { + return Services.textToSubURI.unEscapeURIForUI(url); + } + + _reuseAcItem() { + this.collapsed = false; + + // The popup may have changed size between now and the last + // time the item was shown, so always handle over/underflow. + let dwu = window.windowUtils; + let popupWidth = dwu.getBoundsWithoutFlushing(this.parentNode).width; + if (!this._previousPopupWidth || this._previousPopupWidth != popupWidth) { + this._previousPopupWidth = popupWidth; + this.handleOverUnderflow(); + } + } + + _adjustAcItem() { + let originalUrl = this.getAttribute("ac-value"); + let title = this.getAttribute("ac-comment"); + this.setAttribute("url", originalUrl); + this.setAttribute("image", this.getAttribute("ac-image")); + this.setAttribute("title", title); + this.setAttribute("text", this.getAttribute("ac-text")); + + let type = this.getAttribute("originaltype"); + let types = new Set(type.split(/\s+/)); + // Remove types that should ultimately not be in the `type` string. + types.delete("autofill"); + type = [...types][0] || ""; + this.setAttribute("type", type); + + let displayUrl = this._unescapeUrl(originalUrl); + + // Show the domain as the title if we don't have a title. + if (!title) { + try { + let uri = Services.io.newURI(originalUrl); + // Not all valid URLs have a domain. + if (uri.host) { + title = uri.host; + } + } catch (e) {} + if (!title) { + title = displayUrl; + } + } + + if (Array.isArray(title)) { + this._setUpEmphasisedSections(this._titleText, title); + } else { + this._setUpDescription(this._titleText, title); + } + this._setUpDescription(this._urlText, displayUrl); + + // Removing the max-width may be jarring when the item is visible, but + // we have no other choice to properly crop the text. + // Removing max-widths may cause overflow or underflow events, that + // will set the _inOverflow property. In case both the old and the new + // text are overflowing, the overflow event won't happen, and we must + // enforce an _handleOverflow() call to update the max-widths. + let wasInOverflow = this._inOverflow; + this._removeMaxWidths(); + if (wasInOverflow && this._inOverflow) { + this._handleOverflow(); + } + } + + _removeMaxWidths() { + if (this._hasMaxWidths) { + this._titleText.style.removeProperty("max-width"); + this._urlText.style.removeProperty("max-width"); + this._hasMaxWidths = false; + } + } + + /** + * This method truncates the displayed strings as necessary. + */ + _handleOverflow() { + let itemRect = this.parentNode.getBoundingClientRect(); + let titleRect = this._titleText.getBoundingClientRect(); + let separatorRect = this._separator.getBoundingClientRect(); + let urlRect = this._urlText.getBoundingClientRect(); + let separatorURLWidth = separatorRect.width + urlRect.width; + + // Total width for the title and URL is the width of the item + // minus the start of the title text minus a little optional extra padding. + // This extra padding amount is basically arbitrary but keeps the text + // from getting too close to the popup's edge. + let dir = this.getAttribute("dir"); + let titleStart = + dir == "rtl" + ? itemRect.right - titleRect.right + : titleRect.left - itemRect.left; + + let popup = this.parentNode.parentNode; + let itemWidth = + itemRect.width - + titleStart - + popup.overflowPadding - + (popup.margins ? popup.margins.end : 0); + + let titleWidth = titleRect.width; + if (titleWidth + separatorURLWidth > itemWidth) { + // The percentage of the item width allocated to the title. + let titlePct = 0.66; + + let titleAvailable = itemWidth - separatorURLWidth; + let titleMaxWidth = Math.max(titleAvailable, itemWidth * titlePct); + if (titleWidth > titleMaxWidth) { + this._titleText.style.maxWidth = titleMaxWidth + "px"; + } + let urlMaxWidth = Math.max( + itemWidth - titleWidth, + itemWidth * (1 - titlePct) + ); + urlMaxWidth -= separatorRect.width; + this._urlText.style.maxWidth = urlMaxWidth + "px"; + this._hasMaxWidths = true; + } + } + + handleOverUnderflow() { + this._removeMaxWidths(); + this._handleOverflow(); + } + }; + + MozXULElement.implementCustomInterface( + MozElements.MozAutocompleteRichlistitem, + [Ci.nsIDOMXULSelectControlItemElement] + ); + + class MozAutocompleteRichlistitemInsecureWarning extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + let baseURL = Services.urlFormatter.formatURLPref( + "app.support.baseURL" + ); + window.openTrustedLinkIn(baseURL + "insecure-password", "tab", { + relatedToCurrent: true, + }); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + super.connectedCallback(); + + // Unlike other autocomplete items, the height of the insecure warning + // increases by wrapping. So "forceHandleUnderflow" is for container to + // recalculate an item's height and width. + this.classList.add("forceHandleUnderflow"); + } + + static get inheritedAttributes() { + return { + ".ac-type-icon": "selected,current,type", + ".ac-site-icon": "src=image,selected,type", + ".ac-title-text": "selected", + ".ac-separator": "selected,type", + ".ac-url": "selected", + ".ac-url-text": "selected", + }; + } + + static get markup() { + return ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <vbox class="ac-title" align="left"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text"/> + </description> + </vbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + get _learnMoreString() { + if (!this.__learnMoreString) { + this.__learnMoreString = Services.strings + .createBundle("chrome://passwordmgr/locale/passwordmgr.properties") + .GetStringFromName("insecureFieldWarningLearnMore"); + } + return this.__learnMoreString; + } + + /** + * Override _getSearchTokens to have the Learn More text emphasized + */ + _getSearchTokens(aSearch) { + return [this._learnMoreString.toLowerCase()]; + } + } + + class MozAutocompleteRichlistitemLoginsFooter extends MozElements.MozAutocompleteRichlistitem {} + + class MozAutocompleteImportableLearnMoreRichlistitem extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + } + + static get markup() { + return ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <vbox class="ac-title" align="left"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text" + data-l10n-id="autocomplete-import-learn-more"/> + </description> + </vbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + // Override to avoid clearing out fluent description. + _setUpDescription() {} + } + + class MozAutocompleteTwoLineRichlistitem extends MozElements.MozRichlistitem { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + this.initializeSecondaryAction(); + this._adjustAcItem(); + } + + initializeSecondaryAction() { + const button = this.querySelector(".ac-secondary-action"); + + if (this.onSecondaryAction) { + button.addEventListener("mousedown", event => { + event.stopPropagation(); + this.onSecondaryAction(); + }); + } else { + button?.remove(); + } + } + + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // getCommentAt: + ".line2-label": "text=ac-label", + ".ac-site-icon": "src=ac-image", + }; + } + + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="two-line-wrapper"> + <xul:image class="ac-site-icon"></xul:image> + <div class="labels-wrapper"> + <div class="label-row line1-label"></div> + <div class="label-row line2-label"></div> + </div> + <button class="ac-secondary-action"></button> + </div> + `; + } + + _adjustAcItem() {} + + _onOverflow() {} + + _onUnderflow() {} + + handleOverUnderflow() {} + } + + class MozAutocompleteLoginRichlistitem extends MozAutocompleteTwoLineRichlistitem { + connectedCallback() { + super.connectedCallback(); + this.firstChild.classList.add("ac-login-item"); + } + + onSecondaryAction() { + const details = JSON.parse(this.getAttribute("ac-label")); + LoginHelper.openPasswordManager(window, { + loginGuid: details?.guid, + }); + } + + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // Don't inherit ac-label with getCommentAt since the label is JSON. + ".ac-site-icon": "src=ac-image", + }; + } + + _adjustAcItem() { + super._adjustAcItem(); + + let details = JSON.parse(this.getAttribute("ac-label")); + this.querySelector(".line2-label").textContent = details.comment; + } + } + + class MozAutocompleteGeneratedPasswordRichlistitem extends MozAutocompleteTwoLineRichlistitem { + constructor() { + super(); + + // Line 2 and line 3 both display text with a different line-height than + // line 1 but we want the line-height to be the same so we wrap the text + // in <span> and only adjust the line-height via font CSS properties on them. + this.generatedPasswordText = document.createElement("span"); + + this.line3Text = document.createElement("span"); + this.line3 = document.createElement("div"); + this.line3.className = "label-row generated-password-autosave"; + this.line3.append(this.line3Text); + } + + get _autoSaveString() { + if (!this.__autoSaveString) { + let brandShorterName = Services.strings + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShorterName"); + this.__autoSaveString = Services.strings + .createBundle("chrome://passwordmgr/locale/passwordmgr.properties") + .formatStringFromName("generatedPasswordWillBeSaved", [ + brandShorterName, + ]); + } + return this.__autoSaveString; + } + + _adjustAcItem() { + let { generatedPassword, willAutoSaveGeneratedPassword } = JSON.parse( + this.getAttribute("ac-label") + ); + let line2Label = this.querySelector(".line2-label"); + line2Label.textContent = ""; + this.generatedPasswordText.textContent = generatedPassword; + line2Label.append(this.generatedPasswordText); + + if (willAutoSaveGeneratedPassword) { + this.line3Text.textContent = this._autoSaveString; + this.querySelector(".labels-wrapper").append(this.line3); + } else { + this.line3.remove(); + } + + super._adjustAcItem(); + } + } + + class MozAutocompleteImportableLoginsRichlistitem extends MozAutocompleteTwoLineRichlistitem { + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + } + + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // Don't inherit ac-label with getCommentAt since the label is JSON. + }; + } + + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="two-line-wrapper"> + <xul:image class="ac-site-icon" /> + <div class="labels-wrapper"> + <div class="label-row line1-label" data-l10n-name="line1" /> + <div class="label-row line2-label" data-l10n-name="line2" /> + </div> + </div> + `; + } + + _adjustAcItem() { + super._adjustAcItem(); + document.l10n.setAttributes( + this.querySelector(".labels-wrapper"), + `autocomplete-import-logins-${this.getAttribute("ac-value")}`, + { + host: JSON.parse(this.getAttribute("ac-label")).hostname.replace( + /^www\./, + "" + ), + } + ); + } + } + + customElements.define( + "autocomplete-richlistitem", + MozElements.MozAutocompleteRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-richlistitem-insecure-warning", + MozAutocompleteRichlistitemInsecureWarning, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-richlistitem-logins-footer", + MozAutocompleteRichlistitemLoginsFooter, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-two-line-richlistitem", + MozAutocompleteTwoLineRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-login-richlistitem", + MozAutocompleteLoginRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-generated-password-richlistitem", + MozAutocompleteGeneratedPasswordRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-importable-learn-more-richlistitem", + MozAutocompleteImportableLearnMoreRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-importable-logins-richlistitem", + MozAutocompleteImportableLoginsRichlistitem, + { + extends: "richlistitem", + } + ); +} diff --git a/toolkit/content/widgets/browser-custom-element.js b/toolkit/content/widgets/browser-custom-element.js new file mode 100644 index 0000000000..e9b29034fa --- /dev/null +++ b/toolkit/content/widgets/browser-custom-element.js @@ -0,0 +1,1959 @@ +/* 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" + ); + + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + let lazy = {}; + + ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + Finder: "resource://gre/modules/Finder.sys.mjs", + FinderParent: "resource://gre/modules/FinderParent.sys.mjs", + PopupBlocker: "resource://gre/actors/PopupBlockingParent.sys.mjs", + SelectParentHelper: "resource://gre/actors/SelectParent.sys.mjs", + RemoteWebNavigation: "resource://gre/modules/RemoteWebNavigation.sys.mjs", + }); + + ChromeUtils.defineLazyGetter(lazy, "blankURI", () => + Services.io.newURI("about:blank") + ); + + let lazyPrefs = {}; + XPCOMUtils.defineLazyPreferenceGetter( + lazyPrefs, + "unloadTimeoutMs", + "dom.beforeunload_timeout_ms" + ); + Object.defineProperty(lazy, "ProcessHangMonitor", { + configurable: true, + get() { + // Import if we can - this is a browser/ module so it may not be + // available, in which case we return null. We replace this getter + // when the module becomes available (should be on delayed startup + // when the first browser window loads, via BrowserGlue.sys.mjs). + const kURL = "resource:///modules/ProcessHangMonitor.sys.mjs"; + if (Cu.isESModuleLoaded(kURL)) { + let { ProcessHangMonitor } = ChromeUtils.importESModule(kURL); + // eslint-disable-next-line mozilla/valid-lazy + Object.defineProperty(lazy, "ProcessHangMonitor", { + value: ProcessHangMonitor, + }); + return ProcessHangMonitor; + } + return null; + }, + }); + + // Get SessionStore module in the same as ProcessHangMonitor above. + Object.defineProperty(lazy, "SessionStore", { + configurable: true, + get() { + const kURL = "resource:///modules/sessionstore/SessionStore.sys.mjs"; + if (Cu.isESModuleLoaded(kURL)) { + let { SessionStore } = ChromeUtils.importESModule(kURL); + // eslint-disable-next-line mozilla/valid-lazy + Object.defineProperty(lazy, "SessionStore", { + value: SessionStore, + }); + return SessionStore; + } + return null; + }, + }); + + const elementsToDestroyOnUnload = new Set(); + + window.addEventListener( + "unload", + () => { + for (let element of elementsToDestroyOnUnload.values()) { + element.destroy(); + } + elementsToDestroyOnUnload.clear(); + }, + { mozSystemGroup: true, once: true } + ); + + class MozBrowser extends MozElements.MozElementMixin(XULFrameElement) { + static get observedAttributes() { + return ["remote"]; + } + + constructor() { + super(); + + this.onPageHide = this.onPageHide.bind(this); + + this.isNavigating = false; + + this._documentURI = null; + this._characterSet = null; + this._documentContentType = null; + + this._inPermitUnload = new WeakSet(); + + this._originalURI = null; + this._searchTerms = ""; + // When we open a prompt in reaction to a 401, if this 401 comes from + // a different base domain, the url of that site will be stored here + // and will be used for auth prompt spoofing protections. + // See bug 791594 for reference. + this._currentAuthPromptURI = null; + /** + * These are managed by the tabbrowser: + */ + this.droppedLinkHandler = null; + this.mIconURL = null; + this.lastURI = null; + + ChromeUtils.defineLazyGetter(this, "popupBlocker", () => { + return new lazy.PopupBlocker(this); + }); + + this.addEventListener( + "dragover", + event => { + if (!this.droppedLinkHandler || event.defaultPrevented) { + return; + } + + // For drags that appear to be internal text (for example, tab drags), + // set the dropEffect to 'none'. This prevents the drop even if some + // other listener cancelled the event. + var types = event.dataTransfer.types; + if ( + types.includes("text/x-moz-text-internal") && + !types.includes("text/plain") + ) { + event.dataTransfer.dropEffect = "none"; + event.stopPropagation(); + event.preventDefault(); + } + + // No need to handle "dragover" in e10s, since nsDocShellTreeOwner.cpp in the child process + // handles that case using "@mozilla.org/content/dropped-link-handler;1" service. + if (this.isRemoteBrowser) { + return; + } + + let linkHandler = Services.droppedLinkHandler; + if (linkHandler.canDropLink(event, false)) { + event.preventDefault(); + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener( + "drop", + event => { + // No need to handle "drop" in e10s, since nsDocShellTreeOwner.cpp in the child process + // handles that case using "@mozilla.org/content/dropped-link-handler;1" service. + if ( + !this.droppedLinkHandler || + event.defaultPrevented || + this.isRemoteBrowser + ) { + return; + } + + let linkHandler = Services.droppedLinkHandler; + try { + if (!linkHandler.canDropLink(event, false)) { + return; + } + + // Pass true to prevent the dropping of javascript:/data: URIs + var links = linkHandler.dropLinks(event, true); + } catch (ex) { + return; + } + + if (links.length) { + let triggeringPrincipal = linkHandler.getTriggeringPrincipal(event); + this.droppedLinkHandler(event, links, triggeringPrincipal); + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener("dragstart", event => { + // If we're a remote browser dealing with a dragstart, stop it + // from propagating up, since our content process should be dealing + // with the mouse movement. + if (this.isRemoteBrowser) { + event.stopPropagation(); + } + }); + } + + resetFields() { + if (this.observer) { + try { + Services.obs.removeObserver( + this.observer, + "browser:purge-session-history" + ); + } catch (ex) { + // It's not clear why this sometimes throws an exception. + } + this.observer = null; + } + + let browser = this; + this.observer = { + observe(aSubject, aTopic, aState) { + if (aTopic == "browser:purge-session-history") { + browser.purgeSessionHistory(); + } else if (aTopic == "apz:cancel-autoscroll") { + if (aState == browser._autoScrollScrollId) { + // Set this._autoScrollScrollId to null, so in stopScroll() we + // don't call stopApzAutoscroll() (since it's APZ that + // initiated the stopping). + browser._autoScrollScrollId = null; + browser._autoScrollPresShellId = null; + + browser._autoScrollPopup.hidePopup(); + } + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + + this._documentURI = null; + + this._originalURI = null; + + this._currentAuthPromptURI = null; + + this._searchTerms = ""; + + this._documentContentType = null; + + this._loadContext = null; + + this._webBrowserFind = null; + + this._finder = null; + + this._remoteFinder = null; + + this._fastFind = null; + + this._lastSearchString = null; + + this._characterSet = ""; + + this._mayEnableCharacterEncodingMenu = null; + + this._contentPrincipal = null; + + this._contentPartitionedPrincipal = null; + + this._csp = null; + + this._referrerInfo = null; + + this._contentRequestContextID = null; + + this._rdmFullZoom = 1.0; + + this._isSyntheticDocument = false; + + this.mPrefs = Services.prefs; + + this._audioMuted = false; + + this._hasAnyPlayingMediaBeenBlocked = false; + + this._unselectedTabHoverMessageListenerCount = 0; + + this.urlbarChangeTracker = { + _startedLoadSinceLastUserTyping: false, + + startedLoad() { + this._startedLoadSinceLastUserTyping = true; + }, + finishedLoad() { + this._startedLoadSinceLastUserTyping = false; + }, + userTyped() { + this._startedLoadSinceLastUserTyping = false; + }, + }; + + this._userTypedValue = null; + + this._AUTOSCROLL_SNAP = 10; + + this._autoScrollBrowsingContext = null; + + this._startX = null; + + this._startY = null; + + this._autoScrollPopup = null; + + /** + * These IDs identify the scroll frame being autoscrolled. + */ + this._autoScrollScrollId = null; + + this._autoScrollPresShellId = null; + } + + connectedCallback() { + // We typically use this to avoid running JS that triggers a layout during parse + // (see comment on the delayConnectedCallback implementation). In this case, we + // are using it to avoid a leak - see https://bugzilla.mozilla.org/show_bug.cgi?id=1441935#c20. + if (this.delayConnectedCallback()) { + return; + } + + this.construct(); + } + + disconnectedCallback() { + this.destroy(); + } + + get autoscrollEnabled() { + if (this.getAttribute("autoscroll") == "false") { + return false; + } + + return this.mPrefs.getBoolPref("general.autoScroll", true); + } + + get canGoBack() { + return this.webNavigation.canGoBack; + } + + get canGoForward() { + return this.webNavigation.canGoForward; + } + + // While an auth prompt from a base domain different than the current sites is open, we want to display the url of the cross domain site. + // This is to prevent possible auth spoofing scenarios. + // The URL of the requesting origin is provided by 'currentAuthPromptURI', this will only be non null while an auth prompt is open. + // See bug 791594 for reference. + get currentURI() { + if (this.currentAuthPromptURI) { + return this.currentAuthPromptURI; + } + if (this.webNavigation) { + return this.webNavigation.currentURI; + } + return null; + } + + get documentURI() { + return this.isRemoteBrowser + ? this._documentURI + : this.contentDocument?.documentURIObject; + } + + get documentContentType() { + if (this.isRemoteBrowser) { + return this._documentContentType; + } + return this.contentDocument ? this.contentDocument.contentType : null; + } + + set documentContentType(aContentType) { + if (aContentType != null) { + if (this.isRemoteBrowser) { + this._documentContentType = aContentType; + } else { + this.contentDocument.documentContentType = aContentType; + } + } + } + + get loadContext() { + if (this._loadContext) { + return this._loadContext; + } + + let { frameLoader } = this; + if (!frameLoader) { + return null; + } + this._loadContext = frameLoader.loadContext; + return this._loadContext; + } + + get autoCompletePopup() { + return document.getElementById(this.getAttribute("autocompletepopup")); + } + + set suspendMediaWhenInactive(val) { + this.browsingContext.suspendMediaWhenInactive = val; + } + + get suspendMediaWhenInactive() { + return !!this.browsingContext?.suspendMediaWhenInactive; + } + + set docShellIsActive(val) { + this.browsingContext.isActive = val; + if (this.isRemoteBrowser) { + let remoteTab = this.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.renderLayers = val; + } + } + } + + get docShellIsActive() { + return !!this.browsingContext?.isActive; + } + + set renderLayers(val) { + if (this.isRemoteBrowser) { + let remoteTab = this.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.renderLayers = val; + } + } else { + this.docShellIsActive = val; + } + } + + get renderLayers() { + if (this.isRemoteBrowser) { + return !!this.frameLoader?.remoteTab?.renderLayers; + } + return this.docShellIsActive; + } + + get hasLayers() { + if (this.isRemoteBrowser) { + return !!this.frameLoader?.remoteTab?.hasLayers; + } + return this.docShellIsActive; + } + + get isRemoteBrowser() { + return this.getAttribute("remote") == "true"; + } + + get remoteType() { + return this.browsingContext?.currentRemoteType; + } + + get isCrashed() { + if (!this.isRemoteBrowser || !this.frameLoader) { + return false; + } + + return !this.frameLoader.remoteTab; + } + + get messageManager() { + // Bug 1524084 - Trying to get at the message manager while in the crashed state will + // create a new message manager that won't shut down properly when the crashed browser + // is removed from the DOM. We work around that right now by returning null if we're + // in the crashed state. + if (this.frameLoader && !this.isCrashed) { + return this.frameLoader.messageManager; + } + return null; + } + + get webBrowserFind() { + if (!this._webBrowserFind) { + this._webBrowserFind = this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserFind); + } + return this._webBrowserFind; + } + + get finder() { + if (this.isRemoteBrowser) { + if (!this._remoteFinder) { + this._remoteFinder = new lazy.FinderParent(this); + } + return this._remoteFinder; + } + if (!this._finder) { + if (!this.docShell) { + return null; + } + + this._finder = new lazy.Finder(this.docShell); + } + return this._finder; + } + + get fastFind() { + if (!this._fastFind) { + if (!("@mozilla.org/typeaheadfind;1" in Cc)) { + return null; + } + + var tabBrowser = this.getTabBrowser(); + if (tabBrowser && "fastFind" in tabBrowser) { + return (this._fastFind = tabBrowser.fastFind); + } + + if (!this.docShell) { + return null; + } + + this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance( + Ci.nsITypeAheadFind + ); + this._fastFind.init(this.docShell); + } + return this._fastFind; + } + + get outerWindowID() { + return this.browsingContext?.currentWindowGlobal?.outerWindowId; + } + + get innerWindowID() { + return this.browsingContext?.currentWindowGlobal?.innerWindowId || null; + } + + get browsingContext() { + if (this.frameLoader) { + return this.frameLoader.browsingContext; + } + return null; + } + /** + * Note that this overrides webNavigation on XULFrameElement, and duplicates the return value for the non-remote case + */ + get webNavigation() { + return this.isRemoteBrowser + ? this._remoteWebNavigation + : this.docShell && this.docShell.QueryInterface(Ci.nsIWebNavigation); + } + + get webProgress() { + return this.browsingContext?.webProgress; + } + + get sessionHistory() { + return this.webNavigation.sessionHistory; + } + + get contentTitle() { + return this.isRemoteBrowser + ? this.browsingContext?.currentWindowGlobal?.documentTitle + : this.contentDocument.title; + } + + forceEncodingDetection() { + if (this.isRemoteBrowser) { + this.sendMessageToActor("ForceEncodingDetection", {}, "BrowserTab"); + } else { + this.docShell.forceEncodingDetection(); + } + } + + get characterSet() { + return this.isRemoteBrowser ? this._characterSet : this.docShell.charset; + } + + get mayEnableCharacterEncodingMenu() { + return this.isRemoteBrowser + ? this._mayEnableCharacterEncodingMenu + : this.docShell.mayEnableCharacterEncodingMenu; + } + + set mayEnableCharacterEncodingMenu(aMayEnable) { + if (this.isRemoteBrowser) { + this._mayEnableCharacterEncodingMenu = aMayEnable; + } + } + + get contentPrincipal() { + return this.isRemoteBrowser + ? this._contentPrincipal + : this.contentDocument.nodePrincipal; + } + + get contentPartitionedPrincipal() { + return this.isRemoteBrowser + ? this._contentPartitionedPrincipal + : this.contentDocument.partitionedPrincipal; + } + + get cookieJarSettings() { + return this.isRemoteBrowser + ? this.browsingContext?.currentWindowGlobal?.cookieJarSettings + : this.contentDocument.cookieJarSettings; + } + + get csp() { + return this.isRemoteBrowser ? this._csp : this.contentDocument.csp; + } + + get contentRequestContextID() { + if (this.isRemoteBrowser) { + return this._contentRequestContextID; + } + try { + return this.contentDocument.documentLoadGroup.requestContextID; + } catch (e) { + return null; + } + } + + get referrerInfo() { + return this.isRemoteBrowser + ? this._referrerInfo + : this.contentDocument.referrerInfo; + } + + set fullZoom(val) { + if (val.toFixed(2) == this.fullZoom.toFixed(2)) { + return; + } + if (this.browsingContext.inRDMPane) { + this._rdmFullZoom = val; + let event = document.createEvent("Events"); + event.initEvent("FullZoomChange", true, false); + this.dispatchEvent(event); + } else { + this.browsingContext.fullZoom = val; + } + } + + get fullZoom() { + if (this.browsingContext.inRDMPane) { + return this._rdmFullZoom; + } + return this.browsingContext.fullZoom; + } + + set textZoom(val) { + if (val.toFixed(2) == this.textZoom.toFixed(2)) { + return; + } + this.browsingContext.textZoom = val; + } + + get textZoom() { + return this.browsingContext.textZoom; + } + + enterResponsiveMode() { + if (this.browsingContext.inRDMPane) { + return; + } + this.browsingContext.inRDMPane = true; + this._rdmFullZoom = this.browsingContext.fullZoom; + this.browsingContext.fullZoom = 1.0; + } + + leaveResponsiveMode() { + if (!this.browsingContext.inRDMPane) { + return; + } + this.browsingContext.inRDMPane = false; + this.browsingContext.fullZoom = this._rdmFullZoom; + } + + get isSyntheticDocument() { + if (this.isRemoteBrowser) { + return this._isSyntheticDocument; + } + return this.contentDocument.mozSyntheticDocument; + } + + get hasContentOpener() { + return !!this.browsingContext.opener; + } + + get audioMuted() { + return this._audioMuted; + } + + get shouldHandleUnselectedTabHover() { + return this._unselectedTabHoverMessageListenerCount > 0; + } + + set shouldHandleUnselectedTabHover(value) { + this._unselectedTabHoverMessageListenerCount += value ? 1 : -1; + } + + get securityUI() { + return this.browsingContext.secureBrowserUI; + } + + set userTypedValue(val) { + this.urlbarChangeTracker.userTyped(); + this._userTypedValue = val; + } + + get userTypedValue() { + return this._userTypedValue; + } + + get dontPromptAndDontUnload() { + return 1; + } + + get dontPromptAndUnload() { + return 2; + } + + set originalURI(aURI) { + if (aURI instanceof Ci.nsIURI) { + this._originalURI = aURI; + } + } + + get originalURI() { + return this._originalURI; + } + + set searchTerms(val) { + this._searchTerms = val; + } + + get searchTerms() { + return this._searchTerms; + } + + set currentAuthPromptURI(aURI) { + this._currentAuthPromptURI = aURI; + } + + get currentAuthPromptURI() { + return this._currentAuthPromptURI; + } + _wrapURIChangeCall(fn) { + if (!this.isRemoteBrowser) { + this.isNavigating = true; + try { + fn(); + } finally { + this.isNavigating = false; + } + } else { + fn(); + } + } + + goBack( + requireUserInteraction = lazy.BrowserUtils + .navigationRequireUserInteraction + ) { + var webNavigation = this.webNavigation; + if (webNavigation.canGoBack) { + this._wrapURIChangeCall(() => + webNavigation.goBack(requireUserInteraction) + ); + } + } + + goForward( + requireUserInteraction = lazy.BrowserUtils + .navigationRequireUserInteraction + ) { + var webNavigation = this.webNavigation; + if (webNavigation.canGoForward) { + this._wrapURIChangeCall(() => + webNavigation.goForward(requireUserInteraction) + ); + } + } + + reload() { + const nsIWebNavigation = Ci.nsIWebNavigation; + const flags = nsIWebNavigation.LOAD_FLAGS_NONE; + this.reloadWithFlags(flags); + } + + reloadWithFlags(aFlags) { + this.webNavigation.reload(aFlags); + } + + stop() { + const nsIWebNavigation = Ci.nsIWebNavigation; + const flags = nsIWebNavigation.STOP_ALL; + this.webNavigation.stop(flags); + } + + _fixLoadParamsToLoadURIOptions(params) { + let loadFlags = + params.loadFlags || params.flags || Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + delete params.flags; + params.loadFlags = loadFlags; + } + + /** + * throws exception for unknown schemes + */ + loadURI(uri, params = {}) { + if (!uri) { + uri = lazy.blankURI; + } + this._fixLoadParamsToLoadURIOptions(params); + this._wrapURIChangeCall(() => this.webNavigation.loadURI(uri, params)); + } + + /** + * throws exception for unknown schemes + */ + fixupAndLoadURIString(uriString, params = {}) { + if (!uriString) { + this.loadURI(null, params); + return; + } + this._fixLoadParamsToLoadURIOptions(params); + this._wrapURIChangeCall(() => + this.webNavigation.fixupAndLoadURIString(uriString, params) + ); + } + + gotoIndex(aIndex) { + this._wrapURIChangeCall(() => this.webNavigation.gotoIndex(aIndex)); + } + + preserveLayers(preserve) { + if (!this.isRemoteBrowser) { + return; + } + let { frameLoader } = this; + if (frameLoader.remoteTab) { + frameLoader.remoteTab.preserveLayers(preserve); + } + } + + deprioritize() { + if (!this.isRemoteBrowser) { + return; + } + let { remoteTab } = this.frameLoader; + if (remoteTab) { + remoteTab.priorityHint = false; + remoteTab.deprioritize(); + } + } + + getTabBrowser() { + if (this?.ownerGlobal?.gBrowser?.getTabForBrowser(this)) { + return this.ownerGlobal.gBrowser; + } + return null; + } + + addProgressListener(aListener, aNotifyMask) { + if (!aNotifyMask) { + aNotifyMask = Ci.nsIWebProgress.NOTIFY_ALL; + } + + this.webProgress.addProgressListener(aListener, aNotifyMask); + } + + removeProgressListener(aListener) { + this.webProgress.removeProgressListener(aListener); + } + + onPageHide(aEvent) { + // If we're browsing from the tab crashed UI to a URI that keeps + // this browser non-remote, we'll handle that here. + lazy.SessionStore?.maybeExitCrashedState(this); + + if (!this.docShell || !this.fastFind) { + return; + } + var tabBrowser = this.getTabBrowser(); + if ( + !tabBrowser || + !("fastFind" in tabBrowser) || + tabBrowser.selectedBrowser == this + ) { + this.fastFind.setDocShell(this.docShell); + } + } + + audioPlaybackStarted() { + if (this._audioMuted) { + return; + } + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackStarted", true, false); + this.dispatchEvent(event); + } + + audioPlaybackStopped() { + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackStopped", true, false); + this.dispatchEvent(event); + } + + /** + * When the pref "media.block-autoplay-until-in-foreground" is on, + * Gecko delays starting playback of media resources in tabs until the + * tab has been in the foreground or resumed by tab's play tab icon. + * - When Gecko delays starting playback of a media resource in a window, + * it sends a message to call activeMediaBlockStarted(). This causes the + * tab audio indicator to show. + * - When a tab is foregrounded, Gecko starts playing all delayed media + * resources in that tab, and sends a message to call + * activeMediaBlockStopped(). This causes the tab audio indicator to hide. + */ + activeMediaBlockStarted() { + this._hasAnyPlayingMediaBeenBlocked = true; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStarted", true, false); + this.dispatchEvent(event); + } + + activeMediaBlockStopped() { + if (!this._hasAnyPlayingMediaBeenBlocked) { + return; + } + this._hasAnyPlayingMediaBeenBlocked = false; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStopped", true, false); + this.dispatchEvent(event); + } + + mute(transientState) { + if (!transientState) { + this._audioMuted = true; + } + let context = this.frameLoader.browsingContext; + context.notifyMediaMutedChanged(true); + } + + unmute() { + this._audioMuted = false; + let context = this.frameLoader.browsingContext; + context.notifyMediaMutedChanged(false); + } + + resumeMedia() { + this.frameLoader.browsingContext.notifyStartDelayedAutoplayMedia(); + if (this._hasAnyPlayingMediaBeenBlocked) { + this._hasAnyPlayingMediaBeenBlocked = false; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStopped", true, false); + this.dispatchEvent(event); + } + } + + unselectedTabHover(hovered) { + if (!this.shouldHandleUnselectedTabHover) { + return; + } + this.sendMessageToActor( + "Browser:UnselectedTabHover", + { + hovered, + }, + "UnselectedTabHover", + "roots" + ); + } + + didStartLoadSinceLastUserTyping() { + return ( + !this.isNavigating && + this.urlbarChangeTracker._startedLoadSinceLastUserTyping + ); + } + + constrainPopup(popup) { + if (this.getAttribute("constrainpopups") != "false") { + let constraintRect = this.getBoundingClientRect(); + constraintRect = new DOMRect( + constraintRect.left + window.mozInnerScreenX, + constraintRect.top + window.mozInnerScreenY, + constraintRect.width, + constraintRect.height + ); + popup.setConstraintRect(constraintRect); + } else { + popup.setConstraintRect(new DOMRect(0, 0, 0, 0)); + } + } + + construct() { + elementsToDestroyOnUnload.add(this); + this.resetFields(); + this.mInitialized = true; + if (this.isRemoteBrowser) { + /* + * Don't try to send messages from this function. The message manager for + * the <browser> element may not be initialized yet. + */ + + this._remoteWebNavigation = new lazy.RemoteWebNavigation(this); + + // Initialize contentPrincipal to the about:blank principal for this loadcontext + let aboutBlank = Services.io.newURI("about:blank"); + let ssm = Services.scriptSecurityManager; + this._contentPrincipal = ssm.getLoadContextContentPrincipal( + aboutBlank, + this.loadContext + ); + this._contentPartitionedPrincipal = this._contentPrincipal; + // CSP for about:blank is null; if we ever change _contentPrincipal above, + // we should re-evaluate the CSP here. + this._csp = null; + + if (!this.hasAttribute("disablehistory")) { + Services.obs.addObserver( + this.observer, + "browser:purge-session-history", + true + ); + } + } + + try { + // |webNavigation.sessionHistory| will have been set by the frame + // loader when creating the docShell as long as this xul:browser + // doesn't have the 'disablehistory' attribute set. + if (this.docShell && this.webNavigation.sessionHistory) { + Services.obs.addObserver( + this.observer, + "browser:purge-session-history", + true + ); + + // enable global history if we weren't told otherwise + if ( + !this.hasAttribute("disableglobalhistory") && + !this.isRemoteBrowser + ) { + try { + this.docShell.browsingContext.useGlobalHistory = true; + } catch (ex) { + // This can occur if the Places database is locked + console.error("Error enabling browser global history: ", ex); + } + } + } + } catch (e) { + console.error(e); + } + try { + // Ensures the securityUI is initialized. + var securityUI = this.securityUI; // eslint-disable-line no-unused-vars + } catch (e) {} + + if (!this.isRemoteBrowser) { + this._remoteWebNavigation = null; + this.addEventListener("pagehide", this.onPageHide, true); + } + } + + /** + * This is necessary because custom elements don't have a "real" destructor. + * This method is called explicitly by tabbrowser, when changing remoteness, + * and when we're disconnected or the window unloads. + */ + destroy() { + elementsToDestroyOnUnload.delete(this); + + // If we're browsing from the tab crashed UI to a URI that causes the tab + // to go remote again, we catch this here, because swapping out the + // non-remote browser for a remote one doesn't cause the pagehide event + // to be fired. Previously, we used to do this in the frame script's + // unload handler. + lazy.SessionStore?.maybeExitCrashedState(this); + + // Make sure that any open select is closed. + let menulist = document.getElementById("ContentSelectDropdown"); + if (menulist?.open) { + lazy.SelectParentHelper.hide(menulist, this); + } + + this.resetFields(); + + if (!this.mInitialized) { + return; + } + + this.mInitialized = false; + this.lastURI = null; + + if (!this.isRemoteBrowser) { + this.removeEventListener("pagehide", this.onPageHide, true); + } + } + + updateForStateChange(aCharset, aDocumentURI, aContentType) { + if (this.isRemoteBrowser && this.messageManager) { + if (aCharset != null) { + this._characterSet = aCharset; + } + + if (aDocumentURI != null) { + this._documentURI = aDocumentURI; + } + + if (aContentType != null) { + this._documentContentType = aContentType; + } + } + } + + updateWebNavigationForLocationChange(aCanGoBack, aCanGoForward) { + if ( + this.isRemoteBrowser && + this.messageManager && + !Services.appinfo.sessionHistoryInParent + ) { + this._remoteWebNavigation._canGoBack = aCanGoBack; + this._remoteWebNavigation._canGoForward = aCanGoForward; + } + } + + updateForLocationChange( + aLocation, + aCharset, + aMayEnableCharacterEncodingMenu, + aDocumentURI, + aTitle, + aContentPrincipal, + aContentPartitionedPrincipal, + aCSP, + aReferrerInfo, + aIsSynthetic, + aHaveRequestContextID, + aRequestContextID, + aContentType + ) { + if (this.isRemoteBrowser && this.messageManager) { + if (aCharset != null) { + this._characterSet = aCharset; + this._mayEnableCharacterEncodingMenu = + aMayEnableCharacterEncodingMenu; + } + + if (aContentType != null) { + this._documentContentType = aContentType; + } + + this._remoteWebNavigation._currentURI = aLocation; + this._documentURI = aDocumentURI; + this._contentPrincipal = aContentPrincipal; + this._contentPartitionedPrincipal = aContentPartitionedPrincipal; + this._csp = aCSP; + this._referrerInfo = aReferrerInfo; + this._isSyntheticDocument = aIsSynthetic; + this._contentRequestContextID = aHaveRequestContextID + ? aRequestContextID + : null; + } + } + + purgeSessionHistory() { + if (this.isRemoteBrowser && !Services.appinfo.sessionHistoryInParent) { + this._remoteWebNavigation._canGoBack = false; + this._remoteWebNavigation._canGoForward = false; + } + + try { + if (Services.appinfo.sessionHistoryInParent) { + let sessionHistory = this.browsingContext?.sessionHistory; + if (!sessionHistory) { + return; + } + + // place the entry at current index at the end of the history list, so it won't get removed + if (sessionHistory.index < sessionHistory.count - 1) { + let indexEntry = sessionHistory.getEntryAtIndex( + sessionHistory.index + ); + sessionHistory.addEntry(indexEntry, true); + } + + let purge = sessionHistory.count; + if ( + this.browsingContext.currentWindowGlobal.documentURI != + "about:blank" + ) { + --purge; // Don't remove the page the user's staring at from shistory + } + + if (purge > 0) { + sessionHistory.purgeHistory(purge); + } + + return; + } + + this.sendMessageToActor( + "Browser:PurgeSessionHistory", + {}, + "PurgeSessionHistory", + "roots" + ); + } catch (ex) { + // This can throw if the browser has started to go away. + if (ex.result != Cr.NS_ERROR_NOT_INITIALIZED) { + throw ex; + } + } + } + + createAboutBlankDocumentViewer(aPrincipal, aPartitionedPrincipal) { + let principal = lazy.BrowserUtils.principalWithMatchingOA( + aPrincipal, + this.contentPrincipal + ); + let partitionedPrincipal = lazy.BrowserUtils.principalWithMatchingOA( + aPartitionedPrincipal, + this.contentPartitionedPrincipal + ); + + if (this.isRemoteBrowser) { + this.frameLoader.remoteTab.createAboutBlankDocumentViewer( + principal, + partitionedPrincipal + ); + } else { + this.docShell.createAboutBlankDocumentViewer( + principal, + partitionedPrincipal + ); + } + } + + _acquireAutoScrollWakeLock() { + const pm = Cc["@mozilla.org/power/powermanagerservice;1"].getService( + Ci.nsIPowerManagerService + ); + this._autoScrollWakelock = pm.newWakeLock("autoscroll", window); + } + + _releaseAutoScrollWakeLock() { + if (this._autoScrollWakelock) { + try { + this._autoScrollWakelock.unlock(); + } catch (e) { + // Ignore error since wake lock is already unlocked + } + this._autoScrollWakelock = null; + } + } + + stopScroll() { + if (this._autoScrollBrowsingContext) { + window.removeEventListener("mousemove", this, true); + window.removeEventListener("mousedown", this, true); + window.removeEventListener("mouseup", this, true); + window.removeEventListener("DOMMouseScroll", this, true); + window.removeEventListener("contextmenu", this, true); + window.removeEventListener("keydown", this, true); + window.removeEventListener("keypress", this, true); + window.removeEventListener("keyup", this, true); + + let autoScrollWnd = this._autoScrollBrowsingContext.currentWindowGlobal; + if (autoScrollWnd) { + autoScrollWnd + .getActor("AutoScroll") + .sendAsyncMessage("Autoscroll:Stop", {}); + } + + try { + Services.obs.removeObserver(this.observer, "apz:cancel-autoscroll"); + } catch (ex) { + // It's not clear why this sometimes throws an exception + } + + if (this._autoScrollScrollId != null) { + this._autoScrollBrowsingContext.stopApzAutoscroll( + this._autoScrollScrollId, + this._autoScrollPresShellId + ); + + this._autoScrollScrollId = null; + this._autoScrollPresShellId = null; + } + + this._autoScrollBrowsingContext = null; + this._releaseAutoScrollWakeLock(); + } + } + + _getAndMaybeCreateAutoScrollPopup() { + let autoscrollPopup = document.getElementById("autoscroller"); + if (!autoscrollPopup) { + autoscrollPopup = document.createXULElement("panel"); + autoscrollPopup.className = "autoscroller"; + autoscrollPopup.setAttribute("consumeoutsideclicks", "true"); + autoscrollPopup.setAttribute("rolluponmousewheel", "true"); + autoscrollPopup.id = "autoscroller"; + } + + return autoscrollPopup; + } + + startScroll({ + scrolldir, + screenXDevPx, + screenYDevPx, + scrollId, + presShellId, + browsingContext, + }) { + if (!this.autoscrollEnabled) { + return { autoscrollEnabled: false, usingApz: false }; + } + + // The popup size is 32px for the circle plus space for a 4px box-shadow + // on each side. + const POPUP_SIZE = 40; + if (!this._autoScrollPopup) { + this._autoScrollPopup = this._getAndMaybeCreateAutoScrollPopup(); + document.documentElement.appendChild(this._autoScrollPopup); + this._autoScrollPopup.removeAttribute("hidden"); + this._autoScrollPopup.setAttribute("noautofocus", "true"); + this._autoScrollPopup.style.height = POPUP_SIZE + "px"; + this._autoScrollPopup.style.width = POPUP_SIZE + "px"; + this._autoScrollPopup.style.margin = -POPUP_SIZE / 2 + "px"; + } + + // In desktop pixels. + let screenXDesktopPx = screenXDevPx / window.desktopToDeviceScale; + let screenYDesktopPx = screenYDevPx / window.desktopToDeviceScale; + + let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( + Ci.nsIScreenManager + ); + let screen = screenManager.screenForRect( + screenXDesktopPx, + screenYDesktopPx, + 1, + 1 + ); + + // we need these attributes so themers don't need to create per-platform packages + if (screen.colorDepth > 8) { + // need high color for transparency + // Exclude second-rate platforms + this._autoScrollPopup.setAttribute( + "transparent", + !/BeOS|OS\/2/.test(navigator.appVersion) + ); + // Enable translucency on Windows and Mac + this._autoScrollPopup.setAttribute( + "translucent", + AppConstants.platform == "win" || AppConstants.platform == "macosx" + ); + } + + this._autoScrollPopup.setAttribute("scrolldir", scrolldir); + this._autoScrollPopup.addEventListener("popuphidden", this, true); + + // In CSS pixels + let popupX; + let popupY; + { + let cssToDesktopScale = + window.devicePixelRatio / window.desktopToDeviceScale; + + // Sanitize screenX/screenY for available screen size with half the size + // of the popup removed. The popup uses negative margins to center on the + // coordinates we pass. Use desktop pixels to deal correctly with + // multi-monitor / multi-dpi scenarios. + let left = {}, + top = {}, + width = {}, + height = {}; + screen.GetAvailRectDisplayPix(left, top, width, height); + + let popupSizeDesktopPx = POPUP_SIZE * cssToDesktopScale; + let minX = left.value + 0.5 * popupSizeDesktopPx; + let maxX = left.value + width.value - 0.5 * popupSizeDesktopPx; + let minY = top.value + 0.5 * popupSizeDesktopPx; + let maxY = top.value + height.value - 0.5 * popupSizeDesktopPx; + + popupX = + Math.max(minX, Math.min(maxX, screenXDesktopPx)) / cssToDesktopScale; + popupY = + Math.max(minY, Math.min(maxY, screenYDesktopPx)) / cssToDesktopScale; + } + + // In CSS pixels. + let screenX = screenXDevPx / window.devicePixelRatio; + let screenY = screenYDevPx / window.devicePixelRatio; + + this._autoScrollPopup.openPopupAtScreen(popupX, popupY); + this._ignoreMouseEvents = true; + this._startX = screenX; + this._startY = screenY; + this._autoScrollBrowsingContext = browsingContext; + this._acquireAutoScrollWakeLock(); + + window.addEventListener("mousemove", this, true); + window.addEventListener("mousedown", this, true); + window.addEventListener("mouseup", this, true); + window.addEventListener("DOMMouseScroll", this, true); + window.addEventListener("contextmenu", this, true); + window.addEventListener("keydown", this, true); + window.addEventListener("keypress", this, true); + window.addEventListener("keyup", this, true); + + let usingApz = false; + + if ( + scrollId != null && + this.mPrefs.getBoolPref("apz.autoscroll.enabled", false) + ) { + // If APZ is handling the autoscroll, it may decide to cancel + // it of its own accord, so register an observer to allow it + // to notify us of that. + Services.obs.addObserver(this.observer, "apz:cancel-autoscroll", true); + + usingApz = browsingContext.startApzAutoscroll( + screenXDevPx, + screenYDevPx, + scrollId, + presShellId + ); + + // Save the IDs for later + this._autoScrollScrollId = scrollId; + this._autoScrollPresShellId = presShellId; + } + + return { autoscrollEnabled: true, usingApz }; + } + + cancelScroll() { + this._autoScrollPopup.hidePopup(); + } + + handleEvent(aEvent) { + if (this._autoScrollBrowsingContext) { + switch (aEvent.type) { + case "mousemove": { + var x = aEvent.screenX - this._startX; + var y = aEvent.screenY - this._startY; + + if ( + x > this._AUTOSCROLL_SNAP || + x < -this._AUTOSCROLL_SNAP || + y > this._AUTOSCROLL_SNAP || + y < -this._AUTOSCROLL_SNAP + ) { + this._ignoreMouseEvents = false; + } + break; + } + case "mouseup": + case "mousedown": + // The following mouse click/auxclick event on the autoscroller + // shouldn't be fired in web content for compatibility with Chrome. + aEvent.preventClickEvent(); + // fallthrough + case "contextmenu": { + if (!this._ignoreMouseEvents) { + // Use a timeout to prevent the mousedown from opening the popup again. + // Ideally, we could use preventDefault here, but contenteditable + // and middlemouse paste don't interact well. See bug 1188536. + setTimeout(() => this._autoScrollPopup.hidePopup(), 0); + } + this._ignoreMouseEvents = false; + break; + } + case "DOMMouseScroll": { + this._autoScrollPopup.hidePopup(); + aEvent.preventDefault(); + break; + } + case "popuphidden": { + // TODO: When the autoscroller is closed by clicking outside of it, + // we need to prevent following click event for compatibility + // with Chrome. However, there is no way to do that for now. + this._autoScrollPopup.removeEventListener( + "popuphidden", + this, + true + ); + this.stopScroll(); + break; + } + case "keydown": { + if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) { + // the escape key will be processed by + // nsXULPopupManager::KeyDown and the panel will be closed. + // So, don't consume the key event here. + break; + } + // don't break here. we need to eat keydown events. + } + // fall through + case "keypress": + case "keyup": { + // All keyevents should be eaten here during autoscrolling. + aEvent.stopPropagation(); + aEvent.preventDefault(); + break; + } + } + } + } + + closeBrowser() { + // The request comes from a XPCOM component, we'd want to redirect + // the request to tabbrowser. + let tabbrowser = this.getTabBrowser(); + if (tabbrowser) { + let tab = tabbrowser.getTabForBrowser(this); + if (tab) { + tabbrowser.removeTab(tab); + return; + } + } + + throw new Error( + "Closing a browser which was not attached to a tabbrowser is unsupported." + ); + } + + swapBrowsers(aOtherBrowser) { + // The request comes from a XPCOM component, we'd want to redirect + // the request to tabbrowser so tabbrowser will be setup correctly, + // and it will eventually call swapDocShells. + let ourTabBrowser = this.getTabBrowser(); + let otherTabBrowser = aOtherBrowser.getTabBrowser(); + if (ourTabBrowser && otherTabBrowser) { + let ourTab = ourTabBrowser.getTabForBrowser(this); + let otherTab = otherTabBrowser.getTabForBrowser(aOtherBrowser); + ourTabBrowser.swapBrowsers(ourTab, otherTab); + return; + } + + // One of us is not connected to a tabbrowser, so just swap. + this.swapDocShells(aOtherBrowser); + } + + swapDocShells(aOtherBrowser) { + if (this.isRemoteBrowser != aOtherBrowser.isRemoteBrowser) { + throw new Error( + "Can only swap docshells between browsers in the same process." + ); + } + + // Give others a chance to swap state. + // IMPORTANT: Since a swapDocShells call does not swap the messageManager + // instances attached to a browser to aOtherBrowser, others + // will need to add the message listeners to the new + // messageManager. + // This is not a bug in swapDocShells or the FrameLoader, + // merely a design decision: If message managers were swapped, + // so that no new listeners were needed, the new + // aOtherBrowser.messageManager would have listeners pointing + // to the JS global of the current browser, which would rather + // easily create leaks while swapping. + // IMPORTANT2: When the current browser element is removed from DOM, + // which is quite common after a swapDocShells call, its + // frame loader is destroyed, and that destroys the relevant + // message manager, which will remove the listeners. + let event = new CustomEvent("SwapDocShells", { detail: aOtherBrowser }); + this.dispatchEvent(event); + event = new CustomEvent("SwapDocShells", { detail: this }); + aOtherBrowser.dispatchEvent(event); + + // We need to swap fields that are tied to our docshell or related to + // the loaded page + // Fields which are built as a result of notifactions (pageshow/hide, + // DOMLinkAdded/Removed, onStateChange) should not be swapped here, + // because these notifications are dispatched again once the docshells + // are swapped. + var fieldsToSwap = ["_webBrowserFind", "_rdmFullZoom"]; + + if (this.isRemoteBrowser) { + fieldsToSwap.push( + ...[ + "_remoteWebNavigation", + "_remoteFinder", + "_documentURI", + "_documentContentType", + "_characterSet", + "_mayEnableCharacterEncodingMenu", + "_contentPrincipal", + "_contentPartitionedPrincipal", + "_isSyntheticDocument", + "_originalURI", + "_userTypedValue", + ] + ); + } + + var ourFieldValues = {}; + var otherFieldValues = {}; + for (let field of fieldsToSwap) { + ourFieldValues[field] = this[field]; + otherFieldValues[field] = aOtherBrowser[field]; + } + + if (window.PopupNotifications) { + PopupNotifications._swapBrowserNotifications(aOtherBrowser, this); + } + + try { + this.swapFrameLoaders(aOtherBrowser); + } catch (ex) { + // This may not be implemented for browser elements that are not + // attached to a BrowserDOMWindow. + } + + for (let field of fieldsToSwap) { + this[field] = otherFieldValues[field]; + aOtherBrowser[field] = ourFieldValues[field]; + } + + if (!this.isRemoteBrowser) { + // Null the current nsITypeAheadFind instances so that they're + // lazily re-created on access. We need to do this because they + // might have attached the wrong docShell. + this._fastFind = aOtherBrowser._fastFind = null; + } else { + // Rewire the remote listeners + this._remoteWebNavigation.swapBrowser(this); + aOtherBrowser._remoteWebNavigation.swapBrowser(aOtherBrowser); + + if (this._remoteFinder) { + this._remoteFinder.swapBrowser(this); + } + if (aOtherBrowser._remoteFinder) { + aOtherBrowser._remoteFinder.swapBrowser(aOtherBrowser); + } + } + + event = new CustomEvent("EndSwapDocShells", { detail: aOtherBrowser }); + this.dispatchEvent(event); + event = new CustomEvent("EndSwapDocShells", { detail: this }); + aOtherBrowser.dispatchEvent(event); + } + + getInPermitUnload(aCallback) { + if (this.isRemoteBrowser) { + let { remoteTab } = this.frameLoader; + if (!remoteTab) { + // If we're crashed, we're definitely not in this state anymore. + aCallback(false); + return; + } + + aCallback( + this._inPermitUnload.has(this.browsingContext.currentWindowGlobal) + ); + return; + } + + if (!this.docShell || !this.docShell.docViewer) { + aCallback(false); + return; + } + aCallback(this.docShell.docViewer.inPermitUnload); + } + + async asyncPermitUnload(action) { + let wgp = this.browsingContext.currentWindowGlobal; + if (this._inPermitUnload.has(wgp)) { + throw new Error("permitUnload is already running for this tab."); + } + + this._inPermitUnload.add(wgp); + try { + let permitUnload = await wgp.permitUnload( + action, + lazyPrefs.unloadTimeoutMs + ); + return { permitUnload }; + } finally { + this._inPermitUnload.delete(wgp); + } + } + + get hasBeforeUnload() { + function hasBeforeUnload(bc) { + if (bc.currentWindowContext?.hasBeforeUnload) { + return true; + } + return bc.children.some(hasBeforeUnload); + } + return hasBeforeUnload(this.browsingContext); + } + + permitUnload(action) { + if (this.isRemoteBrowser) { + if (!this.hasBeforeUnload) { + return { permitUnload: true }; + } + + // Don't bother asking if this browser is hung: + if ( + lazy.ProcessHangMonitor?.findActiveReport(this) || + lazy.ProcessHangMonitor?.findPausedReport(this) + ) { + return { permitUnload: true }; + } + + let result; + let success; + + this.asyncPermitUnload(action).then( + val => { + result = val; + success = true; + }, + err => { + result = err; + success = false; + } + ); + + // The permitUnload() promise will, alas, not call its resolution + // callbacks after the browser window the promise lives in has closed, + // so we have to check for that case explicitly. + Services.tm.spinEventLoopUntilOrQuit( + "browser-custom-element.js:permitUnload", + () => window.closed || success !== undefined + ); + if (success) { + return result; + } + throw result; + } + + if (!this.docShell || !this.docShell.docViewer) { + return { permitUnload: true }; + } + return { + permitUnload: this.docShell.docViewer.permitUnload(), + }; + } + + /** + * Gets a screenshot of this browser as an ImageBitmap. + * + * @param {Number} x + * The x coordinate of the region from the underlying document to capture + * as a screenshot. This is ignored if fullViewport is true. + * @param {Number} y + * The y coordinate of the region from the underlying document to capture + * as a screenshot. This is ignored if fullViewport is true. + * @param {Number} w + * The width of the region from the underlying document to capture as a + * screenshot. This is ignored if fullViewport is true. + * @param {Number} h + * The height of the region from the underlying document to capture as a + * screenshot. This is ignored if fullViewport is true. + * @param {Number} scale + * The scale factor for the captured screenshot. See the documentation for + * WindowGlobalParent.drawSnapshot for more detail. + * @param {String} backgroundColor + * The default background color for the captured screenshot. See the + * documentation for WindowGlobalParent.drawSnapshot for more detail. + * @param {boolean|undefined} fullViewport + * True if the viewport rect should be captured. If this is true, the + * x, y, w and h parameters are ignored. Defaults to false. + * @returns {Promise} + * @resolves {ImageBitmap} + */ + async drawSnapshot( + x, + y, + w, + h, + scale, + backgroundColor, + fullViewport = false + ) { + let rect = fullViewport ? null : new DOMRect(x, y, w, h); + try { + return this.browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + backgroundColor + ); + } catch (e) { + return false; + } + } + + dropLinks(aLinks, aTriggeringPrincipal) { + if (!this.droppedLinkHandler) { + return false; + } + let links = []; + for (let i = 0; i < aLinks.length; i += 3) { + links.push({ + url: aLinks[i], + name: aLinks[i + 1], + type: aLinks[i + 2], + }); + } + this.droppedLinkHandler(null, links, aTriggeringPrincipal); + return true; + } + + getContentBlockingLog() { + let windowGlobal = this.browsingContext.currentWindowGlobal; + if (!windowGlobal) { + return null; + } + return windowGlobal.contentBlockingLog; + } + + getContentBlockingEvents() { + let windowGlobal = this.browsingContext.currentWindowGlobal; + if (!windowGlobal) { + return 0; + } + return windowGlobal.contentBlockingEvents; + } + + // Send an asynchronous message to the remote child via an actor. + // Note: use this only for messages through an actor. For old-style + // messages, use the message manager. + // The value of the scope argument determines which browsing contexts + // are sent to: + // 'all' - send to actors associated with all descendant child frames. + // 'roots' - send only to actors associated with process roots. + // undefined/'' - send only to the top-level actor and not any descendants. + sendMessageToActor(messageName, args, actorName, scope) { + if (!this.frameLoader) { + return; + } + + function sendToChildren(browsingContext, childScope) { + let windowGlobal = browsingContext.currentWindowGlobal; + // If 'roots' is set, only send if windowGlobal.isProcessRoot is true. + if ( + windowGlobal && + (childScope != "roots" || windowGlobal.isProcessRoot) + ) { + windowGlobal.getActor(actorName).sendAsyncMessage(messageName, args); + } + + // Iterate as long as scope in assigned. Note that we use the original + // passed in scope, not childScope here. + if (scope) { + for (let context of browsingContext.children) { + sendToChildren(context, scope); + } + } + } + + // Pass no second argument to always send to the top-level browsing context. + sendToChildren(this.browsingContext); + } + + enterModalState() { + this.sendMessageToActor("EnterModalState", {}, "BrowserElement", "roots"); + } + + leaveModalState() { + this.sendMessageToActor( + "LeaveModalState", + { forceLeave: true }, + "BrowserElement", + "roots" + ); + } + + /** + * Can be called for a window with or without modal state. + * If the window is not in modal state, this is a no-op. + */ + maybeLeaveModalState() { + this.sendMessageToActor( + "LeaveModalState", + { forceLeave: false }, + "BrowserElement", + "roots" + ); + } + + getDevicePermissionOrigins(key) { + if (typeof key !== "string" || key.length === 0) { + throw new Error("Key must be non empty string."); + } + if (!this._devicePermissionOrigins) { + this._devicePermissionOrigins = new Map(); + } + let origins = this._devicePermissionOrigins.get(key); + if (!origins) { + origins = new Set(); + this._devicePermissionOrigins.set(key, origins); + } + return origins; + } + + // This method is replaced by frontend code in order to delay performing the + // process switch until some async operatin is completed. + // + // This is used by tabbrowser to flush SessionStore before a process switch. + async prepareToChangeRemoteness() { + /* no-op unless replaced */ + } + + // This method is replaced by frontend code in order to handle restoring + // remote session history + // + // Called immediately after changing remoteness. If this method returns + // `true`, Gecko will assume frontend handled resuming the load, and will + // not attempt to resume the load itself. + afterChangeRemoteness(browser, redirectLoadSwitchId) { + /* no-op unless replaced */ + return false; + } + + // Called by Gecko before the remoteness change happens, allowing for + // listeners, etc. to be stashed before the process switch. + beforeChangeRemoteness() { + // Fire the `WillChangeBrowserRemoteness` event, which may be hooked by + // frontend code for custom behaviour. + let event = document.createEvent("Events"); + event.initEvent("WillChangeBrowserRemoteness", true, false); + this.dispatchEvent(event); + + // Destroy ourselves to unregister from observer notifications + // FIXME: Can we get away with something less destructive here? + this.destroy(); + } + + finishChangeRemoteness(redirectLoadSwitchId) { + // Re-construct ourselves after the destroy in `beforeChangeRemoteness`. + this.construct(); + + // Fire the `DidChangeBrowserRemoteness` event, which may be hooked by + // frontend code for custom behaviour. + let event = document.createEvent("Events"); + event.initEvent("DidChangeBrowserRemoteness", true, false); + this.dispatchEvent(event); + + // Call into frontend code which may want to handle the load (e.g. to + // while restoring session state). + return this.afterChangeRemoteness(redirectLoadSwitchId); + } + } + + MozXULElement.implementCustomInterface(MozBrowser, [Ci.nsIBrowser]); + customElements.define("browser", MozBrowser); +} diff --git a/toolkit/content/widgets/button.js b/toolkit/content/widgets/button.js new file mode 100644 index 0000000000..ce48fac1e9 --- /dev/null +++ b/toolkit/content/widgets/button.js @@ -0,0 +1,312 @@ +/* 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. +{ + class MozButtonBase extends MozElements.BaseText { + constructor() { + super(); + + /** + * While it would seem we could do this by handling oncommand, we can't + * because any external oncommand handlers might get called before ours, + * and then they would see the incorrect value of checked. Additionally + * a command attribute would redirect the command events anyway. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + this._handleClick(); + }); + + this.addEventListener("keypress", event => { + if (event.key != " ") { + return; + } + this._handleClick(); + // Prevent page from scrolling on the space key. + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if (this.hasMenu()) { + if (this.open) { + return; + } + } else if (!this.inRichListItem) { + if ( + event.keyCode == KeyEvent.DOM_VK_UP || + (event.keyCode == KeyEvent.DOM_VK_LEFT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "ltr") || + (event.keyCode == KeyEvent.DOM_VK_RIGHT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "rtl") + ) { + event.preventDefault(); + window.document.commandDispatcher.rewindFocus(); + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_DOWN || + (event.keyCode == KeyEvent.DOM_VK_RIGHT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "ltr") || + (event.keyCode == KeyEvent.DOM_VK_LEFT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "rtl") + ) { + event.preventDefault(); + window.document.commandDispatcher.advanceFocus(); + return; + } + } + + if ( + event.keyCode || + event.charCode <= 32 || + event.altKey || + event.ctrlKey || + event.metaKey + ) { + return; + } // No printable char pressed, not a potential accesskey + + // Possible accesskey pressed + var charPressedLower = String.fromCharCode( + event.charCode + ).toLowerCase(); + + // If the accesskey of the current button is pressed, just activate it + if (this.accessKey.toLowerCase() == charPressedLower) { + this.click(); + return; + } + + // Search for accesskey in the list of buttons for this doc and each subdoc + // Get the buttons for the main document and all sub-frames + for ( + var frameCount = -1; + frameCount < window.top.frames.length; + frameCount++ + ) { + var doc = + frameCount == -1 + ? window.top.document + : window.top.frames[frameCount].document; + if (this.fireAccessKeyButton(doc.documentElement, charPressedLower)) { + return; + } + } + + // Test dialog buttons + let buttonBox = window.top.document.querySelector("dialog")?.buttonBox; + if (buttonBox) { + this.fireAccessKeyButton(buttonBox, charPressedLower); + } + }); + } + + set type(val) { + this.setAttribute("type", val); + } + + get type() { + return this.getAttribute("type"); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + } + + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set group(val) { + this.setAttribute("group", val); + } + + get group() { + return this.getAttribute("group"); + } + + set open(val) { + if (this.hasMenu()) { + this.openMenu(val); + } else if (val) { + // Fall back to just setting the attribute + this.setAttribute("open", "true"); + } else { + this.removeAttribute("open"); + } + } + + get open() { + return this.hasAttribute("open"); + } + + set checked(val) { + if (this.type == "radio" && val) { + var sibs = this.parentNode.getElementsByAttribute("group", this.group); + for (var i = 0; i < sibs.length; ++i) { + sibs[i].removeAttribute("checked"); + } + } + + if (val) { + this.setAttribute("checked", "true"); + } else { + this.removeAttribute("checked"); + } + } + + get checked() { + return this.hasAttribute("checked"); + } + + filterButtons(node) { + // if the node isn't visible, don't descend into it. + var cs = node.ownerGlobal.getComputedStyle(node); + if (cs.visibility != "visible" || cs.display == "none") { + return NodeFilter.FILTER_REJECT; + } + // but it may be a popup element, in which case we look at "state"... + if (XULPopupElement.isInstance(node) && node.state != "open") { + return NodeFilter.FILTER_REJECT; + } + // OK - the node seems visible, so it is a candidate. + if (node.localName == "button" && node.accessKey && !node.disabled) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + } + + fireAccessKeyButton(aSubtree, aAccessKeyLower) { + var iterator = aSubtree.ownerDocument.createTreeWalker( + aSubtree, + NodeFilter.SHOW_ELEMENT, + this.filterButtons + ); + while (iterator.nextNode()) { + var test = iterator.currentNode; + if ( + test.accessKey.toLowerCase() == aAccessKeyLower && + !test.disabled && + !test.collapsed && + !test.hidden + ) { + test.focus(); + test.click(); + return true; + } + } + return false; + } + + _handleClick() { + if (!this.disabled) { + if (this.type == "checkbox") { + this.checked = !this.checked; + } else if (this.type == "radio") { + this.checked = true; + } + } + } + } + + MozXULElement.implementCustomInterface(MozButtonBase, [ + Ci.nsIDOMXULButtonElement, + ]); + + MozElements.ButtonBase = MozButtonBase; + + class MozButton extends MozButtonBase { + static get inheritedAttributes() { + return { + ".box-inherit": "align,dir,pack,orient", + ".button-icon": "src=image", + ".button-text": "value=label,accesskey,crop", + ".button-menu-dropmarker": "open,disabled,label", + }; + } + + get icon() { + return this.querySelector(".button-icon"); + } + + static get buttonFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="box-inherit button-box" align="center" pack="center" flex="1" anonid="button-box"> + <image class="button-icon"/> + <label class="button-text"/> + </hbox>`), + true + ); + Object.defineProperty(this, "buttonFragment", { value: frag }); + return frag; + } + + static get menuFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="box-inherit button-box" align="center" pack="center" flex="1"> + <hbox class="box-inherit" align="center" pack="center" flex="1"> + <image class="button-icon"/> + <label class="button-text"/> + </hbox> + <dropmarker class="button-menu-dropmarker"/> + </hbox>`), + true + ); + Object.defineProperty(this, "menuFragment", { value: frag }); + return frag; + } + + get _hasConnected() { + return this.querySelector(":scope > .button-box") != null; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this._hasConnected) { + return; + } + + let fragment; + if (this.type === "menu") { + fragment = MozButton.menuFragment; + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN && event.key != " ") { + return; + } + + this.open = true; + // Prevent page from scrolling on the space key. + if (event.key == " ") { + event.preventDefault(); + } + }); + } else { + fragment = this.constructor.buttonFragment; + } + + this.appendChild(fragment.cloneNode(true)); + this.initializeAttributeInheritance(); + this.inRichListItem = !!this.closest("richlistitem"); + } + } + + customElements.define("button", MozButton); +} diff --git a/toolkit/content/widgets/calendar.js b/toolkit/content/widgets/calendar.js new file mode 100644 index 0000000000..53a8a69adb --- /dev/null +++ b/toolkit/content/widgets/calendar.js @@ -0,0 +1,485 @@ +/* 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"; + +/** + * Initialize the Calendar and generate nodes for week headers and days, and + * attach event listeners. + * + * @param {Object} options + * { + * {Number} calViewSize: Number of days to appear on a calendar view + * {Function} getDayString: Transform day number to string + * {Function} getWeekHeaderString: Transform day of week number to string + * {Function} setSelection: Set selection for dateKeeper + * {Function} setCalendarMonth: Update the month shown by the dateView + * to a specific month of a specific year + * } + * @param {Object} context + * { + * {DOMElement} weekHeader + * {DOMElement} daysView + * } + */ +function Calendar(options, context) { + this.context = context; + this.context.DAYS_IN_A_WEEK = 7; + this.state = { + days: [], + weekHeaders: [], + setSelection: options.setSelection, + setCalendarMonth: options.setCalendarMonth, + getDayString: options.getDayString, + getWeekHeaderString: options.getWeekHeaderString, + focusedDate: null, + }; + this.elements = { + weekHeaders: this._generateNodes( + this.context.DAYS_IN_A_WEEK, + context.weekHeader + ), + daysView: this._generateNodes(options.calViewSize, context.daysView), + }; + + this._attachEventListeners(); +} + +Calendar.prototype = { + /** + * Set new properties and render them. + * + * @param {Object} props + * { + * {Boolean} isVisible: Whether or not the calendar is in view + * {Array<Object>} days: Data for days + * { + * {Date} dateObj + * {Number} content + * {Array<String>} classNames + * {Boolean} enabled + * } + * {Array<Object>} weekHeaders: Data for weekHeaders + * { + * {Number} content + * {Array<String>} classNames + * } + * } + */ + setProps(props) { + if (props.isVisible) { + // Transform the days and weekHeaders array for rendering + const days = props.days.map( + ({ dateObj, content, classNames, enabled }) => { + return { + dateObj, + textContent: this.state.getDayString(content), + className: classNames.join(" "), + enabled, + }; + } + ); + const weekHeaders = props.weekHeaders.map(({ content, classNames }) => { + return { + textContent: this.state.getWeekHeaderString(content), + className: classNames.join(" "), + }; + }); + // Update the DOM nodes states + this._render({ + elements: this.elements.daysView, + items: days, + prevState: this.state.days, + }); + this._render({ + elements: this.elements.weekHeaders, + items: weekHeaders, + prevState: this.state.weekHeaders, + }); + // Update the state to current and place keyboard focus + this.state.days = days; + this.state.weekHeaders = weekHeaders; + this.focusDay(); + } + }, + + /** + * Render the items onto the DOM nodes + * @param {Object} + * { + * {Array<DOMElement>} elements + * {Array<Object>} items + * {Array<Object>} prevState: state of items from last render + * } + */ + _render({ elements, items, prevState }) { + let selected = {}; + let today = {}; + let sameDay = {}; + let firstDay = {}; + + for (let i = 0, l = items.length; i < l; i++) { + let el = elements[i]; + + // Check if state from last render has changed, if so, update the elements + if (!prevState[i] || prevState[i].textContent != items[i].textContent) { + el.textContent = items[i].textContent; + } + if (!prevState[i] || prevState[i].className != items[i].className) { + el.className = items[i].className; + } + + if (el.tagName === "td") { + el.setAttribute("role", "gridcell"); + + // Flush states from the previous view + el.removeAttribute("tabindex"); + el.removeAttribute("aria-disabled"); + el.removeAttribute("aria-selected"); + el.removeAttribute("aria-current"); + + // Set new states and properties + if ( + this.state.focusedDate && + this._isSameDayOfMonth(items[i].dateObj, this.state.focusedDate) && + !el.classList.contains("outside") + ) { + // When any other date was focused previously, send the focus + // to the same day of month, but only within the current month + sameDay.el = el; + sameDay.dateObj = items[i].dateObj; + } + if (el.classList.contains("today")) { + // Current date/today is communicated to assistive technology + el.setAttribute("aria-current", "date"); + if (!el.classList.contains("outside")) { + today.el = el; + today.dateObj = items[i].dateObj; + } + } + if (el.classList.contains("selection")) { + // Selection is communicated to assistive technology + // and may be included in the focus order when from the current month + el.setAttribute("aria-selected", "true"); + + if (!el.classList.contains("outside")) { + selected.el = el; + selected.dateObj = items[i].dateObj; + } + } else if (el.classList.contains("out-of-range")) { + // Dates that are outside of the range are not selected and cannot be + el.setAttribute("aria-disabled", "true"); + el.removeAttribute("aria-selected"); + } else { + // Other dates are not selected, but could be + el.setAttribute("aria-selected", "false"); + } + if (el.textContent === "1" && !firstDay.el) { + // When no previous day, no selection, or no current day/today + // is present, make the first of the month focusable + firstDay.dateObj = items[i].dateObj; + firstDay.dateObj.setUTCDate("1"); + + if (this._isSameDay(items[i].dateObj, firstDay.dateObj)) { + firstDay.el = el; + firstDay.dateObj = items[i].dateObj; + } + } + } + } + + // The previously focused date (if the picker is updated and the grid still + // contains the date) is always focusable. The selected date on init is also + // always focusable. If neither exist, we make the current day or the first + // day of the month focusable. + if (sameDay.el) { + sameDay.el.setAttribute("tabindex", "0"); + this.state.focusedDate = new Date(sameDay.dateObj); + } else if (selected.el) { + selected.el.setAttribute("tabindex", "0"); + this.state.focusedDate = new Date(selected.dateObj); + } else if (today.el) { + today.el.setAttribute("tabindex", "0"); + this.state.focusedDate = new Date(today.dateObj); + } else if (firstDay.el) { + firstDay.el.setAttribute("tabindex", "0"); + this.state.focusedDate = new Date(firstDay.dateObj); + } + }, + + /** + * Generate DOM nodes with HTML table markup + * + * @param {Number} size: Number of nodes to generate + * @param {DOMElement} context: Element to append the nodes to + * @return {Array<DOMElement>} + */ + _generateNodes(size, context) { + let frag = document.createDocumentFragment(); + let refs = []; + + // Create table row to present a week: + let rowEl = document.createElement("tr"); + for (let i = 0; i < size; i++) { + // Create table cell for a table header (weekday) or body (date) + let el; + if (context.classList.contains("week-header")) { + el = document.createElement("th"); + el.setAttribute("scope", "col"); + // Explicitly assigning the role as a workaround for the bug 1711273: + el.setAttribute("role", "columnheader"); + } else { + el = document.createElement("td"); + } + + el.dataset.id = i; + refs.push(el); + rowEl.appendChild(el); + + // Ensure each table row (week) has only + // seven table cells (days) for a Gregorian calendar + if ((i + 1) % this.context.DAYS_IN_A_WEEK === 0) { + frag.appendChild(rowEl); + rowEl = document.createElement("tr"); + } + } + context.appendChild(frag); + + return refs; + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "click": { + if (this.context.daysView.contains(event.target)) { + let targetId = event.target.dataset.id; + let targetObj = this.state.days[targetId]; + if (targetObj.enabled) { + this.state.setSelection(targetObj.dateObj); + } + } + break; + } + + case "keydown": { + // Providing keyboard navigation support in accordance with + // the ARIA Grid and Dialog design patterns + if (this.context.daysView.contains(event.target)) { + // If RTL, the offset direction for Right/Left needs to be reversed + const direction = Services.locale.isAppLocaleRTL ? -1 : 1; + + switch (event.key) { + case "Enter": + case " ": { + let targetId = event.target.dataset.id; + let targetObj = this.state.days[targetId]; + if (targetObj.enabled) { + this.state.setSelection(targetObj.dateObj); + } + break; + } + + case "ArrowRight": { + // Moves focus to the next day. If the next day is + // out-of-range, update the view to show the next month + this._handleKeydownEvent(1 * direction); + break; + } + case "ArrowLeft": { + // Moves focus to the previous day. If the next day is + // out-of-range, update the view to show the previous month + this._handleKeydownEvent(-1 * direction); + break; + } + case "ArrowUp": { + // Moves focus to the same day of the previous week. If the next + // day is out-of-range, update the view to show the previous month + this._handleKeydownEvent(-1 * this.context.DAYS_IN_A_WEEK); + break; + } + case "ArrowDown": { + // Moves focus to the same day of the next week. If the next + // day is out-of-range, update the view to show the previous month + this._handleKeydownEvent(1 * this.context.DAYS_IN_A_WEEK); + break; + } + case "Home": { + // Moves focus to the first day (ie. Sunday) of the current week + if (event.ctrlKey) { + // Moves focus to the first day of the current month + this.state.focusedDate.setUTCDate(1); + this._updateKeyboardFocus(); + } else { + this._handleKeydownEvent( + this.state.focusedDate.getUTCDay() * -1 + ); + } + break; + } + case "End": { + // Moves focus to the last day (ie. Saturday) of the current week + if (event.ctrlKey) { + // Moves focus to the last day of the current month + let lastDateOfMonth = new Date( + this.state.focusedDate.getUTCFullYear(), + this.state.focusedDate.getUTCMonth() + 1, + 0 + ); + this.state.focusedDate = lastDateOfMonth; + this._updateKeyboardFocus(); + } else { + this._handleKeydownEvent( + this.context.DAYS_IN_A_WEEK - + 1 - + this.state.focusedDate.getUTCDay() + ); + } + break; + } + case "PageUp": { + // Changes the view to the previous month/year + // and sets focus on the same day. + // If that day does not exist, then moves focus + // to the same day of the same week. + if (event.shiftKey) { + // Previous year + let prevYear = this.state.focusedDate.getUTCFullYear() - 1; + this.state.focusedDate.setUTCFullYear(prevYear); + } else { + // Previous month + let prevMonth = this.state.focusedDate.getUTCMonth() - 1; + this.state.focusedDate.setUTCMonth(prevMonth); + } + this.state.setCalendarMonth( + this.state.focusedDate.getUTCFullYear(), + this.state.focusedDate.getUTCMonth() + ); + this._updateKeyboardFocus(); + break; + } + case "PageDown": { + // Changes the view to the next month/year + // and sets focus on the same day. + // If that day does not exist, then moves focus + // to the same day of the same week. + if (event.shiftKey) { + // Next year + let nextYear = this.state.focusedDate.getUTCFullYear() + 1; + this.state.focusedDate.setUTCFullYear(nextYear); + } else { + // Next month + let nextMonth = this.state.focusedDate.getUTCMonth() + 1; + this.state.focusedDate.setUTCMonth(nextMonth); + } + this.state.setCalendarMonth( + this.state.focusedDate.getUTCFullYear(), + this.state.focusedDate.getUTCMonth() + ); + this._updateKeyboardFocus(); + break; + } + } + } + break; + } + } + }, + + /** + * Attach event listener to daysView + */ + _attachEventListeners() { + this.context.daysView.addEventListener("click", this); + this.context.daysView.addEventListener("keydown", this); + }, + + /** + * Find Data-id of the next element to focus on the daysView grid + * @param {Object} nextDate: Data object of the next element to focus + */ + _calculateNextId(nextDate) { + for (let i = 0; i < this.state.days.length; i++) { + if (this._isSameDay(this.state.days[i].dateObj, nextDate)) { + return i; + } + } + return null; + }, + + /** + * Comparing two date objects to ensure they produce the same date + * @param {Date} dateObj1: Date object from the updated state + * @param {Date} dateObj2: Date object from the previous state + * @return {Boolean} If two date objects are the same day + */ + _isSameDay(dateObj1, dateObj2) { + return ( + dateObj1.getUTCFullYear() == dateObj2.getUTCFullYear() && + dateObj1.getUTCMonth() == dateObj2.getUTCMonth() && + dateObj1.getUTCDate() == dateObj2.getUTCDate() + ); + }, + + /** + * Comparing two date objects to ensure they produce the same day of the month, + * while being on different months + * @param {Date} dateObj1: Date object from the updated state + * @param {Date} dateObj2: Date object from the previous state + * @return {Boolean} If two date objects are the same day of the month + */ + _isSameDayOfMonth(dateObj1, dateObj2) { + return dateObj1.getUTCDate() == dateObj2.getUTCDate(); + }, + + /** + * Manage focus for the keyboard navigation for the daysView grid + * @param {Number} offsetDays: The direction and the number of days to move + * the focus by, where a negative number (i.e. -1) + * moves the focus to the previous day + */ + _handleKeydownEvent(offsetDays) { + let newFocusedDay = this.state.focusedDate.getUTCDate() + offsetDays; + let newFocusedDate = new Date(this.state.focusedDate); + newFocusedDate.setUTCDate(newFocusedDay); + + // Update the month, if the next focused element is outside + if (newFocusedDate.getUTCMonth() !== this.state.focusedDate.getUTCMonth()) { + this.state.setCalendarMonth( + newFocusedDate.getUTCFullYear(), + newFocusedDate.getUTCMonth() + ); + } + this.state.focusedDate.setUTCDate(newFocusedDate.getUTCDate()); + this._updateKeyboardFocus(); + }, + + /** + * Update the daysView grid and send focus to the next day + * based on the current state fo the Calendar + */ + _updateKeyboardFocus() { + this._render({ + elements: this.elements.daysView, + items: this.state.days, + prevState: this.state.days, + }); + this.focusDay(); + }, + + /** + * Place keyboard focus on the calendar grid, when the datepicker is initiated or updated. + * A "tabindex" attribute is provided to only one date within the grid + * by the "render()" method and this focusable element will be focused. + */ + focusDay() { + const focusable = this.context.daysView.querySelector('[tabindex="0"]'); + if (focusable) { + focusable.focus(); + } + }, +}; diff --git a/toolkit/content/widgets/checkbox.js b/toolkit/content/widgets/checkbox.js new file mode 100644 index 0000000000..eaa0017b97 --- /dev/null +++ b/toolkit/content/widgets/checkbox.js @@ -0,0 +1,83 @@ +/* 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. +{ + class MozCheckbox extends MozElements.BaseText { + static get markup() { + return ` + <image class="checkbox-check"/> + <hbox class="checkbox-label-box" flex="1"> + <image class="checkbox-icon"/> + <label class="checkbox-label" flex="1"/> + </hbox> + `; + } + + constructor() { + super(); + + // While it would seem we could do this by handling oncommand, we need can't + // because any external oncommand handlers might get called before ours, and + // then they would see the incorrect value of checked. + this.addEventListener("click", event => { + if (event.button === 0 && !this.disabled) { + this.checked = !this.checked; + } + }); + this.addEventListener("keypress", event => { + if (event.key == " ") { + this.checked = !this.checked; + // Prevent page from scrolling on the space key. + event.preventDefault(); + } + }); + } + + static get inheritedAttributes() { + return { + ".checkbox-check": "disabled,checked,native", + ".checkbox-label": "text=label,accesskey,native", + ".checkbox-icon": "src,native", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + this.initializeAttributeInheritance(); + } + + set checked(val) { + let change = val != (this.getAttribute("checked") == "true"); + if (val) { + this.setAttribute("checked", "true"); + } else { + this.removeAttribute("checked"); + } + + if (change) { + let event = document.createEvent("Events"); + event.initEvent("CheckboxStateChange", true, true); + this.dispatchEvent(event); + } + } + + get checked() { + return this.getAttribute("checked") == "true"; + } + } + + MozCheckbox.contentFragment = null; + + customElements.define("checkbox", MozCheckbox); +} diff --git a/toolkit/content/widgets/datekeeper.js b/toolkit/content/widgets/datekeeper.js new file mode 100644 index 0000000000..d720491d4b --- /dev/null +++ b/toolkit/content/widgets/datekeeper.js @@ -0,0 +1,424 @@ +/* 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"; + +/** + * DateKeeper keeps track of the date states. + */ +function DateKeeper(props) { + this.init(props); +} + +{ + const DAYS_IN_A_WEEK = 7, + MONTHS_IN_A_YEAR = 12, + YEAR_VIEW_SIZE = 200, + YEAR_BUFFER_SIZE = 10, + // The min value is 0001-01-01 based on HTML spec: + // https://html.spec.whatwg.org/#valid-date-string + MIN_DATE = -62135596800000, + // The max value is derived from the ECMAScript spec (275760-09-13): + // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1 + MAX_DATE = 8640000000000000, + MAX_YEAR = 275760, + MAX_MONTH = 9, + // One day in ms since epoch. + ONE_DAY = 86400000; + + DateKeeper.prototype = { + get year() { + return this.state.dateObj.getUTCFullYear(); + }, + + get month() { + return this.state.dateObj.getUTCMonth(); + }, + + get selection() { + return this.state.selection; + }, + + /** + * Initialize DateKeeper + * @param {Number} year + * @param {Number} month + * @param {Number} day + * @param {Number} min + * @param {Number} max + * @param {Number} step + * @param {Number} stepBase + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @param {Number} calViewSize + */ + init({ + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek = 0, + weekends = [0], + calViewSize = 42, + }) { + const today = new Date(); + + this.state = { + step, + firstDayOfWeek, + weekends, + calViewSize, + // min & max are NaN if empty or invalid + min: new Date(Number.isNaN(min) ? MIN_DATE : min), + max: new Date(Number.isNaN(max) ? MAX_DATE : max), + stepBase: new Date(stepBase), + today: this._newUTCDate( + today.getFullYear(), + today.getMonth(), + today.getDate() + ), + weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends), + years: [], + dateObj: new Date(0), + selection: { year, month, day }, + }; + + if (year === undefined) { + year = today.getFullYear(); + } + if (month === undefined) { + month = today.getMonth(); + } + + const minYear = this.state.min.getFullYear(); + const maxYear = this.state.max.getFullYear(); + + // Choose a valid year for the value/min/max properties + const selectedYear = Math.min(Math.max(year, minYear), maxYear); + + // Choose the month that correspond to the selectedYear + let selectedMonth = 0; + + if (selectedYear === year) { + selectedMonth = month; + } else if (selectedYear === minYear) { + selectedMonth = this.state.min.getMonth(); + } else if (selectedYear === maxYear) { + selectedMonth = this.state.max.getMonth(); + } + + this.setCalendarMonth({ + year: selectedYear, + month: selectedMonth, + }); + }, + + /** + * Set new calendar month. The year is always treated as full year, so the + * short-form is not supported. + * @param {Object} date parts + * { + * {Number} year [optional] + * {Number} month [optional] + * } + */ + setCalendarMonth({ year = this.year, month = this.month }) { + // Make sure the date is valid before setting. + // Use setUTCFullYear so that year 99 doesn't get parsed as 1999 + if (year > MAX_YEAR || (year === MAX_YEAR && month >= MAX_MONTH)) { + this.state.dateObj.setUTCFullYear(MAX_YEAR, MAX_MONTH - 1, 1); + } else if (year < 1 || (year === 1 && month < 0)) { + this.state.dateObj.setUTCFullYear(1, 0, 1); + } else { + this.state.dateObj.setUTCFullYear(year, month, 1); + } + }, + + /** + * Set selection date + * @param {Number} year + * @param {Number} month + * @param {Number} day + */ + setSelection({ year, month, day }) { + this.state.selection.year = year; + this.state.selection.month = month; + this.state.selection.day = day; + }, + + /** + * Set month. Makes sure the day is <= the last day of the month + * @param {Number} month + */ + setMonth(month) { + this.setCalendarMonth({ year: this.year, month }); + }, + + /** + * Set year. Makes sure the day is <= the last day of the month + * @param {Number} year + */ + setYear(year) { + this.setCalendarMonth({ year, month: this.month }); + }, + + /** + * Set month by offset. Makes sure the day is <= the last day of the month + * @param {Number} offset + */ + setMonthByOffset(offset) { + this.setCalendarMonth({ year: this.year, month: this.month + offset }); + }, + + /** + * Generate the array of months + * @return {Array<Object>} + * { + * {Number} value: Month in int + * {Boolean} enabled + * } + */ + getMonths() { + let months = []; + + const currentYear = this.year; + + const minYear = this.state.min.getFullYear(); + const minMonth = this.state.min.getMonth(); + const maxYear = this.state.max.getFullYear(); + const maxMonth = this.state.max.getMonth(); + + for (let i = 0; i < MONTHS_IN_A_YEAR; i++) { + const disabled = + (currentYear == minYear && i < minMonth) || + (currentYear == maxYear && i > maxMonth); + months.push({ + value: i, + enabled: !disabled, + }); + } + + return months; + }, + + /** + * Generate the array of years + * @return {Array<Object>} + * { + * {Number} value: Year in int + * {Boolean} enabled + * } + */ + getYears() { + let years = []; + + const firstItem = this.state.years[0]; + const lastItem = this.state.years[this.state.years.length - 1]; + const currentYear = this.year; + + const minYear = Math.max(this.state.min.getFullYear(), 1); + const maxYear = Math.min(this.state.max.getFullYear(), MAX_YEAR); + + // Generate new years array when the year is outside of the first & + // last item range. If not, return the cached result. + if ( + !firstItem || + !lastItem || + currentYear <= firstItem.value + YEAR_BUFFER_SIZE || + currentYear >= lastItem.value - YEAR_BUFFER_SIZE + ) { + // The year is set in the middle with items on both directions + for (let i = -(YEAR_VIEW_SIZE / 2); i < YEAR_VIEW_SIZE / 2; i++) { + const year = currentYear + i; + + if (year >= minYear && year <= maxYear) { + years.push({ + value: year, + enabled: true, + }); + } + } + this.state.years = years; + } + return this.state.years; + }, + + /** + * Get days for calendar + * @return {Array<Object>} + * { + * {Date} dateObj + * {Number} content + * {Array<String>} classNames + * {Boolean} enabled + * } + */ + getDays() { + const firstDayOfMonth = this._getFirstCalendarDate( + this.state.dateObj, + this.state.firstDayOfWeek + ); + const month = this.month; + let days = []; + + for (let i = 0; i < this.state.calViewSize; i++) { + const dateObj = this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + firstDayOfMonth.getUTCDate() + i + ); + + let classNames = []; + let enabled = true; + + const isValid = + dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE; + if (!isValid) { + classNames.push("out-of-range"); + enabled = false; + + days.push({ + classNames, + enabled, + }); + continue; + } + + const isWeekend = this.state.weekends.includes(dateObj.getUTCDay()); + const isCurrentMonth = month == dateObj.getUTCMonth(); + const isSelection = + this.state.selection.year == dateObj.getUTCFullYear() && + this.state.selection.month == dateObj.getUTCMonth() && + this.state.selection.day == dateObj.getUTCDate(); + // The date is at 00:00, so if the minimum is that day at e.g. 01:00, + // we should arguably still be able to select that date. So we need to + // compare the date at the very end of the day for minimum purposes. + const isOutOfRange = + dateObj.getTime() + ONE_DAY - 1 < this.state.min.getTime() || + dateObj.getTime() > this.state.max.getTime(); + const isToday = this.state.today.getTime() == dateObj.getTime(); + const isOffStep = this._checkIsOffStep( + dateObj, + this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth(), + dateObj.getUTCDate() + 1 + ) + ); + + if (isWeekend) { + classNames.push("weekend"); + } + if (!isCurrentMonth) { + classNames.push("outside"); + } + if (isSelection && !isOutOfRange && !isOffStep) { + classNames.push("selection"); + } + if (isOutOfRange) { + classNames.push("out-of-range"); + enabled = false; + } + if (isToday) { + classNames.push("today"); + } + if (isOffStep) { + classNames.push("off-step"); + enabled = false; + } + days.push({ + dateObj, + content: dateObj.getUTCDate(), + classNames, + enabled, + }); + } + return days; + }, + + /** + * Check if a date is off step given a starting point and the next increment + * @param {Date} start + * @param {Date} next + * @return {Boolean} + */ + _checkIsOffStep(start, next) { + // If the increment is larger or equal to the step, it must not be off-step. + if (next - start >= this.state.step) { + return false; + } + // Calculate the last valid date + const lastValidStep = Math.floor( + (next - 1 - this.state.stepBase) / this.state.step + ); + const lastValidTimeInMs = + lastValidStep * this.state.step + this.state.stepBase.getTime(); + // The date is off-step if the last valid date is smaller than the start date + return lastValidTimeInMs < start.getTime(); + }, + + /** + * Get week headers for calendar + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @return {Array<Object>} + * { + * {Number} content + * {Array<String>} classNames + * } + */ + _getWeekHeaders(firstDayOfWeek, weekends) { + let headers = []; + let dayOfWeek = firstDayOfWeek; + + for (let i = 0; i < DAYS_IN_A_WEEK; i++) { + headers.push({ + content: dayOfWeek % DAYS_IN_A_WEEK, + classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) + ? ["weekend"] + : [], + }); + dayOfWeek++; + } + return headers; + }, + + /** + * Get the first day on a calendar month + * @param {Date} dateObj + * @param {Number} firstDayOfWeek + * @return {Date} + */ + _getFirstCalendarDate(dateObj, firstDayOfWeek) { + const daysOffset = 1 - DAYS_IN_A_WEEK; + let firstDayOfMonth = this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth() + ); + let dayOfWeek = firstDayOfMonth.getUTCDay(); + + return this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + // When first calendar date is the same as first day of the week, add + // another row on top of it. + firstDayOfWeek == dayOfWeek + ? daysOffset + : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK + ); + }, + + /** + * Helper function for creating UTC dates + * @param {...[Number]} parts + * @return {Date} + */ + _newUTCDate(...parts) { + return new Date(new Date(0).setUTCFullYear(...parts)); + }, + }; +} diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js new file mode 100644 index 0000000000..f771add298 --- /dev/null +++ b/toolkit/content/widgets/datepicker.js @@ -0,0 +1,597 @@ +/* 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/. */ + +/* import-globals-from datekeeper.js */ +/* import-globals-from calendar.js */ +/* import-globals-from spinner.js */ + +"use strict"; + +function DatePicker(context) { + this.context = context; + this._attachEventListeners(); +} + +{ + const CAL_VIEW_SIZE = 42; + + DatePicker.prototype = { + /** + * Initializes the date picker. Set the default states and properties. + * @param {Object} props + * { + * {Number} year [optional] + * {Number} month [optional] + * {Number} date [optional] + * {Number} min + * {Number} max + * {Number} step + * {Number} stepBase + * {Number} firstDayOfWeek + * {Array<Number>} weekends + * {Array<String>} monthStrings + * {Array<String>} weekdayStrings + * {String} locale [optional]: User preferred locale + * } + */ + init(props = {}) { + this.props = props; + this._setDefaultState(); + this._createComponents(); + this._update(); + this.components.calendar.focusDay(); + // TODO(bug 1828721): This is a bit sad. + window.PICKER_READY = true; + document.dispatchEvent(new CustomEvent("PickerReady")); + }, + + /* + * Set initial date picker states. + */ + _setDefaultState() { + const { + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek, + weekends, + monthStrings, + weekdayStrings, + locale, + dir, + } = this.props; + const dateKeeper = new DateKeeper({ + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek, + weekends, + calViewSize: CAL_VIEW_SIZE, + }); + + document.dir = dir; + + this.state = { + dateKeeper, + locale, + isMonthPickerVisible: false, + datetimeOrders: new Intl.DateTimeFormat(locale) + .formatToParts(new Date(0)) + .map(part => part.type), + getDayString: day => + day ? new Intl.NumberFormat(locale).format(day) : "", + getWeekHeaderString: weekday => weekdayStrings[weekday], + getMonthString: month => monthStrings[month], + setSelection: date => { + dateKeeper.setSelection({ + year: date.getUTCFullYear(), + month: date.getUTCMonth(), + day: date.getUTCDate(), + }); + this._update(); + this._dispatchState(); + this._closePopup(); + }, + setMonthByOffset: offset => { + dateKeeper.setMonthByOffset(offset); + this._update(); + }, + setYear: year => { + dateKeeper.setYear(year); + dateKeeper.setSelection({ + year, + month: dateKeeper.selection.month, + day: dateKeeper.selection.day, + }); + this._update(); + this._dispatchState(); + }, + setMonth: month => { + dateKeeper.setMonth(month); + dateKeeper.setSelection({ + year: dateKeeper.selection.year, + month, + day: dateKeeper.selection.day, + }); + this._update(); + this._dispatchState(); + }, + toggleMonthPicker: () => { + this.state.isMonthPickerVisible = !this.state.isMonthPickerVisible; + this._update(); + }, + }; + }, + + /** + * Initalize the date picker components. + */ + _createComponents() { + this.components = { + calendar: new Calendar( + { + calViewSize: CAL_VIEW_SIZE, + locale: this.state.locale, + setSelection: this.state.setSelection, + // Year and month could be changed without changing a selection + setCalendarMonth: (year, month) => { + this.state.dateKeeper.setCalendarMonth({ + year, + month, + }); + this._update(); + }, + getDayString: this.state.getDayString, + getWeekHeaderString: this.state.getWeekHeaderString, + }, + { + weekHeader: this.context.weekHeader, + daysView: this.context.daysView, + } + ), + monthYear: new MonthYear( + { + setYear: this.state.setYear, + setMonth: this.state.setMonth, + getMonthString: this.state.getMonthString, + datetimeOrders: this.state.datetimeOrders, + locale: this.state.locale, + }, + { + monthYear: this.context.monthYear, + monthYearView: this.context.monthYearView, + } + ), + }; + }, + + /** + * Update date picker and its components. + */ + _update(options = {}) { + const { dateKeeper, isMonthPickerVisible } = this.state; + + const calendarEls = [ + this.context.buttonPrev, + this.context.buttonNext, + this.context.weekHeader.parentNode, + this.context.buttonClear, + ]; + // Update MonthYear state and toggle visibility for sighted users + // and for assistive technology: + this.context.monthYearView.hidden = !isMonthPickerVisible; + for (let el of calendarEls) { + el.hidden = isMonthPickerVisible; + } + this.context.monthYearNav.toggleAttribute( + "monthPickerVisible", + isMonthPickerVisible + ); + if (isMonthPickerVisible) { + this.state.months = dateKeeper.getMonths(); + this.state.years = dateKeeper.getYears(); + } else { + this.state.days = dateKeeper.getDays(); + } + + this.components.monthYear.setProps({ + isVisible: isMonthPickerVisible, + dateObj: dateKeeper.state.dateObj, + months: this.state.months, + years: this.state.years, + toggleMonthPicker: this.state.toggleMonthPicker, + noSmoothScroll: options.noSmoothScroll, + }); + this.components.calendar.setProps({ + isVisible: !isMonthPickerVisible, + days: this.state.days, + weekHeaders: dateKeeper.state.weekHeaders, + }); + }, + + /** + * Use postMessage to close the picker. + */ + _closePopup(clear = false) { + window.postMessage( + { + name: "ClosePopup", + detail: clear, + }, + "*" + ); + }, + + /** + * Use postMessage to pass the state of picker to the panel. + */ + _dispatchState() { + const { year, month, day } = this.state.dateKeeper.selection; + + // The panel is listening to window for postMessage event, so we + // do postMessage to itself to send data to input boxes. + window.postMessage( + { + name: "PickerPopupChanged", + detail: { + year, + month, + day, + }, + }, + "*" + ); + }, + + /** + * Attach event listeners + */ + _attachEventListeners() { + window.addEventListener("message", this); + document.addEventListener("mouseup", this, { passive: true }); + document.addEventListener("mousedown", this); + document.addEventListener("keydown", this); + }, + + /** + * Handle events. + * + * @param {Event} event + */ + handleEvent(event) { + switch (event.type) { + case "message": { + this.handleMessage(event); + break; + } + case "keydown": { + switch (event.key) { + case "Enter": + case " ": + case "Escape": { + // If the target is a toggle or a spinner on the month-year panel + const isOnMonthPicker = + this.context.monthYearView.parentNode.contains(event.target); + + if (this.state.isMonthPickerVisible && isOnMonthPicker) { + // While a control on the month-year picker panel is focused, + // keep the spinner's selection and close the month-year dialog + event.stopPropagation(); + event.preventDefault(); + this.state.toggleMonthPicker(); + this.components.calendar.focusDay(); + break; + } + if (event.key == "Escape") { + // Close the date picker on Escape from within the picker + this._closePopup(); + break; + } + if (event.target == this.context.buttonPrev) { + event.target.classList.add("active"); + this.state.setMonthByOffset(-1); + this.context.buttonPrev.focus(); + } else if (event.target == this.context.buttonNext) { + event.target.classList.add("active"); + this.state.setMonthByOffset(1); + this.context.buttonNext.focus(); + } else if (event.target == this.context.buttonClear) { + event.target.classList.add("active"); + this._closePopup(/* clear = */ true); + } + break; + } + case "Tab": { + // Manage tab order of a daysView to prevent keyboard trap + if (event.target.tagName === "td") { + if (event.shiftKey) { + this.context.buttonNext.focus(); + } else if (!event.shiftKey) { + this.context.buttonClear.focus(); + } + event.stopPropagation(); + event.preventDefault(); + } + break; + } + } + break; + } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setPointerCapture(event.pointerId); + + if (event.target == this.context.buttonClear) { + event.target.classList.add("active"); + this._closePopup(/* clear = */ true); + } else if (event.target == this.context.buttonPrev) { + event.target.classList.add("active"); + this.state.dateKeeper.setMonthByOffset(-1); + this._update(); + } else if (event.target == this.context.buttonNext) { + event.target.classList.add("active"); + this.state.dateKeeper.setMonthByOffset(1); + this._update(); + } + break; + } + case "mouseup": { + event.target.releasePointerCapture(event.pointerId); + + if ( + event.target == this.context.buttonPrev || + event.target == this.context.buttonNext + ) { + event.target.classList.remove("active"); + } + break; + } + } + }, + + /** + * Handle postMessage events. + * + * @param {Event} event + */ + handleMessage(event) { + switch (event.data.name) { + case "PickerSetValue": { + this.set(event.data.detail); + break; + } + case "PickerInit": { + this.init(event.data.detail); + break; + } + } + }, + + /** + * Set the date state and update the components with the new state. + * + * @param {Object} dateState + * { + * {Number} year [optional] + * {Number} month [optional] + * {Number} date [optional] + * } + */ + set({ year, month, day }) { + if (!this.state) { + return; + } + + const { dateKeeper } = this.state; + + dateKeeper.setCalendarMonth({ + year, + month, + }); + dateKeeper.setSelection({ + year, + month, + day, + }); + this._update({ noSmoothScroll: true }); + }, + }; + + /** + * MonthYear is a component that handles the month & year spinners + * + * @param {Object} options + * { + * {String} locale + * {Function} setYear + * {Function} setMonth + * {Function} getMonthString + * {Array<String>} datetimeOrders + * } + * @param {DOMElement} context + */ + function MonthYear(options, context) { + const spinnerSize = 5; + const yearFormat = new Intl.DateTimeFormat(options.locale, { + year: "numeric", + timeZone: "UTC", + }).format; + const dateFormat = new Intl.DateTimeFormat(options.locale, { + year: "numeric", + month: "long", + timeZone: "UTC", + }).format; + const spinnerOrder = + options.datetimeOrders.indexOf("month") < + options.datetimeOrders.indexOf("year") + ? "order-month-year" + : "order-year-month"; + + context.monthYearView.classList.add(spinnerOrder); + + this.context = context; + this.state = { dateFormat }; + this.props = {}; + this.components = { + month: new Spinner( + { + id: "spinner-month", + setValue: month => { + this.state.isMonthSet = true; + options.setMonth(month); + }, + getDisplayString: options.getMonthString, + viewportSize: spinnerSize, + }, + context.monthYearView + ), + year: new Spinner( + { + id: "spinner-year", + setValue: year => { + this.state.isYearSet = true; + options.setYear(year); + }, + getDisplayString: year => + yearFormat(new Date(new Date(0).setUTCFullYear(year))), + viewportSize: spinnerSize, + }, + context.monthYearView + ), + }; + + this._updateButtonLabels(); + this._attachEventListeners(); + } + + MonthYear.prototype = { + /** + * Set new properties and pass them to components + * + * @param {Object} props + * { + * {Boolean} isVisible + * {Date} dateObj + * {Array<Object>} months + * {Array<Object>} years + * {Function} toggleMonthPicker + * } + */ + setProps(props) { + this.context.monthYear.textContent = this.state.dateFormat(props.dateObj); + const spinnerDialog = this.context.monthYearView.parentNode; + + if (props.isVisible) { + this.context.monthYear.classList.add("active"); + this.context.monthYear.setAttribute("aria-expanded", "true"); + // To prevent redundancy, as spinners will announce their value on change + this.context.monthYear.setAttribute("aria-live", "off"); + this.components.month.setState({ + value: props.dateObj.getUTCMonth(), + items: props.months, + isInfiniteScroll: true, + isValueSet: this.state.isMonthSet, + smoothScroll: !(this.state.firstOpened || props.noSmoothScroll), + }); + this.components.year.setState({ + value: props.dateObj.getUTCFullYear(), + items: props.years, + isInfiniteScroll: false, + isValueSet: this.state.isYearSet, + smoothScroll: !(this.state.firstOpened || props.noSmoothScroll), + }); + this.state.firstOpened = false; + + // Set up spinner dialog container properties for assistive technology: + spinnerDialog.setAttribute("role", "dialog"); + spinnerDialog.setAttribute("aria-modal", "true"); + } else { + this.context.monthYear.classList.remove("active"); + this.context.monthYear.setAttribute("aria-expanded", "false"); + // To ensure calendar month's changes are announced: + this.context.monthYear.setAttribute("aria-live", "polite"); + // Remove spinner dialog container properties to ensure this hidden + // modal will be ignored by assistive technology, because even though + // the dialog is hidden, the toggle button is a visible descendant, + // so we must not treat its container as a dialog: + spinnerDialog.removeAttribute("role"); + spinnerDialog.removeAttribute("aria-modal"); + this.state.isMonthSet = false; + this.state.isYearSet = false; + this.state.firstOpened = true; + } + + this.props = Object.assign(this.props, props); + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "click": { + this.props.toggleMonthPicker(); + break; + } + case "keydown": { + if (event.key === "Enter" || event.key === " ") { + event.stopPropagation(); + event.preventDefault(); + this.props.toggleMonthPicker(); + } + break; + } + } + }, + + /** + * Update localizable IDs of the spinner and its Prev/Next buttons + */ + _updateButtonLabels() { + document.l10n.setAttributes( + this.components.month.elements.spinner, + "date-spinner-month" + ); + document.l10n.setAttributes( + this.components.year.elements.spinner, + "date-spinner-year" + ); + document.l10n.setAttributes( + this.components.month.elements.up, + "date-spinner-month-previous" + ); + document.l10n.setAttributes( + this.components.month.elements.down, + "date-spinner-month-next" + ); + document.l10n.setAttributes( + this.components.year.elements.up, + "date-spinner-year-previous" + ); + document.l10n.setAttributes( + this.components.year.elements.down, + "date-spinner-year-next" + ); + document.l10n.translateRoots(); + }, + + /** + * Attach event listener to monthYear button + */ + _attachEventListeners() { + this.context.monthYear.addEventListener("click", this); + this.context.monthYear.addEventListener("keydown", this); + }, + }; +} diff --git a/toolkit/content/widgets/datetimebox.css b/toolkit/content/widgets/datetimebox.css new file mode 100644 index 0000000000..4c8914ace4 --- /dev/null +++ b/toolkit/content/widgets/datetimebox.css @@ -0,0 +1,107 @@ +/* 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/. */ + +.datetimebox { + display: flex; + line-height: normal; + /* TODO: Enable selection once bug 1455893 is fixed */ + user-select: none; +} + +.datetime-input-box-wrapper { + display: inline-flex; + flex: 1; + background-color: inherit; + min-width: 0; + justify-content: space-between; + align-items: center; +} + +.datetime-input-edit-wrapper { + overflow: hidden; + white-space: nowrap; + flex-grow: 1; +} + +.datetime-edit-field { + display: inline; + text-align: center; + padding: 1px 3px; + border: 0; + margin: 0; + ime-mode: disabled; + outline: none; + + &:focus { + background-color: Highlight; + color: HighlightText; + outline: none; + } +} + +.datetime-calendar-button { + -moz-context-properties: fill; + color: inherit; + font-size: inherit; + fill: currentColor; + opacity: .65; + background-color: transparent; + border: none; + border-radius: 0.2em; + flex: none; + margin-block: 0; + margin-inline: 0.075em 0.15em; + padding: 0 0.15em; + line-height: 1; + + &:focus-visible { + outline: 0.15em solid SelectedItem; + } + + &:focus-visible, + &:hover { + opacity: 1; + } + + @media (prefers-contrast) { + opacity: 1; + background-color: ButtonFace; + color: ButtonText; + + > .datetime-calendar-button-svg { + background-color: ButtonFace; + -moz-context-properties: fill; + fill: ButtonText; + } + + &:focus-visible, + &:hover { + background-color: SelectedItem; + + > .datetime-calendar-button-svg { + background-color: SelectedItem; + -moz-context-properties: fill; + fill: SelectedItemText; + } + } + } +} + +.datetime-calendar-button-svg { + pointer-events: none; + /* When using a very small font-size, we don't want the button to take extra + * space (which will affect the baseline of the form control) */ + max-width: 1em; + max-height: 1em; +} + +:host(:is(:disabled, :read-only, [type="time"])) .datetime-calendar-button { + display: none; +} + +:host(:is(:disabled, :read-only)) .datetime-edit-field { + user-select: none; + pointer-events: none; + -moz-user-focus: none; +} diff --git a/toolkit/content/widgets/datetimebox.js b/toolkit/content/widgets/datetimebox.js new file mode 100644 index 0000000000..28b32fddfa --- /dev/null +++ b/toolkit/content/widgets/datetimebox.js @@ -0,0 +1,1546 @@ +/* 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 a UA widget. It runs in per-origin UA widget scope, +// to be loaded by UAWidgetsChild.jsm. + +/* + * This is the class of entry. It will construct the actual implementation + * according to the value of the "type" property. + */ +this.DateTimeBoxWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + // The DOMLocalization instance needs to allow for sync methods so that + // the placeholder value may be determined and set during the + // createEditFieldAndAppend() call. + this.l10n = new this.window.DOMLocalization( + ["toolkit/global/datetimebox.ftl"], + /* aSync = */ true + ); + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.onchange(/* aDestroy = */ false); + } + + /* + * Callback called by UAWidgets when the "type" property changes. + */ + onchange(aDestroy = true) { + let newType = this.element.type; + if (this.type == newType) { + return; + } + + if (aDestroy) { + this.teardown(); + } + this.type = newType; + this.setup(); + } + + shouldShowTime() { + return this.type == "time" || this.type == "datetime-local"; + } + + shouldShowDate() { + return this.type == "date" || this.type == "datetime-local"; + } + + teardown() { + this.mInputElement.removeEventListener("keydown", this, { + capture: true, + mozSystemGroup: true, + }); + this.mInputElement.removeEventListener("click", this, { + mozSystemGroup: true, + }); + + this.CONTROL_EVENTS.forEach(eventName => { + this.mDateTimeBoxElement.removeEventListener(eventName, this); + }); + + this.l10n.disconnectRoot(this.shadowRoot); + + this.removeEditFields(); + this.removeEventListenersToField(this.mCalendarButton); + + this.mInputElement = null; + + this.shadowRoot.firstChild.remove(); + } + + removeEditFields() { + this.removeEventListenersToField(this.mYearField); + this.removeEventListenersToField(this.mMonthField); + this.removeEventListenersToField(this.mDayField); + this.removeEventListenersToField(this.mHourField); + this.removeEventListenersToField(this.mMinuteField); + this.removeEventListenersToField(this.mSecondField); + this.removeEventListenersToField(this.mMillisecField); + this.removeEventListenersToField(this.mDayPeriodField); + + this.mYearField = null; + this.mMonthField = null; + this.mDayField = null; + this.mHourField = null; + this.mMinuteField = null; + this.mSecondField = null; + this.mMillisecField = null; + this.mDayPeriodField = null; + + let root = this.shadowRoot.getElementById("edit-wrapper"); + while (root.firstChild) { + root.firstChild.remove(); + } + } + + rebuildEditFieldsIfNeeded() { + if ( + this.shouldShowSecondField() == !!this.mSecondField && + this.shouldShowMillisecField() == !!this.mMillisecField + ) { + return; + } + + let focused = this.mInputElement.matches(":focus"); + + this.removeEditFields(); + this.buildEditFields(); + + if (focused) { + this._focusFirstField(); + } + } + + _focusFirstField() { + this.shadowRoot.querySelector(".datetime-edit-field")?.focus(); + } + + setup() { + this.DEBUG = false; + + this.l10n.connectRoot(this.shadowRoot); + + this.generateContent(); + + this.mDateTimeBoxElement = this.shadowRoot.firstChild; + this.mCalendarButton = this.shadowRoot.getElementById("calendar-button"); + this.mInputElement = this.element; + this.mLocales = this.window.getWebExposedLocales(); + + this.mIsRTL = false; + let intlUtils = this.window.intlUtils; + if (intlUtils) { + this.mIsRTL = intlUtils.isAppLocaleRTL(); + } + + if (this.mIsRTL) { + let inputBoxWrapper = this.shadowRoot.getElementById("input-box-wrapper"); + inputBoxWrapper.dir = "rtl"; + } + + this.mIsPickerOpen = false; + + this.mMinMonth = 1; + this.mMaxMonth = 12; + this.mMinDay = 1; + this.mMaxDay = 31; + this.mMinYear = 1; + // Maximum year limited by ISO 8601. + this.mMaxYear = 9999; + this.mMonthDayLength = 2; + this.mYearLength = 4; + this.mMonthPageUpDownInterval = 3; + this.mDayPageUpDownInterval = 7; + this.mYearPageUpDownInterval = 10; + + const kDefaultAMString = "AM"; + const kDefaultPMString = "PM"; + + let { amString, pmString } = this.getStringsForLocale(this.mLocales); + + this.mAMIndicator = amString || kDefaultAMString; + this.mPMIndicator = pmString || kDefaultPMString; + + this.mHour12 = this.is12HourTime(this.mLocales); + this.mMillisecSeparatorText = "."; + this.mMaxLength = 2; + this.mMillisecMaxLength = 3; + this.mDefaultStep = 60 * 1000; // in milliseconds + + this.mMinHour = this.mHour12 ? 1 : 0; + this.mMaxHour = this.mHour12 ? 12 : 23; + this.mMinMinute = 0; + this.mMaxMinute = 59; + this.mMinSecond = 0; + this.mMaxSecond = 59; + this.mMinMillisecond = 0; + this.mMaxMillisecond = 999; + + this.mHourPageUpDownInterval = 3; + this.mMinSecPageUpDownInterval = 10; + + this.mInputElement.addEventListener( + "keydown", + this, + { + capture: true, + mozSystemGroup: true, + }, + false + ); + // This is to open the picker when input element is tapped on Android + // or for type=time inputs (this includes padding area). + this.isAndroid = this.window.navigator.appVersion.includes("Android"); + if (this.isAndroid || this.type == "time") { + this.mInputElement.addEventListener( + "click", + this, + { mozSystemGroup: true }, + false + ); + } + + // Those events are dispatched to <div class="datetimebox"> with bubble set + // to false. They are trapped inside UA Widget Shadow DOM and are not + // dispatched to the document. + this.CONTROL_EVENTS.forEach(eventName => { + this.mDateTimeBoxElement.addEventListener(eventName, this, {}, false); + }); + + this.buildEditFields(); + this.buildCalendarBtn(); + this.updateEditAttributes(); + + if (this.mInputElement.value) { + this.setFieldsFromInputValue(); + } + + if (this.mInputElement.matches(":focus")) { + this._focusFirstField(); + } + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="datetimebox" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" type="text/css" href="chrome://global/content/bindings/datetimebox.css" /> + <div class="datetime-input-box-wrapper" id="input-box-wrapper" role="presentation"> + <span class="datetime-input-edit-wrapper" + id="edit-wrapper"> + <!-- Each of the date/time input types will append their input child + - elements here --> + </span> + <button data-l10n-id="datetime-calendar" class="datetime-calendar-button" id="calendar-button" aria-expanded="false"> + <svg role="none" class="datetime-calendar-button-svg" xmlns="http://www.w3.org/2000/svg" id="calendar-16" viewBox="0 0 16 16" width="16" height="16"> + <path d="M13.5 2H13V1c0-.6-.4-1-1-1s-1 .4-1 1v1H5V1c0-.6-.4-1-1-1S3 .4 3 1v1h-.5C1.1 2 0 3.1 0 4.5v9C0 14.9 1.1 16 2.5 16h11c1.4 0 2.5-1.1 2.5-2.5v-9C16 3.1 14.9 2 13.5 2zm0 12.5h-11c-.6 0-1-.4-1-1V6h13v7.5c0 .6-.4 1-1 1z"/> + </svg> + </button> + </div> + </div>`, + "application/xml" + ); + + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + this.l10n.translateRoots(); + } + + get FIELD_EVENTS() { + return ["focus", "blur", "copy", "cut", "paste"]; + } + + get CONTROL_EVENTS() { + return [ + "MozDateTimeValueChanged", + "MozNotifyMinMaxStepAttrChanged", + "MozDateTimeAttributeChanged", + "MozPickerValueChanged", + "MozSetDateTimePickerState", + "MozDateTimeShowPickerForJS", + ]; + } + + get showPickerOnClick() { + return this.isAndroid || this.type == "time"; + } + + addEventListenersToField(aElement) { + // These events don't bubble out of the Shadow DOM, so we'll have to add + // event listeners specifically on each of the fields, not just + // on the <input> + this.FIELD_EVENTS.forEach(eventName => { + aElement.addEventListener( + eventName, + this, + { mozSystemGroup: true }, + false + ); + }); + } + + removeEventListenersToField(aElement) { + if (!aElement) { + return; + } + + this.FIELD_EVENTS.forEach(eventName => { + aElement.removeEventListener(eventName, this, { mozSystemGroup: true }); + }); + } + + log(aMsg) { + if (this.DEBUG) { + this.window.dump("[DateTimeBox] " + aMsg + "\n"); + } + } + + createEditFieldAndAppend( + aL10nId, + aPlaceholderId, + aIsNumeric, + aMinDigits, + aMaxLength, + aMinValue, + aMaxValue, + aPageUpDownInterval + ) { + let root = this.shadowRoot.getElementById("edit-wrapper"); + let field = this.shadowRoot.createElementAndAppendChildAt(root, "span"); + field.classList.add("datetime-edit-field"); + field.setAttribute("aria-valuetext", ""); + this.setFieldTabIndexAttribute(field); + + const placeholder = this.l10n.formatValueSync(aPlaceholderId); + field.placeholder = placeholder; + field.textContent = placeholder; + this.l10n.setAttributes(field, aL10nId); + + // Used to store the non-formatted value, cleared when value is + // cleared. + // DateTimeInputTypeBase::HasBadInput() will read this to decide + // if the input has value. + field.setAttribute("value", ""); + + if (aIsNumeric) { + field.classList.add("numeric"); + // Maximum value allowed. + field.setAttribute("min", aMinValue); + // Minumim value allowed. + field.setAttribute("max", aMaxValue); + // Interval when pressing pageUp/pageDown key. + field.setAttribute("pginterval", aPageUpDownInterval); + // Used to store what the user has already typed in the field, + // cleared when value is cleared and when field is blurred. + field.setAttribute("typeBuffer", ""); + // Minimum digits to display, padded with leading 0s. + field.setAttribute("mindigits", aMinDigits); + // Maximum length for the field, will be advance to the next field + // automatically if exceeded. + field.setAttribute("maxlength", aMaxLength); + // Set spinbutton ARIA role + field.setAttribute("role", "spinbutton"); + + if (this.mIsRTL) { + // Force the direction to be "ltr", so that the field stays in the + // same order even when it's empty (with placeholder). By using + // "embed", the text inside the element is still displayed based + // on its directionality. + field.style.unicodeBidi = "embed"; + field.style.direction = "ltr"; + } + } else { + // Set generic textbox ARIA role + field.setAttribute("role", "textbox"); + } + + return field; + } + + updateCalendarButtonState(isExpanded) { + this.mCalendarButton.setAttribute("aria-expanded", isExpanded); + } + + notifyInputElementValueChanged() { + this.log("inputElementValueChanged"); + this.setFieldsFromInputValue(); + } + + notifyMinMaxStepAttrChanged() { + // Second and millisecond part are optional, rebuild edit fields if + // needed. + this.rebuildEditFieldsIfNeeded(); + // Fill in values again. + this.setFieldsFromInputValue(); + } + + setValueFromPicker(aValue) { + if (aValue) { + this.setFieldsFromPicker(aValue); + } else { + this.clearInputFields(); + } + } + + advanceToNextField(aReverse) { + this.log("advanceToNextField"); + + let focusedInput = this.mLastFocusedElement; + let next = aReverse + ? focusedInput.previousElementSibling + : focusedInput.nextElementSibling; + if (!next && !aReverse) { + this.setInputValueFromFields(); + return; + } + + while (next) { + if (next.matches("span.datetime-edit-field")) { + next.focus(); + break; + } + next = aReverse ? next.previousElementSibling : next.nextElementSibling; + } + } + + setPickerState(aIsOpen) { + this.log("picker is now " + (aIsOpen ? "opened" : "closed")); + this.mIsPickerOpen = aIsOpen; + // Calendar button's expanded state mirrors this.mIsPickerOpen + this.updateCalendarButtonState(this.mIsPickerOpen); + } + + setFieldTabIndexAttribute(field) { + field.tabIndex = this.mInputElement.tabIndex; + } + + updateEditAttributes() { + this.log("updateEditAttributes"); + + let editRoot = this.shadowRoot.getElementById("edit-wrapper"); + + for (let child of editRoot.querySelectorAll( + ":scope > span.datetime-edit-field" + )) { + this.setFieldTabIndexAttribute(child); + } + } + + isEmpty(aValue) { + return aValue == undefined || 0 === aValue.length; + } + + getFieldValue(aField) { + if (!aField || !aField.classList.contains("numeric")) { + return undefined; + } + + let value = aField.getAttribute("value"); + // Avoid returning 0 when field is empty. + return this.isEmpty(value) ? undefined : Number(value); + } + + clearFieldValue(aField) { + aField.textContent = aField.placeholder; + aField.setAttribute("value", ""); + aField.setAttribute("aria-valuetext", ""); + if (aField.classList.contains("numeric")) { + aField.setAttribute("typeBuffer", ""); + } + } + + openDateTimePicker() { + this.mInputElement.openDateTimePicker(this.getCurrentValue()); + } + + closeDateTimePicker() { + if (this.mIsPickerOpen) { + this.mInputElement.closeDateTimePicker(); + } + } + + notifyPicker() { + if (this.mIsPickerOpen && this.isAnyFieldAvailable(true)) { + this.mInputElement.updateDateTimePicker(this.getCurrentValue()); + } + } + + isDisabled() { + return this.mInputElement.matches(":disabled"); + } + + isReadonly() { + return this.mInputElement.matches(":read-only"); + } + + isEditable() { + return !this.isDisabled() && !this.isReadonly(); + } + + isRequired() { + return this.mInputElement.hasAttribute("required"); + } + + containingTree() { + return this.mInputElement.containingShadowRoot || this.document; + } + + handleEvent(aEvent) { + this.log("handleEvent: " + aEvent.type); + + if (!aEvent.isTrusted) { + return; + } + + switch (aEvent.type) { + case "MozDateTimeValueChanged": { + this.notifyInputElementValueChanged(); + break; + } + case "MozNotifyMinMaxStepAttrChanged": { + this.notifyMinMaxStepAttrChanged(); + break; + } + case "MozDateTimeAttributeChanged": { + this.updateEditAttributes(); + break; + } + case "MozPickerValueChanged": { + this.setValueFromPicker(aEvent.detail); + break; + } + case "MozSetDateTimePickerState": { + this.setPickerState(aEvent.detail); + break; + } + case "MozDateTimeShowPickerForJS": { + this.openDateTimePicker(); + break; + } + case "keydown": { + this.onKeyDown(aEvent); + break; + } + case "click": { + this.onClick(aEvent); + break; + } + case "focus": { + this.onFocus(aEvent); + break; + } + case "blur": { + this.onBlur(aEvent); + break; + } + case "mousedown": + case "copy": + case "cut": + case "paste": { + aEvent.preventDefault(); + break; + } + default: + break; + } + } + + onFocus(aEvent) { + this.log("onFocus originalTarget: " + aEvent.originalTarget); + if (this.containingTree().activeElement != this.mInputElement) { + return; + } + + let target = aEvent.originalTarget; + if (target.matches(".datetime-edit-field,.datetime-calendar-button")) { + if (target.disabled) { + return; + } + this.mLastFocusedElement = target; + this.mInputElement.setFocusState(true); + } + if (this.mIsPickerOpen && this.isPickerIrrelevantField(target)) { + this.closeDateTimePicker(); + } + } + + onBlur(aEvent) { + this.log( + "onBlur originalTarget: " + + aEvent.originalTarget + + " target: " + + aEvent.target + + " rt: " + + aEvent.relatedTarget + + " open: " + + this.mIsPickerOpen + ); + + let target = aEvent.originalTarget; + target.setAttribute("typeBuffer", ""); + this.setInputValueFromFields(); + // No need to set and unset the focus state (or closing the picker) if the + // focus is staying within our input. + if (aEvent.relatedTarget == this.mInputElement) { + return; + } + + // If we're in chrome and the focus moves to a separate document + // (relatedTarget is null) we also don't want to close it, since it + // could've moved to the datetime popup itself. + if ( + !aEvent.relatedTarget && + this.window.isChromeWindow && + this.window == this.window.top + ) { + return; + } + + this.mInputElement.setFocusState(false); + if (this.mIsPickerOpen) { + this.closeDateTimePicker(); + } + } + + isTimeField(field) { + return ( + field == this.mHourField || + field == this.mMinuteField || + field == this.mSecondField || + field == this.mDayPeriodField + ); + } + + shouldOpenDateTimePickerOnKeyDown() { + if (!this.mLastFocusedElement) { + return true; + } + return !this.isPickerIrrelevantField(this.mLastFocusedElement); + } + + shouldOpenDateTimePickerOnClick(target) { + return !this.isPickerIrrelevantField(target); + } + + // Whether a given field is irrelevant for the purposes of the datetime + // picker. This is useful for datetime-local, which as of right now only + // shows a date picker (not a time picker). + isPickerIrrelevantField(field) { + if (this.type != "datetime-local") { + return false; + } + return this.isTimeField(field); + } + + onKeyDown(aEvent) { + this.log("onKeyDown key: " + aEvent.key); + + switch (aEvent.key) { + // Toggle the picker on Space/Enter on Calendar button or Space on input, + // close on Escape anywhere. + case "Escape": { + if (this.mIsPickerOpen) { + this.closeDateTimePicker(); + aEvent.preventDefault(); + } + break; + } + case "Enter": + case " ": { + // always close, if opened + if (this.mIsPickerOpen) { + this.closeDateTimePicker(); + } else if ( + // open on Space from anywhere within the input + aEvent.key == " " && + this.shouldOpenDateTimePickerOnKeyDown() + ) { + this.openDateTimePicker(); + } else if ( + // open from the Calendar button on either keydown + aEvent.originalTarget == this.mCalendarButton && + this.shouldOpenDateTimePickerOnKeyDown() + ) { + this.openDateTimePicker(); + } else { + // Don't preventDefault(); + break; + } + aEvent.preventDefault(); + break; + } + case "Delete": + case "Backspace": { + if (aEvent.originalTarget == this.mCalendarButton) { + // Do not remove Calendar button + aEvent.preventDefault(); + break; + } + if (this.isEditable()) { + // TODO(emilio, bug 1571533): These functions should look at + // defaultPrevented. + // Ctrl+Backspace/Delete on non-macOS and + // Cmd+Backspace/Delete on macOS to clear the field + if (aEvent.getModifierState("Accel")) { + // Clear the input's value + this.clearInputFields(false); + } else { + let targetField = aEvent.originalTarget; + this.clearFieldValue(targetField); + this.setInputValueFromFields(); + } + aEvent.preventDefault(); + } + break; + } + case "ArrowRight": + case "ArrowLeft": { + this.advanceToNextField(!(aEvent.key == "ArrowRight")); + aEvent.preventDefault(); + break; + } + case "ArrowUp": + case "ArrowDown": + case "PageUp": + case "PageDown": + case "Home": + case "End": { + this.handleKeyboardNav(aEvent); + aEvent.preventDefault(); + break; + } + default: { + // Handle printable characters (e.g. letters, digits and numpad digits) + if ( + aEvent.key.length === 1 && + !(aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey) + ) { + this.handleKeydown(aEvent); + aEvent.preventDefault(); + } + break; + } + } + } + + onClick(aEvent) { + this.log( + "onClick originalTarget: " + + aEvent.originalTarget + + " target: " + + aEvent.target + ); + + if (aEvent.defaultPrevented || !this.isEditable()) { + return; + } + + // We toggle the picker on click on the Calendar button on any platform. + // For Android and for type=time inputs, we also toggle the picker when + // clicking on the input field. + // + // We do not toggle the picker when clicking the input field for Calendar + // on desktop to avoid interfering with the default Calendar behavior. + if ( + aEvent.originalTarget == this.mCalendarButton || + this.showPickerOnClick + ) { + if ( + !this.mIsPickerOpen && + this.shouldOpenDateTimePickerOnClick(aEvent.originalTarget) + ) { + this.openDateTimePicker(); + } else { + this.closeDateTimePicker(); + } + } + } + + buildEditFields() { + let root = this.shadowRoot.getElementById("edit-wrapper"); + + let options = {}; + + if (this.shouldShowTime()) { + options.hour = options.minute = "numeric"; + options.hour12 = this.mHour12; + if (this.shouldShowSecondField()) { + options.second = "numeric"; + } + } + + if (this.shouldShowDate()) { + options.year = options.month = options.day = "numeric"; + } + + let formatter = Intl.DateTimeFormat(this.mLocales, options); + formatter.formatToParts(Date.now()).map(part => { + switch (part.type) { + case "year": + this.mYearField = this.createEditFieldAndAppend( + "datetime-year", + "datetime-year-placeholder", + true, + this.mYearLength, + this.mMaxYear.toString().length, + this.mMinYear, + this.mMaxYear, + this.mYearPageUpDownInterval + ); + this.addEventListenersToField(this.mYearField); + break; + case "month": + this.mMonthField = this.createEditFieldAndAppend( + "datetime-month", + "datetime-month-placeholder", + true, + this.mMonthDayLength, + this.mMonthDayLength, + this.mMinMonth, + this.mMaxMonth, + this.mMonthPageUpDownInterval + ); + this.addEventListenersToField(this.mMonthField); + break; + case "day": + this.mDayField = this.createEditFieldAndAppend( + "datetime-day", + "datetime-day-placeholder", + true, + this.mMonthDayLength, + this.mMonthDayLength, + this.mMinDay, + this.mMaxDay, + this.mDayPageUpDownInterval + ); + this.addEventListenersToField(this.mDayField); + break; + case "hour": + this.mHourField = this.createEditFieldAndAppend( + "datetime-hour", + "datetime-time-placeholder", + true, + this.mMaxLength, + this.mMaxLength, + this.mMinHour, + this.mMaxHour, + this.mHourPageUpDownInterval + ); + this.addEventListenersToField(this.mHourField); + break; + case "minute": + this.mMinuteField = this.createEditFieldAndAppend( + "datetime-minute", + "datetime-time-placeholder", + true, + this.mMaxLength, + this.mMaxLength, + this.mMinMinute, + this.mMaxMinute, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mMinuteField); + break; + case "second": + this.mSecondField = this.createEditFieldAndAppend( + "datetime-second", + "datetime-time-placeholder", + true, + this.mMaxLength, + this.mMaxLength, + this.mMinSecond, + this.mMaxSecond, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mSecondField); + if (this.shouldShowMillisecField()) { + // Intl.DateTimeFormat does not support millisecond, so we + // need to handle this on our own. + let span = this.shadowRoot.createElementAndAppendChildAt( + root, + "span" + ); + span.textContent = this.mMillisecSeparatorText; + this.mMillisecField = this.createEditFieldAndAppend( + "datetime-millisecond", + "datetime-time-placeholder", + true, + this.mMillisecMaxLength, + this.mMillisecMaxLength, + this.mMinMillisecond, + this.mMaxMillisecond, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mMillisecField); + } + break; + case "dayPeriod": + this.mDayPeriodField = this.createEditFieldAndAppend( + "datetime-dayperiod", + "datetime-time-placeholder", + false + ); + this.addEventListenersToField(this.mDayPeriodField); + + // Give aria autocomplete hint for am/pm + this.mDayPeriodField.setAttribute("aria-autocomplete", "inline"); + break; + default: + let span = this.shadowRoot.createElementAndAppendChildAt( + root, + "span" + ); + span.textContent = part.value; + break; + } + }); + } + + buildCalendarBtn() { + this.addEventListenersToField(this.mCalendarButton); + // This is to open the picker when a Calendar button is clicked (this + // includes padding area). + this.mCalendarButton.addEventListener( + "click", + this, + { mozSystemGroup: true }, + false + ); + } + + clearInputFields(aFromInputElement) { + this.log("clearInputFields"); + + if (this.mMonthField) { + this.clearFieldValue(this.mMonthField); + } + + if (this.mDayField) { + this.clearFieldValue(this.mDayField); + } + + if (this.mYearField) { + this.clearFieldValue(this.mYearField); + } + + if (this.mHourField) { + this.clearFieldValue(this.mHourField); + } + + if (this.mMinuteField) { + this.clearFieldValue(this.mMinuteField); + } + + if (this.mSecondField) { + this.clearFieldValue(this.mSecondField); + } + + if (this.mMillisecField) { + this.clearFieldValue(this.mMillisecField); + } + + if (this.mDayPeriodField) { + this.clearFieldValue(this.mDayPeriodField); + } + + if (!aFromInputElement) { + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + } + } + + setFieldsFromInputValue() { + // Second and millisecond part are optional, rebuild edit fields if + // needed. + this.rebuildEditFieldsIfNeeded(); + + let value = this.mInputElement.value; + if (!value) { + this.clearInputFields(true); + return; + } + + let { year, month, day, hour, minute, second, millisecond } = + this.getInputElementValues(); + if (this.shouldShowDate()) { + this.log("setFieldsFromInputValue: " + value); + this.setFieldValue(this.mYearField, year); + this.setFieldValue(this.mMonthField, month); + this.setFieldValue(this.mDayField, day); + } + + if (this.shouldShowTime()) { + if (this.isEmpty(hour) && this.isEmpty(minute)) { + this.clearInputFields(true); + return; + } + + this.setFieldValue(this.mHourField, hour); + this.setFieldValue(this.mMinuteField, minute); + if (this.mHour12) { + this.setDayPeriodValue( + hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator + ); + } + + if (this.mSecondField) { + this.setFieldValue(this.mSecondField, second || 0); + } + + if (this.mMillisecField) { + this.setFieldValue(this.mMillisecField, millisecond || 0); + } + } + + this.notifyPicker(); + } + + setInputValueFromFields() { + if (this.isAnyFieldEmpty()) { + // Clear input element's value if any of the field has been cleared, + // otherwise update the validity state, since it may become "not" + // invalid if fields are not complete. + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + // We still need to notify picker in case any of the field has + // changed. + this.notifyPicker(); + return; + } + + let { year, month, day, hour, minute, second, millisecond, dayPeriod } = + this.getCurrentValue(); + + let time = ""; + let date = ""; + + // Convert to a valid time string according to: + // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-time-string + if (this.shouldShowTime()) { + if (this.mHour12) { + if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { + hour += this.mMaxHour; + } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { + hour = 0; + } + } + + hour = hour < 10 ? "0" + hour : hour; + minute = minute < 10 ? "0" + minute : minute; + + time = hour + ":" + minute; + if (second != undefined) { + second = second < 10 ? "0" + second : second; + time += ":" + second; + } + + if (millisecond != undefined) { + // Convert milliseconds to fraction of second. + millisecond = millisecond + .toString() + .padStart(this.mMillisecMaxLength, "0"); + time += "." + millisecond; + } + } + + if (this.shouldShowDate()) { + // Convert to a valid date string according to: + // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-date-string + year = year.toString().padStart(this.mYearLength, "0"); + month = month < 10 ? "0" + month : month; + day = day < 10 ? "0" + day : day; + date = [year, month, day].join("-"); + } + + let value; + if (date) { + value = date; + } + if (time) { + // https://html.spec.whatwg.org/#valid-normalised-local-date-and-time-string + value = value ? value + "T" + time : time; + } + + if (value == this.mInputElement.value) { + return; + } + this.log("setInputValueFromFields: " + value); + this.notifyPicker(); + this.mInputElement.setUserInput(value); + } + + setFieldsFromPicker({ year, month, day, hour, minute }) { + if (!this.isEmpty(hour)) { + this.setFieldValue(this.mHourField, hour); + if (this.mHour12) { + this.setDayPeriodValue( + hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator + ); + } + } + + if (!this.isEmpty(minute)) { + this.setFieldValue(this.mMinuteField, minute); + } + + if (!this.isEmpty(year)) { + this.setFieldValue(this.mYearField, year); + } + + if (!this.isEmpty(month)) { + this.setFieldValue(this.mMonthField, month); + } + + if (!this.isEmpty(day)) { + this.setFieldValue(this.mDayField, day); + } + + // Update input element's .value if needed. + this.setInputValueFromFields(); + } + + handleKeydown(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (targetField == this.mDayPeriodField) { + if (key == "a" || key == "A") { + this.setDayPeriodValue(this.mAMIndicator); + } else if (key == "p" || key == "P") { + this.setDayPeriodValue(this.mPMIndicator); + } + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + return; + } + + if (targetField.classList.contains("numeric") && key.match(/[0-9]/)) { + let buffer = targetField.getAttribute("typeBuffer") || ""; + + buffer = buffer.concat(key); + this.setFieldValue(targetField, buffer); + + let n = Number(buffer); + let max = targetField.getAttribute("max"); + let maxLength = targetField.getAttribute("maxlength"); + if (targetField == this.mHourField) { + if (n * 10 > 23 || buffer.length === 2) { + buffer = ""; + this.advanceToNextField(); + } + } else if (buffer.length >= maxLength || n * 10 > max) { + buffer = ""; + this.advanceToNextField(); + } + targetField.setAttribute("typeBuffer", buffer); + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + } + } + + getCurrentValue() { + let value = {}; + if (this.shouldShowDate()) { + value.year = this.getFieldValue(this.mYearField); + value.month = this.getFieldValue(this.mMonthField); + value.day = this.getFieldValue(this.mDayField); + } + + if (this.shouldShowTime()) { + let dayPeriod = this.getDayPeriodValue(); + let hour = this.getFieldValue(this.mHourField); + if (!this.isEmpty(hour)) { + if (this.mHour12) { + if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { + hour += this.mMaxHour; + } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { + hour = 0; + } + } + } + value.hour = hour; + value.dayPeriod = dayPeriod; + value.minute = this.getFieldValue(this.mMinuteField); + value.second = this.getFieldValue(this.mSecondField); + value.millisecond = this.getFieldValue(this.mMillisecField); + } + + this.log("getCurrentValue: " + JSON.stringify(value)); + return value; + } + + setFieldValue(aField, aValue) { + if (!aField || !aField.classList.contains("numeric")) { + return; + } + + let value = Number(aValue); + if (isNaN(value)) { + this.log("NaN on setFieldValue!"); + return; + } + + if (aField == this.mHourField) { + if (this.mHour12) { + // Try to change to 12hr format if user input is 0 or greater + // than 12. + switch (true) { + case value == 0 && aValue.length == 2: + value = this.mMaxHour; + this.setDayPeriodValue(this.mAMIndicator); + break; + + case value == this.mMaxHour: + this.setDayPeriodValue(this.mPMIndicator); + break; + + case value < 12: + if (!this.getDayPeriodValue()) { + this.setDayPeriodValue(this.mAMIndicator); + } + break; + + case value > 12 && value < 24: + value = value % this.mMaxHour; + this.setDayPeriodValue(this.mPMIndicator); + break; + + default: + value = Math.floor(value / 10); + break; + } + } else if (value > this.mMaxHour) { + value = this.mMaxHour; + } + } + + let maxLength = aField.getAttribute("maxlength"); + if (aValue.length == maxLength) { + let min = Number(aField.getAttribute("min")); + let max = Number(aField.getAttribute("max")); + + if (value < min) { + value = min; + } else if (value > max) { + value = max; + } + } + + aField.setAttribute("value", value); + + let minDigits = aField.getAttribute("mindigits"); + let formatted = value.toLocaleString(this.mLocales, { + minimumIntegerDigits: minDigits, + useGrouping: false, + }); + + aField.textContent = formatted; + aField.setAttribute("aria-valuetext", formatted); + } + + isAnyFieldAvailable(aForPicker = false) { + let { year, month, day, hour, minute, second, millisecond } = + this.getCurrentValue(); + if ( + !this.isEmpty(year) || + !this.isEmpty(month) || + !this.isEmpty(day) || + !this.isEmpty(hour) || + !this.isEmpty(minute) + ) { + return true; + } + + // Picker doesn't care about seconds / milliseconds / day period. + if (aForPicker) { + return false; + } + + let dayPeriod = this.getDayPeriodValue(); + return ( + (this.mDayPeriodField && !this.isEmpty(dayPeriod)) || + (this.mSecondField && !this.isEmpty(second)) || + (this.mMillisecField && !this.isEmpty(millisecond)) + ); + } + + isAnyFieldEmpty() { + let { year, month, day, hour, minute, second, millisecond } = + this.getCurrentValue(); + return ( + (this.mYearField && this.isEmpty(year)) || + (this.mMonthField && this.isEmpty(month)) || + (this.mDayField && this.isEmpty(day)) || + (this.mHourField && this.isEmpty(hour)) || + (this.mMinuteField && this.isEmpty(minute)) || + (this.mDayPeriodField && this.isEmpty(this.getDayPeriodValue())) || + (this.mSecondField && this.isEmpty(second)) || + (this.mMillisecField && this.isEmpty(millisecond)) + ); + } + + get kMsPerSecond() { + return 1000; + } + + get kMsPerMinute() { + return 60 * 1000; + } + + getInputElementValues() { + let value = this.mInputElement.value; + if (value.length === 0) { + return {}; + } + + let date, time; + + let year, month, day, hour, minute, second, millisecond; + if (this.type == "date") { + date = value; + } + if (this.type == "time") { + time = value; + } + if (this.type == "datetime-local") { + // https://html.spec.whatwg.org/#valid-normalised-local-date-and-time-string + [date, time] = value.split("T"); + } + if (date) { + [year, month, day] = date.split("-"); + } + if (time) { + [hour, minute, second] = time.split(":"); + if (second) { + [second, millisecond] = second.split("."); + + // Convert fraction of second to milliseconds. + if (millisecond && millisecond.length === 1) { + millisecond *= 100; + } else if (millisecond && millisecond.length === 2) { + millisecond *= 10; + } + } + } + return { year, month, day, hour, minute, second, millisecond }; + } + + shouldShowSecondField() { + if (!this.shouldShowTime()) { + return false; + } + let { second } = this.getInputElementValues(); + if (second != undefined) { + return true; + } + + let stepBase = this.mInputElement.getStepBase(); + if (stepBase % this.kMsPerMinute != 0) { + return true; + } + + let step = this.mInputElement.getStep(); + if (step % this.kMsPerMinute != 0) { + return true; + } + + return false; + } + + shouldShowMillisecField() { + if (!this.shouldShowTime()) { + return false; + } + + let { millisecond } = this.getInputElementValues(); + if (millisecond != undefined) { + return true; + } + + let stepBase = this.mInputElement.getStepBase(); + if (stepBase % this.kMsPerSecond != 0) { + return true; + } + + let step = this.mInputElement.getStep(); + if (step % this.kMsPerSecond != 0) { + return true; + } + + return false; + } + + getStringsForLocale(aLocales) { + this.log("getStringsForLocale: " + aLocales); + + let intlUtils = this.window.intlUtils; + if (!intlUtils) { + return {}; + } + + let result = intlUtils.getDisplayNames(this.mLocales, { + type: "dayPeriod", + style: "short", + calendar: "gregory", + keys: ["am", "pm"], + }); + + let [amString, pmString] = result.values; + + return { amString, pmString }; + } + + is12HourTime(aLocales) { + let options = new Intl.DateTimeFormat(aLocales, { + hour: "numeric", + }).resolvedOptions(); + + return options.hour12; + } + + incrementFieldValue(aTargetField, aTimes) { + let value = this.getFieldValue(aTargetField); + + // Use current time if field is empty. + if (this.isEmpty(value)) { + let now = new Date(); + + if (aTargetField == this.mYearField) { + value = now.getFullYear(); + } else if (aTargetField == this.mMonthField) { + value = now.getMonth() + 1; + } else if (aTargetField == this.mDayField) { + value = now.getDate(); + } else if (aTargetField == this.mHourField) { + value = now.getHours(); + if (this.mHour12) { + value = value % this.mMaxHour || this.mMaxHour; + } + } else if (aTargetField == this.mMinuteField) { + value = now.getMinutes(); + } else if (aTargetField == this.mSecondField) { + value = now.getSeconds(); + } else if (aTargetField == this.mMillisecField) { + value = now.getMilliseconds(); + } else { + this.log("Field not supported in incrementFieldValue."); + return; + } + } + + let min = +aTargetField.getAttribute("min"); + let max = +aTargetField.getAttribute("max"); + + value += Number(aTimes); + if (value > max) { + value -= max - min + 1; + } else if (value < min) { + value += max - min + 1; + } + + this.setFieldValue(aTargetField, value); + } + + handleKeyboardNav(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (targetField == this.mYearField && (key == "Home" || key == "End")) { + // Home/End key does nothing on year field. + return; + } + + if (targetField == this.mDayPeriodField) { + // Home/End key does nothing on AM/PM field. + if (key == "Home" || key == "End") { + return; + } + + this.setDayPeriodValue( + this.getDayPeriodValue() == this.mAMIndicator + ? this.mPMIndicator + : this.mAMIndicator + ); + this.setInputValueFromFields(); + return; + } + + switch (key) { + case "ArrowUp": + this.incrementFieldValue(targetField, 1); + break; + case "ArrowDown": + this.incrementFieldValue(targetField, -1); + break; + case "PageUp": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, interval); + break; + } + case "PageDown": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, 0 - interval); + break; + } + case "Home": + let min = targetField.getAttribute("min"); + this.setFieldValue(targetField, min); + break; + case "End": + let max = targetField.getAttribute("max"); + this.setFieldValue(targetField, max); + break; + } + this.setInputValueFromFields(); + } + + getDayPeriodValue() { + if (!this.mDayPeriodField) { + return ""; + } + + let placeholder = this.mDayPeriodField.placeholder; + let value = this.mDayPeriodField.textContent; + return value == placeholder ? "" : value; + } + + setDayPeriodValue(aValue) { + if (!this.mDayPeriodField) { + return; + } + + this.mDayPeriodField.textContent = aValue; + this.mDayPeriodField.setAttribute("value", aValue); + } +}; diff --git a/toolkit/content/widgets/dialog.js b/toolkit/content/widgets/dialog.js new file mode 100644 index 0000000000..52eb2168f8 --- /dev/null +++ b/toolkit/content/widgets/dialog.js @@ -0,0 +1,545 @@ +/* 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(); + } + + 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"); + document.l10n?.connectRoot(this.shadowRoot); + + 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(); + } + }); + + // 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(); + 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) + ); + } 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); +} diff --git a/toolkit/content/widgets/docs/index.rst b/toolkit/content/widgets/docs/index.rst new file mode 100644 index 0000000000..2e576357f3 --- /dev/null +++ b/toolkit/content/widgets/docs/index.rst @@ -0,0 +1,10 @@ +=============== +Toolkit Widgets +=============== + +The ``/toolkit/content/widgets`` directory contains XBL bindings, Mozilla Custom Elements, and UA Widgets usable for all applications. + +.. toctree:: + :maxdepth: 1 + + ua_widget diff --git a/toolkit/content/widgets/docs/ua_widget.rst b/toolkit/content/widgets/docs/ua_widget.rst new file mode 100644 index 0000000000..5d01c95a3c --- /dev/null +++ b/toolkit/content/widgets/docs/ua_widget.rst @@ -0,0 +1,58 @@ +UA Widgets +========== + +Introduction +------------ + +User Agent Widgets (UA Widgets) are intended to be a replacement of our usage of XBL bindings in web content. These widgets run JavaScript inside extended principal per-origin sandboxes. They insert their own DOM inside of a special, closed Shadow Root inaccessible to the page, called a UA Widget Shadow Root. + +UA Widget lifecycle +------------------- + +UA Widgets are generally constructed when the element is appended to the document and destroyed when the element is removed from the tree. Yet, in order to be fast, specialization was made to each of the widgets. + +When the element is appended to the tree, a chrome-only ``UAWidgetSetupOrChange`` event is dispatched and is caught by a frame script, namely UAWidgetsChild. + +UAWidgetsChild then grabs the sandbox for that origin (lazily creating it as needed), loads the script as needed, and initializes an instance by calling the JS constructor with a reference to the UA Widget Shadow Root created by the DOM. We will discuss the sandbox in the latter section. + +The ``onsetup`` method is called right after the instance is constructed. The call to constructor must not throw, or UAWidgetsChild will be confused since an instance of the widget will not be returned, but the widget is already half-initalized. If the ``onsetup`` method call throws, UAWidgetsChild will still be able to hold the reference of the widget and call the teardown method later on. + +When the element is removed from the tree, ``UAWidgetTeardown`` is dispatched so UAWidgetsChild can destroy the widget, if it exists. If so, the UAWidgetsChild calls the ``teardown()`` method on the widget, causing the widget to destruct itself. + +Counter-intuitively, elements are not considered "removed from the tree" when the document is unloaded. This is considered safe as anything the widget touches should be reset or cleaned up when the document unloads. Please do not violate the assumption by having any browser state toggled by the teardown. + +When a UA Widget initializes, it should create its own DOM inside the passed UA Widget Shadow Root, including the ``<link>`` element necessary to load the stylesheet, add event listeners, etc. When destroyed (i.e. the teardown method is called), it should do the opposite. + +**Specialization**: for video controls, we do not want to do the work if the control is not needed (i.e. when the ``<video>`` or ``<audio>`` element has no "controls" attribute set), so we forgo dispatching the event from HTMLMediaElement in the BindToTree method. Instead, another ``UAWidgetSetupOrChange`` event will cause the sandbox and the widget instance to construct when the attribute is set to true. The same event is also responsible for triggering the ``onchange()`` method on UA Widgets if the widget is already initialized. + +Likewise, the datetime box widget is only loaded when the ``type`` attribute of an ``<input>`` is either `date` or `time`. + +The specialization does not apply to the lifecycle of the UA Widget Shadow Root. It is always constructed in order to suppress children of the DOM element from the web content from receiving a layout frame. + +UA Widget Shadow Root +--------------------- + +The UA Widget Shadow Root is a closed shadow root, with the UA Widget flag turned on. As a closed shadow root, it may not be accessed by other scripts. It is attached on host element which the spec disallow a shadow root to be attached. + +The UA Widget flag enables the security feature covered in the next section. + +**Side note**: XML pretty print hides its transformed content inside a UA Widget Shadow DOM as well, even though there isn't any JavaScript to run. This is set in order to leverage the same security feature and behaviors there. + +The JavaScript sandbox +---------------------- + +The sandboxes created for UA Widgets are per-origin and set to the expanded principal. This allows the script to access other DOM APIs unavailable to the web content, while keeping its principal tied to the document origin. They are created as needed, backing the lifecycle of the UA Widgets as previously mentioned. These sandbox globals are not associated with any window object because they are shared across all same-origin documents. It is the job of the UA Widget script to hold and manage the references of the window and document objects the widget is being initiated on, by accessing them from the UA Widget Shadow Root instance passed. + +While the closed shadow root technically prevents content from accessing the contents, we want a stronger guarantee to protect against accidental leakage of references to the UA Widget shadow tree into content script. Access to the UA Widget DOM is restricted by having their reflectors set in the UA Widgets scope, as opposed to the normal scope. To accomplish this, we avoid having any script (UA Widget script included) getting a hold of the reference of any created DOM element before appending to the Shadow DOM. Once the element is in the Shadow DOM, the binding mechanism will put the reflector in the desired scope as it is being accessed. + +To avoid creating reflectors before DOM insertion, the available DOM interfaces are limited. For example, instead of ``createElement()`` and ``appendChild()``, the script would have to call ``createElementAndAppendChildAt()`` available on the UA Widget Shadow Root instance, to avoid receiving a reference to the DOM element and thus triggering the creation of its reflector in the wrong scope, before the element is properly associated with the UA Widget shadow tree. To find out the differences, search for ``Func="IsChromeOrUAWidget"`` and ``Func="IsNotUAWidget"`` in in-tree WebIDL files. + +Other things to watch out for +----------------------------- + +As part of the implementation of the Web Platform, it is important to make sure the web-observable characteristics of the widget correctly reflect what the script on the web expects. + +* Do not dispatch non-spec compliant events on the UA Widget Shadow Root host element, as event listeners in web content scripts can access them. +* The layout and the dimensions of the widget should be ready by the time the constructor and ``onsetup`` returns, since they can be detectable as soon as the content script gets the reference of the host element (i.e. when ``appendChild()`` returns). In order to make this easier we load ``<link>`` elements load chrome stylesheets synchronously when inside a UA Widget Shadow DOM. +* There shouldn't be any white-spaces nodes in the Shadow DOM, because UA Widget could be placed inside ``white-space: pre``. See bug 1502205. +* CSP will block inline styles in the Shadow DOM. ``<link>`` is the only safe way to load styles. diff --git a/toolkit/content/widgets/editor.js b/toolkit/content/widgets/editor.js new file mode 100644 index 0000000000..9e5ffb542e --- /dev/null +++ b/toolkit/content/widgets/editor.js @@ -0,0 +1,203 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + /* globals XULFrameElement */ + + class MozEditor extends XULFrameElement { + connectedCallback() { + this._editorContentListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIURIContentListener", + "nsISupportsWeakReference", + ]), + doContent(contentType, isContentPreferred, request, contentHandler) { + return false; + }, + isPreferred(contentType, desiredContentType) { + return false; + }, + canHandleContent(contentType, isContentPreferred, desiredContentType) { + return false; + }, + loadCookie: null, + parentContentListener: null, + }; + + this._finder = null; + + this._fastFind = null; + + this._lastSearchString = null; + + // Make window editable immediately only + // if the "editortype" attribute is supplied + // This allows using same contentWindow for different editortypes, + // where the type is determined during the apps's window.onload handler. + if (this.editortype) { + this.makeEditable(this.editortype, true); + } + } + + get finder() { + if (!this._finder) { + if (!this.docShell) { + return null; + } + + let { Finder } = ChromeUtils.importESModule( + "resource://gre/modules/Finder.sys.mjs" + ); + this._finder = new Finder(this.docShell); + } + return this._finder; + } + + get fastFind() { + if (!this._fastFind) { + if (!("@mozilla.org/typeaheadfind;1" in Cc)) { + return null; + } + + if (!this.docShell) { + return null; + } + + this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance( + Ci.nsITypeAheadFind + ); + this._fastFind.init(this.docShell); + } + return this._fastFind; + } + + set editortype(val) { + this.setAttribute("editortype", val); + } + + get editortype() { + return this.getAttribute("editortype"); + } + + get currentURI() { + return this.webNavigation.currentURI; + } + + get webBrowserFind() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserFind); + } + + get editingSession() { + return this.docShell.editingSession; + } + + get commandManager() { + return this.webNavigation + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsICommandManager); + } + + set fullZoom(val) { + this.browsingContext.fullZoom = val; + } + + get fullZoom() { + return this.browsingContext.fullZoom; + } + + set textZoom(val) { + this.browsingContext.textZoom = val; + } + + get textZoom() { + return this.browsingContext.textZoom; + } + + get isSyntheticDocument() { + return this.contentDocument.isSyntheticDocument; + } + + get messageManager() { + if (this.frameLoader) { + return this.frameLoader.messageManager; + } + return null; + } + + // Copied from toolkit/content/widgets/browser-custom-element.js. + // Send an asynchronous message to the remote child via an actor. + // Note: use this only for messages through an actor. For old-style + // messages, use the message manager. + // The value of the scope argument determines which browsing contexts + // are sent to: + // 'all' - send to actors associated with all descendant child frames. + // 'roots' - send only to actors associated with process roots. + // undefined/'' - send only to the top-level actor and not any descendants. + sendMessageToActor(messageName, args, actorName, scope) { + if (!this.frameLoader) { + return; + } + + function sendToChildren(browsingContext, childScope) { + let windowGlobal = browsingContext.currentWindowGlobal; + // If 'roots' is set, only send if windowGlobal.isProcessRoot is true. + if ( + windowGlobal && + (childScope != "roots" || windowGlobal.isProcessRoot) + ) { + windowGlobal.getActor(actorName).sendAsyncMessage(messageName, args); + } + + // Iterate as long as scope in assigned. Note that we use the original + // passed in scope, not childScope here. + if (scope) { + for (let context of browsingContext.children) { + sendToChildren(context, scope); + } + } + } + + // Pass no second argument to always send to the top-level browsing context. + sendToChildren(this.browsingContext); + } + + get outerWindowID() { + return this.docShell.outerWindowID; + } + + makeEditable(editortype, waitForUrlLoad) { + let win = this.contentWindow; + this.editingSession.makeWindowEditable( + win, + editortype, + waitForUrlLoad, + true, + false + ); + this.setAttribute("editortype", editortype); + + this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIURIContentListener).parentContentListener = + this._editorContentListener; + } + + getEditor(containingWindow) { + return this.editingSession.getEditorForWindow(containingWindow); + } + + getHTMLEditor(containingWindow) { + var editor = this.editingSession.getEditorForWindow(containingWindow); + return editor.QueryInterface(Ci.nsIHTMLEditor); + } + } + + customElements.define("editor", MozEditor); +} diff --git a/toolkit/content/widgets/findbar.js b/toolkit/content/widgets/findbar.js new file mode 100644 index 0000000000..3cbce11771 --- /dev/null +++ b/toolkit/content/widgets/findbar.js @@ -0,0 +1,1379 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + const PREFS_TO_OBSERVE_BOOL = new Map([ + ["findAsYouType", "accessibility.typeaheadfind"], + ["manualFAYT", "accessibility.typeaheadfind.manual"], + ["typeAheadLinksOnly", "accessibility.typeaheadfind.linksonly"], + ["entireWord", "findbar.entireword"], + ["highlightAll", "findbar.highlightAll"], + ["useModalHighlight", "findbar.modalHighlight"], + ]); + const PREFS_TO_OBSERVE_INT = new Map([ + ["typeAheadCaseSensitive", "accessibility.typeaheadfind.casesensitive"], + ["matchDiacritics", "findbar.matchdiacritics"], + ]); + const PREFS_TO_OBSERVE_ALL = new Map([ + ...PREFS_TO_OBSERVE_BOOL, + ...PREFS_TO_OBSERVE_INT, + ]); + const TOPIC_MAC_APP_ACTIVATE = "mac_app_activate"; + + class MozFindbar extends MozXULElement { + static get markup() { + return ` + <hbox anonid="findbar-container" class="findbar-container" flex="1" align="center"> + <hbox anonid="findbar-textbox-wrapper" align="stretch"> + <html:input anonid="findbar-textbox" class="findbar-textbox" /> + <toolbarbutton anonid="find-previous" class="findbar-find-previous tabbable" + data-l10n-attrs="tooltiptext" data-l10n-id="findbar-previous" + oncommand="onFindAgainCommand(true);" disabled="true" /> + <toolbarbutton anonid="find-next" class="findbar-find-next tabbable" + data-l10n-id="findbar-next" oncommand="onFindAgainCommand(false);" disabled="true" /> + </hbox> + <checkbox anonid="highlight" class="findbar-highlight tabbable" + data-l10n-id="findbar-highlight-all2" oncommand="toggleHighlight(this.checked);"/> + <checkbox anonid="find-case-sensitive" class="findbar-case-sensitive tabbable" + data-l10n-id="findbar-case-sensitive" oncommand="_setCaseSensitivity(this.checked ? 1 : 0);"/> + <checkbox anonid="find-match-diacritics" class="findbar-match-diacritics tabbable" + data-l10n-id="findbar-match-diacritics" oncommand="_setDiacriticMatching(this.checked ? 1 : 0);"/> + <checkbox anonid="find-entire-word" class="findbar-entire-word tabbable" + data-l10n-id="findbar-entire-word" oncommand="toggleEntireWord(this.checked);"/> + <label anonid="match-case-status" class="findbar-label" + data-l10n-id="findbar-case-sensitive-status" hidden="true" /> + <label anonid="match-diacritics-status" class="findbar-label" + data-l10n-id="findbar-match-diacritics-status" hidden="true" /> + <label anonid="entire-word-status" class="findbar-label" + data-l10n-id="findbar-entire-word-status" hidden="true" /> + <label anonid="found-matches" class="findbar-label found-matches" hidden="true" /> + <image anonid="find-status-icon" class="find-status-icon" /> + <description anonid="find-status" control="findbar-textbox" class="findbar-label findbar-find-status" /> + </hbox> + <toolbarbutton anonid="find-closebutton" class="findbar-closebutton tabbable close-icon" + data-l10n-id="findbar-find-button-close" oncommand="close();"/> + `; + } + + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/findbar.ftl"); + this.destroy = this.destroy.bind(this); + + // We have to guard against `this.close` being |null| due to an unknown + // issue, which is tracked in bug 957999. + this.addEventListener( + "keypress", + event => { + if (event.keyCode == event.DOM_VK_ESCAPE) { + if (this.close) { + this.close(); + } + event.preventDefault(); + } + }, + true + ); + } + + connectedCallback() { + // Hide the findbar immediately without animation. This prevents a flicker in the case where + // we'll never be shown (i.e. adopting a tab that has a previously-opened-but-now-closed + // findbar into a new window). + this.setAttribute("noanim", "true"); + this.hidden = true; + this.appendChild(this.constructor.fragment); + if (AppConstants.platform == "macosx") { + this.insertBefore( + this.getElement("find-closebutton"), + this.getElement("findbar-container") + ); + } + + /** + * Please keep in sync with toolkit/modules/FindBarContent.sys.mjs + */ + this.FIND_NORMAL = 0; + + this.FIND_TYPEAHEAD = 1; + + this.FIND_LINKS = 2; + + this._findMode = 0; + + this._flashFindBar = 0; + + this._initialFlashFindBarCount = 6; + + /** + * For tests that need to know when the find bar is finished + * initializing, we store a promise to notify on. + */ + this._startFindDeferred = null; + + this._browser = null; + + this._destroyed = false; + + this._xulBrowserWindow = null; + + // These elements are accessed frequently and are therefore cached. + this._findField = this.getElement("findbar-textbox"); + this._foundMatches = this.getElement("found-matches"); + this._findStatusIcon = this.getElement("find-status-icon"); + this._findStatusDesc = this.getElement("find-status"); + + this._foundURL = null; + + let prefsvc = Services.prefs; + + this.quickFindTimeoutLength = prefsvc.getIntPref( + "accessibility.typeaheadfind.timeout" + ); + this._flashFindBar = prefsvc.getIntPref( + "accessibility.typeaheadfind.flashBar" + ); + + let observe = (this._observe = this.observe.bind(this)); + for (let [propName, prefName] of PREFS_TO_OBSERVE_ALL) { + prefsvc.addObserver(prefName, observe); + let prefGetter = PREFS_TO_OBSERVE_BOOL.has(propName) ? "Bool" : "Int"; + this["_" + propName] = prefsvc[`get${prefGetter}Pref`](prefName); + } + Services.obs.addObserver(observe, TOPIC_MAC_APP_ACTIVATE); + + this._findResetTimeout = -1; + + // Make sure the FAYT keypress listener is attached by initializing the + // browser property. + if (this.getAttribute("browserid")) { + setTimeout(() => { + // eslint-disable-next-line no-self-assign + this.browser = this.browser; + }, 0); + } + + window.addEventListener("unload", this.destroy); + + this._findField.addEventListener("input", event => { + // We should do nothing during composition. E.g., composing string + // before converting may matches a forward word of expected word. + // After that, even if user converts the composition string to the + // expected word, it may find second or later searching word in the + // document. + if (this._isIMEComposing) { + return; + } + + const value = this._findField.value; + if (this._hadValue && !value) { + this._willfullyDeleted = true; + this._hadValue = false; + } else if (value.trim()) { + this._hadValue = true; + this._willfullyDeleted = false; + } + this._find(value); + }); + + this._findField.addEventListener("keypress", event => { + switch (event.keyCode) { + case KeyEvent.DOM_VK_RETURN: + if (this.findMode == this.FIND_NORMAL) { + let findString = this._findField; + if (!findString.value) { + return; + } + if (event.getModifierState("Accel")) { + this.getElement("highlight").click(); + return; + } + + this.onFindAgainCommand(event.shiftKey); + } else { + this._finishFAYT(event); + } + break; + case KeyEvent.DOM_VK_TAB: + let shouldHandle = + !event.altKey && !event.ctrlKey && !event.metaKey; + if (shouldHandle && this.findMode != this.FIND_NORMAL) { + this._finishFAYT(event); + } + break; + case KeyEvent.DOM_VK_PAGE_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) { + this.browser.finder.keyPress(event); + event.preventDefault(); + } + break; + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_DOWN: + this.browser.finder.keyPress(event); + event.preventDefault(); + break; + } + }); + + this._findField.addEventListener("blur", event => { + // Note: This code used to remove the selection + // if it matched an editable. + this.browser.finder.enableSelection(); + }); + + this._findField.addEventListener("focus", event => { + this._updateBrowserWithState(); + }); + + this._findField.addEventListener("compositionstart", event => { + // Don't close the find toolbar while IME is composing. + let findbar = this; + findbar._isIMEComposing = true; + if (findbar._quickFindTimeout) { + clearTimeout(findbar._quickFindTimeout); + findbar._quickFindTimeout = null; + findbar._updateBrowserWithState(); + } + }); + + this._findField.addEventListener("compositionend", event => { + this._isIMEComposing = false; + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + }); + + this._findField.addEventListener("dragover", event => { + if (event.dataTransfer.types.includes("text/plain")) { + event.preventDefault(); + } + }); + + this._findField.addEventListener("drop", event => { + let value = event.dataTransfer.getData("text/plain"); + this._findField.value = value; + this._find(value); + event.stopPropagation(); + event.preventDefault(); + }); + } + + set findMode(val) { + this._findMode = val; + this._updateBrowserWithState(); + } + + get findMode() { + return this._findMode; + } + + set prefillWithSelection(val) { + this.setAttribute("prefillwithselection", val); + } + + get prefillWithSelection() { + return this.getAttribute("prefillwithselection") != "false"; + } + + get hasTransactions() { + if (this._findField.value) { + return true; + } + + // Watch out for lazy editor init + if (this._findField.editor) { + return this._findField.editor.canUndo || this._findField.editor.canRedo; + } + return false; + } + + set browser(val) { + function setFindbarInActor(browser, findbar) { + if (!browser.frameLoader) { + return; + } + + let windowGlobal = browser.browsingContext.currentWindowGlobal; + if (windowGlobal) { + let findbarParent = windowGlobal.getActor("FindBar"); + if (findbarParent) { + findbarParent.setFindbar(browser, findbar); + } + } + } + + if (this._browser) { + setFindbarInActor(this._browser, null); + + let finder = this._browser.finder; + if (finder) { + finder.removeResultListener(this); + } + } + + this._browser = val; + if (this._browser) { + // Need to do this to ensure the correct initial state. + this._updateBrowserWithState(); + + setFindbarInActor(this._browser, this); + + this._browser.finder.addResultListener(this); + } + } + + get browser() { + if (!this._browser) { + const id = this.getAttribute("browserid"); + if (id) { + this._browser = document.getElementById(id); + } + } + return this._browser; + } + + observe(subject, topic, prefName) { + if (topic == TOPIC_MAC_APP_ACTIVATE) { + this._onAppActivateMac(); + return; + } + + if (topic != "nsPref:changed") { + return; + } + + let prefsvc = Services.prefs; + + switch (prefName) { + case "accessibility.typeaheadfind": + this._findAsYouType = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.manual": + this._manualFAYT = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.timeout": + this.quickFindTimeoutLength = prefsvc.getIntPref(prefName); + break; + case "accessibility.typeaheadfind.linksonly": + this._typeAheadLinksOnly = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.casesensitive": + this._setCaseSensitivity(prefsvc.getIntPref(prefName)); + break; + case "findbar.entireword": + this._entireWord = prefsvc.getBoolPref(prefName); + this.toggleEntireWord(this._entireWord, true); + break; + case "findbar.highlightAll": + this.toggleHighlight(prefsvc.getBoolPref(prefName), true); + break; + case "findbar.matchdiacritics": + this._setDiacriticMatching(prefsvc.getIntPref(prefName)); + break; + case "findbar.modalHighlight": + this._useModalHighlight = prefsvc.getBoolPref(prefName); + if (this.browser.finder) { + this.browser.finder.onModalHighlightChange(this._useModalHighlight); + } + break; + } + } + + getElement(aAnonymousID) { + return this.querySelector(`[anonid=${aAnonymousID}]`); + } + + /** + * This is necessary because custom elements don't have a "real" destructor. + * This method is called explicitly from disconnectedCallback, and from + * an unload event handler that we add. + */ + destroy() { + if (this._destroyed) { + return; + } + window.removeEventListener("unload", this.destroy); + this._destroyed = true; + + this.browser?._finder?.destroy(); + + // Invoking this setter also removes the message listeners. + this.browser = null; + + let prefsvc = Services.prefs; + let observe = this._observe; + for (let [, prefName] of PREFS_TO_OBSERVE_ALL) { + prefsvc.removeObserver(prefName, observe); + } + + Services.obs.removeObserver(observe, TOPIC_MAC_APP_ACTIVATE); + + // Clear all timers that might still be running. + this._cancelTimers(); + } + + _cancelTimers() { + if (this._flashFindBarTimeout) { + clearInterval(this._flashFindBarTimeout); + this._flashFindBarTimeout = null; + } + if (this._quickFindTimeout) { + clearTimeout(this._quickFindTimeout); + this._quickFindTimeout = null; + } + if (this._findResetTimeout) { + clearTimeout(this._findResetTimeout); + this._findResetTimeout = null; + } + } + + _setFindCloseTimeout() { + if (this._quickFindTimeout) { + clearTimeout(this._quickFindTimeout); + } + + // Don't close the find toolbar while IME is composing OR when the + // findbar is already hidden. + if (this._isIMEComposing || this.hidden) { + this._quickFindTimeout = null; + this._updateBrowserWithState(); + return; + } + + if (this.quickFindTimeoutLength < 1) { + this._quickFindTimeout = null; + } else { + this._quickFindTimeout = setTimeout(() => { + if (this.findMode != this.FIND_NORMAL) { + this.close(); + } + this._quickFindTimeout = null; + }, this.quickFindTimeoutLength); + } + this._updateBrowserWithState(); + } + + /** + * Updates the search match count after each find operation on a new string. + */ + _updateMatchesCount() { + if (!this._dispatchFindEvent("matchescount")) { + return; + } + + this.browser.finder.requestMatchesCount( + this._findField.value, + this.findMode == this.FIND_LINKS + ); + } + + /** + * Turns highlighting of all occurrences on or off. + * + * @param {Boolean} highlight Whether to turn the highlight on or off. + * @param {Boolean} fromPrefObserver Whether the callee is the pref + * observer, which means we should not set + * the same pref again. + */ + toggleHighlight(highlight, fromPrefObserver) { + if (highlight === this._highlightAll) { + return; + } + + this.browser.finder.onHighlightAllChange(highlight); + + this._setHighlightAll(highlight, fromPrefObserver); + + if (!this._dispatchFindEvent("highlightallchange")) { + return; + } + + let word = this._findField.value; + // Bug 429723. Don't attempt to highlight "" + if (highlight && !word) { + return; + } + + this.browser.finder.highlight( + highlight, + word, + this.findMode == this.FIND_LINKS + ); + + // Update the matches count + this._updateMatchesCount(Ci.nsITypeAheadFind.FIND_FOUND); + } + + /** + * Updates the highlight-all mode of the findbar and its UI. + * + * @param {Boolean} highlight Whether to turn the highlight on or off. + * @param {Boolean} fromPrefObserver Whether the callee is the pref + * observer, which means we should not set + * the same pref again. + */ + _setHighlightAll(highlight, fromPrefObserver) { + if (typeof highlight != "boolean") { + highlight = this._highlightAll; + } + if (highlight !== this._highlightAll) { + this._highlightAll = highlight; + if (!fromPrefObserver) { + Services.telemetry.scalarAdd("findbar.highlight_all", 1); + Services.prefs.setBoolPref("findbar.highlightAll", highlight); + } + } + let checkbox = this.getElement("highlight"); + checkbox.checked = this._highlightAll; + } + + /** + * Updates the case-sensitivity mode of the findbar and its UI. + * + * @param {String} [str] The string for which case sensitivity might be + * turned on. This only used when case-sensitivity is + * in auto mode, see `_shouldBeCaseSensitive`. The + * default value for this parameter is the find-field + * value. + * @see _shouldBeCaseSensitive + */ + _updateCaseSensitivity(str) { + let val = str || this._findField.value; + + let caseSensitive = this._shouldBeCaseSensitive(val); + let checkbox = this.getElement("find-case-sensitive"); + let statusLabel = this.getElement("match-case-status"); + checkbox.checked = caseSensitive; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + if ( + this.findMode == this.FIND_NORMAL && + (this._typeAheadCaseSensitive == 0 || this._typeAheadCaseSensitive == 1) + ) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !caseSensitive; + } + + this.browser.finder.caseSensitive = caseSensitive; + } + + /** + * Sets the findbar case-sensitivity mode. + * + * @param {Number} caseSensitivity 0 - case insensitive, + * 1 - case sensitive, + * 2 - auto = case sensitive if the matching + * string contains upper case letters. + * @see _shouldBeCaseSensitive + */ + _setCaseSensitivity(caseSensitivity) { + this._typeAheadCaseSensitive = caseSensitivity; + this._updateCaseSensitivity(); + this._findFailedString = null; + this._find(); + + this._dispatchFindEvent("casesensitivitychange"); + Services.telemetry.scalarAdd("findbar.match_case", 1); + } + + /** + * Updates the diacritic-matching mode of the findbar and its UI. + * + * @param {String} [str] The string for which diacritic matching might be + * turned on. This is only used when diacritic + * matching is in auto mode, see + * `_shouldMatchDiacritics`. The default value for + * this parameter is the find-field value. + * @see _shouldMatchDiacritics. + */ + _updateDiacriticMatching(str) { + let val = str || this._findField.value; + + let matchDiacritics = this._shouldMatchDiacritics(val); + let checkbox = this.getElement("find-match-diacritics"); + let statusLabel = this.getElement("match-diacritics-status"); + checkbox.checked = matchDiacritics; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + if ( + this.findMode == this.FIND_NORMAL && + (this._matchDiacritics == 0 || this._matchDiacritics == 1) + ) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !matchDiacritics; + } + + this.browser.finder.matchDiacritics = matchDiacritics; + } + + /** + * Sets the findbar diacritic-matching mode + * @param {Number} diacriticMatching 0 - ignore diacritics, + * 1 - match diacritics, + * 2 - auto = match diacritics if the + * matching string contains + * diacritics. + * @see _shouldMatchDiacritics + */ + _setDiacriticMatching(diacriticMatching) { + this._matchDiacritics = diacriticMatching; + this._updateDiacriticMatching(); + this._findFailedString = null; + this._find(); + + this._dispatchFindEvent("diacriticmatchingchange"); + + Services.telemetry.scalarAdd("findbar.match_diacritics", 1); + } + + /** + * Updates the entire-word mode of the findbar and its UI. + */ + _setEntireWord() { + let entireWord = this._entireWord; + let checkbox = this.getElement("find-entire-word"); + let statusLabel = this.getElement("entire-word-status"); + checkbox.checked = entireWord; + + // Show the checkbox on the full Find bar. + // Show the label in all other cases. + if (this.findMode == this.FIND_NORMAL) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !entireWord; + } + + this.browser.finder.entireWord = entireWord; + } + + /** + * Sets the findbar entire-word mode. + * + * @param {Boolean} entireWord Whether or not entire-word mode should be + * turned on. + */ + toggleEntireWord(entireWord, fromPrefObserver) { + if (!fromPrefObserver) { + // Just set the pref; our observer will change the find bar behavior. + Services.prefs.setBoolPref("findbar.entireword", entireWord); + + Services.telemetry.scalarAdd("findbar.whole_words", 1); + return; + } + + this._findFailedString = null; + this._find(); + } + + /** + * Opens and displays the find bar. + * + * @param {Number} mode The find mode to be used, which is either + * FIND_NORMAL, FIND_TYPEAHEAD or FIND_LINKS. If not + * passed, we revert to the last find mode if any or + * FIND_NORMAL. + * @return {Boolean} `true` if the find bar wasn't previously open, `false` + * otherwise. + */ + open(mode) { + if (mode != undefined) { + this.findMode = mode; + } + + this._findFailedString = null; + + this._updateFindUI(); + if (this.hidden) { + Services.telemetry.scalarAdd("findbar.shown", 1); + this.removeAttribute("noanim"); + this.hidden = false; + + this._updateStatusUI(Ci.nsITypeAheadFind.FIND_FOUND); + + let event = document.createEvent("Events"); + event.initEvent("findbaropen", true, false); + this.dispatchEvent(event); + + this.browser.finder.onFindbarOpen(); + + return true; + } + return false; + } + + /** + * Closes the findbar. + * + * @param {Boolean} [noAnim] Whether to disable to closing animation. Used + * to close instantly and synchronously, when + * other operations depend on this state. + */ + close(noAnim) { + if (this.hidden) { + return; + } + + if (noAnim) { + this.setAttribute("noanim", true); + } + this.hidden = true; + + let event = document.createEvent("Events"); + event.initEvent("findbarclose", true, false); + this.dispatchEvent(event); + + // 'focusContent()' iterates over all listeners in the chrome + // process, so we need to call it from here. + this.browser.finder.focusContent(); + this.browser.finder.onFindbarClose(); + + this._cancelTimers(); + this._updateBrowserWithState(); + + this._findFailedString = null; + } + + clear() { + this.browser.finder.removeSelection(); + // Clear value and undo/redo transactions + this._findField.value = ""; + this._findField.editor?.clearUndoRedo(); + this.toggleHighlight(false); + this._updateStatusUI(); + this._enableFindButtons(false); + } + + _dispatchKeypressEvent(target, fakeEvent) { + if (!target) { + return; + } + + // The event information comes from the child process. + let event = new target.ownerGlobal.KeyboardEvent( + fakeEvent.type, + fakeEvent + ); + target.dispatchEvent(event); + } + + _updateStatusUIBar(foundURL) { + if (!this._xulBrowserWindow) { + try { + this._xulBrowserWindow = window.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).XULBrowserWindow; + } catch (ex) {} + if (!this._xulBrowserWindow) { + return false; + } + } + + // Call this has the same effect like hovering over link, + // the browser shows the URL as a tooltip. + this._xulBrowserWindow.setOverLink(foundURL || ""); + return true; + } + + _finishFAYT(keypressEvent) { + this.browser.finder.focusContent(); + + if (keypressEvent) { + keypressEvent.preventDefault(); + } + + this.browser.finder.keyPress(keypressEvent); + + this.close(); + return true; + } + + _shouldBeCaseSensitive(str) { + if (this._typeAheadCaseSensitive == 0) { + return false; + } + if (this._typeAheadCaseSensitive == 1) { + return true; + } + + return str != str.toLowerCase(); + } + + _shouldMatchDiacritics(str) { + if (this._matchDiacritics == 0) { + return false; + } + if (this._matchDiacritics == 1) { + return true; + } + + return str != str.normalize("NFD"); + } + + onMouseUp() { + if (!this.hidden && this.findMode != this.FIND_NORMAL) { + this.close(); + } + } + + /** + * We get a fake event object through an IPC message when FAYT is being used + * from within the browser. We then stuff that input in the find bar here. + * + * @param {Object} fakeEvent Event object that looks and quacks like a + * native DOM KeyPress event. + */ + _onBrowserKeypress(fakeEvent) { + const FAYT_LINKS_KEY = "'"; + const FAYT_TEXT_KEY = "/"; + + if (!this.hidden && this._findField == document.activeElement) { + this._dispatchKeypressEvent(this._findField, fakeEvent); + return; + } + + if (this.findMode != this.FIND_NORMAL && this._quickFindTimeout) { + this._findField.select(); + this._findField.focus(); + this._dispatchKeypressEvent(this._findField, fakeEvent); + return; + } + + let key = fakeEvent.charCode + ? String.fromCharCode(fakeEvent.charCode) + : null; + let manualstartFAYT = + (key == FAYT_LINKS_KEY || key == FAYT_TEXT_KEY) && this._manualFAYT; + let autostartFAYT = + !manualstartFAYT && this._findAsYouType && key && key != " "; + if (manualstartFAYT || autostartFAYT) { + let mode = + key == FAYT_LINKS_KEY || (autostartFAYT && this._typeAheadLinksOnly) + ? this.FIND_LINKS + : this.FIND_TYPEAHEAD; + + // Clear bar first, so that when openFindBar() calls setCaseSensitivity() + // it doesn't get confused by a lingering value + this._findField.value = ""; + + this.open(mode); + this._setFindCloseTimeout(); + this._findField.select(); + this._findField.focus(); + + if (autostartFAYT) { + this._dispatchKeypressEvent(this._findField, fakeEvent); + } else { + this._updateStatusUI(Ci.nsITypeAheadFind.FIND_FOUND); + } + } + } + + _updateBrowserWithState() { + if (this._browser) { + this._browser.sendMessageToActor( + "Findbar:UpdateState", + { + findMode: this.findMode, + isOpenAndFocused: + !this.hidden && document.activeElement == this._findField, + hasQuickFindTimeout: !!this._quickFindTimeout, + }, + "FindBar", + "all" + ); + } + } + + _enableFindButtons(aEnable) { + this.getElement("find-next").disabled = this.getElement( + "find-previous" + ).disabled = !aEnable; + } + + /** + * Determines whether minimalist or general-purpose search UI is to be + * displayed when the find bar is activated. + */ + _updateFindUI() { + let showMinimalUI = this.findMode != this.FIND_NORMAL; + + let nodes = this.getElement("findbar-container").children; + let wrapper = this.getElement("findbar-textbox-wrapper"); + let foundMatches = this._foundMatches; + for (let node of nodes) { + if (node == wrapper || node == foundMatches) { + continue; + } + node.hidden = showMinimalUI; + } + this.getElement("find-next").hidden = this.getElement( + "find-previous" + ).hidden = showMinimalUI; + foundMatches.hidden = showMinimalUI || !foundMatches.value; + this._updateCaseSensitivity(); + this._updateDiacriticMatching(); + this._setEntireWord(); + this._setHighlightAll(); + + if (showMinimalUI) { + this._findField.classList.add("minimal"); + } else { + this._findField.classList.remove("minimal"); + } + + let l10nId; + if (this.findMode == this.FIND_TYPEAHEAD) { + l10nId = "findbar-fast-find"; + } else if (this.findMode == this.FIND_LINKS) { + l10nId = "findbar-fast-find-links"; + } else { + l10nId = "findbar-normal-find"; + } + document.l10n.setAttributes(this._findField, l10nId); + } + + _find(value) { + if (!this._dispatchFindEvent("")) { + return; + } + + let val = value || this._findField.value; + + // We have to carry around an explicit version of this, because + // finder.searchString doesn't update on failed searches. + this.browser._lastSearchString = val; + + // Only search on input if we don't have a last-failed string, + // or if the current search string doesn't start with it. + // In entire-word mode we always attemp a find; since sequential matching + // is not guaranteed, the first character typed may not be a word (no + // match), but the with the second character it may well be a word, + // thus a match. + if ( + !this._findFailedString || + !val.startsWith(this._findFailedString) || + this._entireWord + ) { + // Getting here means the user commanded a find op. Make sure any + // initial prefilling is ignored if it hasn't happened yet. + if (this._startFindDeferred) { + this._startFindDeferred.resolve(); + this._startFindDeferred = null; + } + + this._enableFindButtons(val); + this._updateCaseSensitivity(val); + this._updateDiacriticMatching(val); + this._setEntireWord(); + + this.browser.finder.fastFind( + val, + this.findMode == this.FIND_LINKS, + this.findMode != this.FIND_NORMAL + ); + } + + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + + if (this._findResetTimeout != -1) { + clearTimeout(this._findResetTimeout); + } + + // allow a search to happen on input again after a second has expired + // since the previous input, to allow for dynamic content and/ or page + // loading. + this._findResetTimeout = setTimeout(() => { + this._findFailedString = null; + this._findResetTimeout = -1; + }, 1000); + } + + _flash() { + if (this._flashFindBarCount === undefined) { + this._flashFindBarCount = this._initialFlashFindBarCount; + } + + if (this._flashFindBarCount-- == 0) { + clearInterval(this._flashFindBarTimeout); + this._findField.removeAttribute("flash"); + this._flashFindBarCount = 6; + return; + } + + this._findField.setAttribute( + "flash", + this._flashFindBarCount % 2 == 0 ? "false" : "true" + ); + } + + _findAgain(findPrevious) { + this.browser.finder.findAgain( + this._findField.value, + findPrevious, + this.findMode == this.FIND_LINKS, + this.findMode != this.FIND_NORMAL + ); + } + + _updateStatusUI(res, findPrevious) { + let statusL10nId; + switch (res) { + case Ci.nsITypeAheadFind.FIND_WRAPPED: + this._findStatusIcon.setAttribute("status", "wrapped"); + this._findField.removeAttribute("status"); + statusL10nId = findPrevious + ? "findbar-wrapped-to-bottom" + : "findbar-wrapped-to-top"; + break; + case Ci.nsITypeAheadFind.FIND_NOTFOUND: + this._findStatusDesc.setAttribute("status", "notfound"); + this._findStatusIcon.setAttribute("status", "notfound"); + this._findField.setAttribute("status", "notfound"); + this._foundMatches.hidden = true; + statusL10nId = "findbar-not-found"; + break; + case Ci.nsITypeAheadFind.FIND_PENDING: + this._findStatusIcon.setAttribute("status", "pending"); + this._findField.removeAttribute("status"); + this._findStatusDesc.removeAttribute("status"); + statusL10nId = ""; + break; + case Ci.nsITypeAheadFind.FIND_FOUND: + default: + this._findStatusIcon.removeAttribute("status"); + this._findField.removeAttribute("status"); + this._findStatusDesc.removeAttribute("status"); + statusL10nId = ""; + break; + } + if (statusL10nId) { + document.l10n.setAttributes(this._findStatusDesc, statusL10nId); + } else { + delete this._findStatusDesc.dataset.l10nId; + this._findStatusDesc.textContent = ""; + } + } + + updateControlState(result, findPrevious) { + this._updateStatusUI(result, findPrevious); + this._enableFindButtons( + result !== Ci.nsITypeAheadFind.FIND_NOTFOUND && !!this._findField.value + ); + } + + _dispatchFindEvent(type, findPrevious) { + let event = document.createEvent("CustomEvent"); + event.initCustomEvent("find" + type, true, true, { + query: this._findField.value, + caseSensitive: !!this._typeAheadCaseSensitive, + matchDiacritics: !!this._matchDiacritics, + entireWord: this._entireWord, + highlightAll: this._highlightAll, + findPrevious, + }); + return this.dispatchEvent(event); + } + + /** + * Opens the findbar, focuses the findfield and selects its contents. + * Also flashes the findbar the first time it's used. + * + * @param {Number} mode The find mode to be used, which is either + * FIND_NORMAL, FIND_TYPEAHEAD or FIND_LINKS. If not + * passed, we revert to the last find mode if any or + * FIND_NORMAL. + * @return {Promise} A promise that will be resolved when the findbar is + * fully opened. + */ + startFind(mode) { + let prefsvc = Services.prefs; + let userWantsPrefill = true; + this.open(mode); + + if (this._flashFindBar) { + this._flashFindBarTimeout = setInterval(() => this._flash(), 500); + prefsvc.setIntPref( + "accessibility.typeaheadfind.flashBar", + --this._flashFindBar + ); + } + + this._startFindDeferred = Promise.withResolvers(); + let startFindPromise = this._startFindDeferred.promise; + + if (this.prefillWithSelection) { + userWantsPrefill = prefsvc.getBoolPref( + "accessibility.typeaheadfind.prefillwithselection" + ); + } + + if (this.prefillWithSelection && userWantsPrefill) { + this.browser.finder.getInitialSelection(); + + // NB: We have to focus this._findField here so tests that send + // key events can open and close the find bar synchronously. + this._findField.focus(); + + // (e10s) since we focus lets also select it, otherwise that would + // only happen in this.onCurrentSelection and, because it is async, + // there's a chance keypresses could come inbetween, leading to + // jumbled up queries. + this._findField.select(); + + return startFindPromise; + } + + // If userWantsPrefill is false but prefillWithSelection is true, + // then we might need to check the selection clipboard. Call + // onCurrentSelection to do so. + // Note: this.onCurrentSelection clears this._startFindDeferred. + this.onCurrentSelection("", true); + return startFindPromise; + } + + /** + * Convenient alias to startFind(gFindBar.FIND_NORMAL); + * + * You should generally map the window's find command to this method. + * e.g. <command name="cmd_find" oncommand="gFindBar.onFindCommand();"/> + */ + onFindCommand() { + return this.startFind(this.FIND_NORMAL); + } + + /** + * Stub for find-next and find-previous commands. + * + * @param {Boolean} findPrevious `true` for find-previous, `false` + * otherwise. + */ + onFindAgainCommand(findPrevious) { + if (findPrevious) { + Services.telemetry.scalarAdd("findbar.find_prev", 1); + } else { + Services.telemetry.scalarAdd("findbar.find_next", 1); + } + + let findString = + this._browser.finder.searchString || this._findField.value; + if (!findString) { + return this.startFind(); + } + + // We dispatch the findAgain event here instead of in _findAgain since + // if there is a find event handler that prevents the default then + // finder.searchString will never get updated which in turn means + // there would never be findAgain events because of the logic below. + if (!this._dispatchFindEvent("again", findPrevious)) { + return undefined; + } + + // user explicitly requested another search, so do it even if we think it'll fail + this._findFailedString = null; + + // Ensure the stored SearchString is in sync with what we want to find + if (this._findField.value != this._browser.finder.searchString) { + this._find(this._findField.value); + } else { + this._findAgain(findPrevious); + if (this._useModalHighlight) { + this.open(); + this._findField.focus(); + } + } + + return undefined; + } + + /** + * Fetches the currently selected text and sets that as the text to search + * next. This is a MacOS specific feature. + */ + onFindSelectionCommand() { + this.browser.finder.setSearchStringToSelection().then(searchInfo => { + if (searchInfo.selectedText) { + this._findField.value = searchInfo.selectedText; + } + }); + } + + _onAppActivateMac() { + const kPref = "accessibility.typeaheadfind.prefillwithselection"; + if (this.prefillWithSelection && Services.prefs.getBoolPref(kPref)) { + return; + } + + let clipboardSearchString = this._browser.finder.clipboardSearchString; + if ( + clipboardSearchString && + this._findField.value != clipboardSearchString && + !this._findField._willfullyDeleted + ) { + this._findField.value = clipboardSearchString; + this._findField._hadValue = true; + // Changing the search string makes the previous status invalid, so + // we better clear it here. + this._updateStatusUI(); + } + } + + /** + * This handles all the result changes for both type-ahead-find and + * highlighting. + * + * @param {Object} data A dictionary that holds the following properties: + * - {Number} result One of the FIND_* constants + * indicating the result of a search + * operation. + * - {Boolean} findBackwards If the search was done + * from the bottom to the + * top. This is used for + * status messages when + * reaching "the end of the + * page". + * - {String} linkURL When a link matched, then its + * URL. Always null when not in + * FIND_LINKS mode. + */ + onFindResult(data) { + if (data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND) { + // If an explicit Find Again command fails, re-open the toolbar. + if (data.storeResult && this.open()) { + this._findField.select(); + this._findField.focus(); + } + this._findFailedString = data.searchString; + } else { + this._findFailedString = null; + } + + this._updateStatusUI(data.result, data.findBackwards); + this._updateStatusUIBar(data.linkURL); + + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + } + + /** + * This handles all the result changes for matches counts. + * + * @param {Object} result Result Object, containing the total amount of + * matches and a vector of the current result. + * - {Number} total Total count number of matches found. + * - {Number} limit Current setting of the number of matches + * to hit to hit the limit. + * - {Number} current Vector of the current result. + */ + onMatchesCountResult(result) { + if (!result.total) { + delete this._foundMatches.dataset.l10nId; + this._foundMatches.hidden = true; + this._foundMatches.setAttribute("value", ""); + } else { + const l10nId = + result.total === -1 + ? "findbar-found-matches-count-limit" + : "findbar-found-matches"; + this._foundMatches.hidden = false; + document.l10n.setAttributes(this._foundMatches, l10nId, result); + } + } + + onHighlightFinished(result) { + // Noop. + } + + onCurrentSelection(selectionString, isInitialSelection) { + // Ignore the prefill if the user has already typed in the findbar, + // it would have been overwritten anyway. See bug 1198465. + if (isInitialSelection && !this._startFindDeferred) { + return; + } + + if ( + AppConstants.platform == "macosx" && + isInitialSelection && + !selectionString + ) { + let clipboardSearchString = this.browser.finder.clipboardSearchString; + if (clipboardSearchString) { + selectionString = clipboardSearchString; + } + } + + if (selectionString) { + this._findField.value = selectionString; + } + + if (isInitialSelection) { + this._enableFindButtons(!!this._findField.value); + this._findField.select(); + this._findField.focus(); + + this._startFindDeferred.resolve(); + this._startFindDeferred = null; + } + } + + /** + * This handler may cancel a request to focus content by returning |false| + * explicitly. + */ + shouldFocusContent() { + const fm = Services.focus; + if (fm.focusedWindow != window) { + return false; + } + + let focusedElement = fm.focusedElement; + if (!focusedElement) { + return false; + } + + let focusedParent = focusedElement.closest("findbar"); + if (focusedParent != this && focusedParent != this._findField) { + return false; + } + + return true; + } + + disconnectedCallback() { + // Empty the DOM. We will rebuild if reconnected. + while (this.lastChild) { + this.removeChild(this.lastChild); + } + this.destroy(); + } + } + + customElements.define("findbar", MozFindbar); +} diff --git a/toolkit/content/widgets/general.js b/toolkit/content/widgets/general.js new file mode 100644 index 0000000000..14003a7558 --- /dev/null +++ b/toolkit/content/widgets/general.js @@ -0,0 +1,27 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + class MozCommandSet extends MozXULElement { + connectedCallback() { + if (this.getAttribute("commandupdater") === "true") { + const events = this.getAttribute("events") || "*"; + const targets = this.getAttribute("targets") || "*"; + document.commandDispatcher.addCommandUpdater(this, events, targets); + } + } + + disconnectedCallback() { + if (this.getAttribute("commandupdater") === "true") { + document.commandDispatcher.removeCommandUpdater(this); + } + } + } + + customElements.define("commandset", MozCommandSet); +} diff --git a/toolkit/content/widgets/infobar.css b/toolkit/content/widgets/infobar.css new file mode 100644 index 0000000000..ee811818b5 --- /dev/null +++ b/toolkit/content/widgets/infobar.css @@ -0,0 +1,99 @@ +/* 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/. */ + +:host(.infobar) { + --info-bar-background-color: light-dark(var(--color-white), rgb(66, 65, 77)); + --info-bar-text-color: light-dark(var(--color-gray-100), var(--color-gray-05)); + position: relative; + + &::before { + content: ""; + display: block; + width: 2px; + position: absolute; + top: 0; + inset-inline-start: 0; + height: 100%; + border-start-start-radius: 4px; + border-end-start-radius: 4px; + } + + .container { + /* Don't let lwthemes set a text-shadow. */ + text-shadow: none; + padding-block: 3px; + align-items: center; + } + + .content { + gap: 0 12px; + height: fit-content; + } + + .close { + margin-block: 4px; + margin-inline-start: 8px; + background-size: 12px; + height: 24px; + width: 24px; + align-self: flex-start; + } +} + +@media (prefers-contrast) { + :host(.infobar)::before { + background-color: CanvasText; + } +} + +@media not (prefers-contrast) { + :host(.infobar) { + box-shadow: 0 1px 2px rgba(58, 57, 68, 0.1); + background-color: var(--info-bar-background-color); + color: var(--info-bar-text-color); + + &::before { + background-image: linear-gradient(0deg, #9059ff 0%, #ff4aa2 52.08%, #ffbd4f 100%); + } + } +} + +:host([message-bar-type=infobar]:first-of-type) { + margin-top: 4px; +} + +:host([message-bar-type=infobar]) { + margin: 0 4px 4px; +} + +::slotted(.notification-button-container) { + gap: 8px; + display: inline-flex; +} + +::slotted(.text-link) { + margin: 0 !important; +} + +img.inline-icon { + /* Align inline icon images in the message content */ + vertical-align: middle; + /* Ensure they get the right fill color. */ + -moz-context-properties: fill; + fill: currentColor; +} + +strong { + font-weight: var(--font-weight-bold); +} + +/* type="system" infobar styles */ + +:host([type=system]) .icon { + display: none; +} + +:host([type=system]) .content { + margin-inline-start: 0; +} diff --git a/toolkit/content/widgets/lit-utils.mjs b/toolkit/content/widgets/lit-utils.mjs new file mode 100644 index 0000000000..87bc9073a0 --- /dev/null +++ b/toolkit/content/widgets/lit-utils.mjs @@ -0,0 +1,138 @@ +/* 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/. */ + +import { LitElement } from "chrome://global/content/vendor/lit.all.mjs"; + +/** + * Helper for our replacement of @query. Used with `static queries` property. + * + * https://github.com/lit/lit/blob/main/packages/reactive-element/src/decorators/query.ts + */ +function query(el, selector) { + return () => el.renderRoot.querySelector(selector); +} + +/** + * Helper for our replacement of @queryAll. Used with `static queries` property. + * + * https://github.com/lit/lit/blob/main/packages/reactive-element/src/decorators/query-all.ts + */ +function queryAll(el, selector) { + return () => el.renderRoot.querySelectorAll(selector); +} + +/** + * MozLitElement provides extensions to the lit-provided LitElement class. + * + ******* + * + * `@query` support (define a getter for a querySelector): + * + * static get queries() { + * return { + * propertyName: ".aNormal .cssSelector", + * anotherName: { all: ".selectorFor .querySelectorAll" }, + * }; + * } + * + * This example would add properties that would be written like this without + * using `queries`: + * + * get propertyName() { + * return this.renderRoot?.querySelector(".aNormal .cssSelector"); + * } + * + * get anotherName() { + * return this.renderRoot?.querySelectorAll(".selectorFor .querySelectorAll"); + * } + ******* + * + * Automatic Fluent support for shadow DOM. + * + * Fluent requires that a shadowRoot be connected before it can use Fluent. + * Shadow roots will get connected automatically. + * + ******* + * + * Test helper for sending events after a change: `dispatchOnUpdateComplete` + * + * When some async stuff is going on and you want to wait for it in a test, you + * can use `this.dispatchOnUpdateComplete(myEvent)` and have the test wait on + * your event. + * + * The component will then wait for your reactive property change to take effect + * and dispatch the desired event. + * + * Example: + * + * async onClick() { + * let response = await this.getServerResponse(this.data); + * // Show the response status to the user. + * this.responseStatus = respose.status; + * this.dispatchOnUpdateComplete( + * new CustomEvent("status-shown") + * ); + * } + * + * add_task(async testButton() { + * let button = this.setupAndGetButton(); + * button.click(); + * await BrowserTestUtils.waitForEvent(button, "status-shown"); + * }); + */ +export class MozLitElement extends LitElement { + constructor() { + super(); + let { queries } = this.constructor; + if (queries) { + for (let [selectorName, selector] of Object.entries(queries)) { + if (selector.all) { + Object.defineProperty(this, selectorName, { + get: queryAll(this, selector.all), + }); + } else { + Object.defineProperty(this, selectorName, { + get: query(this, selector), + }); + } + } + } + } + + connectedCallback() { + super.connectedCallback(); + if ( + this.renderRoot == this.shadowRoot && + !this._l10nRootConnected && + document.l10n + ) { + document.l10n.connectRoot(this.renderRoot); + this._l10nRootConnected = true; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if ( + this.renderRoot == this.shadowRoot && + this._l10nRootConnected && + document.l10n + ) { + document.l10n.disconnectRoot(this.renderRoot); + this._l10nRootConnected = false; + } + } + + async dispatchOnUpdateComplete(event) { + await this.updateComplete; + this.dispatchEvent(event); + } + + update() { + super.update(); + if (document.l10n) { + document.l10n.translateFragment(this.renderRoot); + } + } +} diff --git a/toolkit/content/widgets/mach_commands.py b/toolkit/content/widgets/mach_commands.py new file mode 100644 index 0000000000..58a8b8fcda --- /dev/null +++ b/toolkit/content/widgets/mach_commands.py @@ -0,0 +1,208 @@ +# 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/. + +import os +import re + +from mach.decorators import Command, CommandArgument + +FIXME_COMMENT = "// FIXME: replace with path to your reusable widget\n" +LICENSE_HEADER = """/* 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/. */ +""" + +JS_HEADER = """{license} +import {{ html }} from "../vendor/lit.all.mjs"; +import {{ MozLitElement }} from "../lit-utils.mjs"; + +/** + * Component description goes here. + * + * @tagname {element_name} + * @property {{string}} variant - Property description goes here + */ +export default class {class_name} extends MozLitElement {{ + static properties = {{ + variant: {{ type: String }}, + }}; + + constructor() {{ + super(); + this.variant = "default"; + }} + + render() {{ + return html` + <link rel="stylesheet" href="chrome://global/content/elements/{element_name}.css" /> + <div>Variant type: ${{this.variant}}</div> + `; + }} +}} +customElements.define("{element_name}", {class_name}); +""" + +STORY_HEADER = """{license} +{html_lit_import} +// eslint-disable-next-line import/no-unassigned-import +{fixme_comment}import "{element_path}"; + +export default {{ + title: "{story_prefix}/{story_name}", + component: "{element_name}", + argTypes: {{ + variant: {{ + options: ["default", "other"], + control: {{ type: "select" }}, + }}, + }}, +}}; + +const Template = ({{ variant }}) => html` + <{element_name} .variant=${{variant}}></{element_name}> +`; + +export const Default = Template.bind({{}}); +Default.args = {{ + variant: "default", +}}; +""" + + +def run_mach(command_context, cmd, **kwargs): + return command_context._mach_context.commands.dispatch( + cmd, command_context._mach_context, **kwargs + ) + + +def run_npm(command_context, args): + return run_mach( + command_context, "npm", args=[*args, "--prefix=browser/components/storybook"] + ) + + +@Command( + "addwidget", + category="misc", + description="Scaffold a front-end component.", +) +@CommandArgument( + "names", + nargs="+", + help="Component names to create in kebab-case, eg. my-card.", +) +def addwidget(command_context, names): + story_prefix = "UI Widgets" + html_lit_import = 'import { html } from "../vendor/lit.all.mjs";' + for name in names: + component_dir = "toolkit/content/widgets/{0}".format(name) + + try: + os.mkdir(component_dir) + except FileExistsError: + pass + + with open("{0}/{1}.mjs".format(component_dir, name), "w", newline="\n") as f: + class_name = "".join(p.capitalize() for p in name.split("-")) + f.write( + JS_HEADER.format( + license=LICENSE_HEADER, + element_name=name, + class_name=class_name, + ) + ) + + with open("{0}/{1}.css".format(component_dir, name), "w", newline="\n") as f: + f.write(LICENSE_HEADER) + + test_name = name.replace("-", "_") + test_path = "toolkit/content/tests/widgets/test_{0}.html".format(test_name) + jar_path = "toolkit/content/jar.mn" + jar_lines = None + with open(jar_path, "r") as f: + jar_lines = f.readlines() + elements_startswith = " content/global/elements/" + new_css_line = "{0}{1}.css (widgets/{1}/{1}.css)\n".format( + elements_startswith, name + ) + new_js_line = "{0}{1}.mjs (widgets/{1}/{1}.mjs)\n".format( + elements_startswith, name + ) + new_jar_lines = [] + found_elements_section = False + added_widget = False + for line in jar_lines: + if line.startswith(elements_startswith): + found_elements_section = True + if found_elements_section and not added_widget and line > new_css_line: + added_widget = True + new_jar_lines.append(new_css_line) + new_jar_lines.append(new_js_line) + new_jar_lines.append(line) + + with open(jar_path, "w", newline="\n") as f: + f.write("".join(new_jar_lines)) + + story_path = "{0}/{1}.stories.mjs".format(component_dir, name) + element_path = "./{0}.mjs".format(name) + with open(story_path, "w", newline="\n") as f: + story_name = " ".join( + name for name in re.findall(r"[A-Z][a-z]+", class_name) if name != "Moz" + ) + f.write( + STORY_HEADER.format( + license=LICENSE_HEADER, + element_name=name, + story_name=story_name, + story_prefix=story_prefix, + fixme_comment="", + element_path=element_path, + html_lit_import=html_lit_import, + ) + ) + + run_mach( + command_context, "addtest", argv=[test_path, "--suite", "mochitest-chrome"] + ) + + +@Command( + "addstory", + category="misc", + description="Scaffold a front-end Storybook story.", +) +@CommandArgument( + "name", + help="Story to create in kebab-case, eg. my-card.", +) +@CommandArgument( + "project_name", + type=str, + help='Name of the project or team for the new component to keep stories organized. Eg. "Credential Management"', +) +@CommandArgument( + "--path", + help="Path to the widget source, eg. /browser/components/my-module.mjs or chrome://browser/content/my-module.mjs", +) +def addstory(command_context, name, project_name, path): + html_lit_import = 'import { html } from "lit.all.mjs";' + story_path = "browser/components/storybook/stories/{0}.stories.mjs".format(name) + project_name = project_name.split() + project_name = " ".join(p.capitalize() for p in project_name) + story_prefix = "Domain-specific UI Widgets/{0}".format(project_name) + with open(story_path, "w", newline="\n") as f: + print(f"Creating new story {name} in {story_path}") + story_name = " ".join(p.capitalize() for p in name.split("-")) + f.write( + STORY_HEADER.format( + license=LICENSE_HEADER, + element_name=name, + story_name=story_name, + element_path=path, + fixme_comment="" if path else FIXME_COMMENT, + project_name=project_name, + story_prefix=story_prefix, + html_lit_import=html_lit_import, + ) + ) diff --git a/toolkit/content/widgets/marquee.css b/toolkit/content/widgets/marquee.css new file mode 100644 index 0000000000..b898cd0dce --- /dev/null +++ b/toolkit/content/widgets/marquee.css @@ -0,0 +1,26 @@ +/* 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/. */ + +.outerDiv { + overflow: hidden; + width: -moz-available; +} + +.horizontal > .innerDiv { + width: max-content; + /* We want to create overflow of twice our available space. */ + padding: 0 100%; +} + +/* disable scrolling in contenteditable */ +:host(:read-write) .innerDiv { + padding: 0 !important; +} + +/* When printing or when the user doesn't want movement, we disable scrolling */ +@media print, (prefers-reduced-motion) { + .innerDiv { + padding: 0 !important; + } +} diff --git a/toolkit/content/widgets/marquee.js b/toolkit/content/widgets/marquee.js new file mode 100644 index 0000000000..8b18703b92 --- /dev/null +++ b/toolkit/content/widgets/marquee.js @@ -0,0 +1,417 @@ +/* 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 the class of entry. It will construct the actual implementation + * according to the value of the "direction" property. + */ +this.MarqueeWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.switchImpl(); + } + + /* + * Callback called by UAWidgetsChild wheen the direction property + * changes. + */ + onchange() { + this.switchImpl(); + } + + switchImpl() { + let newImpl; + switch (this.element.direction) { + case "up": + case "down": + newImpl = MarqueeVerticalImplWidget; + break; + case "left": + case "right": + newImpl = MarqueeHorizontalImplWidget; + break; + } + + // Skip if we are asked to load the same implementation. + // This can happen if the property is set again w/o value change. + if (this.impl && this.impl.constructor == newImpl) { + return; + } + this.teardown(); + if (newImpl) { + this.impl = new newImpl(this.shadowRoot); + this.impl.onsetup(); + } + } + + teardown() { + if (!this.impl) { + return; + } + this.impl.teardown(); + this.shadowRoot.firstChild.remove(); + delete this.impl; + } +}; + +this.MarqueeBaseImplWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup() { + this.generateContent(); + + // Set up state. + this._currentDirection = this.element.direction || "left"; + this._currentLoop = this.element.loop; + this.dirsign = 1; + this.startAt = 0; + this.stopAt = 0; + this.newPosition = 0; + this.runId = 0; + this.originalHeight = 0; + this.invalidateCache = true; + + this._mutationObserver = new this.window.MutationObserver(aMutations => + this._mutationActor(aMutations) + ); + this._mutationObserver.observe(this.element, { + attributes: true, + attributeOldValue: true, + attributeFilter: ["loop", "", "behavior", "direction", "width", "height"], + }); + + // init needs to be run after the page has loaded in order to calculate + // the correct height/width + if (this.document.readyState == "complete") { + this.init(); + } else { + this.window.addEventListener("load", this, { once: true }); + } + + this.shadowRoot.addEventListener("marquee-start", this); + this.shadowRoot.addEventListener("marquee-stop", this); + } + + teardown() { + this._mutationObserver.disconnect(); + this.window.clearTimeout(this.runId); + + this.window.removeEventListener("load", this); + this.shadowRoot.removeEventListener("marquee-start", this); + this.shadowRoot.removeEventListener("marquee-stop", this); + } + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + return; + } + + switch (aEvent.type) { + case "load": + this.init(); + break; + case "marquee-start": + this.doStart(); + break; + case "marquee-stop": + this.doStop(); + break; + } + } + + get outerDiv() { + return this.shadowRoot.firstChild; + } + + get innerDiv() { + return this.shadowRoot.getElementById("innerDiv"); + } + + get scrollDelayWithTruespeed() { + if (this.element.scrollDelay < 60 && !this.element.trueSpeed) { + return 60; + } + return this.element.scrollDelay; + } + + doStart() { + if (this.runId == 0) { + var lambda = () => this._doMove(false); + this.runId = this.window.setTimeout( + lambda, + this.scrollDelayWithTruespeed - this._deltaStartStop + ); + this._deltaStartStop = 0; + } + } + + doStop() { + if (this.runId != 0) { + this._deltaStartStop = Date.now() - this._lastMoveDate; + this.window.clearTimeout(this.runId); + } + + this.runId = 0; + } + + _fireEvent(aName, aBubbles, aCancelable) { + var e = this.document.createEvent("Events"); + e.initEvent(aName, aBubbles, aCancelable); + this.element.dispatchEvent(e); + } + + _doMove(aResetPosition) { + this._lastMoveDate = Date.now(); + + // invalidateCache is true at first load and whenever an attribute + // is changed + if (this.invalidateCache) { + this.invalidateCache = false; // we only want this to run once every scroll direction change + + var corrvalue = 0; + + switch (this._currentDirection) { + case "up": + case "down": { + let height = this.window.getComputedStyle(this.element).height; + this.outerDiv.style.height = height; + if (this.originalHeight > this.outerDiv.offsetHeight) { + corrvalue = this.originalHeight - this.outerDiv.offsetHeight; + } + this.innerDiv.style.padding = height + " 0"; + let isUp = this._currentDirection == "up"; + if (isUp) { + this.dirsign = 1; + this.startAt = + this.element.behavior == "alternate" + ? this.originalHeight - corrvalue + : 0; + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? parseInt(height) + corrvalue + : this.originalHeight + parseInt(height); + } else { + this.dirsign = -1; + this.startAt = + this.element.behavior == "alternate" + ? parseInt(height) + corrvalue + : this.originalHeight + parseInt(height); + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? this.originalHeight - corrvalue + : 0; + } + break; + } + case "left": + case "right": + default: { + let isRight = this._currentDirection == "right"; + // NOTE: It's important to use getComputedStyle() to not account for the padding. + let innerWidth = parseInt( + this.window.getComputedStyle(this.innerDiv).width + ); + if (innerWidth > this.outerDiv.offsetWidth) { + corrvalue = innerWidth - this.outerDiv.offsetWidth; + } + let rtl = + this.window.getComputedStyle(this.element).direction == "rtl"; + if (isRight != rtl) { + this.dirsign = -1; + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? innerWidth - corrvalue + : 0; + this.startAt = + this.outerDiv.offsetWidth + + (this.element.behavior == "alternate" + ? corrvalue + : innerWidth + this.stopAt); + } else { + this.dirsign = 1; + this.startAt = + this.element.behavior == "alternate" ? innerWidth - corrvalue : 0; + this.stopAt = + this.outerDiv.offsetWidth + + (this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? corrvalue + : innerWidth + this.startAt); + } + if (rtl) { + this.startAt = -this.startAt; + this.stopAt = -this.stopAt; + this.dirsign = -this.dirsign; + } + break; + } + } + + if (aResetPosition) { + this.newPosition = this.startAt; + this._fireEvent("start", false, false); + } + } // end if + + this.newPosition = + this.newPosition + this.dirsign * this.element.scrollAmount; + + if ( + (this.dirsign == 1 && this.newPosition > this.stopAt) || + (this.dirsign == -1 && this.newPosition < this.stopAt) + ) { + switch (this.element.behavior) { + case "alternate": + // lets start afresh + this.invalidateCache = true; + + // swap direction + const swap = { left: "right", down: "up", up: "down", right: "left" }; + this._currentDirection = swap[this._currentDirection] || "left"; + this.newPosition = this.stopAt; + + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + if (this._currentLoop != 1) { + this._fireEvent("bounce", false, true); + } + break; + + case "slide": + if (this._currentLoop > 1) { + this.newPosition = this.startAt; + } + break; + + default: + this.newPosition = this.startAt; + + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + // dispatch start event, even when this._currentLoop == 1, comp. with IE6 + this._fireEvent("start", false, false); + } + + if (this._currentLoop > 1) { + this._currentLoop--; + } else if (this._currentLoop == 1) { + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.stopAt; + } else { + this.outerDiv.scrollLeft = this.stopAt; + } + this.element.stop(); + this._fireEvent("finish", false, true); + return; + } + } else if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + var myThis = this; + var lambda = function myTimeOutFunction() { + myThis._doMove(false); + }; + this.runId = this.window.setTimeout(lambda, this.scrollDelayWithTruespeed); + } + + init() { + this.element.stop(); + + if (this._currentDirection == "up" || this._currentDirection == "down") { + // store the original height before we add padding + this.innerDiv.style.padding = 0; + this.originalHeight = this.innerDiv.offsetHeight; + } + + this._doMove(true); + } + + _mutationActor(aMutations) { + while (aMutations.length) { + var mutation = aMutations.shift(); + var attrName = mutation.attributeName.toLowerCase(); + var oldValue = mutation.oldValue; + var target = mutation.target; + var newValue = target.getAttribute(attrName); + + if (oldValue != newValue) { + this.invalidateCache = true; + switch (attrName) { + case "loop": + this._currentLoop = target.loop; + break; + case "direction": + this._currentDirection = target.direction; + break; + } + } + } + } +}; + +this.MarqueeHorizontalImplWidget = class extends MarqueeBaseImplWidget { + generateContent() { + // White-space isn't allowed because a marquee could be + // inside 'white-space: pre' + this.shadowRoot.innerHTML = `<div class="outerDiv horizontal" + ><link rel="stylesheet" href="chrome://global/content/elements/marquee.css" + /><div class="innerDiv" id="innerDiv" + ><slot + /></div + ></div>`; + } +}; + +this.MarqueeVerticalImplWidget = class extends MarqueeBaseImplWidget { + generateContent() { + // White-space isn't allowed because a marquee could be + // inside 'white-space: pre' + this.shadowRoot.innerHTML = `<div class="outerDiv vertical" + ><link rel="stylesheet" href="chrome://global/content/elements/marquee.css" + /><div class="innerDiv" id="innerDiv" + ><slot + /></div + ></div>`; + } +}; diff --git a/toolkit/content/widgets/menu.js b/toolkit/content/widgets/menu.js new file mode 100644 index 0000000000..f787747a01 --- /dev/null +++ b/toolkit/content/widgets/menu.js @@ -0,0 +1,493 @@ +/* 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. +{ + let imports = {}; + ChromeUtils.defineESModuleGetters(imports, { + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", + }); + + const MozMenuItemBaseMixin = Base => { + class MozMenuItemBase extends MozElements.BaseTextMixin(Base) { + // nsIDOMXULSelectControlItemElement + set value(val) { + this.setAttribute("value", val); + } + get value() { + return this.getAttribute("value"); + } + + // nsIDOMXULSelectControlItemElement + get selected() { + return this.getAttribute("selected") == "true"; + } + + // nsIDOMXULSelectControlItemElement + get control() { + var parent = this.parentNode; + // Return the parent if it is a menu or menulist. + if (parent && XULMenuElement.isInstance(parent.parentNode)) { + return parent.parentNode; + } + return null; + } + + // nsIDOMXULContainerItemElement + get parentContainer() { + for (var parent = this.parentNode; parent; parent = parent.parentNode) { + if (XULMenuElement.isInstance(parent)) { + return parent; + } + } + return null; + } + } + MozXULElement.implementCustomInterface(MozMenuItemBase, [ + Ci.nsIDOMXULSelectControlItemElement, + Ci.nsIDOMXULContainerItemElement, + ]); + return MozMenuItemBase; + }; + + const MozMenuBaseMixin = Base => { + class MozMenuBase extends MozMenuItemBaseMixin(Base) { + set open(val) { + this.openMenu(val); + } + + get open() { + return this.hasAttribute("open"); + } + + get itemCount() { + var menupopup = this.menupopup; + return menupopup ? menupopup.children.length : 0; + } + + get menupopup() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + for ( + var child = this.firstElementChild; + child; + child = child.nextElementSibling + ) { + if (child.namespaceURI == XUL_NS && child.localName == "menupopup") { + return child; + } + } + return null; + } + + appendItem(aLabel, aValue) { + var menupopup = this.menupopup; + if (!menupopup) { + menupopup = this.ownerDocument.createXULElement("menupopup"); + this.appendChild(menupopup); + } + + var menuitem = this.ownerDocument.createXULElement("menuitem"); + menuitem.setAttribute("label", aLabel); + menuitem.setAttribute("value", aValue); + + return menupopup.appendChild(menuitem); + } + + getIndexOfItem(aItem) { + var menupopup = this.menupopup; + if (menupopup) { + var items = menupopup.children; + var length = items.length; + for (var index = 0; index < length; ++index) { + if (items[index] == aItem) { + return index; + } + } + } + return -1; + } + + getItemAtIndex(aIndex) { + var menupopup = this.menupopup; + if (!menupopup || aIndex < 0 || aIndex >= menupopup.children.length) { + return null; + } + + return menupopup.children[aIndex]; + } + } + MozXULElement.implementCustomInterface(MozMenuBase, [ + Ci.nsIDOMXULContainerElement, + ]); + return MozMenuBase; + }; + + // The <menucaption> element is used for rendering <html:optgroup> inside of <html:select>, + // See SelectParentHelper.jsm. + class MozMenuCaption extends MozMenuBaseMixin(MozXULElement) { + static get inheritedAttributes() { + return { + ".menu-iconic-left": "selected,disabled,checked", + ".menu-iconic-icon": "src=image,validate,src", + ".menu-iconic-text": "value=label,crop,highlightable", + ".menu-iconic-highlightable-text": "text=label,crop,highlightable", + }; + } + + connectedCallback() { + this.textContent = ""; + this.appendChild( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon" aria-hidden="true"></image> + </hbox> + <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"></label> + <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"></label> + `) + ); + this.initializeAttributeInheritance(); + } + } + + customElements.define("menucaption", MozMenuCaption); + + // In general, wait to render menus and menuitems inside menupopups + // until they are going to be visible: + window.addEventListener( + "popupshowing", + e => { + if (e.originalTarget.ownerDocument != document) { + return; + } + e.originalTarget.setAttribute("hasbeenopened", "true"); + for (let el of e.originalTarget.querySelectorAll("menuitem, menu")) { + el.render(); + } + }, + { capture: true } + ); + + class MozMenuItem extends MozMenuItemBaseMixin(MozXULElement) { + static get observedAttributes() { + return super.observedAttributes.concat("acceltext", "key"); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name == "acceltext") { + if (this._ignoreAccelTextChange) { + this._ignoreAccelTextChange = false; + } else { + this._accelTextIsDerived = false; + this._computeAccelTextFromKeyIfNeeded(); + } + } + if (name == "key") { + this._computeAccelTextFromKeyIfNeeded(); + } + super.attributeChangedCallback(name, oldValue, newValue); + } + + static get inheritedAttributes() { + return { + ".menu-iconic-text": "value=label,crop,accesskey,highlightable", + ".menu-text": "value=label,crop,accesskey,highlightable", + ".menu-iconic-highlightable-text": + "text=label,crop,accesskey,highlightable", + ".menu-iconic-left": "selected,_moz-menuactive,disabled,checked", + ".menu-iconic-icon": + "src=image,validate,triggeringprincipal=iconloadingprincipal", + ".menu-iconic-accel": "value=acceltext", + ".menu-accel": "value=acceltext", + }; + } + + static get iconicNoAccelFragment() { + // Add aria-hidden="true" on all DOM, since XULMenuAccessible handles accessibility here. + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/> + `), + true + ); + Object.defineProperty(this, "iconicNoAccelFragment", { value: frag }); + return frag; + } + + static get iconicFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/> + <hbox class="menu-accel-container" aria-hidden="true"> + <label class="menu-iconic-accel"/> + </hbox> + `), + true + ); + Object.defineProperty(this, "iconicFragment", { value: frag }); + return frag; + } + + static get plainFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <label class="menu-text" crop="end" aria-hidden="true"/> + <hbox class="menu-accel-container" aria-hidden="true"> + <label class="menu-accel"/> + </hbox> + `), + true + ); + Object.defineProperty(this, "plainFragment", { value: frag }); + return frag; + } + + get isIconic() { + let type = this.getAttribute("type"); + return ( + type == "checkbox" || + type == "radio" || + this.classList.contains("menuitem-iconic") + ); + } + + get isMenulistChild() { + return this.matches("menulist > menupopup > menuitem"); + } + + get isInHiddenMenupopup() { + return this.matches("menupopup:not([hasbeenopened]) menuitem"); + } + + _computeAccelTextFromKeyIfNeeded() { + if (!this._accelTextIsDerived && this.getAttribute("acceltext")) { + return; + } + let accelText = (() => { + if (!document.contains(this)) { + return null; + } + let keyId = this.getAttribute("key"); + if (!keyId) { + return null; + } + let key = document.getElementById(keyId); + if (!key) { + let msg = + `Key ${keyId} of menuitem ${this.getAttribute("label")} ` + + `could not be found`; + if (keyId.startsWith("ext-key-id-")) { + console.info(msg); + } else { + console.error(msg); + } + return null; + } + return imports.ShortcutUtils.prettifyShortcut(key); + })(); + + this._accelTextIsDerived = true; + // We need to ignore the next attribute change callback for acceltext, in + // order to not reenter here. + this._ignoreAccelTextChange = true; + if (accelText) { + this.setAttribute("acceltext", accelText); + } else { + this.removeAttribute("acceltext"); + } + } + + render() { + if (this.renderedOnce) { + return; + } + this.renderedOnce = true; + this.textContent = ""; + if (this.isMenulistChild) { + this.append(this.constructor.iconicNoAccelFragment.cloneNode(true)); + } else if (this.isIconic) { + this.append(this.constructor.iconicFragment.cloneNode(true)); + } else { + this.append(this.constructor.plainFragment.cloneNode(true)); + } + + this._computeAccelTextFromKeyIfNeeded(); + this.initializeAttributeInheritance(); + } + + connectedCallback() { + if (this.renderedOnce) { + this._computeAccelTextFromKeyIfNeeded(); + } + // Eagerly render if we are being inserted into a menulist (since we likely need to + // size it), or into an already-opened menupopup (since we are already visible). + // Checking isConnectedAndReady is an optimization that will let us quickly skip + // non-menulists that are being connected during parse. + if ( + this.isMenulistChild || + (this.isConnectedAndReady && !this.isInHiddenMenupopup) + ) { + this.render(); + } + } + } + + customElements.define("menuitem", MozMenuItem); + + const isHiddenWindow = + document.documentURI == "chrome://browser/content/hiddenWindowMac.xhtml"; + + class MozMenu extends MozMenuBaseMixin( + MozElements.MozElementMixin(XULMenuElement) + ) { + static get inheritedAttributes() { + return { + ".menubar-text": "value=label,accesskey,crop", + ".menu-iconic-text": "value=label,accesskey,crop,highlightable", + ".menu-text": "value=label,accesskey,crop", + ".menu-iconic-highlightable-text": + "text=label,crop,accesskey,highlightable", + ".menubar-left": "src=image", + ".menu-iconic-icon": + "src=image,triggeringprincipal=iconloadingprincipal,validate", + ".menu-iconic-accel": "value=acceltext", + ".menu-right": "_moz-menuactive,disabled", + ".menu-accel": "value=acceltext", + }; + } + + get needsEagerRender() { + return ( + this.isMenubarChild || this.isMenulistChild || !this.isInHiddenMenupopup + ); + } + + get isMenubarChild() { + return this.matches("menubar > menu"); + } + + get isMenulistChild() { + return this.matches("menulist > menupopup > menu"); + } + + get isInHiddenMenupopup() { + return this.matches("menupopup:not([hasbeenopened]) menu"); + } + + get isIconic() { + return this.classList.contains("menu-iconic"); + } + + get fragment() { + let { isMenubarChild, isIconic } = this; + let fragment = null; + // Add aria-hidden="true" on all DOM, since XULMenuAccessible handles accessibility here. + if (isMenubarChild && isIconic) { + if (!MozMenu.menubarIconicFrag) { + MozMenu.menubarIconicFrag = MozXULElement.parseXULToFragment(` + <image class="menubar-left" aria-hidden="true"/> + <label class="menubar-text" crop="end" aria-hidden="true"/> + `); + } + fragment = document.importNode(MozMenu.menubarIconicFrag, true); + } + if (isMenubarChild && !isIconic) { + if (!MozMenu.menubarFrag) { + MozMenu.menubarFrag = MozXULElement.parseXULToFragment(` + <label class="menubar-text" crop="end" aria-hidden="true"/> + `); + } + fragment = document.importNode(MozMenu.menubarFrag, true); + } + if (!isMenubarChild && isIconic) { + if (!MozMenu.normalIconicFrag) { + MozMenu.normalIconicFrag = MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/> + <hbox class="menu-accel-container" anonid="accel" aria-hidden="true"> + <label class="menu-iconic-accel"/> + </hbox> + <hbox align="center" class="menu-right" aria-hidden="true"> + <image/> + </hbox> + `); + } + + fragment = document.importNode(MozMenu.normalIconicFrag, true); + } + if (!isMenubarChild && !isIconic) { + if (!MozMenu.normalFrag) { + MozMenu.normalFrag = MozXULElement.parseXULToFragment(` + <label class="menu-text" crop="end" aria-hidden="true"/> + <hbox class="menu-accel-container" anonid="accel" aria-hidden="true"> + <label class="menu-accel"/> + </hbox> + <hbox align="center" class="menu-right" aria-hidden="true"> + <image/> + </hbox> + `); + } + + fragment = document.importNode(MozMenu.normalFrag, true); + } + return fragment; + } + + render() { + // There are 2 main types of menus: + // (1) direct descendant of a menubar + // (2) all other menus + // There is also an "iconic" variation of (1) and (2) based on the class. + // To make this as simple as possible, we don't support menus being changed from one + // of these types to another after the initial DOM connection. It'd be possible to make + // this work by keeping track of the markup we prepend and then removing / re-prepending + // during a change, but it's not a feature we use anywhere currently. + if (this.renderedOnce) { + return; + } + this.renderedOnce = true; + + // There will be a <menupopup /> already. Don't clear it out, just put our markup before it. + this.prepend(this.fragment); + this.initializeAttributeInheritance(); + } + + connectedCallback() { + // On OSX we will have a bunch of menus in the hidden window. They get converted + // into native menus based on the host attributes, so the inner DOM doesn't need + // to be created. + if (isHiddenWindow) { + return; + } + + if (this.delayConnectedCallback()) { + return; + } + + // Wait until we are going to be visible or required for sizing a popup. + if (!this.needsEagerRender) { + return; + } + + this.render(); + } + } + + customElements.define("menu", MozMenu); +} diff --git a/toolkit/content/widgets/menulist.js b/toolkit/content/widgets/menulist.js new file mode 100644 index 0000000000..4e66c030f3 --- /dev/null +++ b/toolkit/content/widgets/menulist.js @@ -0,0 +1,417 @@ +/* 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" + ); + const MozXULMenuElement = MozElements.MozElementMixin(XULMenuElement); + const MenuBaseControl = MozElements.BaseControlMixin(MozXULMenuElement); + + class MozMenuList extends MenuBaseControl { + constructor() { + super(); + + this.addEventListener( + "command", + event => { + if (event.target.parentNode.parentNode == this) { + this.selectedItem = event.target; + } + }, + true + ); + + this.addEventListener("popupshowing", event => { + if (event.target.parentNode == this) { + this.activeChild = null; + if (this.selectedItem) { + // Not ready for auto-setting the active child in hierarchies yet. + // For now, only do this when the outermost menupopup opens. + this.activeChild = this.mSelectedInternal; + } + } + }); + + this.addEventListener( + "keypress", + event => { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey + ) { + return; + } + + if ( + AppConstants.platform === "macosx" && + !this.open && + (event.keyCode == KeyEvent.DOM_VK_UP || + event.keyCode == KeyEvent.DOM_VK_DOWN) + ) { + // This should open the menulist on macOS, see + // XULButtonElement::PostHandleEvent. + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_UP || + event.keyCode == KeyEvent.DOM_VK_DOWN || + event.keyCode == KeyEvent.DOM_VK_PAGE_UP || + event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN || + event.keyCode == KeyEvent.DOM_VK_HOME || + event.keyCode == KeyEvent.DOM_VK_END || + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE || + event.charCode > 0 + ) { + // Moving relative to an item: start from the currently selected item + this.activeChild = this.mSelectedInternal; + if (this.handleKeyPress(event)) { + this.activeChild.doCommand(); + event.preventDefault(); + } + } + }, + { mozSystemGroup: true } + ); + + this.attachShadow({ mode: "open" }); + } + + static get inheritedAttributes() { + return { + image: "src=image", + "#label": "value=label,crop,accesskey", + "#highlightable-label": "text=label,crop,accesskey", + dropmarker: "disabled,open", + }; + } + + static get markup() { + // Accessibility information of these nodes will be presented + // on XULComboboxAccessible generated from <menulist>; + // hide these nodes from the accessibility tree. + return ` + <html:link href="chrome://global/skin/menulist.css" rel="stylesheet"/> + <hbox id="label-box" part="label-box" flex="1" role="none"> + <image part="icon" role="none"/> + <label id="label" part="label" crop="end" flex="1" role="none"/> + <label id="highlightable-label" part="label" crop="end" flex="1" role="none"/> + </hbox> + <dropmarker part="dropmarker" type="menu" role="none"/> + <html:slot/> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + if (!this.hasAttribute("popuponly")) { + this.shadowRoot.appendChild(this.constructor.fragment); + this._labelBox = this.shadowRoot.getElementById("label-box"); + this._dropmarker = this.shadowRoot.querySelector("dropmarker"); + this.initializeAttributeInheritance(); + } else { + this.shadowRoot.appendChild(document.createElement("slot")); + } + + this.mSelectedInternal = null; + this.mAttributeObserver = null; + this.setInitialSelection(); + } + + // nsIDOMXULSelectControlElement + set value(val) { + // if the new value is null, we still need to remove the old value + if (val == null) { + this.selectedItem = val; + return; + } + + var arr = null; + var popup = this.menupopup; + if (popup) { + arr = popup.getElementsByAttribute("value", val); + } + + if (arr && arr.item(0)) { + this.selectedItem = arr[0]; + } else { + this.selectedItem = null; + this.setAttribute("value", val); + } + } + + // nsIDOMXULSelectControlElement + get value() { + return this.getAttribute("value"); + } + + // nsIDOMXULMenuListElement + set image(val) { + this.setAttribute("image", val); + } + + // nsIDOMXULMenuListElement + get image() { + return this.getAttribute("image"); + } + + // nsIDOMXULMenuListElement + get label() { + return this.getAttribute("label"); + } + + set description(val) { + this.setAttribute("description", val); + } + + get description() { + return this.getAttribute("description"); + } + + // nsIDOMXULMenuListElement + set open(val) { + this.openMenu(val); + } + + // nsIDOMXULMenuListElement + get open() { + return this.hasAttribute("open"); + } + + // nsIDOMXULSelectControlElement + get itemCount() { + return this.menupopup ? this.menupopup.children.length : 0; + } + + get menupopup() { + var popup = this.firstElementChild; + while (popup && popup.localName != "menupopup") { + popup = popup.nextElementSibling; + } + return popup; + } + + // nsIDOMXULSelectControlElement + set selectedIndex(val) { + var popup = this.menupopup; + if (popup && 0 <= val) { + if (val < popup.children.length) { + this.selectedItem = popup.children[val]; + } + } else { + this.selectedItem = null; + } + } + + // nsIDOMXULSelectControlElement + get selectedIndex() { + // Quick and dirty. We won't deal with hierarchical menulists yet. + if ( + !this.selectedItem || + !this.mSelectedInternal.parentNode || + this.mSelectedInternal.parentNode.parentNode != this + ) { + return -1; + } + + var children = this.mSelectedInternal.parentNode.children; + var i = children.length; + while (i--) { + if (children[i] == this.mSelectedInternal) { + break; + } + } + + return i; + } + + // nsIDOMXULSelectControlElement + set selectedItem(val) { + var oldval = this.mSelectedInternal; + if (oldval == val) { + return; + } + + if (val && !this.contains(val)) { + return; + } + + if (oldval) { + oldval.removeAttribute("selected"); + this.mAttributeObserver.disconnect(); + } + + this.mSelectedInternal = val; + let attributeFilter = ["value", "label", "image", "description"]; + if (val) { + val.setAttribute("selected", "true"); + for (let attr of attributeFilter) { + if (val.hasAttribute(attr)) { + this.setAttribute(attr, val.getAttribute(attr)); + } else { + this.removeAttribute(attr); + } + } + + this.mAttributeObserver = new MutationObserver( + this.handleMutation.bind(this) + ); + this.mAttributeObserver.observe(val, { attributeFilter }); + } else { + for (let attr of attributeFilter) { + this.removeAttribute(attr); + } + } + + var event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + + event = document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + this.dispatchEvent(event); + } + + // nsIDOMXULSelectControlElement + get selectedItem() { + return this.mSelectedInternal; + } + + setInitialSelection() { + var popup = this.menupopup; + if (popup) { + var arr = popup.getElementsByAttribute("selected", "true"); + + var editable = this.editable; + var value = this.value; + if (!arr.item(0) && value) { + arr = popup.getElementsByAttribute( + editable ? "label" : "value", + value + ); + } + + if (arr.item(0)) { + this.selectedItem = arr[0]; + } else if (!editable) { + this.selectedIndex = 0; + } + } + } + + contains(item) { + if (!item) { + return false; + } + + var parent = item.parentNode; + return parent && parent.parentNode == this; + } + + handleMutation(aRecords) { + for (let record of aRecords) { + let t = record.target; + if (t == this.mSelectedInternal) { + let attrName = record.attributeName; + switch (attrName) { + case "value": + case "label": + case "image": + case "description": + if (t.hasAttribute(attrName)) { + this.setAttribute(attrName, t.getAttribute(attrName)); + } else { + this.removeAttribute(attrName); + } + } + } + } + } + + // nsIDOMXULSelectControlElement + getIndexOfItem(item) { + var popup = this.menupopup; + if (popup) { + var children = popup.children; + var i = children.length; + while (i--) { + if (children[i] == item) { + return i; + } + } + } + return -1; + } + + // nsIDOMXULSelectControlElement + getItemAtIndex(index) { + var popup = this.menupopup; + if (popup) { + var children = popup.children; + if (index >= 0 && index < children.length) { + return children[index]; + } + } + return null; + } + + appendItem(label, value, description) { + if (!this.menupopup) { + this.appendChild(MozXULElement.parseXULToFragment(`<menupopup />`)); + } + + var popup = this.menupopup; + popup.appendChild(MozXULElement.parseXULToFragment(`<menuitem />`)); + + var item = popup.lastElementChild; + if (label !== undefined) { + item.setAttribute("label", label); + } + item.setAttribute("value", value); + if (description) { + item.setAttribute("description", description); + } + + return item; + } + + removeAllItems() { + this.selectedItem = null; + var popup = this.menupopup; + if (popup) { + this.removeChild(popup); + } + } + + disconnectedCallback() { + if (this.mAttributeObserver) { + this.mAttributeObserver.disconnect(); + } + + if (this._labelBox) { + this._labelBox.remove(); + this._dropmarker.remove(); + this._labelBox = null; + this._dropmarker = null; + } + } + } + + MenuBaseControl.implementCustomInterface(MozMenuList, [ + Ci.nsIDOMXULMenuListElement, + Ci.nsIDOMXULSelectControlElement, + ]); + + customElements.define("menulist", MozMenuList); +} diff --git a/toolkit/content/widgets/menupopup.js b/toolkit/content/widgets/menupopup.js new file mode 100644 index 0000000000..31801d6a33 --- /dev/null +++ b/toolkit/content/widgets/menupopup.js @@ -0,0 +1,297 @@ +/* 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" + ); + + // For the non-native context menu styling, we need to know if we need + // a gutter for checkboxes. To do this, check whether there are any + // radio/checkbox type menuitems in a menupopup when showing it. We use a + // system bubbling event listener to ensure we run *after* the "normal" + // popupshowing listeners, so (visibility) changes they make to their items + // take effect first, before we check for checkable menuitems. + Services.els.addSystemEventListener( + document, + "popupshowing", + function (e) { + if (e.target.nodeName == "menupopup") { + let haveCheckableChild = e.target.querySelector( + `:scope > menuitem:not([hidden]):is([type=checkbox],[type=radio]${ + // On macOS, selected menuitems are checked regardless of type + AppConstants.platform == "macosx" + ? ",[checked=true],[selected=true]" + : "" + })` + ); + e.target.toggleAttribute("needsgutter", haveCheckableChild); + } + }, + false + ); + + class MozMenuPopup extends MozElements.MozElementMixin(XULPopupElement) { + constructor() { + super(); + + this.AUTOSCROLL_INTERVAL = 25; + this.NOT_DRAGGING = 0; + this.DRAG_OVER_BUTTON = -1; + this.DRAG_OVER_POPUP = 1; + this._draggingState = this.NOT_DRAGGING; + this._scrollTimer = 0; + + this.attachShadow({ mode: "open" }); + + this.addEventListener("popupshowing", event => { + if (event.target != this) { + return; + } + + // Make sure we generated shadow DOM to place menuitems into. + this.ensureInitialized(); + }); + + this.addEventListener("DOMMenuItemActive", this); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + + this.hasConnected = true; + if (this.parentNode?.localName == "menulist") { + this._setUpMenulistPopup(); + } + } + + initShadowDOM() { + // Retarget events from shadow DOM arrowscrollbox to the host. + this.scrollBox.addEventListener("scroll", ev => + this.dispatchEvent(new Event("scroll")) + ); + this.scrollBox.addEventListener("overflow", ev => + this.dispatchEvent(new Event("overflow")) + ); + this.scrollBox.addEventListener("underflow", ev => + this.dispatchEvent(new Event("underflow")) + ); + } + + ensureInitialized() { + this.shadowRoot; + } + + get shadowRoot() { + if (!super.shadowRoot.firstChild) { + // We generate shadow DOM lazily on popupshowing event to avoid extra + // load on the system during browser startup. + super.shadowRoot.appendChild(this.fragment); + this.initShadowDOM(); + } + return super.shadowRoot; + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment( + this.markup + ); + } + return document.importNode(this.constructor._fragment, true); + } + + get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/global.css"/> + <html:style>${this.styles}</html:style> + <arrowscrollbox class="menupopup-arrowscrollbox" + part="arrowscrollbox content" + exportparts="scrollbox: arrowscrollbox-scrollbox" + flex="1" + orient="vertical" + smoothscroll="false"> + <html:slot></html:slot> + </arrowscrollbox> + `; + } + + get styles() { + return ` + :host(.in-menulist) arrowscrollbox::part(scrollbutton-up), + :host(.in-menulist) arrowscrollbox::part(scrollbutton-down) { + display: none; + } + :host(.in-menulist) arrowscrollbox::part(scrollbox) { + overflow: auto; + margin: 0; + } + :host(.in-menulist) arrowscrollbox::part(scrollbox-clip) { + overflow: visible; + } + `; + } + + get scrollBox() { + if (!this._scrollBox) { + this._scrollBox = this.shadowRoot.querySelector("arrowscrollbox"); + } + return this._scrollBox; + } + + /** + * Adds event listeners for a MozMenuPopup inside a menulist element. + */ + _setUpMenulistPopup() { + // Access shadow root to generate menupoup shadow DOMs. We do generate + // shadow DOM on popupshowing, but it doesn't work for HTML:selects, + // which are implemented via menulist elements living in the main process. + // So make them a special case then. + this.ensureInitialized(); + this.classList.add("in-menulist"); + + this.addEventListener("popupshown", () => { + // Enable drag scrolling even when the mouse wasn't used. The + // mousemove handler will remove it if the mouse isn't down. + this._enableDragScrolling(false); + }); + + this.addEventListener("popuphidden", () => { + this._draggingState = this.NOT_DRAGGING; + this._clearScrollTimer(); + this.releaseCapture(); + this.scrollBox.scrollbox.scrollTop = 0; + }); + + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + + if ( + this.state == "open" && + (event.target.localName == "menuitem" || + event.target.localName == "menu" || + event.target.localName == "menucaption") + ) { + this._enableDragScrolling(true); + } + }); + + this.addEventListener("mouseup", event => { + if (event.button != 0) { + return; + } + + this._draggingState = this.NOT_DRAGGING; + this._clearScrollTimer(); + }); + + this.addEventListener("mousemove", event => { + if (!this._draggingState) { + return; + } + + this._clearScrollTimer(); + + // If the user released the mouse before the menupopup opens, we will + // still be capturing, so check that the button is still pressed. If + // not, release the capture and do nothing else. This also handles if + // the dropdown was opened via the keyboard. + if (!(event.buttons & 1)) { + this._draggingState = this.NOT_DRAGGING; + this.releaseCapture(); + return; + } + + // If dragging outside the top or bottom edge of the menupopup, but + // within the menupopup area horizontally, scroll the list in that + // direction. The _draggingState flag is used to ensure that scrolling + // does not start until the mouse has moved over the menupopup first, + // preventing scrolling while over the dropdown button. + let popupRect = this.getOuterScreenRect(); + if ( + event.screenX >= popupRect.left && + event.screenX <= popupRect.right + ) { + if (this._draggingState == this.DRAG_OVER_BUTTON) { + if ( + event.screenY > popupRect.top && + event.screenY < popupRect.bottom + ) { + this._draggingState = this.DRAG_OVER_POPUP; + } + } + + if ( + this._draggingState == this.DRAG_OVER_POPUP && + (event.screenY <= popupRect.top || + event.screenY >= popupRect.bottom) + ) { + let scrollAmount = event.screenY <= popupRect.top ? -1 : 1; + this.scrollBox.scrollByIndex(scrollAmount, true); + + let win = this.ownerGlobal; + this._scrollTimer = win.setInterval(() => { + this.scrollBox.scrollByIndex(scrollAmount, true); + }, this.AUTOSCROLL_INTERVAL); + } + } + }); + + this._menulistPopupIsSetUp = true; + } + + _enableDragScrolling(overItem) { + if (!this._draggingState) { + this.setCaptureAlways(); + this._draggingState = overItem + ? this.DRAG_OVER_POPUP + : this.DRAG_OVER_BUTTON; + } + } + + _clearScrollTimer() { + if (this._scrollTimer) { + this.ownerGlobal.clearInterval(this._scrollTimer); + this._scrollTimer = 0; + } + } + + on_DOMMenuItemActive(event) { + // Scroll buttons may overlap the active item. In that case, scroll + // further to stay clear of the buttons. + if ( + this.parentNode?.localName == "menulist" || + !this.scrollBox.hasAttribute("overflowing") + ) { + return; + } + let item = event.target; + if (item.parentNode != this) { + return; + } + let itemRect = item.getBoundingClientRect(); + let buttonRect = this.scrollBox._scrollButtonUp.getBoundingClientRect(); + if (buttonRect.bottom > itemRect.top) { + this.scrollBox.scrollByPixels(itemRect.top - buttonRect.bottom, true); + } else { + buttonRect = this.scrollBox._scrollButtonDown.getBoundingClientRect(); + if (buttonRect.top < itemRect.bottom) { + this.scrollBox.scrollByPixels(itemRect.bottom - buttonRect.top, true); + } + } + } + } + + customElements.define("menupopup", MozMenuPopup); + + MozElements.MozMenuPopup = MozMenuPopup; +} diff --git a/toolkit/content/widgets/message-bar.css b/toolkit/content/widgets/message-bar.css new file mode 100644 index 0000000000..eddc5a3ae6 --- /dev/null +++ b/toolkit/content/widgets/message-bar.css @@ -0,0 +1,219 @@ +/* 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/. */ + +:host { + --info-icon-url: url("chrome://global/skin/icons/info-filled.svg"); + --warn-icon-url: url("chrome://global/skin/icons/warning.svg"); + --success-icon-url: url("chrome://global/skin/icons/check.svg"); + --error-icon-url: url("chrome://global/skin/icons/error.svg"); + --close-icon-url: url("chrome://global/skin/icons/close-12.svg"); + --close-fill-color: var(--in-content-icon-color); + --icon-size: 16px; + --close-icon-size: 28px; +} + +:host { + --message-bar-background-color: var(--in-content-box-info-background); + --message-bar-text-color: var(--in-content-text-color); + --message-bar-icon-url: var(--info-icon-url); + /* The default values of --in-content-button* are sufficient, even for dark themes */ +} + +:host([type=warning]) { + --message-bar-icon-url: var(--warn-icon-url); +} + +:host([type=success]) { + --message-bar-icon-url: var(--success-icon-url); +} + +:host([type=error]), +:host([type=critical]) { + --message-bar-icon-url: var(--error-icon-url); +} + +:host { + border: 1px solid transparent; + border-radius: 4px; +} + +/* Make the host to behave as a block by default, but allow hidden to hide it. */ +:host(:not([hidden])) { + display: block; +} + +::slotted(button) { + /* Enforce micro-button width. */ + min-width: -moz-fit-content !important; +} + +/* MessageBar Grid Layout */ + +.container { + background: var(--message-bar-background-color); + color: var(--message-bar-text-color); + + padding: 3px 7px; + position: relative; + + border-radius: 4px; + + display: flex; + /* Ensure that the message bar shadow dom elements are vertically aligned. */ + align-items: center; +} + +:host([align="center"]) .container { + justify-content: center; +} + +.content { + margin: 0 4px; + display: inline-block; + /* Ensure that the message bar content is vertically aligned. */ + align-items: center; + /* Ensure that the message bar content is wrapped. */ + word-break: break-word; +} + +/* MessageBar icon style */ + +.icon { + padding: 4px; + width: var(--icon-size); + height: var(--icon-size); + flex-shrink: 0; +} + +.icon::after { + display: inline-block; + appearance: none; + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + content: ""; + background-image: var(--message-bar-icon-url); + background-size: var(--icon-size); + width: var(--icon-size); + height: var(--icon-size); +} + +/* Use a spacer to position the close button at the end, but also support + * centering if required. */ +.spacer { + flex-grow: 1; +} + +/* Close icon styles */ + +:host(:not([dismissable])) .close { + display: none; +} + +.close { + background-image: var(--close-icon-url); + background-repeat: no-repeat; + background-position: center center; + -moz-context-properties: fill; + fill: currentColor; + min-width: auto; + min-height: auto; + width: var(--close-icon-size); + height: var(--close-icon-size); + padding: 0; + flex-shrink: 0; + margin: 4px 8px; + background-size: 12px; +} + +@media (prefers-contrast) { + :host { + border-color: CanvasText; + } +} + +@media not (prefers-contrast) { + /* MessageBar colors by message type */ + /* Colors from: https://design.firefox.com/photon/components/message-bars.html#type-specific-style */ + + :host([type=warning]) { + /* Ensure colors within the bar are adjusted and controls are readable */ + color-scheme: light; + + --message-bar-background-color: var(--yellow-50); + --message-bar-text-color: var(--yellow-90); + + --in-content-button-background: var(--yellow-60); + --in-content-button-background-hover: var(--yellow-70); + --in-content-button-background-active: var(--yellow-80); + + --close-fill-color: var(--message-bar-text-color); + } + + :host([type=success]) { + /* Ensure colors within the bar are adjusted and controls are readable */ + color-scheme: light; + + --message-bar-background-color: var(--green-50); + --message-bar-text-color: var(--green-90); + + --in-content-button-background: var(--green-60); + --in-content-button-background-hover: var(--green-70); + --in-content-button-background-active: var(--green-80); + } + + :host([type=error]) { + --message-bar-background-color: var(--red-60); + --message-bar-text-color: #ffffff; + + --in-content-button-background: var(--red-70); + --in-content-button-background-hover: var(--red-80); + --in-content-button-background-active: var(--red-90); + } + + :host([type=info]) .icon { + color: rgb(0,144,237); + } + + :host([type=warning]) .icon { + color: rgb(255,164,54); + } + + :host([type=critical]) .icon { + color: rgb(226,40,80); + } + + .close { + fill: var(--close-fill-color); + } + + @media (prefers-color-scheme: dark) { + /* Don't set the background in prefers-contrast mode or macOS can end up + * with black on black text. */ + :host([type=info]) .icon { + color: rgb(128,235,255); + } + + :host([type=warning]) .icon { + color: rgb(255,189,79); + } + + :host([type=critical]) .icon { + color: rgb(255,154,162); + } + } +} + +strong { + font-weight: 600; +} + +.text-link:hover { + cursor: pointer; +} + +@keyframes spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} diff --git a/toolkit/content/widgets/message-bar.js b/toolkit/content/widgets/message-bar.js new file mode 100644 index 0000000000..d38347b40a --- /dev/null +++ b/toolkit/content/widgets/message-bar.js @@ -0,0 +1,91 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + class MessageBarElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + window.MozXULElement?.insertFTLIfNeeded( + "toolkit/global/notification.ftl" + ); + document.l10n.connectRoot(this.shadowRoot); + const content = this.constructor.template.content.cloneNode(true); + shadowRoot.append(content); + this.closeButton.addEventListener("click", () => this.dismiss(), { + once: true, + }); + } + + disconnectedCallback() { + this.dispatchEvent(new CustomEvent("message-bar:close")); + } + + get closeButton() { + return this.shadowRoot.querySelector("button.close"); + } + + static get template() { + const template = document.createElement("template"); + + const commonStyles = document.createElement("link"); + commonStyles.rel = "stylesheet"; + commonStyles.href = "chrome://global/skin/in-content/common.css"; + const messageBarStyles = document.createElement("link"); + messageBarStyles.rel = "stylesheet"; + messageBarStyles.href = + "chrome://global/content/elements/message-bar.css"; + template.content.append(commonStyles, messageBarStyles); + + // A container for the entire message bar content, + // most of the css rules needed to provide the + // expected message bar layout is applied on this + // element. + const container = document.createElement("div"); + container.part = "container"; + container.classList.add("container"); + template.content.append(container); + + const icon = document.createElement("span"); + icon.classList.add("icon"); + icon.part = "icon"; + container.append(icon); + + const barcontent = document.createElement("span"); + barcontent.classList.add("content"); + barcontent.append(document.createElement("slot")); + container.append(barcontent); + + const spacer = document.createElement("span"); + spacer.classList.add("spacer"); + container.append(spacer); + + const closeIcon = document.createElement("button"); + closeIcon.classList.add("close", "ghost-button"); + document.l10n.setAttributes(closeIcon, "notification-close-button"); + container.append(closeIcon); + + Object.defineProperty(this, "template", { + value: template, + }); + + return template; + } + + dismiss() { + this.dispatchEvent(new CustomEvent("message-bar:user-dismissed")); + this.close(); + } + + close() { + this.remove(); + } + } + + customElements.define("message-bar", MessageBarElement); +} diff --git a/toolkit/content/widgets/moz-button-group/moz-button-group.css b/toolkit/content/widgets/moz-button-group/moz-button-group.css new file mode 100644 index 0000000000..ba79d69e12 --- /dev/null +++ b/toolkit/content/widgets/moz-button-group/moz-button-group.css @@ -0,0 +1,16 @@ +/* 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/. */ + +:host { + display: flex; + justify-content: flex-end; +} + +::slotted(button) { + margin: 0 !important; +} + +::slotted(button:not(:first-child, .popup-notification-dropmarker)) { + margin-inline-start: var(--space-small) !important; +} diff --git a/toolkit/content/widgets/moz-button-group/moz-button-group.mjs b/toolkit/content/widgets/moz-button-group/moz-button-group.mjs new file mode 100644 index 0000000000..0706f94762 --- /dev/null +++ b/toolkit/content/widgets/moz-button-group/moz-button-group.mjs @@ -0,0 +1,105 @@ +/* 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/. */ + +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export const PLATFORM_LINUX = "linux"; +export const PLATFORM_MACOS = "macosx"; +export const PLATFORM_WINDOWS = "win"; + +/** + * A grouping of buttons. Primary button order will be set automatically based + * on class="primary", type="submit" or autofocus attribute. Set slot="primary" + * on a primary button that does not have primary styling to set its position. + * + * @tagname moz-button-group + * @property {string} platform - The detected platform, set automatically. + */ +export default class MozButtonGroup extends MozLitElement { + static queries = { + defaultSlotEl: "slot:not([name])", + primarySlotEl: "slot[name=primary]", + }; + + static properties = { + platform: { state: true }, + }; + + constructor() { + super(); + this.#detectPlatform(); + } + + #detectPlatform() { + if (typeof AppConstants !== "undefined") { + this.platform = AppConstants.platform; + } else if (navigator.platform.includes("Linux")) { + this.platform = PLATFORM_LINUX; + } else if (navigator.platform.includes("Mac")) { + this.platform = PLATFORM_MACOS; + } else { + this.platform = PLATFORM_WINDOWS; + } + } + + onSlotchange(e) { + for (let child of this.defaultSlotEl.assignedNodes()) { + if (!(child instanceof Element)) { + // Text nodes won't support classList or getAttribute. + continue; + } + // Bug 1791816: These should check moz-button instead of button. + if ( + child.localName == "button" && + (child.classList.contains("primary") || + child.getAttribute("type") == "submit" || + child.hasAttribute("autofocus") || + child.hasAttribute("default")) + ) { + child.slot = "primary"; + } + } + this.#reorderLightDom(); + } + + #reorderLightDom() { + let primarySlottedChildren = [...this.primarySlotEl.assignedNodes()]; + if (this.platform == PLATFORM_WINDOWS) { + primarySlottedChildren.reverse(); + for (let child of primarySlottedChildren) { + child.parentElement.prepend(child); + } + } else { + for (let child of primarySlottedChildren) { + // Ensure the primary buttons are at the end of the light DOM. + child.parentElement.append(child); + } + } + } + + updated(changedProperties) { + if (changedProperties.has("platform")) { + this.#reorderLightDom(); + } + } + + render() { + let slots = [ + html` <slot @slotchange=${this.onSlotchange}></slot> `, + html` <slot name="primary"></slot> `, + ]; + if (this.platform == PLATFORM_WINDOWS) { + slots = [slots[1], slots[0]]; + } + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-button-group.css" + /> + ${slots} + `; + } +} +customElements.define("moz-button-group", MozButtonGroup); diff --git a/toolkit/content/widgets/moz-button-group/moz-button-group.stories.mjs b/toolkit/content/widgets/moz-button-group/moz-button-group.stories.mjs new file mode 100644 index 0000000000..444cc5373c --- /dev/null +++ b/toolkit/content/widgets/moz-button-group/moz-button-group.stories.mjs @@ -0,0 +1,57 @@ +/* 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/. */ + +import { html } from "../vendor/lit.all.mjs"; +import { + PLATFORM_LINUX, + PLATFORM_MACOS, + PLATFORM_WINDOWS, +} from "./moz-button-group.mjs"; + +export default { + title: "UI Widgets/Button Group", + component: "moz-button-group", + argTypes: { + platform: { + options: [PLATFORM_LINUX, PLATFORM_MACOS, PLATFORM_WINDOWS], + control: { type: "select" }, + }, + }, + parameters: { + status: "stable", + fluent: ` +moz-button-group-p = The button group is below. Card for emphasis. +moz-button-group-ok = OK +moz-button-group-cancel = Cancel + `, + }, +}; + +const Template = ({ platform }) => html` + <div class="card card-no-hover" style="max-width: 400px"> + <p data-l10n-id="moz-button-group-p"></p> + <moz-button-group .platform=${platform}> + <button class="primary" data-l10n-id="moz-button-group-ok"></button> + <button data-l10n-id="moz-button-group-cancel"></button> + </moz-button-group> + </div> +`; + +export const Default = Template.bind({}); +Default.args = { + // Platform will auto-detected. +}; + +export const Windows = Template.bind({}); +Windows.args = { + platform: PLATFORM_WINDOWS, +}; +export const Mac = Template.bind({}); +Mac.args = { + platform: PLATFORM_MACOS, +}; +export const Linux = Template.bind({}); +Linux.args = { + platform: PLATFORM_LINUX, +}; diff --git a/toolkit/content/widgets/moz-card/moz-card.css b/toolkit/content/widgets/moz-card/moz-card.css new file mode 100644 index 0000000000..52c0ac0980 --- /dev/null +++ b/toolkit/content/widgets/moz-card/moz-card.css @@ -0,0 +1,180 @@ +/* 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/. */ + + :host { + --card-border-color: color-mix(in srgb, currentColor 10%, transparent); + --card-border-radius: var(--border-radius-medium); + --card-border-width: var(--border-width); + --card-border: var(--card-border-width) solid var(--card-border-color); + --card-background-color: var(--box-background-color); + --card-focus-outline: var(--focus-outline); + --card-box-shadow: var(--box-shadow-10); + /* Bug 1791816, 1839523: replace with spacing tokens */ + --card-padding: 1em; + --card-gap: var(--card-padding); + --card-article-gap: 0.45em; + + /* Bug 1791816: replace with button tokens */ + @media (prefers-contrast) { + --button-border-color: var(--border-interactive-color); + --button-border-color-hover: var(--border-interactive-color-hover); + --button-border-color-active: var(--border-interactive-color-active); + --card-border-color: color-mix(in srgb, currentColor 41%, transparent); + } + /* Bug 1791816: replace with button tokens */ + @media (forced-colors) { + --button-background-color: ButtonFace; + --button-background-color-hover: SelectedItemText; + --button-background-color-active: SelectedItemText; + --button-border-color: var(--border-interactive-color); + --button-border-color-hover: var(--border-interactive-color-hover); + --button-border-color-active: var(--border-interactive-color-active); + --button-text-color: ButtonText; + --button-text-color-hover: SelectedItem; + --button-text-color-active: SelectedItem; + } +} + +:host { + display: block; + border: var(--card-border); + border-radius: var(--card-border-radius); + background-color: var(--card-background-color); + box-shadow: var(--card-box-shadow); + box-sizing: border-box; +} + +:host([type=accordion]) { + summary { + padding-block: var(--card-padding); + } + #content { + padding-block-end: var(--card-padding); + } +} +:host(:not([type=accordion])) { + .moz-card { + padding-block: var(--card-padding); + } +} + +.moz-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--card-article-gap); +} + +#moz-card-details { + width: 100%; +} + +summary { + cursor: pointer; +} + +#heading-wrapper { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--card-gap); + padding-inline: var(--card-padding); + border-radius: var(--card-border-radius); +} + +#heading { + font-size: var(--font-size-root); + font-weight: var(--font-weight-bold); +} + +#content { + align-self: stretch; + padding-inline: var(--card-padding); + border-end-start-radius: var(--card-border-radius); + border-end-end-radius: var(--card-border-radius); + + @media (prefers-contrast) { + :host([type=accordion]) & { + border-block-start: 0; + padding-block-start: var(--card-padding); + } + } +} + +details { + > summary { + list-style: none; + border-radius: var(--card-border-radius); + cursor: pointer; + + &:hover { + background-color: var(--button-background-color-hover); + } + @media (prefers-contrast) { + outline: var(--button-border-color) solid var(--border-width); + + &:hover { + outline-color: var(--button-border-color-hover); + } + + &:active { + outline-color: var(--button-border-color-active); + } + } + + @media (forced-colors) { + color: var(--button-text-color); + background-color: var(--button-background-color); + + &:hover { + background-color: var(--button-background-color-hover); + color: var(--button-text-color-hover); + } + + &:active { + background-color: var(--button-background-color-active); + color: var(--button-text-color-active); + } + } + } + + &[open] { + summary { + border-end-start-radius: 0; + border-end-end-radius: 0; + } + @media not (prefers-contrast) { + #content { + /* + There is a border shown above this element in prefers-contrast. + When there isn't a border, there's no need for the extra space. + */ + padding-block-start: 0; + } + } + } + + &:focus-visible { + outline: var(--card-focus-outline); + } +} + +.chevron-icon { + background-image: url("chrome://global/skin/icons/arrow-down.svg"); + background-position: center; + background-repeat: no-repeat; + -moz-context-properties: fill; + fill: currentColor; + width: 24px; + height: 24px; + min-width: 24px; + min-height: 24px; + padding: 0; + flex-shrink: 0; + align-self: flex-start; + + details[open] & { + background-image: url("chrome://global/skin/icons/arrow-up.svg"); + } +} diff --git a/toolkit/content/widgets/moz-card/moz-card.mjs b/toolkit/content/widgets/moz-card/moz-card.mjs new file mode 100644 index 0000000000..2bd6e0f987 --- /dev/null +++ b/toolkit/content/widgets/moz-card/moz-card.mjs @@ -0,0 +1,112 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +/** + * Cards contain content and actions about a single subject. + * There are two card types: + * The default type where no type attribute is required and the card + * will have no extra functionality. + * + * The "accordion" type will initially not show any content. The card + * will contain an arrow to expand the card so that all of the content + * is visible. + * + * + * @property {string} heading - The heading text that will be used for the card. + * @property {string} type - (optional) The type of card. No type specified + * will be the default card. The other available type is "accordion" + * @slot content - The content to show inside of the card. + */ +export default class MozCard extends MozLitElement { + static queries = { + detailsEl: "#moz-card-details", + headingEl: "#heading", + contentSlotEl: "#content", + }; + + static properties = { + heading: { type: String }, + type: { type: String, reflect: true }, + expanded: { type: Boolean }, + }; + + constructor() { + super(); + this.expanded = false; + this.type = "default"; + } + + headingTemplate() { + if (!this.heading) { + return ""; + } + return html` + <div id="heading-wrapper"> + ${this.type == "accordion" + ? html` <div class="chevron-icon"></div>` + : ""} + <span id="heading">${this.heading}</span> + </div> + `; + } + + cardTemplate() { + if (this.type === "accordion") { + return html` + <details id="moz-card-details"> + <summary>${this.headingTemplate()}</summary> + <div id="content"><slot></slot></div> + </details> + `; + } + + return html` + ${this.headingTemplate()} + <div id="content" aria-describedby="content"> + <slot></slot> + </div> + `; + } + /** + * Handles the click event on the chevron icon. + * + * Without this, the click event would be passed to + * toggleDetails which would force the details element + * to stay open. + * + * @memberof MozCard + */ + onDetailsClick() { + this.toggleDetails(); + } + + /** + * @param {boolean} force - Used to force open or force close the + * details element. + * @memberof MozCard + */ + toggleDetails(force) { + this.detailsEl.open = force ?? !this.detailsEl.open; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-card.css" + /> + <article + class="moz-card" + aria-labelledby=${ifDefined(this.heading ? "heading" : undefined)} + > + ${this.cardTemplate()} + </article> + `; + } +} +customElements.define("moz-card", MozCard); diff --git a/toolkit/content/widgets/moz-card/moz-card.stories.mjs b/toolkit/content/widgets/moz-card/moz-card.stories.mjs new file mode 100644 index 0000000000..da3279b2a4 --- /dev/null +++ b/toolkit/content/widgets/moz-card/moz-card.stories.mjs @@ -0,0 +1,88 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unresolved +import { html, ifDefined } from "lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./moz-card.mjs"; + +export default { + title: "UI Widgets/Card", + component: "moz-card", + parameters: { + status: "in-development", + fluent: ` +moz-card-heading = + .heading = This is the label + `, + }, + argTypes: { + type: { + options: ["default", "accordion"], + control: { type: "select" }, + }, + }, +}; + +const Template = ({ l10nId, content, type }) => html` + <main style="max-width: 400px"> + <moz-card + type=${ifDefined(type)} + data-l10n-id=${ifDefined(l10nId)} + data-l10n-attrs="heading" + > + <div>${content}</div> + </moz-card> + </main> +`; + +export const DefaultCard = Template.bind({}); +DefaultCard.args = { + l10nId: "moz-card-heading", + content: "This is the content", +}; + +export const CardOnlyContent = Template.bind({}); +CardOnlyContent.args = { + content: "This card only contains content", +}; + +export const CardTypeAccordion = Template.bind({}); +CardTypeAccordion.args = { + ...DefaultCard.args, + content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Nunc velit turpis, mollis a ultricies vitae, accumsan ut augue. + In a eros ac dolor hendrerit varius et at mauris.`, + type: "accordion", +}; +CardTypeAccordion.parameters = { + a11y: { + config: { + rules: [ + /* + The accordion card can be expanded either by the chevron icon + button or by activating the details element. Mouse users can + click on the chevron button or the details element, while + keyboard users can tab to the details element and have a + focus ring around the details element in the card. + Additionally, the details element is announced as a button + so I don't believe we are providing a degraded experience + to non-mouse users. + + Bug 1854008: We should probably make the accordion button a + clickable div or something that isn't announced to screen + readers. + */ + { + id: "button-name", + reviewOnFail: true, + }, + { + id: "nested-interactive", + reviewOnFail: true, + }, + ], + }, + }, +}; diff --git a/toolkit/content/widgets/moz-five-star/moz-five-star.css b/toolkit/content/widgets/moz-five-star/moz-five-star.css new file mode 100644 index 0000000000..44d2a3e252 --- /dev/null +++ b/toolkit/content/widgets/moz-five-star/moz-five-star.css @@ -0,0 +1,46 @@ +/* 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/. */ + +:host { + display: flex; + justify-content: space-between; +} + +:host([hidden]) { + display: none; +} + +.stars { + --rating-star-size: 1em; + --rating-star-spacing: 0.3ch; + + display: inline-grid; + grid-template-columns: repeat(5, var(--rating-star-size)); + grid-column-gap: var(--rating-star-spacing); + align-content: center; +} + +.rating-star { + display: inline-block; + width: var(--rating-star-size); + height: var(--rating-star-size); + background-image: url("chrome://global/skin/icons/rating-star.svg#empty"); + background-position: center; + background-repeat: no-repeat; + background-size: 100%; + + fill: var(--icon-color); + -moz-context-properties: fill; +} + +.rating-star[fill="half"] { + background-image: url("chrome://global/skin/icons/rating-star.svg#half"); +} +.rating-star[fill="full"] { + background-image: url("chrome://global/skin/icons/rating-star.svg#full"); +} + +.rating-star[fill="half"]:dir(rtl) { + transform: scaleX(-1); +} diff --git a/toolkit/content/widgets/moz-five-star/moz-five-star.mjs b/toolkit/content/widgets/moz-five-star/moz-five-star.mjs new file mode 100644 index 0000000000..e95c74e0ed --- /dev/null +++ b/toolkit/content/widgets/moz-five-star/moz-five-star.mjs @@ -0,0 +1,71 @@ +/* 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/. */ + +import { ifDefined, html } from "../vendor/lit.all.mjs"; +import { MozLitElement } from "../lit-utils.mjs"; + +window.MozXULElement?.insertFTLIfNeeded("toolkit/global/mozFiveStar.ftl"); + +/** + * The visual representation is five stars, each of them either empty, + * half-filled or full. The fill state is derived from the rating, + * rounded to the nearest half. + * + * @tagname moz-five-star + * @property {number} rating - The rating out of 5. + * @property {string} title - The title text. + */ +export default class MozFiveStar extends MozLitElement { + static properties = { + rating: { type: Number, reflect: true }, + title: { type: String }, + }; + + static get queries() { + return { + starEls: { all: ".rating-star" }, + starsWrapperEl: ".stars", + }; + } + + getStarsFill() { + let starFill = []; + let roundedRating = Math.round(this.rating * 2) / 2; + for (let i = 1; i <= 5; i++) { + if (i <= roundedRating) { + starFill.push("full"); + } else if (i - roundedRating === 0.5) { + starFill.push("half"); + } else { + starFill.push("empty"); + } + } + return starFill; + } + + render() { + let starFill = this.getStarsFill(); + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-five-star.css" + /> + <div + class="stars" + role="img" + data-l10n-id=${ifDefined( + this.title ? undefined : "moz-five-star-rating" + )} + data-l10n-args=${ifDefined( + this.title ? undefined : JSON.stringify({ rating: this.rating ?? 0 }) + )} + > + ${starFill.map( + fill => html`<span class="rating-star" fill="${fill}"></span>` + )} + </div> + `; + } +} +customElements.define("moz-five-star", MozFiveStar); diff --git a/toolkit/content/widgets/moz-five-star/moz-five-star.stories.mjs b/toolkit/content/widgets/moz-five-star/moz-five-star.stories.mjs new file mode 100644 index 0000000000..cbbbe9da26 --- /dev/null +++ b/toolkit/content/widgets/moz-five-star/moz-five-star.stories.mjs @@ -0,0 +1,51 @@ +/* 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/. */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./moz-five-star.mjs"; + +export default { + title: "UI Widgets/Five Star", + component: "moz-five-star", + parameters: { + status: "in-development", + fluent: ` +moz-five-star-title = + .title = This is the title +moz-five-star-aria-label = + .aria-label = This is the aria-label + `, + }, +}; + +const Template = ({ rating, ariaLabel, l10nId }) => html` + <div style="max-width: 400px"> + <moz-five-star + rating=${rating} + aria-label=${ifDefined(ariaLabel)} + data-l10n-id=${ifDefined(l10nId)} + data-l10n-attrs="aria-label, title" + > + </moz-five-star> + </div> +`; + +export const FiveStar = Template.bind({}); +FiveStar.args = { + rating: 5.0, + l10nId: "moz-five-star-aria-label", +}; + +export const WithTitle = Template.bind({}); +WithTitle.args = { + ...FiveStar.args, + rating: 0, + l10nId: "moz-five-star-title", +}; + +export const Default = Template.bind({}); +Default.args = { + rating: 3.33, +}; diff --git a/toolkit/content/widgets/moz-input-box.js b/toolkit/content/widgets/moz-input-box.js new file mode 100644 index 0000000000..4704db6dc5 --- /dev/null +++ b/toolkit/content/widgets/moz-input-box.js @@ -0,0 +1,224 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const cachedFragments = { + get editMenuItems() { + return ` + <menuitem data-l10n-id="text-action-undo" cmd="cmd_undo"></menuitem> + <menuitem data-l10n-id="text-action-redo" cmd="cmd_redo"></menuitem> + <menuseparator></menuseparator> + <menuitem data-l10n-id="text-action-cut" cmd="cmd_cut"></menuitem> + <menuitem data-l10n-id="text-action-copy" cmd="cmd_copy"></menuitem> + <menuitem data-l10n-id="text-action-paste" cmd="cmd_paste"></menuitem> + <menuitem data-l10n-id="text-action-delete" cmd="cmd_delete"></menuitem> + <menuitem data-l10n-id="text-action-select-all" cmd="cmd_selectAll"></menuitem> + `; + }, + get normal() { + delete this.normal; + this.normal = MozXULElement.parseXULToFragment( + ` + <menupopup class="textbox-contextmenu" showservicesmenu="true"> + ${this.editMenuItems} + </menupopup> + ` + ); + MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + return this.normal; + }, + get spellcheck() { + delete this.spellcheck; + this.spellcheck = MozXULElement.parseXULToFragment( + ` + <menupopup class="textbox-contextmenu" showservicesmenu="true"> + <menuitem data-l10n-id="text-action-spell-no-suggestions" anonid="spell-no-suggestions" disabled="true"></menuitem> + <menuitem data-l10n-id="text-action-spell-add-to-dictionary" anonid="spell-add-to-dictionary" oncommand="this.parentNode.parentNode.spellCheckerUI.addToDictionary();"></menuitem> + <menuitem data-l10n-id="text-action-spell-undo-add-to-dictionary" anonid="spell-undo-add-to-dictionary" oncommand="this.parentNode.parentNode.spellCheckerUI.undoAddToDictionary();"></menuitem> + <menuseparator anonid="spell-suggestions-separator"></menuseparator> + ${this.editMenuItems} + <menuseparator anonid="spell-check-separator"></menuseparator> + <menuitem data-l10n-id="text-action-spell-check-toggle" type="checkbox" anonid="spell-check-enabled" oncommand="this.parentNode.parentNode.spellCheckerUI.toggleEnabled();"></menuitem> + <menu data-l10n-id="text-action-spell-dictionaries" anonid="spell-dictionaries"> + <menupopup anonid="spell-dictionaries-menu" onpopupshowing="event.stopPropagation();" onpopuphiding="event.stopPropagation();"></menupopup> + </menu> + </menupopup> + ` + ); + return this.spellcheck; + }, + }; + + class MozInputBox extends MozXULElement { + static get observedAttributes() { + return ["spellcheck"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === "spellcheck" && oldValue != newValue) { + this._initUI(); + } + } + + connectedCallback() { + this._initUI(); + } + + _initUI() { + this.spellcheck = this.hasAttribute("spellcheck"); + if (this.menupopup) { + this.menupopup.remove(); + } + + this.setAttribute("context", "_child"); + this.appendChild( + this.spellcheck + ? cachedFragments.spellcheck.cloneNode(true) + : cachedFragments.normal.cloneNode(true) + ); + this.menupopup = this.querySelector(".textbox-contextmenu"); + + this.menupopup.addEventListener("popupshowing", event => { + let input = this._input; + if (document.commandDispatcher.focusedElement != input) { + input.focus(); + } + this._doPopupItemEnabling(event); + }); + + if (this.spellcheck) { + this.menupopup.addEventListener("popuphiding", event => { + if (this.spellCheckerUI) { + this.spellCheckerUI.clearSuggestionsFromMenu(); + this.spellCheckerUI.clearDictionaryListFromMenu(); + } + }); + } + + this.menupopup.addEventListener("command", event => { + var cmd = event.originalTarget.getAttribute("cmd"); + if (cmd) { + this.doCommand(cmd); + event.stopPropagation(); + } + }); + } + + _doPopupItemEnablingSpell(event) { + var spellui = this.spellCheckerUI; + if (!spellui || !spellui.canSpellCheck) { + this._setMenuItemVisibility("spell-no-suggestions", false); + this._setMenuItemVisibility("spell-check-enabled", false); + this._setMenuItemVisibility("spell-check-separator", false); + this._setMenuItemVisibility("spell-add-to-dictionary", false); + this._setMenuItemVisibility("spell-undo-add-to-dictionary", false); + this._setMenuItemVisibility("spell-suggestions-separator", false); + this._setMenuItemVisibility("spell-dictionaries", false); + return; + } + + spellui.initFromEvent(event.rangeParent, event.rangeOffset); + + var enabled = spellui.enabled; + var showUndo = spellui.canSpellCheck && spellui.canUndo(); + + var enabledCheckbox = this.getMenuItem("spell-check-enabled"); + enabledCheckbox.setAttribute("checked", enabled); + + var overMisspelling = spellui.overMisspelling; + this._setMenuItemVisibility("spell-add-to-dictionary", overMisspelling); + this._setMenuItemVisibility("spell-undo-add-to-dictionary", showUndo); + this._setMenuItemVisibility( + "spell-suggestions-separator", + overMisspelling || showUndo + ); + + // suggestion list + var suggestionsSeparator = this.getMenuItem("spell-no-suggestions"); + var numsug = spellui.addSuggestionsToMenuOnParent( + event.target, + suggestionsSeparator, + 5 + ); + this._setMenuItemVisibility( + "spell-no-suggestions", + overMisspelling && numsug == 0 + ); + + // dictionary list + var dictionariesMenu = this.getMenuItem("spell-dictionaries-menu"); + var numdicts = spellui.addDictionaryListToMenu(dictionariesMenu, null); + this._setMenuItemVisibility( + "spell-dictionaries", + enabled && numdicts > 1 + ); + } + + _doPopupItemEnabling(event) { + if (this.spellcheck) { + this._doPopupItemEnablingSpell(event); + } + + let popupNode = event.target; + var children = popupNode.childNodes; + for (var i = 0; i < children.length; i++) { + var command = children[i].getAttribute("cmd"); + if (command) { + var controller = + document.commandDispatcher.getControllerForCommand(command); + var enabled = controller.isCommandEnabled(command); + if (enabled) { + children[i].removeAttribute("disabled"); + } else { + children[i].setAttribute("disabled", "true"); + } + } + } + } + + get spellCheckerUI() { + if (!this._spellCheckInitialized) { + this._spellCheckInitialized = true; + + try { + const { InlineSpellChecker } = ChromeUtils.importESModule( + "resource://gre/modules/InlineSpellChecker.sys.mjs" + ); + this.InlineSpellCheckerUI = new InlineSpellChecker( + this._input.editor + ); + } catch (ex) {} + } + + return this.InlineSpellCheckerUI; + } + + getMenuItem(anonid) { + return this.querySelector(`[anonid="${anonid}"]`); + } + + _setMenuItemVisibility(anonid, visible) { + this.getMenuItem(anonid).hidden = !visible; + } + + doCommand(command) { + var controller = + document.commandDispatcher.getControllerForCommand(command); + controller.doCommand(command); + } + + get _input() { + return ( + this.getElementsByAttribute("anonid", "input")[0] || + this.querySelector(".textbox-input") + ); + } + } + + customElements.define("moz-input-box", MozInputBox); +} diff --git a/toolkit/content/widgets/moz-label/README.stories.md b/toolkit/content/widgets/moz-label/README.stories.md new file mode 100644 index 0000000000..a3492ebefa --- /dev/null +++ b/toolkit/content/widgets/moz-label/README.stories.md @@ -0,0 +1,20 @@ +# MozLabel + +`moz-label` is an extension of the built-in `HTMLLabelElement` that provides accesskey styling and formatting as well as some click handling logic. + +```html story +<label is="moz-label" accesskey="c" for="check"> + This is a label with an accesskey: +</label> +<input id="check" type="checkbox" defaultChecked /> +``` + +Accesskey underlining is enabled by default on Windows and Linux. It is also enabled in Storybook on Mac for demonstrative purposes, but is usually controlled by the `ui.key.menuAccessKey` preference. + +## Component status + +At this time `moz-label` may not be suitable for general use in Firefox. + +`moz-label` is currently only used in the `moz-toggle` custom element. There are no instances in Firefox where we set an accesskey on a toggle, so it is still largely untested in the wild. + +Additionally there is at least [one outstanding bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1819469) related to accesskey handling in the shadow DOM. diff --git a/toolkit/content/widgets/moz-label/moz-label.css b/toolkit/content/widgets/moz-label/moz-label.css new file mode 100644 index 0000000000..8e0576075a --- /dev/null +++ b/toolkit/content/widgets/moz-label/moz-label.css @@ -0,0 +1,8 @@ +/* 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/. */ + +label span.accesskey { + text-decoration: underline; + text-decoration-skip-ink: none; +} diff --git a/toolkit/content/widgets/moz-label/moz-label.mjs b/toolkit/content/widgets/moz-label/moz-label.mjs new file mode 100644 index 0000000000..7812436ecd --- /dev/null +++ b/toolkit/content/widgets/moz-label/moz-label.mjs @@ -0,0 +1,298 @@ +/* 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/. */ + +/** + * An extension of the label element that provides accesskey styling and + * formatting as well as click handling logic. + * + * @tagname moz-label + * @attribute {string} accesskey - Key used for keyboard access. + */ +class MozTextLabel extends HTMLLabelElement { + #insertSeparator = false; + #alwaysAppendAccessKey = false; + #lastFormattedAccessKey = null; + + // Default to underlining accesskeys for Windows and Linux. + static #underlineAccesskey = !navigator.platform.includes("Mac"); + static get observedAttributes() { + return ["accesskey"]; + } + + static stylesheetUrl = "chrome://global/content/elements/moz-label.css"; + + constructor() { + super(); + this.#register(); + this.addEventListener("click", this._onClick); + } + + #register() { + if (window.IS_STORYBOOK) { + MozTextLabel.#underlineAccesskey = true; + } else if (typeof Services !== "undefined") { + MozTextLabel.#underlineAccesskey = !!Services.prefs.getIntPref( + "ui.key.menuAccessKey", + Number(!navigator.platform.includes("Mac")) + ); + if (MozTextLabel.#underlineAccesskey) { + try { + const nsIPrefLocalizedString = Ci.nsIPrefLocalizedString; + const prefNameInsertSeparator = + "intl.menuitems.insertseparatorbeforeaccesskeys"; + const prefNameAlwaysAppendAccessKey = + "intl.menuitems.alwaysappendaccesskeys"; + + let val = Services.prefs.getComplexValue( + prefNameInsertSeparator, + nsIPrefLocalizedString + ).data; + this.#insertSeparator = val == "true"; + val = Services.prefs.getComplexValue( + prefNameAlwaysAppendAccessKey, + nsIPrefLocalizedString + ).data; + this.#alwaysAppendAccessKey = val == "true"; + } catch (e) { + this.#insertSeparator = this.#alwaysAppendAccessKey = true; + } + } + } + } + + connectedCallback() { + this.#setStyles(); + this.formatAccessKey(); + } + + // Bug 1820588 - we may want to generalize this into + // MozHTMLElement.insertCssIfNeeded(style) + #setStyles() { + let root = this.getRootNode(); + let container = root.head ?? root; + + for (let link of container.querySelectorAll("link")) { + if (link.getAttribute("href") == this.constructor.stylesheetUrl) { + return; + } + } + + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = this.constructor.stylesheetUrl; + container.appendChild(style); + } + + set textContent(val) { + super.textContent = val; + this.#lastFormattedAccessKey = null; + this.formatAccessKey(); + } + + get textContent() { + return super.textContent; + } + + attributeChangedCallback(attrName, oldValue, newValue) { + if (oldValue == newValue) { + return; + } + + // Note that this is only happening when "accesskey" attribute changes. + this.formatAccessKey(); + } + + _onClick(event) { + let controlElement = this.labeledControlElement; + if (!controlElement || this.disabled) { + return; + } + controlElement.focus(); + + if ( + (controlElement.localName == "checkbox" || + controlElement.localName == "radio") && + controlElement.getAttribute("disabled") == "true" + ) { + return; + } + + if (controlElement.localName == "checkbox") { + controlElement.checked = !controlElement.checked; + } else if (controlElement.localName == "radio") { + controlElement.control.selectedItem = controlElement; + } + } + + set accessKey(val) { + this.setAttribute("accesskey", val); + let control = this.labeledControlElement; + if (control) { + control.setAttribute("accesskey", val); + } + } + + get accessKey() { + let accessKey = this.getAttribute("accesskey"); + return accessKey ? accessKey[0] : null; + } + + get labeledControlElement() { + let control = this.control; + return control ? document.getElementById(control) : null; + } + + set control(val) { + this.setAttribute("control", val); + } + + get control() { + return this.getAttribute("control"); + } + + // This is used to match the rendering of accesskeys from nsTextBoxFrame.cpp (i.e. when the + // label uses [value]). So this is just for when we have textContent. + formatAccessKey() { + // Skip doing any DOM manipulation whenever possible: + let accessKey = this.accessKey; + if ( + !MozTextLabel.#underlineAccesskey || + this.#lastFormattedAccessKey == accessKey || + !this.textContent || + !this.textContent.trim() + ) { + return; + } + this.#lastFormattedAccessKey = accessKey; + if (this.accessKeySpan) { + // Clear old accesskey + mergeElement(this.accessKeySpan); + this.accessKeySpan = null; + } + + if (this.hiddenColon) { + mergeElement(this.hiddenColon); + this.hiddenColon = null; + } + + if (this.accessKeyParens) { + this.accessKeyParens.remove(); + this.accessKeyParens = null; + } + + // If we used to have an accessKey but not anymore, we're done here + if (!accessKey) { + return; + } + + let labelText = this.textContent; + let accessKeyIndex = -1; + if (!this.#alwaysAppendAccessKey) { + accessKeyIndex = labelText.indexOf(accessKey); + if (accessKeyIndex < 0) { + // Try again in upper case + accessKeyIndex = labelText + .toUpperCase() + .indexOf(accessKey.toUpperCase()); + } + } else if (labelText.endsWith(`(${accessKey.toUpperCase()})`)) { + accessKeyIndex = labelText.length - (1 + accessKey.length); // = index of accessKey. + } + + const HTML_NS = "http://www.w3.org/1999/xhtml"; + this.accessKeySpan = document.createElementNS(HTML_NS, "span"); + this.accessKeySpan.className = "accesskey"; + + // Note that if you change the following code, see the comment of + // nsTextBoxFrame::UpdateAccessTitle. + + // If accesskey is in the string, underline it: + if (accessKeyIndex >= 0) { + wrapChar(this, this.accessKeySpan, accessKeyIndex); + return; + } + + // If accesskey is not in string, append in parentheses + // If end is colon, we should insert before colon. + // i.e., "label:" -> "label(X):" + let colonHidden = false; + if (/:$/.test(labelText)) { + labelText = labelText.slice(0, -1); + this.hiddenColon = document.createElementNS(HTML_NS, "span"); + this.hiddenColon.className = "hiddenColon"; + this.hiddenColon.style.display = "none"; + // Hide the last colon by using span element. + // I.e., label<span style="display:none;">:</span> + wrapChar(this, this.hiddenColon, labelText.length); + colonHidden = true; + } + // If end is space(U+20), + // we should not add space before parentheses. + let endIsSpace = false; + if (/ $/.test(labelText)) { + endIsSpace = true; + } + + this.accessKeyParens = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "span" + ); + this.appendChild(this.accessKeyParens); + if (this.#insertSeparator && !endIsSpace) { + this.accessKeyParens.textContent = " ("; + } else { + this.accessKeyParens.textContent = "("; + } + this.accessKeySpan.textContent = accessKey.toUpperCase(); + this.accessKeyParens.appendChild(this.accessKeySpan); + if (!colonHidden) { + this.accessKeyParens.appendChild(document.createTextNode(")")); + } else { + this.accessKeyParens.appendChild(document.createTextNode("):")); + } + } +} +customElements.define("moz-label", MozTextLabel, { extends: "label" }); + +function mergeElement(element) { + // If the element has been removed already, return: + if (!element.isConnected) { + return; + } + // `isInstance` isn't available to web content (i.e. Storybook) so we need to + // fallback to using `instanceof`. + if ( + Text.hasOwnProperty("isInstance") + ? Text.isInstance(element.previousSibling) + : // eslint-disable-next-line mozilla/use-isInstance + element.previousSibling instanceof Text + ) { + element.previousSibling.appendData(element.textContent); + } else { + element.parentNode.insertBefore(element.firstChild, element); + } + element.remove(); +} + +function wrapChar(parentNode, element, index) { + let treeWalker = document.createNodeIterator( + parentNode, + NodeFilter.SHOW_TEXT, + null + ); + let node = treeWalker.nextNode(); + while (index >= node.length) { + index -= node.length; + node = treeWalker.nextNode(); + } + if (index) { + node = node.splitText(index); + } + + node.parentNode.insertBefore(element, node); + if (node.length > 1) { + node.splitText(1); + } + element.appendChild(node); +} diff --git a/toolkit/content/widgets/moz-label/moz-label.stories.mjs b/toolkit/content/widgets/moz-label/moz-label.stories.mjs new file mode 100644 index 0000000000..f954d4fe3a --- /dev/null +++ b/toolkit/content/widgets/moz-label/moz-label.stories.mjs @@ -0,0 +1,86 @@ +/* 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/. */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./moz-label.mjs"; + +MozXULElement.insertFTLIfNeeded("locales-preview/moz-label.storybook.ftl"); + +export default { + title: "UI Widgets/Label", + component: "moz-label", + argTypes: { + inputType: { + options: ["checkbox", "radio"], + control: { type: "select" }, + }, + }, + parameters: { + status: { + type: "unstable", + links: [ + { + title: "Learn more", + href: "?path=/docs/ui-widgets-label-readme--page#component-status", + }, + ], + }, + }, +}; + +const Template = ({ + accesskey, + inputType, + disabled, + "data-l10n-id": dataL10nId, +}) => html` + <style> + div { + display: flex; + align-items: center; + } + + label { + margin-inline-end: 8px; + } + </style> + <div> + <label + is="moz-label" + accesskey=${ifDefined(accesskey)} + data-l10n-id=${ifDefined(dataL10nId)} + for="cheese" + > + </label> + <input + type=${inputType} + name="cheese" + id="cheese" + ?disabled=${disabled} + checked + /> + </div> +`; + +export const AccessKey = Template.bind({}); +AccessKey.args = { + accesskey: "c", + inputType: "checkbox", + disabled: false, + "data-l10n-id": "default-label", +}; + +export const AccessKeyNotInLabel = Template.bind({}); +AccessKeyNotInLabel.args = { + ...AccessKey.args, + accesskey: "x", + "data-l10n-id": "label-with-colon", +}; + +export const DisabledCheckbox = Template.bind({}); +DisabledCheckbox.args = { + ...AccessKey.args, + disabled: true, +}; diff --git a/toolkit/content/widgets/moz-message-bar/README.stories.md b/toolkit/content/widgets/moz-message-bar/README.stories.md new file mode 100644 index 0000000000..c3fc5a88eb --- /dev/null +++ b/toolkit/content/widgets/moz-message-bar/README.stories.md @@ -0,0 +1,67 @@ +# MozMessageBar + +`moz-message-bar` is a versatile user interface element designed to display messages or notifications. +These messages and notifications are nonmodal, and keep users informed without blocking access to the base page. +It supports various types of messages - info, warning, success, and error - each with distinct visual styling +to convey the message's urgency or importance. You can customize `moz-message-bar` by adding a message, message heading, +`moz-support-link`, actions buttons, or by making the message bar dismissable. + +```html story +<moz-message-bar dismissable + heading="Heading of the message bar" + message="Message for the user"> +</moz-message-bar> +``` + +## When to use + +* Use the message bar to display important announcements or notifications to the user. +* Use it to attract the user's attention without interrupting the user's task. + +## When not to use + +* Do not use the message bar for displaying critical alerts or warnings that require immediate and focused attention. + +## Code + +The source for `moz-message-bar` can be found under +[toolkit/content/widgets/moz-message-bar](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs). +You can find an examples of `moz-message-bar` in use in the Firefox codebase in +[about:addons](https://searchfox.org/mozilla-central/source/toolkit/mozapps/extensions/content/aboutaddons.html), +[unified extensions panel](https://searchfox.org/mozilla-central/source/browser/base/content/browser-addons.js) and +[shopping components](https://searchfox.org/mozilla-central/source/browser/components/shopping/content/shopping-message-bar.mjs). + +`moz-message-bar` can be imported into `.html`/`.xhtml` files: + +```html +<script type="module" src="chrome://global/content/elements/moz-message-bar.mjs"></script> +``` + +And used as follows: + +```html +<moz-message-bar dismissable + heading="Heading of the message bar" + message="Message for the user"> +</moz-message-bar> +``` + +### Fluent usage + +Generally the `heading` and `message` properties of +`moz-message-bar` will be provided via [Fluent attributes](https://mozilla-l10n.github.io/localizer-documentation/tools/fluent/basic_syntax.html#attributes). +To get this working you will need to specify a `data-l10n-id` as well as +`data-l10n-attrs` if you're providing a heading and a message: + +```html +<moz-message-bar data-l10n-id="with-heading-and-message" + data-l10n-attrs="heading, message"></moz-message-bar> +``` + +In which case your Fluent messages will look something like this: + +``` +with-heading-and-message = + .heading = Heading text goes here + .message = Message text goes here +``` diff --git a/toolkit/content/widgets/moz-message-bar/moz-message-bar.css b/toolkit/content/widgets/moz-message-bar/moz-message-bar.css new file mode 100644 index 0000000000..6d35009982 --- /dev/null +++ b/toolkit/content/widgets/moz-message-bar/moz-message-bar.css @@ -0,0 +1,211 @@ +/* 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/. */ + +:host { + /* Icon */ + --message-bar-icon-color: var(--icon-color-information); + --message-bar-icon-size: var(--size-item-small); + --message-bar-icon-close-color: var(--icon-color); + --message-bar-icon-close-url: url("chrome://global/skin/icons/close-12.svg"); + + /* Button */ + --message-bar-button-size-ghost: var(--button-min-height); + --message-bar-button-border-radius-ghost: var(--button-border-radius); + --message-bar-button-background-color-ghost-hover: var(--button-background-color-hover); + --message-bar-button-background-color-ghost-active: var(--button-background-color-active); + + /* Container */ + --message-bar-container-min-height: var(--size-item-large); + + /* Border */ + --message-bar-border-color: color-mix(in srgb, currentColor 9%, transparent); + --message-bar-border-radius: var(--border-radius-small); + --message-bar-border-width: var(--border-width); + + /* Text */ + --message-bar-text-color: var(--text-color); + --message-bar-text-line-height: 1.5em; + + /* Background */ + --message-bar-background-color: var(--color-background-information); + + background-color: var(--message-bar-background-color); + border: var(--message-bar-border-width) solid var(--message-bar-border-color); + border-radius: var(--message-bar-border-radius); + color: var(--message-bar-text-color); +} + +@media (prefers-contrast) { + :host { + --message-bar-border-color: var(--border-color); + } +} + +/* Make the host to behave as a block by default, but allow hidden to hide it. */ +:host(:not([hidden])) { + display: block; +} + +/* MozMessageBar layout */ + +.container { + display: flex; + gap: 8px; + min-height: var(--message-bar-container-min-height); + padding-inline: 16px 8px; + padding-block: 8px; +} + +.content { + display: flex; + flex-grow: 1; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-inline-start: 24px; +} + +.text-container { + display: flex; + gap: 4px 8px; + padding-block: calc((var(--message-bar-container-min-height) - var(--message-bar-text-line-height)) / 2); +} + +.text-content { + display: inline-flex; + gap: 4px 8px; + flex-wrap: wrap; + word-break: break-word; + line-height: var(--message-bar-text-line-height); +} + +/* MozMessageBar icon style */ + +.icon-container { + height: var(--message-bar-text-line-height); + display: flex; + justify-content: center; + align-items: center; + margin-inline-start: -24px; +} + +.icon { + width: var(--message-bar-icon-size); + height: var(--message-bar-icon-size); + flex-shrink: 0; + appearance: none; + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + color: var(--message-bar-icon-color); +} + +/* MozMessageBar heading style */ + +.heading { + font-weight: 600; +} + +/* MozMessageBar message style */ + +.message { + margin-inline-end: 4px; +} + +/* MozMessageBar link style */ + +.link { + display: inline-block; +} + +.link ::slotted(a) { + margin-inline-end: 4px; +} + +/* MozMessageBar actions style */ + +.actions { + display: none; +} + +.actions.active { + display: inline-flex; + gap: 8px; +} + +.actions ::slotted(button) { + /* Enforce micro-button width. */ + min-width: fit-content !important; + + margin: 0 !important; + padding: 4px 16px !important; +} + +/* Close icon styles */ + +.close { + background-image: var(--message-bar-icon-close-url); + background-repeat: no-repeat; + background-position: center center; + -moz-context-properties: fill; + fill: currentColor; + min-width: auto; + min-height: auto; + width: var(--message-bar-button-size-ghost); + height: var(--message-bar-button-size-ghost); + margin: 0; + padding: 0; + flex-shrink: 0; +} + +.ghost-button { + border-radius: var(--message-bar-button-border-radius-ghost); +} + +.ghost-button:enabled:hover { + background-color: var(--message-bar-button-background-color-ghost-hover); +} + +.ghost-button:enabled:hover:active { + background-color: var(--message-bar-button-background-color-ghost-active); +} + +@media not (prefers-contrast) { + /* MozMessageBar colors by message type */ + /* Colors from: https://www.figma.com/file/zd3B9UyknB2XNZNdrYLm2W/Outreachy?type=design&node-id=59-1921&mode=design&t=ZYS4e6pAbAlXGvun-4 */ + + :host([type=warning]) { + --message-bar-background-color: var(--color-background-warning); + + .icon { + --message-bar-icon-color: var(--icon-color-warning); + } + } + + :host([type=success]) { + --message-bar-background-color: var(--color-background-success); + + .icon { + --message-bar-icon-color: var(--icon-color-success); + } + } + + :host([type=error]), + :host([type=critical]) { + --message-bar-background-color: var(--color-background-critical); + + .icon { + --message-bar-icon-color: var(--icon-color-critical); + } + } + + .close { + fill: var(--message-bar-icon-close-color); + } + + .ghost-button { + border: none; + background-color: transparent; + } +} diff --git a/toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs b/toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs new file mode 100644 index 0000000000..58f41c28e4 --- /dev/null +++ b/toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs @@ -0,0 +1,176 @@ +/* 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/. */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +import { MozLitElement } from "../lit-utils.mjs"; + +const messageTypeToIconData = { + info: { + iconSrc: "chrome://global/skin/icons/info-filled.svg", + l10nId: "moz-message-bar-icon-info", + }, + warning: { + iconSrc: "chrome://global/skin/icons/warning.svg", + l10nId: "moz-message-bar-icon-warning", + }, + success: { + iconSrc: "chrome://global/skin/icons/check-filled.svg", + l10nId: "moz-message-bar-icon-success", + }, + error: { + iconSrc: "chrome://global/skin/icons/error.svg", + l10nId: "moz-message-bar-icon-error", + }, + critical: { + iconSrc: "chrome://global/skin/icons/error.svg", + l10nId: "moz-message-bar-icon-error", + }, +}; + +/** + * A simple message bar element that can be used to display + * important information to users. + * + * @tagname moz-message-bar + * @property {string} type - The type of the displayed message. + * @property {string} heading - The heading of the message. + * @property {string} message - The message text. + * @property {boolean} dismissable - Whether or not the element is dismissable. + * @property {string} messageL10nId - l10n ID for the message. + * @property {string} messageL10nArgs - Any args needed for the message l10n ID. + * @fires message-bar:close + * Custom event indicating that message bar was closed. + * @fires message-bar:user-dismissed + * Custom event indicating that message bar was dismissed by the user. + */ + +export default class MozMessageBar extends MozLitElement { + static queries = { + actionsSlotEl: "slot[name=actions]", + actionsEl: ".actions", + closeButtonEl: "button.close", + supportLinkSlotEl: "slot[name=support-link]", + }; + + static properties = { + type: { type: String }, + heading: { type: String }, + message: { type: String }, + dismissable: { type: Boolean }, + messageL10nId: { type: String }, + messageL10nArgs: { type: String }, + }; + + constructor() { + super(); + window.MozXULElement?.insertFTLIfNeeded("toolkit/global/mozMessageBar.ftl"); + this.type = "info"; + this.dismissable = false; + } + + onSlotchange(e) { + let actions = this.actionsSlotEl.assignedNodes(); + this.actionsEl.classList.toggle("active", actions.length); + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("role", "status"); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.dispatchEvent(new CustomEvent("message-bar:close")); + } + + get supportLinkEls() { + return this.supportLinkSlotEl.assignedElements(); + } + + iconTemplate() { + let iconData = messageTypeToIconData[this.type]; + if (iconData) { + let { iconSrc, l10nId } = iconData; + return html` + <div class="icon-container"> + <img + class="icon" + src=${iconSrc} + data-l10n-id=${l10nId} + data-l10n-attrs="alt" + /> + </div> + `; + } + return ""; + } + + headingTemplate() { + if (this.heading) { + return html`<strong class="heading">${this.heading}</strong>`; + } + return ""; + } + + closeButtonTemplate() { + if (this.dismissable) { + return html` + <button + class="close ghost-button" + data-l10n-id="moz-message-bar-close-button" + @click=${this.dismiss} + ></button> + `; + } + return ""; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-message-bar.css" + /> + <div class="container"> + <div class="content"> + <div class="text-container"> + ${this.iconTemplate()} + <div class="text-content"> + ${this.headingTemplate()} + <div> + <span + class="message" + data-l10n-id=${ifDefined(this.messageL10nId)} + data-l10n-args=${ifDefined( + JSON.stringify(this.messageL10nArgs) + )} + > + ${this.message} + </span> + <span class="link"> + <slot name="support-link"></slot> + </span> + </div> + </div> + </div> + <span class="actions"> + <slot name="actions" @slotchange=${this.onSlotchange}></slot> + </span> + </div> + ${this.closeButtonTemplate()} + </div> + `; + } + + dismiss() { + this.dispatchEvent(new CustomEvent("message-bar:user-dismissed")); + this.close(); + } + + close() { + this.remove(); + } +} + +customElements.define("moz-message-bar", MozMessageBar); diff --git a/toolkit/content/widgets/moz-message-bar/moz-message-bar.stories.mjs b/toolkit/content/widgets/moz-message-bar/moz-message-bar.stories.mjs new file mode 100644 index 0000000000..65803eed9f --- /dev/null +++ b/toolkit/content/widgets/moz-message-bar/moz-message-bar.stories.mjs @@ -0,0 +1,123 @@ +/* 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/. */ +/* eslint-disable import/no-unassigned-import */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +import "./moz-message-bar.mjs"; +import "../moz-support-link/moz-support-link.mjs"; + +const fluentStrings = [ + "moz-message-bar-message", + "moz-message-bar-message-heading", + "moz-message-bar-message-heading-long", +]; + +export default { + title: "UI Widgets/Message Bar", + component: "moz-message-bar", + argTypes: { + type: { + options: ["info", "warning", "success", "error"], + control: { type: "select" }, + }, + l10nId: { + options: fluentStrings, + control: { type: "select" }, + }, + heading: { + table: { + disable: true, + }, + }, + message: { + table: { + disable: true, + }, + }, + }, + parameters: { + status: "stable", + fluent: ` +moz-message-bar-message = + .message = For your information message +moz-message-bar-message-heading = + .heading = Heading + .message = For your information message +moz-message-bar-message-heading-long = + .heading = A longer heading to check text wrapping in the message bar + .message = Some message that we use to check text wrapping. Some message that we use to check text wrapping. +moz-message-bar-button = Click me! + `, + }, +}; + +const Template = ({ + type, + heading, + message, + l10nId, + dismissable, + hasSupportLink, + hasActionButton, +}) => html` + <moz-message-bar + type=${type} + heading=${ifDefined(heading)} + message=${ifDefined(message)} + data-l10n-id=${ifDefined(l10nId)} + data-l10n-attrs="heading, message" + ?dismissable=${dismissable} + > + ${hasSupportLink + ? html` + <a + is="moz-support-link" + support-page="addons" + slot="support-link" + ></a> + ` + : ""} + ${hasActionButton + ? html` + <button data-l10n-id="moz-message-bar-button" slot="actions"></button> + ` + : ""} + </moz-message-bar> +`; + +export const Default = Template.bind({}); +Default.args = { + type: "info", + l10nId: "moz-message-bar-message", + dismissable: false, + hasSupportLink: false, + hasActionButton: false, +}; + +export const Dismissable = Template.bind({}); +Dismissable.args = { + type: "info", + l10nId: "moz-message-bar-message", + dismissable: true, + hasSupportLink: false, + hasActionButton: false, +}; + +export const WithActionButton = Template.bind({}); +WithActionButton.args = { + type: "info", + l10nId: "moz-message-bar-message", + dismissable: false, + hasSupportLink: false, + hasActionButton: true, +}; + +export const WithSupportLink = Template.bind({}); +WithSupportLink.args = { + type: "info", + l10nId: "moz-message-bar-message", + dismissable: false, + hasSupportLink: true, + hasActionButton: false, +}; diff --git a/toolkit/content/widgets/moz-support-link/moz-support-link.mjs b/toolkit/content/widgets/moz-support-link/moz-support-link.mjs new file mode 100644 index 0000000000..23f18ac434 --- /dev/null +++ b/toolkit/content/widgets/moz-support-link/moz-support-link.mjs @@ -0,0 +1,129 @@ +/* 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/. */ + +window.MozXULElement?.insertFTLIfNeeded("toolkit/global/mozSupportLink.ftl"); + +/** + * An extension of the anchor element that helps create links to Mozilla's + * support documentation. This should be used for SUMO links only - other "Learn + * more" links can use the regular anchor element. + * + * @tagname moz-support-link + * @attribute {string} support-page - Short-hand string from SUMO to the specific support page. + * @attribute {string} utm-content - UTM parameter for a URL, if it is an AMO URL. + * @attribute {string} data-l10n-id - Fluent ID used to generate the text content. + */ +export default class MozSupportLink extends HTMLAnchorElement { + static SUPPORT_URL = "https://www.mozilla.org/"; + static get observedAttributes() { + return ["support-page", "utm-content"]; + } + + /** + * Handles setting up the SUPPORT_URL preference getter. + * Without this, the tests for this component may not behave + * as expected. + * @private + * @memberof MozSupportLink + */ + #register() { + if (window.document.nodePrincipal?.isSystemPrincipal) { + // eslint-disable-next-line no-shadow + let { XPCOMUtils } = window.XPCOMUtils + ? window + : ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + XPCOMUtils.defineLazyPreferenceGetter( + MozSupportLink, + "SUPPORT_URL", + "app.support.baseURL", + "", + null, + val => Services.urlFormatter.formatURL(val) + ); + } else if (!window.IS_STORYBOOK) { + MozSupportLink.SUPPORT_URL = window.RPMGetFormatURLPref( + "app.support.baseURL" + ); + } + } + + connectedCallback() { + this.#register(); + this.#setHref(); + this.setAttribute("target", "_blank"); + this.addEventListener("click", this); + if ( + !this.getAttribute("data-l10n-id") && + !this.getAttribute("data-l10n-name") && + !this.childElementCount + ) { + document.l10n.setAttributes(this, "moz-support-link-text"); + } + document.l10n.translateFragment(this); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + } + + handleEvent(e) { + if (e.type == "click") { + if (window.openTrustedLinkIn) { + let where = whereToOpenLink(e, false, true); + if (where == "current") { + where = "tab"; + } + e.preventDefault(); + openTrustedLinkIn(this.href, where); + } + } + } + + attributeChangedCallback(attrName, oldVal, newVal) { + if (attrName === "support-page" || attrName === "utm-content") { + this.#setHref(); + } + } + + #setHref() { + let supportPage = this.getAttribute("support-page") ?? ""; + let base = MozSupportLink.SUPPORT_URL + supportPage; + this.href = this.hasAttribute("utm-content") + ? formatUTMParams(this.getAttribute("utm-content"), base) + : base; + } +} +customElements.define("moz-support-link", MozSupportLink, { extends: "a" }); + +/** + * Adds UTM parameters to a given URL, if it is an AMO URL. + * + * @param {string} contentAttribute + * Identifies the part of the UI with which the link is associated. + * @param {string} url + * @returns {string} + * The url with UTM parameters if it is an AMO URL. + * Otherwise the url in unmodified form. + */ +export function formatUTMParams(contentAttribute, url) { + if (!contentAttribute) { + return url; + } + let parsedUrl = new URL(url); + let domain = `.${parsedUrl.hostname}`; + if ( + !domain.endsWith(".mozilla.org") && + // For testing: addons-dev.allizom.org and addons.allizom.org + !domain.endsWith(".allizom.org") + ) { + return url; + } + + parsedUrl.searchParams.set("utm_source", "firefox-browser"); + parsedUrl.searchParams.set("utm_medium", "firefox-browser"); + parsedUrl.searchParams.set("utm_content", contentAttribute); + return parsedUrl.href; +} diff --git a/toolkit/content/widgets/moz-support-link/moz-support-link.stories.mjs b/toolkit/content/widgets/moz-support-link/moz-support-link.stories.mjs new file mode 100644 index 0000000000..9afb80fcd7 --- /dev/null +++ b/toolkit/content/widgets/moz-support-link/moz-support-link.stories.mjs @@ -0,0 +1,67 @@ +/* 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/. */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./moz-support-link.mjs"; + +MozXULElement.insertFTLIfNeeded( + "locales-preview/moz-support-link-storybook.ftl" +); +MozXULElement.insertFTLIfNeeded("toolkit/global/mozSupportLink.ftl"); + +const fluentStrings = [ + "storybook-amo-test", + "storybook-fluent-test", + "moz-support-link-text", +]; + +export default { + title: "UI Widgets/Support Link", + component: "moz-support-link", + argTypes: { + "data-l10n-id": { + options: [fluentStrings[0], fluentStrings[1], fluentStrings[2]], + control: { type: "select" }, + }, + onClick: { action: "clicked" }, + }, + parameters: { + status: "stable", + }, +}; + +const Template = ({ + "data-l10n-id": dataL10nId, + "support-page": supportPage, + "utm-content": utmContent, +}) => html` + <a + is="moz-support-link" + data-l10n-id=${ifDefined(dataL10nId)} + support-page=${ifDefined(supportPage)} + utm-content=${ifDefined(utmContent)} + > + </a> +`; + +export const withAMOUrl = Template.bind({}); +withAMOUrl.args = { + "data-l10n-id": fluentStrings[0], + "support-page": "addons", + "utm-content": "promoted-addon-badge", +}; + +export const Primary = Template.bind({}); +Primary.args = { + "support-page": "preferences", + "utm-content": "", +}; + +export const withFluentId = Template.bind({}); +withFluentId.args = { + "data-l10n-id": fluentStrings[1], + "support-page": "preferences", + "utm-content": "", +}; diff --git a/toolkit/content/widgets/moz-toggle/README.stories.md b/toolkit/content/widgets/moz-toggle/README.stories.md new file mode 100644 index 0000000000..8ab289fd92 --- /dev/null +++ b/toolkit/content/widgets/moz-toggle/README.stories.md @@ -0,0 +1,80 @@ +# MozToggle + +`moz-toggle` is a toggle element that can be used to switch between two states. +It may be helpful to think of it as a button that can be pressed or unpressed, +corresponding with "on" and "off" states. + +```html story +<moz-toggle pressed + label="Toggle label" + description="This is a demo toggle for the docs."> +</moz-toggle> +``` + +## When to use + +* Use a toggle for binary controls like on/off or enabled/disabled. +* Use when the action is performed immediately and doesn't require confirmation + or form submission. +* A toggle is like a switch. If it would be appropriate to use a switch in the + physical world for this action, it is likely appropriate to use a toggle in + software. + +## When not to use + +* If another action is required to execute the choice, use a checkbox (i.e. a + toggle should not generally be used as part of a form). + +## Code + +The source for `moz-toggle` can be found under +[toolkit/content/widgets/moz-toggle](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-toggle/moz-toggle.mjs). +You can find an examples of `moz-toggle` in use in the Firefox codebase in both +[about:preferences](https://searchfox.org/mozilla-central/source/browser/components/preferences/privacy.inc.xhtml#696) +and [about:addons](https://searchfox.org/mozilla-central/source/toolkit/mozapps/extensions/content/aboutaddons.html#182). + +`moz-toggle` can be imported into `.html`/`.xhtml` files: + +```html +<script type="module" src="chrome://global/content/elements/moz-toggle.mjs"></script> +``` + +And used as follows: + +```html +<moz-toggle pressed + label="Label for the toggle" + description="Longer explanation of what the toggle is for" + aria-label="Toggle label if label text isn't visible"></moz-toggle> +``` + +### Fluent usage + +Generally the `label`, `description`, and `aria-label` properties of +`moz-toggle` will be provided via [Fluent attributes](https://mozilla-l10n.github.io/localizer-documentation/tools/fluent/basic_syntax.html#attributes). +To get this working you will need to specify a `data-l10n-id` as well as +`data-l10n-attrs` if you're providing a label and a description: + +```html +<moz-toggle data-l10n-id="with-label-and-description" + data-l10n-attrs="label, description"></moz-toggle> +``` + +In which case your Fluent messages will look something like this: + +``` +with-label-and-description = + .label = Label text goes here + .description = Description text goes here +``` + +You do not have to specify `data-l10n-attrs` if you're only using an `aria-label`: + +```html +<moz-toggle data-l10n-id="with-aria-label-only"></moz-toggle> +``` + +``` +with-aria-label-only = + .aria-label = aria-label text goes here +``` diff --git a/toolkit/content/widgets/moz-toggle/moz-toggle.css b/toolkit/content/widgets/moz-toggle/moz-toggle.css new file mode 100644 index 0000000000..8b67a81878 --- /dev/null +++ b/toolkit/content/widgets/moz-toggle/moz-toggle.css @@ -0,0 +1,198 @@ +/* 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/. */ + +@import url("chrome://global/skin/design-system/text-and-typography.css"); + +:host { + display: flex; + flex-direction: column; + gap: 4px; +} + +:host([disabled]) { + opacity: 0.4 +} + +::slotted(a[is="moz-support-link"]) { + display: inline-block; +} + +#moz-toggle-label { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.description-wrapper, +.description-wrapper ::slotted([slot="support-link"]) { + margin: 0; +} + +.toggle-button { + --toggle-background-color: var(--button-background-color); + --toggle-background-color-hover: var(--button-background-color-hover); + --toggle-background-color-active: var(--button-background-color-active); + --toggle-background-color-pressed: var(--color-accent-primary); + --toggle-background-color-pressed-hover: var(--color-accent-primary-hover); + --toggle-background-color-pressed-active: var(--color-accent-primary-active); + --toggle-border-color: var(--border-interactive-color); + --toggle-border-radius: var(--border-radius-circle); + --toggle-border-width: var(--border-width); + --toggle-height: var(--size-item-small); + --toggle-width: var(--size-item-large); + --toggle-dot-background-color: var(--toggle-border-color); + --toggle-dot-background-color-on-pressed: var(--color-canvas); + --toggle-dot-margin: 1px; + --toggle-dot-height: calc(var(--toggle-height) - 2 * var(--toggle-dot-margin) - 2 * var(--toggle-border-width)); + --toggle-dot-width: var(--toggle-dot-height); + --toggle-dot-transform-x: calc(var(--toggle-width) - 4 * var(--toggle-dot-margin) - var(--toggle-dot-width)); +} + +.toggle-button { + appearance: none; + padding: 0; + margin: 0; + border: var(--toggle-border-width) solid var(--toggle-border-color); + height: var(--toggle-height); + width: var(--toggle-width); + border-radius: var(--toggle-border-radius); + background: var(--toggle-background-color); + box-sizing: border-box; + flex-shrink: 0; +} + +.toggle-button:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); +} + +.toggle-button:enabled:hover { + background: var(--toggle-background-color-hover); + border-color: var(--toggle-border-color); +} + +.toggle-button:enabled:active { + background: var(--toggle-background-color-active); + border-color: var(--toggle-border-color); +} + +.toggle-button[aria-pressed="true"] { + background: var(--toggle-background-color-pressed); + border-color: transparent; +} + +.toggle-button[aria-pressed="true"]:enabled:hover { + background: var(--toggle-background-color-pressed-hover); + border-color: transparent; +} + +.toggle-button[aria-pressed="true"]:enabled:active { + background: var(--toggle-background-color-pressed-active); + border-color: transparent; +} + +.toggle-button::before { + display: block; + content: ""; + background-color: var(--toggle-dot-background-color); + height: var(--toggle-dot-height); + width: var(--toggle-dot-width); + margin: var(--toggle-dot-margin); + border-radius: var(--toggle-border-radius); + translate: 0; +} + +.toggle-button[aria-pressed="true"]::before { + translate: var(--toggle-dot-transform-x); + background-color: var(--toggle-dot-background-color-on-pressed); +} + +.toggle-button[aria-pressed="true"]:enabled:hover::before, +.toggle-button[aria-pressed="true"]:enabled:active::before { + background-color: var(--toggle-dot-background-color-on-pressed); +} + +.toggle-button[aria-pressed="true"]:-moz-locale-dir(rtl)::before, +.toggle-button[aria-pressed="true"]:dir(rtl)::before { + translate: calc(-1 * var(--toggle-dot-transform-x)); +} + +@media (prefers-reduced-motion: no-preference) { + .toggle-button::before { + transition: translate 100ms; + } +} + +@media (prefers-contrast) { + :host([disabled]) { + opacity: 1; + } + + :host([disabled]) > .toggle-button[aria-pressed="true"], + :host([disabled]) > .toggle-button { + background-color: var(--toggle-background-color-disabled); + border-color: var(--toggle-border-color-disabled); + } + + :host([disabled]) > .toggle-button[aria-pressed="false"]::before, + :host([disabled]) > .toggle-button[aria-pressed="true"]::before { + background-color: var(--toggle-background-color-disabled); + } + + .toggle-button { + --toggle-dot-background-color: var(--color-accent-primary); + --toggle-dot-background-color-hover: var(--color-accent-primary-hover); + --toggle-dot-background-color-active: var(--color-accent-primary-active); + --toggle-dot-background-color-on-pressed: var(--button-background-color); + --toggle-background-color-disabled: var(--button-background-color-disabled); + --toggle-border-color-hover: var(--border-interactive-color-hover); + --toggle-border-color-active: var(--border-interactive-color-active); + --toggle-border-color-disabled: var(--border-interactive-color-disabled); + } + + .toggle-button:enabled:hover { + border-color: var(--toggle-border-color-hover); + } + + .toggle-button:enabled:active { + border-color: var(--toggle-border-color-active); + } + + .toggle-button[aria-pressed="true"]:enabled { + border-color: var(--toggle-border-color); + position: relative; + } + + .toggle-button[aria-pressed="true"]:enabled:hover, + .toggle-button[aria-pressed="true"]:enabled:hover:active { + border-color: var(--toggle-border-color-hover); + } + + .toggle-button[aria-pressed="true"]:enabled:active { + background-color: var(--toggle-dot-background-color-active); + border-color: var(--toggle-dot-background-color-hover); + } + + .toggle-button[aria-pressed="true"]:enabled::after { + border: 1px solid var(--button-background-color); + content: ''; + position: absolute; + height: var(--toggle-height); + width: var(--toggle-width); + display: block; + border-radius: var(--toggle-border-radius); + inset: -2px; + } + + .toggle-button[aria-pressed="true"]:enabled:active::after { + border-color: var(--toggle-border-color-active); + } + + .toggle-button:hover::before, + .toggle-button:hover:active::before, + .toggle-button:active::before { + background-color: var(--toggle-dot-background-color-hover); + } +} diff --git a/toolkit/content/widgets/moz-toggle/moz-toggle.mjs b/toolkit/content/widgets/moz-toggle/moz-toggle.mjs new file mode 100644 index 0000000000..be7ee98f34 --- /dev/null +++ b/toolkit/content/widgets/moz-toggle/moz-toggle.mjs @@ -0,0 +1,133 @@ +/* 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 htp://mozilla.org/MPL/2.0/. */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +import { MozLitElement } from "../lit-utils.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-label.mjs"; + +/** + * A simple toggle element that can be used to switch between two states. + * + * @tagname moz-toggle + * @property {boolean} pressed - Whether or not the element is pressed. + * @property {boolean} disabled - Whether or not the element is disabled. + * @property {string} label - The label text. + * @property {string} description - The description text. + * @property {string} ariaLabel + * The aria-label text for cases where there is no visible label. + * @slot support-link - Used to append a moz-support-link to the description. + * @fires toggle + * Custom event indicating that the toggle's pressed state has changed. + */ +export default class MozToggle extends MozLitElement { + static shadowRootOptions = { + ...MozLitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static properties = { + pressed: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + label: { type: String }, + description: { type: String }, + ariaLabel: { type: String, attribute: "aria-label" }, + accessKey: { type: String, attribute: "accesskey" }, + }; + + static get queries() { + return { + buttonEl: "#moz-toggle-button", + labelEl: "#moz-toggle-label", + descriptionEl: "#moz-toggle-description", + }; + } + + constructor() { + super(); + this.pressed = false; + this.disabled = false; + } + + handleClick() { + this.pressed = !this.pressed; + this.dispatchOnUpdateComplete( + new CustomEvent("toggle", { + bubbles: true, + composed: true, + }) + ); + } + + // Delegate clicks on the host to the input element + click() { + this.buttonEl.click(); + } + + descriptionTemplate() { + if (this.description) { + return html` + <p + id="moz-toggle-description" + class="description-wrapper text-deemphasized" + part="description" + > + ${this.description} ${this.supportLinkTemplate()} + </p> + `; + } + return ""; + } + + supportLinkTemplate() { + return html` <slot name="support-link"></slot> `; + } + + buttonTemplate() { + const { pressed, disabled, description, ariaLabel, handleClick } = this; + return html` + <button + id="moz-toggle-button" + part="button" + type="button" + class="toggle-button" + ?disabled=${disabled} + aria-pressed=${pressed} + aria-label=${ifDefined(ariaLabel ?? undefined)} + aria-describedby=${ifDefined( + description ? "moz-toggle-description" : undefined + )} + @click=${handleClick} + ></button> + `; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-toggle.css" + /> + ${this.label + ? html` + <label + is="moz-label" + id="moz-toggle-label" + part="label" + for="moz-toggle-button" + accesskey=${ifDefined(this.accessKey)} + > + <span> + ${this.label} + ${!this.description ? this.supportLinkTemplate() : ""} + </span> + ${this.buttonTemplate()} + </label> + ` + : this.buttonTemplate()} + ${this.descriptionTemplate()} + `; + } +} +customElements.define("moz-toggle", MozToggle); diff --git a/toolkit/content/widgets/moz-toggle/moz-toggle.stories.mjs b/toolkit/content/widgets/moz-toggle/moz-toggle.stories.mjs new file mode 100644 index 0000000000..fa41e7c888 --- /dev/null +++ b/toolkit/content/widgets/moz-toggle/moz-toggle.stories.mjs @@ -0,0 +1,96 @@ +/* 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/. */ +/* eslint-disable import/no-unassigned-import */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +import "./moz-toggle.mjs"; +import "../moz-support-link/moz-support-link.mjs"; + +export default { + title: "UI Widgets/Toggle", + component: "moz-toggle", + parameters: { + status: "in-development", + actions: { + handles: ["toggle"], + }, + fluent: ` +moz-toggle-aria-label = + .aria-label = This is the aria-label +moz-toggle-label = + .label = This is the label +moz-toggle-description = + .label = This is the label + .description = This is the description. + `, + }, +}; + +const Template = ({ + pressed, + disabled, + label, + description, + ariaLabel, + l10nId, + hasSupportLink, + accessKey, +}) => html` + <div style="max-width: 400px"> + <moz-toggle + ?pressed=${pressed} + ?disabled=${disabled} + label=${ifDefined(label)} + description=${ifDefined(description)} + aria-label=${ifDefined(ariaLabel)} + data-l10n-id=${ifDefined(l10nId)} + data-l10n-attrs="aria-label, description, label" + accesskey=${ifDefined(accessKey)} + > + ${hasSupportLink + ? html` + <a + is="moz-support-link" + support-page="addons" + slot="support-link" + ></a> + ` + : ""} + </moz-toggle> + </div> +`; + +export const Toggle = Template.bind({}); +Toggle.args = { + pressed: true, + disabled: false, + l10nId: "moz-toggle-aria-label", +}; + +export const ToggleDisabled = Template.bind({}); +ToggleDisabled.args = { + ...Toggle.args, + disabled: true, +}; + +export const WithLabel = Template.bind({}); +WithLabel.args = { + pressed: true, + disabled: false, + l10nId: "moz-toggle-label", + hasSupportLink: false, + accessKey: "h", +}; + +export const WithDescription = Template.bind({}); +WithDescription.args = { + ...WithLabel.args, + l10nId: "moz-toggle-description", +}; + +export const WithSupportLink = Template.bind({}); +WithSupportLink.args = { + ...WithDescription.args, + hasSupportLink: true, +}; diff --git a/toolkit/content/widgets/named-deck.js b/toolkit/content/widgets/named-deck.js new file mode 100644 index 0000000000..6b3b7a8835 --- /dev/null +++ b/toolkit/content/widgets/named-deck.js @@ -0,0 +1,398 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + /** + * This element is for use with the <named-deck> element. Set the target + * <named-deck>'s ID in the "deck" attribute and the button's selected state + * will reflect the deck's state. When the button is clicked, it will set the + * view in the <named-deck> to the button's "name" attribute. + * + * The "tab" role will be added unless a different role is provided. Wrapping + * a set of these buttons in a <button-group> element will add the key handling + * for a tablist. + * + * NOTE: This does not observe changes to the "deck" or "name" attributes, so + * changing them likely won't work properly. + * + * <button is="named-deck-button" deck="pet-deck" name="dogs">Dogs</button> + * <named-deck id="pet-deck"> + * <p name="cats">I like cats.</p> + * <p name="dogs">I like dogs.</p> + * </named-deck> + * + * let btn = document.querySelector('button[name="dogs"]'); + * let deck = document.querySelector("named-deck"); + * deck.selectedViewName == "cats"; + * btn.selected == false; // Selected was pulled from the related deck. + * btn.click(); + * deck.selectedViewName == "dogs"; + * btn.selected == true; // Selected updated when view changed. + */ + class NamedDeckButton extends HTMLButtonElement { + connectedCallback() { + this.id = `${this.deckId}-button-${this.name}`; + if (!this.hasAttribute("role")) { + this.setAttribute("role", "tab"); + } + this.setSelectedFromDeck(); + this.addEventListener("click", this); + this.getRootNode().addEventListener("view-changed", this, { + capture: true, + }); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + this.getRootNode().removeEventListener("view-changed", this, { + capture: true, + }); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "selected") { + this.selected = newVal; + } + } + + get deckId() { + return this.getAttribute("deck"); + } + + set deckId(val) { + this.setAttribute("deck", val); + } + + get deck() { + return this.getRootNode().querySelector(`#${this.deckId}`); + } + + handleEvent(e) { + if (e.type == "view-changed" && e.target.id == this.deckId) { + this.setSelectedFromDeck(); + } else if (e.type == "click") { + let { deck } = this; + if (deck) { + deck.selectedViewName = this.name; + } + } + } + + get name() { + return this.getAttribute("name"); + } + + get selected() { + return this.hasAttribute("selected"); + } + + set selected(val) { + if (this.selected != val) { + this.toggleAttribute("selected", val); + } + this.setAttribute("aria-selected", !!val); + } + + setSelectedFromDeck() { + let { deck } = this; + this.selected = deck && deck.selectedViewName == this.name; + if (this.selected) { + this.dispatchEvent( + new CustomEvent("button-group:selected", { bubbles: true }) + ); + } + } + } + customElements.define("named-deck-button", NamedDeckButton, { + extends: "button", + }); + + class ButtonGroup extends HTMLElement { + static get observedAttributes() { + return ["orientation"]; + } + + connectedCallback() { + this.setAttribute("role", "tablist"); + + if (!this.observer) { + this.observer = new MutationObserver(changes => { + for (let change of changes) { + this.setChildAttributes(change.addedNodes); + for (let node of change.removedNodes) { + if (this.activeChild == node) { + // Ensure there's still an active child. + this.activeChild = this.firstElementChild; + } + } + } + }); + } + this.observer.observe(this, { childList: true }); + + // Set the role and tabindex for the current children. + this.setChildAttributes(this.children); + + // Try assigning the active child again, this will run through the checks + // to ensure it's still valid. + this.activeChild = this._activeChild; + + this.addEventListener("button-group:selected", this); + this.addEventListener("keydown", this); + this.addEventListener("mousedown", this); + this.getRootNode().addEventListener("keypress", this); + } + + disconnectedCallback() { + this.observer.disconnect(); + this.removeEventListener("button-group:selected", this); + this.removeEventListener("keydown", this); + this.removeEventListener("mousedown", this); + this.getRootNode().removeEventListener("keypress", this); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "orientation") { + if (this.isVertical) { + this.setAttribute("aria-orientation", this.orientation); + } else { + this.removeAttribute("aria-orientation"); + } + } + } + + setChildAttributes(nodes) { + for (let node of nodes) { + if (node.nodeType == Node.ELEMENT_NODE && node != this.activeChild) { + node.setAttribute("tabindex", "-1"); + } + } + } + + // The activeChild is the child that can be focused with tab. + get activeChild() { + return this._activeChild; + } + + set activeChild(node) { + let prevActiveChild = this._activeChild; + let newActiveChild; + + if (node && this.contains(node)) { + newActiveChild = node; + } else { + newActiveChild = this.firstElementChild; + } + + this._activeChild = newActiveChild; + + if (newActiveChild) { + newActiveChild.setAttribute("tabindex", "0"); + } + + if (prevActiveChild && prevActiveChild != newActiveChild) { + prevActiveChild.setAttribute("tabindex", "-1"); + } + } + + get isVertical() { + return this.orientation == "vertical"; + } + + get orientation() { + return this.getAttribute("orientation") == "vertical" + ? "vertical" + : "horizontal"; + } + + set orientation(val) { + if (val == "vertical") { + this.setAttribute("orientation", val); + } else { + this.removeAttribute("orientation"); + } + } + + _navigationKeys() { + if (this.isVertical) { + return { + previousKey: "ArrowUp", + nextKey: "ArrowDown", + }; + } + if (document.dir == "rtl") { + return { + previousKey: "ArrowRight", + nextKey: "ArrowLeft", + }; + } + return { + previousKey: "ArrowLeft", + nextKey: "ArrowRight", + }; + } + + handleEvent(e) { + let { previousKey, nextKey } = this._navigationKeys(); + if (e.type == "keydown" && (e.key == previousKey || e.key == nextKey)) { + this.setAttribute("last-input-type", "keyboard"); + e.preventDefault(); + let oldFocus = this.activeChild; + this.walker.currentNode = oldFocus; + let newFocus; + if (e.key == previousKey) { + newFocus = this.walker.previousNode(); + } else { + newFocus = this.walker.nextNode(); + } + if (newFocus) { + this.activeChild = newFocus; + this.dispatchEvent(new CustomEvent("button-group:key-selected")); + } + } else if (e.type == "button-group:selected") { + this.activeChild = e.target; + } else if (e.type == "mousedown") { + this.setAttribute("last-input-type", "mouse"); + } else if (e.type == "keypress" && e.key == "Tab") { + this.setAttribute("last-input-type", "keyboard"); + } + } + + get walker() { + if (!this._walker) { + this._walker = document.createTreeWalker( + this, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => { + if (node.hidden || node.disabled) { + return NodeFilter.FILTER_REJECT; + } + node.focus(); + return this.getRootNode().activeElement == node + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + } + ); + } + return this._walker; + } + } + customElements.define("button-group", ButtonGroup); + + /** + * A deck that is indexed by the "name" attribute of its children. The + * <named-deck-button> element is a companion element that can update its state + * and change the view of a <named-deck>. + * + * When the deck is connected it will set the first child as the selected view + * if a view is not already selected. + * + * The deck is implemented using a named slot. Setting a slot directly on a + * child element of the deck is not supported. + * + * You can get or set the selected view by name with the `selectedViewName` + * property or by setting the "selected-view" attribute. + * + * <named-deck> + * <section name="cats">Some info about cats.</section> + * <section name="dogs">Some dog stuff.</section> + * </named-deck> + * + * let deck = document.querySelector("named-deck"); + * deck.selectedViewName == "cats"; // Cat info is shown. + * deck.selectedViewName = "dogs"; + * deck.selectedViewName == "dogs"; // Dog stuff is shown. + * deck.setAttribute("selected-view", "cats"); + * deck.selectedViewName == "cats"; // Cat info is shown. + * + * Add the is-tabbed attribute to <named-deck> if you want + * each of its children to have a tabpanel role and aria-labelledby + * referencing the NamedDeckButton component. + */ + class NamedDeck extends HTMLElement { + static get observedAttributes() { + return ["selected-view"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + // Create a slot for the visible content. + let selectedSlot = document.createElement("slot"); + selectedSlot.setAttribute("name", "selected"); + this.shadowRoot.appendChild(selectedSlot); + + this.observer = new MutationObserver(() => { + this._setSelectedViewAttributes(); + }); + } + + connectedCallback() { + if (this.selectedViewName) { + // Make sure the selected view is shown. + this._setSelectedViewAttributes(); + } else { + // If there's no selected view, default to the first. + let firstView = this.firstElementChild; + if (firstView) { + // This will trigger showing the first view. + this.selectedViewName = firstView.getAttribute("name"); + } + } + this.observer.observe(this, { childList: true }); + } + + disconnectedCallback() { + this.observer.disconnect(); + } + + attributeChangedCallback(attr, oldVal, newVal) { + if (attr == "selected-view" && oldVal != newVal) { + // Update the slot attribute on the views. + this._setSelectedViewAttributes(); + + // Notify that the selected view changed. + this.dispatchEvent(new CustomEvent("view-changed")); + } + } + + get selectedViewName() { + return this.getAttribute("selected-view"); + } + + set selectedViewName(name) { + this.setAttribute("selected-view", name); + } + + /** + * Set the slot attribute on all of the views to ensure only the selected view + * is shown. + */ + _setSelectedViewAttributes() { + let { selectedViewName } = this; + for (let view of this.children) { + let name = view.getAttribute("name"); + + if (this.hasAttribute("is-tabbed")) { + view.setAttribute("aria-labelledby", `${this.id}-button-${name}`); + view.setAttribute("role", "tabpanel"); + } + + if (name === selectedViewName) { + view.slot = "selected"; + } else { + view.slot = ""; + } + } + } + } + customElements.define("named-deck", NamedDeck); +} diff --git a/toolkit/content/widgets/notificationbox.js b/toolkit/content/widgets/notificationbox.js new file mode 100644 index 0000000000..f23fb03a74 --- /dev/null +++ b/toolkit/content/widgets/notificationbox.js @@ -0,0 +1,831 @@ +/* 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 chrome windows with the subscript loader. If you need to +// define globals, wrap in a block to prevent leaking onto `window`. +{ + MozElements.NotificationBox = class NotificationBox { + /** + * Creates a new class to handle a notification box, but does not add any + * elements to the DOM until a notification has to be displayed. + * + * @param insertElementFn + * Called with the "notification-stack" element as an argument when the + * first notification has to be displayed. + */ + constructor(insertElementFn) { + this._insertElementFn = insertElementFn; + this._animating = false; + this.currentNotification = null; + } + + get stack() { + if (!this._stack) { + let stack = document.createXULElement("vbox"); + stack._notificationBox = this; + stack.className = "notificationbox-stack"; + stack.addEventListener("transitionend", event => { + if ( + (event.target.localName == "notification" || + event.target.localName == "notification-message") && + event.propertyName == "margin-top" + ) { + this._finishAnimation(); + } + }); + this._stack = stack; + this._insertElementFn(stack); + } + return this._stack; + } + + get _allowAnimation() { + return window.matchMedia("(prefers-reduced-motion: no-preference)") + .matches; + } + + get allNotifications() { + // Don't create any DOM if no new notification has been added yet. + if (!this._stack) { + return []; + } + + var closedNotification = this._closedNotification; + var notifications = [ + ...this.stack.getElementsByTagName("notification"), + ...this.stack.getElementsByTagName("notification-message"), + ]; + return notifications.filter(n => n != closedNotification); + } + + getNotificationWithValue(aValue) { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + if (aValue == notifications[n].getAttribute("value")) { + return notifications[n]; + } + } + return null; + } + + /** + * Creates a <notification> element and shows it. The calling code can modify + * the element synchronously to add features to the notification. + * + * aType + * String identifier that can uniquely identify the type of the notification. + * aNotification + * Object that contains any of the following properties, where only the + * priority must be specified: + * priority + * One of the PRIORITY_ constants. These determine the appearance of + * the notification based on severity (using the "type" attribute), and + * only the notification with the highest priority is displayed. + * label + * The main message text (as string), or object (with l10n-id, l10n-args), + * or a DocumentFragment containing elements to + * add as children of the notification's main <description> element. + * eventCallback + * This may be called with the "removed", "dismissed" or "disconnected" + * parameter: + * removed - notification has been removed + * dismissed - user dismissed notification + * disconnected - notification removed in any way + * notificationIs + * Defines a Custom Element name to use as the "is" value on creation. + * This allows subclassing the created element. + * telemetry + * Specifies the telemetry key to use that triggers when the notification + * is shown, dismissed and an action taken. This telemetry is a keyed scalar with keys for: + * 'shown', 'dismissed' and 'action'. If a button specifies a separate key, + * then 'action' is replaced by values specific to each button. The value telemetryFilter + * can be used to filter out each type. + * telemetryFilter + * If assigned, then an array of the telemetry types to send telemetry for. If not set, + * then all telemetry is sent. + * aButtons + * Array of objects defining action buttons: + * { + * label: + * Label of the <button> element. + * accessKey: + * Access key character for the <button> element. + * "l10n-id" + * Localization id for the <button>, to be used instead of + * specifying a separate label and access key. + * callback: + * When the button is used, this is called with the arguments: + * 1. The <notification> element. + * 2. This button object definition. + * 3. The <button> element. + * 4. The "command" event. + * If the callback returns false, the notification is closed. + * link: + * A url to open when the button is clicked. The button is + * rendered like a link. The callback is called as well. + * supportPage: + * Used for a support page link. If no other properties are specified, + * defaults to a link with a 'Learn more' label. + * popup: + * If specified, the button will open the popup element with this + * ID, anchored to the button. This is alternative to "callback". + * telemetry: + * Specifies the key to add for the telemetry to trigger when the + * button is pressed. If not specified, then 'action' is used for + * a press on any button. Specify this only if you want to distinguish + * which button has been pressed in telemetry data. + * is: + * Defines a Custom Element name to use as the "is" value on + * button creation. + * } + * + * @return The <notification> element that is shown. + */ + async appendNotification(aType, aNotification, aButtons) { + if ( + aNotification.priority < this.PRIORITY_SYSTEM || + aNotification.priority > this.PRIORITY_CRITICAL_HIGH + ) { + throw new Error( + "Invalid notification priority " + aNotification.priority + ); + } + + MozXULElement.insertFTLIfNeeded("toolkit/global/notification.ftl"); + + // Create the Custom Element and connect it to the document immediately. + let newitem; + if (!aNotification.notificationIs) { + if (!customElements.get("notification-message")) { + // There's some weird timing stuff when this element is created at + // script load time, we don't need it until now anyway so be lazy. + // Wrapped in a try/catch to handle rare cases where we start creating + // a notification but then the window gets closed/goes away. + try { + await createNotificationMessageElement(); + } catch (err) { + console.warn(err); + throw err; + } + } + newitem = document.createElement("notification-message"); + newitem.setAttribute("message-bar-type", "infobar"); + } else { + newitem = document.createXULElement( + "notification", + aNotification.notificationIs + ? { is: aNotification.notificationIs } + : {} + ); + } + + // Append or prepend notification, based on stack preference. + if (this.stack.hasAttribute("prepend-notifications")) { + this.stack.prepend(newitem); + } else { + this.stack.append(newitem); + } + + if (newitem.localName === "notification-message" && aNotification.label) { + newitem.label = aNotification.label; + } else if (newitem.messageText) { + // Custom notification classes may not have the messageText property. + // Can't use instanceof in case this was created from a different document: + if ( + aNotification.label && + typeof aNotification.label == "object" && + aNotification.label.nodeType && + aNotification.label.nodeType == + aNotification.label.DOCUMENT_FRAGMENT_NODE + ) { + newitem.messageText.appendChild(aNotification.label); + } else if ( + aNotification.label && + typeof aNotification.label == "object" && + "l10n-id" in aNotification.label + ) { + let message = document.createElement("span"); + document.l10n.setAttributes( + message, + aNotification.label["l10n-id"], + aNotification.label["l10n-args"] + ); + newitem.messageText.appendChild(message); + } else { + newitem.messageText.textContent = aNotification.label; + } + } + newitem.setAttribute("value", aType); + + newitem.eventCallback = aNotification.eventCallback; + + if (aButtons) { + newitem.setButtons(aButtons); + } + + if (aNotification.telemetry) { + newitem.telemetry = aNotification.telemetry; + if (aNotification.telemetryFilter) { + newitem.telemetryFilter = aNotification.telemetryFilter; + } + } + + newitem.priority = aNotification.priority; + if (aNotification.priority == this.PRIORITY_SYSTEM) { + newitem.setAttribute("type", "system"); + } else if (aNotification.priority >= this.PRIORITY_CRITICAL_LOW) { + newitem.setAttribute("type", "critical"); + } else if (aNotification.priority <= this.PRIORITY_INFO_HIGH) { + newitem.setAttribute("type", "info"); + } else { + newitem.setAttribute("type", "warning"); + } + + // Animate the notification. + newitem.style.display = "block"; + newitem.style.position = "fixed"; + newitem.style.top = "100%"; + newitem.style.marginTop = "-15px"; + newitem.style.opacity = "0"; + + // Ensure the DOM has been created for the Lit-based notification-message + // element so that we add the .animated class + it animates as expected. + await newitem.updateComplete; + this._showNotification(newitem, true); + + // Fire event for accessibility APIs + var event = document.createEvent("Events"); + event.initEvent("AlertActive", true, true); + newitem.dispatchEvent(event); + + // If the notification is not visible, don't call shown() on the + // new notification until it is visible. This will typically be + // a tabbrowser that does this when a tab is selected. + if (this.isShown) { + newitem.shown(); + } + + return newitem; + } + + removeNotification(aItem, aSkipAnimation) { + if (!aItem.parentNode) { + return; + } + this.currentNotification = aItem; + this.removeCurrentNotification(aSkipAnimation); + } + + _removeNotificationElement(aChild) { + let hadFocus = aChild.matches(":focus-within"); + + if (aChild.eventCallback) { + aChild.eventCallback("removed"); + } + aChild.remove(); + + // Make sure focus doesn't get lost (workaround for bug 570835). + if (hadFocus) { + Services.focus.moveFocus( + window, + this.stack, + Services.focus.MOVEFOCUS_FORWARD, + 0 + ); + } + } + + removeCurrentNotification(aSkipAnimation) { + this._showNotification(this.currentNotification, false, aSkipAnimation); + } + + removeAllNotifications(aImmediate) { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + if (aImmediate) { + this._removeNotificationElement(notifications[n]); + } else { + this.removeNotification(notifications[n]); + } + } + this.currentNotification = null; + + // Clean up any currently-animating notification; this is necessary + // if a notification was just opened and is still animating, but we + // want to close it *without* animating. This can even happen if + // animations get disabled (via prefers-reduced-motion) and this method + // is called immediately after an animated notification was displayed + // (although this case isn't very likely). + if (aImmediate || !this._allowAnimation) { + this._finishAnimation(); + } + } + + removeTransientNotifications() { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + var notification = notifications[n]; + if (notification.persistence) { + notification.persistence--; + } else if (Date.now() > notification.timeout) { + this.removeNotification(notification, true); + } + } + } + + shown() { + for (let notification of this.allNotifications) { + notification.shown(); + } + } + + get isShown() { + let stack = this.stack; + let parent = this.stack.parentNode; + if (parent.localName == "named-deck") { + return parent.selectedViewName == stack.getAttribute("name"); + } + + return true; + } + + _showNotification(aNotification, aSlideIn, aSkipAnimation) { + this._finishAnimation(); + + let { marginTop, marginBottom } = getComputedStyle(aNotification); + let baseHeight = aNotification.getBoundingClientRect().height; + var height = + baseHeight + parseInt(marginTop, 10) + parseInt(marginBottom, 10); + var skipAnimation = + aSkipAnimation || baseHeight == 0 || !this._allowAnimation; + aNotification.classList.toggle("animated", !skipAnimation); + + if (aSlideIn) { + this.currentNotification = aNotification; + aNotification.style.removeProperty("display"); + aNotification.style.removeProperty("position"); + aNotification.style.removeProperty("top"); + aNotification.style.removeProperty("margin-top"); + aNotification.style.removeProperty("opacity"); + + if (skipAnimation) { + return; + } + } else { + this._closedNotification = aNotification; + var notifications = this.allNotifications; + var idx = notifications.length - 1; + this.currentNotification = idx >= 0 ? notifications[idx] : null; + + if (skipAnimation) { + this._removeNotificationElement(this._closedNotification); + delete this._closedNotification; + return; + } + + aNotification.style.marginTop = -height + "px"; + aNotification.style.opacity = 0; + } + + this._animating = true; + } + + _finishAnimation() { + if (this._animating) { + this._animating = false; + if (this._closedNotification) { + this._removeNotificationElement(this._closedNotification); + delete this._closedNotification; + } + } + } + }; + + // These are defined on the instance prototype for backwards compatibility. + Object.assign(MozElements.NotificationBox.prototype, { + PRIORITY_SYSTEM: 0, + PRIORITY_INFO_LOW: 1, + PRIORITY_INFO_MEDIUM: 2, + PRIORITY_INFO_HIGH: 3, + PRIORITY_WARNING_LOW: 4, + PRIORITY_WARNING_MEDIUM: 5, + PRIORITY_WARNING_HIGH: 6, + PRIORITY_CRITICAL_LOW: 7, + PRIORITY_CRITICAL_MEDIUM: 8, + PRIORITY_CRITICAL_HIGH: 9, + }); + + MozElements.Notification = class Notification extends MozXULElement { + static get markup() { + return ` + <hbox class="messageDetails" align="center" flex="1" + oncommand="this.parentNode._doButtonCommand(event);"> + <image class="messageImage"/> + <description class="messageText" flex="1"/> + <spacer flex="1"/> + </hbox> + <toolbarbutton ondblclick="event.stopPropagation();" + class="messageCloseButton close-icon tabbable" + data-l10n-id="close-notification-message" + oncommand="this.parentNode.dismiss();"/> + `; + } + + constructor() { + super(); + this.persistence = 0; + this.priority = 0; + this.timeout = 0; + this.telemetry = null; + this._shown = false; + } + + connectedCallback() { + MozXULElement.insertFTLIfNeeded("toolkit/global/notification.ftl"); + this.appendChild(this.constructor.fragment); + + for (let [propertyName, selector] of [ + ["messageDetails", ".messageDetails"], + ["messageImage", ".messageImage"], + ["messageText", ".messageText"], + ["spacer", "spacer"], + ["buttonContainer", ".messageDetails"], + ["closeButton", ".messageCloseButton"], + ]) { + this[propertyName] = this.querySelector(selector); + } + } + + disconnectedCallback() { + if (this.eventCallback) { + this.eventCallback("disconnected"); + } + } + + setButtons(aButtons) { + for (let button of aButtons) { + let buttonElem; + + let link = button.link; + let localeId = button["l10n-id"]; + if (!link && button.supportPage) { + link = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + button.supportPage; + if (!button.label && !localeId) { + localeId = "notification-learnmore-default-label"; + } + } + + if (link) { + buttonElem = document.createXULElement("label", { + is: "text-link", + }); + buttonElem.setAttribute("href", link); + buttonElem.classList.add("notification-link"); + buttonElem.onclick = (...args) => this._doButtonCommand(...args); + } else { + buttonElem = document.createXULElement( + "button", + button.is ? { is: button.is } : {} + ); + buttonElem.classList.add("notification-button"); + + if (button.primary) { + buttonElem.classList.add("primary"); + } + } + + if (localeId) { + document.l10n.setAttributes(buttonElem, localeId); + } else { + buttonElem.setAttribute(link ? "value" : "label", button.label); + if (typeof button.accessKey == "string") { + buttonElem.setAttribute("accesskey", button.accessKey); + } + } + + if (link) { + this.messageText.appendChild(buttonElem); + } else { + this.messageDetails.appendChild(buttonElem); + } + buttonElem.buttonInfo = button; + } + } + + get control() { + return this.closest(".notificationbox-stack")._notificationBox; + } + + /** + * Changes the text of an existing notification. If the notification was + * created with a custom fragment, it will be overwritten with plain text + * or a localized message. + * + * @param {string | { "l10n-id": string, "l10n-args"?: string }} value + */ + set label(value) { + if (value && typeof value == "object" && "l10n-id" in value) { + const message = document.createElement("span"); + document.l10n.setAttributes( + message, + value["l10n-id"], + value["l10n-args"] + ); + while (this.messageText.firstChild) { + this.messageText.firstChild.remove(); + } + this.messageText.appendChild(message); + } else { + this.messageText.textContent = value; + } + } + + /** + * This method should only be called when the user has manually closed the + * notification. If you want to programmatically close the notification, you + * should call close() instead. + */ + dismiss() { + this._doTelemetry("dismissed"); + + if (this.eventCallback) { + this.eventCallback("dismissed"); + } + this.close(); + } + + close() { + if (!this.parentNode) { + return; + } + this.control.removeNotification(this); + } + + // This will be called when the host (such as a tabbrowser) determines that + // the notification is made visible to the user. + shown() { + if (!this._shown) { + this._shown = true; + this._doTelemetry("shown"); + } + } + + _doTelemetry(type) { + if ( + this.telemetry && + (!this.telemetryFilter || this.telemetryFilter.includes(type)) + ) { + Services.telemetry.keyedScalarAdd(this.telemetry, type, 1); + } + } + + _doButtonCommand(event) { + if (!("buttonInfo" in event.target)) { + return; + } + + var button = event.target.buttonInfo; + this._doTelemetry(button.telemetry || "action"); + + if (button.popup) { + document + .getElementById(button.popup) + .openPopup( + event.originalTarget, + "after_start", + 0, + 0, + false, + false, + event + ); + event.stopPropagation(); + } else { + var callback = button.callback; + if (callback) { + var result = callback(this, button, event.target, event); + if (!result) { + this.close(); + } + event.stopPropagation(); + } + } + } + }; + + customElements.define("notification", MozElements.Notification); + + async function createNotificationMessageElement() { + await window.ensureCustomElements("moz-message-bar"); + let MozMessageBar = customElements.get("moz-message-bar"); + class NotificationMessage extends MozMessageBar { + static queries = { + ...MozMessageBar.queries, + messageText: ".message", + messageImage: ".icon", + }; + + constructor() { + super(); + this.persistence = 0; + this.priority = 0; + this.timeout = 0; + this.telemetry = null; + this.dismissable = true; + this._shown = false; + + this.addEventListener("click", this); + this.addEventListener("command", this); + } + + connectedCallback() { + super.connectedCallback(); + this.#setStyles(); + + this.classList.add("infobar"); + this.setAlertRole(); + + this.buttonContainer = document.createElement("span"); + this.buttonContainer.classList.add("notification-button-container"); + this.buttonContainer.setAttribute("slot", "actions"); + this.appendChild(this.buttonContainer); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.eventCallback) { + this.eventCallback("disconnected"); + } + } + + #setStyles() { + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://global/content/elements/infobar.css"; + this.renderRoot.append(style); + } + + _doTelemetry(type) { + if ( + this.telemetry && + (!this.telemetryFilter || this.telemetryFilter.includes(type)) + ) { + Services.telemetry.keyedScalarAdd(this.telemetry, type, 1); + } + } + + get control() { + return this.closest(".notificationbox-stack")._notificationBox; + } + + close() { + if (!this.parentNode) { + return; + } + this.control.removeNotification(this); + } + + // This will be called when the host (such as a tabbrowser) determines that + // the notification is made visible to the user. + shown() { + if (!this._shown) { + this._shown = true; + this._doTelemetry("shown"); + } + } + + setAlertRole() { + // Wait a little for this to render before setting the role for more + // consistent alerts to screen readers. + this.removeAttribute("role"); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + this.setAttribute("role", "alert"); + }); + }); + } + + handleEvent(e) { + if (e.type == "click" && e.target.localName != "label") { + return; + } + + if ("buttonInfo" in e.target) { + let { buttonInfo } = e.target; + let { callback, popup } = buttonInfo; + + this._doTelemetry(buttonInfo.telemetry || "action"); + + if (popup) { + document + .getElementById(popup) + .openPopup( + e.originalTarget, + "after_start", + 0, + 0, + false, + false, + e + ); + e.stopPropagation(); + } else if (callback) { + if (!callback(this, buttonInfo, e.target, e)) { + this.close(); + } + e.stopPropagation(); + } + } + } + + /** + * Changes the text of an existing notification. If the notification was + * created with a custom fragment, it will be overwritten with plain text + * or a localized message. + * + * @param {string | { "l10n-id": string, "l10n-args"?: string }} value + */ + set label(value) { + if (value && typeof value == "object" && "l10n-id" in value) { + this.messageL10nId = value["l10n-id"]; + this.messageL10nArgs = value["l10n-args"]; + } else { + this.message = value; + } + this.setAlertRole(); + } + + setButtons(buttons) { + this._buttons = buttons; + for (let button of buttons) { + let link = button.link || button.supportPage; + let localeId = button["l10n-id"]; + + let buttonElem; + if (button.hasOwnProperty("supportPage")) { + window.ensureCustomElements("moz-support-link"); + buttonElem = document.createElement("a", { + is: "moz-support-link", + }); + buttonElem.classList.add("notification-link"); + buttonElem.setAttribute("support-page", button.supportPage); + } else if (link) { + buttonElem = document.createXULElement("label", { + is: "text-link", + }); + buttonElem.setAttribute("href", link); + buttonElem.classList.add("notification-link", "text-link"); + } else { + buttonElem = document.createXULElement( + "button", + button.is ? { is: button.is } : {} + ); + buttonElem.classList.add( + "notification-button", + "small-button", + "footer-button" + ); + + if (button.primary) { + buttonElem.classList.add("primary"); + } + } + + if (localeId) { + document.l10n.setAttributes(buttonElem, localeId); + } else { + buttonElem.setAttribute(link ? "value" : "label", button.label); + if (typeof button.accessKey == "string") { + buttonElem.setAttribute("accesskey", button.accessKey); + } + } + + if (link) { + buttonElem.setAttribute("slot", "support-link"); + this.appendChild(buttonElem); + } else { + this.buttonContainer.appendChild(buttonElem); + } + buttonElem.buttonInfo = button; + } + } + + dismiss() { + this._doTelemetry("dismissed"); + + if (this.eventCallback) { + this.eventCallback("dismissed"); + } + super.dismiss(); + } + } + if (!customElements.get("notification-message")) { + customElements.define("notification-message", NotificationMessage); + } + } +} diff --git a/toolkit/content/widgets/panel-list/README.stories.md b/toolkit/content/widgets/panel-list/README.stories.md new file mode 100644 index 0000000000..b8800e2b5f --- /dev/null +++ b/toolkit/content/widgets/panel-list/README.stories.md @@ -0,0 +1,231 @@ +# Panel Menu + +The `panel-list` and `panel-item` components work together to create a menu for +in-content contexts. The basic structure is a `panel-list` with `panel-item` +children and optional `hr` elements as separators. The `panel-list` will anchor +itself to the target of the initiating event when opened with +`panelList.toggle(event)`. + +Note: Nested menus are not currently supported. XUL is currently required to +support accesskey underlining (although using `moz-label` could change that). +Shortcuts are not displayed automatically in the `panel-item`. + +```html story +<panel-list stay-open open> + <panel-item action="new" accesskey="N">New</panel-item> + <panel-item accesskey="O">Open</panel-item> + <hr /> + <panel-item action="save" accesskey="S">Save</panel-item> + <hr /> + <panel-item accesskey="Q">Quit</panel-item> +</panel-list> +``` + +## Status + +Current status is listed as in-development since this is only intended for use +within in-content contexts. XUL is still required for accesskey underlining, but +could be migrated to use the `moz-label` component. This is a useful but +historical element that could likely use some attention at the API level and to +be brought up to our design systems standards. + +## When to use + +* When there are multiple options for something that would take too + much space with individual buttons. +* When the actions are not frequently needed. +* When you are within an in-content context. + +## When not to use + +* When there is only one action. +* When the actions are frequently needed. +* In the browser chrome, you probably want to use + [menupopup](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/menupopup.js) + or + [panel](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/panel.js) + instead. + +## Basic usage + +The source for `panel-list` can be found under +[toolkit/content/widgets/panel-list.js](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/panel-list.js). +You can find an examples of `panel-list` in use in the Firefox codebase in both +[about:addons](https://searchfox.org/mozilla-central/source/toolkit/mozapps/extensions/content/aboutaddons.html#87,102,114) +and the +[migration-wizard](https://searchfox.org/mozilla-central/source/browser/components/migration/content/migration-dialog-window.html#18). + +`panel-list` will automatically be imported in chrome documents, both through +markup and through JS with `document.createElement("panel-list")` or by cloning +a template. + +```html +<!-- This will import `panel-list` if needed in most cases. --> +<panel-list></panel-list> +``` + +In non-chrome documents it can be imported into `.html`/`.xhtml` files: + +```html +<script src="chrome://global/content/elements/panel-list.js"></script> +``` + +And used as follows: + +```html +<panel-list> + <panel-item accesskey="N">New</panel-item> + <panel-item accesskey="O">Open</panel-item> + <hr /> + <panel-item accesskey="S">Save</panel-item> + <hr /> + <panel-item accesskey="Q">Quit</panel-item> +</panel-list> +``` + +The `toggle` method takes the event you received on your anchor button and opens +the menu attached to that element. + +```js +anchorButton.addEventListener("mousedown", e => panelList.toggle(e)); +``` + +Accesskeys are activated with the bare accesskey letter when the menu is opened. +So for this example after opening the menu pressing `s` will fire a click event +on the Save `panel-item`. + +Note: XUL is currently required for accesskey underlining, but can be [replaced +with `moz-label`](https://bugzilla.mozilla.org/show_bug.cgi?id=1828741) later. + +### Fluent usage + +The `panel-item` expects to have text content set by fluent. + +```html +<panel-list> + <panel-item data-l10n-id="menu-new"></panel-item> + <panel-item data-l10n-id="menu-save"></panel-item> +</panel-list> +``` + +In which case your Fluent messages will look something like this: + +``` +menu-new = New + .accesskey = N +menu-save = Save + .accesskey = S +``` + +## Advanced usage + +### Showing the menu + +By default the menu will be hidden. It is shown when the `open` attribute is +set, but that won't position the menu by default. + +To trigger the auto-positioning of the menu, it should be opened or closed using +the `toggle(event)` method. + +```js +function onMenuButton(event) { + document.querySelector("panel-list").toggle(event); +} +``` + +The `toggle(event)` method will use `event.target` as the anchor for the menu. + +To achieve the expected behaviour, the menu should open on `mousedown` for mouse +events, and `click` for keyboard events. This can be accomplished by checking +the `event.inputSource` property in chrome contexts or `event.detail` in +non-chrome contexts (`event.detail` will be the click count which is `0` when a +click is from the keyboard). + +```js +function openMenu(event) { + if ( + event.type == "mousedown" || + event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD || + !event.detail + ) { + document.querySelector("panel-list").toggle(event); + } +} + +let menuButton = document.getElementById("open-menu-button"); +menuButton.addEventListener("mousedown", openMenu); +menuButton.addEventListener("click", openMenu); +``` + +### Icons + +Icons can be added to the `panel-item`s by setting a `background-image` on +`panel-item::part(button)`. + +```css +panel-item[action="new"]::part(button) { + background-image: url("./new.svg"); +} + +panel-item[action="save"]::part(button) { + background-image: url("./save.svg"); +} +``` + +### Badging + +Icons may be badged by setting the `badged` attribute. This adds a dot next to +the icon. + +```html +<panel-list> + <panel-item action="new">New</panel-item> + <panel-item action="save" badged>Save</panel-item> +</panel-list> +``` + +```html story +<panel-list stay-open open> + <panel-item action="new">New</panel-item> + <panel-item action="save" badged>Save</panel-item> +</panel-list> +``` + +### Matching anchor width + +When using the `panel-list` like a `select` dropdown, it's nice to have it match +the size of the anchor button. You can see this in practice in the +[Wide variant](?path=/story/ui-widgets-panel-list--wide) and the +`migration-wizard`. Setting the `min-width-from-anchor` attribute will cause the +menu to match its anchor's width when it is opened. + +```html +<button class="current-selection">Apples</button> +<panel-list min-width-from-anchor> + <panel-item>Apples</panel-list> + <panel-item>Bananas</panel-list> +</panel-list> +``` + +### Usage in a XUL `panel` + +The "new" (as of early 2023) migration wizard uses the `panel-list` inside of a +XUL `panel` element to let its contents escape its container dialog by creating +an OS-level window. This can be useful if the menu could be larger than its +container, however in chrome contexts you are likely better off using +`menupopup`. + +By placing a `panel-list` inside of a XUL `panel` it will automatically defer +its positioning responsibilities to the XUL `panel` and it will then be able to +grow larger than its containing window if needed. + +```html +<!-- Assuming we're in a XUL document. --> +<panel> + <html:panel-list> + <html:panel-item>Apples</html:panel-item> + <html:panel-item>Apples</html:panel-item> + <html:panel-item>Apples</html:panel-item> + </html:panel-list> +</panel> +``` diff --git a/toolkit/content/widgets/panel-list/panel-item.css b/toolkit/content/widgets/panel-list/panel-item.css new file mode 100644 index 0000000000..28ff8a072f --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-item.css @@ -0,0 +1,96 @@ +/* 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/. */ + +:host(:not([hidden])) { + display: flex; + align-items: center; +} + +::slotted(a) { + margin-inline-end: 12px; +} + +:host button { + -moz-context-properties: fill; + fill: currentColor; +} + +:host([checked]) button { + background-image: url("chrome://global/skin/icons/check.svg"); +} + +button { + background-color: transparent; + color: inherit; + background-position: 8px center; + background-repeat: no-repeat; + background-size: 16px; + border: none; + position: relative; + display: block; + font: inherit; + padding: 4px 8px; + padding-inline-start: 32px; + text-align: start; + width: 100%; +} + +button:dir(rtl), +button:-moz-locale-dir(rtl) { + background-position-x: right 8px; +} + +:host([badged]) button::after { + content: ""; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + background-color: var(--in-content-accent-color); + position: absolute; + top: 4px; + inset-inline-start: 24px; +} + +button:enabled:hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +button:enabled:hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +button:focus-visible { + outline-offset: var(--in-content-focus-outline-inset); +} + +button:disabled { + opacity: 0.4; +} + +.submenu-container { + display: flex; + flex-direction: row; +} + +.submenu-icon { + display: inline-block; + background-image: url("chrome://global/skin/icons/arrow-right.svg"); + background-position: center center; + background-repeat: no-repeat; + fill: currentColor; + width: var(--size-item-small); + height: var(--size-item-small); + flex: 1 1 auto; + + &:dir(rtl) { + background-image: url("chrome://global/skin/icons/arrow-left.svg"); + } +} + +.submenu-label { + flex: 90% 1 0; +} diff --git a/toolkit/content/widgets/panel-list/panel-list.css b/toolkit/content/widgets/panel-list/panel-list.css new file mode 100644 index 0000000000..4358fc0cf8 --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-list.css @@ -0,0 +1,59 @@ +/* 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/. */ + +:host([showing]) { + visibility: hidden; +} + +:host(:not([open])) { + display: none; +} + +:host { + position: absolute; + font: menu; + background-color: var(--in-content-box-background); + border-radius: 4px; + padding: 6px 0; + margin-bottom: 16px; + box-shadow: var(--shadow-30); + min-width: 12em; + z-index: var(--z-index-popup, 10); + white-space: nowrap; + cursor: default; + overflow-y: auto; + box-sizing: border-box; +} + +:host(:not([slot=submenu])) { + max-height: 100%; +} + +:host([stay-open]) { + position: initial; + display: inline-block; +} + +:host([inxulpanel]) { + position: static; + margin: 0; +} + +:host(:not([inxulpanel])) { + border: 1px solid color-mix(in srgb, currentColor 20%, transparent); +} + +.list { + margin: 0; + padding: 0; +} + +::slotted(hr:not([hidden])) { + display: block !important; + height: 1px !important; + background: var(--in-content-box-border-color) !important; + padding: 0 !important; + margin: 6px 0 !important; + border: none !important; +} diff --git a/toolkit/content/widgets/panel-list/panel-list.js b/toolkit/content/widgets/panel-list/panel-list.js new file mode 100644 index 0000000000..1cc1f865c3 --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-list.js @@ -0,0 +1,836 @@ +/* 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"; + +{ + class PanelList extends HTMLElement { + static get observedAttributes() { + return ["open"]; + } + + static get fragment() { + if (!this._template) { + let parser = new DOMParser(); + let cssPath = "chrome://global/content/elements/panel-list.css"; + let doc = parser.parseFromString( + ` + <template> + <link rel="stylesheet" href=${cssPath}> + <div class="arrow top" role="presentation"></div> + <div class="list" role="presentation"> + <slot></slot> + </div> + <div class="arrow bottom" role="presentation"></div> + </template> + `, + "text/html" + ); + this._template = document.importNode( + doc.querySelector("template"), + true + ); + } + return this._template.content.cloneNode(true); + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.constructor.fragment); + } + + connectedCallback() { + this.setAttribute("role", "menu"); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "open" && newVal != oldVal) { + if (this.open) { + this.onShow(); + } else { + this.onHide(); + } + } + } + + get open() { + return this.hasAttribute("open"); + } + + set open(val) { + this.toggleAttribute("open", val); + } + + get stayOpen() { + return this.hasAttribute("stay-open"); + } + + set stayOpen(val) { + this.toggleAttribute("stay-open", val); + } + + getTargetForEvent(event) { + if (!event) { + return null; + } + if (event._savedComposedTarget) { + return event._savedComposedTarget; + } + if (event.composed) { + event._savedComposedTarget = + event.composedTarget || event.composedPath()[0]; + } + return event._savedComposedTarget || event.target; + } + + show(triggeringEvent, target) { + this.triggeringEvent = triggeringEvent; + this.lastAnchorNode = + target || this.getTargetForEvent(this.triggeringEvent); + + this.wasOpenedByKeyboard = + triggeringEvent && + (triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD || + triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_UNKNOWN || + triggeringEvent.code == "ArrowRight" || + triggeringEvent.code == "ArrowLeft"); + this.open = true; + + if (this.parentIsXULPanel()) { + this.toggleAttribute("inxulpanel", true); + let panel = this.parentElement; + panel.hidden = false; + // Bug 1842070 - There appears to be a race here where panel-lists + // embedded in XUL panels won't appear during the first call to show() + // without waiting for a mix of rAF and another tick of the event + // loop. + requestAnimationFrame(() => { + setTimeout(() => { + panel.openPopup( + this.lastAnchorNode, + "after_start", + 0, + 0, + false, + false, + this.triggeringEvent + ); + }, 0); + }); + } else { + this.toggleAttribute("inxulpanel", false); + } + } + + hide(triggeringEvent, { force = false } = {}, eventTarget) { + // It's possible this is being used in an unprivileged context, in which + // case it won't have access to Services / Services will be undeclared. + const autohideDisabled = this.hasServices() + ? Services.prefs.getBoolPref("ui.popup.disable_autohide", false) + : false; + + if (autohideDisabled && !force) { + // Don't hide if this wasn't "forced" (using escape or click in menu). + return; + } + let openingEvent = this.triggeringEvent; + this.triggeringEvent = triggeringEvent; + this.open = false; + + if (this.parentIsXULPanel()) { + // It's possible that we're being programattically hidden, in which + // case, we need to hide the XUL panel we're embedded in. If, however, + // we're being hidden because the XUL panel is being hidden, calling + // hidePopup again on it is a no-op. + let panel = this.parentElement; + panel.hidePopup(); + } + + let target = eventTarget || this.getTargetForEvent(openingEvent); + // Refocus the button that opened the menu if we have one. + if (target && this.wasOpenedByKeyboard) { + target.focus(); + } + } + + toggle(triggeringEvent, target = null) { + if (this.open) { + this.hide(triggeringEvent, { force: true }, target); + } else { + this.show(triggeringEvent, target); + } + } + + hasServices() { + // Safely check for Services without throwing a ReferenceError. + return typeof Services !== "undefined"; + } + + isDocumentRTL() { + if (this.hasServices()) { + return Services.locale.isAppLocaleRTL; + } + return document.dir === "rtl"; + } + + parentIsXULPanel() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + return ( + this.parentElement?.namespaceURI == XUL_NS && + this.parentElement?.localName == "panel" + ); + } + + async setAlign() { + const hostElement = this.parentElement || this.getRootNode().host; + if (!hostElement) { + // This could get called before we're added to the DOM. + // Nothing to do in that case. + return; + } + + // Set the showing attribute to hide the panel until its alignment is set. + this.setAttribute("showing", "true"); + // Tell the host element to hide any overflow in case the panel extends off + // the page before the alignment is set. + hostElement.style.overflow = "hidden"; + + // Wait for a layout flush, then find the bounds. + let { + anchorBottom, // distance from the bottom of the anchor el to top of viewport. + anchorLeft, + anchorTop, + anchorWidth, + panelHeight, + panelWidth, + winHeight, + winScrollY, + winScrollX, + clientWidth, + } = await new Promise(resolve => { + this.style.left = 0; + this.style.top = 0; + + requestAnimationFrame(() => + setTimeout(() => { + let target = this.getTargetForEvent(this.triggeringEvent); + let anchorElement = target || hostElement; + // It's possible this is being used in a context where windowUtils is + // not available. In that case, fallback to using the element. + let getBounds = el => + window.windowUtils + ? window.windowUtils.getBoundsWithoutFlushing(el) + : el.getBoundingClientRect(); + // Use y since top is reserved. + let anchorBounds = getBounds(anchorElement); + let panelBounds = getBounds(this); + let clientWidth = document.scrollingElement.clientWidth; + + resolve({ + anchorBottom: anchorBounds.bottom, + anchorHeight: anchorBounds.height, + anchorLeft: anchorBounds.left, + anchorTop: anchorBounds.top, + anchorWidth: anchorBounds.width, + panelHeight: panelBounds.height, + panelWidth: panelBounds.width, + winHeight: innerHeight, + winScrollX: scrollX, + winScrollY: scrollY, + clientWidth, + }); + }, 0) + ); + }); + + // If we're embedded in a XUL panel, let it handle alignment. + if (!this.parentIsXULPanel()) { + // Calculate the left/right alignment. + let align; + let leftOffset; + let leftAlignX = anchorLeft; + let rightAlignX = anchorLeft + anchorWidth - panelWidth; + + if (this.isDocumentRTL()) { + // Prefer aligning on the right. + align = rightAlignX < 0 ? "left" : "right"; + } else { + // Prefer aligning on the left. + align = leftAlignX + panelWidth > clientWidth ? "right" : "left"; + } + leftOffset = align === "left" ? leftAlignX : rightAlignX; + + let bottomSpaceY = winHeight - anchorBottom; + + let valign; + let topOffset; + const VIEWPORT_PANEL_MIN_MARGIN = 10; // 10px ensures that the panel is not flush with the viewport. + + // Only want to valign top when there's more space between the bottom of the anchor element and the top of the viewport. + // If there's more space between the bottom of the anchor element and the bottom of the viewport, we valign bottom. + if ( + anchorBottom > bottomSpaceY && + anchorBottom + panelHeight > winHeight + ) { + // Never want to have a negative value for topOffset, so ensure it's at least 10px. + topOffset = Math.max( + anchorTop - panelHeight, + VIEWPORT_PANEL_MIN_MARGIN + ); + // Provide a max-height for larger elements which will provide scrolling as needed. + this.style.maxHeight = `${anchorTop + VIEWPORT_PANEL_MIN_MARGIN}px`; + valign = "top"; + } else { + topOffset = anchorBottom; + this.style.maxHeight = `${ + bottomSpaceY - VIEWPORT_PANEL_MIN_MARGIN + }px`; + valign = "bottom"; + } + + // Set the alignments and show the panel. + this.setAttribute("align", align); + this.setAttribute("valign", valign); + hostElement.style.overflow = ""; + + this.style.left = `${leftOffset + winScrollX}px`; + this.style.top = `${topOffset + winScrollY}px`; + } + + this.style.minWidth = this.hasAttribute("min-width-from-anchor") + ? `${anchorWidth}px` + : ""; + + this.removeAttribute("showing"); + } + + addHideListeners() { + if (this.hasAttribute("stay-open") && !this.lastAnchorNode.hasSubmenu) { + // This is intended for inspection in Storybook. + return; + } + // Hide when a panel-item is clicked in the list. + this.addEventListener("click", this); + // Allows submenus to stopPropagation when focus is already in the menu + this.addEventListener("keydown", this); + // We need Escape/Tab/ArrowDown to work when opened with the mouse. + document.addEventListener("keydown", this); + // Hide when a click is initiated outside the panel. + document.addEventListener("mousedown", this); + // Hide if focus changes and the panel isn't in focus. + document.addEventListener("focusin", this); + // Reset or focus tracking, we treat the first focusin differently. + this.focusHasChanged = false; + // Hide on resize, scroll or losing window focus. + window.addEventListener("resize", this); + window.addEventListener("scroll", this, { capture: true }); + window.addEventListener("blur", this); + if (this.parentIsXULPanel()) { + this.parentElement.addEventListener("popuphidden", this); + } + } + + removeHideListeners() { + this.removeEventListener("click", this); + this.removeEventListener("keydown", this); + document.removeEventListener("keydown", this); + document.removeEventListener("mousedown", this); + document.removeEventListener("focusin", this); + window.removeEventListener("resize", this); + window.removeEventListener("scroll", this, { capture: true }); + window.removeEventListener("blur", this); + if (this.parentIsXULPanel()) { + this.parentElement.removeEventListener("popuphidden", this); + } + } + + handleEvent(e) { + // Ignore the event if it caused the panel to open. + if (e == this.triggeringEvent) { + return; + } + + let target = this.getTargetForEvent(e); + let inPanelList = e.composed + ? e.composedPath().some(el => el == this) + : e.target.closest && e.target.closest("panel-list") == this; + + switch (e.type) { + case "resize": + case "scroll": + if (inPanelList) { + break; + } + // Intentional fall-through + case "blur": + case "popuphidden": + this.hide(); + break; + case "click": + if (inPanelList) { + this.hide(undefined, { force: true }); + } else { + // Avoid falling through to the default click handler of the parent. + e.stopPropagation(); + } + break; + case "mousedown": + // Close if there's a click started outside the panel. + if (!inPanelList) { + this.hide(); + } + break; + case "keydown": + if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab") { + // Ignore tabbing with a modifer other than shift. + if (e.key === "Tab" && (e.altKey || e.ctrlKey || e.metaKey)) { + return; + } + + // Don't scroll the page or let the regular tab order take effect. + e.preventDefault(); + + // Prevents the host panel list from responding to these events while + // the submenu is active. + e.stopPropagation(); + + // Keep moving to the next/previous element sibling until we find a + // panel-item that isn't hidden. + let moveForward = + e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey); + + let nextItem = moveForward + ? this.focusWalker.nextNode() + : this.focusWalker.previousNode(); + + // If the next item wasn't found, try looping to the top/bottom. + if (!nextItem) { + this.focusWalker.currentNode = this; + if (moveForward) { + nextItem = this.focusWalker.firstChild(); + } else { + nextItem = this.focusWalker.lastChild(); + } + } + break; + } else if (e.key === "Escape") { + this.hide(undefined, { force: true }); + } else if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) { + // Check if any of the children have an accesskey for this letter. + let item = this.querySelector( + `[accesskey="${e.key.toLowerCase()}"], + [accesskey="${e.key.toUpperCase()}"]` + ); + if (item) { + item.click(); + } + } + break; + case "focusin": + if ( + this.triggeringEvent && + target == this.getTargetForEvent(this.triggeringEvent) && + !this.focusHasChanged + ) { + // There will be a focusin after the mousedown that opens the panel + // using the mouse. Ignore the first focusin event if it's on the + // triggering target. + this.focusHasChanged = true; + } else if (!target || !inPanelList) { + // If the target isn't in the panel, hide. This will close when focus + // moves out of the panel. + this.hide(); + } else { + // Just record that there was a focusin event. + this.focusHasChanged = true; + } + break; + } + } + + /** + * A TreeWalker that can be used to focus elements. The returned element will + * be the element that has gained focus based on the requested movement + * through the tree. + * + * Example: + * + * this.focusWalker.currentNode = this; + * // Focus and get the first focusable child. + * let focused = this.focusWalker.nextNode(); + * // Focus the second focusable child. + * this.focusWalker.nextNode(); + */ + get focusWalker() { + if (!this._focusWalker) { + this._focusWalker = document.createTreeWalker( + this, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => { + // No need to look at hidden nodes. + if (node.hidden) { + return NodeFilter.FILTER_REJECT; + } + + // Focus the node, if it worked then this is the node we want. + node.focus(); + if (node === node.getRootNode().activeElement) { + return NodeFilter.FILTER_ACCEPT; + } + + // Continue into child nodes if the parent couldn't be focused. + return NodeFilter.FILTER_SKIP; + }, + } + ); + } + return this._focusWalker; + } + async setSubmenuAlign() { + const hostElement = + this.lastAnchorNode.parentElement || this.getRootNode().host; + // The showing attribute allows layout of the panel while remaining hidden + // from the user until alignment is set. + this.setAttribute("showing", "true"); + + // Wait for a layout flush, then find the bounds. + let { + anchorLeft, + anchorWidth, + anchorTop, + parentPanelTop, + panelWidth, + clientWidth, + } = await new Promise(resolve => { + requestAnimationFrame(() => { + // It's possible this is being used in a context where windowUtils is + // not available. In that case, fallback to using the element. + let getBounds = el => + window.windowUtils + ? window.windowUtils.getBoundsWithoutFlushing(el) + : el.getBoundingClientRect(); + // submenu item in the parent panel list + let anchorBounds = getBounds(this.lastAnchorNode); + let parentPanelBounds = getBounds(hostElement); + let panelBounds = getBounds(this); + let clientWidth = document.scrollingElement.clientWidth; + + resolve({ + anchorLeft: anchorBounds.left, + anchorWidth: anchorBounds.width, + anchorTop: anchorBounds.top, + parentPanelTop: parentPanelBounds.top, + panelWidth: panelBounds.width, + clientWidth, + }); + }); + }); + + let align = hostElement.getAttribute("align"); + + // we use document.scrollingElement.clientWidth to exclude the width + // of vertical scrollbars, because its inclusion can cause the submenu + // to open to the wrong side and be overlapped by the scrollbar. + if ( + align == "left" && + anchorLeft + anchorWidth + panelWidth < clientWidth + ) { + this.style.left = `${anchorWidth}px`; + this.style.right = ""; + } else { + this.style.right = `${anchorWidth}px`; + this.style.left = ""; + } + + let topOffset = + anchorTop - + parentPanelTop - + (parseFloat(window.getComputedStyle(this)?.paddingTop) || 0); + this.style.top = `${topOffset}px`; + + this.removeAttribute("showing"); + } + + async onShow() { + this.sendEvent("showing"); + this.addHideListeners(); + + if (this.lastAnchorNode?.hasSubmenu) { + await this.setSubmenuAlign(); + } else { + await this.setAlign(); + } + + // Always reset this regardless of how the panel list is opened + // so the first child will be focusable. + this.focusWalker.currentNode = this; + + // Wait until the next paint for the alignment to be set and panel to be + // visible. + requestAnimationFrame(() => { + if (this.wasOpenedByKeyboard) { + // Focus the first focusable panel-item if opened by keyboard. + this.focusWalker.nextNode(); + } + + this.lastAnchorNode?.setAttribute("aria-expanded", "true"); + + this.sendEvent("shown"); + }); + } + + onHide() { + requestAnimationFrame(() => { + this.sendEvent("hidden"); + this.lastAnchorNode?.setAttribute("aria-expanded", "false"); + }); + this.removeHideListeners(); + } + + sendEvent(name, detail) { + this.dispatchEvent( + new CustomEvent(name, { detail, bubbles: true, composed: true }) + ); + } + } + customElements.define("panel-list", PanelList); + + class PanelItem extends HTMLElement { + #initialized = false; + #defaultSlot; + + static get observedAttributes() { + return ["accesskey"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://global/content/elements/panel-item.css"; + + this.button = document.createElement("button"); + this.button.setAttribute("role", "menuitem"); + this.button.setAttribute("part", "button"); + // Use a XUL label element if possible to show the accesskey. + this.label = document.createXULElement + ? document.createXULElement("label") + : document.createElement("span"); + + this.button.appendChild(this.label); + + let supportLinkSlot = document.createElement("slot"); + supportLinkSlot.name = "support-link"; + + this.#defaultSlot = document.createElement("slot"); + this.#defaultSlot.style.display = "none"; + + if (this.hasSubmenu) { + this.icon = document.createElement("div"); + this.icon.setAttribute("class", "submenu-icon"); + this.label.setAttribute("class", "submenu-label"); + + this.button.setAttribute("class", "submenu-container"); + this.button.appendChild(this.icon); + + this.submenuSlot = document.createElement("slot"); + this.submenuSlot.name = "submenu"; + + this.shadowRoot.append( + style, + this.button, + this.#defaultSlot, + this.submenuSlot + ); + } else { + this.shadowRoot.append( + style, + this.button, + supportLinkSlot, + this.#defaultSlot + ); + } + } + + connectedCallback() { + if (!this._l10nRootConnected && document.l10n) { + document.l10n.connectRoot(this.shadowRoot); + this._l10nRootConnected = true; + } + + if (!this.#initialized) { + this.#initialized = true; + // When click listeners are added to the panel-item it creates a node in + // the a11y tree for this element. This breaks the association between the + // menu and the button[role="menuitem"] in this shadow DOM and causes + // announcement issues with screen readers. (bug 995064) + this.setAttribute("role", "presentation"); + + this.#setLabelContents(); + + // When our content changes, move the text into the label. It doesn't work + // with a <slot>, unfortunately. + new MutationObserver(() => this.#setLabelContents()).observe(this, { + characterData: true, + childList: true, + subtree: true, + }); + + if (this.hasSubmenu) { + this.setSubmenuContents(); + } + } + + this.panel = + this.getRootNode()?.host?.closest("panel-list") || + this.closest("panel-list"); + + if (this.panel) { + this.panel.addEventListener("hidden", this); + this.panel.addEventListener("shown", this); + } + if (this.hasSubmenu) { + this.addEventListener("mouseenter", this); + this.addEventListener("mouseleave", this); + this.addEventListener("keydown", this); + } + } + + disconnectedCallback() { + if (this._l10nRootConnected) { + document.l10n.disconnectRoot(this.shadowRoot); + this._l10nRootConnected = false; + } + + if (this.panel) { + this.panel.removeEventListener("hidden", this); + this.panel.removeEventListener("shown", this); + this.panel = null; + } + + if (this.hasSubmenu) { + this.removeEventListener("mouseenter", this); + this.removeEventListener("mouseleave", this); + this.removeEventListener("keydown", this); + } + } + + get hasSubmenu() { + return this.hasAttribute("submenu"); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name === "accesskey") { + // Bug 1037709 - Accesskey doesn't work in shadow DOM. + // Ideally we'd have the accesskey set in shadow DOM, and on + // attributeChangedCallback we'd just update the shadow DOM accesskey. + + // Skip this change event if we caused it. + if (this._modifyingAccessKey) { + this._modifyingAccessKey = false; + return; + } + + this.label.accessKey = newVal || ""; + + // Bug 1588156 - Accesskey is not ignored for hidden non-input elements. + // Since the accesskey won't be ignored, we need to remove it ourselves + // when the panel is closed, and move it back when it opens. + if (!this.panel || !this.panel.open) { + // When the panel isn't open, just store the key for later. + this._accessKey = newVal || null; + this._modifyingAccessKey = true; + this.accessKey = ""; + } else { + this._accessKey = null; + } + } + } + + #setLabelContents() { + this.label.textContent = this.#defaultSlot + .assignedNodes() + .map(node => node.textContent) + .join(""); + } + + setSubmenuContents() { + this.submenuPanel = this.submenuSlot.assignedNodes()[0]; + this.shadowRoot.append(this.submenuPanel); + } + + get disabled() { + return this.button.hasAttribute("disabled"); + } + + set disabled(val) { + this.button.toggleAttribute("disabled", val); + } + + get checked() { + return this.hasAttribute("checked"); + } + + set checked(val) { + this.toggleAttribute("checked", val); + } + + focus() { + this.button.focus(); + } + + setArrowKeyRTL() { + let arrowOpenKey = "ArrowRight"; + let arrowCloseKey = "ArrowLeft"; + + if (this.submenuPanel.isDocumentRTL()) { + arrowOpenKey = "ArrowLeft"; + arrowCloseKey = "ArrowRight"; + } + return [arrowOpenKey, arrowCloseKey]; + } + + handleEvent(e) { + // Bug 1588156 - Accesskey is not ignored for hidden non-input elements. + // Since the accesskey won't be ignored, we need to remove it ourselves + // when the panel is closed, and move it back when it opens. + switch (e.type) { + case "shown": + if (this._accessKey) { + this.accessKey = this._accessKey; + this._accessKey = null; + } + break; + case "hidden": + if (this.accessKey) { + this._accessKey = this.accessKey; + this._modifyingAccessKey = true; + this.accessKey = ""; + } + break; + case "mouseenter": + case "mouseleave": + this.submenuPanel.toggle(e); + break; + case "keydown": + let [arrowOpenKey, arrowCloseKey] = this.setArrowKeyRTL(); + if (e.key === arrowOpenKey) { + this.submenuPanel.show(e, e.target); + e.stopPropagation(); + } + if (e.key === arrowCloseKey) { + this.submenuPanel.hide(e, { force: true }, e.target); + e.stopPropagation(); + } + break; + } + } + } + customElements.define("panel-item", PanelItem); +} diff --git a/toolkit/content/widgets/panel-list/panel-list.stories.mjs b/toolkit/content/widgets/panel-list/panel-list.stories.mjs new file mode 100644 index 0000000000..9c5a4cbe1f --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-list.stories.mjs @@ -0,0 +1,147 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unassigned-import +import "./panel-list.js"; +import { html, ifDefined } from "../vendor/lit.all.mjs"; + +export default { + title: "UI Widgets/Panel List", + component: "panel-list", + parameters: { + status: "in-development", + actions: { + handles: ["showing", "shown", "hidden", "click"], + }, + fluent: ` +panel-list-item-one = Item One +panel-list-item-two = Item Two (accesskey w) +panel-list-item-three = Item Three +panel-list-checked = Checked +panel-list-badged = Badged, look at me +panel-list-passwords = Passwords +panel-list-settings = Settings + `, + }, +}; + +function openMenu(event) { + if ( + event.type == "mousedown" || + event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD || + !event.detail + ) { + event.target.getRootNode().querySelector("panel-list").toggle(event); + } +} + +const Template = ({ isOpen, items, wideAnchor }) => + html` + <style> + panel-item[icon="passwords"]::part(button) { + background-image: url("chrome://browser/skin/login.svg"); + } + panel-item[icon="settings"]::part(button) { + background-image: url("chrome://global/skin/icons/settings.svg"); + } + button { + position: absolute; + background-image: url("chrome://global/skin/icons/more.svg"); + } + button[wide] { + width: 400px !important; + } + .end { + inset-inline-end: 30px; + } + + .bottom { + inset-block-end: 30px; + } + </style> + ${isOpen + ? "" + : html` + <button + class="ghost-button icon-button" + @click=${openMenu} + @mousedown=${openMenu} + ?wide="${wideAnchor}" + ></button> + <button + class="ghost-button icon-button end" + @click=${openMenu} + @mousedown=${openMenu} + ?wide="${wideAnchor}" + ></button> + <button + class="ghost-button icon-button bottom" + @click=${openMenu} + @mousedown=${openMenu} + ?wide="${wideAnchor}" + ></button> + <button + class="ghost-button icon-button bottom end" + @click=${openMenu} + @mousedown=${openMenu} + ?wide="${wideAnchor}" + ></button> + `} + <panel-list + ?stay-open=${isOpen} + ?open=${isOpen} + ?min-width-from-anchor=${wideAnchor} + > + ${items.map(i => + i == "<hr>" + ? html` <hr /> ` + : html` + <panel-item + icon=${i.icon ?? ""} + ?checked=${i.checked} + ?badged=${i.badged} + accesskey=${ifDefined(i.accesskey)} + data-l10n-id=${i.l10nId ?? i} + ></panel-item> + ` + )} + </panel-list> + `; + +export const Simple = Template.bind({}); +Simple.args = { + isOpen: false, + wideAnchor: false, + items: [ + "panel-list-item-one", + { l10nId: "panel-list-item-two", accesskey: "w" }, + "panel-list-item-three", + "<hr>", + { l10nId: "panel-list-checked", checked: true }, + { l10nId: "panel-list-badged", badged: true, icon: "settings" }, + ], +}; + +export const Icons = Template.bind({}); +Icons.args = { + isOpen: false, + wideAnchor: false, + items: [ + { l10nId: "panel-list-passwords", icon: "passwords" }, + { l10nId: "panel-list-settings", icon: "settings" }, + ], +}; + +export const Open = Template.bind({}); +Open.args = { + ...Simple.args, + wideAnchor: false, + isOpen: true, +}; + +export const Wide = Template.bind({}); +Wide.args = { + ...Simple.args, + wideAnchor: true, +}; diff --git a/toolkit/content/widgets/panel.js b/toolkit/content/widgets/panel.js new file mode 100644 index 0000000000..a301ecb1f2 --- /dev/null +++ b/toolkit/content/widgets/panel.js @@ -0,0 +1,293 @@ +/* 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. +{ + class MozPanel extends MozElements.MozElementMixin(XULPopupElement) { + static get markup() { + return `<html:slot part="content" style="display: none !important"/>`; + } + constructor() { + super(); + + this._prevFocus = 0; + this._fadeTimer = null; + + this.attachShadow({ mode: "open" }); + + this.addEventListener("popupshowing", this); + this.addEventListener("popupshown", this); + this.addEventListener("popuphiding", this); + this.addEventListener("popuphidden", this); + this.addEventListener("popuppositioned", this); + } + + connectedCallback() { + // Create shadow DOM lazily if a panel is hidden. It helps to reduce + // cycles on startup. + if (!this.hidden) { + this.ensureInitialized(); + } + + if (this.isArrowPanel) { + if (!this.hasAttribute("flip")) { + this.setAttribute("flip", "both"); + } + if (!this.hasAttribute("side")) { + this.setAttribute("side", "top"); + } + if (!this.hasAttribute("position")) { + this.setAttribute("position", "bottomleft topleft"); + } + if (!this.hasAttribute("consumeoutsideclicks")) { + this.setAttribute("consumeoutsideclicks", "false"); + } + } + } + + ensureInitialized() { + // As an optimization, we don't slot contents if the panel is [hidden] in + // connectedCallback this means we can avoid running this code at startup + // and only need to do it when a panel is about to be shown. We then + // override the `hidden` setter and `removeAttribute` and call this + // function if the node is about to be shown. + if (this.shadowRoot.firstChild) { + return; + } + + this.shadowRoot.appendChild(this.constructor.fragment); + if (this.hasAttribute("neverhidden")) { + this.panelContent.style.display = ""; + } + } + + get panelContent() { + return this.shadowRoot.querySelector("[part=content]"); + } + + get hidden() { + return super.hidden; + } + + set hidden(v) { + if (!v) { + this.ensureInitialized(); + } + super.hidden = v; + } + + removeAttribute(name) { + if (name == "hidden") { + this.ensureInitialized(); + } + super.removeAttribute(name); + } + + get isArrowPanel() { + return this.getAttribute("type") == "arrow"; + } + + get noOpenOnAnchor() { + return this.hasAttribute("no-open-on-anchor"); + } + + _setSideAttribute(event) { + if (!this.isArrowPanel || !event.isAnchored) { + return; + } + + let position = event.alignmentPosition; + if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { + // The assigned side stays the same regardless of direction. + let isRTL = window.getComputedStyle(this).direction == "rtl"; + + if (position.indexOf("start_") == 0) { + this.setAttribute("side", isRTL ? "left" : "right"); + } else { + this.setAttribute("side", isRTL ? "right" : "left"); + } + } else if ( + position.indexOf("before_") == 0 || + position.indexOf("after_") == 0 + ) { + if (position.indexOf("before_") == 0) { + this.setAttribute("side", "bottom"); + } else { + this.setAttribute("side", "top"); + } + } + + // This method isn't implemented by panel.js, but it can be added to + // individual instances that need to show an arrow. + this.setArrowPosition?.(event); + } + + on_popupshowing(event) { + if (event.target == this) { + this.panelContent.style.display = ""; + } + if (this.isArrowPanel && event.target == this) { + if (this.anchorNode && !this.noOpenOnAnchor) { + let anchorRoot = + this.anchorNode.closest("toolbarbutton, .anchor-root") || + this.anchorNode; + anchorRoot.setAttribute("open", "true"); + } + + if (this.getAttribute("animate") != "false") { + this.setAttribute("animate", "open"); + // the animating attribute prevents user interaction during transition + // it is removed when popupshown fires + this.setAttribute("animating", "true"); + } + + // set fading + var fade = this.getAttribute("fade"); + var fadeDelay = 0; + if (fade == "fast") { + fadeDelay = 1; + } else if (fade == "slow") { + fadeDelay = 4000; + } + + if (fadeDelay != 0) { + this._fadeTimer = setTimeout( + () => this.hidePopup(true), + fadeDelay, + this + ); + } + } + + // Capture the previous focus before has a chance to get set inside the panel + try { + this._prevFocus = Cu.getWeakReference( + document.commandDispatcher.focusedElement + ); + if (!this._prevFocus.get()) { + this._prevFocus = Cu.getWeakReference(document.activeElement); + } + } catch (ex) { + this._prevFocus = Cu.getWeakReference(document.activeElement); + } + } + + on_popupshown(event) { + if (this.isArrowPanel && event.target == this) { + this.removeAttribute("animating"); + this.setAttribute("panelopen", "true"); + } + + // Fire event for accessibility APIs + let alertEvent = document.createEvent("Events"); + alertEvent.initEvent("AlertActive", true, true); + this.dispatchEvent(alertEvent); + } + + on_popuphiding(event) { + if (this.isArrowPanel && event.target == this) { + let animate = this.getAttribute("animate") != "false"; + + if (this._fadeTimer) { + clearTimeout(this._fadeTimer); + if (animate) { + this.setAttribute("animate", "fade"); + } + } else if (animate) { + this.setAttribute("animate", "cancel"); + } + + if (this.anchorNode && !this.noOpenOnAnchor) { + let anchorRoot = + this.anchorNode.closest("toolbarbutton, .anchor-root") || + this.anchorNode; + anchorRoot.removeAttribute("open"); + } + } + + try { + this._currentFocus = document.commandDispatcher.focusedElement; + } catch (e) { + this._currentFocus = document.activeElement; + } + } + + on_popuphidden(event) { + if (event.target == this && !this.hasAttribute("neverhidden")) { + this.panelContent.style.setProperty("display", "none", "important"); + } + if (this.isArrowPanel && event.target == this) { + this.removeAttribute("panelopen"); + if (this.getAttribute("animate") != "false") { + this.removeAttribute("animate"); + } + } + + function doFocus() { + // Focus was set on an element inside this panel, + // so we need to move it back to where it was previously. + // Elements can use refocused-by-panel to change their focus behaviour + // when re-focused by a panel hiding. + prevFocus.setAttribute("refocused-by-panel", true); + try { + let fm = Services.focus; + fm.setFocus(prevFocus, fm.FLAG_NOSCROLL); + } catch (e) { + prevFocus.focus(); + } + prevFocus.removeAttribute("refocused-by-panel"); + } + var currentFocus = this._currentFocus; + var prevFocus = this._prevFocus ? this._prevFocus.get() : null; + this._currentFocus = null; + this._prevFocus = null; + + // Avoid changing focus if focus changed while we hide the popup + // (This can happen e.g. if the popup is hiding as a result of a + // click/keypress that focused something) + let nowFocus; + try { + nowFocus = document.commandDispatcher.focusedElement; + } catch (e) { + nowFocus = document.activeElement; + } + if (nowFocus && nowFocus != currentFocus) { + return; + } + + if (prevFocus && this.getAttribute("norestorefocus") != "true") { + // Try to restore focus + try { + if (document.commandDispatcher.focusedWindow != window) { + // Focus has already been set to a window outside of this panel + return; + } + } catch (ex) {} + + if (!currentFocus) { + doFocus(); + return; + } + while (currentFocus) { + if (currentFocus == this) { + doFocus(); + return; + } + currentFocus = currentFocus.parentNode; + } + } + } + + on_popuppositioned(event) { + if (event.target == this) { + this._setSideAttribute(event); + } + } + } + + customElements.define("panel", MozPanel); +} diff --git a/toolkit/content/widgets/popupnotification.js b/toolkit/content/widgets/popupnotification.js new file mode 100644 index 0000000000..835151496c --- /dev/null +++ b/toolkit/content/widgets/popupnotification.js @@ -0,0 +1,169 @@ +/* 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. +{ + class MozPopupNotification extends MozXULElement { + static get inheritedAttributes() { + return { + ".popup-notification-icon": "popupid,src=icon,class=iconclass,hasicon", + ".popup-notification-body": "popupid", + ".popup-notification-origin": "value=origin,tooltiptext=origin", + ".popup-notification-description": "popupid,id=descriptionid", + ".popup-notification-description > span:first-of-type": + "text=label,popupid", + ".popup-notification-description > b:first-of-type": + "text=name,popupid", + ".popup-notification-description > span:nth-of-type(2)": + "text=endlabel,popupid", + ".popup-notification-description > b:last-of-type": + "text=secondname,popupid", + ".popup-notification-description > span:last-of-type": + "text=secondendlabel,popupid", + ".popup-notification-hint-text": "text=hinttext", + ".popup-notification-closebutton": + "oncommand=closebuttoncommand,hidden=closebuttonhidden", + ".popup-notification-learnmore-link": + "onclick=learnmoreclick,href=learnmoreurl", + ".popup-notification-warning": "hidden=warninghidden,text=warninglabel", + ".popup-notification-secondary-button": + "oncommand=secondarybuttoncommand,label=secondarybuttonlabel,accesskey=secondarybuttonaccesskey,hidden=secondarybuttonhidden,dropmarkerhidden", + ".popup-notification-dropmarker": + "onpopupshown=dropmarkerpopupshown,hidden=dropmarkerhidden", + ".popup-notification-dropmarker > menupopup": "oncommand=menucommand", + ".popup-notification-primary-button": + "oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey,default=buttonhighlight,disabled=mainactiondisabled", + }; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this._hasSlotted) { + return; + } + + // If the label and/or accesskey for the primary button is set by + // inherited attributes, its data-l10n-id needs to be unset or + // DOM Localization will overwrite the values. + if (name === "buttonlabel" || name === "buttonaccesskey") { + this.button?.removeAttribute("data-l10n-id"); + } + + super.attributeChangedCallback(name, oldValue, newValue); + } + + show() { + this.slotContents(); + + if (this.checkboxState) { + this.checkbox.checked = this.checkboxState.checked; + this.checkbox.setAttribute("label", this.checkboxState.label); + this.checkbox.hidden = false; + } else { + this.checkbox.hidden = true; + // Reset checked state to avoid wrong using of previous value. + this.checkbox.checked = false; + } + + this.hidden = false; + } + + static get markup() { + return ` + <hbox class="popup-notification-header-container"></hbox> + <hbox align="start" class="popup-notification-body-container"> + <image class="popup-notification-icon"/> + <vbox pack="start" class="popup-notification-body"> + <hbox align="start"> + <vbox flex="1"> + <label class="popup-notification-origin header" crop="center"></label> + <!-- These need to be on the same line to avoid creating + whitespace between them (whitespace is added in the + localization file, if necessary). --> + <description class="popup-notification-description"><html:span></html:span><html:b></html:b><html:span></html:span><html:b></html:b><html:span></html:span></description> + <description class="popup-notification-hint-text"></description> + </vbox> + <toolbarbutton class="messageCloseButton close-icon popup-notification-closebutton tabbable" data-l10n-id="close-notification-message"></toolbarbutton> + </hbox> + <vbox class="popup-notification-bottom-content" align="start"> + <label class="popup-notification-learnmore-link" is="text-link" data-l10n-id="popup-notification-learn-more"></label> + <checkbox class="popup-notification-checkbox" oncommand="PopupNotifications._onCheckboxCommand(event)"/> + <description class="popup-notification-warning"/> + </vbox> + </vbox> + </hbox> + <hbox class="popup-notification-footer-container"></hbox> + <html:moz-button-group class="panel-footer"> + <button class="popup-notification-secondary-button footer-button"/> + <button type="menu" class="popup-notification-dropmarker footer-button" data-l10n-id="popup-notification-more-actions-button"> + <menupopup position="after_end" data-l10n-id="popup-notification-more-actions-button"> + </menupopup> + </button> + <button class="popup-notification-primary-button primary footer-button" data-l10n-id="popup-notification-default-button"/> + </html:moz-button-group> + `; + } + + slotContents() { + if (this._hasSlotted) { + return; + } + this._hasSlotted = true; + MozXULElement.insertFTLIfNeeded("toolkit/global/notification.ftl"); + MozXULElement.insertFTLIfNeeded("toolkit/global/popupnotification.ftl"); + this.appendChild(this.constructor.fragment); + + window.ensureCustomElements("moz-button-group"); + + this.button = this.querySelector(".popup-notification-primary-button"); + if ( + this.hasAttribute("buttonlabel") || + this.hasAttribute("buttonaccesskey") + ) { + this.button.removeAttribute("data-l10n-id"); + } + this.secondaryButton = this.querySelector( + ".popup-notification-secondary-button" + ); + this.checkbox = this.querySelector(".popup-notification-checkbox"); + this.closebutton = this.querySelector(".popup-notification-closebutton"); + this.menubutton = this.querySelector(".popup-notification-dropmarker"); + this.menupopup = this.menubutton.querySelector("menupopup"); + + let popupnotificationfooter = this.querySelector( + "popupnotificationfooter" + ); + if (popupnotificationfooter) { + this.querySelector(".popup-notification-footer-container").append( + popupnotificationfooter + ); + } + + let popupnotificationheader = this.querySelector( + "popupnotificationheader" + ); + if (popupnotificationheader) { + this.querySelector(".popup-notification-header-container").append( + popupnotificationheader + ); + } + + for (let popupnotificationcontent of this.querySelectorAll( + "popupnotificationcontent" + )) { + this.appendNotificationContent(popupnotificationcontent); + } + + this.initializeAttributeInheritance(); + } + + appendNotificationContent(el) { + this.querySelector(".popup-notification-bottom-content").before(el); + } + } + + customElements.define("popupnotification", MozPopupNotification); +} diff --git a/toolkit/content/widgets/radio.js b/toolkit/content/widgets/radio.js new file mode 100644 index 0000000000..482323acb9 --- /dev/null +++ b/toolkit/content/widgets/radio.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. +(() => { + class MozRadiogroup extends MozElements.BaseControl { + constructor() { + super(); + + this.addEventListener("mousedown", event => { + if (this.disabled) { + event.preventDefault(); + } + }); + + /** + * keyboard navigation Here's how keyboard navigation works in radio groups on Windows: + * The group takes 'focus' + * The user is then free to navigate around inside the group + * using the arrow keys. Accessing previous or following radio buttons + * is done solely through the arrow keys and not the tab button. Tab + * takes you to the next widget in the tab order + */ + this.addEventListener("keypress", event => { + if (event.key != " " || event.originalTarget != this) { + return; + } + this.selectedItem = this.focusedItem; + this.selectedItem.doCommand(); + // Prevent page from scrolling on the space key. + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_UP || + event.originalTarget != this + ) { + return; + } + this.checkAdjacentElement(false); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_LEFT || + event.originalTarget != this + ) { + return; + } + // left arrow goes back when we are ltr, forward when we are rtl + this.checkAdjacentElement( + document.defaultView.getComputedStyle(this).direction == "rtl" + ); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_DOWN || + event.originalTarget != this + ) { + return; + } + this.checkAdjacentElement(true); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_RIGHT || + event.originalTarget != this + ) { + return; + } + // right arrow goes forward when we are ltr, back when we are rtl + this.checkAdjacentElement( + document.defaultView.getComputedStyle(this).direction == "ltr" + ); + event.stopPropagation(); + event.preventDefault(); + }); + + /** + * set a focused attribute on the selected item when the group + * receives focus so that we can style it as if it were focused even though + * it is not (Windows platform behaviour is for the group to receive focus, + * not the item + */ + this.addEventListener("focus", event => { + if (event.originalTarget != this) { + return; + } + this.setAttribute("focused", "true"); + if (this.focusedItem) { + return; + } + + var val = this.selectedItem; + if (!val || val.disabled || val.hidden || val.collapsed) { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if ( + !children[i].hidden && + !children[i].collapsed && + !children[i].disabled + ) { + val = children[i]; + break; + } + } + } + this.focusedItem = val; + }); + + this.addEventListener("blur", event => { + if (event.originalTarget != this) { + return; + } + this.removeAttribute("focused"); + this.focusedItem = null; + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + // When this is called via `connectedCallback` there are two main variations: + // 1) The radiogroup and radio children are defined in markup. + // 2) We are appending a DocumentFragment + // In both cases, the <radiogroup> connectedCallback fires first. But in (2), + // the children <radio>s won't be upgraded yet, so r.control will be undefined. + // To avoid churn in this case where we would have to reinitialize the list as each + // child radio gets upgraded as a result of init(), ignore the resulting calls + // to radioAttached. + this.ignoreRadioChildConstruction = true; + this.init(); + this.ignoreRadioChildConstruction = false; + if (!this.value) { + this.selectedIndex = 0; + } + } + + init() { + this._radioChildren = null; + + if (this.getAttribute("disabled") == "true") { + this.disabled = true; + } + + var children = this._getRadioChildren(); + var length = children.length; + for (var i = 0; i < length; i++) { + if (children[i].getAttribute("selected") == "true") { + this.selectedIndex = i; + return; + } + } + + var value = this.value; + if (value) { + this.value = value; + } + } + + /** + * Called when a new <radio> gets added to an already connected radiogroup. + * This can happen due to DOM getting appended after the <radiogroup> is created. + * When this happens, reinitialize the UI if necessary to make sure the state is + * consistent. + * + * @param {DOMNode} child + * The <radio> element that got added + */ + radioAttached(child) { + if (this.ignoreRadioChildConstruction) { + return; + } + if (!this._radioChildren || !this._radioChildren.includes(child)) { + this.init(); + } + } + + /** + * Called when a new <radio> gets removed from a radio group. + * + * @param {DOMNode} child + * The <radio> element that got removed + */ + radioUnattached(child) { + // Just invalidate the cache, next time it's fetched it'll get rebuilt. + this._radioChildren = null; + } + + set value(val) { + this.setAttribute("value", val); + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; i++) { + if (String(children[i].value) == String(val)) { + this.selectedItem = children[i]; + break; + } + } + } + + get value() { + return this.getAttribute("value"); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + children[i].disabled = val; + } + } + + get disabled() { + if (this.getAttribute("disabled") == "true") { + return true; + } + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if ( + !children[i].hidden && + !children[i].collapsed && + !children[i].disabled + ) { + return false; + } + } + return true; + } + + get itemCount() { + return this._getRadioChildren().length; + } + + set selectedIndex(val) { + this.selectedItem = this._getRadioChildren()[val]; + } + + get selectedIndex() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].selected) { + return i; + } + } + return -1; + } + + set selectedItem(val) { + var focused = this.getAttribute("focused") == "true"; + var alreadySelected = false; + + if (val) { + alreadySelected = val.getAttribute("selected") == "true"; + val.setAttribute("focused", focused); + val.setAttribute("selected", "true"); + this.setAttribute("value", val.value); + } else { + this.removeAttribute("value"); + } + + // uncheck all other group nodes + var children = this._getRadioChildren(); + var previousItem = null; + for (var i = 0; i < children.length; ++i) { + if (children[i] != val) { + if (children[i].getAttribute("selected") == "true") { + previousItem = children[i]; + } + + children[i].removeAttribute("selected"); + children[i].removeAttribute("focused"); + } + } + + var event = document.createEvent("Events"); + event.initEvent("select", false, true); + this.dispatchEvent(event); + + if (focused) { + if (alreadySelected) { + // Notify accessibility that this item got focus. + event = document.createEvent("Events"); + event.initEvent("DOMMenuItemActive", true, true); + val.dispatchEvent(event); + } else { + // Only report if actual change + if (val) { + // Accessibility will fire focus for this. + event = document.createEvent("Events"); + event.initEvent("RadioStateChange", true, true); + val.dispatchEvent(event); + } + + if (previousItem) { + event = document.createEvent("Events"); + event.initEvent("RadioStateChange", true, true); + previousItem.dispatchEvent(event); + } + } + } + } + + get selectedItem() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].selected) { + return children[i]; + } + } + return null; + } + + set focusedItem(val) { + if (val) { + val.setAttribute("focused", "true"); + // Notify accessibility that this item got focus. + let event = document.createEvent("Events"); + event.initEvent("DOMMenuItemActive", true, true); + val.dispatchEvent(event); + } + + // unfocus all other group nodes + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i] != val) { + children[i].removeAttribute("focused"); + } + } + } + + get focusedItem() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].getAttribute("focused") == "true") { + return children[i]; + } + } + return null; + } + + checkAdjacentElement(aNextFlag) { + var currentElement = this.focusedItem || this.selectedItem; + var i; + var children = this._getRadioChildren(); + for (i = 0; i < children.length; ++i) { + if (children[i] == currentElement) { + break; + } + } + var index = i; + + if (aNextFlag) { + do { + if (++i == children.length) { + i = 0; + } + if (i == index) { + break; + } + } while ( + children[i].hidden || + children[i].collapsed || + children[i].disabled + ); + // XXX check for display/visibility props too + + this.selectedItem = children[i]; + children[i].doCommand(); + } else { + do { + if (i == 0) { + i = children.length; + } + if (--i == index) { + break; + } + } while ( + children[i].hidden || + children[i].collapsed || + children[i].disabled + ); + // XXX check for display/visibility props too + + this.selectedItem = children[i]; + children[i].doCommand(); + } + } + + _getRadioChildren() { + if (this._radioChildren) { + return this._radioChildren; + } + + let radioChildren = []; + if (this.hasChildNodes()) { + for (let radio of this.querySelectorAll("radio")) { + customElements.upgrade(radio); + if (radio.control == this) { + radioChildren.push(radio); + } + } + } else { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + for (let radio of this.ownerDocument.getElementsByAttribute( + "group", + this.id + )) { + if (radio.namespaceURI == XUL_NS && radio.localName == "radio") { + customElements.upgrade(radio); + radioChildren.push(radio); + } + } + } + + return (this._radioChildren = radioChildren); + } + + getIndexOfItem(item) { + return this._getRadioChildren().indexOf(item); + } + + getItemAtIndex(index) { + var children = this._getRadioChildren(); + return index >= 0 && index < children.length ? children[index] : null; + } + + appendItem(label, value) { + var radio = document.createXULElement("radio"); + radio.setAttribute("label", label); + radio.setAttribute("value", value); + this.appendChild(radio); + return radio; + } + } + + MozXULElement.implementCustomInterface(MozRadiogroup, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULRadioGroupElement, + ]); + + customElements.define("radiogroup", MozRadiogroup); + + class MozRadio extends MozElements.BaseText { + static get markup() { + return ` + <image class="radio-check"></image> + <hbox class="radio-label-box" align="center" flex="1"> + <image class="radio-icon"></image> + <label class="radio-label" flex="1"></label> + </hbox> + `; + } + + static get inheritedAttributes() { + return { + ".radio-check": "disabled,selected", + ".radio-label": "text=label,accesskey,crop", + ".radio-icon": "src", + }; + } + + constructor() { + super(); + this.addEventListener("click", event => { + if (!this.disabled) { + this.control.selectedItem = this; + } + }); + + this.addEventListener("mousedown", event => { + if (!this.disabled) { + this.control.focusedItem = this; + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + if (!this.connectedOnce) { + this.connectedOnce = true; + // If the caller didn't provide custom content then append the default: + if (!this.firstElementChild) { + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + } + } + + var control = (this._control = this.control); + if (control) { + control.radioAttached(this); + } + } + + disconnectedCallback() { + if (this.control) { + this.control.radioUnattached(this); + } + this._control = null; + } + + set value(val) { + this.setAttribute("value", val); + } + + get value() { + return this.getAttribute("value"); + } + + get selected() { + return this.hasAttribute("selected"); + } + + get radioGroup() { + return this.control; + } + + get control() { + if (this._control) { + return this._control; + } + + var radiogroup = this.closest("radiogroup"); + if (radiogroup) { + return radiogroup; + } + + var group = this.getAttribute("group"); + if (!group) { + return null; + } + + var parent = this.ownerDocument.getElementById(group); + if (!parent || parent.localName != "radiogroup") { + parent = null; + } + return parent; + } + } + + MozXULElement.implementCustomInterface(MozRadio, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + customElements.define("radio", MozRadio); +})(); diff --git a/toolkit/content/widgets/richlistbox.js b/toolkit/content/widgets/richlistbox.js new file mode 100644 index 0000000000..904ef9ceec --- /dev/null +++ b/toolkit/content/widgets/richlistbox.js @@ -0,0 +1,1040 @@ +/* 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" + ); + + /** + * XUL:richlistbox element. + */ + MozElements.RichListBox = class RichListBox extends MozElements.BaseControl { + constructor() { + super(); + + this.selectedItems = new ChromeNodeList(); + this._currentIndex = null; + this._lastKeyTime = 0; + this._incrementalString = ""; + this._suppressOnSelect = false; + this._userSelecting = false; + this._selectTimeout = null; + this._currentItem = null; + this._selectionStart = null; + + this.addEventListener( + "keypress", + event => { + if (event.altKey || event.metaKey) { + return; + } + + switch (event.keyCode) { + case KeyEvent.DOM_VK_UP: + this._moveByOffsetFromUserEvent(-1, event); + break; + case KeyEvent.DOM_VK_DOWN: + this._moveByOffsetFromUserEvent(1, event); + break; + case KeyEvent.DOM_VK_HOME: + this._moveByOffsetFromUserEvent(-this.currentIndex, event); + break; + case KeyEvent.DOM_VK_END: + this._moveByOffsetFromUserEvent( + this.getRowCount() - this.currentIndex - 1, + event + ); + break; + case KeyEvent.DOM_VK_PAGE_UP: + this._moveByOffsetFromUserEvent(this.scrollOnePage(-1), event); + break; + case KeyEvent.DOM_VK_PAGE_DOWN: + this._moveByOffsetFromUserEvent(this.scrollOnePage(1), event); + break; + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener("keypress", event => { + if (event.target != this) { + return; + } + + if ( + event.key == " " && + event.ctrlKey && + !event.shiftKey && + !event.altKey && + !event.metaKey && + this.currentItem && + this.selType == "multiple" + ) { + this.toggleItemSelection(this.currentItem); + } + + if (!event.charCode || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + + if (event.timeStamp - this._lastKeyTime > 1000) { + this._incrementalString = ""; + } + + var key = String.fromCharCode(event.charCode).toLowerCase(); + this._incrementalString += key; + this._lastKeyTime = event.timeStamp; + + // If all letters in the incremental string are the same, just + // try to match the first one + var incrementalString = /^(.)\1+$/.test(this._incrementalString) + ? RegExp.$1 + : this._incrementalString; + var length = incrementalString.length; + + var rowCount = this.getRowCount(); + var l = this.selectedItems.length; + var start = l > 0 ? this.getIndexOfItem(this.selectedItems[l - 1]) : -1; + // start from the first element if none was selected or from the one + // following the selected one if it's a new or a repeated-letter search + if (start == -1 || length == 1) { + start++; + } + + for (var i = 0; i < rowCount; i++) { + var k = (start + i) % rowCount; + var listitem = this.getItemAtIndex(k); + if (!this._canUserSelect(listitem)) { + continue; + } + // allow richlistitems to specify the string being searched for + var searchText = + "searchLabel" in listitem + ? listitem.searchLabel + : listitem.getAttribute("label"); // (see also bug 250123) + searchText = searchText.substring(0, length).toLowerCase(); + if (searchText == incrementalString) { + this.ensureIndexIsVisible(k); + this.timedSelect(listitem, this._selectDelay); + break; + } + } + }); + + this.addEventListener("focus", event => { + if (this.getRowCount() > 0) { + if (this.currentIndex == -1) { + this.currentIndex = this.getIndexOfFirstVisibleRow(); + let currentItem = this.getItemAtIndex(this.currentIndex); + if (currentItem) { + this.selectItem(currentItem); + } + } else { + this._fireEvent(this.currentItem, "DOMMenuItemActive"); + } + } + this._lastKeyTime = 0; + }); + + this.addEventListener("click", event => { + // clicking into nothing should unselect multiple selections + if (event.originalTarget == this && this.selType == "multiple") { + this.clearSelection(); + this.currentItem = null; + } + }); + + this.addEventListener("MozSwipeGesture", event => { + // Only handle swipe gestures up and down + switch (event.direction) { + case event.DIRECTION_DOWN: + this.scrollTop = this.scrollHeight; + break; + case event.DIRECTION_UP: + this.scrollTop = 0; + break; + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("allowevents", "true"); + this._refreshSelection(); + } + + // nsIDOMXULSelectControlElement + set selectedItem(val) { + this.selectItem(val); + } + get selectedItem() { + return this.selectedItems.length ? this.selectedItems[0] : null; + } + + // nsIDOMXULSelectControlElement + set selectedIndex(val) { + if (val >= 0) { + // This is a micro-optimization so that a call to getIndexOfItem or + // getItemAtIndex caused by _fireOnSelect (especially for derived + // widgets) won't loop the children. + this._selecting = { + item: this.getItemAtIndex(val), + index: val, + }; + this.selectItem(this._selecting.item); + delete this._selecting; + } else { + this.clearSelection(); + this.currentItem = null; + } + } + get selectedIndex() { + if (this.selectedItems.length) { + return this.getIndexOfItem(this.selectedItems[0]); + } + return -1; + } + + // nsIDOMXULSelectControlElement + set value(val) { + var kids = this.getElementsByAttribute("value", val); + if (kids && kids.item(0)) { + this.selectItem(kids[0]); + } + } + get value() { + if (this.selectedItems.length) { + return this.selectedItem.value; + } + return null; + } + + // nsIDOMXULSelectControlElement + get itemCount() { + return this.itemChildren.length; + } + + // nsIDOMXULSelectControlElement + set selType(val) { + this.setAttribute("seltype", val); + } + get selType() { + return this.getAttribute("seltype"); + } + + // nsIDOMXULSelectControlElement + set currentItem(val) { + if (this._currentItem == val) { + return; + } + + if (this._currentItem) { + this._currentItem.current = false; + if (!val && !this.suppressMenuItemEvent) { + // An item is losing focus and there is no new item to focus. + // Notify a11y that there is no focused item. + this._fireEvent(this._currentItem, "DOMMenuItemInactive"); + } + } + this._currentItem = val; + + if (val) { + val.current = true; + if (!this.suppressMenuItemEvent) { + // Notify a11y that this item got focus. + this._fireEvent(val, "DOMMenuItemActive"); + } + } + } + get currentItem() { + return this._currentItem; + } + + // nsIDOMXULSelectControlElement + set currentIndex(val) { + if (val >= 0) { + this.currentItem = this.getItemAtIndex(val); + } else { + this.currentItem = null; + } + } + get currentIndex() { + return this.currentItem ? this.getIndexOfItem(this.currentItem) : -1; + } + + // nsIDOMXULSelectControlElement + get selectedCount() { + return this.selectedItems.length; + } + + get itemChildren() { + let children = Array.from(this.children).filter( + node => node.localName == "richlistitem" + ); + return children; + } + + set suppressOnSelect(val) { + this.setAttribute("suppressonselect", val); + } + get suppressOnSelect() { + return this.getAttribute("suppressonselect") == "true"; + } + + set _selectDelay(val) { + this.setAttribute("_selectDelay", val); + } + get _selectDelay() { + return this.getAttribute("_selectDelay") || 50; + } + + _fireOnSelect() { + // make sure not to modify last-selected when suppressing select events + // (otherwise we'll lose the selection when a template gets rebuilt) + if (this._suppressOnSelect || this.suppressOnSelect) { + return; + } + + // remember the current item and all selected items with IDs + var state = this.currentItem ? this.currentItem.id : ""; + if (this.selType == "multiple" && this.selectedCount) { + let getId = function getId(aItem) { + return aItem.id; + }; + state += + " " + [...this.selectedItems].filter(getId).map(getId).join(" "); + } + if (state) { + this.setAttribute("last-selected", state); + } else { + this.removeAttribute("last-selected"); + } + + // preserve the index just in case no IDs are available + if (this.currentIndex > -1) { + this._currentIndex = this.currentIndex + 1; + } + + var event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + + // always call this (allows a commandupdater without controller) + document.commandDispatcher.updateCommands("richlistbox-select"); + } + + getNextItem(aStartItem, aDelta) { + while (aStartItem) { + aStartItem = aStartItem.nextSibling; + if ( + aStartItem && + aStartItem.localName == "richlistitem" && + (!this._userSelecting || this._canUserSelect(aStartItem)) + ) { + --aDelta; + if (aDelta == 0) { + return aStartItem; + } + } + } + return null; + } + + getPreviousItem(aStartItem, aDelta) { + while (aStartItem) { + aStartItem = aStartItem.previousSibling; + if ( + aStartItem && + aStartItem.localName == "richlistitem" && + (!this._userSelecting || this._canUserSelect(aStartItem)) + ) { + --aDelta; + if (aDelta == 0) { + return aStartItem; + } + } + } + return null; + } + + appendItem(aLabel, aValue) { + var item = this.ownerDocument.createXULElement("richlistitem"); + item.setAttribute("value", aValue); + + var label = this.ownerDocument.createXULElement("label"); + label.setAttribute("value", aLabel); + label.setAttribute("flex", "1"); + label.setAttribute("crop", "end"); + item.appendChild(label); + + this.appendChild(item); + + return item; + } + + // nsIDOMXULSelectControlElement + getIndexOfItem(aItem) { + // don't search the children, if we're looking for none of them + if (aItem == null) { + return -1; + } + if (this._selecting && this._selecting.item == aItem) { + return this._selecting.index; + } + return this.itemChildren.indexOf(aItem); + } + + // nsIDOMXULSelectControlElement + getItemAtIndex(aIndex) { + if (this._selecting && this._selecting.index == aIndex) { + return this._selecting.item; + } + return this.itemChildren[aIndex] || null; + } + + // nsIDOMXULMultiSelectControlElement + addItemToSelection(aItem) { + if (this.selType != "multiple" && this.selectedCount) { + return; + } + + if (aItem.selected) { + return; + } + + this.selectedItems.append(aItem); + aItem.selected = true; + + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + removeItemFromSelection(aItem) { + if (!aItem.selected) { + return; + } + + this.selectedItems.remove(aItem); + aItem.selected = false; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + toggleItemSelection(aItem) { + if (aItem.selected) { + this.removeItemFromSelection(aItem); + } else { + this.addItemToSelection(aItem); + } + } + + // nsIDOMXULMultiSelectControlElement + selectItem(aItem) { + if (!aItem || aItem.disabled) { + return; + } + + if (this.selectedItems.length == 1 && this.selectedItems[0] == aItem) { + return; + } + + this._selectionStart = null; + + var suppress = this._suppressOnSelect; + this._suppressOnSelect = true; + + this.clearSelection(); + this.addItemToSelection(aItem); + this.currentItem = aItem; + + this._suppressOnSelect = suppress; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + selectItemRange(aStartItem, aEndItem) { + if (this.selType != "multiple") { + return; + } + + if (!aStartItem) { + aStartItem = this._selectionStart + ? this._selectionStart + : this.currentItem; + } + + if (!aStartItem) { + aStartItem = aEndItem; + } + + var suppressSelect = this._suppressOnSelect; + this._suppressOnSelect = true; + + this._selectionStart = aStartItem; + + var currentItem; + var startIndex = this.getIndexOfItem(aStartItem); + var endIndex = this.getIndexOfItem(aEndItem); + if (endIndex < startIndex) { + currentItem = aEndItem; + aEndItem = aStartItem; + aStartItem = currentItem; + } else { + currentItem = aStartItem; + } + + while (currentItem) { + this.addItemToSelection(currentItem); + if (currentItem == aEndItem) { + currentItem = this.getNextItem(currentItem, 1); + break; + } + currentItem = this.getNextItem(currentItem, 1); + } + + // Clear around new selection + // Don't use clearSelection() because it causes a lot of noise + // with respect to selection removed notifications used by the + // accessibility API support. + var userSelecting = this._userSelecting; + this._userSelecting = false; // that's US automatically unselecting + for (; currentItem; currentItem = this.getNextItem(currentItem, 1)) { + this.removeItemFromSelection(currentItem); + } + + for ( + currentItem = this.getItemAtIndex(0); + currentItem != aStartItem; + currentItem = this.getNextItem(currentItem, 1) + ) { + this.removeItemFromSelection(currentItem); + } + this._userSelecting = userSelecting; + + this._suppressOnSelect = suppressSelect; + + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + selectAll() { + this._selectionStart = null; + + var suppress = this._suppressOnSelect; + this._suppressOnSelect = true; + + var item = this.getItemAtIndex(0); + while (item) { + this.addItemToSelection(item); + item = this.getNextItem(item, 1); + } + + this._suppressOnSelect = suppress; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + clearSelection() { + if (this.selectedItems) { + while (this.selectedItems.length) { + let item = this.selectedItems[0]; + item.selected = false; + this.selectedItems.remove(item); + } + } + + this._selectionStart = null; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + getSelectedItem(aIndex) { + return aIndex < this.selectedItems.length + ? this.selectedItems[aIndex] + : null; + } + + ensureIndexIsVisible(aIndex) { + return this.ensureElementIsVisible(this.getItemAtIndex(aIndex)); + } + + ensureElementIsVisible(aElement, aAlignToTop) { + if (!aElement) { + return; + } + + // These calculations assume that there is no padding on the + // "richlistbox" element, although there might be a margin. + var targetRect = aElement.getBoundingClientRect(); + var scrollRect = this.getBoundingClientRect(); + var offset = targetRect.top - scrollRect.top; + if (!aAlignToTop && offset >= 0) { + // scrollRect.bottom wouldn't take a horizontal scroll bar into account + let scrollRectBottom = scrollRect.top + this.clientHeight; + offset = targetRect.bottom - scrollRectBottom; + if (offset <= 0) { + return; + } + } + this.scrollTop += offset; + } + + getIndexOfFirstVisibleRow() { + var children = this.itemChildren; + + for (var ix = 0; ix < children.length; ix++) { + if (this._isItemVisible(children[ix])) { + return ix; + } + } + + return -1; + } + + getRowCount() { + return this.itemChildren.length; + } + + scrollOnePage(aDirection) { + var children = this.itemChildren; + + if (!children.length) { + return 0; + } + + // If nothing is selected, we just select the first element + // at the extreme we're moving away from + if (!this.currentItem) { + return aDirection == -1 ? children.length : 0; + } + + // If the current item is visible, scroll by one page so that + // the new current item is at approximately the same position as + // the existing current item. + let height = this.getBoundingClientRect().height; + if (this._isItemVisible(this.currentItem)) { + this.scrollBy(0, height * aDirection); + } + + // Figure out, how many items fully fit into the view port + // (including the currently selected one), and determine + // the index of the first one lying (partially) outside + let currentItemRect = this.currentItem.getBoundingClientRect(); + var startBorder = currentItemRect.y; + if (aDirection == -1) { + startBorder += currentItemRect.height; + } + + var index = this.currentIndex; + for (var ix = index; 0 <= ix && ix < children.length; ix += aDirection) { + let childRect = children[ix].getBoundingClientRect(); + if (childRect.height == 0) { + continue; // hidden children have a y of 0 + } + var endBorder = childRect.y + (aDirection == -1 ? childRect.height : 0); + if ((endBorder - startBorder) * aDirection > height) { + break; // we've reached the desired distance + } + index = ix; + } + + return index != this.currentIndex + ? index - this.currentIndex + : aDirection; + } + + _refreshSelection() { + // when this method is called, we know that either the currentItem + // and selectedItems we have are null (ctor) or a reference to an + // element no longer in the DOM (template). + + // first look for the last-selected attribute + var state = this.getAttribute("last-selected"); + if (state) { + var ids = state.split(" "); + + var suppressSelect = this._suppressOnSelect; + this._suppressOnSelect = true; + this.clearSelection(); + for (let i = 1; i < ids.length; i++) { + var selectedItem = document.getElementById(ids[i]); + if (selectedItem) { + this.addItemToSelection(selectedItem); + } + } + + var currentItem = document.getElementById(ids[0]); + if (!currentItem && this._currentIndex) { + currentItem = this.getItemAtIndex( + Math.min(this._currentIndex - 1, this.getRowCount()) + ); + } + if (currentItem) { + this.currentItem = currentItem; + if (this.selType != "multiple" && this.selectedCount == 0) { + this.selectedItem = currentItem; + } + + if (this.getBoundingClientRect().height) { + this.ensureElementIsVisible(currentItem); + } else { + // XXX hack around a bug in ensureElementIsVisible as it will + // scroll beyond the last element, bug 493645. + this.ensureElementIsVisible(currentItem.previousElementSibling); + } + } + this._suppressOnSelect = suppressSelect; + // XXX actually it's just a refresh, but at least + // the Extensions manager expects this: + this._fireOnSelect(); + return; + } + + // try to restore the selected items according to their IDs + // (applies after a template rebuild, if last-selected was not set) + if (this.selectedItems) { + let itemIds = []; + for (let i = this.selectedCount - 1; i >= 0; i--) { + let selectedItem = this.selectedItems[i]; + itemIds.push(selectedItem.id); + this.selectedItems.remove(selectedItem); + } + for (let i = 0; i < itemIds.length; i++) { + let selectedItem = document.getElementById(itemIds[i]); + if (selectedItem) { + this.selectedItems.append(selectedItem); + } + } + } + if (this.currentItem && this.currentItem.id) { + this.currentItem = document.getElementById(this.currentItem.id); + } else { + this.currentItem = null; + } + + // if we have no previously current item or if the above check fails to + // find the previous nodes (which causes it to clear selection) + if (!this.currentItem && this.selectedCount == 0) { + this.currentIndex = this._currentIndex ? this._currentIndex - 1 : 0; + + // cf. listbox constructor: + // select items according to their attributes + var children = this.itemChildren; + for (let i = 0; i < children.length; ++i) { + if (children[i].getAttribute("selected") == "true") { + this.selectedItems.append(children[i]); + } + } + } + + if (this.selType != "multiple" && this.selectedCount == 0) { + this.selectedItem = this.currentItem; + } + } + + _isItemVisible(aItem) { + if (!aItem) { + return false; + } + + var y = this.getBoundingClientRect().y; + + // Partially visible items are also considered visible + let itemRect = aItem.getBoundingClientRect(); + return ( + itemRect.y + itemRect.height > y && itemRect.y < y + this.clientHeight + ); + } + + moveByOffset(aOffset, aIsSelecting, aIsSelectingRange, aEvent) { + if ((aIsSelectingRange || !aIsSelecting) && this.selType != "multiple") { + return; + } + + var newIndex = this.currentIndex + aOffset; + if (newIndex < 0) { + newIndex = 0; + } + + var numItems = this.getRowCount(); + if (newIndex > numItems - 1) { + newIndex = numItems - 1; + } + + var newItem = this.getItemAtIndex(newIndex); + // make sure that the item is actually visible/selectable + if (this._userSelecting && newItem && !this._canUserSelect(newItem)) { + newItem = + aOffset > 0 + ? this.getNextItem(newItem, 1) || this.getPreviousItem(newItem, 1) + : this.getPreviousItem(newItem, 1) || this.getNextItem(newItem, 1); + } + if (newItem) { + let hadFocus = this.currentItem.contains(document.activeElement); + this.ensureIndexIsVisible(this.getIndexOfItem(newItem)); + if (aIsSelectingRange) { + this.selectItemRange(null, newItem); + } else if (aIsSelecting) { + this.selectItem(newItem); + } + if (hadFocus) { + let flags = + Services.focus[ + aEvent.type.startsWith("key") ? "FLAG_BYKEY" : "FLAG_BYJS" + ]; + Services.focus.moveFocus( + window, + newItem, + Services.focus.MOVEFOCUS_FIRST, + flags + ); + } + + this.currentItem = newItem; + } + } + + _moveByOffsetFromUserEvent(aOffset, aEvent) { + if (!aEvent.defaultPrevented) { + this._userSelecting = true; + this.moveByOffset(aOffset, !aEvent.ctrlKey, aEvent.shiftKey, aEvent); + this._userSelecting = false; + aEvent.preventDefault(); + } + } + + _canUserSelect(aItem) { + var style = document.defaultView.getComputedStyle(aItem); + return ( + style.display != "none" && + style.visibility == "visible" && + style.MozUserInput != "none" + ); + } + + _selectTimeoutHandler(aMe) { + aMe._fireOnSelect(); + aMe._selectTimeout = null; + } + + timedSelect(aItem, aTimeout) { + var suppress = this._suppressOnSelect; + if (aTimeout != -1) { + this._suppressOnSelect = true; + } + + this.selectItem(aItem); + + this._suppressOnSelect = suppress; + + if (aTimeout != -1) { + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this._selectTimeout = window.setTimeout( + this._selectTimeoutHandler, + aTimeout, + this + ); + } + } + + /** + * For backwards-compatibility and for convenience. + * Use ensureElementIsVisible instead + */ + ensureSelectedElementIsVisible() { + return this.ensureElementIsVisible(this.selectedItem); + } + + _fireEvent(aTarget, aName) { + let event = document.createEvent("Events"); + event.initEvent(aName, true, true); + aTarget.dispatchEvent(event); + } + }; + + MozXULElement.implementCustomInterface(MozElements.RichListBox, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULMultiSelectControlElement, + ]); + + customElements.define("richlistbox", MozElements.RichListBox); + + /** + * XUL:richlistitem element. + */ + MozElements.MozRichlistitem = class MozRichlistitem extends ( + MozElements.BaseText + ) { + constructor() { + super(); + + this.selectedByMouseOver = false; + + /** + * If there is no modifier key, we select on mousedown, not + * click, so that drags work correctly. + */ + this.addEventListener("mousedown", event => { + var control = this.control; + if (!control || control.disabled) { + return; + } + if ( + (!event.ctrlKey || + (AppConstants.platform == "macosx" && event.button == 2)) && + !event.shiftKey && + !event.metaKey + ) { + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + } + }); + + /** + * On a click (up+down on the same item), deselect everything + * except this item. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + var control = this.control; + if (!control || control.disabled) { + return; + } + control._userSelecting = true; + if (control.selType != "multiple") { + control.selectItem(this); + } else if (event.ctrlKey || event.metaKey) { + control.toggleItemSelection(this); + control.currentItem = this; + } else if (event.shiftKey) { + control.selectItemRange(null, this); + control.currentItem = this; + } else { + /* We want to deselect all the selected items except what was + clicked, UNLESS it was a right-click. We have to do this + in click rather than mousedown so that you can drag a + selected group of items */ + + // use selectItemRange instead of selectItem, because this + // doesn't de- and reselect this item if it is selected + control.selectItemRange(this, this); + } + control._userSelecting = false; + }); + } + + connectedCallback() { + this._updateInnerControlsForSelection(this.selected); + } + + /** + * nsIDOMXULSelectControlItemElement + */ + get label() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + return Array.from( + this.getElementsByTagNameNS(XUL_NS, "label"), + label => label.value + ).join(" "); + } + + set searchLabel(val) { + if (val !== null) { + this.setAttribute("searchlabel", val); + } + // fall back to the label property (default value) + else { + this.removeAttribute("searchlabel"); + } + } + + get searchLabel() { + return this.hasAttribute("searchlabel") + ? this.getAttribute("searchlabel") + : this.label; + } + /** + * nsIDOMXULSelectControlItemElement + */ + set value(val) { + this.setAttribute("value", val); + } + + get value() { + return this.getAttribute("value"); + } + + /** + * nsIDOMXULSelectControlItemElement + */ + set selected(val) { + if (val) { + this.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + } + this._updateInnerControlsForSelection(val); + } + + get selected() { + return this.getAttribute("selected") == "true"; + } + /** + * nsIDOMXULSelectControlItemElement + */ + get control() { + var parent = this.parentNode; + while (parent) { + if (parent.localName == "richlistbox") { + return parent; + } + parent = parent.parentNode; + } + return null; + } + + set current(val) { + if (val) { + this.setAttribute("current", "true"); + } else { + this.removeAttribute("current"); + } + } + + get current() { + return this.getAttribute("current") == "true"; + } + + _updateInnerControlsForSelection(selected) { + for (let control of this.querySelectorAll("button,menulist")) { + if (!selected && control.tabIndex == 0) { + control.tabIndex = -1; + } else if (selected && control.tabIndex == -1) { + control.tabIndex = 0; + } + } + } + }; + + MozXULElement.implementCustomInterface(MozElements.MozRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("richlistitem", MozElements.MozRichlistitem); +} diff --git a/toolkit/content/widgets/search-textbox.js b/toolkit/content/widgets/search-textbox.js new file mode 100644 index 0000000000..abdcfa2999 --- /dev/null +++ b/toolkit/content/widgets/search-textbox.js @@ -0,0 +1,262 @@ +/* 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. +{ + class MozSearchTextbox extends MozXULElement { + constructor() { + super(); + + MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + + this.inputField = document.createElement("input"); + + const METHODS = [ + "focus", + "blur", + "select", + "setUserInput", + "setSelectionRange", + ]; + for (const method of METHODS) { + this[method] = (...args) => this.inputField[method](...args); + } + + const READ_WRITE_PROPERTIES = [ + "defaultValue", + "placeholder", + "readOnly", + "size", + "selectionStart", + "selectionEnd", + ]; + for (const property of READ_WRITE_PROPERTIES) { + Object.defineProperty(this, property, { + enumerable: true, + get() { + return this.inputField[property]; + }, + set(val) { + this.inputField[property] = val; + }, + }); + } + + this.attachShadow({ mode: "open" }); + this.addEventListener("input", this); + this.addEventListener("keypress", this); + this.addEventListener("mousedown", this); + } + + static get inheritedAttributes() { + return { + input: + "value,maxlength,disabled,size,readonly,placeholder,tabindex,accesskey,inputmode,spellcheck", + ".textbox-search-icon": "label=searchbuttonlabel,disabled", + ".textbox-search-clear": "disabled", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.connected) { + return; + } + + document.l10n.connectRoot(this.shadowRoot); + + this.connected = true; + this.textContent = ""; + + const stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = "chrome://global/skin/search-textbox.css"; + + const textboxSign = document.createXULElement("image"); + textboxSign.className = "textbox-search-sign"; + textboxSign.part = "search-sign"; + + const input = this.inputField; + input.setAttribute("inputmode", "search"); + input.autocomplete = "off"; // not applicable in XUL docs and confuses aria. + input.addEventListener("focus", this); + input.addEventListener("blur", this); + + const searchBtn = (this._searchButtonIcon = + document.createXULElement("image")); + searchBtn.className = "textbox-search-icon"; + searchBtn.addEventListener("click", e => this._iconClick(e)); + + const clearBtn = document.createXULElement("image"); + clearBtn.className = "textbox-search-clear"; + clearBtn.part = "clear-icon"; + clearBtn.setAttribute("role", "button"); + document.l10n.setAttributes( + clearBtn, + "text-action-search-text-box-clear" + ); + clearBtn.addEventListener("click", () => this._clearSearch()); + + const deck = (this._searchIcons = document.createXULElement("deck")); + deck.className = "textbox-search-icons"; + deck.append(searchBtn, clearBtn); + this.shadowRoot.append(stylesheet, textboxSign, input, deck); + + this._timer = null; + + // Ensure the button state is up to date: + // eslint-disable-next-line no-self-assign + this.searchButton = this.searchButton; + + this.initializeAttributeInheritance(); + } + + disconnectedCallback() { + document.l10n.disconnectRoot(this.shadowRoot); + } + + set timeout(val) { + this.setAttribute("timeout", val); + } + + get timeout() { + return parseInt(this.getAttribute("timeout")) || 500; + } + + set searchButton(val) { + if (val) { + this.setAttribute("searchbutton", "true"); + this.inputField.removeAttribute("aria-autocomplete"); + this._searchButtonIcon.setAttribute("role", "button"); + } else { + this.removeAttribute("searchbutton"); + this.inputField.setAttribute("aria-autocomplete", "list"); + this._searchButtonIcon.setAttribute("role", "none"); + } + } + + get searchButton() { + return this.getAttribute("searchbutton") == "true"; + } + + set value(val) { + this.inputField.value = val; + + if (val) { + this._searchIcons.selectedIndex = this.searchButton ? 0 : 1; + } else { + this._searchIcons.selectedIndex = 0; + } + + if (this._timer) { + clearTimeout(this._timer); + } + } + + get value() { + return this.inputField.value; + } + + get editor() { + return this.inputField.editor; + } + + set disabled(val) { + this.inputField.disabled = val; + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + } + + get disabled() { + return this.inputField.disabled; + } + + on_blur() { + this.removeAttribute("focused"); + } + + on_focus() { + this.setAttribute("focused", "true"); + } + + on_input() { + if (this.searchButton) { + this._searchIcons.selectedIndex = 0; + return; + } + if (this._timer) { + clearTimeout(this._timer); + } + this._timer = + this.timeout && setTimeout(this._fireCommand, this.timeout, this); + this._searchIcons.selectedIndex = this.value ? 1 : 0; + } + + on_keypress(event) { + switch (event.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + if (this._clearSearch()) { + event.preventDefault(); + event.stopPropagation(); + } + break; + case KeyEvent.DOM_VK_RETURN: + this._enterSearch(); + event.preventDefault(); + event.stopPropagation(); + break; + } + } + + on_mousedown(event) { + if (!this.hasAttribute("focused")) { + this.setSelectionRange(0, 0); + this.focus(); + } + } + + _fireCommand(me) { + if (me._timer) { + clearTimeout(me._timer); + } + me._timer = null; + me.doCommand(); + } + + _iconClick() { + if (this.searchButton) { + this._enterSearch(); + } else { + this.focus(); + } + } + + _enterSearch() { + if (this.disabled) { + return; + } + if (this.searchButton && this.value && !this.readOnly) { + this._searchIcons.selectedIndex = 1; + } + this._fireCommand(this); + } + + _clearSearch() { + if (!this.disabled && !this.readOnly && this.value) { + this.value = ""; + this._fireCommand(this); + this._searchIcons.selectedIndex = 0; + return true; + } + return false; + } + } + + customElements.define("search-textbox", MozSearchTextbox); +} diff --git a/toolkit/content/widgets/spinner.js b/toolkit/content/widgets/spinner.js new file mode 100644 index 0000000000..2adefc7392 --- /dev/null +++ b/toolkit/content/widgets/spinner.js @@ -0,0 +1,637 @@ +/* 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"; + +/* + * The spinner is responsible for displaying the items, and does + * not care what the values represent. The setValue function is called + * when it detects a change in value triggered by scroll event. + * Supports scrolling, clicking on up or down, clicking on item, and + * dragging. + */ + +function Spinner(props, context) { + this.context = context; + this._init(props); +} + +{ + const ITEM_HEIGHT = 2.5, + VIEWPORT_SIZE = 7, + VIEWPORT_COUNT = 5; + + Spinner.prototype = { + /** + * Initializes a spinner. Set the default states and properties, cache + * element references, create the HTML markup, and add event listeners. + * + * @param {Object} props [Properties passed in from parent] + * { + * {Function} setValue: Takes a value and set the state to + * the parent component. + * {Function} getDisplayString: Takes a value, and output it + * as localized strings. + * {Number} viewportSize [optional]: Number of items in a + * viewport. + * {Boolean} hideButtons [optional]: Hide up & down buttons + * {Number} rootFontSize [optional]: Used to support zoom in/out + * } + */ + _init(props) { + const { + id, + setValue, + getDisplayString, + hideButtons, + rootFontSize = 10, + } = props; + + const spinnerTemplate = document.getElementById("spinner-template"); + const spinnerElement = document.importNode(spinnerTemplate.content, true); + + // Make sure viewportSize is an odd number because we want to have the selected + // item in the center. If it's an even number, use the default size instead. + const viewportSize = + props.viewportSize % 2 ? props.viewportSize : VIEWPORT_SIZE; + + this.state = { + items: [], + isScrolling: false, + }; + this.props = { + setValue, + getDisplayString, + viewportSize, + rootFontSize, + // We can assume that the viewportSize is an odd number. Calculate how many + // items we need to insert on top of the spinner so that the selected is at + // the center. Ex: if viewportSize is 5, we need 2 items on top. + viewportTopOffset: (viewportSize - 1) / 2, + }; + this.elements = { + container: spinnerElement.querySelector(".spinner-container"), + spinner: spinnerElement.querySelector(".spinner"), + up: spinnerElement.querySelector(".up"), + down: spinnerElement.querySelector(".down"), + itemsViewElements: [], + }; + + this.elements.spinner.style.height = ITEM_HEIGHT * viewportSize + "rem"; + + // Prepares the spinner container to function as a spinbutton and expose + // its properties to assistive technology + this.elements.spinner.setAttribute("role", "spinbutton"); + this.elements.spinner.setAttribute("tabindex", "0"); + // Remove up/down buttons from the focus order, because a keyboard-only + // user can adjust values by pressing Up/Down arrow keys on a spinbutton, + // otherwise it creates extra, redundant tab order stops for users + this.elements.up.setAttribute("tabindex", "-1"); + this.elements.down.setAttribute("tabindex", "-1"); + + if (id) { + this.elements.container.id = id; + } + if (hideButtons) { + this.elements.container.classList.add("hide-buttons"); + } + + this.context.appendChild(spinnerElement); + this._attachEventListeners(); + }, + + /** + * Only the parent component calls setState on the spinner. + * It checks if the items have changed and updates the spinner. + * If only the value has changed, smooth scrolls to the new value. + * + * @param {Object} newState [The new spinner state] + * { + * {Number/String} value: The centered value + * {Array} items: The list of items for display + * {Boolean} isInfiniteScroll: Whether or not the spinner should + * have infinite scroll capability + * {Boolean} isValueSet: true if user has selected a value + * } + */ + setState(newState) { + const { value, items } = this.state; + const { + value: newValue, + items: newItems, + isValueSet, + isInvalid, + smoothScroll = true, + } = newState; + + if (this._isArrayDiff(newItems, items)) { + this.state = Object.assign(this.state, newState); + this._updateItems(); + this._scrollTo(newValue, /* centering = */ true, /* smooth = */ false); + } else if (newValue != value) { + this.state = Object.assign(this.state, newState); + this._scrollTo(newValue, /* centering = */ true, smoothScroll); + } + + this.elements.spinner.setAttribute( + "aria-valuemin", + this.state.items[0].value + ); + this.elements.spinner.setAttribute( + "aria-valuemax", + this.state.items.at(-1).value + ); + this.elements.spinner.setAttribute("aria-valuenow", this.state.value); + if (!this.elements.spinner.getAttribute("aria-valuetext")) { + this.elements.spinner.setAttribute( + "aria-valuetext", + this.props.getDisplayString(this.state.value) + ); + } + + // Show selection even if it's passed down from the parent + if ((isValueSet && !isInvalid) || this.state.index) { + this._updateSelection(); + } else { + this._removeSelection(); + } + }, + + /** + * Whenever scroll event is detected: + * - Update the index state + * - If the value has changed, update the [value] state and call [setValue] + * - If infinite scrolling is on, reset the scrolling position if necessary + */ + _onScroll() { + const { items, itemsView, isInfiniteScroll } = this.state; + const { viewportSize, viewportTopOffset } = this.props; + const { spinner } = this.elements; + + this.state.index = this._getIndexByOffset(spinner.scrollTop); + + const value = itemsView[this.state.index + viewportTopOffset].value; + + // Call setValue if value has changed + if (this.state.value != value) { + this.state.value = value; + this.props.setValue(value); + } + + // Do infinite scroll when items length is bigger or equal to viewport + // and isInfiniteScroll is not false. + if (items.length >= viewportSize && isInfiniteScroll) { + // If the scroll position is near the top or bottom, jump back to the middle + // so user can keep scrolling up or down. + if ( + this.state.index < viewportSize || + this.state.index > itemsView.length - viewportSize + ) { + this._scrollTo(this.state.value, true); + } + } + + this.elements.spinner.classList.add("scrolling"); + }, + + /** + * Remove the "scrolling" state on scrollend. + */ + _onScrollend() { + this.elements.spinner.classList.remove("scrolling"); + this.elements.spinner.setAttribute( + "aria-valuetext", + this.props.getDisplayString(this.state.value) + ); + }, + + /** + * Updates the spinner items to the current states. + */ + _updateItems() { + const { viewportSize, viewportTopOffset } = this.props; + const { items, isInfiniteScroll } = this.state; + + // Prepends null elements so the selected value is centered in spinner + let itemsView = new Array(viewportTopOffset).fill({}).concat(items); + + if (items.length >= viewportSize && isInfiniteScroll) { + // To achieve infinite scroll, we move the scroll position back to the + // center when it is near the top or bottom. The scroll momentum could + // be lost in the process, so to minimize that, we need at least 2 sets + // of items to act as buffer: one for the top and one for the bottom. + // But if the number of items is small ( < viewportSize * viewport count) + // we should add more sets. + let count = + Math.ceil((viewportSize * VIEWPORT_COUNT) / items.length) * 2; + for (let i = 0; i < count; i += 1) { + itemsView.push(...items); + } + } + + // Reuse existing DOM nodes when possible. Create or remove + // nodes based on how big itemsView is. + this._prepareNodes(itemsView.length, this.elements.spinner); + // Once DOM nodes are ready, set display strings using textContent + this._setDisplayStringAndClass( + itemsView, + this.elements.itemsViewElements + ); + + this.state.itemsView = itemsView; + }, + + /** + * Make sure the number or child elements is the same as length + * and keep the elements' references for updating textContent + * + * @param {Number} length [The number of child elements] + * @param {DOMElement} parent [The parent element reference] + */ + _prepareNodes(length, parent) { + const diff = length - parent.childElementCount; + + if (!diff) { + return; + } + + if (diff > 0) { + // Add more elements if length is greater than current + let frag = document.createDocumentFragment(); + + // Remove margin bottom on the last element before appending + if (parent.lastChild) { + parent.lastChild.style.marginBottom = ""; + } + + for (let i = 0; i < diff; i++) { + let el = document.createElement("div"); + // Spinbutton elements should be hidden from assistive technology: + el.setAttribute("aria-hidden", "true"); + frag.appendChild(el); + this.elements.itemsViewElements.push(el); + } + parent.appendChild(frag); + } else if (diff < 0) { + // Remove elements if length is less than current + for (let i = 0; i < Math.abs(diff); i++) { + parent.removeChild(parent.lastChild); + } + this.elements.itemsViewElements.splice(diff); + } + + parent.lastChild.style.marginBottom = + ITEM_HEIGHT * this.props.viewportTopOffset + "rem"; + }, + + /** + * Set the display string and class name to the elements. + * + * @param {Array<Object>} items + * [{ + * {Number/String} value: The value in its original form + * {Boolean} enabled: Whether or not the item is enabled + * }] + * @param {Array<DOMElement>} elements + */ + _setDisplayStringAndClass(items, elements) { + const { getDisplayString } = this.props; + + items.forEach((item, index) => { + elements[index].textContent = + item.value != undefined ? getDisplayString(item.value) : ""; + elements[index].className = item.enabled ? "" : "disabled"; + }); + }, + + /** + * Attach event listeners to the spinner and buttons. + */ + _attachEventListeners() { + const { spinner, container } = this.elements; + + spinner.addEventListener("scroll", this, { passive: true }); + spinner.addEventListener("scrollend", this, { passive: true }); + spinner.addEventListener("keydown", this); + container.addEventListener("mouseup", this, { passive: true }); + container.addEventListener("mousedown", this, { passive: true }); + container.addEventListener("keydown", this); + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + const { mouseState = {}, index, itemsView } = this.state; + const { viewportTopOffset, setValue } = this.props; + const { spinner, up, down } = this.elements; + + switch (event.type) { + case "scroll": { + this._onScroll(); + break; + } + case "scrollend": { + this._onScrollend(); + break; + } + case "mousedown": { + this.state.mouseState = { + down: true, + layerX: event.layerX, + layerY: event.layerY, + }; + if (event.target == up) { + // An "active" class is needed to simulate :active pseudo-class + // because element is not focused. + event.target.classList.add("active"); + this._smoothScrollToIndex(index - 1); + } + if (event.target == down) { + event.target.classList.add("active"); + this._smoothScrollToIndex(index + 1); + } + if (event.target.parentNode == spinner) { + // Listen to dragging events + spinner.addEventListener("mousemove", this, { passive: true }); + spinner.addEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mouseup": { + this.state.mouseState.down = false; + if (event.target == up || event.target == down) { + event.target.classList.remove("active"); + } + if (event.target.parentNode == spinner) { + // Check if user clicks or drags, scroll to the item if clicked, + // otherwise get the current index and smooth scroll there. + if ( + event.layerX == mouseState.layerX && + event.layerY == mouseState.layerY + ) { + const newIndex = + this._getIndexByOffset(event.target.offsetTop) - + viewportTopOffset; + if (index == newIndex) { + // Set value manually if the clicked element is already centered. + // This happens when the picker first opens, and user pick the + // default value. + setValue(itemsView[index + viewportTopOffset].value); + } else { + this._smoothScrollToIndex(newIndex); + } + } else { + this._smoothScrollToIndex( + this._getIndexByOffset(spinner.scrollTop) + ); + } + // Stop listening to dragging + spinner.removeEventListener("mousemove", this, { passive: true }); + spinner.removeEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mouseleave": { + if (event.target == spinner) { + // Stop listening to drag event if mouse is out of the spinner + this._smoothScrollToIndex( + this._getIndexByOffset(spinner.scrollTop) + ); + spinner.removeEventListener("mousemove", this, { passive: true }); + spinner.removeEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mousemove": { + // Change spinner position on drag + spinner.scrollTop -= event.movementY; + break; + } + case "keydown": { + // Providing keyboard navigation support in accordance with + // the ARIA Spinbutton design pattern + if (event.target === spinner) { + switch (event.key) { + case "ArrowUp": { + // While the spinner is focused, selects previous value and centers it + this._setValueForSpinner(event, index - 1); + break; + } + case "ArrowDown": { + // While the spinner is focused, selects next value and centers it + this._setValueForSpinner(event, index + 1); + break; + } + case "PageUp": { + // While the spinner is focused, selects 5th value above and centers it + this._setValueForSpinner(event, index - 5); + break; + } + case "PageDown": { + // While the spinner is focused, selects 5th value below and centers it + this._setValueForSpinner(event, index + 5); + break; + } + case "Home": { + // While the spinner is focused, selects the min value and centers it + let targetValue; + for (let i = 0; i < this.state.items.length - 1; i++) { + if (this.state.items[i].enabled) { + targetValue = this.state.items[i].value; + break; + } + } + this._smoothScrollTo(targetValue); + event.stopPropagation(); + event.preventDefault(); + break; + } + case "End": { + // While the spinner is focused, selects the max value and centers it + let targetValue; + for (let i = this.state.items.length - 1; i >= 0; i--) { + if (this.state.items[i].enabled) { + targetValue = this.state.items[i].value; + break; + } + } + this._smoothScrollTo(targetValue); + event.stopPropagation(); + event.preventDefault(); + break; + } + } + } + } + } + }, + + /** + * Find the index by offset + * @param {Number} offset: Offset value in pixel. + * @return {Number} Index number + */ + _getIndexByOffset(offset) { + return Math.round(offset / (ITEM_HEIGHT * this.props.rootFontSize)); + }, + + /** + * Find the index of a value that is the closest to the current position. + * If centering is true, find the index closest to the center. + * + * @param {Number/String} value: The value to find + * @param {Boolean} centering: Whether or not to find the value closest to center + * @return {Number} index of the value, returns -1 if value is not found + */ + _getScrollIndex(value, centering) { + const { itemsView } = this.state; + const { viewportTopOffset } = this.props; + + // If index doesn't exist, or centering is true, start from the middle point + let currentIndex = + centering || this.state.index == undefined + ? Math.round((itemsView.length - viewportTopOffset) / 2) + : this.state.index; + let closestIndex = itemsView.length; + let indexes = []; + let diff = closestIndex; + let isValueFound = false; + + // Find indexes of items match the value + itemsView.forEach((item, index) => { + if (item.value == value) { + indexes.push(index); + } + }); + + // Find the index closest to currentIndex + indexes.forEach(index => { + let d = Math.abs(index - currentIndex); + if (d < diff) { + diff = d; + closestIndex = index; + isValueFound = true; + } + }); + + return isValueFound ? closestIndex - viewportTopOffset : -1; + }, + + /** + * Scroll to a value based on the index + * + * @param {Number} index: Index number + * @param {Boolean} smooth: Whether or not scroll should be smooth by default + */ + _scrollToIndex(index, smooth) { + // Do nothing if the value is not found + if (index < 0) { + return; + } + this.state.index = index; + const element = this.elements.spinner.children[index]; + if (!element) { + return; + } + element.scrollIntoView({ + behavior: smooth ? "auto" : "instant", + block: "start", + }); + }, + + /** + * Scroll to a value. + * + * @param {Number/String} value: Value to scroll to + * @param {Boolean} centering: Whether or not to scroll to center location + * @param {Boolean} smooth: Whether or not scroll should be smooth by default + */ + _scrollTo(value, centering, smooth) { + const index = this._getScrollIndex(value, centering); + this._scrollToIndex(index, smooth); + }, + + _smoothScrollTo(value) { + this._scrollTo(value, /* centering = */ false, /* smooth = */ true); + }, + + _smoothScrollToIndex(index) { + this._scrollToIndex(index, /* smooth = */ true); + }, + + /** + * Update the selection state. + */ + _updateSelection() { + const { itemsViewElements, selected } = this.elements; + const { itemsView, index } = this.state; + const { viewportTopOffset } = this.props; + const currentItemIndex = index + viewportTopOffset; + + if (selected && selected != itemsViewElements[currentItemIndex]) { + this._removeSelection(); + } + + this.elements.selected = itemsViewElements[currentItemIndex]; + if (itemsView[currentItemIndex] && itemsView[currentItemIndex].enabled) { + this.elements.selected.classList.add("selection"); + } + }, + + /** + * Remove selection if selected exists and different from current + */ + _removeSelection() { + const { selected } = this.elements; + + if (selected) { + selected.classList.remove("selection"); + } + }, + + /** + * Compares arrays of objects. It assumes the structure is an array of + * objects, and objects in a and b have the same number of properties. + * + * @param {Array<Object>} a + * @param {Array<Object>} b + * @return {Boolean} Returns true if a and b are different + */ + _isArrayDiff(a, b) { + // Check reference first, exit early if reference is the same. + if (a == b) { + return false; + } + + if (a.length != b.length) { + return true; + } + + for (let i = 0; i < a.length; i++) { + for (let prop in a[i]) { + if (a[i][prop] != b[i][prop]) { + return true; + } + } + } + return false; + }, + + /** + * While the spinner is focused and keyboard command is used, selects an + * appropriate index and centers it, while preventing default behavior and + * stopping event propagation. + * + * @param {Object} event: Keyboard event + * @param {Number} index: The index of the expected next item + */ + _setValueForSpinner(event, index) { + this._smoothScrollToIndex(index); + event.stopPropagation(); + event.preventDefault(); + }, + }; +} diff --git a/toolkit/content/widgets/stringbundle.js b/toolkit/content/widgets/stringbundle.js new file mode 100644 index 0000000000..9a19ffc47f --- /dev/null +++ b/toolkit/content/widgets/stringbundle.js @@ -0,0 +1,73 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + class MozStringbundle extends MozXULElement { + get stringBundle() { + if (!this._bundle) { + try { + this._bundle = Services.strings.createBundle(this.src); + } catch (e) { + dump("Failed to get stringbundle:\n"); + dump(e + "\n"); + } + } + return this._bundle; + } + + set src(val) { + this._bundle = null; + this.setAttribute("src", val); + } + + get src() { + return this.getAttribute("src"); + } + + get strings() { + // Note: this is a sucky method name! Should be: + // readonly attribute nsISimpleEnumerator strings; + return this.stringBundle.getSimpleEnumeration(); + } + + getString(aStringKey) { + try { + return this.stringBundle.GetStringFromName(aStringKey); + } catch (e) { + dump( + "*** Failed to get string " + + aStringKey + + " in bundle: " + + this.src + + "\n" + ); + throw e; + } + } + + getFormattedString(aStringKey, aStringsArray) { + try { + return this.stringBundle.formatStringFromName( + aStringKey, + aStringsArray + ); + } catch (e) { + dump( + "*** Failed to format string " + + aStringKey + + " in bundle: " + + this.src + + "\n" + ); + throw e; + } + } + } + + customElements.define("stringbundle", MozStringbundle); +} diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox.js new file mode 100644 index 0000000000..997e8413f2 --- /dev/null +++ b/toolkit/content/widgets/tabbox.js @@ -0,0 +1,884 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + let imports = {}; + ChromeUtils.defineESModuleGetters(imports, { + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", + }); + + class MozTabbox extends MozXULElement { + constructor() { + super(); + this._handleMetaAltArrows = AppConstants.platform == "macosx"; + this.disconnectedCallback = this.disconnectedCallback.bind(this); + } + + connectedCallback() { + Services.els.addSystemEventListener(document, "keydown", this, false); + window.addEventListener("unload", this.disconnectedCallback, { + once: true, + }); + } + + disconnectedCallback() { + window.removeEventListener("unload", this.disconnectedCallback); + Services.els.removeSystemEventListener(document, "keydown", this, false); + } + + set handleCtrlTab(val) { + this.setAttribute("handleCtrlTab", val); + } + + get handleCtrlTab() { + return this.getAttribute("handleCtrlTab") != "false"; + } + + get tabs() { + if (this.hasAttribute("tabcontainer")) { + return document.getElementById(this.getAttribute("tabcontainer")); + } + return this.getElementsByTagNameNS( + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + "tabs" + ).item(0); + } + + get tabpanels() { + return this.getElementsByTagNameNS( + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + "tabpanels" + ).item(0); + } + + set selectedIndex(val) { + let tabs = this.tabs; + if (tabs) { + tabs.selectedIndex = val; + } + this.setAttribute("selectedIndex", val); + } + + get selectedIndex() { + let tabs = this.tabs; + return tabs ? tabs.selectedIndex : -1; + } + + set selectedTab(val) { + if (val) { + let tabs = this.tabs; + if (tabs) { + tabs.selectedItem = val; + } + } + } + + get selectedTab() { + let tabs = this.tabs; + return tabs && tabs.selectedItem; + } + + set selectedPanel(val) { + if (val) { + let tabpanels = this.tabpanels; + if (tabpanels) { + tabpanels.selectedPanel = val; + } + } + } + + get selectedPanel() { + let tabpanels = this.tabpanels; + return tabpanels && tabpanels.selectedPanel; + } + + handleEvent(event) { + if (!event.isTrusted) { + // Don't let untrusted events mess with tabs. + return; + } + + // Skip this only if something has explicitly cancelled it. + if (event.defaultCancelled) { + return; + } + + // Skip if chrome code has cancelled this: + if (event.defaultPreventedByChrome) { + return; + } + + // Don't check if the event was already consumed because tab + // navigation should always work for better user experience. + + const { ShortcutUtils } = imports; + + switch (ShortcutUtils.getSystemActionForEvent(event)) { + case ShortcutUtils.CYCLE_TABS: + Services.telemetry.keyedScalarAdd( + "browser.ui.interaction.keyboard", + "ctrl-tab", + 1 + ); + Services.prefs.setBoolPref( + "browser.engagement.ctrlTab.has-used", + true + ); + if (this.tabs && this.handleCtrlTab) { + this.tabs.advanceSelectedTab(event.shiftKey ? -1 : 1, true); + event.preventDefault(); + } + break; + case ShortcutUtils.PREVIOUS_TAB: + if (this.tabs) { + this.tabs.advanceSelectedTab(-1, true); + event.preventDefault(); + } + break; + case ShortcutUtils.NEXT_TAB: + if (this.tabs) { + this.tabs.advanceSelectedTab(1, true); + event.preventDefault(); + } + break; + } + } + } + + customElements.define("tabbox", MozTabbox); + + class MozDeck extends MozXULElement { + get isAsync() { + return this.getAttribute("async") == "true"; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + this._selectedPanel = null; + this._inAsyncOperation = false; + + let selectCurrentIndex = () => { + // Try to select the new node if any. + let index = this.selectedIndex; + let oldPanel = this._selectedPanel; + this._selectedPanel = this.children.item(index) || null; + this.updateSelectedIndex(index, oldPanel); + }; + + this._mutationObserver = new MutationObserver(records => { + let anyRemovals = records.some(record => !!record.removedNodes.length); + if (anyRemovals) { + // Try to keep the current selected panel in-place first. + let index = Array.from(this.children).indexOf(this._selectedPanel); + if (index != -1) { + // Try to keep the same node selected. + this.setAttribute("selectedIndex", index); + } + } + // Select the current index if needed in case mutations have made that + // available where it wasn't before. + if (!this._inAsyncOperation) { + selectCurrentIndex(); + } + }); + + this._mutationObserver.observe(this, { + childList: true, + }); + + selectCurrentIndex(); + } + + disconnectedCallback() { + this._mutationObserver?.disconnect(); + this._mutationObserver = null; + } + + updateSelectedIndex( + val, + oldPanel = this.querySelector(":scope > .deck-selected") + ) { + this._inAsyncOperation = false; + if (oldPanel != this._selectedPanel) { + oldPanel?.classList.remove("deck-selected"); + this._selectedPanel?.classList.add("deck-selected"); + } + this.setAttribute("selectedIndex", val); + } + + set selectedIndex(val) { + if (val < 0 || val >= this.children.length) { + return; + } + + let oldPanel = this._selectedPanel; + this._selectedPanel = this.children[val]; + + this._inAsyncOperation = this.isAsync; + if (!this._inAsyncOperation) { + this.updateSelectedIndex(val, oldPanel); + } + + if (this._selectedPanel != oldPanel) { + let event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + } + } + + get selectedIndex() { + let indexStr = this.getAttribute("selectedIndex"); + return indexStr ? parseInt(indexStr) : 0; + } + + set selectedPanel(val) { + this.selectedIndex = Array.from(this.children).indexOf(val); + } + + get selectedPanel() { + return this._selectedPanel; + } + } + + customElements.define("deck", MozDeck); + + class MozTabpanels extends MozDeck { + constructor() { + super(); + this._tabbox = null; + } + + get tabbox() { + // Memoize the result rather than replacing this getter, so that + // it can be reset if the parent changes. + if (this._tabbox) { + return this._tabbox; + } + + let parent = this.parentNode; + while (parent) { + if (parent.localName == "tabbox") { + break; + } + parent = parent.parentNode; + } + + return (this._tabbox = parent); + } + + /** + * nsIDOMXULRelatedElement + */ + getRelatedElement(aTabPanelElm) { + if (!aTabPanelElm) { + return null; + } + + let tabboxElm = this.tabbox; + if (!tabboxElm) { + return null; + } + + let tabsElm = tabboxElm.tabs; + if (!tabsElm) { + return null; + } + + // Return tab element having 'linkedpanel' attribute equal to the id + // of the tab panel or the same index as the tab panel element. + let tabpanelIdx = Array.prototype.indexOf.call( + this.children, + aTabPanelElm + ); + if (tabpanelIdx == -1) { + return null; + } + + let tabElms = tabsElm.allTabs; + let tabElmFromIndex = tabElms[tabpanelIdx]; + + let tabpanelId = aTabPanelElm.id; + if (tabpanelId) { + for (let idx = 0; idx < tabElms.length; idx++) { + let tabElm = tabElms[idx]; + if (tabElm.linkedPanel == tabpanelId) { + return tabElm; + } + } + } + + return tabElmFromIndex; + } + } + + MozXULElement.implementCustomInterface(MozTabpanels, [ + Ci.nsIDOMXULRelatedElement, + ]); + customElements.define("tabpanels", MozTabpanels); + + MozElements.MozTab = class MozTab extends MozElements.BaseText { + static get markup() { + return ` + <hbox class="tab-middle box-inherit" flex="1"> + <image class="tab-icon" role="presentation"></image> + <label class="tab-text" flex="1" role="presentation"></label> + </hbox> + `; + } + + constructor() { + super(); + + this.addEventListener("mousedown", this); + this.addEventListener("keydown", this); + + this.arrowKeysShouldWrap = AppConstants.platform == "macosx"; + } + + static get inheritedAttributes() { + return { + ".tab-middle": "align,dir,pack,orient,selected,visuallyselected", + ".tab-icon": "validate,src=image", + ".tab-text": "value=label,accesskey,crop,disabled", + }; + } + + connectedCallback() { + if (!this._initialized) { + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + this._initialized = true; + } + } + + on_mousedown(event) { + if (event.button != 0 || this.disabled) { + return; + } + + this.parentNode.ariaFocusedItem = null; + + if (this == this.parentNode.selectedItem) { + // This tab is already selected and we will fall + // through to mousedown behavior which sets focus on the current tab, + // Only a click on an already selected tab should focus the tab itself. + return; + } + + let stopwatchid = this.parentNode.getAttribute("stopwatchid"); + if (stopwatchid) { + TelemetryStopwatch.start(stopwatchid); + } + + // Call this before setting the 'ignorefocus' attribute because this + // will pass on focus if the formerly selected tab was focused as well. + this.closest("tabs")._selectNewTab(this); + + var isTabFocused = false; + try { + isTabFocused = document.commandDispatcher.focusedElement == this; + } catch (e) {} + + // Set '-moz-user-focus' to 'ignore' so that PostHandleEvent() can't + // focus the tab; we only want tabs to be focusable by the mouse if + // they are already focused. After a short timeout we'll reset + // '-moz-user-focus' so that tabs can be focused by keyboard again. + if (!isTabFocused) { + this.setAttribute("ignorefocus", "true"); + setTimeout(tab => tab.removeAttribute("ignorefocus"), 0, this); + } + + if (stopwatchid) { + TelemetryStopwatch.finish(stopwatchid); + } + } + + on_keydown(event) { + if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return; + } + switch (event.keyCode) { + case KeyEvent.DOM_VK_LEFT: { + let direction = window.getComputedStyle(this.parentNode).direction; + this.container.advanceSelectedTab( + direction == "ltr" ? -1 : 1, + this.arrowKeysShouldWrap + ); + event.preventDefault(); + break; + } + + case KeyEvent.DOM_VK_RIGHT: { + let direction = window.getComputedStyle(this.parentNode).direction; + this.container.advanceSelectedTab( + direction == "ltr" ? 1 : -1, + this.arrowKeysShouldWrap + ); + event.preventDefault(); + break; + } + + case KeyEvent.DOM_VK_UP: + this.container.advanceSelectedTab(-1, this.arrowKeysShouldWrap); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_DOWN: + this.container.advanceSelectedTab(1, this.arrowKeysShouldWrap); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_HOME: + this.container._selectNewTab(this.container.allTabs[0]); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_END: { + let { allTabs } = this.container; + this.container._selectNewTab(allTabs[allTabs.length - 1], -1); + event.preventDefault(); + break; + } + } + } + + set value(val) { + this.setAttribute("value", val); + } + + get value() { + return this.getAttribute("value"); + } + + get control() { + var parent = this.parentNode; + return parent.localName == "tabs" ? parent : null; + } + + get selected() { + return this.getAttribute("selected") == "true"; + } + + set _selected(val) { + if (val) { + this.setAttribute("selected", "true"); + this.setAttribute("visuallyselected", "true"); + } else { + this.removeAttribute("selected"); + this.removeAttribute("visuallyselected"); + } + } + + set linkedPanel(val) { + this.setAttribute("linkedpanel", val); + } + + get linkedPanel() { + return this.getAttribute("linkedpanel"); + } + }; + + MozXULElement.implementCustomInterface(MozElements.MozTab, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + customElements.define("tab", MozElements.MozTab); + + class TabsBase extends MozElements.BaseControl { + constructor() { + super(); + + this.addEventListener("DOMMouseScroll", event => { + if (Services.prefs.getBoolPref("toolkit.tabbox.switchByScrolling")) { + if (event.detail > 0) { + this.advanceSelectedTab(1, false); + } else { + this.advanceSelectedTab(-1, false); + } + event.stopPropagation(); + } + }); + } + + // to be called from derived class connectedCallback + baseConnect() { + this._tabbox = null; + this.ACTIVE_DESCENDANT_ID = + "keyboard-focused-tab-" + Math.trunc(Math.random() * 1000000); + + if (!this.hasAttribute("orient")) { + this.setAttribute("orient", "horizontal"); + } + + if (this.tabbox && this.tabbox.hasAttribute("selectedIndex")) { + let selectedIndex = parseInt(this.tabbox.getAttribute("selectedIndex")); + this.selectedIndex = selectedIndex > 0 ? selectedIndex : 0; + return; + } + + let children = this.allTabs; + let length = children.length; + for (var i = 0; i < length; i++) { + if (children[i].getAttribute("selected") == "true") { + this.selectedIndex = i; + return; + } + } + + var value = this.value; + if (value) { + this.value = value; + } else { + this.selectedIndex = 0; + } + } + + /** + * nsIDOMXULSelectControlElement + */ + get itemCount() { + return this.allTabs.length; + } + + set value(val) { + this.setAttribute("value", val); + var children = this.allTabs; + for (var c = children.length - 1; c >= 0; c--) { + if (children[c].value == val) { + this.selectedIndex = c; + break; + } + } + } + + get value() { + return this.getAttribute("value"); + } + + get tabbox() { + if (!this._tabbox) { + // Memoize the result in a field rather than replacing this property, + // so that it can be reset along with the binding. + this._tabbox = this.closest("tabbox"); + } + + return this._tabbox; + } + + set selectedIndex(val) { + var tab = this.getItemAtIndex(val); + if (!tab) { + return; + } + for (let otherTab of this.allTabs) { + if (otherTab != tab && otherTab.selected) { + otherTab._selected = false; + } + } + tab._selected = true; + + this.setAttribute("value", tab.value); + + let linkedPanel = this.getRelatedElement(tab); + if (linkedPanel) { + this.tabbox.setAttribute("selectedIndex", val); + + // This will cause an onselect event to fire for the tabpanel + // element. + this.tabbox.tabpanels.selectedPanel = linkedPanel; + } + } + + get selectedIndex() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].selected) { + return i; + } + } + return -1; + } + + set selectedItem(val) { + if (val && !val.selected) { + // The selectedIndex setter ignores invalid values + // such as -1 if |val| isn't one of our child nodes. + this.selectedIndex = this.getIndexOfItem(val); + } + } + + get selectedItem() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].selected) { + return tabs[i]; + } + } + return null; + } + + get ariaFocusedIndex() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].id == this.ACTIVE_DESCENDANT_ID) { + return i; + } + } + return -1; + } + + set ariaFocusedItem(val) { + let setNewItem = val && this.getIndexOfItem(val) != -1; + let clearExistingItem = this.ariaFocusedItem && (!val || setNewItem); + if (clearExistingItem) { + let ariaFocusedItem = this.ariaFocusedItem; + ariaFocusedItem.classList.remove("keyboard-focused-tab"); + ariaFocusedItem.id = ""; + this.selectedItem.removeAttribute("aria-activedescendant"); + let evt = new CustomEvent("AriaFocus"); + this.selectedItem.dispatchEvent(evt); + } + + if (setNewItem) { + this.ariaFocusedItem = null; + val.id = this.ACTIVE_DESCENDANT_ID; + val.classList.add("keyboard-focused-tab"); + this.selectedItem.setAttribute( + "aria-activedescendant", + this.ACTIVE_DESCENDANT_ID + ); + let evt = new CustomEvent("AriaFocus"); + val.dispatchEvent(evt); + } + } + + get ariaFocusedItem() { + return document.getElementById(this.ACTIVE_DESCENDANT_ID); + } + + /** + * nsIDOMXULRelatedElement + */ + getRelatedElement(aTabElm) { + if (!aTabElm) { + return null; + } + + let tabboxElm = this.tabbox; + if (!tabboxElm) { + return null; + } + + let tabpanelsElm = tabboxElm.tabpanels; + if (!tabpanelsElm) { + return null; + } + + // Get linked tab panel by 'linkedpanel' attribute on the given tab + // element. + let linkedPanelId = aTabElm.linkedPanel; + if (linkedPanelId) { + return this.ownerDocument.getElementById(linkedPanelId); + } + + // otherwise linked tabpanel element has the same index as the given + // tab element. + let tabElmIdx = this.getIndexOfItem(aTabElm); + return tabpanelsElm.children[tabElmIdx]; + } + + getIndexOfItem(item) { + return Array.prototype.indexOf.call(this.allTabs, item); + } + + getItemAtIndex(index) { + return this.allTabs[index] || null; + } + + /** + * Find an adjacent tab. + * + * @param {Node} startTab A <tab> element to start searching from. + * @param {Number} opts.direction 1 to search forward, -1 to search backward. + * @param {Boolean} opts.wrap If true, wrap around if the search reaches + * the end (or beginning) of the tab strip. + * @param {Boolean} opts.startWithAdjacent + * If true (which is the default), start + * searching from the next tab after (or + * before) startTab. If false, startTab may + * be returned if it passes the filter. + * @param {Boolean} opts.advance If false, start searching with startTab. If + * true, start searching with an adjacent tab. + * @param {Function} opts.filter A function to select which tabs to return. + * + * @return {Node | null} The next <tab> element or, if none exists, null. + */ + findNextTab(startTab, opts = {}) { + let { + direction = 1, + wrap = false, + startWithAdjacent = true, + filter = tab => true, + } = opts; + + let tab = startTab; + if (!startWithAdjacent && filter(tab)) { + return tab; + } + + let children = this.allTabs; + let i = children.indexOf(tab); + if (i < 0) { + return null; + } + + while (true) { + i += direction; + if (wrap) { + if (i < 0) { + i = children.length - 1; + } else if (i >= children.length) { + i = 0; + } + } else if (i < 0 || i >= children.length) { + return null; + } + + tab = children[i]; + if (tab == startTab) { + return null; + } + if (filter(tab)) { + return tab; + } + } + } + + _selectNewTab(aNewTab, aFallbackDir, aWrap) { + this.ariaFocusedItem = null; + + aNewTab = this.findNextTab(aNewTab, { + direction: aFallbackDir, + wrap: aWrap, + startWithAdjacent: false, + filter: tab => + !tab.hidden && !tab.disabled && this._canAdvanceToTab(tab), + }); + + var isTabFocused = false; + try { + isTabFocused = + document.commandDispatcher.focusedElement == this.selectedItem; + } catch (e) {} + this.selectedItem = aNewTab; + if (isTabFocused) { + aNewTab.focus(); + } else if (this.getAttribute("setfocus") != "false") { + let selectedPanel = this.tabbox.selectedPanel; + document.commandDispatcher.advanceFocusIntoSubtree(selectedPanel); + + // Make sure that the focus doesn't move outside the tabbox + if (this.tabbox) { + try { + let el = document.commandDispatcher.focusedElement; + while (el && el != this.tabbox.tabpanels) { + if (el == this.tabbox || el == selectedPanel) { + return; + } + el = el.parentNode; + } + aNewTab.focus(); + } catch (e) {} + } + } + } + + _canAdvanceToTab(aTab) { + return true; + } + + advanceSelectedTab(aDir, aWrap) { + let startTab = this.ariaFocusedItem || this.selectedItem; + let newTab = null; + + // Handle keyboard navigation for a hidden tab that can be selected, like the Firefox View tab, + // which has a random placement in this.allTabs. + if (startTab.hidden) { + if (aDir == 1) { + newTab = this.allTabs.find(tab => !tab.hidden); + } else { + newTab = this.allTabs.findLast(tab => !tab.hidden); + } + } else { + newTab = this.findNextTab(startTab, { + direction: aDir, + wrap: aWrap, + }); + } + + if (newTab && newTab != startTab) { + this._selectNewTab(newTab, aDir, aWrap); + } + } + + appendItem(label, value) { + var tab = document.createXULElement("tab"); + tab.setAttribute("label", label); + tab.setAttribute("value", value); + this.appendChild(tab); + return tab; + } + } + + MozXULElement.implementCustomInterface(TabsBase, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULRelatedElement, + ]); + + MozElements.TabsBase = TabsBase; + + class MozTabs extends TabsBase { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + let start = MozXULElement.parseXULToFragment( + `<spacer class="tabs-left"/>` + ); + this.insertBefore(start, this.firstChild); + + let end = MozXULElement.parseXULToFragment( + `<spacer class="tabs-right" flex="1"/>` + ); + this.insertBefore(end, null); + + this.baseConnect(); + } + + // Accessor for tabs. This element has spacers as the first and + // last elements and <tab>s are everything in between. + get allTabs() { + let children = Array.from(this.children); + return children.splice(1, children.length - 2); + } + + appendChild(tab) { + // insert before the end spacer. + this.insertBefore(tab, this.lastChild); + } + } + + customElements.define("tabs", MozTabs); +} diff --git a/toolkit/content/widgets/text.js b/toolkit/content/widgets/text.js new file mode 100644 index 0000000000..ca10f1489e --- /dev/null +++ b/toolkit/content/widgets/text.js @@ -0,0 +1,389 @@ +/* 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 MozXULTextElement = MozElements.MozElementMixin(XULTextElement); + + let gInsertSeparator = false; + let gAlwaysAppendAccessKey = false; + let gUnderlineAccesskey = + Services.prefs.getIntPref("ui.key.menuAccessKey") != 0; + if (gUnderlineAccesskey) { + try { + const nsIPrefLocalizedString = Ci.nsIPrefLocalizedString; + const prefNameInsertSeparator = + "intl.menuitems.insertseparatorbeforeaccesskeys"; + const prefNameAlwaysAppendAccessKey = + "intl.menuitems.alwaysappendaccesskeys"; + + let val = Services.prefs.getComplexValue( + prefNameInsertSeparator, + nsIPrefLocalizedString + ).data; + gInsertSeparator = val == "true"; + + val = Services.prefs.getComplexValue( + prefNameAlwaysAppendAccessKey, + nsIPrefLocalizedString + ).data; + gAlwaysAppendAccessKey = val == "true"; + } catch (e) { + gInsertSeparator = gAlwaysAppendAccessKey = true; + } + } + + class MozTextLabel extends MozXULTextElement { + constructor() { + super(); + this._lastFormattedAccessKey = null; + this.addEventListener("click", this._onClick); + } + + static get observedAttributes() { + return ["accesskey"]; + } + + set textContent(val) { + super.textContent = val; + this._lastFormattedAccessKey = null; + this.formatAccessKey(); + } + + get textContent() { + return super.textContent; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this.isConnectedAndReady || oldValue == newValue) { + return; + } + + // Note that this is only happening when "accesskey" attribute change: + this.formatAccessKey(); + } + + _onClick(event) { + let controlElement = this.labeledControlElement; + if (!controlElement || this.disabled) { + return; + } + controlElement.focus(); + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + if (controlElement.namespaceURI != XUL_NS) { + return; + } + + if ( + (controlElement.localName == "checkbox" || + controlElement.localName == "radio") && + controlElement.getAttribute("disabled") == "true" + ) { + return; + } + + if (controlElement.localName == "checkbox") { + controlElement.checked = !controlElement.checked; + } else if (controlElement.localName == "radio") { + controlElement.control.selectedItem = controlElement; + } + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.formatAccessKey(); + } + + set accessKey(val) { + this.setAttribute("accesskey", val); + var control = this.labeledControlElement; + if (control) { + control.setAttribute("accesskey", val); + } + } + + get accessKey() { + let accessKey = this.getAttribute("accesskey"); + return accessKey ? accessKey[0] : null; + } + + get labeledControlElement() { + let control = this.control; + return control ? document.getElementById(control) : null; + } + + set control(val) { + this.setAttribute("control", val); + } + + get control() { + return this.getAttribute("control"); + } + + // This is used to match the rendering of accesskeys from nsTextBoxFrame.cpp (i.e. when the + // label uses [value]). So this is just for when we have textContent. + formatAccessKey() { + // Skip doing any DOM manipulation whenever possible: + let accessKey = this.accessKey; + if ( + !gUnderlineAccesskey || + !this.isConnectedAndReady || + this._lastFormattedAccessKey == accessKey || + !this.textContent + ) { + return; + } + this._lastFormattedAccessKey = accessKey; + if (this.accessKeySpan) { + // Clear old accesskey + mergeElement(this.accessKeySpan); + this.accessKeySpan = null; + } + + if (this.hiddenColon) { + mergeElement(this.hiddenColon); + this.hiddenColon = null; + } + + if (this.accessKeyParens) { + this.accessKeyParens.remove(); + this.accessKeyParens = null; + } + + // If we used to have an accessKey but not anymore, we're done here + if (!accessKey) { + return; + } + + let labelText = this.textContent; + let accessKeyIndex = -1; + if (!gAlwaysAppendAccessKey) { + accessKeyIndex = labelText.indexOf(accessKey); + if (accessKeyIndex < 0) { + // Try again in upper case + accessKeyIndex = labelText + .toUpperCase() + .indexOf(accessKey.toUpperCase()); + } + } else if (labelText.endsWith(`(${accessKey.toUpperCase()})`)) { + accessKeyIndex = labelText.length - (1 + accessKey.length); // = index of accessKey. + } + + const HTML_NS = "http://www.w3.org/1999/xhtml"; + this.accessKeySpan = document.createElementNS(HTML_NS, "span"); + this.accessKeySpan.className = "accesskey"; + + // Note that if you change the following code, see the comment of + // nsTextBoxFrame::UpdateAccessTitle. + + // If accesskey is in the string, underline it: + if (accessKeyIndex >= 0) { + wrapChar(this, this.accessKeySpan, accessKeyIndex); + return; + } + + // If accesskey is not in string, append in parentheses + // If end is colon, we should insert before colon. + // i.e., "label:" -> "label(X):" + let colonHidden = false; + if (/:$/.test(labelText)) { + labelText = labelText.slice(0, -1); + this.hiddenColon = document.createElementNS(HTML_NS, "span"); + this.hiddenColon.className = "hiddenColon"; + this.hiddenColon.style.display = "none"; + // Hide the last colon by using span element. + // I.e., label<span style="display:none;">:</span> + wrapChar(this, this.hiddenColon, labelText.length); + colonHidden = true; + } + // If end is space(U+20), + // we should not add space before parentheses. + let endIsSpace = false; + if (/ $/.test(labelText)) { + endIsSpace = true; + } + + this.accessKeyParens = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "span" + ); + this.appendChild(this.accessKeyParens); + if (gInsertSeparator && !endIsSpace) { + this.accessKeyParens.textContent = " ("; + } else { + this.accessKeyParens.textContent = "("; + } + this.accessKeySpan.textContent = accessKey.toUpperCase(); + this.accessKeyParens.appendChild(this.accessKeySpan); + if (!colonHidden) { + this.accessKeyParens.appendChild(document.createTextNode(")")); + } else { + this.accessKeyParens.appendChild(document.createTextNode("):")); + } + } + } + + customElements.define("label", MozTextLabel); + + function mergeElement(element) { + // If the element has been removed already, return: + if (!element.isConnected) { + return; + } + if (Text.isInstance(element.previousSibling)) { + element.previousSibling.appendData(element.textContent); + } else { + element.parentNode.insertBefore(element.firstChild, element); + } + element.remove(); + } + + function wrapChar(parent, element, index) { + let treeWalker = document.createNodeIterator( + parent, + NodeFilter.SHOW_TEXT, + null + ); + let node = treeWalker.nextNode(); + while (index >= node.length) { + index -= node.length; + node = treeWalker.nextNode(); + } + if (index) { + node = node.splitText(index); + } + + node.parentNode.insertBefore(element, node); + if (node.length > 1) { + node.splitText(1); + } + element.appendChild(node); + } + + class MozTextLink extends MozXULTextElement { + constructor() { + super(); + + this.addEventListener( + "click", + event => { + if (event.button == 0 || event.button == 1) { + this.open(event); + } + }, + true + ); + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + this.click(); + }); + } + + connectedCallback() { + this.classList.add("text-link"); + } + + set href(val) { + this.setAttribute("href", val); + } + + get href() { + return this.getAttribute("href"); + } + + open(aEvent) { + var href = this.href; + if (!href || this.disabled || aEvent.defaultPrevented) { + return; + } + + var uri = null; + try { + const nsISSM = Ci.nsIScriptSecurityManager; + const secMan = + Cc["@mozilla.org/scriptsecuritymanager;1"].getService(nsISSM); + + uri = Services.io.newURI(href); + + let principal; + if (this.getAttribute("useoriginprincipal") == "true") { + principal = this.nodePrincipal; + } else { + principal = secMan.createNullPrincipal({}); + } + try { + secMan.checkLoadURIWithPrincipal( + principal, + uri, + nsISSM.DISALLOW_INHERIT_PRINCIPAL + ); + } catch (ex) { + var msg = + "Error: Cannot open a " + + uri.scheme + + ": link using \ + the text-link binding."; + console.error(msg); + return; + } + + const cID = "@mozilla.org/uriloader/external-protocol-service;1"; + const nsIEPS = Ci.nsIExternalProtocolService; + var protocolSvc = Cc[cID].getService(nsIEPS); + + // if the scheme is not an exposed protocol, then opening this link + // should be deferred to the system's external protocol handler + if (!protocolSvc.isExposedProtocol(uri.scheme)) { + protocolSvc.loadURI(uri, principal); + aEvent.preventDefault(); + return; + } + } catch (ex) { + console.error(ex); + } + + aEvent.preventDefault(); + href = uri ? uri.spec : href; + + // Try handing off the link to the host application, e.g. for + // opening it in a tabbed browser. + var linkHandled = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + linkHandled.data = false; + let { shiftKey, ctrlKey, metaKey, altKey, button } = aEvent; + let data = { shiftKey, ctrlKey, metaKey, altKey, button, href }; + Services.obs.notifyObservers( + linkHandled, + "handle-xul-text-link", + JSON.stringify(data) + ); + if (linkHandled.data) { + return; + } + + // otherwise, fall back to opening the anchor directly + var win = window; + if (window.isChromeWindow) { + while (win.opener && !win.opener.closed) { + win = win.opener; + } + } + win.open(href, "_blank", "noopener"); + } + } + + customElements.define("text-link", MozTextLink, { extends: "label" }); +} diff --git a/toolkit/content/widgets/textrecognition.js b/toolkit/content/widgets/textrecognition.js new file mode 100644 index 0000000000..887d576770 --- /dev/null +++ b/toolkit/content/widgets/textrecognition.js @@ -0,0 +1,366 @@ +/* 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 a UA widget. It runs in per-origin UA widget scope, +// to be loaded by UAWidgetsChild.jsm. + +this.TextRecognitionWidget = class { + /** + * @param {ShadowRoot} shadowRoot + * @param {Record<string, string | boolean | number>} _prefs + */ + constructor(shadowRoot, _prefs) { + /** @type {ShadowRoot} */ + this.shadowRoot = shadowRoot; + /** @type {HTMLElement} */ + this.element = shadowRoot.host; + /** @type {Document} */ + this.document = this.element.ownerDocument; + /** @type {Window} */ + this.window = this.document.defaultView; + /** @type {ResizeObserver} */ + this.resizeObserver = null; + /** @type {Map<HTMLSpanElement, DOMRect} */ + this.spanRects = new Map(); + /** @type {boolean} */ + this.isInitialized = false; + /** @type {null | number} */ + this.lastCanvasStyleWidth = null; + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.resizeObserver = new this.window.ResizeObserver(() => { + this.positionSpans(); + }); + this.resizeObserver.observe(this.element); + } + + positionSpans() { + if (!this.shadowRoot.firstChild) { + return; + } + this.lazilyInitialize(); + + /** @type {HTMLDivElement} */ + const div = this.shadowRoot.firstChild; + const canvas = div.querySelector("canvas"); + const spans = div.querySelectorAll("span"); + + // TODO Bug 1770438 - The <img> element does not currently let child elements be + // sized relative to the size of the containing <img> element. It would be better + // to teach the <img> element how to do this. For the prototype, do the more expensive + // operation of getting the bounding client rect, and handle the positioning manually. + const imgRect = this.element.getBoundingClientRect(); + div.style.width = imgRect.width + "px"; + div.style.height = imgRect.height + "px"; + canvas.style.width = imgRect.width + "px"; + canvas.style.height = imgRect.height + "px"; + + // The ctx is only available when redrawing the canvas. This is operation is only + // done when necessary, as it can be expensive. + /** @type {null | CanvasRenderingContext2D} */ + let ctx = null; + + if ( + // The canvas hasn't been drawn to yet. + this.lastCanvasStyleWidth === null || + // Only redraw when the image has grown 25% larger. This percentage was chosen + // as it visually seemed to work well, with the canvas never appearing blurry + // when manually testing it. + imgRect.width > this.lastCanvasStyleWidth * 1.25 + ) { + const dpr = this.window.devicePixelRatio; + canvas.width = imgRect.width * dpr; + canvas.height = imgRect.height * dpr; + this.lastCanvasStyleWidth = imgRect.width; + + ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + ctx.fillStyle = "#00000088"; + ctx.fillRect(0, 0, imgRect.width, imgRect.height); + + ctx.beginPath(); + } + + for (const span of spans) { + let spanRect = this.spanRects.get(span); + if (!spanRect) { + // This only needs to happen once. + spanRect = span.getBoundingClientRect(); + this.spanRects.set(span, spanRect); + } + + const points = span.dataset.points.split(",").map(p => Number(p)); + // Use the points in the string, e.g. + // "0.0275349,0.14537,0.0275349,0.244662,0.176966,0.244565,0.176966,0.145273" + // 0 1 2 3 4 5 6 7 + // ^ bottomleft ^ topleft ^ topright ^ bottomright + let [ + bottomLeftX, + bottomLeftY, + topLeftX, + topLeftY, + topRightX, + topRightY, + bottomRightX, + bottomRightY, + ] = points; + + // Invert the Y. + topLeftY = 1 - topLeftY; + topRightY = 1 - topRightY; + bottomLeftY = 1 - bottomLeftY; + bottomRightY = 1 - bottomRightY; + + // Create a projection matrix to position the <span> relative to the bounds. + // prettier-ignore + const mat4 = projectPoints( + spanRect.width, spanRect.height, + imgRect.width * topLeftX, imgRect.height * topLeftY, + imgRect.width * topRightX, imgRect.height * topRightY, + imgRect.width * bottomLeftX, imgRect.height * bottomLeftY, + imgRect.width * bottomRightX, imgRect.height * bottomRightY + ); + + span.style.transform = "matrix3d(" + mat4.join(", ") + ")"; + + if (ctx) { + const inset = 3; + ctx.moveTo( + imgRect.width * bottomLeftX + inset, + imgRect.height * bottomLeftY - inset + ); + ctx.lineTo( + imgRect.width * topLeftX + inset, + imgRect.height * topLeftY + inset + ); + ctx.lineTo( + imgRect.width * topRightX - inset, + imgRect.height * topRightY + inset + ); + ctx.lineTo( + imgRect.width * bottomRightX - inset, + imgRect.height * bottomRightY - inset + ); + ctx.closePath(); + } + } + + if (ctx) { + // This composite operation will cut out the quads. The color is arbitrary. + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + ctx.fill(); + + // Creating a round line will grow the selection slightly, and round the corners. + ctx.lineWidth = 10; + ctx.lineJoin = "round"; + ctx.strokeStyle = "#ffffff"; + ctx.stroke(); + } + } + + teardown() { + this.shadowRoot.firstChild.remove(); + this.resizeObserver.disconnect(); + this.spanRects.clear(); + } + + lazilyInitialize() { + if (this.isInitialized) { + return; + } + this.isInitialized = true; + + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="textrecognition" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/textrecognition.css" /> + <canvas /> + <!-- The spans will be reattached here --> + </div>`, + "application/xml" + ); + if ( + this.shadowRoot.children.length !== 1 || + this.shadowRoot.firstChild.tagName !== "DIV" + ) { + throw new Error( + "Expected the shadowRoot to have a single div as the root element." + ); + } + + const spansDiv = this.shadowRoot.firstChild; + // Example layout of spansDiv: + // <div> + // <span data-points="0.0275349,0.14537,0.0275349,0.244662,0.176966,0.244565,0.176966,0.145273"> + // Text that has been recognized + // </span> + // ... + // </div> + spansDiv.remove(); + + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true /* deep */ + ); + + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot.firstChild, + spansDiv, + true /* deep */ + ); + } +}; + +/** + * A three dimensional vector. + * + * @typedef {[number, number, number]} Vec3 + */ + +/** + * A 3x3 matrix. + * + * @typedef {[number, number, number, + * number, number, number, + * number, number, number]} Matrix3 + */ + +/** + * A 4x4 matrix. + * + * @typedef {[number, number, number, number, + * number, number, number, number, + * number, number, number, number, + * number, number, number, number]} Matrix4 + */ + +/** + * Compute the adjugate matrix. + * https://en.wikipedia.org/wiki/Adjugate_matrix + * + * @param {Matrix3} m + * @returns {Matrix3} + */ +function computeAdjugate(m) { + // prettier-ignore + return [ + m[4] * m[8] - m[5] * m[7], + m[2] * m[7] - m[1] * m[8], + m[1] * m[5] - m[2] * m[4], + m[5] * m[6] - m[3] * m[8], + m[0] * m[8] - m[2] * m[6], + m[2] * m[3] - m[0] * m[5], + m[3] * m[7] - m[4] * m[6], + m[1] * m[6] - m[0] * m[7], + m[0] * m[4] - m[1] * m[3], + ]; +} + +/** + * @param {Matrix3} a + * @param {Matrix3} b + * @returns {Matrix3} + */ +function multiplyMat3(a, b) { + let out = []; + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + let sum = 0; + for (let k = 0; k < 3; k++) { + sum += a[3 * i + k] * b[3 * k + j]; + } + out[3 * i + j] = sum; + } + } + return out; +} + +/** + * @param {Matrix3} m + * @param {Vec3} v + * @returns {Vec3} + */ +function multiplyMat3Vec3(m, v) { + // prettier-ignore + return [ + m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2], + ]; +} + +/** + * @returns {Matrix3} + */ +function basisToPoints(x1, y1, x2, y2, x3, y3, x4, y4) { + /** @type {Matrix3} */ + let mat3 = [x1, x2, x3, y1, y2, y3, 1, 1, 1]; + let vec3 = multiplyMat3Vec3(computeAdjugate(mat3), [x4, y4, 1]); + // prettier-ignore + return multiplyMat3( + mat3, + [ + vec3[0], 0, 0, + 0, vec3[1], 0, + 0, 0, vec3[2] + ] + ); +} + +/** + * @type {(...Matrix4) => Matrix3} + */ +// prettier-ignore +function general2DProjection( + x1s, y1s, x1d, y1d, + x2s, y2s, x2d, y2d, + x3s, y3s, x3d, y3d, + x4s, y4s, x4d, y4d +) { + let s = basisToPoints(x1s, y1s, x2s, y2s, x3s, y3s, x4s, y4s); + let d = basisToPoints(x1d, y1d, x2d, y2d, x3d, y3d, x4d, y4d); + return multiplyMat3(d, computeAdjugate(s)); +} + +/** + * Given a width and height, compute a projection matrix to points 1-4. + * + * The points (x1,y1) through (x4, y4) use the following ordering: + * + * w + * ┌─────┐ project 1 ─────── 2 + * h │ │ --> │ / + * └─────┘ │ / + * 3 ──── 4 + * + * @returns {Matrix4} + */ +function projectPoints(w, h, x1, y1, x2, y2, x3, y3, x4, y4) { + // prettier-ignore + const mat3 = general2DProjection( + 0, 0, x1, y1, + w, 0, x2, y2, + 0, h, x3, y3, + w, h, x4, y4 + ); + + for (let i = 0; i < 9; i++) { + mat3[i] = mat3[i] / mat3[8]; + } + + // prettier-ignore + return [ + mat3[0], mat3[3], 0, mat3[6], + mat3[1], mat3[4], 0, mat3[7], + 0, 0, 1, 0, + mat3[2], mat3[5], 0, mat3[8], + ]; +} diff --git a/toolkit/content/widgets/timekeeper.js b/toolkit/content/widgets/timekeeper.js new file mode 100644 index 0000000000..9e53ce602f --- /dev/null +++ b/toolkit/content/widgets/timekeeper.js @@ -0,0 +1,445 @@ +/* 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"; + +/** + * TimeKeeper keeps track of the time states. Given min, max, step, and + * format (12/24hr), TimeKeeper will determine the ranges of possible + * selections, and whether or not the current time state is out of range + * or off step. + * + * @param {Object} props + * { + * {Date} min + * {Date} max + * {Number} step + * {String} format: Either "12" or "24" + * } + */ +function TimeKeeper(props) { + this.props = props; + this.state = { time: new Date(0), ranges: {} }; +} + +{ + const DAY_PERIOD_IN_HOURS = 12, + SECOND_IN_MS = 1000, + MINUTE_IN_MS = 60000, + HOUR_IN_MS = 3600000, + DAY_PERIOD_IN_MS = 43200000, + DAY_IN_MS = 86400000, + TIME_FORMAT_24 = "24"; + + TimeKeeper.prototype = { + /** + * Getters for different time units. + * @return {Number} + */ + get hour() { + return this.state.time.getUTCHours(); + }, + get minute() { + return this.state.time.getUTCMinutes(); + }, + get second() { + return this.state.time.getUTCSeconds(); + }, + get millisecond() { + return this.state.time.getUTCMilliseconds(); + }, + get dayPeriod() { + // 0 stands for AM and 12 for PM + return this.state.time.getUTCHours() < DAY_PERIOD_IN_HOURS + ? 0 + : DAY_PERIOD_IN_HOURS; + }, + + /** + * Get the ranges of different time units. + * @return {Object} + * { + * {Array<Number>} dayPeriod + * {Array<Number>} hours + * {Array<Number>} minutes + * {Array<Number>} seconds + * {Array<Number>} milliseconds + * } + */ + get ranges() { + return this.state.ranges; + }, + + /** + * Set new time, check if the current state is valid, and set ranges. + * + * @param {Object} timeState: The new time + * { + * {Number} hour [optional] + * {Number} minute [optional] + * {Number} second [optional] + * {Number} millisecond [optional] + * } + */ + setState(timeState) { + const { min, max } = this.props; + const { hour, minute, second, millisecond } = timeState; + + if (hour != undefined) { + this.state.time.setUTCHours(hour); + } + if (minute != undefined) { + this.state.time.setUTCMinutes(minute); + } + if (second != undefined) { + this.state.time.setUTCSeconds(second); + } + if (millisecond != undefined) { + this.state.time.setUTCMilliseconds(millisecond); + } + + this.state.isOffStep = this._isOffStep(this.state.time); + this.state.isOutOfRange = this.state.time < min || this.state.time > max; + this.state.isInvalid = this.state.isOutOfRange || this.state.isOffStep; + + this._setRanges(this.dayPeriod, this.hour, this.minute, this.second); + }, + + /** + * Set day-period (AM/PM) + * @param {Number} dayPeriod: 0 as AM, 12 as PM + */ + setDayPeriod(dayPeriod) { + if (dayPeriod == this.dayPeriod) { + return; + } + + if (dayPeriod == 0) { + this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS }); + } else { + this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS }); + } + }, + + /** + * Set hour in 24hr format (0 ~ 23) + * @param {Number} hour + */ + setHour(hour) { + this.setState({ hour }); + }, + + /** + * Set minute (0 ~ 59) + * @param {Number} minute + */ + setMinute(minute) { + this.setState({ minute }); + }, + + /** + * Set second (0 ~ 59) + * @param {Number} second + */ + setSecond(second) { + this.setState({ second }); + }, + + /** + * Set millisecond (0 ~ 999) + * @param {Number} millisecond + */ + setMillisecond(millisecond) { + this.setState({ millisecond }); + }, + + /** + * Calculate the range of possible choices for each time unit. + * Reuse the old result if the input has not changed. + * + * @param {Number} dayPeriod + * @param {Number} hour + * @param {Number} minute + * @param {Number} second + */ + _setRanges(dayPeriod, hour, minute, second) { + this.state.ranges.dayPeriod = + this.state.ranges.dayPeriod || this._getDayPeriodRange(); + + if (this.state.dayPeriod != dayPeriod) { + this.state.ranges.hours = this._getHoursRange(dayPeriod); + } + + if (this.state.hour != hour) { + this.state.ranges.minutes = this._getMinutesRange(hour); + } + + if (this.state.hour != hour || this.state.minute != minute) { + this.state.ranges.seconds = this._getSecondsRange(hour, minute); + } + + if ( + this.state.hour != hour || + this.state.minute != minute || + this.state.second != second + ) { + this.state.ranges.milliseconds = this._getMillisecondsRange( + hour, + minute, + second + ); + } + + // Save the time states for comparison. + this.state.dayPeriod = dayPeriod; + this.state.hour = hour; + this.state.minute = minute; + this.state.second = second; + }, + + /** + * Get the AM/PM range. Return an empty array if in 24hr mode. + * + * @return {Array<Number>} + */ + _getDayPeriodRange() { + if (this.props.format == TIME_FORMAT_24) { + return []; + } + + const start = 0; + const end = DAY_IN_MS - 1; + const minStep = DAY_PERIOD_IN_MS; + const formatter = time => + new Date(time).getUTCHours() < DAY_PERIOD_IN_HOURS + ? 0 + : DAY_PERIOD_IN_HOURS; + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the hours range. + * + * @param {Number} dayPeriod + * @return {Array<Number>} + */ + _getHoursRange(dayPeriod) { + const { format } = this.props; + const start = format == "24" ? 0 : dayPeriod * HOUR_IN_MS; + const end = format == "24" ? DAY_IN_MS - 1 : start + DAY_PERIOD_IN_MS - 1; + const minStep = HOUR_IN_MS; + const formatter = time => new Date(time).getUTCHours(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the minutes range + * + * @param {Number} hour + * @return {Array<Number>} + */ + _getMinutesRange(hour) { + const start = hour * HOUR_IN_MS; + const end = start + HOUR_IN_MS - 1; + const minStep = MINUTE_IN_MS; + const formatter = time => new Date(time).getUTCMinutes(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the seconds range + * + * @param {Number} hour + * @param {Number} minute + * @return {Array<Number>} + */ + _getSecondsRange(hour, minute) { + const start = hour * HOUR_IN_MS + minute * MINUTE_IN_MS; + const end = start + MINUTE_IN_MS - 1; + const minStep = SECOND_IN_MS; + const formatter = time => new Date(time).getUTCSeconds(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the milliseconds range + * @param {Number} hour + * @param {Number} minute + * @param {Number} second + * @return {Array<Number>} + */ + _getMillisecondsRange(hour, minute, second) { + const start = + hour * HOUR_IN_MS + minute * MINUTE_IN_MS + second * SECOND_IN_MS; + const end = start + SECOND_IN_MS - 1; + const minStep = 1; + const formatter = time => new Date(time).getUTCMilliseconds(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Calculate the range of possible steps. + * + * @param {Number} startValue: Start time in ms + * @param {Number} endValue: End time in ms + * @param {Number} minStep: Smallest step in ms for the time unit + * @param {Function} formatter: Outputs time in a particular format + * @return {Array<Object>} + * { + * {Number} value + * {Boolean} enabled + * } + */ + _getSteps(startValue, endValue, minStep, formatter) { + const { min, max, step } = this.props; + // The timeStep should be big enough so that there won't be + // duplications. Ex: minimum step for minute should be 60000ms, + // if smaller than that, next step might return the same minute. + const timeStep = Math.max(minStep, step); + + // Make sure the starting point and end point is not off step + let time = + min.valueOf() + + Math.ceil((startValue - min.valueOf()) / timeStep) * timeStep; + let maxValue = + min.valueOf() + + Math.floor((max.valueOf() - min.valueOf()) / step) * step; + let steps = []; + + // Increment by timeStep until reaching the end of the range. + while (time <= endValue) { + steps.push({ + value: formatter(time), + // Check if the value is within the min and max. If it's out of range, + // also check for the case when minStep is too large, and has stepped out + // of range when it should be enabled. + enabled: + (time >= min.valueOf() && time <= max.valueOf()) || + (time > maxValue && + startValue <= maxValue && + endValue >= maxValue && + formatter(time) == formatter(maxValue)), + }); + time += timeStep; + } + + return steps; + }, + + /** + * A generic function for stepping up or down from a value of a range. + * It stops at the upper and lower limits. + * + * @param {Number} current: The current value + * @param {Number} offset: The offset relative to current value + * @param {Array<Object>} range: List of possible steps + * @return {Number} The new value + */ + _step(current, offset, range) { + const index = range.findIndex(step => step.value == current); + const newIndex = + offset > 0 + ? Math.min(index + offset, range.length - 1) + : Math.max(index + offset, 0); + return range[newIndex].value; + }, + + /** + * Step up or down AM/PM + * + * @param {Number} offset + */ + stepDayPeriodBy(offset) { + const current = this.dayPeriod; + const dayPeriod = this._step( + current, + offset, + this.state.ranges.dayPeriod + ); + + if (current != dayPeriod) { + this.hour < DAY_PERIOD_IN_HOURS + ? this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS }) + : this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS }); + } + }, + + /** + * Step up or down hours + * + * @param {Number} offset + */ + stepHourBy(offset) { + const current = this.hour; + const hour = this._step(current, offset, this.state.ranges.hours); + + if (current != hour) { + this.setState({ hour }); + } + }, + + /** + * Step up or down minutes + * + * @param {Number} offset + */ + stepMinuteBy(offset) { + const current = this.minute; + const minute = this._step(current, offset, this.state.ranges.minutes); + + if (current != minute) { + this.setState({ minute }); + } + }, + + /** + * Step up or down seconds + * + * @param {Number} offset + */ + stepSecondBy(offset) { + const current = this.second; + const second = this._step(current, offset, this.state.ranges.seconds); + + if (current != second) { + this.setState({ second }); + } + }, + + /** + * Step up or down milliseconds + * + * @param {Number} offset + */ + stepMillisecondBy(offset) { + const current = this.milliseconds; + const millisecond = this._step( + current, + offset, + this.state.ranges.millisecond + ); + + if (current != millisecond) { + this.setState({ millisecond }); + } + }, + + /** + * Checks if the time state is off step. + * + * @param {Date} time + * @return {Boolean} + */ + _isOffStep(time) { + const { min, step } = this.props; + + return (time.valueOf() - min.valueOf()) % step != 0; + }, + }; +} diff --git a/toolkit/content/widgets/timepicker.js b/toolkit/content/widgets/timepicker.js new file mode 100644 index 0000000000..83c4840a70 --- /dev/null +++ b/toolkit/content/widgets/timepicker.js @@ -0,0 +1,291 @@ +/* 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/. */ + +/* import-globals-from timekeeper.js */ +/* import-globals-from spinner.js */ + +"use strict"; + +function TimePicker(context) { + this.context = context; + this._attachEventListeners(); +} + +{ + const DAY_PERIOD_IN_HOURS = 12, + DAY_IN_MS = 86400000; + + TimePicker.prototype = { + /** + * Initializes the time picker. Set the default states and properties. + * @param {Object} props + * { + * {Number} hour [optional]: Hour in 24 hours format (0~23), default is current hour + * {Number} minute [optional]: Minute (0~59), default is current minute + * {Number} min: Minimum time, in ms + * {Number} max: Maximum time, in ms + * {Number} step: Step size in ms + * {String} format [optional]: "12" for 12 hours, "24" for 24 hours format + * {String} locale [optional]: User preferred locale + * } + */ + init(props) { + this.props = props || {}; + this._setDefaultState(); + this._createComponents(); + this._setComponentStates(); + // TODO(bug 1828721): This is a bit sad. + window.PICKER_READY = true; + document.dispatchEvent(new CustomEvent("PickerReady")); + }, + + /* + * Set initial time states. If there's no hour & minute, it will + * use the current time. The Time module keeps track of the time states, + * and calculates the valid options given the time, min, max, step, + * and format (12 or 24). + */ + _setDefaultState() { + const { hour, minute, min, max, step, format } = this.props; + const now = new Date(); + + let timerHour = hour == undefined ? now.getHours() : hour; + let timerMinute = minute == undefined ? now.getMinutes() : minute; + let timeKeeper = new TimeKeeper({ + min: new Date(Number.isNaN(min) ? 0 : min), + max: new Date(Number.isNaN(max) ? DAY_IN_MS - 1 : max), + step, + format: format || "12", + }); + timeKeeper.setState({ hour: timerHour, minute: timerMinute }); + + this.state = { timeKeeper }; + }, + + /** + * Initalize the spinner components. + */ + _createComponents() { + const { locale, format } = this.props; + const { timeKeeper } = this.state; + + const wrapSetValueFn = setTimeFunction => { + return value => { + setTimeFunction(value); + this._setComponentStates(); + this._dispatchState(); + }; + }; + const numberFormat = new Intl.NumberFormat(locale).format; + + this.components = { + hour: new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setHour(value); + this.state.isHourSet = true; + }), + getDisplayString: hour => { + if (format == "24") { + return numberFormat(hour); + } + // Hour 0 in 12 hour format is displayed as 12. + const hourIn12 = hour % DAY_PERIOD_IN_HOURS; + return hourIn12 == 0 ? numberFormat(12) : numberFormat(hourIn12); + }, + }, + this.context + ), + minute: new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setMinute(value); + this.state.isMinuteSet = true; + }), + getDisplayString: minute => numberFormat(minute), + }, + this.context + ), + }; + + this._insertLayoutElement({ + tag: "div", + textContent: ":", + className: "colon", + insertBefore: this.components.minute.elements.container, + }); + + // The AM/PM spinner is only available in 12hr mode + // TODO: Replace AM & PM string with localized string + if (format == "12") { + this.components.dayPeriod = new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setDayPeriod(value); + this.state.isDayPeriodSet = true; + }), + getDisplayString: dayPeriod => (dayPeriod == 0 ? "AM" : "PM"), + hideButtons: true, + }, + this.context + ); + + this._insertLayoutElement({ + tag: "div", + className: "spacer", + insertBefore: this.components.dayPeriod.elements.container, + }); + } + }, + + /** + * Insert element for layout purposes. + * + * @param {Object} + * { + * {String} tag: The tag to create + * {DOMElement} insertBefore: The DOM node to insert before + * {String} className [optional]: Class name + * {String} textContent [optional]: Text content + * } + */ + _insertLayoutElement({ tag, insertBefore, className, textContent }) { + let el = document.createElement(tag); + el.textContent = textContent; + el.className = className; + this.context.insertBefore(el, insertBefore); + }, + + /** + * Set component states. + */ + _setComponentStates() { + const { timeKeeper, isHourSet, isMinuteSet, isDayPeriodSet } = this.state; + const isInvalid = timeKeeper.state.isInvalid; + // Value is set to min if it's first opened and time state is invalid + const setToMinValue = + !isHourSet && !isMinuteSet && !isDayPeriodSet && isInvalid; + + this.components.hour.setState({ + value: setToMinValue + ? timeKeeper.ranges.hours[0].value + : timeKeeper.hour, + items: timeKeeper.ranges.hours, + isInfiniteScroll: true, + isValueSet: isHourSet, + isInvalid, + }); + + this.components.minute.setState({ + value: setToMinValue + ? timeKeeper.ranges.minutes[0].value + : timeKeeper.minute, + items: timeKeeper.ranges.minutes, + isInfiniteScroll: true, + isValueSet: isMinuteSet, + isInvalid, + }); + + // The AM/PM spinner is only available in 12hr mode + if (this.props.format == "12") { + this.components.dayPeriod.setState({ + value: setToMinValue + ? timeKeeper.ranges.dayPeriod[0].value + : timeKeeper.dayPeriod, + items: timeKeeper.ranges.dayPeriod, + isInfiniteScroll: false, + isValueSet: isDayPeriodSet, + isInvalid, + }); + } + }, + + /** + * Dispatch CustomEvent to pass the state of picker to the panel. + */ + _dispatchState() { + const { hour, minute } = this.state.timeKeeper; + const { isHourSet, isMinuteSet, isDayPeriodSet } = this.state; + // The panel is listening to window for postMessage event, so we + // do postMessage to itself to send data to input boxes. + window.postMessage( + { + name: "PickerPopupChanged", + detail: { + hour, + minute, + isHourSet, + isMinuteSet, + isDayPeriodSet, + }, + }, + "*" + ); + }, + _attachEventListeners() { + window.addEventListener("message", this); + document.addEventListener("mousedown", this); + }, + + /** + * Handle events. + * + * @param {Event} event + */ + handleEvent(event) { + switch (event.type) { + case "message": { + this.handleMessage(event); + break; + } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setCapture(); + break; + } + } + }, + + /** + * Handle postMessage events. + * + * @param {Event} event + */ + handleMessage(event) { + switch (event.data.name) { + case "PickerSetValue": { + this.set(event.data.detail); + break; + } + case "PickerInit": { + this.init(event.data.detail); + break; + } + } + }, + + /** + * Set the time state and update the components with the new state. + * + * @param {Object} timeState + * { + * {Number} hour [optional] + * {Number} minute [optional] + * {Number} second [optional] + * {Number} millisecond [optional] + * } + */ + set(timeState) { + if (timeState.hour != undefined) { + this.state.isHourSet = true; + } + if (timeState.minute != undefined) { + this.state.isMinuteSet = true; + } + this.state.timeKeeper.setState(timeState); + this._setComponentStates(); + }, + }; +} diff --git a/toolkit/content/widgets/toolbarbutton.js b/toolkit/content/widgets/toolbarbutton.js new file mode 100644 index 0000000000..31dd0dc545 --- /dev/null +++ b/toolkit/content/widgets/toolbarbutton.js @@ -0,0 +1,231 @@ +/* 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 KEEP_CHILDREN = new Set([ + "observes", + "template", + "menupopup", + "panel", + "tooltip", + ]); + + window.addEventListener( + "popupshowing", + e => { + if (e.originalTarget.ownerDocument != document) { + return; + } + + e.originalTarget.setAttribute("hasbeenopened", "true"); + for (let el of e.originalTarget.querySelectorAll("toolbarbutton")) { + el.render(); + } + }, + { capture: true } + ); + + class MozToolbarbutton extends MozElements.ButtonBase { + static get inheritedAttributes() { + // Note: if you remove 'wrap' or 'label' from the inherited attributes, + // you'll need to add them to observedAttributes. + return { + ".toolbarbutton-icon": + "validate,src=image,label,type,consumeanchor,triggeringprincipal=iconloadingprincipal", + ".toolbarbutton-text": "accesskey,crop,dragover-top,wrap", + ".toolbarbutton-menu-dropmarker": "disabled,label", + + ".toolbarbutton-badge": "text=badge,style=badgeStyle", + }; + } + + static get fragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <image class="toolbarbutton-icon"></image> + <label class="toolbarbutton-text" crop="end" flex="1"></label> + `), + true + ); + Object.defineProperty(this, "fragment", { value: frag }); + return frag; + } + + static get badgedFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <stack class="toolbarbutton-badge-stack"> + <image class="toolbarbutton-icon"/> + <html:label class="toolbarbutton-badge"/> + </stack> + <label class="toolbarbutton-text" crop="end" flex="1"/> + `), + true + ); + Object.defineProperty(this, "badgedFragment", { value: frag }); + return frag; + } + + static get dropmarkerFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <dropmarker type="menu" class="toolbarbutton-menu-dropmarker"></dropmarker> + `), + true + ); + Object.defineProperty(this, "dropmarkerFragment", { value: frag }); + return frag; + } + + get _hasRendered() { + return this.querySelector(":scope > .toolbarbutton-text") != null; + } + + get _textNode() { + let node = this.getElementForAttrInheritance(".toolbarbutton-text"); + if (node) { + Object.defineProperty(this, "_textNode", { value: node }); + } + return node; + } + + _setLabel() { + let label = this.getAttribute("label") || ""; + let hasLabel = this.hasAttribute("label"); + if (this.getAttribute("wrap") == "true") { + this._textNode.removeAttribute("value"); + this._textNode.textContent = label; + } else { + this._textNode.textContent = ""; + if (hasLabel) { + this._textNode.setAttribute("value", label); + } else { + this._textNode.removeAttribute("value"); + } + } + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue || !this.initializedAttributeInheritance) { + return; + } + // Deal with single/multiline label inheritance: + if (name == "label" || name == "wrap") { + this._setLabel(); + } + // The normal implementation will deal with everything else. + super.attributeChangedCallback(name, oldValue, newValue); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + // Defer creating DOM elements for content inside popups. + // These will be added in the popupshown handler above. + let panel = this.closest("panel"); + if (panel && !panel.hasAttribute("hasbeenopened")) { + return; + } + + this.render(); + } + + render() { + if (this._hasRendered) { + return; + } + + let badged = this.getAttribute("badged") == "true"; + + if (badged) { + let moveChildren = []; + for (let child of this.children) { + if (!KEEP_CHILDREN.has(child.tagName)) { + moveChildren.push(child); + } + } + + this.appendChild(this.constructor.badgedFragment.cloneNode(true)); + + if (this.hasAttribute("wantdropmarker")) { + this.appendChild(this.constructor.dropmarkerFragment.cloneNode(true)); + } + + if (moveChildren.length) { + let { badgeStack, icon } = this; + for (let child of moveChildren) { + if (child.getAttribute("move-after-stack") === "true") { + this.appendChild(child); + } else { + badgeStack.insertBefore(child, icon); + } + } + } + } else { + let moveChildren = []; + for (let child of this.children) { + if (!KEEP_CHILDREN.has(child.tagName) && child.tagName != "box") { + // XBL toolbarbutton doesn't insert any anonymous content + // if it has a child of any other type + return; + } + + if (child.tagName == "box") { + moveChildren.push(child); + } + } + + this.appendChild(this.constructor.fragment.cloneNode(true)); + + if (this.hasAttribute("wantdropmarker")) { + this.appendChild(this.constructor.dropmarkerFragment.cloneNode(true)); + } + + // XBL toolbarbutton explicitly places any <box> children + // right before the menu marker. + for (let child of moveChildren) { + this.insertBefore(child, this.lastChild); + } + } + + this.initializeAttributeInheritance(); + this._setLabel(); + } + + get icon() { + return this.querySelector(".toolbarbutton-icon"); + } + + get badgeLabel() { + return this.querySelector(".toolbarbutton-badge"); + } + + get badgeStack() { + return this.querySelector(".toolbarbutton-badge-stack"); + } + + get multilineLabel() { + if (this.getAttribute("wrap") == "true") { + return this._textNode; + } + return null; + } + + get dropmarker() { + return this.querySelector(".toolbarbutton-menu-dropmarker"); + } + + get menupopup() { + return this.querySelector("menupopup"); + } + } + + customElements.define("toolbarbutton", MozToolbarbutton); +} diff --git a/toolkit/content/widgets/tree.js b/toolkit/content/widgets/tree.js new file mode 100644 index 0000000000..322e42586e --- /dev/null +++ b/toolkit/content/widgets/tree.js @@ -0,0 +1,1705 @@ +/* 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/. */ + +/* globals XULTreeElement */ + +"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 MozTreeChildren extends MozElements.BaseControl { + constructor() { + super(); + + /** + * If there is no modifier key, we select on mousedown, not + * click, so that drags work correctly. + */ + this.addEventListener("mousedown", event => { + if (this.parentNode.disabled) { + return; + } + if ( + ((!event.getModifierState("Accel") || + !this.parentNode.pageUpOrDownMovesSelection) && + !event.shiftKey && + !event.metaKey) || + this.parentNode.view.selection.single + ) { + var b = this.parentNode; + var cell = b.getCellAt(event.clientX, event.clientY); + var view = this.parentNode.view; + + // save off the last selected row + this._lastSelectedRow = cell.row; + + if (cell.row == -1) { + return; + } + + if (cell.childElt == "twisty") { + return; + } + + if (cell.col && event.button == 0) { + if (cell.col.cycler) { + view.cycleCell(cell.row, cell.col); + return; + } else if (cell.col.type == window.TreeColumn.TYPE_CHECKBOX) { + if ( + this.parentNode.editable && + cell.col.editable && + view.isEditable(cell.row, cell.col) + ) { + var value = view.getCellValue(cell.row, cell.col); + value = value == "true" ? "false" : "true"; + view.setCellValue(cell.row, cell.col, value); + return; + } + } + } + + if (!view.selection.isSelected(cell.row)) { + view.selection.select(cell.row); + b.ensureRowIsVisible(cell.row); + } + } + }); + + /** + * On a click (up+down on the same item), deselect everything + * except this item. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + if (this.parentNode.disabled) { + return; + } + var b = this.parentNode; + var cell = b.getCellAt(event.clientX, event.clientY); + var view = this.parentNode.view; + + if (cell.row == -1) { + return; + } + + if (cell.childElt == "twisty") { + if ( + view.selection.currentIndex >= 0 && + view.isContainerOpen(cell.row) + ) { + var parentIndex = view.getParentIndex(view.selection.currentIndex); + while (parentIndex >= 0 && parentIndex != cell.row) { + parentIndex = view.getParentIndex(parentIndex); + } + if (parentIndex == cell.row) { + var parentSelectable = true; + if (parentSelectable) { + view.selection.select(parentIndex); + } + } + } + this.parentNode.changeOpenState(cell.row); + return; + } + + if (!view.selection.single) { + var augment = event.getModifierState("Accel"); + if (event.shiftKey) { + view.selection.rangedSelect(-1, cell.row, augment); + b.ensureRowIsVisible(cell.row); + return; + } + if (augment) { + view.selection.toggleSelect(cell.row); + b.ensureRowIsVisible(cell.row); + view.selection.currentIndex = cell.row; + return; + } + } + + /* We want to deselect all the selected items except what was + clicked, UNLESS it was a right-click. We have to do this + in click rather than mousedown so that you can drag a + selected group of items */ + + if (!cell.col) { + return; + } + + // if the last row has changed in between the time we + // mousedown and the time we click, don't fire the select handler. + // see bug #92366 + if ( + !cell.col.cycler && + this._lastSelectedRow == cell.row && + cell.col.type != window.TreeColumn.TYPE_CHECKBOX + ) { + view.selection.select(cell.row); + b.ensureRowIsVisible(cell.row); + } + }); + + /** + * double-click + */ + this.addEventListener("dblclick", event => { + if (this.parentNode.disabled) { + return; + } + var tree = this.parentNode; + var view = this.parentNode.view; + var row = view.selection.currentIndex; + + if (row == -1) { + return; + } + + var cell = tree.getCellAt(event.clientX, event.clientY); + + if (cell.childElt != "twisty") { + this.parentNode.startEditing(row, cell.col); + } + + if (this.parentNode._editingColumn || !view.isContainer(row)) { + return; + } + + // Cyclers and twisties respond to single clicks, not double clicks + if (cell.col && !cell.col.cycler && cell.childElt != "twisty") { + this.parentNode.changeOpenState(row); + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("slot", "treechildren"); + + this._lastSelectedRow = -1; + + if ("_ensureColumnOrder" in this.parentNode) { + this.parentNode._ensureColumnOrder(); + } + } + } + + customElements.define("treechildren", MozTreeChildren); + + class MozTreecolPicker extends MozElements.BaseControl { + static get markup() { + return ` + <button class="tree-columnpicker-button"/> + <menupopup anonid="popup"> + <menuseparator anonid="menuseparator"/> + <menuitem anonid="menuitem" data-l10n-id="tree-columnpicker-restore-order"/> + </menupopup> + `; + } + constructor() { + super(); + + window.MozXULElement.insertFTLIfNeeded("toolkit/global/tree.ftl"); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + let button = this.querySelector(".tree-columnpicker-button"); + let popup = this.querySelector('[anonid="popup"]'); + let menuitem = this.querySelector('[anonid="menuitem"]'); + + button.addEventListener("command", e => { + this.buildPopup(popup); + popup.openPopup(this, "after_end"); + e.preventDefault(); + }); + + menuitem.addEventListener("command", e => { + let tree = this.parentNode.parentNode; + tree.stopEditing(true); + this.style.order = ""; + tree._ensureColumnOrder(tree.NATURAL_ORDER); + e.preventDefault(); + }); + } + + buildPopup(aPopup) { + // We no longer cache the picker content, remove the old content related to + // the cols - menuitem and separator should stay. + aPopup.querySelectorAll("[colindex]").forEach(e => { + e.remove(); + }); + + var refChild = aPopup.firstChild; + + var tree = this.parentNode.parentNode; + for ( + var currCol = tree.columns.getFirstColumn(); + currCol; + currCol = currCol.getNext() + ) { + // Construct an entry for each column in the row, unless + // it is not being shown. + var currElement = currCol.element; + if (!currElement.hasAttribute("ignoreincolumnpicker")) { + var popupChild = document.createXULElement("menuitem"); + popupChild.setAttribute("type", "checkbox"); + var columnName = + currElement.getAttribute("display") || + currElement.getAttribute("label"); + popupChild.setAttribute("label", columnName); + popupChild.setAttribute("colindex", currCol.index); + if (currElement.getAttribute("hidden") != "true") { + popupChild.setAttribute("checked", "true"); + } + if (currCol.primary) { + popupChild.setAttribute("disabled", "true"); + } + if (currElement.hasAttribute("closemenu")) { + popupChild.setAttribute( + "closemenu", + currElement.getAttribute("closemenu") + ); + } + + popupChild.addEventListener("command", function () { + let colindex = this.getAttribute("colindex"); + let column = tree.columns[colindex]; + if (column) { + var element = column.element; + element.hidden = !element.hidden; + } + }); + + aPopup.insertBefore(popupChild, refChild); + } + } + + var hidden = !tree.enableColumnDrag; + aPopup.querySelectorAll(":scope > :not([colindex])").forEach(e => { + e.hidden = hidden; + }); + } + } + + customElements.define("treecolpicker", MozTreecolPicker); + + class MozTreecol extends MozElements.BaseControl { + static get observedAttributes() { + return ["primary", ...super.observedAttributes]; + } + + static get inheritedAttributes() { + return { + ".treecol-sortdirection": "sortdirection,hidden=hideheader", + ".treecol-text": "value=label,crop", + }; + } + + static get markup() { + return ` + <label class="treecol-text" flex="1" crop="end"></label> + <image class="treecol-sortdirection"></image> + `; + } + + get _tree() { + return this.parentNode?.parentNode; + } + + _invalidate() { + let tree = this._tree; + if (!tree || !XULTreeElement.isInstance(tree)) { + return; + } + tree.invalidate(); + tree.columns?.invalidateColumns(); + } + + constructor() { + super(); + + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + if (this._tree.enableColumnDrag) { + var XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + var cols = this.parentNode.getElementsByTagNameNS(XUL_NS, "treecol"); + + // only start column drag operation if there are at least 2 visible columns + var visible = 0; + for (var i = 0; i < cols.length; ++i) { + if (cols[i].getBoundingClientRect().width > 0) { + ++visible; + } + } + + if (visible > 1) { + window.addEventListener("mousemove", this._onDragMouseMove, true); + window.addEventListener("mouseup", this._onDragMouseUp, true); + document.treecolDragging = this; + this.mDragGesturing = true; + this.mStartDragX = event.clientX; + this.mStartDragY = event.clientY; + } + } + }); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + if (event.target != event.originalTarget) { + return; + } + + // On Windows multiple clicking on tree columns only cycles one time + // every 2 clicks. + if (AppConstants.platform == "win" && event.detail % 2 == 0) { + return; + } + + var tree = this._tree; + if (tree.columns) { + tree.view.cycleHeader(tree.columns.getColumnFor(this)); + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + if (this.hasAttribute("ordinal")) { + this.style.order = this.getAttribute("ordinal"); + } + if (this.hasAttribute("width")) { + this.style.width = this.getAttribute("width") + "px"; + } + + this._resizeObserver = new ResizeObserver(() => { + this._invalidate(); + }); + this._resizeObserver.observe(this); + } + + disconnectedCallback() { + this._resizeObserver?.unobserve(this); + this._resizeObserver = null; + } + + attributeChangedCallback(name, oldValue, newValue) { + super.attributeChangedCallback(name, oldValue, newValue); + this._invalidate(); + } + + set ordinal(val) { + this.style.order = val; + this.setAttribute("ordinal", val); + } + + get ordinal() { + var val = this.style.order; + if (val == "") { + return "1"; + } + + return "" + (val == "0" ? 0 : parseInt(val)); + } + + get _previousVisibleColumn() { + var tree = this.parentNode.parentNode; + let sib = tree.columns.getColumnFor(this).previousColumn; + while (sib) { + if (sib.element && sib.element.getBoundingClientRect().width > 0) { + return sib.element; + } + + sib = sib.previousColumn; + } + + return null; + } + + _onDragMouseMove(aEvent) { + var col = document.treecolDragging; + if (!col) { + return; + } + + // determine if we have moved the mouse far enough + // to initiate a drag + if (col.mDragGesturing) { + if ( + Math.abs(aEvent.clientX - col.mStartDragX) < 5 && + Math.abs(aEvent.clientY - col.mStartDragY) < 5 + ) { + return; + } + col.mDragGesturing = false; + col.setAttribute("dragging", "true"); + window.addEventListener("click", col._onDragMouseClick, true); + } + + var pos = {}; + var targetCol = col.parentNode.parentNode._getColumnAtX( + aEvent.clientX, + 0.5, + pos + ); + + // bail if we haven't mousemoved to a different column + if (col.mTargetCol == targetCol && col.mTargetDir == pos.value) { + return; + } + + var tree = col.parentNode.parentNode; + var sib; + var column; + if (col.mTargetCol) { + // remove previous insertbefore/after attributes + col.mTargetCol.removeAttribute("insertbefore"); + col.mTargetCol.removeAttribute("insertafter"); + column = tree.columns.getColumnFor(col.mTargetCol); + tree.invalidateColumn(column); + sib = col.mTargetCol._previousVisibleColumn; + if (sib) { + sib.removeAttribute("insertafter"); + column = tree.columns.getColumnFor(sib); + tree.invalidateColumn(column); + } + col.mTargetCol = null; + col.mTargetDir = null; + } + + if (targetCol) { + // set insertbefore/after attributes + if (pos.value == "after") { + targetCol.setAttribute("insertafter", "true"); + } else { + targetCol.setAttribute("insertbefore", "true"); + sib = targetCol._previousVisibleColumn; + if (sib) { + sib.setAttribute("insertafter", "true"); + column = tree.columns.getColumnFor(sib); + tree.invalidateColumn(column); + } + } + column = tree.columns.getColumnFor(targetCol); + tree.invalidateColumn(column); + col.mTargetCol = targetCol; + col.mTargetDir = pos.value; + } + } + + _onDragMouseUp(aEvent) { + var col = document.treecolDragging; + if (!col) { + return; + } + + if (!col.mDragGesturing) { + if (col.mTargetCol) { + // remove insertbefore/after attributes + var before = col.mTargetCol.hasAttribute("insertbefore"); + col.mTargetCol.removeAttribute( + before ? "insertbefore" : "insertafter" + ); + + var sib = col.mTargetCol._previousVisibleColumn; + if (before && sib) { + sib.removeAttribute("insertafter"); + } + + // Move the column only if it will result in a different column + // ordering + var move = true; + + // If this is a before move and the previous visible column is + // the same as the column we're moving, don't move + if (before && col == sib) { + move = false; + } else if (!before && col == col.mTargetCol) { + // If this is an after move and the column we're moving is + // the same as the target column, don't move. + move = false; + } + + if (move) { + col.parentNode.parentNode._reorderColumn( + col, + col.mTargetCol, + before + ); + } + + // repaint to remove lines + col.parentNode.parentNode.invalidate(); + + col.mTargetCol = null; + } + } else { + col.mDragGesturing = false; + } + + document.treecolDragging = null; + col.removeAttribute("dragging"); + + window.removeEventListener("mousemove", col._onDragMouseMove, true); + window.removeEventListener("mouseup", col._onDragMouseUp, true); + // we have to wait for the click event to fire before removing + // cancelling handler + var clickHandler = function (handler) { + window.removeEventListener("click", handler, true); + }; + window.setTimeout(clickHandler, 0, col._onDragMouseClick); + } + + _onDragMouseClick(aEvent) { + // prevent click event from firing after column drag and drop + aEvent.stopPropagation(); + aEvent.preventDefault(); + } + } + + customElements.define("treecol", MozTreecol); + + class MozTreecols extends MozElements.BaseControl { + static get inheritedAttributes() { + return { + treecolpicker: "tooltiptext=pickertooltiptext", + }; + } + + static get markup() { + return ` + <treecolpicker fixed="true"></treecolpicker> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("slot", "treecols"); + + if (!this.querySelector("treecolpicker")) { + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + } + + // Set resizeafter="farthest" on the splitters if nothing else has been + // specified. + for (let splitter of this.getElementsByTagName("splitter")) { + if (!splitter.hasAttribute("resizeafter")) { + splitter.setAttribute("resizeafter", "farthest"); + } + } + } + } + + customElements.define("treecols", MozTreecols); + + class MozTree extends MozElements.BaseControlMixin( + MozElements.MozElementMixin(XULTreeElement) + ) { + static get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/content/widgets.css" /> + <html:slot name="treecols"></html:slot> + <stack class="tree-stack" flex="1"> + <hbox class="tree-rows" flex="1"> + <hbox flex="1" class="tree-bodybox"> + <html:slot name="treechildren"></html:slot> + </hbox> + <scrollbar height="0" minwidth="0" minheight="0" orient="vertical" + class="hidevscroll-scrollbar scrollbar-topmost" + ></scrollbar> + </hbox> + <html:input class="tree-input" type="text" hidden="true"/> + </stack> + <hbox class="hidehscroll-box"> + <scrollbar orient="horizontal" flex="1" increment="16" class="scrollbar-topmost" ></scrollbar> + <scrollcorner class="hidevscroll-scrollcorner"></scrollcorner> + </hbox> + `; + } + + constructor() { + super(); + + // These enumerated constants are used as the first argument to + // _ensureColumnOrder to specify what column ordering should be used. + this.CURRENT_ORDER = 0; + this.NATURAL_ORDER = 1; // The original order, which is the DOM ordering + + this.attachShadow({ mode: "open" }); + let handledElements = this.constructor.fragment.querySelectorAll( + "scrollbar,scrollcorner" + ); + let stopAndPrevent = e => { + e.stopPropagation(); + e.preventDefault(); + }; + let stopProp = e => e.stopPropagation(); + for (let el of handledElements) { + el.addEventListener("click", stopAndPrevent); + el.addEventListener("contextmenu", stopAndPrevent); + el.addEventListener("dblclick", stopProp); + el.addEventListener("command", stopProp); + } + this.shadowRoot.appendChild(this.constructor.fragment); + + this.#verticalScrollbar = this.shadowRoot.querySelector( + "scrollbar[orient='vertical']" + ); + } + + static get inheritedAttributes() { + return { + ".hidehscroll-box": "collapsed=hidehscroll", + ".hidevscroll-scrollbar": "collapsed=hidevscroll", + ".hidevscroll-scrollcorner": "collapsed=hidevscroll", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + if (!this._eventListenersSetup) { + this._eventListenersSetup = true; + this.setupEventListeners(); + } + + this.setAttribute("hidevscroll", "true"); + this.setAttribute("hidehscroll", "true"); + + this.initializeAttributeInheritance(); + + this.pageUpOrDownMovesSelection = AppConstants.platform != "macosx"; + + this._inputField = null; + + this._editingRow = -1; + + this._editingColumn = null; + + this._columnsDirty = true; + + this._lastKeyTime = 0; + + this._incrementalString = ""; + + this._touchY = -1; + } + + setupEventListeners() { + this.addEventListener("underflow", event => { + // Scrollport event orientation + // 0: vertical + // 1: horizontal + // 2: both (not used) + if (event.target.tagName != "treechildren") { + return; + } + if (event.detail == 1) { + this.setAttribute("hidehscroll", "true"); + } else if (event.detail == 0) { + this.setAttribute("hidevscroll", "true"); + } + event.stopPropagation(); + }); + + this.addEventListener("overflow", event => { + if (event.target.tagName != "treechildren") { + return; + } + if (event.detail == 1) { + this.removeAttribute("hidehscroll"); + } else if (event.detail == 0) { + this.removeAttribute("hidevscroll"); + } + event.stopPropagation(); + }); + + this.addEventListener("touchstart", event => { + function isScrollbarElement(target) { + return ( + (target.localName == "thumb" || target.localName == "slider") && + target.namespaceURI == + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + ); + } + if ( + event.touches.length > 1 || + isScrollbarElement(event.touches[0].target) + ) { + // Multiple touch points detected, abort. In particular this aborts + // the panning gesture when the user puts a second finger down after + // already panning with one finger. Aborting at this point prevents + // the pan gesture from being resumed until all fingers are lifted + // (as opposed to when the user is back down to one finger). + // Additionally, if the user lands on the scrollbar don't use this + // code for scrolling, instead allow gecko to handle scrollbar + // interaction normally. + this._touchY = -1; + } else { + this._touchY = event.touches[0].screenY; + } + }); + + this.addEventListener("touchmove", event => { + if (event.touches.length == 1 && this._touchY >= 0) { + var deltaY = this._touchY - event.touches[0].screenY; + var lines = Math.trunc(deltaY / this.rowHeight); + if (Math.abs(lines) > 0) { + this.scrollByLines(lines); + deltaY -= lines * this.rowHeight; + this._touchY = event.touches[0].screenY + deltaY; + } + event.preventDefault(); + } + }); + + this.addEventListener("touchend", event => { + this._touchY = -1; + }); + + // This event doesn't retarget, so listen on the shadow DOM directly + this.shadowRoot.addEventListener("MozMousePixelScroll", event => { + if (this.#canScroll(event)) { + event.preventDefault(); + } + }); + + // This event doesn't retarget, so listen on the shadow DOM directly + this.shadowRoot.addEventListener("DOMMouseScroll", event => { + if (!this.#canScroll(event)) { + return; + } + + event.preventDefault(); + + if (this._editingColumn) { + return; + } + + var rows = event.detail; + if (rows == UIEvent.SCROLL_PAGE_UP) { + this.scrollByPages(-1); + } else if (rows == UIEvent.SCROLL_PAGE_DOWN) { + this.scrollByPages(1); + } else { + this.scrollByLines(rows); + } + }); + + this.addEventListener("MozSwipeGesture", event => { + // Figure out which row to show + let targetRow = 0; + + // Only handle swipe gestures up and down + switch (event.direction) { + case event.DIRECTION_DOWN: + targetRow = this.view.rowCount - 1; + // Fall through for actual action + case event.DIRECTION_UP: + this.ensureRowIsVisible(targetRow); + break; + } + }); + + this.addEventListener("select", event => { + if (event.originalTarget == this) { + this.stopEditing(true); + } + }); + + this.addEventListener("focus", event => { + this.focused = true; + if (this.currentIndex == -1 && this.view.rowCount > 0) { + this.currentIndex = this.getFirstVisibleRow(); + } + }); + + this.addEventListener( + "blur", + event => { + this.focused = false; + if (event.target == this.inputField) { + this.stopEditing(true); + } + }, + true + ); + + this.addEventListener("keydown", event => { + if (event.altKey) { + return; + } + + let toggleClose = () => { + if (this._editingColumn) { + return; + } + + let row = this.currentIndex; + if (row < 0) { + return; + } + + if (this.changeOpenState(this.currentIndex, false)) { + event.preventDefault(); + return; + } + + let parentIndex = this.view.getParentIndex(this.currentIndex); + if (parentIndex >= 0) { + this.view.selection.select(parentIndex); + this.ensureRowIsVisible(parentIndex); + event.preventDefault(); + } + }; + + let toggleOpen = () => { + if (this._editingColumn) { + return; + } + + let row = this.currentIndex; + if (row < 0) { + return; + } + + if (this.changeOpenState(row, true)) { + event.preventDefault(); + return; + } + let c = row + 1; + let view = this.view; + if (c < view.rowCount && view.getParentIndex(c) == row) { + // If already opened, select the first child. + // The getParentIndex test above ensures that the children + // are already populated and ready. + this.view.selection.timedSelect(c, this._selectDelay); + this.ensureRowIsVisible(c); + event.preventDefault(); + } + }; + + switch (event.keyCode) { + case KeyEvent.DOM_VK_RETURN: { + if (this._handleEnter(event)) { + event.stopPropagation(); + event.preventDefault(); + } + break; + } + case KeyEvent.DOM_VK_ESCAPE: { + if (this._editingColumn) { + this.stopEditing(false); + this.focus(); + event.stopPropagation(); + event.preventDefault(); + } + break; + } + case KeyEvent.DOM_VK_LEFT: { + if (!this.isRTL) { + toggleClose(); + } else { + toggleOpen(); + } + break; + } + case KeyEvent.DOM_VK_RIGHT: { + if (!this.isRTL) { + toggleOpen(); + } else { + toggleClose(); + } + break; + } + case KeyEvent.DOM_VK_UP: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByOffsetShift(-1, 0, event); + } else { + this._moveByOffset(-1, 0, event); + } + break; + } + case KeyEvent.DOM_VK_DOWN: { + if (this._editingColumn) { + return; + } + if (event.getModifierState("Shift")) { + this._moveByOffsetShift(1, this.view.rowCount - 1, event); + } else { + this._moveByOffset(1, this.view.rowCount - 1, event); + } + break; + } + case KeyEvent.DOM_VK_PAGE_UP: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByPageShift(-1, 0, event); + } else { + this._moveByPage(-1, 0, event); + } + break; + } + case KeyEvent.DOM_VK_PAGE_DOWN: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByPageShift(1, this.view.rowCount - 1, event); + } else { + this._moveByPage(1, this.view.rowCount - 1, event); + } + break; + } + case KeyEvent.DOM_VK_HOME: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveToEdgeShift(0, event); + } else { + this._moveToEdge(0, event); + } + break; + } + case KeyEvent.DOM_VK_END: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveToEdgeShift(this.view.rowCount - 1, event); + } else { + this._moveToEdge(this.view.rowCount - 1, event); + } + break; + } + } + }); + + this.addEventListener("keypress", event => { + if (this._editingColumn) { + return; + } + + if (event.charCode == " ".charCodeAt(0)) { + var c = this.currentIndex; + if ( + !this.view.selection.isSelected(c) || + (!this.view.selection.single && event.getModifierState("Accel")) + ) { + this.view.selection.toggleSelect(c); + event.preventDefault(); + } + } else if ( + !this.disableKeyNavigation && + event.charCode > 0 && + !event.altKey && + !event.getModifierState("Accel") && + !event.metaKey && + !event.ctrlKey + ) { + var l = this._keyNavigate(event); + if (l >= 0) { + this.view.selection.timedSelect(l, this._selectDelay); + this.ensureRowIsVisible(l); + } + event.preventDefault(); + } + }); + } + + get body() { + return this.treeBody; + } + + get isRTL() { + return document.defaultView.getComputedStyle(this).direction == "rtl"; + } + + set editable(val) { + if (val) { + this.setAttribute("editable", "true"); + } else { + this.removeAttribute("editable"); + } + } + + get editable() { + return this.getAttribute("editable") == "true"; + } + /** + * ///////////////// nsIDOMXULSelectControlElement ///////////////// ///////////////// nsIDOMXULMultiSelectControlElement ///////////////// + */ + set selType(val) { + this.setAttribute("seltype", val); + } + + get selType() { + return this.getAttribute("seltype"); + } + + set currentIndex(val) { + if (this.view) { + this.view.selection.currentIndex = val; + } + } + + get currentIndex() { + if (this.view && this.view.selection) { + return this.view.selection.currentIndex; + } + return -1; + } + + set keepCurrentInView(val) { + if (val) { + this.setAttribute("keepcurrentinview", "true"); + } else { + this.removeAttribute("keepcurrentinview"); + } + } + + get keepCurrentInView() { + return this.getAttribute("keepcurrentinview") == "true"; + } + + set enableColumnDrag(val) { + if (val) { + this.setAttribute("enableColumnDrag", "true"); + } else { + this.removeAttribute("enableColumnDrag"); + } + } + + get enableColumnDrag() { + return this.hasAttribute("enableColumnDrag"); + } + + get inputField() { + if (!this._inputField) { + this._inputField = this.shadowRoot.querySelector(".tree-input"); + this._inputField.addEventListener("blur", () => this.stopEditing(true)); + } + return this._inputField; + } + + set disableKeyNavigation(val) { + if (val) { + this.setAttribute("disableKeyNavigation", "true"); + } else { + this.removeAttribute("disableKeyNavigation"); + } + } + + get disableKeyNavigation() { + return this.hasAttribute("disableKeyNavigation"); + } + + get editingRow() { + return this._editingRow; + } + + get editingColumn() { + return this._editingColumn; + } + + set _selectDelay(val) { + this.setAttribute("_selectDelay", val); + } + + get _selectDelay() { + return this.getAttribute("_selectDelay") || 50; + } + + // The first argument (order) can be either one of these constants: + // this.CURRENT_ORDER + // this.NATURAL_ORDER + _ensureColumnOrder(order = this.CURRENT_ORDER) { + if (this.columns) { + // update the ordinal position of each column to assure that it is + // an odd number and 2 positions above its next sibling + var cols = []; + + if (order == this.CURRENT_ORDER) { + for ( + let col = this.columns.getFirstColumn(); + col; + col = col.getNext() + ) { + cols.push(col.element); + } + } else { + // order == this.NATURAL_ORDER + cols = this.getElementsByTagName("treecol"); + } + + for (let i = 0; i < cols.length; ++i) { + cols[i].ordinal = i * 2 + 1; + } + // update the ordinal positions of splitters to even numbers, so that + // they are in between columns + var splitters = this.getElementsByTagName("splitter"); + for (let i = 0; i < splitters.length; ++i) { + splitters[i].style.order = (i + 1) * 2; + } + } + } + + _reorderColumn(aColMove, aColBefore, aBefore) { + this._ensureColumnOrder(); + + var i; + var cols = []; + var col = this.columns.getColumnFor(aColBefore); + if (parseInt(aColBefore.ordinal) < parseInt(aColMove.ordinal)) { + if (aBefore) { + cols.push(aColBefore); + } + for ( + col = col.getNext(); + col.element != aColMove; + col = col.getNext() + ) { + cols.push(col.element); + } + + aColMove.ordinal = cols[0].ordinal; + for (i = 0; i < cols.length; ++i) { + cols[i].ordinal = parseInt(cols[i].ordinal) + 2; + } + } else if (aColBefore.ordinal != aColMove.ordinal) { + if (!aBefore) { + cols.push(aColBefore); + } + for ( + col = col.getPrevious(); + col.element != aColMove; + col = col.getPrevious() + ) { + cols.push(col.element); + } + + aColMove.ordinal = cols[0].ordinal; + for (i = 0; i < cols.length; ++i) { + cols[i].ordinal = parseInt(cols[i].ordinal) - 2; + } + } else { + return; + } + this.columns.invalidateColumns(); + } + + _getColumnAtX(aX, aThresh, aPos) { + let isRTL = this.isRTL; + + if (aPos) { + aPos.value = isRTL ? "after" : "before"; + } + + var columns = []; + var col = this.columns.getFirstColumn(); + while (col) { + columns.push(col); + col = col.getNext(); + } + if (isRTL) { + columns.reverse(); + } + var currentX = this.getBoundingClientRect().x; + var adjustedX = aX + this.horizontalPosition; + for (var i = 0; i < columns.length; ++i) { + col = columns[i]; + var cw = col.element.getBoundingClientRect().width; + if (cw > 0) { + currentX += cw; + if (currentX - cw * aThresh > adjustedX) { + return col.element; + } + } + } + + if (aPos) { + aPos.value = isRTL ? "before" : "after"; + } + return columns.pop().element; + } + + changeOpenState(row, openState) { + if (row < 0 || !this.view.isContainer(row)) { + return false; + } + + if (this.view.isContainerOpen(row) != openState) { + this.view.toggleOpenState(row); + if (row == this.currentIndex) { + // Only fire event when current row is expanded or collapsed + // because that's all the assistive technology really cares about. + var event = document.createEvent("Events"); + event.initEvent("OpenStateChange", true, true); + this.dispatchEvent(event); + } + return true; + } + return false; + } + + _keyNavigate(event) { + var key = String.fromCharCode(event.charCode).toLowerCase(); + if (event.timeStamp - this._lastKeyTime > 1000) { + this._incrementalString = key; + } else { + this._incrementalString += key; + } + this._lastKeyTime = event.timeStamp; + + var length = this._incrementalString.length; + var incrementalString = this._incrementalString; + var charIndex = 1; + while ( + charIndex < length && + incrementalString[charIndex] == incrementalString[charIndex - 1] + ) { + charIndex++; + } + // If all letters in incremental string are same, just try to match the first one + if (charIndex == length) { + length = 1; + incrementalString = incrementalString.substring(0, length); + } + + var keyCol = this.columns.getKeyColumn(); + var rowCount = this.view.rowCount; + var start = 1; + + var c = this.currentIndex; + if (length > 1) { + start = 0; + if (c < 0) { + c = 0; + } + } + + for (var i = 0; i < rowCount; i++) { + var l = (i + start + c) % rowCount; + var cellText = this.view.getCellText(l, keyCol); + cellText = cellText.substring(0, length).toLowerCase(); + if (cellText == incrementalString) { + return l; + } + } + return -1; + } + + startEditing(row, column) { + if (!this.editable) { + return false; + } + if (row < 0 || row >= this.view.rowCount || !column) { + return false; + } + if (column.type !== window.TreeColumn.TYPE_TEXT) { + return false; + } + if (column.cycler || !this.view.isEditable(row, column)) { + return false; + } + + // Beyond this point, we are going to edit the cell. + if (this._editingColumn) { + this.stopEditing(); + } + + var input = this.inputField; + + this.ensureCellIsVisible(row, column); + + // Get the coordinates of the text inside the cell. + var textRect = this.getCoordsForCellItem(row, column, "text"); + + // Get the coordinates of the cell itself. + var cellRect = this.getCoordsForCellItem(row, column, "cell"); + + // Calculate the top offset of the textbox. + var style = window.getComputedStyle(input); + var topadj = parseInt(style.borderTopWidth) + parseInt(style.paddingTop); + input.style.top = `${textRect.y - topadj}px`; + + // The leftside of the textbox is aligned to the left side of the text + // in LTR mode, and left side of the cell in RTL mode. + let left = style.direction == "rtl" ? cellRect.x : textRect.x; + let scrollbarWidth = window.windowUtils.getBoundsWithoutFlushing( + this.#verticalScrollbar + ).width; + // Note: this won't be quite right in RTL for trees using twisties + // or indentation. bug 1708159 tracks fixing the implementation + // of getCoordsForCellItem which we called above so it provides + // better numbers in those cases. + let widthdiff = Math.abs(textRect.x - cellRect.x) - scrollbarWidth; + + input.style.left = `${left}px`; + input.style.height = `${ + textRect.height + + topadj + + parseInt(style.borderBottomWidth) + + parseInt(style.paddingBottom) + }px`; + input.style.width = `${cellRect.width - widthdiff}px`; + input.hidden = false; + + input.value = this.view.getCellText(row, column); + + input.select(); + input.focus(); + + this._editingRow = row; + this._editingColumn = column; + this.setAttribute("editing", "true"); + + this.invalidateCell(row, column); + return true; + } + + stopEditing(accept) { + if (!this._editingColumn) { + return; + } + + var input = this.inputField; + var editingRow = this._editingRow; + var editingColumn = this._editingColumn; + this._editingRow = -1; + this._editingColumn = null; + + // `this.view` could be null if the tree was hidden before we were called. + if (accept && this.view) { + var value = input.value; + this.view.setCellText(editingRow, editingColumn, value); + } + input.hidden = true; + input.value = ""; + this.removeAttribute("editing"); + } + + _moveByOffset(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (event.getModifierState("Accel") && this.view.selection.single) { + this.scrollByLines(offset); + return; + } + + var c = this.currentIndex + offset; + if (offset > 0 ? c > edge : c < edge) { + if ( + this.view.selection.isSelected(edge) && + this.view.selection.count <= 1 + ) { + return; + } + c = edge; + } + + if (!event.getModifierState("Accel")) { + this.view.selection.timedSelect(c, this._selectDelay); + } + // Ctrl+Up/Down moves the anchor without selecting + else { + this.currentIndex = c; + } + this.ensureRowIsVisible(c); + } + + _moveByOffsetShift(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.view.selection.single) { + this.scrollByLines(offset); + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + var c = this.currentIndex; + if (c == -1) { + c = 0; + } + + if (c == edge) { + if (this.view.selection.isSelected(c)) { + return; + } + } + + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + c + offset, + event.getModifierState("Accel") + ); + this.ensureRowIsVisible(c + offset); + } + + _moveByPage(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.pageUpOrDownMovesSelection == event.getModifierState("Accel")) { + this.scrollByPages(offset); + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + var c = this.currentIndex; + if (c == -1) { + return; + } + + if (c == edge && this.view.selection.isSelected(c)) { + this.ensureRowIsVisible(c); + return; + } + var i = this.getFirstVisibleRow(); + var p = this.getPageLength(); + + if (offset > 0) { + i += p - 1; + if (c >= i) { + i = c + p; + this.ensureRowIsVisible(i > edge ? edge : i); + } + i = i > edge ? edge : i; + } else if (c <= i) { + i = c <= p ? 0 : c - p; + this.ensureRowIsVisible(i); + } + this.view.selection.timedSelect(i, this._selectDelay); + } + + _moveByPageShift(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if ( + this.view.rowCount == 1 && + !this.view.selection.isSelected(0) && + !(this.pageUpOrDownMovesSelection == event.getModifierState("Accel")) + ) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + if (this.view.selection.single) { + return; + } + + var c = this.currentIndex; + if (c == -1) { + return; + } + if (c == edge && this.view.selection.isSelected(c)) { + this.ensureRowIsVisible(edge); + return; + } + var i = this.getFirstVisibleRow(); + var p = this.getPageLength(); + + if (offset > 0) { + i += p - 1; + if (c >= i) { + i = c + p; + this.ensureRowIsVisible(i > edge ? edge : i); + } + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + i > edge ? edge : i, + event.getModifierState("Accel") + ); + } else { + if (c <= i) { + i = c <= p ? 0 : c - p; + this.ensureRowIsVisible(i); + } + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + i, + event.getModifierState("Accel") + ); + } + } + + _moveToEdge(edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if ( + this.view.selection.isSelected(edge) && + this.view.selection.count == 1 + ) { + this.currentIndex = edge; + return; + } + + // Normal behaviour is to select the first/last row + if (!event.getModifierState("Accel")) { + this.view.selection.timedSelect(edge, this._selectDelay); + } + // In a multiselect tree Ctrl+Home/End moves the anchor + else if (!this.view.selection.single) { + this.currentIndex = edge; + } + + this.ensureRowIsVisible(edge); + } + + _moveToEdgeShift(edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + if ( + this.view.selection.single || + (this.view.selection.isSelected(edge) && + this.view.selection.isSelected(this.currentIndex)) + ) { + return; + } + + // Extend the selection from the existing pivot, if any. + // -1 doesn't work here, so using currentIndex instead + this.view.selection.rangedSelect( + this.currentIndex, + edge, + event.getModifierState("Accel") + ); + + this.ensureRowIsVisible(edge); + } + + _handleEnter(event) { + if (this._editingColumn) { + this.stopEditing(true); + this.focus(); + return true; + } + + return this.changeOpenState(this.currentIndex); + } + + #verticalScrollbar = null; + #lastScrollEventTimeStampMap = new Map(); + + #canScroll(event) { + const lastScrollEventTimeStamp = this.#lastScrollEventTimeStampMap.get( + event.type + ); + this.#lastScrollEventTimeStampMap.set(event.type, event.timeStamp); + + if ( + window.windowUtils.getWheelScrollTarget() || + event.axis == event.HORIZONTAL_AXIS || + (this.getAttribute("allowunderflowscroll") == "true" && + this.getAttribute("hidevscroll") == "true") + ) { + return false; + } + + if ( + event.timeStamp - (lastScrollEventTimeStamp ?? 0) < + Services.prefs.getIntPref("mousewheel.scroll_series_timeout") + ) { + // If the time difference of previous event does not over the timeout, + // handle the event in tree as the same seies of events even if the + // current position is edge. + return true; + } + + const curpos = Number(this.#verticalScrollbar.getAttribute("curpos")); + return ( + (event.detail < 0 && 0 < curpos) || + (event.detail > 0 && + curpos < Number(this.#verticalScrollbar.getAttribute("maxpos"))) + ); + } + } + + MozXULElement.implementCustomInterface(MozTree, [ + Ci.nsIDOMXULMultiSelectControlElement, + ]); + customElements.define("tree", MozTree); +} diff --git a/toolkit/content/widgets/vendor/lit.all.mjs b/toolkit/content/widgets/vendor/lit.all.mjs new file mode 100644 index 0000000000..ec542440ae --- /dev/null +++ b/toolkit/content/widgets/vendor/lit.all.mjs @@ -0,0 +1,4467 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const NODE_MODE$1 = false; +const global$2 = window; +/** + * Whether the current browser supports `adoptedStyleSheets`. + */ +const supportsAdoptingStyleSheets = global$2.ShadowRoot && + (global$2.ShadyCSS === undefined || global$2.ShadyCSS.nativeShadow) && + 'adoptedStyleSheets' in Document.prototype && + 'replace' in CSSStyleSheet.prototype; +const constructionToken = Symbol(); +const cssTagCache = new WeakMap(); +/** + * A container for a string of CSS text, that may be used to create a CSSStyleSheet. + * + * CSSResult is the return value of `css`-tagged template literals and + * `unsafeCSS()`. In order to ensure that CSSResults are only created via the + * `css` tag and `unsafeCSS()`, CSSResult cannot be constructed directly. + */ +class CSSResult { + constructor(cssText, strings, safeToken) { + // This property needs to remain unminified. + this['_$cssResult$'] = true; + if (safeToken !== constructionToken) { + throw new Error('CSSResult is not constructable. Use `unsafeCSS` or `css` instead.'); + } + this.cssText = cssText; + this._strings = strings; + } + // This is a getter so that it's lazy. In practice, this means stylesheets + // are not created until the first element instance is made. + get styleSheet() { + // If `supportsAdoptingStyleSheets` is true then we assume CSSStyleSheet is + // constructable. + let styleSheet = this._styleSheet; + const strings = this._strings; + if (supportsAdoptingStyleSheets && styleSheet === undefined) { + const cacheable = strings !== undefined && strings.length === 1; + if (cacheable) { + styleSheet = cssTagCache.get(strings); + } + if (styleSheet === undefined) { + (this._styleSheet = styleSheet = new CSSStyleSheet()).replaceSync(this.cssText); + if (cacheable) { + cssTagCache.set(strings, styleSheet); + } + } + } + return styleSheet; + } + toString() { + return this.cssText; + } +} +const textFromCSSResult = (value) => { + // This property needs to remain unminified. + if (value['_$cssResult$'] === true) { + return value.cssText; + } + else if (typeof value === 'number') { + return value; + } + else { + throw new Error(`Value passed to 'css' function must be a 'css' function result: ` + + `${value}. Use 'unsafeCSS' to pass non-literal values, but take care ` + + `to ensure page security.`); + } +}; +/** + * Wrap a value for interpolation in a {@linkcode css} tagged template literal. + * + * This is unsafe because untrusted CSS text can be used to phone home + * or exfiltrate data to an attacker controlled site. Take care to only use + * this with trusted input. + */ +const unsafeCSS = (value) => new CSSResult(typeof value === 'string' ? value : String(value), undefined, constructionToken); +/** + * A template literal tag which can be used with LitElement's + * {@linkcode LitElement.styles} property to set element styles. + * + * For security reasons, only literal string values and number may be used in + * embedded expressions. To incorporate non-literal values {@linkcode unsafeCSS} + * may be used inside an expression. + */ +const css = (strings, ...values) => { + const cssText = strings.length === 1 + ? strings[0] + : values.reduce((acc, v, idx) => acc + textFromCSSResult(v) + strings[idx + 1], strings[0]); + return new CSSResult(cssText, strings, constructionToken); +}; +/** + * Applies the given styles to a `shadowRoot`. When Shadow DOM is + * available but `adoptedStyleSheets` is not, styles are appended to the + * `shadowRoot` to [mimic spec behavior](https://wicg.github.io/construct-stylesheets/#using-constructed-stylesheets). + * Note, when shimming is used, any styles that are subsequently placed into + * the shadowRoot should be placed *before* any shimmed adopted styles. This + * will match spec behavior that gives adopted sheets precedence over styles in + * shadowRoot. + */ +const adoptStyles = (renderRoot, styles) => { + if (supportsAdoptingStyleSheets) { + renderRoot.adoptedStyleSheets = styles.map((s) => s instanceof CSSStyleSheet ? s : s.styleSheet); + } + else { + styles.forEach((s) => { + const style = document.createElement('style'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nonce = global$2['litNonce']; + if (nonce !== undefined) { + style.setAttribute('nonce', nonce); + } + style.textContent = s.cssText; + renderRoot.appendChild(style); + }); + } +}; +const cssResultFromStyleSheet = (sheet) => { + let cssText = ''; + for (const rule of sheet.cssRules) { + cssText += rule.cssText; + } + return unsafeCSS(cssText); +}; +const getCompatibleStyle = supportsAdoptingStyleSheets || + (NODE_MODE$1 ) + ? (s) => s + : (s) => s instanceof CSSStyleSheet ? cssResultFromStyleSheet(s) : s; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var _d$1; +var _e; +const global$1 = window; +const trustedTypes$1 = global$1 + .trustedTypes; +// Temporary workaround for https://crbug.com/993268 +// Currently, any attribute starting with "on" is considered to be a +// TrustedScript source. Such boolean attributes must be set to the equivalent +// trusted emptyScript value. +const emptyStringForBooleanAttribute$1 = trustedTypes$1 + ? trustedTypes$1.emptyScript + : ''; +const polyfillSupport$2 = global$1.reactiveElementPolyfillSupport; +/* + * When using Closure Compiler, JSCompiler_renameProperty(property, object) is + * replaced at compile time by the munged name for object[property]. We cannot + * alias this function, so we have to use a small shim that has the same + * behavior when not compiling. + */ +/*@__INLINE__*/ +const JSCompiler_renameProperty = (prop, _obj) => prop; +const defaultConverter = { + toAttribute(value, type) { + switch (type) { + case Boolean: + value = value ? emptyStringForBooleanAttribute$1 : null; + break; + case Object: + case Array: + // if the value is `null` or `undefined` pass this through + // to allow removing/no change behavior. + value = value == null ? value : JSON.stringify(value); + break; + } + return value; + }, + fromAttribute(value, type) { + let fromValue = value; + switch (type) { + case Boolean: + fromValue = value !== null; + break; + case Number: + fromValue = value === null ? null : Number(value); + break; + case Object: + case Array: + // Do *not* generate exception when invalid JSON is set as elements + // don't normally complain on being mis-configured. + // TODO(sorvell): Do generate exception in *dev mode*. + try { + // Assert to adhere to Bazel's "must type assert JSON parse" rule. + fromValue = JSON.parse(value); + } + catch (e) { + fromValue = null; + } + break; + } + return fromValue; + }, +}; +/** + * Change function that returns true if `value` is different from `oldValue`. + * This method is used as the default for a property's `hasChanged` function. + */ +const notEqual = (value, old) => { + // This ensures (old==NaN, value==NaN) always returns false + return old !== value && (old === old || value === value); +}; +const defaultPropertyDeclaration = { + attribute: true, + type: String, + converter: defaultConverter, + reflect: false, + hasChanged: notEqual, +}; +/** + * The Closure JS Compiler doesn't currently have good support for static + * property semantics where "this" is dynamic (e.g. + * https://github.com/google/closure-compiler/issues/3177 and others) so we use + * this hack to bypass any rewriting by the compiler. + */ +const finalized = 'finalized'; +/** + * Base element class which manages element properties and attributes. When + * properties change, the `update` method is asynchronously called. This method + * should be supplied by subclassers to render updates as desired. + * @noInheritDoc + */ +class ReactiveElement extends HTMLElement { + constructor() { + super(); + this.__instanceProperties = new Map(); + /** + * True if there is a pending update as a result of calling `requestUpdate()`. + * Should only be read. + * @category updates + */ + this.isUpdatePending = false; + /** + * Is set to `true` after the first update. The element code cannot assume + * that `renderRoot` exists before the element `hasUpdated`. + * @category updates + */ + this.hasUpdated = false; + /** + * Name of currently reflecting property + */ + this.__reflectingProperty = null; + this._initialize(); + } + /** + * Adds an initializer function to the class that is called during instance + * construction. + * + * This is useful for code that runs against a `ReactiveElement` + * subclass, such as a decorator, that needs to do work for each + * instance, such as setting up a `ReactiveController`. + * + * ```ts + * const myDecorator = (target: typeof ReactiveElement, key: string) => { + * target.addInitializer((instance: ReactiveElement) => { + * // This is run during construction of the element + * new MyController(instance); + * }); + * } + * ``` + * + * Decorating a field will then cause each instance to run an initializer + * that adds a controller: + * + * ```ts + * class MyElement extends LitElement { + * @myDecorator foo; + * } + * ``` + * + * Initializers are stored per-constructor. Adding an initializer to a + * subclass does not add it to a superclass. Since initializers are run in + * constructors, initializers will run in order of the class hierarchy, + * starting with superclasses and progressing to the instance's class. + * + * @nocollapse + */ + static addInitializer(initializer) { + var _a; + this.finalize(); + ((_a = this._initializers) !== null && _a !== void 0 ? _a : (this._initializers = [])).push(initializer); + } + /** + * Returns a list of attributes corresponding to the registered properties. + * @nocollapse + * @category attributes + */ + static get observedAttributes() { + // note: piggy backing on this to ensure we're finalized. + this.finalize(); + const attributes = []; + // Use forEach so this works even if for/of loops are compiled to for loops + // expecting arrays + this.elementProperties.forEach((v, p) => { + const attr = this.__attributeNameForProperty(p, v); + if (attr !== undefined) { + this.__attributeToPropertyMap.set(attr, p); + attributes.push(attr); + } + }); + return attributes; + } + /** + * Creates a property accessor on the element prototype if one does not exist + * and stores a {@linkcode PropertyDeclaration} for the property with the + * given options. The property setter calls the property's `hasChanged` + * property option or uses a strict identity check to determine whether or not + * to request an update. + * + * This method may be overridden to customize properties; however, + * when doing so, it's important to call `super.createProperty` to ensure + * the property is setup correctly. This method calls + * `getPropertyDescriptor` internally to get a descriptor to install. + * To customize what properties do when they are get or set, override + * `getPropertyDescriptor`. To customize the options for a property, + * implement `createProperty` like this: + * + * ```ts + * static createProperty(name, options) { + * options = Object.assign(options, {myOption: true}); + * super.createProperty(name, options); + * } + * ``` + * + * @nocollapse + * @category properties + */ + static createProperty(name, options = defaultPropertyDeclaration) { + // if this is a state property, force the attribute to false. + if (options.state) { + // Cast as any since this is readonly. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options.attribute = false; + } + // Note, since this can be called by the `@property` decorator which + // is called before `finalize`, we ensure finalization has been kicked off. + this.finalize(); + this.elementProperties.set(name, options); + // Do not generate an accessor if the prototype already has one, since + // it would be lost otherwise and that would never be the user's intention; + // Instead, we expect users to call `requestUpdate` themselves from + // user-defined accessors. Note that if the super has an accessor we will + // still overwrite it + if (!options.noAccessor && !this.prototype.hasOwnProperty(name)) { + const key = typeof name === 'symbol' ? Symbol() : `__${name}`; + const descriptor = this.getPropertyDescriptor(name, key, options); + if (descriptor !== undefined) { + Object.defineProperty(this.prototype, name, descriptor); + } + } + } + /** + * Returns a property descriptor to be defined on the given named property. + * If no descriptor is returned, the property will not become an accessor. + * For example, + * + * ```ts + * class MyElement extends LitElement { + * static getPropertyDescriptor(name, key, options) { + * const defaultDescriptor = + * super.getPropertyDescriptor(name, key, options); + * const setter = defaultDescriptor.set; + * return { + * get: defaultDescriptor.get, + * set(value) { + * setter.call(this, value); + * // custom action. + * }, + * configurable: true, + * enumerable: true + * } + * } + * } + * ``` + * + * @nocollapse + * @category properties + */ + static getPropertyDescriptor(name, key, options) { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get() { + return this[key]; + }, + set(value) { + const oldValue = this[name]; + this[key] = value; + this.requestUpdate(name, oldValue, options); + }, + configurable: true, + enumerable: true, + }; + } + /** + * Returns the property options associated with the given property. + * These options are defined with a `PropertyDeclaration` via the `properties` + * object or the `@property` decorator and are registered in + * `createProperty(...)`. + * + * Note, this method should be considered "final" and not overridden. To + * customize the options for a given property, override + * {@linkcode createProperty}. + * + * @nocollapse + * @final + * @category properties + */ + static getPropertyOptions(name) { + return this.elementProperties.get(name) || defaultPropertyDeclaration; + } + /** + * Creates property accessors for registered properties, sets up element + * styling, and ensures any superclasses are also finalized. Returns true if + * the element was finalized. + * @nocollapse + */ + static finalize() { + if (this.hasOwnProperty(finalized)) { + return false; + } + this[finalized] = true; + // finalize any superclasses + const superCtor = Object.getPrototypeOf(this); + superCtor.finalize(); + // Create own set of initializers for this class if any exist on the + // superclass and copy them down. Note, for a small perf boost, avoid + // creating initializers unless needed. + if (superCtor._initializers !== undefined) { + this._initializers = [...superCtor._initializers]; + } + this.elementProperties = new Map(superCtor.elementProperties); + // initialize Map populated in observedAttributes + this.__attributeToPropertyMap = new Map(); + // make any properties + // Note, only process "own" properties since this element will inherit + // any properties defined on the superClass, and finalization ensures + // the entire prototype chain is finalized. + if (this.hasOwnProperty(JSCompiler_renameProperty('properties'))) { + const props = this.properties; + // support symbols in properties (IE11 does not support this) + const propKeys = [ + ...Object.getOwnPropertyNames(props), + ...Object.getOwnPropertySymbols(props), + ]; + // This for/of is ok because propKeys is an array + for (const p of propKeys) { + // note, use of `any` is due to TypeScript lack of support for symbol in + // index types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.createProperty(p, props[p]); + } + } + this.elementStyles = this.finalizeStyles(this.styles); + return true; + } + /** + * Takes the styles the user supplied via the `static styles` property and + * returns the array of styles to apply to the element. + * Override this method to integrate into a style management system. + * + * Styles are deduplicated preserving the _last_ instance in the list. This + * is a performance optimization to avoid duplicated styles that can occur + * especially when composing via subclassing. The last item is kept to try + * to preserve the cascade order with the assumption that it's most important + * that last added styles override previous styles. + * + * @nocollapse + * @category styles + */ + static finalizeStyles(styles) { + const elementStyles = []; + if (Array.isArray(styles)) { + // Dedupe the flattened array in reverse order to preserve the last items. + // Casting to Array<unknown> works around TS error that + // appears to come from trying to flatten a type CSSResultArray. + const set = new Set(styles.flat(Infinity).reverse()); + // Then preserve original order by adding the set items in reverse order. + for (const s of set) { + elementStyles.unshift(getCompatibleStyle(s)); + } + } + else if (styles !== undefined) { + elementStyles.push(getCompatibleStyle(styles)); + } + return elementStyles; + } + /** + * Returns the property name for the given attribute `name`. + * @nocollapse + */ + static __attributeNameForProperty(name, options) { + const attribute = options.attribute; + return attribute === false + ? undefined + : typeof attribute === 'string' + ? attribute + : typeof name === 'string' + ? name.toLowerCase() + : undefined; + } + /** + * Internal only override point for customizing work done when elements + * are constructed. + * + * @internal + */ + _initialize() { + var _a; + this.__updatePromise = new Promise((res) => (this.enableUpdating = res)); + this._$changedProperties = new Map(); + this.__saveInstanceProperties(); + // ensures first update will be caught by an early access of + // `updateComplete` + this.requestUpdate(); + (_a = this.constructor._initializers) === null || _a === void 0 ? void 0 : _a.forEach((i) => i(this)); + } + /** + * Registers a `ReactiveController` to participate in the element's reactive + * update cycle. The element automatically calls into any registered + * controllers during its lifecycle callbacks. + * + * If the element is connected when `addController()` is called, the + * controller's `hostConnected()` callback will be immediately called. + * @category controllers + */ + addController(controller) { + var _a, _b; + ((_a = this.__controllers) !== null && _a !== void 0 ? _a : (this.__controllers = [])).push(controller); + // If a controller is added after the element has been connected, + // call hostConnected. Note, re-using existence of `renderRoot` here + // (which is set in connectedCallback) to avoid the need to track a + // first connected state. + if (this.renderRoot !== undefined && this.isConnected) { + (_b = controller.hostConnected) === null || _b === void 0 ? void 0 : _b.call(controller); + } + } + /** + * Removes a `ReactiveController` from the element. + * @category controllers + */ + removeController(controller) { + var _a; + // Note, if the indexOf is -1, the >>> will flip the sign which makes the + // splice do nothing. + (_a = this.__controllers) === null || _a === void 0 ? void 0 : _a.splice(this.__controllers.indexOf(controller) >>> 0, 1); + } + /** + * Fixes any properties set on the instance before upgrade time. + * Otherwise these would shadow the accessor and break these properties. + * The properties are stored in a Map which is played back after the + * constructor runs. Note, on very old versions of Safari (<=9) or Chrome + * (<=41), properties created for native platform properties like (`id` or + * `name`) may not have default values set in the element constructor. On + * these browsers native properties appear on instances and therefore their + * default value will overwrite any element default (e.g. if the element sets + * this.id = 'id' in the constructor, the 'id' will become '' since this is + * the native platform default). + */ + __saveInstanceProperties() { + // Use forEach so this works even if for/of loops are compiled to for loops + // expecting arrays + this.constructor.elementProperties.forEach((_v, p) => { + if (this.hasOwnProperty(p)) { + this.__instanceProperties.set(p, this[p]); + delete this[p]; + } + }); + } + /** + * Returns the node into which the element should render and by default + * creates and returns an open shadowRoot. Implement to customize where the + * element's DOM is rendered. For example, to render into the element's + * childNodes, return `this`. + * + * @return Returns a node into which to render. + * @category rendering + */ + createRenderRoot() { + var _a; + const renderRoot = (_a = this.shadowRoot) !== null && _a !== void 0 ? _a : this.attachShadow(this.constructor.shadowRootOptions); + adoptStyles(renderRoot, this.constructor.elementStyles); + return renderRoot; + } + /** + * On first connection, creates the element's renderRoot, sets up + * element styling, and enables updating. + * @category lifecycle + */ + connectedCallback() { + var _a; + // create renderRoot before first update. + if (this.renderRoot === undefined) { + this.renderRoot = this.createRenderRoot(); + } + this.enableUpdating(true); + (_a = this.__controllers) === null || _a === void 0 ? void 0 : _a.forEach((c) => { var _a; return (_a = c.hostConnected) === null || _a === void 0 ? void 0 : _a.call(c); }); + } + /** + * Note, this method should be considered final and not overridden. It is + * overridden on the element instance with a function that triggers the first + * update. + * @category updates + */ + enableUpdating(_requestedUpdate) { } + /** + * Allows for `super.disconnectedCallback()` in extensions while + * reserving the possibility of making non-breaking feature additions + * when disconnecting at some point in the future. + * @category lifecycle + */ + disconnectedCallback() { + var _a; + (_a = this.__controllers) === null || _a === void 0 ? void 0 : _a.forEach((c) => { var _a; return (_a = c.hostDisconnected) === null || _a === void 0 ? void 0 : _a.call(c); }); + } + /** + * Synchronizes property values when attributes change. + * + * Specifically, when an attribute is set, the corresponding property is set. + * You should rarely need to implement this callback. If this method is + * overridden, `super.attributeChangedCallback(name, _old, value)` must be + * called. + * + * See [using the lifecycle callbacks](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks) + * on MDN for more information about the `attributeChangedCallback`. + * @category attributes + */ + attributeChangedCallback(name, _old, value) { + this._$attributeToProperty(name, value); + } + __propertyToAttribute(name, value, options = defaultPropertyDeclaration) { + var _a; + const attr = this.constructor.__attributeNameForProperty(name, options); + if (attr !== undefined && options.reflect === true) { + const converter = ((_a = options.converter) === null || _a === void 0 ? void 0 : _a.toAttribute) !== + undefined + ? options.converter + : defaultConverter; + const attrValue = converter.toAttribute(value, options.type); + // Track if the property is being reflected to avoid + // setting the property again via `attributeChangedCallback`. Note: + // 1. this takes advantage of the fact that the callback is synchronous. + // 2. will behave incorrectly if multiple attributes are in the reaction + // stack at time of calling. However, since we process attributes + // in `update` this should not be possible (or an extreme corner case + // that we'd like to discover). + // mark state reflecting + this.__reflectingProperty = name; + if (attrValue == null) { + this.removeAttribute(attr); + } + else { + this.setAttribute(attr, attrValue); + } + // mark state not reflecting + this.__reflectingProperty = null; + } + } + /** @internal */ + _$attributeToProperty(name, value) { + var _a; + const ctor = this.constructor; + // Note, hint this as an `AttributeMap` so closure clearly understands + // the type; it has issues with tracking types through statics + const propName = ctor.__attributeToPropertyMap.get(name); + // Use tracking info to avoid reflecting a property value to an attribute + // if it was just set because the attribute changed. + if (propName !== undefined && this.__reflectingProperty !== propName) { + const options = ctor.getPropertyOptions(propName); + const converter = typeof options.converter === 'function' + ? { fromAttribute: options.converter } + : ((_a = options.converter) === null || _a === void 0 ? void 0 : _a.fromAttribute) !== undefined + ? options.converter + : defaultConverter; + // mark state reflecting + this.__reflectingProperty = propName; + this[propName] = converter.fromAttribute(value, options.type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ); + // mark state not reflecting + this.__reflectingProperty = null; + } + } + /** + * Requests an update which is processed asynchronously. This should be called + * when an element should update based on some state not triggered by setting + * a reactive property. In this case, pass no arguments. It should also be + * called when manually implementing a property setter. In this case, pass the + * property `name` and `oldValue` to ensure that any configured property + * options are honored. + * + * @param name name of requesting property + * @param oldValue old value of requesting property + * @param options property options to use instead of the previously + * configured options + * @category updates + */ + requestUpdate(name, oldValue, options) { + let shouldRequestUpdate = true; + // If we have a property key, perform property update steps. + if (name !== undefined) { + options = + options || + this.constructor.getPropertyOptions(name); + const hasChanged = options.hasChanged || notEqual; + if (hasChanged(this[name], oldValue)) { + if (!this._$changedProperties.has(name)) { + this._$changedProperties.set(name, oldValue); + } + // Add to reflecting properties set. + // Note, it's important that every change has a chance to add the + // property to `_reflectingProperties`. This ensures setting + // attribute + property reflects correctly. + if (options.reflect === true && this.__reflectingProperty !== name) { + if (this.__reflectingProperties === undefined) { + this.__reflectingProperties = new Map(); + } + this.__reflectingProperties.set(name, options); + } + } + else { + // Abort the request if the property should not be considered changed. + shouldRequestUpdate = false; + } + } + if (!this.isUpdatePending && shouldRequestUpdate) { + this.__updatePromise = this.__enqueueUpdate(); + } + // Note, since this no longer returns a promise, in dev mode we return a + // thenable which warns if it's called. + return undefined; + } + /** + * Sets up the element to asynchronously update. + */ + async __enqueueUpdate() { + this.isUpdatePending = true; + try { + // Ensure any previous update has resolved before updating. + // This `await` also ensures that property changes are batched. + await this.__updatePromise; + } + catch (e) { + // Refire any previous errors async so they do not disrupt the update + // cycle. Errors are refired so developers have a chance to observe + // them, and this can be done by implementing + // `window.onunhandledrejection`. + Promise.reject(e); + } + const result = this.scheduleUpdate(); + // If `scheduleUpdate` returns a Promise, we await it. This is done to + // enable coordinating updates with a scheduler. Note, the result is + // checked to avoid delaying an additional microtask unless we need to. + if (result != null) { + await result; + } + return !this.isUpdatePending; + } + /** + * Schedules an element update. You can override this method to change the + * timing of updates by returning a Promise. The update will await the + * returned Promise, and you should resolve the Promise to allow the update + * to proceed. If this method is overridden, `super.scheduleUpdate()` + * must be called. + * + * For instance, to schedule updates to occur just before the next frame: + * + * ```ts + * override protected async scheduleUpdate(): Promise<unknown> { + * await new Promise((resolve) => requestAnimationFrame(() => resolve())); + * super.scheduleUpdate(); + * } + * ``` + * @category updates + */ + scheduleUpdate() { + return this.performUpdate(); + } + /** + * Performs an element update. Note, if an exception is thrown during the + * update, `firstUpdated` and `updated` will not be called. + * + * Call `performUpdate()` to immediately process a pending update. This should + * generally not be needed, but it can be done in rare cases when you need to + * update synchronously. + * + * Note: To ensure `performUpdate()` synchronously completes a pending update, + * it should not be overridden. In LitElement 2.x it was suggested to override + * `performUpdate()` to also customizing update scheduling. Instead, you should now + * override `scheduleUpdate()`. For backwards compatibility with LitElement 2.x, + * scheduling updates via `performUpdate()` continues to work, but will make + * also calling `performUpdate()` to synchronously process updates difficult. + * + * @category updates + */ + performUpdate() { + var _b; + // Abort any update if one is not pending when this is called. + // This can happen if `performUpdate` is called early to "flush" + // the update. + if (!this.isUpdatePending) { + return; + } + // create renderRoot before first update. + if (!this.hasUpdated) ; + // Mixin instance properties once, if they exist. + if (this.__instanceProperties) { + // Use forEach so this works even if for/of loops are compiled to for loops + // expecting arrays + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.__instanceProperties.forEach((v, p) => (this[p] = v)); + this.__instanceProperties = undefined; + } + let shouldUpdate = false; + const changedProperties = this._$changedProperties; + try { + shouldUpdate = this.shouldUpdate(changedProperties); + if (shouldUpdate) { + this.willUpdate(changedProperties); + (_b = this.__controllers) === null || _b === void 0 ? void 0 : _b.forEach((c) => { var _a; return (_a = c.hostUpdate) === null || _a === void 0 ? void 0 : _a.call(c); }); + this.update(changedProperties); + } + else { + this.__markUpdated(); + } + } + catch (e) { + // Prevent `firstUpdated` and `updated` from running when there's an + // update exception. + shouldUpdate = false; + // Ensure element can accept additional updates after an exception. + this.__markUpdated(); + throw e; + } + // The update is no longer considered pending and further updates are now allowed. + if (shouldUpdate) { + this._$didUpdate(changedProperties); + } + } + /** + * Invoked before `update()` to compute values needed during the update. + * + * Implement `willUpdate` to compute property values that depend on other + * properties and are used in the rest of the update process. + * + * ```ts + * willUpdate(changedProperties) { + * // only need to check changed properties for an expensive computation. + * if (changedProperties.has('firstName') || changedProperties.has('lastName')) { + * this.sha = computeSHA(`${this.firstName} ${this.lastName}`); + * } + * } + * + * render() { + * return html`SHA: ${this.sha}`; + * } + * ``` + * + * @category updates + */ + willUpdate(_changedProperties) { } + // Note, this is an override point for polyfill-support. + // @internal + _$didUpdate(changedProperties) { + var _a; + (_a = this.__controllers) === null || _a === void 0 ? void 0 : _a.forEach((c) => { var _a; return (_a = c.hostUpdated) === null || _a === void 0 ? void 0 : _a.call(c); }); + if (!this.hasUpdated) { + this.hasUpdated = true; + this.firstUpdated(changedProperties); + } + this.updated(changedProperties); + } + __markUpdated() { + this._$changedProperties = new Map(); + this.isUpdatePending = false; + } + /** + * Returns a Promise that resolves when the element has completed updating. + * The Promise value is a boolean that is `true` if the element completed the + * update without triggering another update. The Promise result is `false` if + * a property was set inside `updated()`. If the Promise is rejected, an + * exception was thrown during the update. + * + * To await additional asynchronous work, override the `getUpdateComplete` + * method. For example, it is sometimes useful to await a rendered element + * before fulfilling this Promise. To do this, first await + * `super.getUpdateComplete()`, then any subsequent state. + * + * @return A promise of a boolean that resolves to true if the update completed + * without triggering another update. + * @category updates + */ + get updateComplete() { + return this.getUpdateComplete(); + } + /** + * Override point for the `updateComplete` promise. + * + * It is not safe to override the `updateComplete` getter directly due to a + * limitation in TypeScript which means it is not possible to call a + * superclass getter (e.g. `super.updateComplete.then(...)`) when the target + * language is ES5 (https://github.com/microsoft/TypeScript/issues/338). + * This method should be overridden instead. For example: + * + * ```ts + * class MyElement extends LitElement { + * override async getUpdateComplete() { + * const result = await super.getUpdateComplete(); + * await this._myChild.updateComplete; + * return result; + * } + * } + * ``` + * + * @return A promise of a boolean that resolves to true if the update completed + * without triggering another update. + * @category updates + */ + getUpdateComplete() { + return this.__updatePromise; + } + /** + * Controls whether or not `update()` should be called when the element requests + * an update. By default, this method always returns `true`, but this can be + * customized to control when to update. + * + * @param _changedProperties Map of changed properties with old values + * @category updates + */ + shouldUpdate(_changedProperties) { + return true; + } + /** + * Updates the element. This method reflects property values to attributes. + * It can be overridden to render and keep updated element DOM. + * Setting properties inside this method will *not* trigger + * another update. + * + * @param _changedProperties Map of changed properties with old values + * @category updates + */ + update(_changedProperties) { + if (this.__reflectingProperties !== undefined) { + // Use forEach so this works even if for/of loops are compiled to for + // loops expecting arrays + this.__reflectingProperties.forEach((v, k) => this.__propertyToAttribute(k, this[k], v)); + this.__reflectingProperties = undefined; + } + this.__markUpdated(); + } + /** + * Invoked whenever the element is updated. Implement to perform + * post-updating tasks via DOM APIs, for example, focusing an element. + * + * Setting properties inside this method will trigger the element to update + * again after this update cycle completes. + * + * @param _changedProperties Map of changed properties with old values + * @category updates + */ + updated(_changedProperties) { } + /** + * Invoked when the element is first updated. Implement to perform one time + * work on the element after update. + * + * ```ts + * firstUpdated() { + * this.renderRoot.getElementById('my-text-area').focus(); + * } + * ``` + * + * Setting properties inside this method will trigger the element to update + * again after this update cycle completes. + * + * @param _changedProperties Map of changed properties with old values + * @category updates + */ + firstUpdated(_changedProperties) { } +} +_e = finalized; +/** + * Marks class as having finished creating properties. + */ +ReactiveElement[_e] = true; +/** + * Memoized list of all element properties, including any superclass properties. + * Created lazily on user subclasses when finalizing the class. + * @nocollapse + * @category properties + */ +ReactiveElement.elementProperties = new Map(); +/** + * Memoized list of all element styles. + * Created lazily on user subclasses when finalizing the class. + * @nocollapse + * @category styles + */ +ReactiveElement.elementStyles = []; +/** + * Options used when calling `attachShadow`. Set this property to customize + * the options for the shadowRoot; for example, to create a closed + * shadowRoot: `{mode: 'closed'}`. + * + * Note, these options are used in `createRenderRoot`. If this method + * is customized, options should be respected if possible. + * @nocollapse + * @category rendering + */ +ReactiveElement.shadowRootOptions = { mode: 'open' }; +// Apply polyfills if available +polyfillSupport$2 === null || polyfillSupport$2 === void 0 ? void 0 : polyfillSupport$2({ ReactiveElement }); +// IMPORTANT: do not change the property name or the assignment expression. +// This line will be used in regexes to search for ReactiveElement usage. +((_d$1 = global$1.reactiveElementVersions) !== null && _d$1 !== void 0 ? _d$1 : (global$1.reactiveElementVersions = [])).push('1.5.0'); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var _d; +// Use window for browser builds because IE11 doesn't have globalThis. +const global = window; +const __moz_domParser = new DOMParser(); +const wrap$1 = (node) => node; +const trustedTypes = global.trustedTypes; +/** + * Our TrustedTypePolicy for HTML which is declared using the html template + * tag function. + * + * That HTML is a developer-authored constant, and is parsed with innerHTML + * before any untrusted expressions have been mixed in. Therefor it is + * considered safe by construction. + */ +const policy = trustedTypes + ? trustedTypes.createPolicy('lit-html', { + createHTML: (s) => s, + }) + : undefined; +// Added to an attribute name to mark the attribute as bound so we can find +// it easily. +const boundAttributeSuffix = '$lit$'; +// This marker is used in many syntactic positions in HTML, so it must be +// a valid element name and attribute name. We don't support dynamic names (yet) +// but this at least ensures that the parse tree is closer to the template +// intention. +const marker = `lit$${String(Math.random()).slice(9)}$`; +// String used to tell if a comment is a marker comment +const markerMatch = '?' + marker; +// Text used to insert a comment marker node. We use processing instruction +// syntax because it's slightly smaller, but parses as a comment node. +const nodeMarker = `<${markerMatch}>`; +const d = document; +// Creates a dynamic marker. We never have to search for these in the DOM. +const createMarker$1 = (v = '') => d.createComment(v); +const isPrimitive$1 = (value) => value === null || (typeof value != 'object' && typeof value != 'function'); +const isArray = Array.isArray; +const isIterable = (value) => isArray(value) || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeof (value === null || value === void 0 ? void 0 : value[Symbol.iterator]) === 'function'; +const SPACE_CHAR = `[ \t\n\f\r]`; +const ATTR_VALUE_CHAR = `[^ \t\n\f\r"'\`<>=]`; +const NAME_CHAR = `[^\\s"'>=/]`; +// These regexes represent the five parsing states that we care about in the +// Template's HTML scanner. They match the *end* of the state they're named +// after. +// Depending on the match, we transition to a new state. If there's no match, +// we stay in the same state. +// Note that the regexes are stateful. We utilize lastIndex and sync it +// across the multiple regexes used. In addition to the five regexes below +// we also dynamically create a regex to find the matching end tags for raw +// text elements. +/** + * End of text is: `<` followed by: + * (comment start) or (tag) or (dynamic tag binding) + */ +const textEndRegex = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g; +const COMMENT_START = 1; +const TAG_NAME = 2; +const DYNAMIC_TAG_NAME = 3; +const commentEndRegex = /-->/g; +/** + * Comments not started with <!--, like </{, can be ended by a single `>` + */ +const comment2EndRegex = />/g; +/** + * The tagEnd regex matches the end of the "inside an opening" tag syntax + * position. It either matches a `>`, an attribute-like sequence, or the end + * of the string after a space (attribute-name position ending). + * + * See attributes in the HTML spec: + * https://www.w3.org/TR/html5/syntax.html#elements-attributes + * + * " \t\n\f\r" are HTML space characters: + * https://infra.spec.whatwg.org/#ascii-whitespace + * + * So an attribute is: + * * The name: any character except a whitespace character, ("), ('), ">", + * "=", or "/". Note: this is different from the HTML spec which also excludes control characters. + * * Followed by zero or more space characters + * * Followed by "=" + * * Followed by zero or more space characters + * * Followed by: + * * Any character except space, ('), ("), "<", ">", "=", (`), or + * * (") then any non-("), or + * * (') then any non-(') + */ +const tagEndRegex = new RegExp(`>|${SPACE_CHAR}(?:(${NAME_CHAR}+)(${SPACE_CHAR}*=${SPACE_CHAR}*(?:${ATTR_VALUE_CHAR}|("|')|))|$)`, 'g'); +const ENTIRE_MATCH = 0; +const ATTRIBUTE_NAME = 1; +const SPACES_AND_EQUALS = 2; +const QUOTE_CHAR = 3; +const singleQuoteAttrEndRegex = /'/g; +const doubleQuoteAttrEndRegex = /"/g; +/** + * Matches the raw text elements. + * + * Comments are not parsed within raw text elements, so we need to search their + * text content for marker strings. + */ +const rawTextElement = /^(?:script|style|textarea|title)$/i; +/** TemplateResult types */ +const HTML_RESULT$1 = 1; +const SVG_RESULT$1 = 2; +// TemplatePart types +// IMPORTANT: these must match the values in PartType +const ATTRIBUTE_PART = 1; +const CHILD_PART = 2; +const PROPERTY_PART = 3; +const BOOLEAN_ATTRIBUTE_PART = 4; +const EVENT_PART = 5; +const ELEMENT_PART = 6; +const COMMENT_PART = 7; +/** + * Generates a template literal tag function that returns a TemplateResult with + * the given result type. + */ +const tag = (type) => (strings, ...values) => { + return { + // This property needs to remain unminified. + ['_$litType$']: type, + strings, + values, + }; +}; +/** + * Interprets a template literal as an HTML template that can efficiently + * render to and update a container. + * + * ```ts + * const header = (title: string) => html`<h1>${title}</h1>`; + * ``` + * + * The `html` tag returns a description of the DOM to render as a value. It is + * lazy, meaning no work is done until the template is rendered. When rendering, + * if a template comes from the same expression as a previously rendered result, + * it's efficiently updated instead of replaced. + */ +const html$1 = tag(HTML_RESULT$1); +/** + * Interprets a template literal as an SVG fragment that can efficiently + * render to and update a container. + * + * ```ts + * const rect = svg`<rect width="10" height="10"></rect>`; + * + * const myImage = html` + * <svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + * ${rect} + * </svg>`; + * ``` + * + * The `svg` *tag function* should only be used for SVG fragments, or elements + * that would be contained **inside** an `<svg>` HTML element. A common error is + * placing an `<svg>` *element* in a template tagged with the `svg` tag + * function. The `<svg>` element is an HTML element and should be used within a + * template tagged with the {@linkcode html} tag function. + * + * In LitElement usage, it's invalid to return an SVG fragment from the + * `render()` method, as the SVG fragment will be contained within the element's + * shadow root and thus cannot be used within an `<svg>` HTML element. + */ +const svg$1 = tag(SVG_RESULT$1); +/** + * A sentinel value that signals that a value was handled by a directive and + * should not be written to the DOM. + */ +const noChange = Symbol.for('lit-noChange'); +/** + * A sentinel value that signals a ChildPart to fully clear its content. + * + * ```ts + * const button = html`${ + * user.isAdmin + * ? html`<button>DELETE</button>` + * : nothing + * }`; + * ``` + * + * Prefer using `nothing` over other falsy values as it provides a consistent + * behavior between various expression binding contexts. + * + * In child expressions, `undefined`, `null`, `''`, and `nothing` all behave the + * same and render no nodes. In attribute expressions, `nothing` _removes_ the + * attribute, while `undefined` and `null` will render an empty string. In + * property expressions `nothing` becomes `undefined`. + */ +const nothing = Symbol.for('lit-nothing'); +/** + * The cache of prepared templates, keyed by the tagged TemplateStringsArray + * and _not_ accounting for the specific template tag used. This means that + * template tags cannot be dynamic - the must statically be one of html, svg, + * or attr. This restriction simplifies the cache lookup, which is on the hot + * path for rendering. + */ +const templateCache = new WeakMap(); +const walker = d.createTreeWalker(d, 129 /* NodeFilter.SHOW_{ELEMENT|COMMENT} */, null, false); +/** + * Returns an HTML string for the given TemplateStringsArray and result type + * (HTML or SVG), along with the case-sensitive bound attribute names in + * template order. The HTML contains comment markers denoting the `ChildPart`s + * and suffixes on bound attributes denoting the `AttributeParts`. + * + * @param strings template strings array + * @param type HTML or SVG + * @return Array containing `[html, attrNames]` (array returned for terseness, + * to avoid object fields since this code is shared with non-minified SSR + * code) + */ +const getTemplateHtml = (strings, type) => { + // Insert makers into the template HTML to represent the position of + // bindings. The following code scans the template strings to determine the + // syntactic position of the bindings. They can be in text position, where + // we insert an HTML comment, attribute value position, where we insert a + // sentinel string and re-write the attribute name, or inside a tag where + // we insert the sentinel string. + const l = strings.length - 1; + // Stores the case-sensitive bound attribute names in the order of their + // parts. ElementParts are also reflected in this array as undefined + // rather than a string, to disambiguate from attribute bindings. + const attrNames = []; + let html = type === SVG_RESULT$1 ? '<svg>' : ''; + // When we're inside a raw text tag (not it's text content), the regex + // will still be tagRegex so we can find attributes, but will switch to + // this regex when the tag ends. + let rawTextEndRegex; + // The current parsing state, represented as a reference to one of the + // regexes + let regex = textEndRegex; + for (let i = 0; i < l; i++) { + const s = strings[i]; + // The index of the end of the last attribute name. When this is + // positive at end of a string, it means we're in an attribute value + // position and need to rewrite the attribute name. + // We also use a special value of -2 to indicate that we encountered + // the end of a string in attribute name position. + let attrNameEndIndex = -1; + let attrName; + let lastIndex = 0; + let match; + // The conditions in this loop handle the current parse state, and the + // assignments to the `regex` variable are the state transitions. + while (lastIndex < s.length) { + // Make sure we start searching from where we previously left off + regex.lastIndex = lastIndex; + match = regex.exec(s); + if (match === null) { + break; + } + lastIndex = regex.lastIndex; + if (regex === textEndRegex) { + if (match[COMMENT_START] === '!--') { + regex = commentEndRegex; + } + else if (match[COMMENT_START] !== undefined) { + // We started a weird comment, like </{ + regex = comment2EndRegex; + } + else if (match[TAG_NAME] !== undefined) { + if (rawTextElement.test(match[TAG_NAME])) { + // Record if we encounter a raw-text element. We'll switch to + // this regex at the end of the tag. + rawTextEndRegex = new RegExp(`</${match[TAG_NAME]}`, 'g'); + } + regex = tagEndRegex; + } + else if (match[DYNAMIC_TAG_NAME] !== undefined) { + regex = tagEndRegex; + } + } + else if (regex === tagEndRegex) { + if (match[ENTIRE_MATCH] === '>') { + // End of a tag. If we had started a raw-text element, use that + // regex + regex = rawTextEndRegex !== null && rawTextEndRegex !== void 0 ? rawTextEndRegex : textEndRegex; + // We may be ending an unquoted attribute value, so make sure we + // clear any pending attrNameEndIndex + attrNameEndIndex = -1; + } + else if (match[ATTRIBUTE_NAME] === undefined) { + // Attribute name position + attrNameEndIndex = -2; + } + else { + attrNameEndIndex = regex.lastIndex - match[SPACES_AND_EQUALS].length; + attrName = match[ATTRIBUTE_NAME]; + regex = + match[QUOTE_CHAR] === undefined + ? tagEndRegex + : match[QUOTE_CHAR] === '"' + ? doubleQuoteAttrEndRegex + : singleQuoteAttrEndRegex; + } + } + else if (regex === doubleQuoteAttrEndRegex || + regex === singleQuoteAttrEndRegex) { + regex = tagEndRegex; + } + else if (regex === commentEndRegex || regex === comment2EndRegex) { + regex = textEndRegex; + } + else { + // Not one of the five state regexes, so it must be the dynamically + // created raw text regex and we're at the close of that element. + regex = tagEndRegex; + rawTextEndRegex = undefined; + } + } + // We have four cases: + // 1. We're in text position, and not in a raw text element + // (regex === textEndRegex): insert a comment marker. + // 2. We have a non-negative attrNameEndIndex which means we need to + // rewrite the attribute name to add a bound attribute suffix. + // 3. We're at the non-first binding in a multi-binding attribute, use a + // plain marker. + // 4. We're somewhere else inside the tag. If we're in attribute name + // position (attrNameEndIndex === -2), add a sequential suffix to + // generate a unique attribute name. + // Detect a binding next to self-closing tag end and insert a space to + // separate the marker from the tag end: + const end = regex === tagEndRegex && strings[i + 1].startsWith('/>') ? ' ' : ''; + html += + regex === textEndRegex + ? s + nodeMarker + : attrNameEndIndex >= 0 + ? (attrNames.push(attrName), + s.slice(0, attrNameEndIndex) + + boundAttributeSuffix + + s.slice(attrNameEndIndex)) + + marker + + end + : s + + marker + + (attrNameEndIndex === -2 ? (attrNames.push(undefined), i) : end); + } + const htmlResult = html + (strings[l] || '<?>') + (type === SVG_RESULT$1 ? '</svg>' : ''); + // A security check to prevent spoofing of Lit template results. + // In the future, we may be able to replace this with Array.isTemplateObject, + // though we might need to make that check inside of the html and svg + // functions, because precompiled templates don't come in as + // TemplateStringArray objects. + if (!Array.isArray(strings) || !strings.hasOwnProperty('raw')) { + let message = 'invalid template strings array'; + throw new Error(message); + } + // Returned as an array for terseness + return [ + policy !== undefined + ? policy.createHTML(htmlResult) + : htmlResult, + attrNames, + ]; +}; +class Template { + constructor( + // This property needs to remain unminified. + { strings, ['_$litType$']: type }, options) { + /** @internal */ + this.parts = []; + let node; + let nodeIndex = 0; + let attrNameIndex = 0; + const partCount = strings.length - 1; + const parts = this.parts; + // Create template element + const [html, attrNames] = getTemplateHtml(strings, type); + this.el = Template.createElement(html, options); + walker.currentNode = this.el.content; + // Reparent SVG nodes into template root + if (type === SVG_RESULT$1) { + const content = this.el.content; + const svgElement = content.firstChild; + svgElement.remove(); + content.append(...svgElement.childNodes); + } + // Walk the template to find binding markers and create TemplateParts + while ((node = walker.nextNode()) !== null && parts.length < partCount) { + if (node.nodeType === 1) { + // TODO (justinfagnani): for attempted dynamic tag names, we don't + // increment the bindingIndex, and it'll be off by 1 in the element + // and off by two after it. + if (node.hasAttributes()) { + // We defer removing bound attributes because on IE we might not be + // iterating attributes in their template order, and would sometimes + // remove an attribute that we still need to create a part for. + const attrsToRemove = []; + for (const name of node.getAttributeNames()) { + // `name` is the name of the attribute we're iterating over, but not + // _neccessarily_ the name of the attribute we will create a part + // for. They can be different in browsers that don't iterate on + // attributes in source order. In that case the attrNames array + // contains the attribute name we'll process next. We only need the + // attribute name here to know if we should process a bound attribute + // on this element. + if (name.endsWith(boundAttributeSuffix) || + name.startsWith(marker)) { + const realName = attrNames[attrNameIndex++]; + attrsToRemove.push(name); + if (realName !== undefined) { + // Lowercase for case-sensitive SVG attributes like viewBox + const value = node.getAttribute(realName.toLowerCase() + boundAttributeSuffix); + const statics = value.split(marker); + const m = /([.?@])?(.*)/.exec(realName); + parts.push({ + type: ATTRIBUTE_PART, + index: nodeIndex, + name: m[2], + strings: statics, + ctor: m[1] === '.' + ? PropertyPart + : m[1] === '?' + ? BooleanAttributePart + : m[1] === '@' + ? EventPart + : AttributePart, + }); + } + else { + parts.push({ + type: ELEMENT_PART, + index: nodeIndex, + }); + } + } + } + for (const name of attrsToRemove) { + node.removeAttribute(name); + } + } + // TODO (justinfagnani): benchmark the regex against testing for each + // of the 3 raw text element names. + if (rawTextElement.test(node.tagName)) { + // For raw text elements we need to split the text content on + // markers, create a Text node for each segment, and create + // a TemplatePart for each marker. + const strings = node.textContent.split(marker); + const lastIndex = strings.length - 1; + if (lastIndex > 0) { + node.textContent = trustedTypes + ? trustedTypes.emptyScript + : ''; + // Generate a new text node for each literal section + // These nodes are also used as the markers for node parts + // We can't use empty text nodes as markers because they're + // normalized when cloning in IE (could simplify when + // IE is no longer supported) + for (let i = 0; i < lastIndex; i++) { + node.append(strings[i], createMarker$1()); + // Walk past the marker node we just added + walker.nextNode(); + parts.push({ type: CHILD_PART, index: ++nodeIndex }); + } + // Note because this marker is added after the walker's current + // node, it will be walked to in the outer loop (and ignored), so + // we don't need to adjust nodeIndex here + node.append(strings[lastIndex], createMarker$1()); + } + } + } + else if (node.nodeType === 8) { + const data = node.data; + if (data === markerMatch) { + parts.push({ type: CHILD_PART, index: nodeIndex }); + } + else { + let i = -1; + while ((i = node.data.indexOf(marker, i + 1)) !== -1) { + // Comment node has a binding marker inside, make an inactive part + // The binding won't work, but subsequent bindings will + parts.push({ type: COMMENT_PART, index: nodeIndex }); + // Move to the end of the match + i += marker.length - 1; + } + } + } + nodeIndex++; + } + } + // Overridden via `litHtmlPolyfillSupport` to provide platform support. + /** @nocollapse */ + static createElement(html, _options) { + const doc = __moz_domParser.parseFromString(`<template>${html}</template>`, 'text/html'); + return document.importNode(doc.querySelector('template'), true); + } +} +function resolveDirective(part, value, parent = part, attributeIndex) { + var _a, _b, _c; + var _d; + // Bail early if the value is explicitly noChange. Note, this means any + // nested directive is still attached and is not run. + if (value === noChange) { + return value; + } + let currentDirective = attributeIndex !== undefined + ? (_a = parent.__directives) === null || _a === void 0 ? void 0 : _a[attributeIndex] + : parent.__directive; + const nextDirectiveConstructor = isPrimitive$1(value) + ? undefined + : // This property needs to remain unminified. + value['_$litDirective$']; + if ((currentDirective === null || currentDirective === void 0 ? void 0 : currentDirective.constructor) !== nextDirectiveConstructor) { + // This property needs to remain unminified. + (_b = currentDirective === null || currentDirective === void 0 ? void 0 : currentDirective['_$notifyDirectiveConnectionChanged']) === null || _b === void 0 ? void 0 : _b.call(currentDirective, false); + if (nextDirectiveConstructor === undefined) { + currentDirective = undefined; + } + else { + currentDirective = new nextDirectiveConstructor(part); + currentDirective._$initialize(part, parent, attributeIndex); + } + if (attributeIndex !== undefined) { + ((_c = (_d = parent).__directives) !== null && _c !== void 0 ? _c : (_d.__directives = []))[attributeIndex] = + currentDirective; + } + else { + parent.__directive = currentDirective; + } + } + if (currentDirective !== undefined) { + value = resolveDirective(part, currentDirective._$resolve(part, value.values), currentDirective, attributeIndex); + } + return value; +} +/** + * An updateable instance of a Template. Holds references to the Parts used to + * update the template instance. + */ +class TemplateInstance { + constructor(template, parent) { + /** @internal */ + this._parts = []; + /** @internal */ + this._$disconnectableChildren = undefined; + this._$template = template; + this._$parent = parent; + } + // Called by ChildPart parentNode getter + get parentNode() { + return this._$parent.parentNode; + } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + return this._$parent._$isConnected; + } + // This method is separate from the constructor because we need to return a + // DocumentFragment and we don't want to hold onto it with an instance field. + _clone(options) { + var _a; + const { el: { content }, parts: parts, } = this._$template; + const fragment = ((_a = options === null || options === void 0 ? void 0 : options.creationScope) !== null && _a !== void 0 ? _a : d).importNode(content, true); + walker.currentNode = fragment; + let node = walker.nextNode(); + let nodeIndex = 0; + let partIndex = 0; + let templatePart = parts[0]; + while (templatePart !== undefined) { + if (nodeIndex === templatePart.index) { + let part; + if (templatePart.type === CHILD_PART) { + part = new ChildPart$1(node, node.nextSibling, this, options); + } + else if (templatePart.type === ATTRIBUTE_PART) { + part = new templatePart.ctor(node, templatePart.name, templatePart.strings, this, options); + } + else if (templatePart.type === ELEMENT_PART) { + part = new ElementPart(node, this, options); + } + this._parts.push(part); + templatePart = parts[++partIndex]; + } + if (nodeIndex !== (templatePart === null || templatePart === void 0 ? void 0 : templatePart.index)) { + node = walker.nextNode(); + nodeIndex++; + } + } + return fragment; + } + _update(values) { + let i = 0; + for (const part of this._parts) { + if (part !== undefined) { + if (part.strings !== undefined) { + part._$setValue(values, part, i); + // The number of values the part consumes is part.strings.length - 1 + // since values are in between template spans. We increment i by 1 + // later in the loop, so increment it by part.strings.length - 2 here + i += part.strings.length - 2; + } + else { + part._$setValue(values[i]); + } + } + i++; + } + } +} +class ChildPart$1 { + constructor(startNode, endNode, parent, options) { + var _a; + this.type = CHILD_PART; + this._$committedValue = nothing; + // The following fields will be patched onto ChildParts when required by + // AsyncDirective + /** @internal */ + this._$disconnectableChildren = undefined; + this._$startNode = startNode; + this._$endNode = endNode; + this._$parent = parent; + this.options = options; + // Note __isConnected is only ever accessed on RootParts (i.e. when there is + // no _$parent); the value on a non-root-part is "don't care", but checking + // for parent would be more code + this.__isConnected = (_a = options === null || options === void 0 ? void 0 : options.isConnected) !== null && _a !== void 0 ? _a : true; + } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + var _a, _b; + // ChildParts that are not at the root should always be created with a + // parent; only RootChildNode's won't, so they return the local isConnected + // state + return (_b = (_a = this._$parent) === null || _a === void 0 ? void 0 : _a._$isConnected) !== null && _b !== void 0 ? _b : this.__isConnected; + } + /** + * The parent node into which the part renders its content. + * + * A ChildPart's content consists of a range of adjacent child nodes of + * `.parentNode`, possibly bordered by 'marker nodes' (`.startNode` and + * `.endNode`). + * + * - If both `.startNode` and `.endNode` are non-null, then the part's content + * consists of all siblings between `.startNode` and `.endNode`, exclusively. + * + * - If `.startNode` is non-null but `.endNode` is null, then the part's + * content consists of all siblings following `.startNode`, up to and + * including the last child of `.parentNode`. If `.endNode` is non-null, then + * `.startNode` will always be non-null. + * + * - If both `.endNode` and `.startNode` are null, then the part's content + * consists of all child nodes of `.parentNode`. + */ + get parentNode() { + let parentNode = wrap$1(this._$startNode).parentNode; + const parent = this._$parent; + if (parent !== undefined && + parentNode.nodeType === 11 /* Node.DOCUMENT_FRAGMENT */) { + // If the parentNode is a DocumentFragment, it may be because the DOM is + // still in the cloned fragment during initial render; if so, get the real + // parentNode the part will be committed into by asking the parent. + parentNode = parent.parentNode; + } + return parentNode; + } + /** + * The part's leading marker node, if any. See `.parentNode` for more + * information. + */ + get startNode() { + return this._$startNode; + } + /** + * The part's trailing marker node, if any. See `.parentNode` for more + * information. + */ + get endNode() { + return this._$endNode; + } + _$setValue(value, directiveParent = this) { + value = resolveDirective(this, value, directiveParent); + if (isPrimitive$1(value)) { + // Non-rendering child values. It's important that these do not render + // empty text nodes to avoid issues with preventing default <slot> + // fallback content. + if (value === nothing || value == null || value === '') { + if (this._$committedValue !== nothing) { + this._$clear(); + } + this._$committedValue = nothing; + } + else if (value !== this._$committedValue && value !== noChange) { + this._commitText(value); + } + // This property needs to remain unminified. + } + else if (value['_$litType$'] !== undefined) { + this._commitTemplateResult(value); + } + else if (value.nodeType !== undefined) { + this._commitNode(value); + } + else if (isIterable(value)) { + this._commitIterable(value); + } + else { + // Fallback, will render the string representation + this._commitText(value); + } + } + _insert(node, ref = this._$endNode) { + return wrap$1(wrap$1(this._$startNode).parentNode).insertBefore(node, ref); + } + _commitNode(value) { + if (this._$committedValue !== value) { + this._$clear(); + this._$committedValue = this._insert(value); + } + } + _commitText(value) { + // If the committed value is a primitive it means we called _commitText on + // the previous render, and we know that this._$startNode.nextSibling is a + // Text node. We can now just replace the text content (.data) of the node. + if (this._$committedValue !== nothing && + isPrimitive$1(this._$committedValue)) { + const node = wrap$1(this._$startNode).nextSibling; + node.data = value; + } + else { + { + this._commitNode(d.createTextNode(value)); + } + } + this._$committedValue = value; + } + _commitTemplateResult(result) { + var _a; + // This property needs to remain unminified. + const { values, ['_$litType$']: type } = result; + // If $litType$ is a number, result is a plain TemplateResult and we get + // the template from the template cache. If not, result is a + // CompiledTemplateResult and _$litType$ is a CompiledTemplate and we need + // to create the <template> element the first time we see it. + const template = typeof type === 'number' + ? this._$getTemplate(result) + : (type.el === undefined && + (type.el = Template.createElement(type.h, this.options)), + type); + if (((_a = this._$committedValue) === null || _a === void 0 ? void 0 : _a._$template) === template) { + this._$committedValue._update(values); + } + else { + const instance = new TemplateInstance(template, this); + const fragment = instance._clone(this.options); + instance._update(values); + this._commitNode(fragment); + this._$committedValue = instance; + } + } + // Overridden via `litHtmlPolyfillSupport` to provide platform support. + /** @internal */ + _$getTemplate(result) { + let template = templateCache.get(result.strings); + if (template === undefined) { + templateCache.set(result.strings, (template = new Template(result))); + } + return template; + } + _commitIterable(value) { + // For an Iterable, we create a new InstancePart per item, then set its + // value to the item. This is a little bit of overhead for every item in + // an Iterable, but it lets us recurse easily and efficiently update Arrays + // of TemplateResults that will be commonly returned from expressions like: + // array.map((i) => html`${i}`), by reusing existing TemplateInstances. + // If value is an array, then the previous render was of an + // iterable and value will contain the ChildParts from the previous + // render. If value is not an array, clear this part and make a new + // array for ChildParts. + if (!isArray(this._$committedValue)) { + this._$committedValue = []; + this._$clear(); + } + // Lets us keep track of how many items we stamped so we can clear leftover + // items from a previous render + const itemParts = this._$committedValue; + let partIndex = 0; + let itemPart; + for (const item of value) { + if (partIndex === itemParts.length) { + // If no existing part, create a new one + // TODO (justinfagnani): test perf impact of always creating two parts + // instead of sharing parts between nodes + // https://github.com/lit/lit/issues/1266 + itemParts.push((itemPart = new ChildPart$1(this._insert(createMarker$1()), this._insert(createMarker$1()), this, this.options))); + } + else { + // Reuse an existing part + itemPart = itemParts[partIndex]; + } + itemPart._$setValue(item); + partIndex++; + } + if (partIndex < itemParts.length) { + // itemParts always have end nodes + this._$clear(itemPart && wrap$1(itemPart._$endNode).nextSibling, partIndex); + // Truncate the parts array so _value reflects the current state + itemParts.length = partIndex; + } + } + /** + * Removes the nodes contained within this Part from the DOM. + * + * @param start Start node to clear from, for clearing a subset of the part's + * DOM (used when truncating iterables) + * @param from When `start` is specified, the index within the iterable from + * which ChildParts are being removed, used for disconnecting directives in + * those Parts. + * + * @internal + */ + _$clear(start = wrap$1(this._$startNode).nextSibling, from) { + var _a; + (_a = this._$notifyConnectionChanged) === null || _a === void 0 ? void 0 : _a.call(this, false, true, from); + while (start && start !== this._$endNode) { + const n = wrap$1(start).nextSibling; + wrap$1(start).remove(); + start = n; + } + } + /** + * Implementation of RootPart's `isConnected`. Note that this metod + * should only be called on `RootPart`s (the `ChildPart` returned from a + * top-level `render()` call). It has no effect on non-root ChildParts. + * @param isConnected Whether to set + * @internal + */ + setConnected(isConnected) { + var _a; + if (this._$parent === undefined) { + this.__isConnected = isConnected; + (_a = this._$notifyConnectionChanged) === null || _a === void 0 ? void 0 : _a.call(this, isConnected); + } + } +} +class AttributePart { + constructor(element, name, strings, parent, options) { + this.type = ATTRIBUTE_PART; + /** @internal */ + this._$committedValue = nothing; + /** @internal */ + this._$disconnectableChildren = undefined; + this.element = element; + this.name = name; + this._$parent = parent; + this.options = options; + if (strings.length > 2 || strings[0] !== '' || strings[1] !== '') { + this._$committedValue = new Array(strings.length - 1).fill(new String()); + this.strings = strings; + } + else { + this._$committedValue = nothing; + } + } + get tagName() { + return this.element.tagName; + } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + return this._$parent._$isConnected; + } + /** + * Sets the value of this part by resolving the value from possibly multiple + * values and static strings and committing it to the DOM. + * If this part is single-valued, `this._strings` will be undefined, and the + * method will be called with a single value argument. If this part is + * multi-value, `this._strings` will be defined, and the method is called + * with the value array of the part's owning TemplateInstance, and an offset + * into the value array from which the values should be read. + * This method is overloaded this way to eliminate short-lived array slices + * of the template instance values, and allow a fast-path for single-valued + * parts. + * + * @param value The part value, or an array of values for multi-valued parts + * @param valueIndex the index to start reading values from. `undefined` for + * single-valued parts + * @param noCommit causes the part to not commit its value to the DOM. Used + * in hydration to prime attribute parts with their first-rendered value, + * but not set the attribute, and in SSR to no-op the DOM operation and + * capture the value for serialization. + * + * @internal + */ + _$setValue(value, directiveParent = this, valueIndex, noCommit) { + const strings = this.strings; + // Whether any of the values has changed, for dirty-checking + let change = false; + if (strings === undefined) { + // Single-value binding case + value = resolveDirective(this, value, directiveParent, 0); + change = + !isPrimitive$1(value) || + (value !== this._$committedValue && value !== noChange); + if (change) { + this._$committedValue = value; + } + } + else { + // Interpolation case + const values = value; + value = strings[0]; + let i, v; + for (i = 0; i < strings.length - 1; i++) { + v = resolveDirective(this, values[valueIndex + i], directiveParent, i); + if (v === noChange) { + // If the user-provided value is `noChange`, use the previous value + v = this._$committedValue[i]; + } + change || (change = !isPrimitive$1(v) || v !== this._$committedValue[i]); + if (v === nothing) { + value = nothing; + } + else if (value !== nothing) { + value += (v !== null && v !== void 0 ? v : '') + strings[i + 1]; + } + // We always record each value, even if one is `nothing`, for future + // change detection. + this._$committedValue[i] = v; + } + } + if (change && !noCommit) { + this._commitValue(value); + } + } + /** @internal */ + _commitValue(value) { + if (value === nothing) { + wrap$1(this.element).removeAttribute(this.name); + } + else { + wrap$1(this.element).setAttribute(this.name, (value !== null && value !== void 0 ? value : '')); + } + } +} +class PropertyPart extends AttributePart { + constructor() { + super(...arguments); + this.type = PROPERTY_PART; + } + /** @internal */ + _commitValue(value) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.element[this.name] = value === nothing ? undefined : value; + } +} +// Temporary workaround for https://crbug.com/993268 +// Currently, any attribute starting with "on" is considered to be a +// TrustedScript source. Such boolean attributes must be set to the equivalent +// trusted emptyScript value. +const emptyStringForBooleanAttribute = trustedTypes + ? trustedTypes.emptyScript + : ''; +class BooleanAttributePart extends AttributePart { + constructor() { + super(...arguments); + this.type = BOOLEAN_ATTRIBUTE_PART; + } + /** @internal */ + _commitValue(value) { + if (value && value !== nothing) { + wrap$1(this.element).setAttribute(this.name, emptyStringForBooleanAttribute); + } + else { + wrap$1(this.element).removeAttribute(this.name); + } + } +} +class EventPart extends AttributePart { + constructor(element, name, strings, parent, options) { + super(element, name, strings, parent, options); + this.type = EVENT_PART; + } + // EventPart does not use the base _$setValue/_resolveValue implementation + // since the dirty checking is more complex + /** @internal */ + _$setValue(newListener, directiveParent = this) { + var _a; + newListener = + (_a = resolveDirective(this, newListener, directiveParent, 0)) !== null && _a !== void 0 ? _a : nothing; + if (newListener === noChange) { + return; + } + const oldListener = this._$committedValue; + // If the new value is nothing or any options change we have to remove the + // part as a listener. + const shouldRemoveListener = (newListener === nothing && oldListener !== nothing) || + newListener.capture !== + oldListener.capture || + newListener.once !== + oldListener.once || + newListener.passive !== + oldListener.passive; + // If the new value is not nothing and we removed the listener, we have + // to add the part as a listener. + const shouldAddListener = newListener !== nothing && + (oldListener === nothing || shouldRemoveListener); + if (shouldRemoveListener) { + this.element.removeEventListener(this.name, this, oldListener); + } + if (shouldAddListener) { + // Beware: IE11 and Chrome 41 don't like using the listener as the + // options object. Figure out how to deal w/ this in IE11 - maybe + // patch addEventListener? + this.element.addEventListener(this.name, this, newListener); + } + this._$committedValue = newListener; + } + handleEvent(event) { + var _a, _b; + if (typeof this._$committedValue === 'function') { + this._$committedValue.call((_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.host) !== null && _b !== void 0 ? _b : this.element, event); + } + else { + this._$committedValue.handleEvent(event); + } + } +} +class ElementPart { + constructor(element, parent, options) { + this.element = element; + this.type = ELEMENT_PART; + /** @internal */ + this._$disconnectableChildren = undefined; + this._$parent = parent; + this.options = options; + } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + return this._$parent._$isConnected; + } + _$setValue(value) { + resolveDirective(this, value); + } +} +/** + * END USERS SHOULD NOT RELY ON THIS OBJECT. + * + * Private exports for use by other Lit packages, not intended for use by + * external users. + * + * We currently do not make a mangled rollup build of the lit-ssr code. In order + * to keep a number of (otherwise private) top-level exports mangled in the + * client side code, we export a _$LH object containing those members (or + * helper methods for accessing private fields of those members), and then + * re-export them for use in lit-ssr. This keeps lit-ssr agnostic to whether the + * client-side code is being used in `dev` mode or `prod` mode. + * + * This has a unique name, to disambiguate it from private exports in + * lit-element, which re-exports all of lit-html. + * + * @private + */ +const _$LH = { + // Used in lit-ssr + _boundAttributeSuffix: boundAttributeSuffix, + _marker: marker, + _markerMatch: markerMatch, + _HTML_RESULT: HTML_RESULT$1, + _getTemplateHtml: getTemplateHtml, + // Used in hydrate + _TemplateInstance: TemplateInstance, + _isIterable: isIterable, + _resolveDirective: resolveDirective, + // Used in tests and private-ssr-support + _ChildPart: ChildPart$1, + _AttributePart: AttributePart, + _BooleanAttributePart: BooleanAttributePart, + _EventPart: EventPart, + _PropertyPart: PropertyPart, + _ElementPart: ElementPart, +}; +// Apply polyfills if available +const polyfillSupport$1 = global.litHtmlPolyfillSupport; +polyfillSupport$1 === null || polyfillSupport$1 === void 0 ? void 0 : polyfillSupport$1(Template, ChildPart$1); +// IMPORTANT: do not change the property name or the assignment expression. +// This line will be used in regexes to search for lit-html usage. +((_d = global.litHtmlVersions) !== null && _d !== void 0 ? _d : (global.litHtmlVersions = [])).push('2.5.0'); +/** + * Renders a value, usually a lit-html TemplateResult, to the container. + * + * This example renders the text "Hello, Zoe!" inside a paragraph tag, appending + * it to the container `document.body`. + * + * ```js + * import {html, render} from 'lit'; + * + * const name = "Zoe"; + * render(html`<p>Hello, ${name}!</p>`, document.body); + * ``` + * + * @param value Any [renderable + * value](https://lit.dev/docs/templates/expressions/#child-expressions), + * typically a {@linkcode TemplateResult} created by evaluating a template tag + * like {@linkcode html} or {@linkcode svg}. + * @param container A DOM container to render to. The first render will append + * the rendered value to the container, and subsequent renders will + * efficiently update the rendered value if the same result type was + * previously rendered there. + * @param options See {@linkcode RenderOptions} for options documentation. + * @see + * {@link https://lit.dev/docs/libraries/standalone-templates/#rendering-lit-html-templates| Rendering Lit HTML Templates} + */ +const render = (value, container, options) => { + var _a, _b; + const partOwnerNode = (_a = options === null || options === void 0 ? void 0 : options.renderBefore) !== null && _a !== void 0 ? _a : container; + // This property needs to remain unminified. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let part = partOwnerNode['_$litPart$']; + if (part === undefined) { + const endNode = (_b = options === null || options === void 0 ? void 0 : options.renderBefore) !== null && _b !== void 0 ? _b : null; + // This property needs to remain unminified. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + partOwnerNode['_$litPart$'] = part = new ChildPart$1(container.insertBefore(createMarker$1(), endNode), endNode, undefined, options !== null && options !== void 0 ? options : {}); + } + part._$setValue(value); + return part; +}; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var _b, _c; +// For backwards compatibility export ReactiveElement as UpdatingElement. Note, +// IE transpilation requires exporting like this. +const UpdatingElement = ReactiveElement; +/** + * Base element class that manages element properties and attributes, and + * renders a lit-html template. + * + * To define a component, subclass `LitElement` and implement a + * `render` method to provide the component's template. Define properties + * using the {@linkcode LitElement.properties properties} property or the + * {@linkcode property} decorator. + */ +class LitElement extends ReactiveElement { + constructor() { + super(...arguments); + /** + * @category rendering + */ + this.renderOptions = { host: this }; + this.__childPart = undefined; + } + /** + * @category rendering + */ + createRenderRoot() { + var _a; + var _b; + const renderRoot = super.createRenderRoot(); + // When adoptedStyleSheets are shimmed, they are inserted into the + // shadowRoot by createRenderRoot. Adjust the renderBefore node so that + // any styles in Lit content render before adoptedStyleSheets. This is + // important so that adoptedStyleSheets have precedence over styles in + // the shadowRoot. + (_a = (_b = this.renderOptions).renderBefore) !== null && _a !== void 0 ? _a : (_b.renderBefore = renderRoot.firstChild); + return renderRoot; + } + /** + * Updates the element. This method reflects property values to attributes + * and calls `render` to render DOM via lit-html. Setting properties inside + * this method will *not* trigger another update. + * @param changedProperties Map of changed properties with old values + * @category updates + */ + update(changedProperties) { + // Setting properties in `render` should not trigger an update. Since + // updates are allowed after super.update, it's important to call `render` + // before that. + const value = this.render(); + if (!this.hasUpdated) { + this.renderOptions.isConnected = this.isConnected; + } + super.update(changedProperties); + this.__childPart = render(value, this.renderRoot, this.renderOptions); + } + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + * + * ```ts + * connectedCallback() { + * super.connectedCallback(); + * addEventListener('keydown', this._handleKeydown); + * } + * ``` + * + * Typically, anything done in `connectedCallback()` should be undone when the + * element is disconnected, in `disconnectedCallback()`. + * + * @category lifecycle + */ + connectedCallback() { + var _a; + super.connectedCallback(); + (_a = this.__childPart) === null || _a === void 0 ? void 0 : _a.setConnected(true); + } + /** + * Invoked when the component is removed from the document's DOM. + * + * This callback is the main signal to the element that it may no longer be + * used. `disconnectedCallback()` should ensure that nothing is holding a + * reference to the element (such as event listeners added to nodes external + * to the element), so that it is free to be garbage collected. + * + * ```ts + * disconnectedCallback() { + * super.disconnectedCallback(); + * window.removeEventListener('keydown', this._handleKeydown); + * } + * ``` + * + * An element may be re-connected after being disconnected. + * + * @category lifecycle + */ + disconnectedCallback() { + var _a; + super.disconnectedCallback(); + (_a = this.__childPart) === null || _a === void 0 ? void 0 : _a.setConnected(false); + } + /** + * Invoked on each update to perform rendering tasks. This method may return + * any value renderable by lit-html's `ChildPart` - typically a + * `TemplateResult`. Setting properties inside this method will *not* trigger + * the element to update. + * @category rendering + */ + render() { + return noChange; + } +} +/** + * Ensure this class is marked as `finalized` as an optimization ensuring + * it will not needlessly try to `finalize`. + * + * Note this property name is a string to prevent breaking Closure JS Compiler + * optimizations. See @lit/reactive-element for more information. + */ +LitElement['finalized'] = true; +// This property needs to remain unminified. +LitElement['_$litElement$'] = true; +// Install hydration if available +(_b = globalThis.litElementHydrateSupport) === null || _b === void 0 ? void 0 : _b.call(globalThis, { LitElement }); +// Apply polyfills if available +const polyfillSupport = globalThis.litElementPolyfillSupport; +polyfillSupport === null || polyfillSupport === void 0 ? void 0 : polyfillSupport({ LitElement }); +/** + * END USERS SHOULD NOT RELY ON THIS OBJECT. + * + * Private exports for use by other Lit packages, not intended for use by + * external users. + * + * We currently do not make a mangled rollup build of the lit-ssr code. In order + * to keep a number of (otherwise private) top-level exports mangled in the + * client side code, we export a _$LE object containing those members (or + * helper methods for accessing private fields of those members), and then + * re-export them for use in lit-ssr. This keeps lit-ssr agnostic to whether the + * client-side code is being used in `dev` mode or `prod` mode. + * + * This has a unique name, to disambiguate it from private exports in + * lit-html, since this module re-exports all of lit-html. + * + * @private + */ +const _$LE = { + _$attributeToProperty: (el, name, value) => { + // eslint-disable-next-line + el._$attributeToProperty(name, value); + }, + // eslint-disable-next-line + _$changedProperties: (el) => el._$changedProperties, +}; +// IMPORTANT: do not change the property name or the assignment expression. +// This line will be used in regexes to search for LitElement usage. +((_c = globalThis.litElementVersions) !== null && _c !== void 0 ? _c : (globalThis.litElementVersions = [])).push('3.2.2'); + +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * @fileoverview + * + * This file exports a boolean const whose value will depend on what environment + * the module is being imported from. + */ +const NODE_MODE = false; +/** + * A boolean that will be `true` in server environments like Node, and `false` + * in browser environments. Note that your server environment or toolchain must + * support the `"node"` export condition for this to be `true`. + * + * This can be used when authoring components to change behavior based on + * whether or not the component is executing in an SSR context. + */ +const isServer = NODE_MODE; + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const { _ChildPart: ChildPart } = _$LH; +const wrap = (node) => node; +/** + * Tests if a value is a primitive value. + * + * See https://tc39.github.io/ecma262/#sec-typeof-operator + */ +const isPrimitive = (value) => value === null || (typeof value != 'object' && typeof value != 'function'); +const TemplateResultType = { + HTML: 1, + SVG: 2, +}; +/** + * Tests if a value is a TemplateResult. + */ +const isTemplateResult = (value, type) => type === undefined + ? // This property needs to remain unminified. + (value === null || value === void 0 ? void 0 : value['_$litType$']) !== undefined + : (value === null || value === void 0 ? void 0 : value['_$litType$']) === type; +/** + * Tests if a value is a DirectiveResult. + */ +const isDirectiveResult = (value) => +// This property needs to remain unminified. +(value === null || value === void 0 ? void 0 : value['_$litDirective$']) !== undefined; +/** + * Retrieves the Directive class for a DirectiveResult + */ +const getDirectiveClass = (value) => +// This property needs to remain unminified. +value === null || value === void 0 ? void 0 : value['_$litDirective$']; +/** + * Tests whether a part has only a single-expression with no strings to + * interpolate between. + * + * Only AttributePart and PropertyPart can have multiple expressions. + * Multi-expression parts have a `strings` property and single-expression + * parts do not. + */ +const isSingleExpression = (part) => part.strings === undefined; +const createMarker = () => document.createComment(''); +/** + * Inserts a ChildPart into the given container ChildPart's DOM, either at the + * end of the container ChildPart, or before the optional `refPart`. + * + * This does not add the part to the containerPart's committed value. That must + * be done by callers. + * + * @param containerPart Part within which to add the new ChildPart + * @param refPart Part before which to add the new ChildPart; when omitted the + * part added to the end of the `containerPart` + * @param part Part to insert, or undefined to create a new part + */ +const insertPart = (containerPart, refPart, part) => { + var _a; + const container = wrap(containerPart._$startNode).parentNode; + const refNode = refPart === undefined ? containerPart._$endNode : refPart._$startNode; + if (part === undefined) { + const startNode = wrap(container).insertBefore(createMarker(), refNode); + const endNode = wrap(container).insertBefore(createMarker(), refNode); + part = new ChildPart(startNode, endNode, containerPart, containerPart.options); + } + else { + const endNode = wrap(part._$endNode).nextSibling; + const oldParent = part._$parent; + const parentChanged = oldParent !== containerPart; + if (parentChanged) { + (_a = part._$reparentDisconnectables) === null || _a === void 0 ? void 0 : _a.call(part, containerPart); + // Note that although `_$reparentDisconnectables` updates the part's + // `_$parent` reference after unlinking from its current parent, that + // method only exists if Disconnectables are present, so we need to + // unconditionally set it here + part._$parent = containerPart; + // Since the _$isConnected getter is somewhat costly, only + // read it once we know the subtree has directives that need + // to be notified + let newConnectionState; + if (part._$notifyConnectionChanged !== undefined && + (newConnectionState = containerPart._$isConnected) !== + oldParent._$isConnected) { + part._$notifyConnectionChanged(newConnectionState); + } + } + if (endNode !== refNode || parentChanged) { + let start = part._$startNode; + while (start !== endNode) { + const n = wrap(start).nextSibling; + wrap(container).insertBefore(start, refNode); + start = n; + } + } + } + return part; +}; +/** + * Sets the value of a Part. + * + * Note that this should only be used to set/update the value of user-created + * parts (i.e. those created using `insertPart`); it should not be used + * by directives to set the value of the directive's container part. Directives + * should return a value from `update`/`render` to update their part state. + * + * For directives that require setting their part value asynchronously, they + * should extend `AsyncDirective` and call `this.setValue()`. + * + * @param part Part to set + * @param value Value to set + * @param index For `AttributePart`s, the index to set + * @param directiveParent Used internally; should not be set by user + */ +const setChildPartValue = (part, value, directiveParent = part) => { + part._$setValue(value, directiveParent); + return part; +}; +// A sentinal value that can never appear as a part value except when set by +// live(). Used to force a dirty-check to fail and cause a re-render. +const RESET_VALUE = {}; +/** + * Sets the committed value of a ChildPart directly without triggering the + * commit stage of the part. + * + * This is useful in cases where a directive needs to update the part such + * that the next update detects a value change or not. When value is omitted, + * the next update will be guaranteed to be detected as a change. + * + * @param part + * @param value + */ +const setCommittedValue = (part, value = RESET_VALUE) => (part._$committedValue = value); +/** + * Returns the committed value of a ChildPart. + * + * The committed value is used for change detection and efficient updates of + * the part. It can differ from the value set by the template or directive in + * cases where the template value is transformed before being commited. + * + * - `TemplateResult`s are committed as a `TemplateInstance` + * - Iterables are committed as `Array<ChildPart>` + * - All other types are committed as the template value or value returned or + * set by a directive. + * + * @param part + */ +const getCommittedValue = (part) => part._$committedValue; +/** + * Removes a ChildPart from the DOM, including any of its content. + * + * @param part The Part to remove + */ +const removePart = (part) => { + var _a; + (_a = part._$notifyConnectionChanged) === null || _a === void 0 ? void 0 : _a.call(part, false, true); + let start = part._$startNode; + const end = wrap(part._$endNode).nextSibling; + while (start !== end) { + const n = wrap(start).nextSibling; + wrap(start).remove(); + start = n; + } +}; +const clearPart = (part) => { + part._$clear(); +}; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const PartType = { + ATTRIBUTE: 1, + CHILD: 2, + PROPERTY: 3, + BOOLEAN_ATTRIBUTE: 4, + EVENT: 5, + ELEMENT: 6, +}; +/** + * Creates a user-facing directive function from a Directive class. This + * function has the same parameters as the directive's render() method. + */ +const directive = (c) => (...values) => ({ + // This property needs to remain unminified. + ['_$litDirective$']: c, + values, +}); +/** + * Base class for creating custom directives. Users should extend this class, + * implement `render` and/or `update`, and then pass their subclass to + * `directive`. + */ +class Directive { + constructor(_partInfo) { } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + return this._$parent._$isConnected; + } + /** @internal */ + _$initialize(part, parent, attributeIndex) { + this.__part = part; + this._$parent = parent; + this.__attributeIndex = attributeIndex; + } + /** @internal */ + _$resolve(part, props) { + return this.update(part, props); + } + update(_part, props) { + return this.render(...props); + } +} + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Recursively walks down the tree of Parts/TemplateInstances/Directives to set + * the connected state of directives and run `disconnected`/ `reconnected` + * callbacks. + * + * @return True if there were children to disconnect; false otherwise + */ +const notifyChildrenConnectedChanged = (parent, isConnected) => { + var _a, _b; + const children = parent._$disconnectableChildren; + if (children === undefined) { + return false; + } + for (const obj of children) { + // The existence of `_$notifyDirectiveConnectionChanged` is used as a "brand" to + // disambiguate AsyncDirectives from other DisconnectableChildren + // (as opposed to using an instanceof check to know when to call it); the + // redundancy of "Directive" in the API name is to avoid conflicting with + // `_$notifyConnectionChanged`, which exists `ChildParts` which are also in + // this list + // Disconnect Directive (and any nested directives contained within) + // This property needs to remain unminified. + (_b = (_a = obj)['_$notifyDirectiveConnectionChanged']) === null || _b === void 0 ? void 0 : _b.call(_a, isConnected, false); + // Disconnect Part/TemplateInstance + notifyChildrenConnectedChanged(obj, isConnected); + } + return true; +}; +/** + * Removes the given child from its parent list of disconnectable children, and + * if the parent list becomes empty as a result, removes the parent from its + * parent, and so forth up the tree when that causes subsequent parent lists to + * become empty. + */ +const removeDisconnectableFromParent = (obj) => { + let parent, children; + do { + if ((parent = obj._$parent) === undefined) { + break; + } + children = parent._$disconnectableChildren; + children.delete(obj); + obj = parent; + } while ((children === null || children === void 0 ? void 0 : children.size) === 0); +}; +const addDisconnectableToParent = (obj) => { + // Climb the parent tree, creating a sparse tree of children needing + // disconnection + for (let parent; (parent = obj._$parent); obj = parent) { + let children = parent._$disconnectableChildren; + if (children === undefined) { + parent._$disconnectableChildren = children = new Set(); + } + else if (children.has(obj)) { + // Once we've reached a parent that already contains this child, we + // can short-circuit + break; + } + children.add(obj); + installDisconnectAPI(parent); + } +}; +/** + * Changes the parent reference of the ChildPart, and updates the sparse tree of + * Disconnectable children accordingly. + * + * Note, this method will be patched onto ChildPart instances and called from + * the core code when parts are moved between different parents. + */ +function reparentDisconnectables(newParent) { + if (this._$disconnectableChildren !== undefined) { + removeDisconnectableFromParent(this); + this._$parent = newParent; + addDisconnectableToParent(this); + } + else { + this._$parent = newParent; + } +} +/** + * Sets the connected state on any directives contained within the committed + * value of this part (i.e. within a TemplateInstance or iterable of + * ChildParts) and runs their `disconnected`/`reconnected`s, as well as within + * any directives stored on the ChildPart (when `valueOnly` is false). + * + * `isClearingValue` should be passed as `true` on a top-level part that is + * clearing itself, and not as a result of recursively disconnecting directives + * as part of a `clear` operation higher up the tree. This both ensures that any + * directive on this ChildPart that produced a value that caused the clear + * operation is not disconnected, and also serves as a performance optimization + * to avoid needless bookkeeping when a subtree is going away; when clearing a + * subtree, only the top-most part need to remove itself from the parent. + * + * `fromPartIndex` is passed only in the case of a partial `_clear` running as a + * result of truncating an iterable. + * + * Note, this method will be patched onto ChildPart instances and called from the + * core code when parts are cleared or the connection state is changed by the + * user. + */ +function notifyChildPartConnectedChanged(isConnected, isClearingValue = false, fromPartIndex = 0) { + const value = this._$committedValue; + const children = this._$disconnectableChildren; + if (children === undefined || children.size === 0) { + return; + } + if (isClearingValue) { + if (Array.isArray(value)) { + // Iterable case: Any ChildParts created by the iterable should be + // disconnected and removed from this ChildPart's disconnectable + // children (starting at `fromPartIndex` in the case of truncation) + for (let i = fromPartIndex; i < value.length; i++) { + notifyChildrenConnectedChanged(value[i], false); + removeDisconnectableFromParent(value[i]); + } + } + else if (value != null) { + // TemplateInstance case: If the value has disconnectable children (will + // only be in the case that it is a TemplateInstance), we disconnect it + // and remove it from this ChildPart's disconnectable children + notifyChildrenConnectedChanged(value, false); + removeDisconnectableFromParent(value); + } + } + else { + notifyChildrenConnectedChanged(this, isConnected); + } +} +/** + * Patches disconnection API onto ChildParts. + */ +const installDisconnectAPI = (obj) => { + var _a, _b; + var _c, _d; + if (obj.type == PartType.CHILD) { + (_a = (_c = obj)._$notifyConnectionChanged) !== null && _a !== void 0 ? _a : (_c._$notifyConnectionChanged = notifyChildPartConnectedChanged); + (_b = (_d = obj)._$reparentDisconnectables) !== null && _b !== void 0 ? _b : (_d._$reparentDisconnectables = reparentDisconnectables); + } +}; +/** + * An abstract `Directive` base class whose `disconnected` method will be + * called when the part containing the directive is cleared as a result of + * re-rendering, or when the user calls `part.setConnected(false)` on + * a part that was previously rendered containing the directive (as happens + * when e.g. a LitElement disconnects from the DOM). + * + * If `part.setConnected(true)` is subsequently called on a + * containing part, the directive's `reconnected` method will be called prior + * to its next `update`/`render` callbacks. When implementing `disconnected`, + * `reconnected` should also be implemented to be compatible with reconnection. + * + * Note that updates may occur while the directive is disconnected. As such, + * directives should generally check the `this.isConnected` flag during + * render/update to determine whether it is safe to subscribe to resources + * that may prevent garbage collection. + */ +class AsyncDirective extends Directive { + constructor() { + super(...arguments); + // @internal + this._$disconnectableChildren = undefined; + } + /** + * Initialize the part with internal fields + * @param part + * @param parent + * @param attributeIndex + */ + _$initialize(part, parent, attributeIndex) { + super._$initialize(part, parent, attributeIndex); + addDisconnectableToParent(this); + this.isConnected = part._$isConnected; + } + // This property needs to remain unminified. + /** + * Called from the core code when a directive is going away from a part (in + * which case `shouldRemoveFromParent` should be true), and from the + * `setChildrenConnected` helper function when recursively changing the + * connection state of a tree (in which case `shouldRemoveFromParent` should + * be false). + * + * @param isConnected + * @param isClearingDirective - True when the directive itself is being + * removed; false when the tree is being disconnected + * @internal + */ + ['_$notifyDirectiveConnectionChanged'](isConnected, isClearingDirective = true) { + var _a, _b; + if (isConnected !== this.isConnected) { + this.isConnected = isConnected; + if (isConnected) { + (_a = this.reconnected) === null || _a === void 0 ? void 0 : _a.call(this); + } + else { + (_b = this.disconnected) === null || _b === void 0 ? void 0 : _b.call(this); + } + } + if (isClearingDirective) { + notifyChildrenConnectedChanged(this, isConnected); + removeDisconnectableFromParent(this); + } + } + /** + * Sets the value of the directive's Part outside the normal `update`/`render` + * lifecycle of a directive. + * + * This method should not be called synchronously from a directive's `update` + * or `render`. + * + * @param directive The directive to update + * @param value The value to set + */ + setValue(value) { + if (isSingleExpression(this.__part)) { + this.__part._$setValue(value, this); + } + else { + const newValues = [...this.__part._$committedValue]; + newValues[this.__attributeIndex] = value; + this.__part._$setValue(newValues, this, 0); + } + } + /** + * User callbacks for implementing logic to release any resources/subscriptions + * that may have been retained by this directive. Since directives may also be + * re-connected, `reconnected` should also be implemented to restore the + * working state of the directive prior to the next render. + */ + disconnected() { } + reconnected() { } +} + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +// Note, this module is not included in package exports so that it's private to +// our first-party directives. If it ends up being useful, we can open it up and +// export it. +/** + * Helper to iterate an AsyncIterable in its own closure. + * @param iterable The iterable to iterate + * @param callback The callback to call for each value. If the callback returns + * `false`, the loop will be broken. + */ +const forAwaitOf = async (iterable, callback) => { + for await (const v of iterable) { + if ((await callback(v)) === false) { + return; + } + } +}; +/** + * Holds a reference to an instance that can be disconnected and reconnected, + * so that a closure over the ref (e.g. in a then function to a promise) does + * not strongly hold a ref to the instance. Approximates a WeakRef but must + * be manually connected & disconnected to the backing instance. + */ +class PseudoWeakRef { + constructor(ref) { + this._ref = ref; + } + /** + * Disassociates the ref with the backing instance. + */ + disconnect() { + this._ref = undefined; + } + /** + * Reassociates the ref with the backing instance. + */ + reconnect(ref) { + this._ref = ref; + } + /** + * Retrieves the backing instance (will be undefined when disconnected) + */ + deref() { + return this._ref; + } +} +/** + * A helper to pause and resume waiting on a condition in an async function + */ +class Pauser { + constructor() { + this._promise = undefined; + this._resolve = undefined; + } + /** + * When paused, returns a promise to be awaited; when unpaused, returns + * undefined. Note that in the microtask between the pauser being resumed + * an an await of this promise resolving, the pauser could be paused again, + * hence callers should check the promise in a loop when awaiting. + * @returns A promise to be awaited when paused or undefined + */ + get() { + return this._promise; + } + /** + * Creates a promise to be awaited + */ + pause() { + var _a; + (_a = this._promise) !== null && _a !== void 0 ? _a : (this._promise = new Promise((resolve) => (this._resolve = resolve))); + } + /** + * Resolves the promise which may be awaited + */ + resume() { + var _a; + (_a = this._resolve) === null || _a === void 0 ? void 0 : _a.call(this); + this._promise = this._resolve = undefined; + } +} + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class AsyncReplaceDirective extends AsyncDirective { + constructor() { + super(...arguments); + this.__weakThis = new PseudoWeakRef(this); + this.__pauser = new Pauser(); + } + // @ts-expect-error value not used, but we want a nice parameter for docs + // eslint-disable-next-line @typescript-eslint/no-unused-vars + render(value, _mapper) { + return noChange; + } + update(_part, [value, mapper]) { + // If our initial render occurs while disconnected, ensure that the pauser + // and weakThis are in the disconnected state + if (!this.isConnected) { + this.disconnected(); + } + // If we've already set up this particular iterable, we don't need + // to do anything. + if (value === this.__value) { + return; + } + this.__value = value; + let i = 0; + const { __weakThis: weakThis, __pauser: pauser } = this; + // Note, the callback avoids closing over `this` so that the directive + // can be gc'ed before the promise resolves; instead `this` is retrieved + // from `weakThis`, which can break the hard reference in the closure when + // the directive disconnects + forAwaitOf(value, async (v) => { + // The while loop here handles the case that the connection state + // thrashes, causing the pauser to resume and then get re-paused + while (pauser.get()) { + await pauser.get(); + } + // If the callback gets here and there is no `this`, it means that the + // directive has been disconnected and garbage collected and we don't + // need to do anything else + const _this = weakThis.deref(); + if (_this !== undefined) { + // Check to make sure that value is the still the current value of + // the part, and if not bail because a new value owns this part + if (_this.__value !== value) { + return false; + } + // As a convenience, because functional-programming-style + // transforms of iterables and async iterables requires a library, + // we accept a mapper function. This is especially convenient for + // rendering a template for each item. + if (mapper !== undefined) { + v = mapper(v, i); + } + _this.commitValue(v, i); + i++; + } + return true; + }); + return noChange; + } + // Override point for AsyncAppend to append rather than replace + commitValue(value, _index) { + this.setValue(value); + } + disconnected() { + this.__weakThis.disconnect(); + this.__pauser.pause(); + } + reconnected() { + this.__weakThis.reconnect(this); + this.__pauser.resume(); + } +} +/** + * A directive that renders the items of an async iterable[1], replacing + * previous values with new values, so that only one value is ever rendered + * at a time. This directive may be used in any expression type. + * + * Async iterables are objects with a `[Symbol.asyncIterator]` method, which + * returns an iterator who's `next()` method returns a Promise. When a new + * value is available, the Promise resolves and the value is rendered to the + * Part controlled by the directive. If another value other than this + * directive has been set on the Part, the iterable will no longer be listened + * to and new values won't be written to the Part. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of + * + * @param value An async iterable + * @param mapper An optional function that maps from (value, index) to another + * value. Useful for generating templates for each item in the iterable. + */ +const asyncReplace = directive(AsyncReplaceDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class AsyncAppendDirective extends AsyncReplaceDirective { + // Override AsyncReplace to narrow the allowed part type to ChildPart only + constructor(partInfo) { + super(partInfo); + if (partInfo.type !== PartType.CHILD) { + throw new Error('asyncAppend can only be used in child expressions'); + } + } + // Override AsyncReplace to save the part since we need to append into it + update(part, params) { + this.__childPart = part; + return super.update(part, params); + } + // Override AsyncReplace to append rather than replace + commitValue(value, index) { + // When we get the first value, clear the part. This lets the + // previous value display until we can replace it. + if (index === 0) { + clearPart(this.__childPart); + } + // Create and insert a new part and set its value to the next value + const newPart = insertPart(this.__childPart); + setChildPartValue(newPart, value); + } +} +/** + * A directive that renders the items of an async iterable[1], appending new + * values after previous values, similar to the built-in support for iterables. + * This directive is usable only in child expressions. + * + * Async iterables are objects with a [Symbol.asyncIterator] method, which + * returns an iterator who's `next()` method returns a Promise. When a new + * value is available, the Promise resolves and the value is appended to the + * Part controlled by the directive. If another value other than this + * directive has been set on the Part, the iterable will no longer be listened + * to and new values won't be written to the Part. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of + * + * @param value An async iterable + * @param mapper An optional function that maps from (value, index) to another + * value. Useful for generating templates for each item in the iterable. + */ +const asyncAppend = directive(AsyncAppendDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class CacheDirective extends Directive { + constructor(partInfo) { + super(partInfo); + this._templateCache = new WeakMap(); + } + render(v) { + // Return an array of the value to induce lit-html to create a ChildPart + // for the value that we can move into the cache. + return [v]; + } + update(containerPart, [v]) { + // If the previous value is a TemplateResult and the new value is not, + // or is a different Template as the previous value, move the child part + // into the cache. + if (isTemplateResult(this._value) && + (!isTemplateResult(v) || this._value.strings !== v.strings)) { + // This is always an array because we return [v] in render() + const partValue = getCommittedValue(containerPart); + const childPart = partValue.pop(); + let cachedContainerPart = this._templateCache.get(this._value.strings); + if (cachedContainerPart === undefined) { + const fragment = document.createDocumentFragment(); + cachedContainerPart = render(nothing, fragment); + cachedContainerPart.setConnected(false); + this._templateCache.set(this._value.strings, cachedContainerPart); + } + // Move into cache + setCommittedValue(cachedContainerPart, [childPart]); + insertPart(cachedContainerPart, undefined, childPart); + } + // If the new value is a TemplateResult and the previous value is not, + // or is a different Template as the previous value, restore the child + // part from the cache. + if (isTemplateResult(v)) { + if (!isTemplateResult(this._value) || this._value.strings !== v.strings) { + const cachedContainerPart = this._templateCache.get(v.strings); + if (cachedContainerPart !== undefined) { + // Move the cached part back into the container part value + const partValue = getCommittedValue(cachedContainerPart); + const cachedPart = partValue.pop(); + // Move cached part back into DOM + clearPart(containerPart); + insertPart(containerPart, undefined, cachedPart); + setCommittedValue(containerPart, [cachedPart]); + } + } + this._value = v; + } + else { + this._value = undefined; + } + return this.render(v); + } +} +/** + * Enables fast switching between multiple templates by caching the DOM nodes + * and TemplateInstances produced by the templates. + * + * Example: + * + * ```js + * let checked = false; + * + * html` + * ${cache(checked ? html`input is checked` : html`input is not checked`)} + * ` + * ``` + */ +const cache = directive(CacheDirective); + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Chooses and evaluates a template function from a list based on matching + * the given `value` to a case. + * + * Cases are structured as `[caseValue, func]`. `value` is matched to + * `caseValue` by strict equality. The first match is selected. Case values + * can be of any type including primitives, objects, and symbols. + * + * This is similar to a switch statement, but as an expression and without + * fallthrough. + * + * @example + * + * ```ts + * render() { + * return html` + * ${choose(this.section, [ + * ['home', () => html`<h1>Home</h1>`], + * ['about', () => html`<h1>About</h1>`] + * ], + * () => html`<h1>Error</h1>`)} + * `; + * } + * ``` + */ +const choose = (value, cases, defaultCase) => { + for (const c of cases) { + const caseValue = c[0]; + if (caseValue === value) { + const fn = c[1]; + return fn(); + } + } + return defaultCase === null || defaultCase === void 0 ? void 0 : defaultCase(); +}; + +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class ClassMapDirective extends Directive { + constructor(partInfo) { + var _a; + super(partInfo); + if (partInfo.type !== PartType.ATTRIBUTE || + partInfo.name !== 'class' || + ((_a = partInfo.strings) === null || _a === void 0 ? void 0 : _a.length) > 2) { + throw new Error('`classMap()` can only be used in the `class` attribute ' + + 'and must be the only part in the attribute.'); + } + } + render(classInfo) { + // Add spaces to ensure separation from static classes + return (' ' + + Object.keys(classInfo) + .filter((key) => classInfo[key]) + .join(' ') + + ' '); + } + update(part, [classInfo]) { + var _a, _b; + // Remember dynamic classes on the first render + if (this._previousClasses === undefined) { + this._previousClasses = new Set(); + if (part.strings !== undefined) { + this._staticClasses = new Set(part.strings + .join(' ') + .split(/\s/) + .filter((s) => s !== '')); + } + for (const name in classInfo) { + if (classInfo[name] && !((_a = this._staticClasses) === null || _a === void 0 ? void 0 : _a.has(name))) { + this._previousClasses.add(name); + } + } + return this.render(classInfo); + } + const classList = part.element.classList; + // Remove old classes that no longer apply + // We use forEach() instead of for-of so that we don't require down-level + // iteration. + this._previousClasses.forEach((name) => { + if (!(name in classInfo)) { + classList.remove(name); + this._previousClasses.delete(name); + } + }); + // Add or remove classes based on their classMap value + for (const name in classInfo) { + // We explicitly want a loose truthy check of `value` because it seems + // more convenient that '' and 0 are skipped. + const value = !!classInfo[name]; + if (value !== this._previousClasses.has(name) && + !((_b = this._staticClasses) === null || _b === void 0 ? void 0 : _b.has(name))) { + if (value) { + classList.add(name); + this._previousClasses.add(name); + } + else { + classList.remove(name); + this._previousClasses.delete(name); + } + } + } + return noChange; + } +} +/** + * A directive that applies dynamic CSS classes. + * + * This must be used in the `class` attribute and must be the only part used in + * the attribute. It takes each property in the `classInfo` argument and adds + * the property name to the element's `classList` if the property value is + * truthy; if the property value is falsey, the property name is removed from + * the element's `class`. + * + * For example `{foo: bar}` applies the class `foo` if the value of `bar` is + * truthy. + * + * @param classInfo + */ +const classMap = directive(ClassMapDirective); + +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +// A sentinal that indicates guard() hasn't rendered anything yet +const initialValue = {}; +class GuardDirective extends Directive { + constructor() { + super(...arguments); + this._previousValue = initialValue; + } + render(_value, f) { + return f(); + } + update(_part, [value, f]) { + if (Array.isArray(value)) { + // Dirty-check arrays by item + if (Array.isArray(this._previousValue) && + this._previousValue.length === value.length && + value.every((v, i) => v === this._previousValue[i])) { + return noChange; + } + } + else if (this._previousValue === value) { + // Dirty-check non-arrays by identity + return noChange; + } + // Copy the value if it's an array so that if it's mutated we don't forget + // what the previous values were. + this._previousValue = Array.isArray(value) ? Array.from(value) : value; + const r = this.render(value, f); + return r; + } +} +/** + * Prevents re-render of a template function until a single value or an array of + * values changes. + * + * Values are checked against previous values with strict equality (`===`), and + * so the check won't detect nested property changes inside objects or arrays. + * Arrays values have each item checked against the previous value at the same + * index with strict equality. Nested arrays are also checked only by strict + * equality. + * + * Example: + * + * ```js + * html` + * <div> + * ${guard([user.id, company.id], () => html`...`)} + * </div> + * ` + * ``` + * + * In this case, the template only rerenders if either `user.id` or `company.id` + * changes. + * + * guard() is useful with immutable data patterns, by preventing expensive work + * until data updates. + * + * Example: + * + * ```js + * html` + * <div> + * ${guard([immutableItems], () => immutableItems.map(i => html`${i}`))} + * </div> + * ` + * ``` + * + * In this case, items are mapped over only when the array reference changes. + * + * @param value the value to check before re-rendering + * @param f the template function + */ +const guard = directive(GuardDirective); + +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * For AttributeParts, sets the attribute if the value is defined and removes + * the attribute if the value is undefined. + * + * For other part types, this directive is a no-op. + */ +const ifDefined = (value) => value !== null && value !== void 0 ? value : nothing; + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function* join(items, joiner) { + const isFunction = typeof joiner === 'function'; + if (items !== undefined) { + let i = -1; + for (const value of items) { + if (i > -1) { + yield isFunction ? joiner(i) : joiner; + } + i++; + yield value; + } + } +} + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class Keyed extends Directive { + constructor() { + super(...arguments); + this.key = nothing; + } + render(k, v) { + this.key = k; + return v; + } + update(part, [k, v]) { + if (k !== this.key) { + // Clear the part before returning a value. The one-arg form of + // setCommittedValue sets the value to a sentinel which forces a + // commit the next render. + setCommittedValue(part); + this.key = k; + } + return v; + } +} +/** + * Associates a renderable value with a unique key. When the key changes, the + * previous DOM is removed and disposed before rendering the next value, even + * if the value - such as a template - is the same. + * + * This is useful for forcing re-renders of stateful components, or working + * with code that expects new data to generate new HTML elements, such as some + * animation techniques. + */ +const keyed = directive(Keyed); + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class LiveDirective extends Directive { + constructor(partInfo) { + super(partInfo); + if (!(partInfo.type === PartType.PROPERTY || + partInfo.type === PartType.ATTRIBUTE || + partInfo.type === PartType.BOOLEAN_ATTRIBUTE)) { + throw new Error('The `live` directive is not allowed on child or event bindings'); + } + if (!isSingleExpression(partInfo)) { + throw new Error('`live` bindings can only contain a single expression'); + } + } + render(value) { + return value; + } + update(part, [value]) { + if (value === noChange || value === nothing) { + return value; + } + const element = part.element; + const name = part.name; + if (part.type === PartType.PROPERTY) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (value === element[name]) { + return noChange; + } + } + else if (part.type === PartType.BOOLEAN_ATTRIBUTE) { + if (!!value === element.hasAttribute(name)) { + return noChange; + } + } + else if (part.type === PartType.ATTRIBUTE) { + if (element.getAttribute(name) === String(value)) { + return noChange; + } + } + // Resets the part's value, causing its dirty-check to fail so that it + // always sets the value. + setCommittedValue(part); + return value; + } +} +/** + * Checks binding values against live DOM values, instead of previously bound + * values, when determining whether to update the value. + * + * This is useful for cases where the DOM value may change from outside of + * lit-html, such as with a binding to an `<input>` element's `value` property, + * a content editable elements text, or to a custom element that changes it's + * own properties or attributes. + * + * In these cases if the DOM value changes, but the value set through lit-html + * bindings hasn't, lit-html won't know to update the DOM value and will leave + * it alone. If this is not what you want--if you want to overwrite the DOM + * value with the bound value no matter what--use the `live()` directive: + * + * ```js + * html`<input .value=${live(x)}>` + * ``` + * + * `live()` performs a strict equality check against the live DOM value, and if + * the new value is equal to the live value, does nothing. This means that + * `live()` should not be used when the binding will cause a type conversion. If + * you use `live()` with an attribute binding, make sure that only strings are + * passed in, or the binding will update every render. + */ +const live = directive(LiveDirective); + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Returns an iterable containing the result of calling `f(value)` on each + * value in `items`. + * + * @example + * + * ```ts + * render() { + * return html` + * <ul> + * ${map(items, (i) => html`<li>${i}</li>`)} + * </ul> + * `; + * } + * ``` + */ +function* map(items, f) { + if (items !== undefined) { + let i = 0; + for (const value of items) { + yield f(value, i++); + } + } +} + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function* range(startOrEnd, end, step = 1) { + const start = end === undefined ? 0 : startOrEnd; + end !== null && end !== void 0 ? end : (end = startOrEnd); + for (let i = start; step > 0 ? i < end : end < i; i += step) { + yield i; + } +} + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Creates a new Ref object, which is container for a reference to an element. + */ +const createRef = () => new Ref(); +/** + * An object that holds a ref value. + */ +class Ref { +} +// When callbacks are used for refs, this map tracks the last value the callback +// was called with, for ensuring a directive doesn't clear the ref if the ref +// has already been rendered to a new spot. It is double-keyed on both the +// context (`options.host`) and the callback, since we auto-bind class methods +// to `options.host`. +const lastElementForContextAndCallback = new WeakMap(); +class RefDirective extends AsyncDirective { + render(_ref) { + return nothing; + } + update(part, [ref]) { + var _a; + const refChanged = ref !== this._ref; + if (refChanged && this._ref !== undefined) { + // The ref passed to the directive has changed; + // unset the previous ref's value + this._updateRefValue(undefined); + } + if (refChanged || this._lastElementForRef !== this._element) { + // We either got a new ref or this is the first render; + // store the ref/element & update the ref value + this._ref = ref; + this._context = (_a = part.options) === null || _a === void 0 ? void 0 : _a.host; + this._updateRefValue((this._element = part.element)); + } + return nothing; + } + _updateRefValue(element) { + var _a; + if (typeof this._ref === 'function') { + // If the current ref was called with a previous value, call with + // `undefined`; We do this to ensure callbacks are called in a consistent + // way regardless of whether a ref might be moving up in the tree (in + // which case it would otherwise be called with the new value before the + // previous one unsets it) and down in the tree (where it would be unset + // before being set). Note that element lookup is keyed by + // both the context and the callback, since we allow passing unbound + // functions that are called on options.host, and we want to treat + // these as unique "instances" of a function. + const context = (_a = this._context) !== null && _a !== void 0 ? _a : globalThis; + let lastElementForCallback = lastElementForContextAndCallback.get(context); + if (lastElementForCallback === undefined) { + lastElementForCallback = new WeakMap(); + lastElementForContextAndCallback.set(context, lastElementForCallback); + } + if (lastElementForCallback.get(this._ref) !== undefined) { + this._ref.call(this._context, undefined); + } + lastElementForCallback.set(this._ref, element); + // Call the ref with the new element value + if (element !== undefined) { + this._ref.call(this._context, element); + } + } + else { + this._ref.value = element; + } + } + get _lastElementForRef() { + var _a, _b, _c; + return typeof this._ref === 'function' + ? (_b = lastElementForContextAndCallback + .get((_a = this._context) !== null && _a !== void 0 ? _a : globalThis)) === null || _b === void 0 ? void 0 : _b.get(this._ref) + : (_c = this._ref) === null || _c === void 0 ? void 0 : _c.value; + } + disconnected() { + // Only clear the box if our element is still the one in it (i.e. another + // directive instance hasn't rendered its element to it before us); that + // only happens in the event of the directive being cleared (not via manual + // disconnection) + if (this._lastElementForRef === this._element) { + this._updateRefValue(undefined); + } + } + reconnected() { + // If we were manually disconnected, we can safely put our element back in + // the box, since no rendering could have occurred to change its state + this._updateRefValue(this._element); + } +} +/** + * Sets the value of a Ref object or calls a ref callback with the element it's + * bound to. + * + * A Ref object acts as a container for a reference to an element. A ref + * callback is a function that takes an element as its only argument. + * + * The ref directive sets the value of the Ref object or calls the ref callback + * during rendering, if the referenced element changed. + * + * Note: If a ref callback is rendered to a different element position or is + * removed in a subsequent render, it will first be called with `undefined`, + * followed by another call with the new element it was rendered to (if any). + * + * ```js + * // Using Ref object + * const inputRef = createRef(); + * render(html`<input ${ref(inputRef)}>`, container); + * inputRef.value.focus(); + * + * // Using callback + * const callback = (inputElement) => inputElement.focus(); + * render(html`<input ${ref(callback)}>`, container); + * ``` + */ +const ref = directive(RefDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +// Helper for generating a map of array item to its index over a subset +// of an array (used to lazily generate `newKeyToIndexMap` and +// `oldKeyToIndexMap`) +const generateMap = (list, start, end) => { + const map = new Map(); + for (let i = start; i <= end; i++) { + map.set(list[i], i); + } + return map; +}; +class RepeatDirective extends Directive { + constructor(partInfo) { + super(partInfo); + if (partInfo.type !== PartType.CHILD) { + throw new Error('repeat() can only be used in text expressions'); + } + } + _getValuesAndKeys(items, keyFnOrTemplate, template) { + let keyFn; + if (template === undefined) { + template = keyFnOrTemplate; + } + else if (keyFnOrTemplate !== undefined) { + keyFn = keyFnOrTemplate; + } + const keys = []; + const values = []; + let index = 0; + for (const item of items) { + keys[index] = keyFn ? keyFn(item, index) : index; + values[index] = template(item, index); + index++; + } + return { + values, + keys, + }; + } + render(items, keyFnOrTemplate, template) { + return this._getValuesAndKeys(items, keyFnOrTemplate, template).values; + } + update(containerPart, [items, keyFnOrTemplate, template]) { + var _a; + // Old part & key lists are retrieved from the last update (which may + // be primed by hydration) + const oldParts = getCommittedValue(containerPart); + const { values: newValues, keys: newKeys } = this._getValuesAndKeys(items, keyFnOrTemplate, template); + // We check that oldParts, the committed value, is an Array as an + // indicator that the previous value came from a repeat() call. If + // oldParts is not an Array then this is the first render and we return + // an array for lit-html's array handling to render, and remember the + // keys. + if (!Array.isArray(oldParts)) { + this._itemKeys = newKeys; + return newValues; + } + // In SSR hydration it's possible for oldParts to be an arrray but for us + // to not have item keys because the update() hasn't run yet. We set the + // keys to an empty array. This will cause all oldKey/newKey comparisons + // to fail and execution to fall to the last nested brach below which + // reuses the oldPart. + const oldKeys = ((_a = this._itemKeys) !== null && _a !== void 0 ? _a : (this._itemKeys = [])); + // New part list will be built up as we go (either reused from + // old parts or created for new keys in this update). This is + // saved in the above cache at the end of the update. + const newParts = []; + // Maps from key to index for current and previous update; these + // are generated lazily only when needed as a performance + // optimization, since they are only required for multiple + // non-contiguous changes in the list, which are less common. + let newKeyToIndexMap; + let oldKeyToIndexMap; + // Head and tail pointers to old parts and new values + let oldHead = 0; + let oldTail = oldParts.length - 1; + let newHead = 0; + let newTail = newValues.length - 1; + // Overview of O(n) reconciliation algorithm (general approach + // based on ideas found in ivi, vue, snabbdom, etc.): + // + // * We start with the list of old parts and new values (and + // arrays of their respective keys), head/tail pointers into + // each, and we build up the new list of parts by updating + // (and when needed, moving) old parts or creating new ones. + // The initial scenario might look like this (for brevity of + // the diagrams, the numbers in the array reflect keys + // associated with the old parts or new values, although keys + // and parts/values are actually stored in parallel arrays + // indexed using the same head/tail pointers): + // + // oldHead v v oldTail + // oldKeys: [0, 1, 2, 3, 4, 5, 6] + // newParts: [ , , , , , , ] + // newKeys: [0, 2, 1, 4, 3, 7, 6] <- reflects the user's new + // item order + // newHead ^ ^ newTail + // + // * Iterate old & new lists from both sides, updating, + // swapping, or removing parts at the head/tail locations + // until neither head nor tail can move. + // + // * Example below: keys at head pointers match, so update old + // part 0 in-place (no need to move it) and record part 0 in + // the `newParts` list. The last thing we do is advance the + // `oldHead` and `newHead` pointers (will be reflected in the + // next diagram). + // + // oldHead v v oldTail + // oldKeys: [0, 1, 2, 3, 4, 5, 6] + // newParts: [0, , , , , , ] <- heads matched: update 0 + // newKeys: [0, 2, 1, 4, 3, 7, 6] and advance both oldHead + // & newHead + // newHead ^ ^ newTail + // + // * Example below: head pointers don't match, but tail + // pointers do, so update part 6 in place (no need to move + // it), and record part 6 in the `newParts` list. Last, + // advance the `oldTail` and `oldHead` pointers. + // + // oldHead v v oldTail + // oldKeys: [0, 1, 2, 3, 4, 5, 6] + // newParts: [0, , , , , , 6] <- tails matched: update 6 + // newKeys: [0, 2, 1, 4, 3, 7, 6] and advance both oldTail + // & newTail + // newHead ^ ^ newTail + // + // * If neither head nor tail match; next check if one of the + // old head/tail items was removed. We first need to generate + // the reverse map of new keys to index (`newKeyToIndexMap`), + // which is done once lazily as a performance optimization, + // since we only hit this case if multiple non-contiguous + // changes were made. Note that for contiguous removal + // anywhere in the list, the head and tails would advance + // from either end and pass each other before we get to this + // case and removals would be handled in the final while loop + // without needing to generate the map. + // + // * Example below: The key at `oldTail` was removed (no longer + // in the `newKeyToIndexMap`), so remove that part from the + // DOM and advance just the `oldTail` pointer. + // + // oldHead v v oldTail + // oldKeys: [0, 1, 2, 3, 4, 5, 6] + // newParts: [0, , , , , , 6] <- 5 not in new map: remove + // newKeys: [0, 2, 1, 4, 3, 7, 6] 5 and advance oldTail + // newHead ^ ^ newTail + // + // * Once head and tail cannot move, any mismatches are due to + // either new or moved items; if a new key is in the previous + // "old key to old index" map, move the old part to the new + // location, otherwise create and insert a new part. Note + // that when moving an old part we null its position in the + // oldParts array if it lies between the head and tail so we + // know to skip it when the pointers get there. + // + // * Example below: neither head nor tail match, and neither + // were removed; so find the `newHead` key in the + // `oldKeyToIndexMap`, and move that old part's DOM into the + // next head position (before `oldParts[oldHead]`). Last, + // null the part in the `oldPart` array since it was + // somewhere in the remaining oldParts still to be scanned + // (between the head and tail pointers) so that we know to + // skip that old part on future iterations. + // + // oldHead v v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, , , , , 6] <- stuck: update & move 2 + // newKeys: [0, 2, 1, 4, 3, 7, 6] into place and advance + // newHead + // newHead ^ ^ newTail + // + // * Note that for moves/insertions like the one above, a part + // inserted at the head pointer is inserted before the + // current `oldParts[oldHead]`, and a part inserted at the + // tail pointer is inserted before `newParts[newTail+1]`. The + // seeming asymmetry lies in the fact that new parts are + // moved into place outside in, so to the right of the head + // pointer are old parts, and to the right of the tail + // pointer are new parts. + // + // * We always restart back from the top of the algorithm, + // allowing matching and simple updates in place to + // continue... + // + // * Example below: the head pointers once again match, so + // simply update part 1 and record it in the `newParts` + // array. Last, advance both head pointers. + // + // oldHead v v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, 1, , , , 6] <- heads matched: update 1 + // newKeys: [0, 2, 1, 4, 3, 7, 6] and advance both oldHead + // & newHead + // newHead ^ ^ newTail + // + // * As mentioned above, items that were moved as a result of + // being stuck (the final else clause in the code below) are + // marked with null, so we always advance old pointers over + // these so we're comparing the next actual old value on + // either end. + // + // * Example below: `oldHead` is null (already placed in + // newParts), so advance `oldHead`. + // + // oldHead v v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] <- old head already used: + // newParts: [0, 2, 1, , , , 6] advance oldHead + // newKeys: [0, 2, 1, 4, 3, 7, 6] + // newHead ^ ^ newTail + // + // * Note it's not critical to mark old parts as null when they + // are moved from head to tail or tail to head, since they + // will be outside the pointer range and never visited again. + // + // * Example below: Here the old tail key matches the new head + // key, so the part at the `oldTail` position and move its + // DOM to the new head position (before `oldParts[oldHead]`). + // Last, advance `oldTail` and `newHead` pointers. + // + // oldHead v v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, 1, 4, , , 6] <- old tail matches new + // newKeys: [0, 2, 1, 4, 3, 7, 6] head: update & move 4, + // advance oldTail & newHead + // newHead ^ ^ newTail + // + // * Example below: Old and new head keys match, so update the + // old head part in place, and advance the `oldHead` and + // `newHead` pointers. + // + // oldHead v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, 1, 4, 3, ,6] <- heads match: update 3 + // newKeys: [0, 2, 1, 4, 3, 7, 6] and advance oldHead & + // newHead + // newHead ^ ^ newTail + // + // * Once the new or old pointers move past each other then all + // we have left is additions (if old list exhausted) or + // removals (if new list exhausted). Those are handled in the + // final while loops at the end. + // + // * Example below: `oldHead` exceeded `oldTail`, so we're done + // with the main loop. Create the remaining part and insert + // it at the new head position, and the update is complete. + // + // (oldHead > oldTail) + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, 1, 4, 3, 7 ,6] <- create and insert 7 + // newKeys: [0, 2, 1, 4, 3, 7, 6] + // newHead ^ newTail + // + // * Note that the order of the if/else clauses is not + // important to the algorithm, as long as the null checks + // come first (to ensure we're always working on valid old + // parts) and that the final else clause comes last (since + // that's where the expensive moves occur). The order of + // remaining clauses is is just a simple guess at which cases + // will be most common. + // + // * Note, we could calculate the longest + // increasing subsequence (LIS) of old items in new position, + // and only move those not in the LIS set. However that costs + // O(nlogn) time and adds a bit more code, and only helps + // make rare types of mutations require fewer moves. The + // above handles removes, adds, reversal, swaps, and single + // moves of contiguous items in linear time, in the minimum + // number of moves. As the number of multiple moves where LIS + // might help approaches a random shuffle, the LIS + // optimization becomes less helpful, so it seems not worth + // the code at this point. Could reconsider if a compelling + // case arises. + while (oldHead <= oldTail && newHead <= newTail) { + if (oldParts[oldHead] === null) { + // `null` means old part at head has already been used + // below; skip + oldHead++; + } + else if (oldParts[oldTail] === null) { + // `null` means old part at tail has already been used + // below; skip + oldTail--; + } + else if (oldKeys[oldHead] === newKeys[newHead]) { + // Old head matches new head; update in place + newParts[newHead] = setChildPartValue(oldParts[oldHead], newValues[newHead]); + oldHead++; + newHead++; + } + else if (oldKeys[oldTail] === newKeys[newTail]) { + // Old tail matches new tail; update in place + newParts[newTail] = setChildPartValue(oldParts[oldTail], newValues[newTail]); + oldTail--; + newTail--; + } + else if (oldKeys[oldHead] === newKeys[newTail]) { + // Old head matches new tail; update and move to new tail + newParts[newTail] = setChildPartValue(oldParts[oldHead], newValues[newTail]); + insertPart(containerPart, newParts[newTail + 1], oldParts[oldHead]); + oldHead++; + newTail--; + } + else if (oldKeys[oldTail] === newKeys[newHead]) { + // Old tail matches new head; update and move to new head + newParts[newHead] = setChildPartValue(oldParts[oldTail], newValues[newHead]); + insertPart(containerPart, oldParts[oldHead], oldParts[oldTail]); + oldTail--; + newHead++; + } + else { + if (newKeyToIndexMap === undefined) { + // Lazily generate key-to-index maps, used for removals & + // moves below + newKeyToIndexMap = generateMap(newKeys, newHead, newTail); + oldKeyToIndexMap = generateMap(oldKeys, oldHead, oldTail); + } + if (!newKeyToIndexMap.has(oldKeys[oldHead])) { + // Old head is no longer in new list; remove + removePart(oldParts[oldHead]); + oldHead++; + } + else if (!newKeyToIndexMap.has(oldKeys[oldTail])) { + // Old tail is no longer in new list; remove + removePart(oldParts[oldTail]); + oldTail--; + } + else { + // Any mismatches at this point are due to additions or + // moves; see if we have an old part we can reuse and move + // into place + const oldIndex = oldKeyToIndexMap.get(newKeys[newHead]); + const oldPart = oldIndex !== undefined ? oldParts[oldIndex] : null; + if (oldPart === null) { + // No old part for this value; create a new one and + // insert it + const newPart = insertPart(containerPart, oldParts[oldHead]); + setChildPartValue(newPart, newValues[newHead]); + newParts[newHead] = newPart; + } + else { + // Reuse old part + newParts[newHead] = setChildPartValue(oldPart, newValues[newHead]); + insertPart(containerPart, oldParts[oldHead], oldPart); + // This marks the old part as having been used, so that + // it will be skipped in the first two checks above + oldParts[oldIndex] = null; + } + newHead++; + } + } + } + // Add parts for any remaining new values + while (newHead <= newTail) { + // For all remaining additions, we insert before last new + // tail, since old pointers are no longer valid + const newPart = insertPart(containerPart, newParts[newTail + 1]); + setChildPartValue(newPart, newValues[newHead]); + newParts[newHead++] = newPart; + } + // Remove any remaining unused old parts + while (oldHead <= oldTail) { + const oldPart = oldParts[oldHead++]; + if (oldPart !== null) { + removePart(oldPart); + } + } + // Save order of new parts for next round + this._itemKeys = newKeys; + // Directly set part value, bypassing it's dirty-checking + setCommittedValue(containerPart, newParts); + return noChange; + } +} +/** + * A directive that repeats a series of values (usually `TemplateResults`) + * generated from an iterable, and updates those items efficiently when the + * iterable changes based on user-provided `keys` associated with each item. + * + * Note that if a `keyFn` is provided, strict key-to-DOM mapping is maintained, + * meaning previous DOM for a given key is moved into the new position if + * needed, and DOM will never be reused with values for different keys (new DOM + * will always be created for new keys). This is generally the most efficient + * way to use `repeat` since it performs minimum unnecessary work for insertions + * and removals. + * + * The `keyFn` takes two parameters, the item and its index, and returns a unique key value. + * + * ```js + * html` + * <ol> + * ${repeat(this.items, (item) => item.id, (item, index) => { + * return html`<li>${index}: ${item.name}</li>`; + * })} + * </ol> + * ` + * ``` + * + * **Important**: If providing a `keyFn`, keys *must* be unique for all items in a + * given call to `repeat`. The behavior when two or more items have the same key + * is undefined. + * + * If no `keyFn` is provided, this directive will perform similar to mapping + * items to values, and DOM will be reused against potentially different items. + */ +const repeat = directive(RepeatDirective); + +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class StyleMapDirective extends Directive { + constructor(partInfo) { + var _a; + super(partInfo); + if (partInfo.type !== PartType.ATTRIBUTE || + partInfo.name !== 'style' || + ((_a = partInfo.strings) === null || _a === void 0 ? void 0 : _a.length) > 2) { + throw new Error('The `styleMap` directive must be used in the `style` attribute ' + + 'and must be the only part in the attribute.'); + } + } + render(styleInfo) { + return Object.keys(styleInfo).reduce((style, prop) => { + return style + prop.slice(0, 0); + }, ''); + } + update(part, [styleInfo]) { + const { style } = part.element; + if (this._previousStyleProperties === undefined) { + this._previousStyleProperties = new Set(); + } + // Remove old properties that no longer exist in styleInfo + // We use forEach() instead of for-of so that re don't require down-level + // iteration. + this._previousStyleProperties.forEach((name) => { + // If the name isn't in styleInfo or it's null/undefined + if (styleInfo[name] == null) { + this._previousStyleProperties.delete(name); + if (name.includes('-')) { + style.removeProperty(name); + } + else { + // Note reset using empty string (vs null) as IE11 does not always + // reset via null (https://developer.mozilla.org/en-US/docs/Web/API/ElementCSSInlineStyle/style#setting_styles) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + style[name] = ''; + } + } + }); + // Add or update properties + for (const name in styleInfo) { + const value = styleInfo[name]; + if (value != null) { + this._previousStyleProperties.add(name); + if (name.includes('-')) { + style.setProperty(name, value); + } + else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + style[name] = value; + } + } + } + return noChange; + } +} +/** + * A directive that applies CSS properties to an element. + * + * `styleMap` can only be used in the `style` attribute and must be the only + * expression in the attribute. It takes the property names in the + * {@link StyleInfo styleInfo} object and adds the property values as CSS + * properties. Property names with dashes (`-`) are assumed to be valid CSS + * property names and set on the element's style object using `setProperty()`. + * Names without dashes are assumed to be camelCased JavaScript property names + * and set on the element's style object using property assignment, allowing the + * style object to translate JavaScript-style names to CSS property names. + * + * For example `styleMap({backgroundColor: 'red', 'border-top': '5px', '--size': + * '0'})` sets the `background-color`, `border-top` and `--size` properties. + * + * @param styleInfo + * @see {@link https://lit.dev/docs/templates/directives/#stylemap styleMap code samples on Lit.dev} + */ +const styleMap = directive(StyleMapDirective); + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class TemplateContentDirective extends Directive { + constructor(partInfo) { + super(partInfo); + if (partInfo.type !== PartType.CHILD) { + throw new Error('templateContent can only be used in child bindings'); + } + } + render(template) { + if (this._previousTemplate === template) { + return noChange; + } + this._previousTemplate = template; + return document.importNode(template.content, true); + } +} +/** + * Renders the content of a template element as HTML. + * + * Note, the template should be developer controlled and not user controlled. + * Rendering a user-controlled template with this directive + * could lead to cross-site-scripting vulnerabilities. + */ +const templateContent = directive(TemplateContentDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const HTML_RESULT = 1; +class UnsafeHTMLDirective extends Directive { + constructor(partInfo) { + super(partInfo); + this._value = nothing; + if (partInfo.type !== PartType.CHILD) { + throw new Error(`${this.constructor.directiveName}() can only be used in child bindings`); + } + } + render(value) { + if (value === nothing || value == null) { + this._templateResult = undefined; + return (this._value = value); + } + if (value === noChange) { + return value; + } + if (typeof value != 'string') { + throw new Error(`${this.constructor.directiveName}() called with a non-string value`); + } + if (value === this._value) { + return this._templateResult; + } + this._value = value; + const strings = [value]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + strings.raw = strings; + // WARNING: impersonating a TemplateResult like this is extremely + // dangerous. Third-party directives should not do this. + return (this._templateResult = { + // Cast to a known set of integers that satisfy ResultType so that we + // don't have to export ResultType and possibly encourage this pattern. + // This property needs to remain unminified. + ['_$litType$']: this.constructor + .resultType, + strings, + values: [], + }); + } +} +UnsafeHTMLDirective.directiveName = 'unsafeHTML'; +UnsafeHTMLDirective.resultType = HTML_RESULT; +/** + * Renders the result as HTML, rather than text. + * + * The values `undefined`, `null`, and `nothing`, will all result in no content + * (empty string) being rendered. + * + * Note, this is unsafe to use with any user-provided input that hasn't been + * sanitized or escaped, as it may lead to cross-site-scripting + * vulnerabilities. + */ +const unsafeHTML = directive(UnsafeHTMLDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const SVG_RESULT = 2; +class UnsafeSVGDirective extends UnsafeHTMLDirective { +} +UnsafeSVGDirective.directiveName = 'unsafeSVG'; +UnsafeSVGDirective.resultType = SVG_RESULT; +/** + * Renders the result as SVG, rather than text. + * + * The values `undefined`, `null`, and `nothing`, will all result in no content + * (empty string) being rendered. + * + * Note, this is unsafe to use with any user-provided input that hasn't been + * sanitized or escaped, as it may lead to cross-site-scripting + * vulnerabilities. + */ +const unsafeSVG = directive(UnsafeSVGDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const isPromise = (x) => { + return !isPrimitive(x) && typeof x.then === 'function'; +}; +// Effectively infinity, but a SMI. +const _infinity = 0x3fffffff; +class UntilDirective extends AsyncDirective { + constructor() { + super(...arguments); + this.__lastRenderedIndex = _infinity; + this.__values = []; + this.__weakThis = new PseudoWeakRef(this); + this.__pauser = new Pauser(); + } + render(...args) { + var _a; + return (_a = args.find((x) => !isPromise(x))) !== null && _a !== void 0 ? _a : noChange; + } + update(_part, args) { + const previousValues = this.__values; + let previousLength = previousValues.length; + this.__values = args; + const weakThis = this.__weakThis; + const pauser = this.__pauser; + // If our initial render occurs while disconnected, ensure that the pauser + // and weakThis are in the disconnected state + if (!this.isConnected) { + this.disconnected(); + } + for (let i = 0; i < args.length; i++) { + // If we've rendered a higher-priority value already, stop. + if (i > this.__lastRenderedIndex) { + break; + } + const value = args[i]; + // Render non-Promise values immediately + if (!isPromise(value)) { + this.__lastRenderedIndex = i; + // Since a lower-priority value will never overwrite a higher-priority + // synchronous value, we can stop processing now. + return value; + } + // If this is a Promise we've already handled, skip it. + if (i < previousLength && value === previousValues[i]) { + continue; + } + // We have a Promise that we haven't seen before, so priorities may have + // changed. Forget what we rendered before. + this.__lastRenderedIndex = _infinity; + previousLength = 0; + // Note, the callback avoids closing over `this` so that the directive + // can be gc'ed before the promise resolves; instead `this` is retrieved + // from `weakThis`, which can break the hard reference in the closure when + // the directive disconnects + Promise.resolve(value).then(async (result) => { + // If we're disconnected, wait until we're (maybe) reconnected + // The while loop here handles the case that the connection state + // thrashes, causing the pauser to resume and then get re-paused + while (pauser.get()) { + await pauser.get(); + } + // If the callback gets here and there is no `this`, it means that the + // directive has been disconnected and garbage collected and we don't + // need to do anything else + const _this = weakThis.deref(); + if (_this !== undefined) { + const index = _this.__values.indexOf(value); + // If state.values doesn't contain the value, we've re-rendered without + // the value, so don't render it. Then, only render if the value is + // higher-priority than what's already been rendered. + if (index > -1 && index < _this.__lastRenderedIndex) { + _this.__lastRenderedIndex = index; + _this.setValue(result); + } + } + }); + } + return noChange; + } + disconnected() { + this.__weakThis.disconnect(); + this.__pauser.pause(); + } + reconnected() { + this.__weakThis.reconnect(this); + this.__pauser.resume(); + } +} +/** + * Renders one of a series of values, including Promises, to a Part. + * + * Values are rendered in priority order, with the first argument having the + * highest priority and the last argument having the lowest priority. If a + * value is a Promise, low-priority values will be rendered until it resolves. + * + * The priority of values can be used to create placeholder content for async + * data. For example, a Promise with pending content can be the first, + * highest-priority, argument, and a non_promise loading indicator template can + * be used as the second, lower-priority, argument. The loading indicator will + * render immediately, and the primary content will render when the Promise + * resolves. + * + * Example: + * + * ```js + * const content = fetch('./content.txt').then(r => r.text()); + * html`${until(content, html`<span>Loading...</span>`)}` + * ``` + */ +const until = directive(UntilDirective); +/** + * The type of the class that powers this directive. Necessary for naming the + * directive's return type. + */ +// export type {UntilDirective}; + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function when(condition, trueCase, falseCase) { + return condition ? trueCase() : falseCase === null || falseCase === void 0 ? void 0 : falseCase(); +} + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Prevents JSON injection attacks. + * + * The goals of this brand: + * 1) fast to check + * 2) code is small on the wire + * 3) multiple versions of Lit in a single page will all produce mutually + * interoperable StaticValues + * 4) normal JSON.parse (without an unusual reviver) can not produce a + * StaticValue + * + * Symbols satisfy (1), (2), and (4). We use Symbol.for to satisfy (3), but + * we don't care about the key, so we break ties via (2) and use the empty + * string. + */ +const brand = Symbol.for(''); +/** Safely extracts the string part of a StaticValue. */ +const unwrapStaticValue = (value) => { + if ((value === null || value === void 0 ? void 0 : value.r) !== brand) { + return undefined; + } + return value === null || value === void 0 ? void 0 : value['_$litStatic$']; +}; +/** + * Wraps a string so that it behaves like part of the static template + * strings instead of a dynamic value. + * + * Users must take care to ensure that adding the static string to the template + * results in well-formed HTML, or else templates may break unexpectedly. + * + * Note that this function is unsafe to use on untrusted content, as it will be + * directly parsed into HTML. Do not pass user input to this function + * without sanitizing it. + * + * Static values can be changed, but they will cause a complete re-render + * since they effectively create a new template. + */ +const unsafeStatic = (value) => ({ + ['_$litStatic$']: value, + r: brand, +}); +const textFromStatic = (value) => { + if (value['_$litStatic$'] !== undefined) { + return value['_$litStatic$']; + } + else { + throw new Error(`Value passed to 'literal' function must be a 'literal' result: ${value}. Use 'unsafeStatic' to pass non-literal values, but + take care to ensure page security.`); + } +}; +/** + * Tags a string literal so that it behaves like part of the static template + * strings instead of a dynamic value. + * + * The only values that may be used in template expressions are other tagged + * `literal` results or `unsafeStatic` values (note that untrusted content + * should never be passed to `unsafeStatic`). + * + * Users must take care to ensure that adding the static string to the template + * results in well-formed HTML, or else templates may break unexpectedly. + * + * Static values can be changed, but they will cause a complete re-render since + * they effectively create a new template. + */ +const literal = (strings, ...values) => ({ + ['_$litStatic$']: values.reduce((acc, v, idx) => acc + textFromStatic(v) + strings[idx + 1], strings[0]), + r: brand, +}); +const stringsCache = new Map(); +/** + * Wraps a lit-html template tag (`html` or `svg`) to add static value support. + */ +const withStatic = (coreTag) => (strings, ...values) => { + const l = values.length; + let staticValue; + let dynamicValue; + const staticStrings = []; + const dynamicValues = []; + let i = 0; + let hasStatics = false; + let s; + while (i < l) { + s = strings[i]; + // Collect any unsafeStatic values, and their following template strings + // so that we treat a run of template strings and unsafe static values as + // a single template string. + while (i < l && + ((dynamicValue = values[i]), + (staticValue = unwrapStaticValue(dynamicValue))) !== undefined) { + s += staticValue + strings[++i]; + hasStatics = true; + } + dynamicValues.push(dynamicValue); + staticStrings.push(s); + i++; + } + // If the last value isn't static (which would have consumed the last + // string), then we need to add the last string. + if (i === l) { + staticStrings.push(strings[l]); + } + if (hasStatics) { + const key = staticStrings.join('$$lit$$'); + strings = stringsCache.get(key); + if (strings === undefined) { + // Beware: in general this pattern is unsafe, and doing so may bypass + // lit's security checks and allow an attacker to execute arbitrary + // code and inject arbitrary content. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + staticStrings.raw = staticStrings; + stringsCache.set(key, (strings = staticStrings)); + } + values = dynamicValues; + } + return coreTag(strings, ...values); +}; +/** + * Interprets a template literal as an HTML template that can efficiently + * render to and update a container. + * + * Includes static value support from `lit-html/static.js`. + */ +const html = withStatic(html$1); +/** + * Interprets a template literal as an SVG template that can efficiently + * render to and update a container. + * + * Includes static value support from `lit-html/static.js`. + */ +const svg = withStatic(svg$1); + +export { AsyncDirective, AsyncReplaceDirective, CSSResult, Directive, LitElement, PartType, ReactiveElement, TemplateResultType, UnsafeHTMLDirective, UntilDirective, UpdatingElement, _$LE, _$LH, adoptStyles, asyncAppend, asyncReplace, cache, choose, classMap, clearPart, createRef, css, defaultConverter, directive, getCommittedValue, getCompatibleStyle, getDirectiveClass, guard, html$1 as html, ifDefined, insertPart, isDirectiveResult, isPrimitive, isServer, isSingleExpression, isTemplateResult, join, keyed, literal, live, map, noChange, notEqual, nothing, range, ref, removePart, render, repeat, setChildPartValue, setCommittedValue, html as staticHtml, svg as staticSvg, styleMap, supportsAdoptingStyleSheets, svg$1 as svg, templateContent, unsafeCSS, unsafeHTML, unsafeSVG, unsafeStatic, until, when, withStatic }; diff --git a/toolkit/content/widgets/videocontrols.js b/toolkit/content/widgets/videocontrols.js new file mode 100644 index 0000000000..21c8946e60 --- /dev/null +++ b/toolkit/content/widgets/videocontrols.js @@ -0,0 +1,3365 @@ +/* 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 a UA widget. It runs in per-origin UA widget scope, +// to be loaded by UAWidgetsChild.jsm. + +/* + * This is the class of entry. It will construct the actual implementation + * according to the value of the "controls" property. + */ +this.VideoControlsWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + + this.isMobile = this.window.navigator.appVersion.includes("Android"); + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.switchImpl(); + } + + /* + * Callback called by UAWidgets when the "controls" property changes. + */ + onchange() { + this.switchImpl(); + } + + /* + * Actually switch the implementation. + * - With "controls" set, the VideoControlsImplWidget controls should load. + * - Without it, on mobile, the NoControlsMobileImplWidget should load, so + * the user could see the click-to-play button when the video/audio is blocked. + * - Without it, on desktop, the NoControlsPictureInPictureImpleWidget should load + * if the video is being viewed in Picture-in-Picture. + */ + switchImpl() { + let newImpl; + let pageURI = this.document.documentURI; + if (this.element.controls) { + newImpl = VideoControlsImplWidget; + } else if (this.isMobile) { + newImpl = NoControlsMobileImplWidget; + } else if (VideoControlsWidget.isPictureInPictureVideo(this.element)) { + newImpl = NoControlsPictureInPictureImplWidget; + } else if ( + pageURI.startsWith("http://") || + pageURI.startsWith("https://") + ) { + newImpl = NoControlsDesktopImplWidget; + } + + // Skip if we are asked to load the same implementation, and + // the underlying element state hasn't changed in ways that we + // care about. This can happen if the property is set again + // without a value change. + if ( + this.impl && + this.impl.constructor == newImpl && + this.impl.elementStateMatches(this.element) + ) { + return; + } + if (this.impl) { + this.impl.teardown(); + this.shadowRoot.firstChild.remove(); + } + if (newImpl) { + this.impl = new newImpl(this.shadowRoot, this.prefs); + + this.mDirection = "ltr"; + let intlUtils = this.window.intlUtils; + if (intlUtils) { + this.mDirection = intlUtils.isAppLocaleRTL() ? "rtl" : "ltr"; + } + + this.impl.onsetup(this.mDirection); + } else { + this.impl = undefined; + } + } + + teardown() { + if (!this.impl) { + return; + } + this.impl.teardown(); + this.shadowRoot.firstChild.remove(); + delete this.impl; + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + + if (!this.impl) { + return; + } + + this.impl.onPrefChange(prefName, prefValue); + } + + // If you change this, also change SEEK_TIME_SECS in PictureInPictureChild.sys.mjs + static SEEK_TIME_SECS = 5; + + static isPictureInPictureVideo(someVideo) { + return someVideo.isCloningElementVisually; + } + + /** + * Returns true if a <video> meets the requirements to show the Picture-in-Picture + * toggle. Those requirements currently are: + * + * 1. The video must be 45 seconds in length or longer. + * 2. Neither the width or the height of the video can be less than 140px. + * 3. The video must have audio. + * 4. The video must not a MediaStream video (Bug 1592539) + * + * This can be overridden via the + * media.videocontrols.picture-in-picture.video-toggle.always-show pref, which + * is mostly used for testing. + * + * @param {Object} prefs + * The preferences set that was passed to the UAWidget. + * @param {Element} someVideo + * The <video> to test. + * @param {Object} reflowedDimensions + * An object representing the reflowed dimensions of the <video>. Properties + * are: + * + * videoWidth (Number): + * The width of the video in pixels. + * + * videoHeight (Number): + * The height of the video in pixels. + * + * @return {Boolean} + */ + static shouldShowPictureInPictureToggle( + prefs, + someVideo, + reflowedDimensions + ) { + if ( + prefs[ + "media.videocontrols.picture-in-picture.respect-disablePictureInPicture" + ] && + someVideo.disablePictureInPicture + ) { + return false; + } + + if (isNaN(someVideo.duration)) { + return false; + } + + if ( + prefs["media.videocontrols.picture-in-picture.video-toggle.always-show"] + ) { + return true; + } + + const MIN_VIDEO_LENGTH = + prefs[ + "media.videocontrols.picture-in-picture.video-toggle.min-video-secs" + ]; + + if (someVideo.duration < MIN_VIDEO_LENGTH) { + return false; + } + + const MIN_VIDEO_DIMENSION = 140; // pixels + if ( + reflowedDimensions.videoWidth < MIN_VIDEO_DIMENSION || + reflowedDimensions.videoHeight < MIN_VIDEO_DIMENSION + ) { + return false; + } + + return true; + } + + /** + * Some variations on the Picture-in-Picture toggle are being experimented with. + * These variations have slightly different setup parameters from the currently + * shipping toggle, so this method sets up the experimental toggles in the event + * that they're being used. It also will enable the appropriate stylesheet for + * the preferred toggle experiment. + * + * @param {Object} prefs + * The preferences set that was passed to the UAWidget. + * @param {ShadowRoot} shadowRoot + * The shadowRoot of the <video> element where the video controls are. + * @param {Element} toggle + * The toggle element. + * @param {Object} reflowedDimensions + * An object representing the reflowed dimensions of the <video>. Properties + * are: + * + * videoWidth (Number): + * The width of the video in pixels. + * + * videoHeight (Number): + * The height of the video in pixels. + */ + static setupToggle(prefs, toggle, reflowedDimensions) { + // These thresholds are all in pixels + const SMALL_VIDEO_WIDTH_MAX = 320; + const MEDIUM_VIDEO_WIDTH_MAX = 720; + + let isSmall = reflowedDimensions.videoWidth <= SMALL_VIDEO_WIDTH_MAX; + toggle.toggleAttribute("small-video", isSmall); + toggle.toggleAttribute( + "medium-video", + !isSmall && reflowedDimensions.videoWidth <= MEDIUM_VIDEO_WIDTH_MAX + ); + + toggle.setAttribute( + "position", + prefs["media.videocontrols.picture-in-picture.video-toggle.position"] + ); + toggle.toggleAttribute( + "has-used", + prefs["media.videocontrols.picture-in-picture.video-toggle.has-used"] + ); + } +}; + +this.VideoControlsImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + debug: false, + video: null, + videocontrols: null, + controlBar: null, + playButton: null, + muteButton: null, + volumeControl: null, + durationLabel: null, + positionLabel: null, + scrubber: null, + progressBar: null, + bufferBar: null, + statusOverlay: null, + controlsSpacer: null, + clickToPlay: null, + controlsOverlay: null, + fullscreenButton: null, + layoutControls: null, + isShowingPictureInPictureMessage: false, + l10n: this.l10n, + + textTracksCount: 0, + videoEvents: [ + "play", + "pause", + "ended", + "volumechange", + "loadeddata", + "loadstart", + "timeupdate", + "progress", + "playing", + "waiting", + "canplay", + "canplaythrough", + "seeking", + "seeked", + "emptied", + "loadedmetadata", + "error", + "suspend", + "stalled", + "mozvideoonlyseekbegin", + "mozvideoonlyseekcompleted", + "durationchange", + ], + + firstFrameShown: false, + timeUpdateCount: 0, + maxCurrentTimeSeen: 0, + isPausedByDragging: false, + _isAudioOnly: false, + + get isAudioTag() { + return this.video.localName == "audio"; + }, + + get isAudioOnly() { + return this._isAudioOnly; + }, + set isAudioOnly(val) { + this._isAudioOnly = val; + this.setFullscreenButtonState(); + this.updatePictureInPictureToggleDisplay(); + + if (!this.isTopLevelSyntheticDocument) { + return; + } + if (this._isAudioOnly) { + this.video.style.height = this.controlBarMinHeight + "px"; + this.video.style.width = "66%"; + } else { + this.video.style.removeProperty("height"); + this.video.style.removeProperty("width"); + } + }, + + suppressError: false, + + setupStatusFader(immediate) { + // Since the play button will be showing, we don't want to + // show the throbber behind it. The throbber here will + // only show if needed after the play button has been pressed. + if (!this.clickToPlay.hidden) { + this.startFadeOut(this.statusOverlay, true); + return; + } + + var show = false; + if ( + this.video.seeking || + (this.video.error && !this.suppressError) || + this.video.networkState == this.video.NETWORK_NO_SOURCE || + (this.video.networkState == this.video.NETWORK_LOADING && + (this.video.paused || this.video.ended + ? this.video.readyState < this.video.HAVE_CURRENT_DATA + : this.video.readyState < this.video.HAVE_FUTURE_DATA)) || + (this.timeUpdateCount <= 1 && + !this.video.ended && + this.video.readyState < this.video.HAVE_FUTURE_DATA && + this.video.networkState == this.video.NETWORK_LOADING) + ) { + show = true; + } + + // Explicitly hide the status fader if this + // is audio only until bug 619421 is fixed. + if (this.isAudioOnly) { + show = false; + } + + if (this._showThrobberTimer) { + show = true; + } + + this.log( + "Status overlay: seeking=" + + this.video.seeking + + " error=" + + this.video.error + + " readyState=" + + this.video.readyState + + " paused=" + + this.video.paused + + " ended=" + + this.video.ended + + " networkState=" + + this.video.networkState + + " timeUpdateCount=" + + this.timeUpdateCount + + " _showThrobberTimer=" + + this._showThrobberTimer + + " --> " + + (show ? "SHOW" : "HIDE") + ); + this.startFade(this.statusOverlay, show, immediate); + }, + + /* + * Set the initial state of the controls. The UA widget is normally created along + * with video element, but could be attached at any point (eg, if the video is + * removed from the document and then reinserted). Thus, some one-time events may + * have already fired, and so we'll need to explicitly check the initial state. + */ + setupInitialState() { + this.setPlayButtonState(this.video.paused); + + this.setFullscreenButtonState(); + + var duration = Math.round(this.video.duration * 1000); // in ms + var currentTime = Math.round(this.video.currentTime * 1000); // in ms + this.log( + "Initial playback position is at " + currentTime + " of " + duration + ); + // It would be nice to retain maxCurrentTimeSeen, but it would be difficult + // to determine if the media source changed while we were detached. + this.maxCurrentTimeSeen = currentTime; + this.showPosition(currentTime, duration); + + // If we have metadata, check if this is a <video> without + // video data, or a video with no audio track. + if (this.video.readyState >= this.video.HAVE_METADATA) { + if ( + this.video.localName == "video" && + (this.video.videoWidth == 0 || this.video.videoHeight == 0) + ) { + this.isAudioOnly = true; + } + + // We have to check again if the media has audio here. + if (!this.isAudioOnly && !this.video.mozHasAudio) { + this.muteButton.setAttribute("noAudio", "true"); + this.muteButton.disabled = true; + } + } + + // The video itself might not be fullscreen, but part of the + // document might be, in which case we set this attribute to + // apply any styles for the DOM fullscreen case. + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } + + if (this.isAudioOnly) { + this.startFadeOut(this.clickToPlay, true); + } + + // If the first frame hasn't loaded, kick off a throbber fade-in. + if (this.video.readyState >= this.video.HAVE_CURRENT_DATA) { + this.firstFrameShown = true; + } + + // We can't determine the exact buffering status, but do know if it's + // fully loaded. (If it's still loading, it will fire a progress event + // and we'll figure out the exact state then.) + this.bufferBar.max = 100; + if (this.video.readyState >= this.video.HAVE_METADATA) { + this.showBuffered(); + } else { + this.bufferBar.value = 0; + } + + // Set the current status icon. + if (this.hasError()) { + this.startFadeOut(this.clickToPlay, true); + this.statusIcon.setAttribute("type", "error"); + this.updateErrorText(); + this.setupStatusFader(true); + } else if (VideoControlsWidget.isPictureInPictureVideo(this.video)) { + this.setShowPictureInPictureMessage(true); + } + + if (this.video.readyState >= this.video.HAVE_METADATA) { + // According to the spec[1], at the HAVE_METADATA (or later) state, we know + // the video duration and dimensions, which means we can calculate whether or + // not to show the Picture-in-Picture toggle now. + // + // [1]: https://www.w3.org/TR/html50/embedded-content-0.html#dom-media-have_metadata + this.updatePictureInPictureToggleDisplay(); + } + + let adjustableControls = [ + ...this.prioritizedControls, + this.controlBar, + this.clickToPlay, + ]; + + for (let control of adjustableControls) { + if (!control) { + break; + } + + this.defineControlProperties(control); + } + this.adjustControlSize(); + + // Can only update the volume controls once we've computed + // _volumeControlWidth, since the volume slider implementation + // depends on it. + this.updateVolumeControls(); + }, + + defineControlProperties(control) { + let throwOnGet = { + get() { + throw new Error("Please don't trigger reflow. See bug 1493525."); + }, + }; + Object.defineProperties(control, { + // We should directly access CSSOM to get pre-defined style instead of + // retrieving computed dimensions from layout. + minWidth: { + get: () => { + let controlId = control.id; + let propertyName = `--${controlId}-width`; + if (control.modifier) { + propertyName += "-" + control.modifier; + } + let preDefinedSize = + this.controlBarComputedStyles.getPropertyValue(propertyName); + + // The stylesheet from <link> might not be loaded if the + // element was inserted into a hidden iframe. + // We can safely return 0 here for now, given that the controls + // will be resized again, by the resizevideocontrols event, + // from nsVideoFrame, when the element is visible. + if (!preDefinedSize) { + return 0; + } + + return parseInt(preDefinedSize, 10); + }, + }, + offsetLeft: throwOnGet, + offsetTop: throwOnGet, + offsetWidth: throwOnGet, + offsetHeight: throwOnGet, + offsetParent: throwOnGet, + clientLeft: throwOnGet, + clientTop: throwOnGet, + clientWidth: throwOnGet, + clientHeight: throwOnGet, + getClientRects: throwOnGet, + getBoundingClientRect: throwOnGet, + isAdjustableControl: { + value: true, + }, + modifier: { + value: "", + writable: true, + }, + isWanted: { + value: true, + writable: true, + }, + hidden: { + set: v => { + control._isHiddenExplicitly = v; + control._updateHiddenAttribute(); + }, + get: () => { + return ( + control.hasAttribute("hidden") || + control.classList.contains("fadeout") + ); + }, + }, + hiddenByAdjustment: { + set: v => { + control._isHiddenByAdjustment = v; + control._updateHiddenAttribute(); + }, + get: () => control._isHiddenByAdjustment, + }, + _isHiddenByAdjustment: { + value: false, + writable: true, + }, + _isHiddenExplicitly: { + value: false, + writable: true, + }, + _updateHiddenAttribute: { + value: () => { + control.toggleAttribute( + "hidden", + control._isHiddenExplicitly || control._isHiddenByAdjustment + ); + }, + }, + }); + }, + + updatePictureInPictureToggleDisplay() { + if (this.isAudioOnly) { + this.pictureInPictureToggle.hidden = true; + return; + } + + // We only want to show the toggle when the closed captions menu + // is closed, in order to avoid visual overlap. + if ( + this.pipToggleEnabled && + !this.isShowingPictureInPictureMessage && + this.textTrackListContainer.hidden && + VideoControlsWidget.shouldShowPictureInPictureToggle( + this.prefs, + this.video, + this.reflowedDimensions + ) + ) { + this.pictureInPictureToggle.hidden = false; + VideoControlsWidget.setupToggle( + this.prefs, + this.pictureInPictureToggle, + this.reflowedDimensions + ); + } else { + this.pictureInPictureToggle.hidden = true; + } + }, + + setupNewLoadState() { + // For videos with |autoplay| set, we'll leave the controls initially hidden, + // so that they don't get in the way of the playing video. Otherwise we'll + // go ahead and reveal the controls now, so they're an obvious user cue. + var shouldShow = + !this.dynamicControls || (this.video.paused && !this.video.autoplay); + // Hide the overlay if the video time is non-zero or if an error occurred to workaround bug 718107. + let shouldClickToPlayShow = + shouldShow && + !this.isAudioOnly && + this.video.currentTime == 0 && + !this.hasError() && + !this.isShowingPictureInPictureMessage; + this.startFade(this.clickToPlay, shouldClickToPlayShow, true); + this.startFade(this.controlBar, shouldShow, true); + }, + + get dynamicControls() { + // Don't fade controls for <audio> elements. + var enabled = !this.isAudioOnly; + + // Allow tests to explicitly suppress the fading of controls. + if (this.video.hasAttribute("mozNoDynamicControls")) { + enabled = false; + } + + // If the video hits an error, suppress controls if it + // hasn't managed to do anything else yet. + if (!this.firstFrameShown && this.hasError()) { + enabled = false; + } + + return enabled; + }, + + updateVolume() { + const volume = this.volumeControl.value; + this.setVolume(volume / 100); + }, + + updateVolumeControls() { + var volume = this.video.muted ? 0 : this.video.volume; + var volumePercentage = Math.round(volume * 100); + this.updateMuteButtonState(); + this.volumeControl.value = volumePercentage; + }, + + /* + * We suspend a video element's video decoder if the video + * element is invisible. However, resuming the video decoder + * takes time and we show the throbber UI if it takes more than + * 250 ms. + * + * When an already-suspended video element becomes visible, we + * resume its video decoder immediately and queue a video-only seek + * task to seek the resumed video decoder to the current position; + * meanwhile, we also file a "mozvideoonlyseekbegin" event which + * we used to start the timer here. + * + * Once the queued seek operation is done, we dispatch a + * "canplay" event which indicates that the resuming operation + * is completed. + */ + SHOW_THROBBER_TIMEOUT_MS: 250, + _showThrobberTimer: null, + _delayShowThrobberWhileResumingVideoDecoder() { + this._showThrobberTimer = this.window.setTimeout(() => { + this.statusIcon.setAttribute("type", "throbber"); + // Show the throbber immediately since we have waited for SHOW_THROBBER_TIMEOUT_MS. + // We don't want to wait for another animation delay(750ms) and the + // animation duration(300ms). + this.setupStatusFader(true); + }, this.SHOW_THROBBER_TIMEOUT_MS); + }, + _cancelShowThrobberWhileResumingVideoDecoder() { + if (this._showThrobberTimer) { + this.window.clearTimeout(this._showThrobberTimer); + this._showThrobberTimer = null; + } + }, + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + this.log("Drop untrusted event ----> " + aEvent.type); + return; + } + + this.log("Got event ----> " + aEvent.type); + + if (this.videoEvents.includes(aEvent.type)) { + this.handleVideoEvent(aEvent); + } else { + this.handleControlEvent(aEvent); + } + }, + + handleVideoEvent(aEvent) { + switch (aEvent.type) { + case "play": + this.setPlayButtonState(false); + this.setupStatusFader(); + if ( + !this._triggeredByControls && + this.dynamicControls && + this.isTouchControls + ) { + this.startFadeOut(this.controlBar); + } + if (!this._triggeredByControls) { + this.startFadeOut(this.clickToPlay, true); + } + this._triggeredByControls = false; + break; + case "pause": + // Little white lie: if we've internally paused the video + // while dragging the scrubber, don't change the button state. + if (!this.scrubber.isDragging) { + this.setPlayButtonState(true); + } + this.setupStatusFader(); + break; + case "ended": + this.setPlayButtonState(true); + // We throttle timechange events, so the thumb might not be + // exactly at the end when the video finishes. + this.showPosition( + Math.round(this.video.currentTime * 1000), + Math.round(this.video.duration * 1000) + ); + this.startFadeIn(this.controlBar); + this.setupStatusFader(); + break; + case "volumechange": + this.updateVolumeControls(); + // Show the controls to highlight the changing volume, + // but only if the click-to-play overlay has already + // been hidden (we don't hide controls when the overlay is visible). + if (this.clickToPlay.hidden && !this.isAudioOnly) { + this.startFadeIn(this.controlBar); + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + break; + case "loadedmetadata": + // If a <video> doesn't have any video data, treat it as <audio> + // and show the controls (they won't fade back out) + if ( + this.video.localName == "video" && + (this.video.videoWidth == 0 || this.video.videoHeight == 0) + ) { + this.isAudioOnly = true; + this.startFadeOut(this.clickToPlay, true); + this.startFadeIn(this.controlBar); + this.setFullscreenButtonState(); + } + this.showPosition( + Math.round(this.video.currentTime * 1000), + Math.round(this.video.duration * 1000) + ); + if (!this.isAudioOnly && !this.video.mozHasAudio) { + this.muteButton.setAttribute("noAudio", "true"); + this.muteButton.disabled = true; + } + this.adjustControlSize(); + this.updatePictureInPictureToggleDisplay(); + break; + case "durationchange": + this.updatePictureInPictureToggleDisplay(); + break; + case "loadeddata": + this.firstFrameShown = true; + this.setupStatusFader(); + break; + case "loadstart": + this.maxCurrentTimeSeen = 0; + this.controlsSpacer.removeAttribute("aria-label"); + this.statusOverlay.removeAttribute("status"); + this.statusIcon.setAttribute("type", "throbber"); + this.isAudioOnly = this.isAudioTag; + this.setPlayButtonState(true); + this.setupNewLoadState(); + this.setupStatusFader(); + break; + case "progress": + this.statusIcon.removeAttribute("stalled"); + this.showBuffered(); + this.setupStatusFader(); + break; + case "stalled": + this.statusIcon.setAttribute("stalled", "true"); + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "suspend": + this.setupStatusFader(); + break; + case "timeupdate": + var currentTime = Math.round(this.video.currentTime * 1000); // in ms + var duration = Math.round(this.video.duration * 1000); // in ms + + // If playing/seeking after the video ended, we won't get a "play" + // event, so update the button state here. + if (!this.video.paused) { + this.setPlayButtonState(false); + } + + this.timeUpdateCount++; + // Whether we show the statusOverlay sometimes depends + // on whether we've seen more than one timeupdate + // event (if we haven't, there hasn't been any + // "playback activity" and we may wish to show the + // statusOverlay while we wait for HAVE_ENOUGH_DATA). + // If we've seen more than 2 timeupdate events, + // the count is no longer relevant to setupStatusFader. + if (this.timeUpdateCount <= 2) { + this.setupStatusFader(); + } + + // If the user is dragging the scrubber ignore the delayed seek + // responses (don't yank the thumb away from the user) + if (this.scrubber.isDragging) { + return; + } + this.showPosition(currentTime, duration); + this.showBuffered(); + break; + case "emptied": + this.bufferBar.value = 0; + this.showPosition(0, 0); + break; + case "seeking": + this.showBuffered(); + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "waiting": + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "seeked": + case "playing": + case "canplay": + case "canplaythrough": + this.setupStatusFader(); + break; + case "error": + // We'll show the error status icon when we receive an error event + // under either of the following conditions: + // 1. The video has its error attribute set; this means we're loading + // from our src attribute, and the load failed, or we we're loading + // from source children and the decode or playback failed after we + // determined our selected resource was playable. + // 2. The video's networkState is NETWORK_NO_SOURCE. This means we we're + // loading from child source elements, but we were unable to select + // any of the child elements for playback during resource selection. + if (this.hasError()) { + this.suppressError = false; + this.startFadeOut(this.clickToPlay, true); + this.statusIcon.setAttribute("type", "error"); + this.updateErrorText(); + this.setupStatusFader(true); + // If video hasn't shown anything yet, disable the controls. + if (!this.firstFrameShown && !this.isAudioOnly) { + this.startFadeOut(this.controlBar); + } + this.controlsSpacer.removeAttribute("hideCursor"); + } + break; + case "mozvideoonlyseekbegin": + this._delayShowThrobberWhileResumingVideoDecoder(); + break; + case "mozvideoonlyseekcompleted": + this._cancelShowThrobberWhileResumingVideoDecoder(); + this.setupStatusFader(); + break; + default: + this.log("!!! media event " + aEvent.type + " not handled!"); + } + }, + + handleControlEvent(aEvent) { + switch (aEvent.type) { + case "click": + switch (aEvent.currentTarget) { + case this.muteButton: + this.toggleMute(); + break; + case this.castingButton: + this.toggleCasting(); + break; + case this.closedCaptionButton: + this.toggleClosedCaption(); + break; + case this.fullscreenButton: + this.toggleFullscreen(); + break; + case this.playButton: + case this.clickToPlay: + case this.controlsSpacer: + this.clickToPlayClickHandler(aEvent); + break; + case this.textTrackList: + const index = +aEvent.originalTarget.getAttribute("index"); + this.changeTextTrack(index); + this.closedCaptionButton.focus(); + break; + case this.videocontrols: + // Prevent any click event within media controls from dispatching through to video. + aEvent.stopPropagation(); + break; + } + break; + case "dblclick": + this.toggleFullscreen(); + break; + case "resizevideocontrols": + // Since this event come from the layout, this is the only place + // we are sure of that probing into layout won't trigger or force + // reflow. + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = true; + this.updateReflowedDimensions(); + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = false; + + let scrubberWasHidden = this.scrubberStack.hidden; + this.adjustControlSize(); + if (scrubberWasHidden && !this.scrubberStack.hidden) { + // requestAnimationFrame + setTimeout of 0ms is a best effort way to avoid + // triggering reflows, but cannot fully guarantee a reflow will not happen. + this.window.requestAnimationFrame(() => + this.window.setTimeout(() => { + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = true; + this.updateReflowedDimensions(); + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = false; + }, 0) + ); + } + this.updatePictureInPictureToggleDisplay(); + break; + case "fullscreenchange": + this.onFullscreenChange(); + break; + case "keypress": + this.keyHandler(aEvent); + break; + case "dragstart": + aEvent.preventDefault(); // prevent dragging of controls image (bug 517114) + break; + case "input": + switch (aEvent.currentTarget) { + case this.scrubber: + this.onScrubberInput(aEvent); + break; + case this.volumeControl: + this.updateVolume(); + break; + } + break; + case "change": + switch (aEvent.currentTarget) { + case this.scrubber: + this.onScrubberChange(aEvent); + break; + case this.video.textTracks: + this.setClosedCaptionButtonState(); + break; + } + break; + case "mouseup": + // add mouseup listener additionally to handle the case that `change` event + // isn't fired when the input value before/after dragging are the same. (bug 1328061) + this.onScrubberChange(aEvent); + break; + case "addtrack": + this.onTextTrackAdd(aEvent); + break; + case "removetrack": + this.onTextTrackRemove(aEvent); + break; + case "media-videoCasting": + this.updateCasting(aEvent.detail); + break; + case "focusin": + // Show the controls to highlight the focused control, but only + // under certain conditions: + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + // The click-to-play overlay must already be hidden (we don't + // hide controls when the overlay is visible). + this.clickToPlay.hidden && + // Don't do this if the controls are static. + this.dynamicControls && + // If the mouse is hovering over the control bar, the controls + // are already showing and they shouldn't hide, so don't mess + // with them. + // We use "div:hover" instead of just ":hover" so this works in + // quirks mode documents. See + // https://quirks.spec.whatwg.org/#the-active-and-hover-quirk + !this.controlBar.matches("div:hover") + ) { + this.startFadeIn(this.controlBar); + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + break; + case "mousedown": + // We only listen for mousedown on sliders. + // If this slider isn't focused already, mousedown will focus it. + // We don't want that because it will then handle additional keys. + // For example, we don't want the up/down arrow keys to seek after + // the scrubber is clicked. To prevent that, we need to redirect + // focus. However, dragging only works while the slider is focused, + // so we must redirect focus after mouseup. + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + !aEvent.currentTarget.matches(":focus") + ) { + aEvent.currentTarget.addEventListener( + "mouseup", + aEvent => { + if (aEvent.currentTarget.matches(":focus")) { + // We can't use target.blur() because that will blur the + // video element as well. + this.video.focus(); + } + }, + { once: true } + ); + } + break; + default: + this.log("!!! control event " + aEvent.type + " not handled!"); + } + }, + + terminate() { + if (this.videoEvents) { + for (let event of this.videoEvents) { + try { + this.video.removeEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } catch (ex) {} + } + } + + try { + for (let { el, type, capture = false } of this.controlsEvents) { + el.removeEventListener(type, this, { + mozSystemGroup: true, + capture, + }); + } + } catch (ex) {} + + this.window.clearTimeout(this._showControlsTimeout); + this.window.clearTimeout(this._hideControlsTimeout); + this._cancelShowThrobberWhileResumingVideoDecoder(); + + this.log("--- videocontrols terminated ---"); + }, + + hasError() { + // We either have an explicit error, or the resource selection + // algorithm is running and we've tried to load something and failed. + // Note: we don't consider the case where we've tried to load but + // there's no sources to load as an error condition, as sites may + // do this intentionally to work around requires-user-interaction to + // play restrictions, and we don't want to display a debug message + // if that's the case. + return ( + this.video.error != null || + (this.video.networkState == this.video.NETWORK_NO_SOURCE && + this.hasSources()) + ); + }, + + setShowPictureInPictureMessage(showMessage) { + this.pictureInPictureOverlay.hidden = !showMessage; + this.isShowingPictureInPictureMessage = showMessage; + }, + + hasSources() { + if ( + this.video.hasAttribute("src") && + this.video.getAttribute("src") !== "" + ) { + return true; + } + for ( + var child = this.video.firstChild; + child !== null; + child = child.nextElementSibling + ) { + if (child instanceof this.window.HTMLSourceElement) { + return true; + } + } + return false; + }, + + updateErrorText() { + let error; + let v = this.video; + // It is possible to have both v.networkState == NETWORK_NO_SOURCE + // as well as v.error being non-null. In this case, we will show + // the v.error.code instead of the v.networkState error. + if (v.error) { + switch (v.error.code) { + case v.error.MEDIA_ERR_ABORTED: + error = "errorAborted"; + break; + case v.error.MEDIA_ERR_NETWORK: + error = "errorNetwork"; + break; + case v.error.MEDIA_ERR_DECODE: + error = "errorDecode"; + break; + case v.error.MEDIA_ERR_SRC_NOT_SUPPORTED: + error = + v.networkState == v.NETWORK_NO_SOURCE + ? "errorNoSource" + : "errorSrcNotSupported"; + break; + default: + error = "errorGeneric"; + break; + } + } else if (v.networkState == v.NETWORK_NO_SOURCE) { + error = "errorNoSource"; + } else { + return; // No error found. + } + + let label = this.shadowRoot.getElementById(error); + this.controlsSpacer.setAttribute("aria-label", label.textContent); + this.statusOverlay.setAttribute("status", error); + }, + + formatTime(aTime) { + aTime = Math.round(aTime / 1000); + let hours = Math.floor(aTime / 3600); + let mins = Math.floor((aTime % 3600) / 60); + let secs = Math.floor(aTime % 60); + let timeString; + if (secs < 10) { + secs = "0" + secs; + } + if (hours) { + if (mins < 10) { + mins = "0" + mins; + } + timeString = hours + ":" + mins + ":" + secs; + } else { + timeString = mins + ":" + secs; + } + return timeString; + }, + + pauseVideoDuringDragging() { + if ( + !this.video.paused && + !this.isPausedByDragging && + this.scrubber.isDragging + ) { + this.isPausedByDragging = true; + this.video.pause(); + } + }, + + onScrubberInput(e) { + const duration = Math.round(this.video.duration * 1000); // in ms + let time = this.scrubber.value; + + this.seekToPosition(time); + this.showPosition(time, duration); + this.updateScrubberProgress(); + + this.scrubber.isDragging = true; + this.pauseVideoDuringDragging(); + }, + + onScrubberChange(e) { + this.scrubber.isDragging = false; + + if (this.isPausedByDragging) { + this.video.play(); + this.isPausedByDragging = false; + } + }, + + updateScrubberProgress() { + const positionPercent = (this.scrubber.value / this.scrubber.max) * 100; + + if (!isNaN(positionPercent) && positionPercent != Infinity) { + this.progressBar.value = positionPercent; + } else { + this.progressBar.value = 0; + } + }, + + seekToPosition(newPosition) { + newPosition /= 1000; // convert from ms + this.log("+++ seeking to " + newPosition); + this.video.currentTime = newPosition; + }, + + setVolume(newVolume) { + this.log("*** setting volume to " + newVolume); + this.video.volume = newVolume; + this.video.muted = false; + }, + + showPosition(currentTimeMs, durationMs) { + // If the duration is unknown (because the server didn't provide + // it, or the video is a stream), then we want to fudge the duration + // by using the maximum playback position that's been seen. + if (currentTimeMs > this.maxCurrentTimeSeen) { + this.maxCurrentTimeSeen = currentTimeMs; + } + this.log( + "time update @ " + currentTimeMs + "ms of " + durationMs + "ms" + ); + + let durationIsInfinite = durationMs == Infinity; + if (isNaN(durationMs) || durationIsInfinite) { + durationMs = this.maxCurrentTimeSeen; + } + this.log("durationMs is " + durationMs + "ms.\n"); + + let scrubberProgress = Math.abs( + currentTimeMs / durationMs - this.scrubber.value / this.scrubber.max + ); + let devPxProgress = + scrubberProgress * + this.reflowedDimensions.scrubberWidth * + this.window.devicePixelRatio; + // Hack: if we haven't updated the scrubber width to be non-0, but + // the scrubber stack is visible, assume there is progress. + // This should be rectified by the next time we do layout (see handling + // of resizevideocontrols events in handleEvent). + if ( + !this.reflowedDimensions.scrubberWidth && + !this.scrubberStack.hidden + ) { + devPxProgress = 1; + } + // Update the scrubber only if it will move by at least 1 pixel + // Note that this.scrubber.max can be "" if unitialized, + // and either or both of currentTimeMs or durationMs can be 0, leading + // to NaN or Infinity values for devPxProgress. + if (!this.scrubber.max || isNaN(devPxProgress) || devPxProgress > 0.5) { + this.scrubber.max = durationMs; + this.scrubber.value = currentTimeMs; + this.updateScrubberProgress(); + } + + // If the duration is over an hour, thumb should show h:mm:ss instead + // of mm:ss, which makes it bigger. We set the modifier prop which + // informs CSS custom properties used elsewhere to determine minimum + // widths we need to show stuff. + let modifier = durationMs >= 3600000 ? "long" : ""; + this.positionDurationBox.modifier = this.durationSpan.modifier = + modifier; + + // Update the text-based labels: + let position = this.formatTime(currentTimeMs); + let duration = durationIsInfinite ? "" : this.formatTime(durationMs); + if ( + this.positionString != position || + this.durationString != duration + ) { + // Only update the DOM if there is a visible change. + this._updatePositionLabels(position, duration); + } + }, + + _updatePositionLabels(position, duration) { + this.positionString = position; + this.durationString = duration; + + this.l10n.setAttributes( + this.positionDurationBox, + "videocontrols-position-and-duration-labels", + { position, duration } + ); + this.l10n.setAttributes( + this.scrubber, + "videocontrols-scrubber-position-and-duration", + { position, duration } + ); + }, + + showBuffered() { + function bsearch(haystack, needle, cmp) { + var length = haystack.length; + var low = 0; + var high = length; + while (low < high) { + var probe = low + ((high - low) >> 1); + var r = cmp(haystack, probe, needle); + if (r == 0) { + return probe; + } else if (r > 0) { + low = probe + 1; + } else { + high = probe; + } + } + return -1; + } + + function bufferedCompare(buffered, i, time) { + if (time > buffered.end(i)) { + return 1; + } else if (time >= buffered.start(i)) { + return 0; + } + return -1; + } + + var duration = Math.round(this.video.duration * 1000); + if (isNaN(duration) || duration == Infinity) { + duration = this.maxCurrentTimeSeen; + } + + // Find the range that the current play position is in and use that + // range for bufferBar. At some point we may support multiple ranges + // displayed in the bar. + var currentTime = this.video.currentTime; + var buffered = this.video.buffered; + var index = bsearch(buffered, currentTime, bufferedCompare); + var endTime = 0; + if (index >= 0) { + endTime = Math.round(buffered.end(index) * 1000); + } + if (this.duration == duration && this.buffered == endTime) { + // Avoid modifying the DOM if there is no update to show. + return; + } + + this.bufferBar.max = this.duration = duration; + this.bufferBar.value = this.buffered = endTime; + // Progress bars are automatically reported by screen readers even when + // they aren't focused, which intrudes on the audio being played. + // Ideally, we'd just change the a11y role of bufferBar, but there's + // no role which will let us just expose text via an ARIA attribute. + // Therefore, we hide bufferBar for a11y and expose the info as + // off-screen text. + this.bufferA11yVal.textContent = + (this.bufferBar.position * 100).toFixed() + "%"; + }, + + _controlsHiddenByTimeout: false, + _showControlsTimeout: 0, + SHOW_CONTROLS_TIMEOUT_MS: 500, + _showControlsFn() { + if (this.video.matches("video:hover")) { + this.startFadeIn(this.controlBar, false); + this._showControlsTimeout = 0; + this._controlsHiddenByTimeout = false; + } + }, + + _hideControlsTimeout: 0, + _hideControlsFn() { + if (!this.scrubber.isDragging) { + this.startFade(this.controlBar, false); + this._hideControlsTimeout = 0; + this._controlsHiddenByTimeout = true; + } + }, + HIDE_CONTROLS_TIMEOUT_MS: 2000, + + // By "Video" we actually mean the video controls container, + // because we don't want to consider the padding of <video> added + // by the web content. + isMouseOverVideo(event) { + // XXX: this triggers reflow too, but the layout should only be dirty + // if the web content touches it while the mouse is moving. + let el = this.shadowRoot.elementFromPoint(event.clientX, event.clientY); + + // As long as this is not null, the cursor is over something within our + // Shadow DOM. + return !!el; + }, + + isMouseOverControlBar(event) { + // XXX: this triggers reflow too, but the layout should only be dirty + // if the web content touches it while the mouse is moving. + let el = this.shadowRoot.elementFromPoint(event.clientX, event.clientY); + while (el && el !== this.shadowRoot) { + if (el == this.controlBar) { + return true; + } + el = el.parentNode; + } + return false; + }, + + onMouseMove(event) { + // If the controls are static, don't change anything. + if (!this.dynamicControls) { + return; + } + + this.window.clearTimeout(this._hideControlsTimeout); + + // Suppress fading out the controls until the video has rendered + // its first frame. But since autoplay videos start off with no + // controls, let them fade-out so the controls don't get stuck on. + if (!this.firstFrameShown && !this.video.autoplay) { + return; + } + + if (this._controlsHiddenByTimeout) { + this._showControlsTimeout = this.window.setTimeout( + () => this._showControlsFn(), + this.SHOW_CONTROLS_TIMEOUT_MS + ); + } else { + this.startFade(this.controlBar, true); + } + + // Hide the controls if the mouse cursor is left on top of the video + // but above the control bar and if the click-to-play overlay is hidden. + if ( + (this._controlsHiddenByTimeout || + !this.isMouseOverControlBar(event)) && + this.clickToPlay.hidden + ) { + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + }, + + onMouseInOut(event) { + // If the controls are static, don't change anything. + if (!this.dynamicControls) { + return; + } + + this.window.clearTimeout(this._hideControlsTimeout); + + let isMouseOverVideo = this.isMouseOverVideo(event); + + // Suppress fading out the controls until the video has rendered + // its first frame. But since autoplay videos start off with no + // controls, let them fade-out so the controls don't get stuck on. + if ( + !this.firstFrameShown && + !isMouseOverVideo && + !this.video.autoplay + ) { + return; + } + + if (!isMouseOverVideo && !this.isMouseOverControlBar(event)) { + this.adjustControlSize(); + + // Keep the controls visible if the click-to-play is visible. + if (!this.clickToPlay.hidden) { + return; + } + + this.startFadeOut(this.controlBar, false); + this.hideClosedCaptionMenu(); + this.window.clearTimeout(this._showControlsTimeout); + this._controlsHiddenByTimeout = false; + } + }, + + startFadeIn(element, immediate) { + this.startFade(element, true, immediate); + }, + + startFadeOut(element, immediate) { + this.startFade(element, false, immediate); + }, + + animationMap: new WeakMap(), + + animationProps: { + clickToPlay: { + keyframes: [ + { transform: "scale(3)", opacity: 0 }, + { transform: "scale(1)", opacity: 0.55 }, + ], + options: { + easing: "ease", + duration: 400, + // The fill mode here and below is a workaround to avoid flicker + // due to bug 1495350. + fill: "both", + }, + }, + controlBar: { + keyframes: [{ opacity: 0 }, { opacity: 1 }], + options: { + easing: "ease", + duration: 200, + fill: "both", + }, + }, + statusOverlay: { + keyframes: [ + { opacity: 0 }, + { opacity: 0, offset: 0.72 }, // ~750ms into animation + { opacity: 1 }, + ], + options: { + duration: 1050, + fill: "both", + }, + }, + }, + + startFade(element, fadeIn, immediate = false) { + let animationProp = this.animationProps[element.id]; + if (!animationProp) { + throw new Error( + "Element " + + element.id + + " has no transition. Toggle the hidden property directly." + ); + } + + let animation = this.animationMap.get(element); + if (!animation) { + animation = new this.window.Animation( + new this.window.KeyframeEffect( + element, + animationProp.keyframes, + animationProp.options + ) + ); + + this.animationMap.set(element, animation); + } + + if (fadeIn) { + if (element == this.controlBar) { + this.controlsSpacer.removeAttribute("hideCursor"); + // Ensure the Full Screen button is in the tab order. + this.fullscreenButton.removeAttribute("tabindex"); + } + + // hidden state should be controlled by adjustControlSize + if (element.isAdjustableControl && element.hiddenByAdjustment) { + return; + } + + // No need to fade in again if the hidden property returns false + // (not hidden and not fading out.) + if (!element.hidden) { + return; + } + + // Unhide + element.hidden = false; + } else { + if (element == this.controlBar) { + if (!this.hasError() && this.isVideoInFullScreen) { + this.controlsSpacer.setAttribute("hideCursor", true); + } + if ( + !this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] + ) { + // The Full Screen button is currently the only tabbable button + // when the controls are shown. Remove it from the tab order when + // visually hidden to prevent visual confusion. + this.fullscreenButton.setAttribute("tabindex", "-1"); + } + } + + // No need to fade out if the hidden property returns true + // (hidden or is fading out) + if (element.hidden) { + return; + } + } + + element.classList.toggle("fadeout", !fadeIn); + element.classList.toggle("fadein", fadeIn); + let finishedPromise; + if (!immediate) { + // At this point, if there is a pending animation, we just stop it to avoid it happening. + // If there is a running animation, we reverse it, to have it rewind to the beginning. + // If there is an idle/finished animation, we schedule a new one that reverses the finished one. + if (animation.pending) { + // Animation is running but pending. + // Just cancel the pending animation to stop its effect. + animation.cancel(); + finishedPromise = Promise.resolve(); + } else { + switch (animation.playState) { + case "idle": + case "finished": + // There is no animation currently playing. + // Schedule a new animation with the desired playback direction. + animation.playbackRate = fadeIn ? 1 : -1; + animation.play(); + break; + case "running": + // Allow the animation to play from its current position in + // reverse to finish. + animation.reverse(); + break; + case "pause": + throw new Error("Animation should never reach pause state."); + default: + throw new Error( + "Unknown Animation playState: " + animation.playState + ); + } + finishedPromise = animation.finished; + } + } else { + // immediate + animation.cancel(); + finishedPromise = Promise.resolve(); + } + finishedPromise.then( + animation => { + if (element == this.controlBar) { + this.onControlBarAnimationFinished(); + } + element.classList.remove(fadeIn ? "fadein" : "fadeout"); + if (!fadeIn) { + element.hidden = true; + } + if (animation) { + // Explicitly clear the animation effect so that filling animations + // stop overwriting stylesheet styles. Remove when bug 1495350 is + // fixed and animations are no longer filling animations. + // This also stops them from accumulating (See bug 1253476). + animation.cancel(); + } + }, + () => { + /* Do nothing on rejection */ + } + ); + }, + + _triggeredByControls: false, + + startPlay() { + this._triggeredByControls = true; + this.hideClickToPlay(); + this.video.play(); + }, + + togglePause() { + if (this.video.paused || this.video.ended) { + this.startPlay(); + } else { + this.video.pause(); + } + + // We'll handle style changes in the event listener for + // the "play" and "pause" events, same as if content + // script was controlling video playback. + }, + + get isVideoWithoutAudioTrack() { + return ( + this.video.readyState >= this.video.HAVE_METADATA && + !this.isAudioOnly && + !this.video.mozHasAudio + ); + }, + + toggleMute() { + if (this.isVideoWithoutAudioTrack) { + return; + } + this.video.muted = !this.isEffectivelyMuted; + if (this.video.volume === 0) { + this.video.volume = 0.5; + } + + // We'll handle style changes in the event listener for + // the "volumechange" event, same as if content script was + // controlling volume. + }, + + get isVideoInFullScreen() { + return this.video.isSameNode( + this.video.getRootNode().fullscreenElement + ); + }, + + toggleFullscreen() { + // audio tags cannot toggle fullscreen + if (!this.isAudioTag) { + this.isVideoInFullScreen + ? this.document.exitFullscreen() + : this.video.requestFullscreen(); + } + }, + + setFullscreenButtonState() { + if (this.isAudioOnly || !this.document.fullscreenEnabled) { + this.controlBar.setAttribute("fullscreen-unavailable", true); + this.adjustControlSize(); + return; + } + this.controlBar.removeAttribute("fullscreen-unavailable"); + this.adjustControlSize(); + + var id = this.isVideoInFullScreen + ? "videocontrols-exitfullscreen-button" + : "videocontrols-enterfullscreen-button"; + this.l10n.setAttributes(this.fullscreenButton, id); + + if (this.isVideoInFullScreen) { + this.fullscreenButton.setAttribute("fullscreened", "true"); + } else { + this.fullscreenButton.removeAttribute("fullscreened"); + } + }, + + onFullscreenChange() { + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } else { + this.videocontrols.removeAttribute("inDOMFullscreen"); + } + + if (this.isVideoInFullScreen) { + this.startFadeOut(this.controlBar, true); + } + + this.setFullscreenButtonState(); + }, + + clickToPlayClickHandler(e) { + if (e.button != 0) { + return; + } + if (this.hasError() && !this.suppressError) { + // Errors that can be dismissed should be placed here as we discover them. + if (this.video.error.code != this.video.error.MEDIA_ERR_ABORTED) { + return; + } + this.startFadeOut(this.statusOverlay, true); + this.suppressError = true; + return; + } + if (e.defaultPrevented) { + return; + } + if (this.playButton.hasAttribute("paused")) { + this.startPlay(); + } else { + this.video.pause(); + } + }, + hideClickToPlay() { + let videoHeight = this.reflowedDimensions.videoHeight; + let videoWidth = this.reflowedDimensions.videoWidth; + + // The play button will animate to 3x its size. This + // shows the animation unless the video is too small + // to show 2/3 of the animation. + let animationScale = 2; + let animationMinSize = this.clickToPlay.minWidth * animationScale; + + let immediate = + animationMinSize > videoWidth || + animationMinSize > videoHeight - this.controlBarMinHeight; + this.startFadeOut(this.clickToPlay, immediate); + }, + + setPlayButtonState(aPaused) { + if (aPaused) { + this.playButton.setAttribute("paused", "true"); + } else { + this.playButton.removeAttribute("paused"); + } + + var id = aPaused + ? "videocontrols-play-button" + : "videocontrols-pause-button"; + this.l10n.setAttributes(this.playButton, id); + this.l10n.setAttributes(this.clickToPlay, id); + }, + + get isEffectivelyMuted() { + return this.video.muted || !this.video.volume; + }, + + updateMuteButtonState() { + var muted = this.isEffectivelyMuted; + + if (muted) { + this.muteButton.setAttribute("muted", "true"); + } else { + this.muteButton.removeAttribute("muted"); + } + + var id = muted + ? "videocontrols-unmute-button" + : "videocontrols-mute-button"; + this.l10n.setAttributes(this.muteButton, id); + }, + + keyboardVolumeDecrease() { + const oldval = this.video.volume; + this.video.volume = oldval < 0.1 ? 0 : oldval - 0.1; + this.video.muted = false; + }, + + keyboardVolumeIncrease() { + const oldval = this.video.volume; + this.video.volume = oldval > 0.9 ? 1 : oldval + 0.1; + this.video.muted = false; + }, + + keyboardSeekBack(tenPercent) { + const oldval = this.video.currentTime; + let newval; + if (tenPercent) { + newval = + oldval - + (this.video.duration || this.maxCurrentTimeSeen / 1000) / 10; + } else { + newval = oldval - VideoControlsWidget.SEEK_TIME_SECS; + } + this.video.currentTime = Math.max(0, newval); + }, + + keyboardSeekForward(tenPercent) { + const oldval = this.video.currentTime; + const maxtime = this.video.duration || this.maxCurrentTimeSeen / 1000; + let newval; + if (tenPercent) { + newval = oldval + maxtime / 10; + } else { + newval = oldval + VideoControlsWidget.SEEK_TIME_SECS; + } + this.video.currentTime = Math.min(newval, maxtime); + }, + + keyHandler(event) { + // Ignore keys when content might be providing its own. + if (!this.video.hasAttribute("controls")) { + return; + } + + let keystroke = ""; + if (event.altKey) { + keystroke += "alt-"; + } + if (event.shiftKey) { + keystroke += "shift-"; + } + if (this.window.navigator.platform.startsWith("Mac")) { + if (event.metaKey) { + keystroke += "accel-"; + } + if (event.ctrlKey) { + keystroke += "control-"; + } + } else { + if (event.metaKey) { + keystroke += "meta-"; + } + if (event.ctrlKey) { + keystroke += "accel-"; + } + } + if (event.key == " ") { + keystroke += "Space"; + } else { + keystroke += event.key; + } + + this.log("Got keystroke: " + keystroke); + + // If unmodified cursor keys are pressed when a slider is focused, we + // should act on that slider. For example, if we're focused on the + // volume slider, rightArrow should increase the volume, not seek. + // Normally, we'd just pass the keys through to the slider in this case. + // However, the native adjustment is too small, so we override it. + try { + const target = event.originalTarget; + const allTabbable = + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]; + switch (keystroke) { + case "Space" /* Play */: + if (target.localName === "button" && !target.disabled) { + break; + } + this.togglePause(); + break; + case "ArrowDown" /* Volume decrease */: + if (allTabbable && target == this.scrubber) { + this.keyboardSeekBack(/* tenPercent */ false); + } else if (target.classList.contains("textTrackItem")) { + target.nextSibling?.focus(); + } else { + this.keyboardVolumeDecrease(); + } + break; + case "ArrowUp" /* Volume increase */: + if (allTabbable && target == this.scrubber) { + this.keyboardSeekForward(/* tenPercent */ false); + } else if (target.classList.contains("textTrackItem")) { + target.previousSibling?.focus(); + } else { + this.keyboardVolumeIncrease(); + } + break; + case "accel-ArrowDown" /* Mute */: + this.video.muted = true; + break; + case "accel-ArrowUp" /* Unmute */: + this.video.muted = false; + break; + case "ArrowLeft" /* Seek back 5 seconds */: + if (allTabbable && target == this.volumeControl) { + this.keyboardVolumeDecrease(); + } else { + this.keyboardSeekBack(/* tenPercent */ false); + } + break; + case "accel-ArrowLeft" /* Seek back 10% */: + this.keyboardSeekBack(/* tenPercent */ true); + break; + case "ArrowRight" /* Seek forward 5 seconds */: + if (allTabbable && target == this.volumeControl) { + this.keyboardVolumeIncrease(); + } else { + this.keyboardSeekForward(/* tenPercent */ false); + } + break; + case "accel-ArrowRight" /* Seek forward 10% */: + this.keyboardSeekForward(/* tenPercent */ true); + break; + case "Home" /* Seek to beginning */: + this.video.currentTime = 0; + break; + case "End" /* Seek to end */: + if (this.video.currentTime != this.video.duration) { + this.video.currentTime = + this.video.duration || this.maxCurrentTimeSeen / 1000; + } + break; + case "Escape" /* Escape */: + if ( + target.classList.contains("textTrackItem") && + !this.textTrackListContainer.hidden + ) { + this.toggleClosedCaption(); + this.closedCaptionButton.focus(); + } + break; + default: + return; + } + } catch (e) { + /* ignore any exception from setting .currentTime */ + } + + event.preventDefault(); // Prevent page scrolling + }, + + checkTextTrackSupport(textTrack) { + return textTrack.kind == "subtitles" || textTrack.kind == "captions"; + }, + + get isCastingAvailable() { + return !this.isAudioOnly && this.video.mozAllowCasting; + }, + + get isClosedCaptionAvailable() { + // There is no rendering area, no need to show the caption. + if (this.isAudioOnly) { + return false; + } + return this.overlayableTextTracks.length; + }, + + get overlayableTextTracks() { + return Array.prototype.filter.call( + this.video.textTracks, + this.checkTextTrackSupport + ); + }, + + get currentTextTrackIndex() { + const showingTT = this.overlayableTextTracks.find( + tt => tt.mode == "showing" + ); + + // fallback to off button if there's no showing track. + return showingTT ? showingTT.index : 0; + }, + + get isCastingOn() { + return this.isCastingAvailable && this.video.mozIsCasting; + }, + + setCastingButtonState() { + if (this.isCastingOn) { + this.castingButton.setAttribute("enabled", "true"); + } else { + this.castingButton.removeAttribute("enabled"); + } + + this.adjustControlSize(); + }, + + updateCasting(eventDetail) { + let castingData = JSON.parse(eventDetail); + if ("allow" in castingData) { + this.video.mozAllowCasting = !!castingData.allow; + } + + if ("active" in castingData) { + this.video.mozIsCasting = !!castingData.active; + } + this.setCastingButtonState(); + }, + + get isClosedCaptionOn() { + for (let tt of this.overlayableTextTracks) { + if (tt.mode === "showing") { + return true; + } + } + + return false; + }, + + setClosedCaptionButtonState() { + if (this.isClosedCaptionOn) { + this.closedCaptionButton.setAttribute("enabled", "true"); + } else { + this.closedCaptionButton.removeAttribute("enabled"); + } + + let ttItems = this.textTrackList.childNodes; + + for (let tti of ttItems) { + const idx = +tti.getAttribute("index"); + + if (idx == this.currentTextTrackIndex) { + tti.setAttribute("aria-checked", "true"); + } else { + tti.setAttribute("aria-checked", "false"); + } + } + + this.adjustControlSize(); + }, + + addNewTextTrack(tt) { + if (!this.checkTextTrackSupport(tt)) { + return; + } + + if (tt.index && tt.index < this.textTracksCount) { + // Don't create items for initialized tracks. However, we + // still need to care about mode since TextTrackManager would + // turn on the first available track automatically. + if (tt.mode === "showing") { + this.changeTextTrack(tt.index); + } + return; + } + + tt.index = this.textTracksCount++; + + const ttBtn = this.shadowRoot.createElementAndAppendChildAt( + this.textTrackList, + "button" + ); + ttBtn.textContent = tt.label || ""; + + ttBtn.classList.add("textTrackItem"); + ttBtn.setAttribute("index", tt.index); + ttBtn.setAttribute("role", "menuitemradio"); + + if (tt.mode === "showing" && tt.index) { + this.changeTextTrack(tt.index); + } + }, + + changeTextTrack(index) { + for (let tt of this.overlayableTextTracks) { + if (tt.index === index) { + tt.mode = "showing"; + } else { + tt.mode = "disabled"; + } + } + + if (!this.textTrackListContainer.hidden) { + this.toggleClosedCaption(); + } + }, + + onControlBarAnimationFinished() { + this.hideClosedCaptionMenu(); + this.video.dispatchEvent( + new this.window.CustomEvent("controlbarchange") + ); + this.adjustControlSize(); + }, + + toggleCasting() { + this.videocontrols.dispatchEvent( + new this.window.CustomEvent("VideoBindingCast") + ); + }, + + hideClosedCaptionMenu() { + this.textTrackListContainer.hidden = true; + this.closedCaptionButton.setAttribute("aria-expanded", "false"); + this.updatePictureInPictureToggleDisplay(); + }, + + showClosedCaptionMenu() { + this.textTrackListContainer.hidden = false; + this.closedCaptionButton.setAttribute("aria-expanded", "true"); + this.updatePictureInPictureToggleDisplay(); + }, + + toggleClosedCaption() { + if (this.textTrackListContainer.hidden) { + this.showClosedCaptionMenu(); + if (this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]) { + // If we're about to hide the controls after focus, prevent that, as + // that will dismiss the CC menu before the user can use it. + this.textTrackList.firstChild.focus(); + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = 0; + } + } else { + this.hideClosedCaptionMenu(); + // If the CC menu was shown via the keyboard, we may have prevented + // the controls from hiding. We can now hide them. + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + !this.controlBar.hidden && + // The click-to-play overlay must already be hidden (we don't + // hide controls when the overlay is visible). + this.clickToPlay.hidden && + // Don't do this if the controls are static. + this.dynamicControls && + // If the mouse is hovering over the control bar, the controls + // shouldn't hide. + // We use "div:hover" instead of just ":hover" so this works in + // quirks mode documents. See + // https://quirks.spec.whatwg.org/#the-active-and-hover-quirk + !this.controlBar.matches("div:hover") + ) { + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + } + }, + + onTextTrackAdd(trackEvent) { + this.addNewTextTrack(trackEvent.track); + this.setClosedCaptionButtonState(); + }, + + onTextTrackRemove(trackEvent) { + const toRemoveIndex = trackEvent.track.index; + const ttItems = this.textTrackList.childNodes; + + if (!ttItems) { + return; + } + + for (let tti of ttItems) { + const idx = +tti.getAttribute("index"); + + if (idx === toRemoveIndex) { + tti.remove(); + this.textTracksCount--; + } + + this.video.dispatchEvent( + new this.window.CustomEvent("texttrackchange") + ); + } + + this.setClosedCaptionButtonState(); + }, + + initTextTracks() { + // add 'off' button anyway as new text track might be + // dynamically added after initialization. + const offLabel = this.textTrackList.getAttribute("offlabel"); + this.addNewTextTrack({ + label: offLabel, + kind: "subtitles", + }); + + for (let tt of this.overlayableTextTracks) { + this.addNewTextTrack(tt); + } + + this.setClosedCaptionButtonState(); + // Hide the Closed Caption menu when the user moves focus + this.hideClosedCaptionMenu = this.hideClosedCaptionMenu.bind(this); + this.closedCaptionButton.addEventListener( + "focus", + this.hideClosedCaptionMenu + ); + this.fullscreenButton.addEventListener( + "focus", + this.hideClosedCaptionMenu + ); + }, + + log(msg) { + if (this.debug) { + this.window.console.log("videoctl: " + msg + "\n"); + } + }, + + get isTopLevelSyntheticDocument() { + return ( + this.document.mozSyntheticDocument && this.window === this.window.top + ); + }, + + controlBarMinHeight: 40, + controlBarMinVisibleHeight: 28, + + reflowTriggeringCallValidator: { + isReflowTriggeringPropsAllowed: false, + reflowTriggeringProps: Object.freeze([ + "offsetLeft", + "offsetTop", + "offsetWidth", + "offsetHeight", + "offsetParent", + "clientLeft", + "clientTop", + "clientWidth", + "clientHeight", + "getClientRects", + "getBoundingClientRect", + ]), + get(obj, prop) { + if ( + !this.isReflowTriggeringPropsAllowed && + this.reflowTriggeringProps.includes(prop) + ) { + throw new Error("Please don't trigger reflow. See bug 1493525."); + } + let val = obj[prop]; + if (typeof val == "function") { + return function () { + return val.apply(obj, arguments); + }; + } + return val; + }, + + set(obj, prop, value) { + return Reflect.set(obj, prop, value); + }, + }, + + installReflowCallValidator(element) { + return new Proxy(element, this.reflowTriggeringCallValidator); + }, + + reflowedDimensions: { + // Set the dimensions to intrinsic <video> dimensions before the first + // update. + // These values are not picked up by <audio> in adjustControlSize() + // (except for the fact that they are non-zero), + // it takes controlBarMinHeight and the value below instead. + videoHeight: 150, + videoWidth: 300, + + // <audio> takes this width to grow/shrink controls. + // The initial value has to be smaller than the calculated minRequiredWidth + // so that we don't run into bug 1495821 (see comment on adjustControlSize() + // below) + videocontrolsWidth: 0, + + // Used to decide if updating the scrubber progress will make a visible + // change (ie. make it move by at least one pixel). + // The default value is set to Infinity so that any small change is + // assumed to cause a visible change until updateReflowedDimensions + // has been called. (See bug 1817604) + scrubberWidth: Infinity, + }, + + updateReflowedDimensions() { + this.reflowedDimensions.videoHeight = this.video.clientHeight; + this.reflowedDimensions.videoWidth = this.video.clientWidth; + this.reflowedDimensions.videocontrolsWidth = + this.videocontrols.clientWidth; + this.reflowedDimensions.scrubberWidth = this.scrubber.clientWidth; + }, + + /** + * adjustControlSize() considers outer dimensions of the <video>/<audio> element + * from layout, and accordingly, sets/hides the controls, and adjusts + * the width/height of the control bar. + * + * It's important to remember that for <audio>, layout (specifically, + * nsVideoFrame) rely on us to expose the intrinsic dimensions of the + * control bar to properly size the <audio> element. We interact with layout + * by: + * + * 1) When the element has a non-zero height, explicitly set the height + * of the control bar to a size between controlBarMinHeight and + * controlBarMinVisibleHeight in response. + * Note: the logic here is flawed and had caused the end height to be + * depend on its previous state, see bug 1495817. + * 2) When the element has a outer width smaller or equal to minControlBarPaddingWidth, + * explicitly set the control bar to minRequiredWidth, so that when the + * outer width is unset, the audio element could go back to minRequiredWidth. + * Otherwise, set the width of the control bar to be the current outer width. + * Note: the logic here is also flawed; when the control bar is set to + * the current outer width, it never go back when the width is unset, + * see bug 1495821. + */ + adjustControlSize() { + const minControlBarPaddingWidth = 18; + + this.fullscreenButton.isWanted = !this.controlBar.hasAttribute( + "fullscreen-unavailable" + ); + this.castingButton.isWanted = this.isCastingAvailable; + this.closedCaptionButton.isWanted = this.isClosedCaptionAvailable; + this.volumeStack.isWanted = !this.muteButton.hasAttribute("noAudio"); + + let minRequiredWidth = this.prioritizedControls + .filter(control => control && control.isWanted) + .reduce( + (accWidth, cc) => accWidth + cc.minWidth, + minControlBarPaddingWidth + ); + // Skip the adjustment in case the stylesheets haven't been loaded yet. + if (!minRequiredWidth) { + return; + } + + let givenHeight = this.reflowedDimensions.videoHeight; + let videoWidth = + (this.isAudioOnly + ? this.reflowedDimensions.videocontrolsWidth + : this.reflowedDimensions.videoWidth) || minRequiredWidth; + let videoHeight = this.isAudioOnly + ? this.controlBarMinHeight + : givenHeight; + let videocontrolsWidth = this.reflowedDimensions.videocontrolsWidth; + + let widthUsed = minControlBarPaddingWidth; + let preventAppendControl = false; + + for (let [index, control] of this.prioritizedControls.entries()) { + // The "durationSpan" element is disconnected from the document during l10n so + // we check if our reference to "durationSpan" is the connected one and if not we + // replace it with the correct one + if (control.id === "durationSpan" && !control.isConnected) { + const durationSpan = this.durationSpan; + if (durationSpan) { + this.defineControlProperties(durationSpan); + this.prioritizedControls[index] = durationSpan; + control = durationSpan; + } + } + if (!control.isWanted) { + control.hiddenByAdjustment = true; + continue; + } + + control.hiddenByAdjustment = + preventAppendControl || widthUsed + control.minWidth > videoWidth; + + if (control.hiddenByAdjustment) { + preventAppendControl = true; + } else { + widthUsed += control.minWidth; + } + } + + // Use flexible spacer to separate controls when scrubber is hidden. + // As long as muteButton hidden, which means only play button presents, + // hide spacer and make playButton centered. + this.controlBarSpacer.hidden = + !this.scrubberStack.hidden || this.muteButton.hidden; + + // Since the size of videocontrols is expanded with controlBar in <audio>, we + // should fix the dimensions in order not to recursively trigger reflow afterwards. + if (this.isAudioTag) { + if (givenHeight) { + // The height of controlBar should be capped with the bounds between controlBarMinHeight + // and controlBarMinVisibleHeight. + let controlBarHeight = Math.max( + Math.min(givenHeight, this.controlBarMinHeight), + this.controlBarMinVisibleHeight + ); + this.controlBar.style.height = `${controlBarHeight}px`; + } + // Bug 1367875: Set minimum required width to controlBar if the given size is smaller than padding. + // This can help us expand the control and restore to the default size the next time we need + // to adjust the sizing. + if (videocontrolsWidth <= minControlBarPaddingWidth) { + this.controlBar.style.width = `${minRequiredWidth}px`; + } else { + this.controlBar.style.width = `${videoWidth}px`; + } + return; + } + + if ( + videoHeight < this.controlBarMinHeight || + widthUsed === minControlBarPaddingWidth + ) { + this.controlBar.setAttribute("size", "hidden"); + this.controlBar.hiddenByAdjustment = true; + } else { + this.controlBar.removeAttribute("size"); + this.controlBar.hiddenByAdjustment = false; + } + + // Adjust clickToPlayButton size. + const minVideoSideLength = Math.min(videoWidth, videoHeight); + const clickToPlayViewRatio = 0.15; + const clickToPlayScaledSize = Math.max( + this.clickToPlay.minWidth, + minVideoSideLength * clickToPlayViewRatio + ); + + if ( + clickToPlayScaledSize >= videoWidth || + clickToPlayScaledSize + this.controlBarMinHeight / 2 >= + videoHeight / 2 + ) { + this.clickToPlay.hiddenByAdjustment = true; + } else { + if ( + this.clickToPlay.hidden && + !this.video.played.length && + this.video.paused + ) { + this.clickToPlay.hiddenByAdjustment = false; + } + this.clickToPlay.style.width = `${clickToPlayScaledSize}px`; + this.clickToPlay.style.height = `${clickToPlayScaledSize}px`; + } + }, + + get pipToggleEnabled() { + return ( + this.prefs[ + "media.videocontrols.picture-in-picture.video-toggle.enabled" + ] && this.prefs["media.videocontrols.picture-in-picture.enabled"] + ); + }, + + get positionDurationBox() { + return this.shadowRoot.getElementById("positionDurationBox"); + }, + + get durationSpan() { + return this.positionDurationBox?.getElementsByTagName("span")[0]; + }, + + init(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.video = this.installReflowCallValidator(shadowRoot.host); + this.videocontrols = this.installReflowCallValidator( + shadowRoot.firstChild + ); + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + this.prefs = prefs; + + this.controlsContainer = + this.shadowRoot.getElementById("controlsContainer"); + this.statusIcon = this.shadowRoot.getElementById("statusIcon"); + this.controlBar = this.shadowRoot.getElementById("controlBar"); + this.playButton = this.shadowRoot.getElementById("playButton"); + this.controlBarSpacer = + this.shadowRoot.getElementById("controlBarSpacer"); + this.muteButton = this.shadowRoot.getElementById("muteButton"); + this.volumeStack = this.shadowRoot.getElementById("volumeStack"); + this.volumeControl = this.shadowRoot.getElementById("volumeControl"); + this.progressBar = this.shadowRoot.getElementById("progressBar"); + this.bufferBar = this.shadowRoot.getElementById("bufferBar"); + this.bufferA11yVal = this.shadowRoot.getElementById("bufferA11yVal"); + this.scrubberStack = this.shadowRoot.getElementById("scrubberStack"); + this.scrubber = this.shadowRoot.getElementById("scrubber"); + this.durationLabel = this.shadowRoot.getElementById("durationLabel"); + this.positionLabel = this.shadowRoot.getElementById("positionLabel"); + this.statusOverlay = this.shadowRoot.getElementById("statusOverlay"); + this.controlsOverlay = + this.shadowRoot.getElementById("controlsOverlay"); + this.pictureInPictureOverlay = this.shadowRoot.getElementById( + "pictureInPictureOverlay" + ); + this.controlsSpacer = this.shadowRoot.getElementById("controlsSpacer"); + this.clickToPlay = this.shadowRoot.getElementById("clickToPlay"); + this.fullscreenButton = + this.shadowRoot.getElementById("fullscreenButton"); + this.castingButton = this.shadowRoot.getElementById("castingButton"); + this.closedCaptionButton = this.shadowRoot.getElementById( + "closedCaptionButton" + ); + this.textTrackList = this.shadowRoot.getElementById("textTrackList"); + this.textTrackListContainer = this.shadowRoot.getElementById( + "textTrackListContainer" + ); + this.pictureInPictureToggle = this.shadowRoot.getElementById( + "pictureInPictureToggle" + ); + + let isMobile = this.window.navigator.appVersion.includes("Android"); + if (isMobile) { + this.controlsContainer.classList.add("mobile"); + } + + // TODO: Switch to touch controls on touch-based desktops (bug 1447547) + this.isTouchControls = isMobile; + if (this.isTouchControls) { + this.controlsContainer.classList.add("touch"); + } + + // XXX: Calling getComputedStyle() here by itself doesn't cause any reflow, + // but there is no guard proventing accessing any properties and methods + // of this saved CSSStyleDeclaration instance that could trigger reflow. + this.controlBarComputedStyles = this.window.getComputedStyle( + this.controlBar + ); + + // Hide and show control in certain order. + this.prioritizedControls = [ + this.playButton, + this.muteButton, + this.fullscreenButton, + this.castingButton, + this.closedCaptionButton, + this.positionDurationBox, + this.scrubberStack, + this.durationSpan, + this.volumeStack, + ]; + + this.isAudioOnly = this.isAudioTag; + this.setupInitialState(); + this.setupNewLoadState(); + this.initTextTracks(); + + // Use the handleEvent() callback for all media events. + // Only the "error" event listener must capture, so that it can trap error + // events from <source> children, which don't bubble. But we use capture + // for all events in order to simplify the event listener add/remove. + for (let event of this.videoEvents) { + this.video.addEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } + + this.controlsEvents = [ + { el: this.muteButton, type: "click" }, + { el: this.castingButton, type: "click" }, + { el: this.closedCaptionButton, type: "click" }, + { el: this.fullscreenButton, type: "click" }, + { el: this.playButton, type: "click" }, + { el: this.clickToPlay, type: "click" }, + + // On touch videocontrols, tapping controlsSpacer should show/hide + // the control bar, instead of playing the video or toggle fullscreen. + { el: this.controlsSpacer, type: "click", nonTouchOnly: true }, + { el: this.controlsSpacer, type: "dblclick", nonTouchOnly: true }, + + { el: this.textTrackList, type: "click" }, + + { el: this.videocontrols, type: "resizevideocontrols" }, + + { el: this.document, type: "fullscreenchange" }, + { el: this.video, type: "keypress", capture: true }, + + // Prevent any click event within media controls from dispatching through to video. + { el: this.videocontrols, type: "click", mozSystemGroup: false }, + + // prevent dragging of controls image (bug 517114) + { el: this.videocontrols, type: "dragstart" }, + + { el: this.scrubber, type: "input" }, + { el: this.scrubber, type: "change" }, + // add mouseup listener additionally to handle the case that `change` event + // isn't fired when the input value before/after dragging are the same. (bug 1328061) + { el: this.scrubber, type: "mouseup" }, + { el: this.volumeControl, type: "input" }, + { el: this.video.textTracks, type: "addtrack" }, + { el: this.video.textTracks, type: "removetrack" }, + { el: this.video.textTracks, type: "change" }, + + { el: this.video, type: "media-videoCasting", touchOnly: true }, + + { el: this.controlBar, type: "focusin" }, + { el: this.scrubber, type: "mousedown" }, + { el: this.volumeControl, type: "mousedown" }, + ]; + + for (let { + el, + type, + nonTouchOnly = false, + touchOnly = false, + mozSystemGroup = true, + capture = false, + } of this.controlsEvents) { + if ( + (this.isTouchControls && nonTouchOnly) || + (!this.isTouchControls && touchOnly) + ) { + continue; + } + el.addEventListener(type, this, { mozSystemGroup, capture }); + } + + this.log("--- videocontrols initialized ---"); + }, + }; + + this.TouchUtils = { + videocontrols: null, + video: null, + controlsTimer: null, + controlsTimeout: 5000, + + get visible() { + return ( + !this.Utils.controlBar.hasAttribute("fadeout") && + !this.Utils.controlBar.hidden + ); + }, + + firstShow: false, + + toggleControls() { + if (!this.Utils.dynamicControls || !this.visible) { + this.showControls(); + } else { + this.delayHideControls(0); + } + }, + + showControls() { + if (this.Utils.dynamicControls) { + this.Utils.startFadeIn(this.Utils.controlBar); + this.delayHideControls(this.controlsTimeout); + } + }, + + clearTimer() { + if (this.controlsTimer) { + this.window.clearTimeout(this.controlsTimer); + this.controlsTimer = null; + } + }, + + delayHideControls(aTimeout) { + this.clearTimer(); + this.controlsTimer = this.window.setTimeout( + () => this.hideControls(), + aTimeout + ); + }, + + hideControls() { + if (!this.Utils.dynamicControls) { + return; + } + this.Utils.startFadeOut(this.Utils.controlBar); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "click": + switch (aEvent.currentTarget) { + case this.Utils.playButton: + if (!this.video.paused) { + this.delayHideControls(0); + } else { + this.showControls(); + } + break; + case this.Utils.muteButton: + this.delayHideControls(this.controlsTimeout); + break; + } + break; + case "touchstart": + this.clearTimer(); + break; + case "touchend": + this.delayHideControls(this.controlsTimeout); + break; + case "mouseup": + if (aEvent.originalTarget == this.Utils.controlsSpacer) { + if (this.firstShow) { + this.Utils.video.play(); + this.firstShow = false; + } + this.toggleControls(); + } + + break; + } + }, + + terminate() { + try { + for (let { el, type, mozSystemGroup = true } of this.controlsEvents) { + el.removeEventListener(type, this, { mozSystemGroup }); + } + } catch (ex) {} + + this.clearTimer(); + }, + + init(shadowRoot, utils) { + this.Utils = utils; + this.videocontrols = this.Utils.videocontrols; + this.video = this.Utils.video; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.controlsEvents = [ + { el: this.Utils.playButton, type: "click" }, + { el: this.Utils.scrubber, type: "touchstart" }, + { el: this.Utils.scrubber, type: "touchend" }, + { el: this.Utils.muteButton, type: "click" }, + { el: this.Utils.controlsSpacer, type: "mouseup" }, + ]; + + for (let { el, type, mozSystemGroup = true } of this.controlsEvents) { + el.addEventListener(type, this, { mozSystemGroup }); + } + + // The first time the controls appear we want to just display + // a play button that does not fade away. The firstShow property + // makes that happen. But because of bug 718107 this init() method + // may be called again when we switch in or out of fullscreen + // mode. So we only set firstShow if we're not autoplaying and + // if we are at the beginning of the video and not already playing + if ( + !this.video.autoplay && + this.Utils.dynamicControls && + this.video.paused && + this.video.currentTime === 0 + ) { + this.firstShow = true; + } + + // If the video is not at the start, then we probably just + // transitioned into or out of fullscreen mode, and we don't want + // the controls to remain visible. this.controlsTimeout is a full + // 5s, which feels too long after the transition. + if (this.video.currentTime !== 0) { + this.delayHideControls(this.Utils.HIDE_CONTROLS_TIMEOUT_MS); + } + }, + }; + + this.Utils.init(this.shadowRoot, this.prefs); + if (this.Utils.isTouchControls) { + this.TouchUtils.init(this.shadowRoot, this.Utils); + } + this.shadowRoot.firstChild.dispatchEvent( + new this.window.CustomEvent("VideoBindingAttached") + ); + + this._setupEventListeners(); + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <link rel="stylesheet" href="chrome://global/skin/media/pipToggle.css" /> + + <div id="controlsContainer" class="controlsContainer" role="none"> + <div id="statusOverlay" class="statusOverlay stackItem" hidden="true"> + <div id="statusIcon" class="statusIcon"></div> + <bdi class="statusLabel" id="errorAborted" data-l10n-id="videocontrols-error-aborted"></bdi> + <bdi class="statusLabel" id="errorNetwork" data-l10n-id="videocontrols-error-network"></bdi> + <bdi class="statusLabel" id="errorDecode" data-l10n-id="videocontrols-error-decode"></bdi> + <bdi class="statusLabel" id="errorSrcNotSupported" data-l10n-id="videocontrols-error-src-not-supported"></bdi> + <bdi class="statusLabel" id="errorNoSource" data-l10n-id="videocontrols-error-no-source"></bdi> + <bdi class="statusLabel" id="errorGeneric" data-l10n-id="videocontrols-error-generic"></bdi> + </div> + + <div id="pictureInPictureOverlay" class="pictureInPictureOverlay stackItem" status="pictureInPicture" hidden="true"> + <div class="statusIcon" type="pictureInPicture"></div> + <bdi class="statusLabel" id="pictureInPicture" data-l10n-id="videocontrols-status-picture-in-picture"></bdi> + </div> + + <div id="controlsOverlay" class="controlsOverlay stackItem" role="none"> + <div class="controlsSpacerStack"> + <div id="controlsSpacer" class="controlsSpacer stackItem" role="none"></div> + <button id="clickToPlay" class="clickToPlay" hidden="true"></button> + </div> + + <button id="pictureInPictureToggle" class="pip-wrapper" position="left" hidden="true"> + <div class="pip-small clickable"></div> + <div class="pip-expanded clickable"> + <span class="pip-icon-label clickable"> + <span class="pip-icon"></span> + <span class="pip-label" data-l10n-id="videocontrols-picture-in-picture-toggle-label2"></span> + </span> + <div class="pip-explainer clickable" data-l10n-id="videocontrols-picture-in-picture-explainer3"></div> + </div> + <div class="pip-icon clickable"></div> + </button> + + <div id="controlBar" class="controlBar" role="none" hidden="true"> + <button id="playButton" + class="button playButton" + tabindex="-1"/> + <div id="scrubberStack" class="scrubberStack progressContainer" role="none"> + <div class="progressBackgroundBar stackItem" role="none"> + <div class="progressStack" role="none"> + <progress id="bufferBar" class="bufferBar" value="0" max="100" aria-hidden="true"></progress> + <span class="a11y-only" role="status" aria-live="off"> + <span data-l10n-id="videocontrols-buffer-bar-label"></span> + <span id="bufferA11yVal"></span> + </span> + <progress id="progressBar" class="progressBar" value="0" max="100" aria-hidden="true"></progress> + </div> + </div> + <input type="range" id="scrubber" class="scrubber" tabindex="-1" data-l10n-attrs="aria-valuetext" value="0"/> + </div> + <bdi id="positionLabel" class="positionLabel" role="presentation"></bdi> + <bdi id="durationLabel" class="durationLabel" role="presentation"></bdi> + <bdi id="positionDurationBox" class="positionDurationBox" aria-hidden="true"> + <span id="durationSpan" class="duration" role="none" + data-l10n-name="position-duration-format"></span> + </bdi> + <div id="controlBarSpacer" class="controlBarSpacer" hidden="true" role="none"></div> + <button id="muteButton" + class="button muteButton" + tabindex="-1"/> + <div id="volumeStack" class="volumeStack progressContainer" role="none"> + <input type="range" id="volumeControl" class="volumeControl" min="0" max="100" step="1" tabindex="-1" + data-l10n-id="videocontrols-volume-control"/> + </div> + <button id="castingButton" class="button castingButton" + data-l10n-id="videocontrols-casting-button-label"/> + <button id="closedCaptionButton" class="button closedCaptionButton" aria-controls="textTrackList" + aria-haspopup="menu" aria-expanded="false" data-l10n-id="videocontrols-closed-caption-button"/> + <div id="textTrackListContainer" class="textTrackListContainer" hidden="true" role="presentation"> + <div id="textTrackList" role="menu" class="textTrackList" + data-l10n-id="videocontrols-closed-caption-off" data-l10n-attrs="offlabel"/> + </div> + <button id="fullscreenButton" + class="button fullscreenButton"/> + </div> + </div> + </div> + </div>`, + "application/xml" + ); + this.l10n = new this.window.DOMLocalization( + ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"], + true + ); + this.l10n.connectRoot(this.shadowRoot); + if (this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]) { + // Make all of the individual controls tabbable. + for (const el of parserDoc.documentElement.querySelectorAll( + '[tabindex="-1"]' + )) { + el.removeAttribute("tabindex"); + } + } + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + this.l10n.translateRoots(); + } + + elementStateMatches(element) { + let elementInPiP = VideoControlsWidget.isPictureInPictureVideo(element); + return this.isShowingPictureInPictureMessage == elementInPiP; + } + + teardown() { + this.Utils.terminate(); + this.TouchUtils.terminate(); + this.l10n.disconnectRoot(this.shadowRoot); + this.l10n = null; + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + this.Utils.updatePictureInPictureToggleDisplay(); + } + + _setupEventListeners() { + this.shadowRoot.firstChild.addEventListener("mouseover", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseInOut(event); + } + }); + + this.shadowRoot.firstChild.addEventListener("mouseout", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseInOut(event); + } + }); + + this.shadowRoot.firstChild.addEventListener("mousemove", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseMove(event); + } + }); + } +}; + +this.NoControlsMobileImplWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + videoEvents: ["play", "playing"], + videoControlEvents: ["MozNoControlsBlockedVideo"], + terminate() { + for (let event of this.videoEvents) { + try { + this.video.removeEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } catch (ex) {} + } + + for (let event of this.videoControlEvents) { + try { + this.videocontrols.removeEventListener(event, this); + } catch (ex) {} + } + + try { + this.clickToPlay.removeEventListener("click", this, { + mozSystemGroup: true, + }); + } catch (ex) {} + }, + + hasError() { + return ( + this.video.error != null || + this.video.networkState == this.video.NETWORK_NO_SOURCE + ); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "play": + this.noControlsOverlay.hidden = true; + break; + case "playing": + this.noControlsOverlay.hidden = true; + break; + case "MozNoControlsBlockedVideo": + this.blockedVideoHandler(); + break; + case "click": + this.clickToPlayClickHandler(aEvent); + break; + } + }, + + blockedVideoHandler() { + if (this.hasError()) { + this.noControlsOverlay.hidden = true; + return; + } + this.noControlsOverlay.hidden = false; + }, + + clickToPlayClickHandler(e) { + if (e.button != 0) { + return; + } + + this.noControlsOverlay.hidden = true; + this.video.play(); + }, + + init(shadowRoot) { + this.shadowRoot = shadowRoot; + this.video = shadowRoot.host; + this.videocontrols = shadowRoot.firstChild; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.controlsContainer = + this.shadowRoot.getElementById("controlsContainer"); + this.clickToPlay = this.shadowRoot.getElementById("clickToPlay"); + this.noControlsOverlay = + this.shadowRoot.getElementById("controlsContainer"); + + let isMobile = this.window.navigator.appVersion.includes("Android"); + if (isMobile) { + this.controlsContainer.classList.add("mobile"); + } + + // TODO: Switch to touch controls on touch-based desktops (bug 1447547) + this.isTouchControls = isMobile; + if (this.isTouchControls) { + this.controlsContainer.classList.add("touch"); + } + + this.clickToPlay.addEventListener("click", this, { + mozSystemGroup: true, + }); + + for (let event of this.videoEvents) { + this.video.addEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } + + for (let event of this.videoControlEvents) { + this.videocontrols.addEventListener(event, this); + } + }, + }; + this.Utils.init(this.shadowRoot); + this.Utils.video.dispatchEvent( + new this.window.CustomEvent("MozNoControlsVideoBindingAttached") + ); + } + + elementStateMatches(element) { + return true; + } + + teardown() { + this.Utils.terminate(); + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <div id="controlsContainer" class="controlsContainer" role="none" hidden="true"> + <div class="controlsOverlay stackItem"> + <div class="controlsSpacerStack"> + <button id="clickToPlay" class="clickToPlay"></button> + </div> + </div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + } +}; + +this.NoControlsPictureInPictureImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + } + + elementStateMatches(element) { + return true; + } + + teardown() {} + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <div id="controlsContainer" class="controlsContainer" role="none"> + <div class="pictureInPictureOverlay stackItem" status="pictureInPicture"> + <div id="statusIcon" class="statusIcon" type="pictureInPicture"></div> + <bdi class="statusLabel" id="pictureInPicture" data-l10n-id="videocontrols-status-picture-in-picture"></bdi> + </div> + <div class="controlsOverlay stackItem"></div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + this.l10n = new this.window.DOMLocalization([ + "branding/brand.ftl", + "toolkit/global/videocontrols.ftl", + ]); + this.l10n.connectRoot(this.shadowRoot); + this.l10n.translateRoots(); + } +}; + +this.NoControlsDesktopImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + this.prefs = prefs; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + handleEvent(event) { + switch (event.type) { + case "fullscreenchange": { + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } else { + this.videocontrols.removeAttribute("inDOMFullscreen"); + } + break; + } + case "resizevideocontrols": { + this.updateReflowedDimensions(); + this.updatePictureInPictureToggleDisplay(); + break; + } + case "durationchange": + // Intentional fall-through + case "emptied": + // Intentional fall-through + case "loadedmetadata": { + this.updatePictureInPictureToggleDisplay(); + break; + } + } + }, + + updatePictureInPictureToggleDisplay() { + if ( + this.pipToggleEnabled && + VideoControlsWidget.shouldShowPictureInPictureToggle( + this.prefs, + this.video, + this.reflowedDimensions + ) + ) { + this.pictureInPictureToggle.hidden = false; + VideoControlsWidget.setupToggle( + this.prefs, + this.pictureInPictureToggle, + this.reflowedDimensions + ); + } else { + this.pictureInPictureToggle.hidden = true; + } + }, + + init(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.video = shadowRoot.host; + this.videocontrols = shadowRoot.firstChild; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.pictureInPictureToggle = this.shadowRoot.getElementById( + "pictureInPictureToggle" + ); + + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } + + // Default the Picture-in-Picture toggle button to being hidden. We might unhide it + // later if we determine that this video is qualified to show it. + this.pictureInPictureToggle.hidden = true; + + if (this.video.readyState >= this.video.HAVE_METADATA) { + // According to the spec[1], at the HAVE_METADATA (or later) state, we know + // the video duration and dimensions, which means we can calculate whether or + // not to show the Picture-in-Picture toggle now. + // + // [1]: https://www.w3.org/TR/html50/embedded-content-0.html#dom-media-have_metadata + this.updatePictureInPictureToggleDisplay(); + } + + this.document.addEventListener("fullscreenchange", this, { + capture: true, + }); + + this.video.addEventListener("emptied", this); + this.video.addEventListener("loadedmetadata", this); + this.video.addEventListener("durationchange", this); + this.videocontrols.addEventListener("resizevideocontrols", this); + }, + + terminate() { + this.document.removeEventListener("fullscreenchange", this, { + capture: true, + }); + + this.video.removeEventListener("emptied", this); + this.video.removeEventListener("loadedmetadata", this); + this.video.removeEventListener("durationchange", this); + this.videocontrols.removeEventListener("resizevideocontrols", this); + }, + + updateReflowedDimensions() { + this.reflowedDimensions.videoHeight = this.video.clientHeight; + this.reflowedDimensions.videoWidth = this.video.clientWidth; + this.reflowedDimensions.videocontrolsWidth = + this.videocontrols.clientWidth; + }, + + reflowedDimensions: { + // Set the dimensions to intrinsic <video> dimensions before the first + // update. + videoHeight: 150, + videoWidth: 300, + videocontrolsWidth: 0, + }, + + get pipToggleEnabled() { + return ( + this.prefs[ + "media.videocontrols.picture-in-picture.video-toggle.enabled" + ] && this.prefs["media.videocontrols.picture-in-picture.enabled"] + ); + }, + }; + this.Utils.init(this.shadowRoot, this.prefs); + } + + elementStateMatches(element) { + return true; + } + + teardown() { + this.Utils.terminate(); + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + this.Utils.updatePictureInPictureToggleDisplay(); + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <link rel="stylesheet" href="chrome://global/skin/media/pipToggle.css" /> + + <div id="controlsContainer" class="controlsContainer" role="none"> + <div class="controlsOverlay stackItem"> + <button id="pictureInPictureToggle" class="pip-wrapper" position="left" hidden="true"> + <div class="pip-small clickable"></div> + <div class="pip-expanded clickable"> + <span class="pip-icon-label clickable"> + <span class="pip-icon"></span> + <span class="pip-label" data-l10n-id="videocontrols-picture-in-picture-toggle-label2"></span> + </span> + <div class="pip-explainer clickable" data-l10n-id="videocontrols-picture-in-picture-explainer3"></div> + </div> + <div class="pip-icon"></div> + </button> + </div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + this.l10n = new this.window.DOMLocalization([ + "branding/brand.ftl", + "toolkit/global/videocontrols.ftl", + ]); + this.l10n.connectRoot(this.shadowRoot); + this.l10n.translateRoots(); + } +}; diff --git a/toolkit/content/widgets/wizard.js b/toolkit/content/widgets/wizard.js new file mode 100644 index 0000000000..6eb4bcb517 --- /dev/null +++ b/toolkit/content/widgets/wizard.js @@ -0,0 +1,651 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + // Note: MozWizard currently supports adding, but not removing MozWizardPage + // children. + class MozWizard extends MozXULElement { + constructor() { + super(); + + // About this._accessMethod: + // There are two possible access methods: "sequential" and "random". + // "sequential" causes the MozWizardPage's to be displayed in the order + // that they are added to the DOM. + // The "random" method name is a bit misleading since the pages aren't + // displayed in a random order. Instead, each MozWizardPage must have + // a "next" attribute containing the id of the MozWizardPage that should + // be loaded next. + this._accessMethod = null; + this._currentPage = null; + this._canAdvance = true; + this._canRewind = false; + this._hasLoaded = false; + this._hasStarted = false; // Whether any MozWizardPage has been shown yet + this._wizardButtonsReady = false; + this.pageCount = 0; + this._pageStack = []; + + this._bundle = Services.strings.createBundle( + "chrome://global/locale/wizard.properties" + ); + + this.addEventListener( + "keypress", + event => { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + this._hitEnter(event); + } else if ( + event.keyCode == KeyEvent.DOM_VK_ESCAPE && + !event.defaultPrevented + ) { + this.cancel(); + } + }, + { mozSystemGroup: true } + ); + + /* + XXX(ntim): We import button.css here for the wizard-buttons children + This won't be needed after bug 1624888. + */ + this.attachShadow({ mode: "open" }).appendChild( + MozXULElement.parseXULToFragment(` + <html:link rel="stylesheet" href="chrome://global/skin/button.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/wizard.css"/> + <hbox class="wizard-header"></hbox> + <html:slot name="wizardpage" class="wizard-page-box" style="display: grid; flex: 1;"/> + <html:slot/> + <wizard-buttons class="wizard-buttons"></wizard-buttons> + `) + ); + this.initializeAttributeInheritance(); + + this._wizardButtons = this.shadowRoot.querySelector(".wizard-buttons"); + + this._wizardHeader = this.shadowRoot.querySelector(".wizard-header"); + this._wizardHeader.appendChild( + MozXULElement.parseXULToFragment( + AppConstants.platform == "macosx" + ? `<stack class="wizard-header-stack" flex="1"> + <vbox class="wizard-header-box-1"> + <vbox class="wizard-header-box-text"> + <label class="wizard-header-label"/> + </vbox> + </vbox> + <hbox class="wizard-header-box-icon"> + <spacer flex="1"/> + <image class="wizard-header-icon"/> + </hbox> + </stack>` + : `<hbox class="wizard-header-box-1" flex="1"> + <vbox class="wizard-header-box-text" flex="1"> + <label class="wizard-header-label"/> + <label class="wizard-header-description"/> + </vbox> + <image class="wizard-header-icon"/> + </hbox>` + ) + ); + } + + static get inheritedAttributes() { + return { + ".wizard-buttons": "pagestep,firstpage,lastpage", + }; + } + + connectedCallback() { + if (document.l10n) { + document.l10n.connectRoot(this.shadowRoot); + } + document.documentElement.setAttribute("role", "dialog"); + document.documentElement.classList.add("wizard-window"); + this._maybeStartWizard(); + + window.addEventListener("close", event => { + if (this.cancel()) { + event.preventDefault(); + } + }); + + // Give focus to the first focusable element in the wizard, do it after + // onload completes, see bug 103197. + window.addEventListener("load", () => + window.setTimeout(() => { + this._hasLoaded = true; + if (!document.commandDispatcher.focusedElement) { + document.commandDispatcher.advanceFocusIntoSubtree(this); + } + try { + let button = this._wizardButtons.defaultButton; + if (button) { + window.notifyDefaultButtonLoaded(button); + } + } catch (e) {} + }, 0) + ); + } + + set title(val) { + document.title = val; + } + + get title() { + return document.title; + } + + set canAdvance(val) { + this.getButton("next").disabled = !val; + this._canAdvance = val; + } + + get canAdvance() { + return this._canAdvance; + } + + set canRewind(val) { + this.getButton("back").disabled = !val; + this._canRewind = val; + } + + get canRewind() { + return this._canRewind; + } + + get pageStep() { + return this._pageStack.length; + } + + get wizardPages() { + return this.getElementsByTagNameNS(XUL_NS, "wizardpage"); + } + + set currentPage(val) { + if (!val) { + return; + } + + this._currentPage?.classList.remove("selected"); + val.classList.add("selected"); + + this._currentPage = val; + + // Setting this attribute allows wizard's clients to dynamically + // change the styles of each page based on purpose of the page. + this.setAttribute("currentpageid", val.pageid); + + this._initCurrentPage(); + + this._advanceFocusToPage(val); + + this._fireEvent(val, "pageshow"); + } + + get currentPage() { + return this._currentPage; + } + + set pageIndex(val) { + if (val < 0 || val >= this.pageCount) { + return; + } + + var page = this.wizardPages[val]; + this._pageStack[this._pageStack.length - 1] = page; + this.currentPage = page; + } + + get pageIndex() { + return this._currentPage ? this._currentPage.pageIndex : -1; + } + + get onFirstPage() { + return this._pageStack.length == 1; + } + + get onLastPage() { + var cp = this.currentPage; + return ( + cp && + ((this._accessMethod == "sequential" && + cp.pageIndex == this.pageCount - 1) || + (this._accessMethod == "random" && cp.next == "")) + ); + } + + getButton(aDlgType) { + return this._wizardButtons.getButton(aDlgType); + } + + getPageById(aPageId) { + var els = this.getElementsByAttribute("pageid", aPageId); + return els.item(0); + } + + extra1() { + if (this.currentPage) { + this._fireEvent(this.currentPage, "extra1"); + } + } + + extra2() { + if (this.currentPage) { + this._fireEvent(this.currentPage, "extra2"); + } + } + + rewind() { + if (!this.canRewind) { + return; + } + + if (this.currentPage && !this._fireEvent(this.currentPage, "pagehide")) { + return; + } + + if ( + this.currentPage && + !this._fireEvent(this.currentPage, "pagerewound") + ) { + return; + } + + if (!this._fireEvent(this, "wizardback")) { + return; + } + + this._pageStack.pop(); + this.currentPage = this._pageStack[this._pageStack.length - 1]; + this.setAttribute("pagestep", this._pageStack.length); + } + + advance(aPageId) { + if (!this.canAdvance) { + return; + } + + if (this.currentPage && !this._fireEvent(this.currentPage, "pagehide")) { + return; + } + + if ( + this.currentPage && + !this._fireEvent(this.currentPage, "pageadvanced") + ) { + return; + } + + if (this.onLastPage && !aPageId) { + if (this._fireEvent(this, "wizardfinish")) { + window.setTimeout(function () { + window.close(); + }, 1); + } + } else { + if (!this._fireEvent(this, "wizardnext")) { + return; + } + + let page; + if (aPageId) { + page = this.getPageById(aPageId); + } else if (this.currentPage) { + if (this._accessMethod == "random") { + page = this.getPageById(this.currentPage.next); + } else { + page = this.wizardPages[this.currentPage.pageIndex + 1]; + } + } else { + page = this.wizardPages[0]; + } + + if (page) { + this._pageStack.push(page); + this.setAttribute("pagestep", this._pageStack.length); + + this.currentPage = page; + } + } + } + + goTo(aPageId) { + var page = this.getPageById(aPageId); + if (page) { + this._pageStack[this._pageStack.length - 1] = page; + this.currentPage = page; + } + } + + cancel() { + if (!this._fireEvent(this, "wizardcancel")) { + return true; + } + + window.close(); + window.setTimeout(function () { + window.close(); + }, 1); + return false; + } + + _initCurrentPage() { + this.canRewind = !this.onFirstPage; + this.setAttribute("firstpage", String(this.onFirstPage)); + if (AppConstants.platform == "linux") { + this.getButton("back").hidden = this.onFirstPage; + } + + if (this.onLastPage) { + this.canAdvance = true; + this.setAttribute("lastpage", "true"); + } else { + this.setAttribute("lastpage", "false"); + } + + this._adjustWizardHeader(); + this._wizardButtons.onPageChange(); + } + + _advanceFocusToPage(aPage) { + if (!this._hasLoaded) { + return; + } + + // XXX: it'd be correct to advance focus into the panel, however we can't do + // it until bug 1558990 is fixed, so moving the focus into a wizard itsef + // as a workaround - it's same behavior but less optimal. + document.commandDispatcher.advanceFocusIntoSubtree(this); + + // if advanceFocusIntoSubtree tries to focus one of our + // dialog buttons, then remove it and put it on the root + var focused = document.commandDispatcher.focusedElement; + if (focused && focused.hasAttribute("dlgtype")) { + this.focus(); + } + } + + _registerPage(aPage) { + aPage.pageIndex = this.pageCount; + this.pageCount += 1; + if (!this._accessMethod) { + this._accessMethod = aPage.next == "" ? "sequential" : "random"; + } + if (!this._maybeStartWizard() && this._hasStarted) { + // If the wizard has already started, adding a page might require + // updating elements to reflect that (ex: changing the Finish button to + // the Next button). + this._initCurrentPage(); + } + } + + _onWizardButtonsReady() { + this._wizardButtonsReady = true; + this._maybeStartWizard(); + } + + _maybeStartWizard() { + if ( + !this._hasStarted && + this.isConnected && + this._wizardButtonsReady && + this.pageCount > 0 + ) { + this._hasStarted = true; + this.advance(); + return true; + } + return false; + } + + _adjustWizardHeader() { + let labelElement = this._wizardHeader.querySelector( + ".wizard-header-label" + ); + // First deal with fluent. Ideally, we'd stop supporting anything else, + // but some comm-central consumers still use DTDs. (bug 1627049). + // Removing the DTD support is bug 1627051. + if (this.currentPage.hasAttribute("data-header-label-id")) { + let id = this.currentPage.getAttribute("data-header-label-id"); + document.l10n.setAttributes(labelElement, id); + } else { + // Otherwise, make sure we remove any fluent IDs leftover: + if (labelElement.hasAttribute("data-l10n-id")) { + labelElement.removeAttribute("data-l10n-id"); + } + // And use the label attribute or the default: + var label = this.currentPage.getAttribute("label") || ""; + if (!label && this.onFirstPage && this._bundle) { + if (AppConstants.platform == "macosx") { + label = this._bundle.GetStringFromName("default-first-title-mac"); + } else { + label = this._bundle.formatStringFromName("default-first-title", [ + this.title, + ]); + } + } else if (!label && this.onLastPage && this._bundle) { + if (AppConstants.platform == "macosx") { + label = this._bundle.GetStringFromName("default-last-title-mac"); + } else { + label = this._bundle.formatStringFromName("default-last-title", [ + this.title, + ]); + } + } + labelElement.textContent = label; + } + let headerDescEl = this._wizardHeader.querySelector( + ".wizard-header-description" + ); + if (headerDescEl) { + headerDescEl.textContent = this.currentPage.getAttribute("description"); + } + } + + _hitEnter(evt) { + if (!evt.defaultPrevented) { + this.advance(); + } + } + + _fireEvent(aTarget, aType) { + var event = document.createEvent("Events"); + event.initEvent(aType, true, true); + + // handle dom event handlers + return aTarget.dispatchEvent(event); + } + } + + customElements.define("wizard", MozWizard); + + class MozWizardPage extends MozXULElement { + constructor() { + super(); + this.pageIndex = -1; + } + connectedCallback() { + this.setAttribute("slot", "wizardpage"); + + let wizard = this.closest("wizard"); + if (wizard) { + wizard._registerPage(this); + } + } + get pageid() { + return this.getAttribute("pageid"); + } + set pageid(val) { + this.setAttribute("pageid", val); + } + get next() { + return this.getAttribute("next"); + } + set next(val) { + this.setAttribute("next", val); + this.parentNode._accessMethod = "random"; + } + } + + customElements.define("wizardpage", MozWizardPage); + + class MozWizardButtons extends MozXULElement { + connectedCallback() { + this._wizard = this.getRootNode().host; + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + MozXULElement.insertFTLIfNeeded("toolkit/global/wizard.ftl"); + + this._wizardButtonDeck = this.querySelector(".wizard-next-deck"); + + this.initializeAttributeInheritance(); + + const listeners = [ + ["back", () => this._wizard.rewind()], + ["next", () => this._wizard.advance()], + ["finish", () => this._wizard.advance()], + ["cancel", () => this._wizard.cancel()], + ["extra1", () => this._wizard.extra1()], + ["extra2", () => this._wizard.extra2()], + ]; + for (let [name, listener] of listeners) { + let btn = this.getButton(name); + if (btn) { + btn.addEventListener("command", listener); + } + } + + this._wizard._onWizardButtonsReady(); + } + + static get inheritedAttributes() { + return AppConstants.platform == "macosx" + ? { + "[dlgtype='next']": "hidden=lastpage", + } + : null; + } + + static get markup() { + if (AppConstants.platform == "macosx") { + return ` + <vbox flex="1"> + <hbox class="wizard-buttons-btm"> + <button class="wizard-button" dlgtype="extra1" hidden="true"/> + <button class="wizard-button" dlgtype="extra2" hidden="true"/> + <button data-l10n-id="wizard-macos-button-cancel" + class="wizard-button" dlgtype="cancel"/> + <spacer flex="1"/> + <button data-l10n-id="wizard-macos-button-back" + class="wizard-button wizard-nav-button" dlgtype="back"/> + <button data-l10n-id="wizard-macos-button-next" + class="wizard-button wizard-nav-button" dlgtype="next" + default="true" /> + <button data-l10n-id="wizard-macos-button-finish" class="wizard-button" + dlgtype="finish" default="true" /> + </hbox> + </vbox>`; + } + + let buttons = + AppConstants.platform == "linux" + ? ` + <button data-l10n-id="wizard-linux-button-cancel" + class="wizard-button" + dlgtype="cancel"/> + <spacer style="width: 24px;"/> + <button data-l10n-id="wizard-linux-button-back" + class="wizard-button" dlgtype="back"/> + <deck class="wizard-next-deck"> + <hbox> + <button data-l10n-id="wizard-linux-button-finish" + class="wizard-button" + dlgtype="finish" default="true" flex="1"/> + </hbox> + <hbox> + <button data-l10n-id="wizard-linux-button-next" + class="wizard-button" dlgtype="next" + default="true" flex="1"/> + </hbox> + </deck>` + : ` + <button data-l10n-id="wizard-win-button-back" + class="wizard-button" dlgtype="back"/> + <deck class="wizard-next-deck"> + <hbox> + <button data-l10n-id="wizard-win-button-finish" + class="wizard-button" + dlgtype="finish" default="true" flex="1"/> + </hbox> + <hbox> + <button data-l10n-id="wizard-win-button-next" + class="wizard-button" dlgtype="next" + default="true" flex="1"/> + </hbox> + </deck> + <button data-l10n-id="wizard-win-button-cancel" + class="wizard-button" + dlgtype="cancel"/>`; + + return ` + <vbox class="wizard-buttons-box-1" flex="1"> + <separator class="wizard-buttons-separator groove"/> + <hbox class="wizard-buttons-box-2"> + <button class="wizard-button" dlgtype="extra1" hidden="true"/> + <button class="wizard-button" dlgtype="extra2" hidden="true"/> + <spacer flex="1" anonid="spacer"/> + ${buttons} + </hbox> + </vbox>`; + } + + onPageChange() { + if (AppConstants.platform == "macosx") { + this.getButton("finish").hidden = !( + this.getAttribute("lastpage") == "true" + ); + } else if (this.getAttribute("lastpage") == "true") { + this._wizardButtonDeck.selectedIndex = 0; + } else { + this._wizardButtonDeck.selectedIndex = 1; + } + } + + getButton(type) { + return this.querySelector(`[dlgtype="${type}"]`); + } + + get defaultButton() { + let buttons = this._wizardButtonDeck.selectedPanel.getElementsByTagNameNS( + XUL_NS, + "button" + ); + for (let i = 0; i < buttons.length; i++) { + if ( + buttons[i].getAttribute("default") == "true" && + !buttons[i].hidden && + !buttons[i].disabled + ) { + return buttons[i]; + } + } + return null; + } + } + + customElements.define("wizard-buttons", MozWizardButtons); +} diff --git a/toolkit/content/xul.css b/toolkit/content/xul.css new file mode 100644 index 0000000000..e8635d4525 --- /dev/null +++ b/toolkit/content/xul.css @@ -0,0 +1,806 @@ +/* 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/. */ + +/** + * Rules for everything related to XUL except scrollbars can be found in this + * file. + * + * This file should also not contain any app specific styling. Defaults for + * widgets of a particular application should be in that application's style + * sheet. For example, style definitions for browser can be found in + * browser.css. + */ + +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); /* set default namespace to XUL */ +@namespace html url("http://www.w3.org/1999/xhtml"); /* namespace for HTML elements */ + +* { + -moz-user-focus: ignore; + display: flex; + box-sizing: border-box; +} + +/* hide the content and destroy the frame */ +[hidden="true"] { + display: none; +} + +/* hide the content, but don't destroy the frames */ +[collapsed="true"] { + visibility: collapse; +} + +/* TODO: investigate unifying these two root selectors + * https://bugzilla.mozilla.org/show_bug.cgi?id=1592344 + */ +*|*:root { + --animation-easing-function: cubic-bezier(.07, .95, 0, 1); + flex: 1; + -moz-box-collapse: legacy; +} + +:root { + text-rendering: optimizeLegibility; + -moz-control-character-visibility: visible; + width: 100%; + height: 100%; + user-select: none; +} + +:root:-moz-locale-dir(rtl) { + direction: rtl; +} + +/* XUL doesn't show outlines by default */ +:focus-visible { + outline: initial; +} + +/* + * Native anonymous popups and tooltips in html are document-level, which means + * that they don't inherit from the root, so this is needed. + */ +popupgroup:-moz-native-anonymous:-moz-locale-dir(rtl), +tooltip:-moz-native-anonymous:-moz-locale-dir(rtl) { + direction: rtl; +} + +/* :::::::::: + :: Rules for 'hiding' portions of the chrome for special + :: kinds of windows (not JUST browser windows) with toolbars + ::::: */ + +*|*:root[chromehidden~="menubar"] .chromeclass-menubar, +*|*:root[chromehidden~="directories"] .chromeclass-directories, +*|*:root[chromehidden~="status"] .chromeclass-status, +*|*:root[chromehidden~="extrachrome"] .chromeclass-extrachrome, +*|*:root[chromehidden~="location"] .chromeclass-location, +*|*:root[chromehidden~="location"][chromehidden~="toolbar"] .chromeclass-toolbar, +*|*:root[chromehidden~="toolbar"] .chromeclass-toolbar-additional { + display: none; +} + +/* :::::::::: + :: Rules for forcing direction for entry and display of URIs + :: or URI elements + ::::: */ + +.uri-element { + direction: ltr !important; +} + +/****** elements that have no visual representation ******/ + +script, data, commandset, command, +broadcasterset, broadcaster, observes, +keyset, key, toolbarpalette, template, +treeitem, treeseparator, treerow, treecell { + display: none; +} + +/********** focus rules **********/ + +button, +checkbox, +menulist, +radiogroup, +richlistbox, +tree, +browser, +editor, +iframe, +label:is(.text-link, [onclick]), +tab[selected="true"]:not([ignorefocus="true"]) { + -moz-user-focus: normal; +} + +/* Avoid losing focus on tabs by keeping them focusable, since some browser + * tests rely on this. + * + * TODO(emilio): Remove this and fix the tests / front-end code: + * * browser/base/content/test/general/browser_tabfocus.js + */ +tab:focus { + -moz-user-focus: normal; +} + +/******** window & page ******/ + +window { + overflow: clip; + flex-direction: column; +} + +/******** box *******/ + +vbox { + flex-direction: column; +} + +/********** label **********/ + +label { + display: inline-block; +} + +description { + display: flow-root; +} + +label html|span.accesskey { + text-decoration: underline; + text-decoration-skip-ink: none; +} + +description:where([value]) { + /* Preserves legacy behavior, maybe could be removed */ + display: flex; +} + +label:where([value]) { + /* Preserves legacy behavior, maybe could be removed */ + display: inline-flex; +} + +:is(label, description)[value] { + white-space: nowrap; +} + +:is(label, description)[value]::before { + /* This displays the value attribute, along with the underlined + * accesskey if needed */ + content: -moz-label-content; + display: block; +} + +label[value=""]::before { + content: "\200b" !important; /* zwsp */ +} + +:is(label, description)[value][crop] { + min-width: 0; +} + +:is(label, description)[value][crop]::before { + min-width: 0; + max-width: 100%; + /* This implements crop=end */ + overflow: hidden; + text-overflow: ellipsis; +} + +/* Invert the direction to clip at the start rather than end */ +:is(label, description)[value][crop="start"]::before { + direction: rtl; + /* Mark text as ltr to preserve punctuation/numeric order */ + content: "\200e" -moz-label-content; +} + +:is(label, description)[value][crop="start"]:-moz-locale-dir(rtl)::before { + direction: ltr; + /* Mark text as rtl to preserve punctuation/numeric order */ + content: "\200f" -moz-label-content; +} + +.checkbox-label-box, +.radio-label-box { + min-width: 0; +} + +/********** toolbarbutton **********/ + +toolbarbutton { + align-items: center; + justify-content: center; +} + +toolbarbutton.tabbable { + -moz-user-focus: normal !important; +} + +toolbarbutton[crop] { + min-width: 0; +} + +.toolbarbutton-text { + display: block; + text-align: center; +} + +toolbar[mode="icons"] .toolbarbutton-text, +toolbar[mode="text"] .toolbarbutton-icon, +html|label.toolbarbutton-badge:empty { + display: none; +} + +.toolbarbutton-icon, +.toolbarbutton-text, +.toolbarbutton-badge-stack, +.toolbarbutton-menu-dropmarker, +.treecol-text, +.treecol-sortdirection, +.menubar-left, +.menubar-text, +.menu-text, +.menu-iconic-text, +.menu-iconic-highlightable-text, +.menu-iconic-left, +.menu-right, +.menu-accel-container, +.button-box { + /* Preserves legacy behavior */ + pointer-events: none; +} + +/********** button **********/ + +button { + -moz-default-appearance: button; + appearance: auto; +} + +dropmarker { + -moz-default-appearance: -moz-menulist-arrow-button; + appearance: auto; +} + +/******** browser, editor, iframe ********/ + +browser, +editor, +iframe { + display: inline; +} + +/* Allow the browser to shrink below its intrinsic size, to match legacy + * behavior */ +browser { + align-self: stretch; + justify-self: stretch; + min-height: 0; + min-width: 0; + contain: size; +} + +/*********** popup notification ************/ +popupnotification { + flex-direction: column; +} + +.popup-notification-menubutton:not([label]) { + display: none; +} + +/********** radio **********/ + +radiogroup { + flex-direction: column; +} + +/******** groupbox *********/ + +groupbox { + flex-direction: column; +} + +/******** draggable elements *********/ + +toolbar:not([nowindowdrag="true"], [customizing="true"]) { + -moz-window-dragging: drag; +} + +/* The list below is non-comprehensive and will probably need some tweaking. */ +toolbaritem, +toolbarbutton, +toolbarseparator, +button, +search-textbox, +html|input, +tab, +radio, +splitter, +menu, +menulist { + -moz-window-dragging: no-drag; +} + +titlebar { + pointer-events: auto !important; +} + +/******* toolbar *******/ + +toolbox { + flex-direction: column; +} + +@media (-moz-platform: macos) { + toolbar[type="menubar"] { + min-height: 0 !important; + border: 0 !important; + } +} + +toolbarspring { + flex: 1000 1000; +} + +/********* menu ***********/ + +menubar > menu:empty { + visibility: collapse; +} + +.menu-text { + flex: 1; +} + +/********* menupopup, panel, & tooltip ***********/ + +menupopup, +panel { + flex-direction: column; +} + +menupopup, +panel, +tooltip { + position: fixed; + -moz-top-layer: top; + width: fit-content; + height: fit-content; + /* Popups can't have overflow */ + contain: paint; + z-index: 2147483647; + text-shadow: none; +} + +tooltip { + appearance: auto; + -moz-default-appearance: tooltip; + + white-space: pre-wrap; + + background-color: InfoBackground; + color: InfoText; + font: message-box; + padding: 2px 3px; + max-width: 40em; + overflow: clip; + pointer-events: none; +} + +tooltip:not([position]) { + margin-top: 21px; +} + +/** + * It's important that these styles are in a UA sheet, because the default + * tooltip is native anonymous content + */ +@media (-moz-platform: linux) { + tooltip { + padding: 6px 10px; /* Matches Adwaita. */ + line-height: 1.4; /* For Noto Sans; note that 1.2 may clip descenders. */ + } +} + +@media (-moz-platform: macos) { + tooltip { + padding: 2px 6px; /* Matches native metrics. */ + } +} + +@media (-moz-platform: windows) { + tooltip { + appearance: none; + border: 1px solid; + } + + /* TODO(emilio): Probably make InfoText/InfoBackground do the right thing and + * remove this? */ + @media not (prefers-contrast) { + tooltip { + background-color: #f9f9fb; + color: black; + border-color: #67676c; + border-radius: 4px; + } + + @media (prefers-color-scheme: dark) { + tooltip { + background-color: #2b2a33; + color: white; + border-color: #f9f9fb; + } + } + } +} + +@media (-moz-panel-animations) and (prefers-reduced-motion: no-preference) { +@media (-moz-platform: macos) { + /* On Mac, use the properties "-moz-window-transform" and "-moz-window-opacity" + instead of "transform" and "opacity" for these animations. + The -moz-window* properties apply to the whole window including the window's + shadow, and they don't affect the window's "shape", so the system doesn't + have to recompute the shadow shape during the animation. This makes them a + lot faster. In fact, Gecko no longer triggers shadow shape recomputations + for repaints. + These properties are not implemented on other platforms. */ + panel[type="arrow"]:not([animate="false"]) { + transition-property: -moz-window-transform, -moz-window-opacity; + transition-duration: 0.18s, 0.18s; + transition-timing-function: + var(--animation-easing-function), ease-out; + } + + /* Only do the fade-in animation on pre-Big Sur to avoid missing shadows on + * Big Sur, see bug 1672091. */ + @media (-moz-mac-big-sur-theme: 0) { + panel[type="arrow"]:not([animate="false"]) { + -moz-window-opacity: 0; + -moz-window-transform: translateY(-70px); + } + + panel[type="arrow"][side="bottom"]:not([animate="false"]) { + -moz-window-transform: translateY(70px); + } + } + + /* [animate] is here only so that this rule has greater specificity than the + * rule right above */ + panel[type="arrow"][animate][animate="open"] { + -moz-window-opacity: 1.0; + transition-duration: 0.18s, 0.18s; + -moz-window-transform: none; + transition-timing-function: + var(--animation-easing-function), ease-in-out; + } + + panel[type="arrow"][animate][animate="cancel"] { + -moz-window-opacity: 0; + -moz-window-transform: none; + } +} /* end of macOS rules */ + +@media not (-moz-platform: macos) { + panel[type="arrow"]:not([animate="false"]) { + opacity: 0; + transform: translateY(-70px); + transition-property: transform, opacity; + transition-duration: 0.18s, 0.18s; + transition-timing-function: + var(--animation-easing-function), ease-out; + will-change: transform, opacity; + } + + panel[type="arrow"][side="bottom"]:not([animate="false"]) { + transform: translateY(70px); + } + + /* [animate] is here only so that this rule has greater specificity than the + * rule right above */ + panel[type="arrow"][animate][animate="open"] { + opacity: 1.0; + transition-duration: 0.18s, 0.18s; + transform: none; + transition-timing-function: + var(--animation-easing-function), ease-in-out; + } + + panel[type="arrow"][animate][animate="cancel"] { + transform: none; + } +} /* end of non-macOS rules */ +} + +panel[type="arrow"][animating] { + pointer-events: none; +} + +/******** tree ******/ + +treecolpicker { + order: 2147483646; +} + +treechildren { + display: flex; + flex: 1; +} + +tree { + flex-direction: column; +} + +tree[hidecolumnpicker="true"] treecolpicker { + display: none; +} + +treecol { + min-width: 16px; + /* This preserves the behavior of -moz-box-ordinal-group. To change this we'd + * need to migrate the persisted ordinal values etc. */ + order: 1; +} + +treecol[hidden="true"] { + visibility: collapse; + display: flex; +} + +/* ::::: lines connecting cells ::::: */ +tree:not([treelines="true"]) treechildren::-moz-tree-line { + visibility: hidden; +} + +treechildren::-moz-tree-cell(ltr) { + direction: ltr !important; +} + +/********** deck, tabpanels & stack *********/ + +tabpanels > *|*:not(:-moz-native-anonymous) { + /* tabpanels is special: we want to avoid displaying them, but we still want + * the hidden children to be accessible */ + -moz-subtree-hidden-only-visually: 1; +} + +deck > *|*:not(:-moz-native-anonymous) { + visibility: hidden; +} + +tabpanels > .deck-selected, +deck > .deck-selected { + -moz-subtree-hidden-only-visually: 0; + visibility: inherit; +} + +tabpanels, +deck, +stack { + display: grid; + position: relative; +} + +/* We shouldn't style native anonymous children like scrollbars or what not. */ +tabpanels > *|*:not(:-moz-native-anonymous), +deck > *|*:not(:-moz-native-anonymous), +stack > *|*:not(:-moz-native-anonymous) { + grid-area: 1 / 1; + z-index: 0; + + /* + The default `min-height: auto` value makes grid items refuse to be smaller + than their content. This doesn't match the traditional behavior of XUL stack, + which often shoehorns tall content into a smaller stack and allows the content + to decide how to handle overflow (e.g. by scaling down if it's an image, or + by adding scrollbars if it's scrollable). + */ + min-height: 0; +} + +/********** tabbox *********/ + +tabbox { + flex-direction: column; + min-height: 0; +} + +tabpanels { + min-height: 0; +} + +tabs { + flex-direction: row; +} + +tab { + align-items: center; + justify-content: center; +} + +/********** tooltip *********/ + +tooltip[titletip="true"] { + /* The width of the tooltip isn't limited on cropped <tree> cells. */ + max-width: none; +} + +/********** basic rule for anonymous content that needs to pass box properties through + ********** to an insertion point parent that holds the real kids **************/ + +.box-inherit { + align-items: inherit; + justify-content: inherit; + flex-grow: inherit; + flex-shrink: inherit; + flex-direction: inherit; +} + +/********** textbox **********/ + +search-textbox { + text-shadow: none; +} + +/* Prefix with (xul|*):root to workaround HTML tests loading xul.css */ +:root html|textarea:not([resizable="true"]) { + resize: none; +} + +/********** autocomplete textbox **********/ + +.autocomplete-richlistbox { + -moz-user-focus: ignore; + overflow-x: hidden !important; + flex: 1; +} + +.autocomplete-richlistitem { + flex-direction: column; + align-items: center; + overflow: clip; +} + +/* The following rule is here to fix bug 96899 (and now 117952). + Somehow trees create a situation + in which a popupset flows itself as if its popup child is directly within it + instead of the placeholder child that should actually be inside the popupset. + This is a stopgap measure, and it does not address the real bug. */ +.autocomplete-result-popupset { + max-width: 0px; + width: 0 !important; + min-width: 0%; + min-height: 0%; +} + +/********** menulist **********/ + +menulist[popuponly] { + appearance: none !important; + margin: 0 !important; + height: 0 !important; + min-height: 0 !important; + border: 0 !important; + padding: 0 !important; +} + +/********** splitter **********/ + +.tree-splitter { + margin-inline: -4px; + width: 8px; + max-width: 8px; + min-width: 8px; + appearance: none !important; + border: none !important; + background: none !important; + order: 2147483646; + z-index: 2147483646; +} + +/******** scrollbar ********/ + +slider { + /* This is a hint to layerization that the scrollbar thumb can never leave + the scrollbar track. */ + overflow: hidden; +} + +/******** scrollbox ********/ + +scrollbox { + /* This makes it scrollable! */ + overflow: hidden; +} + +@media (prefers-reduced-motion: no-preference) { + scrollbox[smoothscroll=true] { + scroll-behavior: smooth; + } +} + +/********** stringbundle **********/ + +stringbundle, +stringbundleset { + display: none; +} + +/********** dialog **********/ + +dialog { + flex: 1; + flex-direction: column; +} + +/********** wizard **********/ + +wizard { + flex: 1; + flex-direction: column; + contain: inline-size; + min-width: 40em; + min-height: 30em; +} + +wizard > wizardpage { + grid-area: 1 / 1; + min-height: 0; +} + +wizard > wizardpage:not(.selected) { + visibility: hidden; +} + +wizardpage { + flex-direction: column; + overflow: auto; +} + +/********** Rich Listbox ********/ + +richlistbox { + flex-direction: column; + overflow: auto; + min-width: 0; + min-height: 0; +} + +richlistitem { + flex-shrink: 0; +} + +/*********** findbar ************/ +findbar { + overflow-x: hidden; + contain: inline-size; +} + +/* Some elements that in HTML blocks should be inline-level by default */ +button, image { + display: inline-flex; +} + +.menu-iconic-highlightable-text:not([highlightable="true"]), +.menu-iconic-text[highlightable="true"] { + display: none; +} + +[orient="vertical"] { flex-direction: column !important; } +[orient="horizontal"] { flex-direction: row !important; } + +[align="start"] { align-items: flex-start !important; } +[align="center"] { align-items: center !important; } +[align="end"] { align-items: flex-end !important; } +[align="baseline"] { align-items: baseline !important; } +[align="stretch"] { align-items: stretch !important; } + +[pack="start"] { justify-content: start !important; } +[pack="center"] { justify-content: center !important; } +[pack="end"] { justify-content: flex-end !important; } + +[flex="0"] { flex: none !important; } +[flex="1"] { flex: 1 !important; } |