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