diff --git a/toolkit/components/translations/content/icons/swap-languages.svg b/toolkit/components/translations/content/icons/swap-languages.svg new file mode 100644 index 000000000000..6a30bdcdeb87 --- /dev/null +++ b/toolkit/components/translations/content/icons/swap-languages.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/toolkit/components/translations/content/translations.css b/toolkit/components/translations/content/translations.css index ee3c0ba8eca0..ef539375437c 100644 --- a/toolkit/components/translations/content/translations.css +++ b/toolkit/components/translations/content/translations.css @@ -34,21 +34,8 @@ body { } .about-translations-header { - display: flex; -} - -.about-translations-header > * { - flex: 1; - display: flex; - max-width: 50%; -} - -.about-translations-header-start { - justify-content: start; -} - -.about-translations-header-end { - justify-content: end; + display: grid; + grid-template-columns: 1fr max-content 1fr; } /* Increase the selector specificity to override the base `select` styles. */ @@ -57,10 +44,26 @@ select.about-translations-select { padding-inline: 10px 20px; padding-block: 0px; min-width: 50%; + width: max-content; margin: 5px; background-position: right var(--AT-select-arrow-inset) center; } +select#language-to { + justify-self: end; +} + +.language-swap-icon { + width: 20px; + height: 20px; + background-image: url('chrome://global/content/translations/icons/swap-languages.svg'); + background-size: contain; + margin: 0 auto; + -moz-context-properties: fill; + fill: currentColor; + transform: rotateY(180deg) rotateZ(90deg); +} + select.about-translations-select:dir(rtl) { background-position-x: left var(--AT-select-arrow-inset); } diff --git a/toolkit/components/translations/content/translations.html b/toolkit/components/translations/content/translations.html index fea32897718a..3af448a9b7f5 100644 --- a/toolkit/components/translations/content/translations.html +++ b/toolkit/components/translations/content/translations.html @@ -22,22 +22,21 @@
-
- -
-
- -
+ + +
diff --git a/toolkit/components/translations/content/translations.mjs b/toolkit/components/translations/content/translations.mjs index 7da524e460f0..1649faa8c785 100644 --- a/toolkit/components/translations/content/translations.mjs +++ b/toolkit/components/translations/content/translations.mjs @@ -351,6 +351,8 @@ class TranslationsUI { languageFrom = document.getElementById("language-from"); /** @type {HTMLSelectElement} */ languageTo = document.getElementById("language-to"); + /** @type {HTMLButtonElement} */ + languageSwap = document.getElementById("language-swap"); /** @type {HTMLTextAreaElement} */ translationFrom = document.getElementById("translation-from"); /** @type {HTMLDivElement} */ @@ -396,6 +398,7 @@ class TranslationsUI { } this.setupDropdowns(); this.setupTextarea(); + this.setupLanguageSwapButton(); } /** @@ -440,20 +443,75 @@ class TranslationsUI { this.state.setFromLanguage(this.languageFrom.value); this.state.setToLanguage(this.languageTo.value); - this.updateOnLanguageChange(); - this.languageFrom.addEventListener("input", () => { + await this.updateOnLanguageChange(); + + this.languageFrom.addEventListener("input", async () => { this.state.setFromLanguage(this.languageFrom.value); - this.updateOnLanguageChange(); + await this.updateOnLanguageChange(); }); - this.languageTo.addEventListener("input", () => { + this.languageTo.addEventListener("input", async () => { this.state.setToLanguage(this.languageTo.value); - this.updateOnLanguageChange(); + await this.updateOnLanguageChange(); this.translationTo.setAttribute("lang", this.languageTo.value); }); } + /** + * Sets up the language swap button, so that when it's clicked, it: + * - swaps the selected source adn target lanauges + * - replaces the text to translate with the previous translation result + */ + setupLanguageSwapButton() { + this.languageSwap.addEventListener("click", async () => { + const translationToValue = this.translationTo.innerText; + + const newFromLanguage = this.sanitizeTargetLangTagAsSourceLangTag( + this.state.toLanguage + ); + const newToLanguage = this.sanitizeSourceLangTagAsTargetLangTag( + this.state.fromLanguage + ); + this.state.setFromLanguage(newFromLanguage); + this.state.setToLanguage(newToLanguage); + + this.languageFrom.value = newFromLanguage; + this.languageTo.value = newToLanguage; + await this.updateOnLanguageChange(); + this.translationTo.setAttribute("lang", this.languageTo.value); + + this.translationFrom.value = translationToValue; + this.state.setMessageToTranslate(translationToValue); + }); + } + + /** + * Get the target language dropdown option equivalent to the given source language dropdown option. + * `detect` will be converted to `` as `detect` is not a valid option in the target language dropdown + * + * @param {string} sourceLangTag + */ + sanitizeSourceLangTagAsTargetLangTag(sourceLangTag) { + if (sourceLangTag === "detect") { + return ""; + } + return sourceLangTag; + } + + /** + * Get the source language dropdown option equivalent to the given target language dropdown option. + * `` will be converted to `detect` as `` is not a valid option in the source language dropdown + * + * @param {string} targetLangTag + */ + sanitizeTargetLangTagAsSourceLangTag(targetLangTag) { + if (targetLangTag === "") { + return "detect"; + } + return targetLangTag; + } + /** * Show an info message to the user. * @@ -516,9 +574,10 @@ class TranslationsUI { /** * React to language changes. */ - updateOnLanguageChange() { + async updateOnLanguageChange() { this.#updateDropdownLanguages(); this.#updateMessageDirections(); + await this.#updateLanguageSwapButton(); } /** @@ -592,10 +651,49 @@ class TranslationsUI { } } + /** + * Disable the language swap button if fromLanguage is equivalent to toLanguage, or if the languages are not a valid option in the opposite direction + */ + async #updateLanguageSwapButton() { + const sourceLanguage = this.state.fromLanguage; + const targetLanguage = this.state.toLanguage; + + if ( + sourceLanguage === + this.sanitizeTargetLangTagAsSourceLangTag(targetLanguage) + ) { + this.languageSwap.disabled = true; + return; + } + + if (this.translationFrom.value && !this.translationTo.innerText) { + this.languageSwap.disabled = true; + return; + } + + const supportedLanguages = await this.state.supportedLanguages; + + const isSourceLanguageValidAsTargetLanguage = + sourceLanguage === "detect" || + supportedLanguages.languagePairs.some( + ({ toLang }) => toLang === sourceLanguage + ); + const isTargetLanguageValidAsSourceLanguage = + targetLanguage === "" || + supportedLanguages.languagePairs.some( + ({ fromLang }) => fromLang === targetLanguage + ); + + this.languageSwap.disabled = + !isSourceLanguageValidAsTargetLanguage || + !isTargetLanguageValidAsSourceLanguage; + } + setupTextarea() { this.state.setMessageToTranslate(this.translationFrom.value); - this.translationFrom.addEventListener("input", () => { - this.state.setMessageToTranslate(this.translationFrom.value); + this.translationFrom.addEventListener("input", async () => { + await this.state.setMessageToTranslate(this.translationFrom.value); + this.#updateLanguageSwapButton(); }); } @@ -603,6 +701,7 @@ class TranslationsUI { this.translationFrom.disabled = true; this.languageFrom.disabled = true; this.languageTo.disabled = true; + this.languageSwap.disabled = true; } /** @@ -618,6 +717,7 @@ class TranslationsUI { this.translationTo.style.visibility = "hidden"; this.translationToBlank.style.visibility = "visible"; } + this.#updateLanguageSwapButton(); } } diff --git a/toolkit/components/translations/jar.mn b/toolkit/components/translations/jar.mn index 1dde76d3ff9c..d74c153ab435 100644 --- a/toolkit/components/translations/jar.mn +++ b/toolkit/components/translations/jar.mn @@ -13,6 +13,7 @@ toolkit.jar: content/global/translations/translations.mjs (content/translations.mjs) content/global/translations/Translator.mjs (content/Translator.mjs) content/global/translations/TranslationsTelemetry.sys.mjs (TranslationsTelemetry.sys.mjs) + content/global/translations/icons/swap-languages.svg (content/icons/swap-languages.svg) # Uncomment this line to test a local build of Bergamot. It will automatically be loaded in. # content/global/translations/bergamot-translator-worker.wasm (bergamot-translator/thirdparty/build-wasm/bergamot-translator-worker.wasm) diff --git a/toolkit/components/translations/tests/browser/browser.toml b/toolkit/components/translations/tests/browser/browser.toml index fd037d27e403..532f5c866827 100644 --- a/toolkit/components/translations/tests/browser/browser.toml +++ b/toolkit/components/translations/tests/browser/browser.toml @@ -26,6 +26,8 @@ skip-if = ["os == 'linux'"] # Bug 1821461 ["browser_about_translations_enabling.js"] +["browser_about_translations_language_swap.js"] + ["browser_about_translations_translations.js"] ["browser_translations_actor.js"] diff --git a/toolkit/components/translations/tests/browser/browser_about_translations_language_swap.js b/toolkit/components/translations/tests/browser/browser_about_translations_language_swap.js new file mode 100644 index 000000000000..34df959c2224 --- /dev/null +++ b/toolkit/components/translations/tests/browser/browser_about_translations_language_swap.js @@ -0,0 +1,269 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test the language swap behaviour. + */ +add_task(async function test_about_translations_language_swap() { + const { runInPage, cleanup, resolveDownloads } = await openAboutTranslations({ + languagePairs: [ + { fromLang: "en", toLang: "fr" }, + { fromLang: "en", toLang: "it" }, + { fromLang: "fr", toLang: "en" }, + ], + autoDownloadFromRemoteSettings: false, + }); + + await runInPage(async ({ selectors }) => { + const { document, window } = content; + Cu.waiveXrays(window).DEBOUNCE_DELAY = 5; // Make the timer run faster for tests. + + await ContentTaskUtils.waitForCondition( + () => { + return document.body.hasAttribute("ready"); + }, + "Waiting for the document to be ready.", + 100, + 200 + ); + + /** @type {HTMLSelectElement} */ + const fromSelect = document.querySelector(selectors.fromLanguageSelect); + /** @type {HTMLSelectElement} */ + const toSelect = document.querySelector(selectors.toLanguageSelect); + /** @type {HTMLTextAreaElement} */ + const translationFrom = document.querySelector( + selectors.translationTextarea + ); + /** @type {HTMLButtonElement} */ + const swapButton = document.querySelector(selectors.languageSwapButton); + + // default option -> default option + is(fromSelect.value, "detect"); + is(toSelect.value, ""); + is(swapButton.disabled, true, "The language swap button is disabled"); // disabled because from-language is equivalent to to-language + + fromSelect.value = "en"; + fromSelect.dispatchEvent(new Event("input")); + + await ContentTaskUtils.waitForCondition( + () => fromSelect.value === "en", + "en selected in fromSelect", + 100, + 200 + ); + + // en -> default option + is(fromSelect.value, "en"); + is(toSelect.value, ""); + is(swapButton.disabled, false, "The language swap button is enabled"); + + translationFrom.value = "Translation text number 1."; + translationFrom.dispatchEvent(new Event("input")); + + // default option (detect: en) -> default option + await ContentTaskUtils.waitForCondition( + () => swapButton.disabled === true, + "The language swap button is disabled", + 100, + 200 + ); + is(swapButton.disabled, true, "The language swap button is disabled"); // disabled because swapping would wipe the input + + translationFrom.value = ""; + translationFrom.dispatchEvent(new Event("input")); + + await ContentTaskUtils.waitForCondition( + () => swapButton.disabled === false, + "The language swap button is enabled", + 100, + 200 + ); // re-enabled after the input is cleared + + swapButton.dispatchEvent(new Event("click")); + + // default option -> en + is(fromSelect.value, "detect"); + is(toSelect.value, "en"); + await ContentTaskUtils.waitForCondition( + () => swapButton.disabled === false, + "The language swap button is enabled", + 100, + 200 + ); + is(swapButton.disabled, false, "The language swap button is enabled"); + + translationFrom.value = "Translation text number 1."; + translationFrom.dispatchEvent(new Event("input")); + + // default option (detect: en) -> en + await ContentTaskUtils.waitForCondition( + () => swapButton.disabled === true, + "The language swap button is disabled", + 100, + 200 + ); + is(swapButton.disabled, true, "The language swap button is disabled"); // disabled because from-language (detected as en) is equivalent to to-language + + fromSelect.value = "fr"; + fromSelect.dispatchEvent(new Event("input")); + + await ContentTaskUtils.waitForCondition( + () => fromSelect.value === "fr", + "fr selected in fromSelect", + 100, + 200 + ); + + translationFrom.value = ""; + translationFrom.dispatchEvent(new Event("input")); + + await ContentTaskUtils.waitForCondition( + () => swapButton.disabled === false, + "The language swap button is enabled", + 100, + 200 + ); + is(swapButton.disabled, false, "The language swap button is enabled"); // both sides are empty + }); + + await resolveDownloads(1); // fr -> en + + await runInPage(async ({ selectors }) => { + const { document, window } = content; + Cu.waiveXrays(window).DEBOUNCE_DELAY = 5; + + /** @type {HTMLSelectElement} */ + const fromSelect = document.querySelector(selectors.fromLanguageSelect); + /** @type {HTMLSelectElement} */ + const toSelect = document.querySelector(selectors.toLanguageSelect); + /** @type {HTMLTextAreaElement} */ + const translationFrom = document.querySelector( + selectors.translationTextarea + ); + /** @type {HTMLDivElement} */ + const translationTo = document.querySelector(selectors.translationResult); + /** @type {HTMLButtonElement} */ + const swapButton = document.querySelector(selectors.languageSwapButton); + + translationFrom.value = "Translation text number 1."; + translationFrom.dispatchEvent(new Event("input")); + + await ContentTaskUtils.waitForCondition( + () => translationTo.innerText === "TRANSLATION TEXT NUMBER 1. [fr to en]", + "translation from fr to en is complete", + 100, + 200 + ); + + // fr -> en + is(fromSelect.value, "fr"); + is(toSelect.value, "en"); + is(translationFrom.value, "Translation text number 1."); + is(translationTo.innerText, "TRANSLATION TEXT NUMBER 1. [fr to en]"); + is(swapButton.disabled, false, "The language swap button is enabled"); + + swapButton.dispatchEvent(new Event("click")); + + await ContentTaskUtils.waitForCondition( + () => swapButton.disabled === true, + "The language swap button is disabled", + 100, + 200 + ); + is(swapButton.disabled, true, "The language swap button is disabled"); // after the swap, the input is not empty while the output is + }); + + await resolveDownloads(1); // en -> fr + + await runInPage(async ({ selectors }) => { + const { document, window } = content; + Cu.waiveXrays(window).DEBOUNCE_DELAY = 5; + + /** @type {HTMLSelectElement} */ + const fromSelect = document.querySelector(selectors.fromLanguageSelect); + /** @type {HTMLSelectElement} */ + const toSelect = document.querySelector(selectors.toLanguageSelect); + /** @type {HTMLTextAreaElement} */ + const translationFrom = document.querySelector( + selectors.translationTextarea + ); + /** @type {HTMLDivElement} */ + const translationTo = document.querySelector(selectors.translationResult); + /** @type {HTMLButtonElement} */ + const swapButton = document.querySelector(selectors.languageSwapButton); + await ContentTaskUtils.waitForCondition( + () => + translationTo.innerText === + "TRANSLATION TEXT NUMBER 1. [FR TO EN] [en to fr]", + "translation from en to fr is complete", + 100, + 200 + ); + + // en -> fr + is(fromSelect.value, "en"); + is(toSelect.value, "fr"); + is(translationFrom.value, "TRANSLATION TEXT NUMBER 1. [fr to en]"); + is( + translationTo.innerText, + "TRANSLATION TEXT NUMBER 1. [FR TO EN] [en to fr]" + ); // translating the original fr-to-en translation back to fr + is(swapButton.disabled, false, "The language swap button is enabled"); + + toSelect.value = "it"; + toSelect.dispatchEvent(new Event("input")); + + await ContentTaskUtils.waitForCondition( + () => toSelect.value === "it", + "it selected in toSelect", + 100, + 200 + ); + + is(swapButton.disabled, true, "The language swap button is disabled"); // after the toLanguage change, the input is not empty while the output is + }); + + await resolveDownloads(1); // en -> it + + await runInPage(async ({ selectors }) => { + const { document, window } = content; + Cu.waiveXrays(window).DEBOUNCE_DELAY = 5; + + /** @type {HTMLSelectElement} */ + const fromSelect = document.querySelector(selectors.fromLanguageSelect); + /** @type {HTMLSelectElement} */ + const toSelect = document.querySelector(selectors.toLanguageSelect); + /** @type {HTMLTextAreaElement} */ + const translationFrom = document.querySelector( + selectors.translationTextarea + ); + /** @type {HTMLDivElement} */ + const translationTo = document.querySelector(selectors.translationResult); + /** @type {HTMLButtonElement} */ + const swapButton = document.querySelector(selectors.languageSwapButton); + + await ContentTaskUtils.waitForCondition( + () => + translationTo.innerText === + "TRANSLATION TEXT NUMBER 1. [FR TO EN] [en to it]", + "translation from en to it is complete", + 100, + 200 + ); + + // en -> it + is(fromSelect.value, "en"); + is(toSelect.value, "it"); + is(translationFrom.value, "TRANSLATION TEXT NUMBER 1. [fr to en]"); + is( + translationTo.innerText, + "TRANSLATION TEXT NUMBER 1. [FR TO EN] [en to it]" + ); + is(swapButton.disabled, true, "The language swap button is disabled"); // disabled because to-language (it) is not a valid from-language + }); + + await cleanup(); +}); diff --git a/toolkit/components/translations/tests/browser/shared-head.js b/toolkit/components/translations/tests/browser/shared-head.js index a79e8810f82f..0a38d7b23c8c 100644 --- a/toolkit/components/translations/tests/browser/shared-head.js +++ b/toolkit/components/translations/tests/browser/shared-head.js @@ -118,6 +118,7 @@ async function openAboutTranslations({ pageHeader: '[data-l10n-id="about-translations-header"]', fromLanguageSelect: "select#language-from", toLanguageSelect: "select#language-to", + languageSwapButton: "button#language-swap", translationTextarea: "textarea#translation-from", translationResult: "#translation-to", translationResultBlank: "#translation-to-blank", diff --git a/toolkit/locales-preview/aboutTranslations.ftl b/toolkit/locales-preview/aboutTranslations.ftl index 54506bd59545..8509f2b4cfe6 100644 --- a/toolkit/locales-preview/aboutTranslations.ftl +++ b/toolkit/locales-preview/aboutTranslations.ftl @@ -5,6 +5,10 @@ # The title of the about:translations page, referencing the translations feature. about-translations-title = Translations about-translations-header = { -translations-brand-name } +# The title attribute for the swap languages button, that swaps the +# source and target languages, reversing the translation direction. +about-translations-swap-languages = + .title = Swap languages about-translations-results-placeholder = Translation about-translations-translating-message = Translating… # Text displayed on from-language dropdown when no language is selected