tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mtoolkit/components/translations/content/translations-document.sys.mjs | 49+++++++++++++++++++++++++++++++++++++++++--------
Mtoolkit/components/translations/tests/browser/browser_translations_translation_document.js | 440++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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(); +});