commit 74f09dc58c47e98e1d9238fc6010eb5d004609ac
parent a62db80e52a9b80aa2b150802bdee27be9b88744
Author: Erik Nordin <enordin@mozilla.com>
Date: Thu, 18 Dec 2025 01:17:27 +0000
Bug 2002127 - Part 11: Implement Never Translate Languages Functionality r=hjones
This commit adds implements the "Never Translate Languages" functionality
in the Translations subpage within the about:settings UI, compatible with
the settings redesign initiative.
Differential Revision: https://phabricator.services.mozilla.com/D274228
Diffstat:
2 files changed, 252 insertions(+), 11 deletions(-)
diff --git a/browser/components/preferences/translations.d.ts b/browser/components/preferences/translations.d.ts
@@ -20,4 +20,7 @@ export interface TranslationsSettingsElements {
alwaysTranslateLanguagesGroup: HTMLElement;
alwaysTranslateLanguagesSelect: HTMLSelectElement;
alwaysTranslateLanguagesNoneRow: HTMLElement;
+ neverTranslateLanguagesGroup: HTMLElement;
+ neverTranslateLanguagesSelect: HTMLSelectElement;
+ neverTranslateLanguagesNoneRow: HTMLElement;
}
diff --git a/browser/components/preferences/translations.js b/browser/components/preferences/translations.js
@@ -30,6 +30,11 @@ const ALWAYS_LANGUAGE_ITEM_CLASS = "translations-always-language-item";
/** @type {string} */
const ALWAYS_LANGUAGE_REMOVE_BUTTON_CLASS = "translations-always-remove-button";
+/** @type {string} */
+const NEVER_LANGUAGE_ITEM_CLASS = "translations-never-language-item";
+/** @type {string} */
+const NEVER_LANGUAGE_REMOVE_BUTTON_CLASS = "translations-never-remove-button";
+
const TranslationsSettings = {
/**
* True once initialization has completed.
@@ -74,6 +79,13 @@ const TranslationsSettings = {
alwaysLanguageTags: new Set(),
/**
+ * Current never-translate language tags.
+ *
+ * @type {Set<string>}
+ */
+ neverLanguageTags: new Set(),
+
+ /**
* Cached DOM elements used by the module.
*
* @type {TranslationsSettingsElements|null}
@@ -97,15 +109,29 @@ const TranslationsSettings = {
await this.onAlwaysLanguageChosen(
/** @type {HTMLSelectElement} */ (event.target).value
);
+ } else if (
+ event.target === this.elements?.neverTranslateLanguagesSelect
+ ) {
+ await this.onNeverLanguageChosen(
+ /** @type {HTMLSelectElement} */ (event.target).value
+ );
}
break;
case "click": {
const target = /** @type {HTMLElement} */ (event.target);
- const removeButton = /** @type {HTMLElement|null} */ (
+ const alwaysRemoveButton = /** @type {HTMLElement|null} */ (
target.closest?.(`.${ALWAYS_LANGUAGE_REMOVE_BUTTON_CLASS}`)
);
- if (removeButton?.dataset.langTag) {
- this.removeAlwaysLanguage(removeButton.dataset.langTag);
+ if (alwaysRemoveButton?.dataset.langTag) {
+ this.removeAlwaysLanguage(alwaysRemoveButton.dataset.langTag);
+ break;
+ }
+
+ const neverRemoveButton = /** @type {HTMLElement|null} */ (
+ target.closest?.(`.${NEVER_LANGUAGE_REMOVE_BUTTON_CLASS}`)
+ );
+ if (neverRemoveButton?.dataset.langTag) {
+ this.removeNeverLanguage(neverRemoveButton.dataset.langTag);
}
break;
}
@@ -123,11 +149,12 @@ const TranslationsSettings = {
* @param {string} data
*/
observe(subject, topic, data) {
- if (
- topic === TOPIC_TRANSLATIONS_PREF_CHANGED &&
- data === ALWAYS_TRANSLATE_LANGS_PREF
- ) {
- this.refreshAlwaysLanguages().catch(console.error);
+ if (topic === TOPIC_TRANSLATIONS_PREF_CHANGED) {
+ if (data === ALWAYS_TRANSLATE_LANGS_PREF) {
+ this.refreshAlwaysLanguages().catch(console.error);
+ } else if (data === NEVER_TRANSLATE_LANGS_PREF) {
+ this.refreshNeverLanguages().catch(console.error);
+ }
}
},
@@ -145,11 +172,13 @@ const TranslationsSettings = {
if (this.initPromise) {
await this.initPromise;
await this.refreshAlwaysLanguages();
+ await this.refreshNeverLanguages();
return;
}
if (this.initialized) {
await this.refreshAlwaysLanguages();
+ await this.refreshNeverLanguages();
return;
}
@@ -200,7 +229,7 @@ const TranslationsSettings = {
},
/**
- * Initialize the "Always translate languages" section.
+ * Initialize the "Always translate languages" and "Never translate languages" sections.
*
* @returns {Promise<void>}
*/
@@ -210,7 +239,10 @@ const TranslationsSettings = {
if (
!this.elements?.alwaysTranslateLanguagesGroup ||
!this.elements?.alwaysTranslateLanguagesSelect ||
- !this.elements?.alwaysTranslateLanguagesNoneRow
+ !this.elements?.alwaysTranslateLanguagesNoneRow ||
+ !this.elements?.neverTranslateLanguagesGroup ||
+ !this.elements?.neverTranslateLanguagesSelect ||
+ !this.elements?.neverTranslateLanguagesNoneRow
) {
return;
}
@@ -223,21 +255,30 @@ const TranslationsSettings = {
} catch (error) {
console.error("Failed to initialize translations settings UI", error);
this.elements.alwaysTranslateLanguagesSelect.disabled = true;
+ this.elements.neverTranslateLanguagesSelect.disabled = true;
return;
}
this.elements.alwaysTranslateLanguagesSelect.disabled = false;
+ this.elements.neverTranslateLanguagesSelect.disabled = false;
await this.buildAlwaysSelectOptions();
+ await this.buildNeverSelectOptions();
this.elements.alwaysTranslateLanguagesSelect.addEventListener(
"change",
this
);
this.elements.alwaysTranslateLanguagesGroup.addEventListener("click", this);
+ this.elements.neverTranslateLanguagesSelect.addEventListener(
+ "change",
+ this
+ );
+ this.elements.neverTranslateLanguagesGroup.addEventListener("click", this);
Services.obs.addObserver(this, TOPIC_TRANSLATIONS_PREF_CHANGED);
window.addEventListener("unload", this);
await this.refreshAlwaysLanguages();
+ await this.refreshNeverLanguages();
this.initialized = true;
},
@@ -259,12 +300,24 @@ const TranslationsSettings = {
alwaysTranslateLanguagesNoneRow: /** @type {HTMLElement} */ (
document.getElementById("translationsAlwaysTranslateLanguagesNoneRow")
),
+ neverTranslateLanguagesGroup: /** @type {HTMLElement} */ (
+ document.getElementById("translationsNeverTranslateLanguagesGroup")
+ ),
+ neverTranslateLanguagesSelect: /** @type {HTMLSelectElement} */ (
+ document.getElementById("translationsNeverTranslateLanguagesSelect")
+ ),
+ neverTranslateLanguagesNoneRow: /** @type {HTMLElement} */ (
+ document.getElementById("translationsNeverTranslateLanguagesNoneRow")
+ ),
};
if (
!elements.alwaysTranslateLanguagesGroup ||
!elements.alwaysTranslateLanguagesSelect ||
- !elements.alwaysTranslateLanguagesNoneRow
+ !elements.alwaysTranslateLanguagesNoneRow ||
+ !elements.neverTranslateLanguagesGroup ||
+ !elements.neverTranslateLanguagesSelect ||
+ !elements.neverTranslateLanguagesNoneRow
) {
return;
}
@@ -505,6 +558,183 @@ const TranslationsSettings = {
},
/**
+ * Handle a selection in the "Never translate languages" dropdown.
+ *
+ * @param {string} langTag
+ */
+ async onNeverLanguageChosen(langTag) {
+ if (!langTag) {
+ return;
+ }
+
+ TranslationsParent.addLangTagToPref(langTag, NEVER_TRANSLATE_LANGS_PREF);
+ TranslationsParent.removeLangTagFromPref(
+ langTag,
+ ALWAYS_TRANSLATE_LANGS_PREF
+ );
+ await this.resetNeverSelect();
+ },
+
+ /**
+ * Remove the given language from the never translate list.
+ *
+ * @param {string} langTag
+ */
+ removeNeverLanguage(langTag) {
+ TranslationsParent.removeLangTagFromPref(
+ langTag,
+ NEVER_TRANSLATE_LANGS_PREF
+ );
+ },
+
+ /**
+ * Reset the dropdown back to the placeholder value and underlying setting state.
+ */
+ async resetNeverSelect() {
+ await this.resetSelect(
+ this.elements?.neverTranslateLanguagesSelect,
+ "translationsNeverTranslateLanguagesSelect"
+ );
+ },
+
+ /**
+ * Refresh the rendered list of never-translate languages to match prefs.
+ */
+ async refreshNeverLanguages() {
+ if (!this.elements?.neverTranslateLanguagesGroup) {
+ return;
+ }
+
+ const langTags = Array.from(
+ TranslationsParent.getNeverTranslateLanguages?.() ?? []
+ );
+ this.neverLanguageTags = new Set(langTags);
+
+ this.renderNeverLanguages(langTags);
+ await this.updateNeverSelectOptionState();
+ },
+
+ /**
+ * Render the current set of never-translate languages into the list UI.
+ *
+ * @param {string[]} langTags
+ */
+ renderNeverLanguages(langTags) {
+ const { neverTranslateLanguagesGroup, neverTranslateLanguagesNoneRow } =
+ this.elements;
+
+ for (const item of neverTranslateLanguagesGroup.querySelectorAll(
+ `.${NEVER_LANGUAGE_ITEM_CLASS}`
+ )) {
+ item.remove();
+ }
+
+ if (neverTranslateLanguagesNoneRow) {
+ const hasLanguages = Boolean(langTags.length);
+ neverTranslateLanguagesNoneRow.hidden = hasLanguages;
+
+ if (hasLanguages && neverTranslateLanguagesNoneRow.isConnected) {
+ neverTranslateLanguagesNoneRow.remove();
+ } else if (!hasLanguages && !neverTranslateLanguagesNoneRow.isConnected) {
+ neverTranslateLanguagesGroup.appendChild(
+ neverTranslateLanguagesNoneRow
+ );
+ }
+ }
+
+ const sortedLangTags = [...langTags].sort((langTagA, langTagB) => {
+ const labelA = this.formatLanguageLabel(langTagA) ?? langTagA;
+ const labelB = this.formatLanguageLabel(langTagB) ?? langTagB;
+ return labelA.localeCompare(labelB);
+ });
+
+ for (const langTag of sortedLangTags) {
+ const label = this.formatLanguageLabel(langTag);
+ if (!label) {
+ continue;
+ }
+
+ const removeButton = document.createElement("moz-button");
+ removeButton.setAttribute("slot", "actions-start");
+ removeButton.setAttribute("type", "icon ghost");
+ removeButton.setAttribute(
+ "iconsrc",
+ "chrome://global/skin/icons/delete.svg"
+ );
+ removeButton.classList.add(NEVER_LANGUAGE_REMOVE_BUTTON_CLASS);
+ removeButton.dataset.langTag = langTag;
+ removeButton.setAttribute("aria-label", label);
+
+ const item = document.createElement("moz-box-item");
+ item.classList.add(NEVER_LANGUAGE_ITEM_CLASS);
+ item.setAttribute("label", label);
+ item.dataset.langTag = langTag;
+ item.appendChild(removeButton);
+ if (
+ neverTranslateLanguagesNoneRow &&
+ neverTranslateLanguagesNoneRow.parentElement ===
+ neverTranslateLanguagesGroup
+ ) {
+ neverTranslateLanguagesGroup.insertBefore(
+ item,
+ neverTranslateLanguagesNoneRow
+ );
+ } else {
+ neverTranslateLanguagesGroup.appendChild(item);
+ }
+ }
+ },
+
+ /**
+ * Populate the select options for the supported source languages.
+ */
+ async buildNeverSelectOptions() {
+ const select = this.elements?.neverTranslateLanguagesSelect;
+ if (!select || !this.supportedLanguages?.sourceLanguages?.length) {
+ return;
+ }
+
+ const placeholder = select.querySelector('moz-option[value=""]');
+ for (const option of select.querySelectorAll("moz-option")) {
+ if (option !== placeholder) {
+ option.remove();
+ }
+ }
+
+ const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort(
+ (a, b) => a.displayName.localeCompare(b.displayName)
+ );
+ for (const { langTag, displayName } of sourceLanguages) {
+ const option = document.createElement("moz-option");
+ option.setAttribute("value", langTag);
+ option.setAttribute("label", displayName);
+ select.appendChild(option);
+ }
+
+ await this.resetNeverSelect();
+ },
+
+ /**
+ * Disable already-added languages in the select so they cannot be re-added.
+ */
+ async updateNeverSelectOptionState() {
+ const select = this.elements?.neverTranslateLanguagesSelect;
+ if (!select) {
+ return;
+ }
+
+ for (const option of select.querySelectorAll("moz-option")) {
+ const value = option.getAttribute("value");
+ if (!value) {
+ continue;
+ }
+ option.disabled = this.neverLanguageTags.has(value);
+ }
+
+ await this.resetNeverSelect();
+ },
+
+ /**
* Remove observers and listeners added during init.
*/
teardown() {
@@ -523,6 +753,14 @@ const TranslationsSettings = {
"click",
this
);
+ this.elements?.neverTranslateLanguagesSelect?.removeEventListener(
+ "change",
+ this
+ );
+ this.elements?.neverTranslateLanguagesGroup?.removeEventListener(
+ "click",
+ this
+ );
},
};