commit e59372e02fa87e427d60e8eab9dfce2d37db078d
parent adc30c93d80374a2670fd6b3fa086bba9c0aaca2
Author: Erik Nordin <enordin@mozilla.com>
Date: Fri, 17 Oct 2025 18:41:02 +0000
Bug 1994794 - Apply translated script direciton to <ul>/<ol>/<li> r=translations-reviewers,gregtatum
This patch adds some special-case logic that ensures `<ul>/<ol>/<li>`
elements are flipped to the target script direction when translating
between LTR/RTL text.
Differential Revision: https://phabricator.services.mozilla.com/D268926
Diffstat:
2 files changed, 477 insertions(+), 12 deletions(-)
diff --git a/toolkit/components/translations/content/translations-document.sys.mjs b/toolkit/components/translations/content/translations-document.sys.mjs
@@ -3213,6 +3213,42 @@ export class TranslationsDocument {
}
/**
+ * Updates the script direction of a given element,
+ * only if the source and target script directions differ.
+ *
+ * If the element is contained within a list item, then this
+ * also updates the script direction of the list item as well
+ * as the containing list.
+ *
+ * This is a special-case scenario that really improves the layout
+ * of lists on pages when translating to the reverse script direciton.
+ *
+ * @param {Element?} element
+ */
+ #maybeUpdateScriptDirection(element) {
+ if (
+ !element ||
+ this.#sourceScriptDirection === this.#targetScriptDirection
+ ) {
+ return;
+ }
+
+ const targetScriptDirection = this.#targetScriptDirection;
+
+ element.setAttribute("dir", targetScriptDirection);
+
+ const listItemAncestor = element.closest("li");
+ if (!listItemAncestor) {
+ return;
+ }
+
+ listItemAncestor.setAttribute("dir", targetScriptDirection);
+ listItemAncestor
+ .closest("ul, ol")
+ ?.setAttribute("dir", targetScriptDirection);
+ }
+
+ /**
* Updates all nodes that have completed attribute translation requests.
*
* This function is called asynchronously, so nodes may already be dead. Before
@@ -3258,19 +3294,16 @@ export class TranslationsDocument {
"text/html"
);
- if (this.#sourceScriptDirection !== this.#targetScriptDirection) {
- element.setAttribute("dir", this.#targetScriptDirection);
- }
-
updateElement(translationsDocument, element);
+ this.#maybeUpdateScriptDirection(element);
+
this.#processedContentNodes.add(targetNode);
} else {
- if (this.#sourceScriptDirection !== this.#targetScriptDirection) {
- const parentElement = asElement(targetNode.parentNode);
- parentElement?.setAttribute("dir", this.#targetScriptDirection);
- }
textNodeCount++;
+
targetNode.textContent = translatedContent;
+ this.#maybeUpdateScriptDirection(asElement(targetNode.parentNode));
+
this.#processedContentNodes.add(targetNode);
}
diff --git a/toolkit/components/translations/tests/browser/browser_translations_translation_document.js b/toolkit/components/translations/tests/browser/browser_translations_translation_document.js
@@ -1948,7 +1948,7 @@ add_task(async function test_node_specific_attribute_mutation() {
});
add_task(
- async function test_direction_ltr_to_rtl_sets_dir_on_content_not_attributes() {
+ async function test_direction_ltr_to_rtl_basic_content_not_attributes() {
const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
/* html */ `
<div id="content" title="A translated title">
@@ -1971,7 +1971,7 @@ add_task(
translate();
await htmlMatches(
- 'LTR to RTL: content elements get dir="rtl", but attribute-only elements do not.',
+ 'LTR to RTL (basic): content elements get dir="rtl", but attribute-only elements do not.',
/* html */ `
<div id="content" title="A TRANSLATED TITLE" dir="rtl">
THIS BLOCK OF CONTENT SHOULD GET RTL DIRECTION.
@@ -1997,8 +1997,224 @@ add_task(
}
);
+add_task(async function test_direction_ltr_to_rtl_lists_ul_basic() {
+ const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
+ /* html */ `
+ <ul>
+ <li>List item.</li>
+ </ul>
+ <ul>
+ <li>
+ Span within list item.
+ <span>Span inside list item.</span>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ Div within list item.
+ <div>Div inside list item.</div>
+ </li>
+ </ul>
+ `,
+ { sourceLanguage: "en", targetLanguage: "ar" }
+ );
+
+ translate();
+
+ await htmlMatches(
+ "LTR to RTL (UL basic): <ul> and <li> with simple nested inline/block content.",
+ /* html */ `
+ <ul dir="rtl">
+ <li dir="rtl">
+ LIST ITEM.
+ </li>
+ </ul>
+ <ul dir="rtl">
+ <li dir="rtl">
+ SPAN WITHIN LIST ITEM.
+ <span>
+ SPAN INSIDE LIST ITEM.
+ </span>
+ </li>
+ </ul>
+ <ul dir="rtl">
+ <li dir="rtl">
+ DIV WITHIN LIST ITEM.
+ <div dir="rtl">
+ DIV INSIDE LIST ITEM.
+ </div>
+ </li>
+ </ul>
+ `
+ );
+
+ cleanup();
+});
+
+add_task(async function test_direction_ltr_to_rtl_lists_ul_nested_combos() {
+ const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
+ /* html */ `
+ <ul>
+ <li>
+ Span within div within list item.
+ <div>
+ <span>Span inside div inside list item.</span>
+ </div>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ Div within span within list item.
+ <span>
+ <div>Div inside span inside list item.</div>
+ </span>
+ </li>
+ </ul>
+ `,
+ { sourceLanguage: "en", targetLanguage: "ar" }
+ );
+
+ translate();
+
+ await htmlMatches(
+ "LTR to RTL (UL nested combos): nested inline/block permutations.",
+ /* html */ `
+ <ul dir="rtl">
+ <li dir="rtl">
+ SPAN WITHIN DIV WITHIN LIST ITEM.
+ <div dir="rtl">
+ <span>
+ SPAN INSIDE DIV INSIDE LIST ITEM.
+ </span>
+ </div>
+ </li>
+ </ul>
+ <ul dir="rtl">
+ <li dir="rtl">
+ DIV WITHIN SPAN WITHIN LIST ITEM.
+ <span>
+ <div dir="rtl">
+ DIV INSIDE SPAN INSIDE LIST ITEM.
+ </div>
+ </span>
+ </li>
+ </ul>
+ `
+ );
+
+ cleanup();
+});
+
+add_task(async function test_direction_ltr_to_rtl_lists_ol_basic() {
+ const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
+ /* html */ `
+ <ol>
+ <li>List item.</li>
+ </ol>
+ <ol>
+ <li>
+ Span within list item.
+ <span>Span inside list item.</span>
+ </li>
+ </ol>
+ <ol>
+ <li>
+ Div within list item.
+ <div>Div inside list item.</div>
+ </li>
+ </ol>
+ `,
+ { sourceLanguage: "en", targetLanguage: "ar" }
+ );
+
+ translate();
+
+ await htmlMatches(
+ "LTR to RTL (OL basic): <ol> and <li> with simple nested inline/block content.",
+ /* html */ `
+ <ol dir="rtl">
+ <li dir="rtl">
+ LIST ITEM.
+ </li>
+ </ol>
+ <ol dir="rtl">
+ <li dir="rtl">
+ SPAN WITHIN LIST ITEM.
+ <span>
+ SPAN INSIDE LIST ITEM.
+ </span>
+ </li>
+ </ol>
+ <ol dir="rtl">
+ <li dir="rtl">
+ DIV WITHIN LIST ITEM.
+ <div dir="rtl">
+ DIV INSIDE LIST ITEM.
+ </div>
+ </li>
+ </ol>
+ `
+ );
+
+ cleanup();
+});
+
+add_task(async function test_direction_ltr_to_rtl_lists_ol_nested_combos() {
+ const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
+ /* html */ `
+ <ol>
+ <li>
+ Span within div within list item.
+ <div>
+ <span>Span inside div inside list item.</span>
+ </div>
+ </li>
+ </ol>
+ <ol>
+ <li>
+ Div within span within list item.
+ <span>
+ <div>Div inside span inside list item.</div>
+ </span>
+ </li>
+ </ol>
+ `,
+ { sourceLanguage: "en", targetLanguage: "ar" }
+ );
+
+ translate();
+
+ await htmlMatches(
+ "LTR to RTL (OL nested combos): nested inline/block permutations.",
+ /* html */ `
+ <ol dir="rtl">
+ <li dir="rtl">
+ SPAN WITHIN DIV WITHIN LIST ITEM.
+ <div dir="rtl">
+ <span>
+ SPAN INSIDE DIV INSIDE LIST ITEM.
+ </span>
+ </div>
+ </li>
+ </ol>
+ <ol dir="rtl">
+ <li dir="rtl">
+ DIV WITHIN SPAN WITHIN LIST ITEM.
+ <span>
+ <div dir="rtl">
+ DIV INSIDE SPAN INSIDE LIST ITEM.
+ </div>
+ </span>
+ </li>
+ </ol>
+ `
+ );
+
+ cleanup();
+});
+
add_task(
- async function test_direction_rtl_to_ltr_sets_dir_on_content_not_attributes() {
+ async function test_direction_rtl_to_ltr_basic_content_not_attributes() {
const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
/* html */ `
<div id="content" title="A translated title">
@@ -2021,7 +2237,7 @@ add_task(
translate();
await htmlMatches(
- 'RTL to LTR: content elements get dir="ltr", but attribute-only elements do not.',
+ 'RTL to LTR (basic): content elements get dir="ltr", but attribute-only elements do not.',
/* html */ `
<div id="content" title="A TRANSLATED TITLE" dir="ltr">
THIS BLOCK OF CONTENT SHOULD GET LTR DIRECTION.
@@ -2046,3 +2262,219 @@ add_task(
cleanup();
}
);
+
+add_task(async function test_direction_rtl_to_ltr_lists_ul_basic() {
+ const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
+ /* html */ `
+ <ul>
+ <li>List item.</li>
+ </ul>
+ <ul>
+ <li>
+ Span within list item.
+ <span>Span inside list item.</span>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ Div within list item.
+ <div>Div inside list item.</div>
+ </li>
+ </ul>
+ `,
+ { sourceLanguage: "ar", targetLanguage: "en" }
+ );
+
+ translate();
+
+ await htmlMatches(
+ "RTL to LTR (UL basic): <ul> and <li> with simple nested inline/block content.",
+ /* html */ `
+ <ul dir="ltr">
+ <li dir="ltr">
+ LIST ITEM.
+ </li>
+ </ul>
+ <ul dir="ltr">
+ <li dir="ltr">
+ SPAN WITHIN LIST ITEM.
+ <span>
+ SPAN INSIDE LIST ITEM.
+ </span>
+ </li>
+ </ul>
+ <ul dir="ltr">
+ <li dir="ltr">
+ DIV WITHIN LIST ITEM.
+ <div dir="ltr">
+ DIV INSIDE LIST ITEM.
+ </div>
+ </li>
+ </ul>
+ `
+ );
+
+ cleanup();
+});
+
+add_task(async function test_direction_rtl_to_ltr_lists_ul_nested_combos() {
+ const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
+ /* html */ `
+ <ul>
+ <li>
+ Span within div within list item.
+ <div>
+ <span>Span inside div inside list item.</span>
+ </div>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ Div within span within list item.
+ <span>
+ <div>Div inside span inside list item.</div>
+ </span>
+ </li>
+ </ul>
+ `,
+ { sourceLanguage: "ar", targetLanguage: "en" }
+ );
+
+ translate();
+
+ await htmlMatches(
+ "RTL to LTR (UL nested combos): nested inline/block permutations.",
+ /* html */ `
+ <ul dir="ltr">
+ <li dir="ltr">
+ SPAN WITHIN DIV WITHIN LIST ITEM.
+ <div dir="ltr">
+ <span>
+ SPAN INSIDE DIV INSIDE LIST ITEM.
+ </span>
+ </div>
+ </li>
+ </ul>
+ <ul dir="ltr">
+ <li dir="ltr">
+ DIV WITHIN SPAN WITHIN LIST ITEM.
+ <span>
+ <div dir="ltr">
+ DIV INSIDE SPAN INSIDE LIST ITEM.
+ </div>
+ </span>
+ </li>
+ </ul>
+ `
+ );
+
+ cleanup();
+});
+
+add_task(async function test_direction_rtl_to_ltr_lists_ol_basic() {
+ const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
+ /* html */ `
+ <ol>
+ <li>List item.</li>
+ </ol>
+ <ol>
+ <li>
+ Span within list item.
+ <span>Span inside list item.</span>
+ </li>
+ </ol>
+ <ol>
+ <li>
+ Div within list item.
+ <div>Div inside list item.</div>
+ </li>
+ </ol>
+ `,
+ { sourceLanguage: "ar", targetLanguage: "en" }
+ );
+
+ translate();
+
+ await htmlMatches(
+ "RTL to LTR (OL basic): <ol> and <li> with simple nested inline/block content.",
+ /* html */ `
+ <ol dir="ltr">
+ <li dir="ltr">
+ LIST ITEM.
+ </li>
+ </ol>
+ <ol dir="ltr">
+ <li dir="ltr">
+ SPAN WITHIN LIST ITEM.
+ <span>
+ SPAN INSIDE LIST ITEM.
+ </span>
+ </li>
+ </ol>
+ <ol dir="ltr">
+ <li dir="ltr">
+ DIV WITHIN LIST ITEM.
+ <div dir="ltr">
+ DIV INSIDE LIST ITEM.
+ </div>
+ </li>
+ </ol>
+ `
+ );
+
+ cleanup();
+});
+
+add_task(async function test_direction_rtl_to_ltr_lists_ol_nested_combos() {
+ const { translate, htmlMatches, cleanup } = await createTranslationsDoc(
+ /* html */ `
+ <ol>
+ <li>
+ Span within div within list item.
+ <div>
+ <span>Span inside div inside list item.</span>
+ </div>
+ </li>
+ </ol>
+ <ol>
+ <li>
+ Div within span within list item.
+ <span>
+ <div>Div inside span inside list item.</div>
+ </span>
+ </li>
+ </ol>
+ `,
+ { sourceLanguage: "ar", targetLanguage: "en" }
+ );
+
+ translate();
+
+ await htmlMatches(
+ "RTL to LTR (OL nested combos): nested inline/block permutations.",
+ /* html */ `
+ <ol dir="ltr">
+ <li dir="ltr">
+ SPAN WITHIN DIV WITHIN LIST ITEM.
+ <div dir="ltr">
+ <span>
+ SPAN INSIDE DIV INSIDE LIST ITEM.
+ </span>
+ </div>
+ </li>
+ </ol>
+ <ol dir="ltr">
+ <li dir="ltr">
+ DIV WITHIN SPAN WITHIN LIST ITEM.
+ <span>
+ <div dir="ltr">
+ DIV INSIDE SPAN INSIDE LIST ITEM.
+ </div>
+ </span>
+ </li>
+ </ol>
+ `
+ );
+
+ cleanup();
+});