From c7ac1cce6f165847094876f08f025824fef5265b Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 7 May 2026 23:28:01 +0800 Subject: [PATCH] fix(a11y): localized aria-label + title on export-as links (#7697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(a11y): localized aria-label + title on export-as links The six export anchors in the import/export dialog had no aria-label or title; their accessible name relied on the inner icon span's translated text (e.g. "Etherpad"). That announces just the format name with no "export" verb context, and the icon span doubles as the visible glyph which screen readers also pick up. Add `data-l10n-id="pad.importExport.exporta.title"` to each anchor — html10n populates both the `title` attribute and `aria-label` from the same key, so screen readers announce e.g. "Export as Etherpad" and sighted users get a tooltip. Mark the inner icon span `aria-hidden="true"` so SR doesn't double-read the format name. Strengthens the existing `a11y_dialogs.spec.ts` "export links" test to assert aria-label/title are populated and inner spans are aria-hidden, and only checks the three formats that are present without soffice. * Address Qodo review - Add rel="noopener" to each export anchor: closes the reverse-tabnabbing window-opener gap. Other target="_blank" links in pad.html (e.g. the "Powered by Etherpad" link) already follow this hardening pattern. - Pin Playwright locale to en-US for the a11y_dialogs spec: this file already asserts specific English strings (e.g. "Close chat", "Active users on this pad"); without the pin, translatewiki updates would break those tests. Tighten the export-anchor assertions to exact-match the English aria-label/title now that the locale is stable, and assert rel="noopener" too. --- src/locales/en.json | 6 +++ src/templates/pad.html | 24 +++++------ .../frontend-new/specs/a11y_dialogs.spec.ts | 41 +++++++++++-------- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index e33cdef8b..a8602ad35 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -141,6 +141,12 @@ "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", + "pad.importExport.exportetherpada.title": "Export as Etherpad", + "pad.importExport.exporthtmla.title": "Export as HTML", + "pad.importExport.exportplaina.title": "Export as plain text", + "pad.importExport.exportworda.title": "Export as Microsoft Word", + "pad.importExport.exportpdfa.title": "Export as PDF", + "pad.importExport.exportopena.title": "Export as ODF (Open Document Format)", "pad.importExport.noConverter.innerHTML": "You can only import from plain text or HTML formats. For more advanced import features, please install LibreOffice.", "pad.modals.connected": "Connected.", diff --git a/src/templates/pad.html b/src/templates/pad.html index 5212b0046..8a4e3ac3c 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -323,23 +323,23 @@

<% e.begin_block("exportColumn"); %> - - + + - - + + - - + + - - + + - - + + - - + + <% e.end_block(); %>
diff --git a/src/tests/frontend-new/specs/a11y_dialogs.spec.ts b/src/tests/frontend-new/specs/a11y_dialogs.spec.ts index c5891ae1f..ec6406a32 100644 --- a/src/tests/frontend-new/specs/a11y_dialogs.spec.ts +++ b/src/tests/frontend-new/specs/a11y_dialogs.spec.ts @@ -1,6 +1,12 @@ import {expect, test} from '@playwright/test'; import {goToNewPad} from '../helper/padHelper'; +// Pin browser locale so html10n picks the English bundle. Several +// assertions in this file compare against specific English strings +// (e.g. "Close chat", "Export as Etherpad"); without this, translatewiki +// updates would localise those strings and break the suite. +test.use({locale: 'en-US'}); + test.beforeEach(async ({page}) => { await goToNewPad(page); }); @@ -65,28 +71,31 @@ test('users popup closes on Escape even when focus is outside the popup', async await expect(dialog).not.toHaveClass(/popup-show/); }); -test('export links have an accessible name from their localized content', async ({page}) => { +test('export links have a localized aria-label and matching title', async ({page}) => { await page.locator('button[data-l10n-id="pad.toolbar.import_export.title"]').click(); // The Word/PDF/ODF export links are removed client-side by pad_impexp.ts // when soffice is not configured, so only assert on links that the - // environment actually renders. For the ones that are present, their - // accessible name comes from the localized child span (data-l10n-id - // pad.importExport.exportetherpad etc.), not a hard-coded English - // aria-label. Assert the visible text is non-empty, which is what a - // screen reader will announce. - const ids = [ - '#exportetherpada', - '#exporthtmla', - '#exportplaina', - '#exportworda', - '#exportpdfa', - '#exportopena', + // environment actually renders. Each anchor carries + // data-l10n-id="pad.importExport.exporta.title", which html10n + // expands into both `title` and `aria-label` from the same translation + // (e.g. "Export as Etherpad"). The inner icon span is aria-hidden so a + // screen reader announces the anchor's label once, not twice. + const cases: Array<[string, string]> = [ + ['#exportetherpada', 'Export as Etherpad'], + ['#exporthtmla', 'Export as HTML'], + ['#exportplaina', 'Export as plain text'], + ['#exportworda', 'Export as Microsoft Word'], + ['#exportpdfa', 'Export as PDF'], + ['#exportopena', 'Export as ODF (Open Document Format)'], ]; - for (const id of ids) { + for (const [id, expected] of cases) { const locator = page.locator(id); if ((await locator.count()) === 0) continue; - const text = (await locator.innerText()).trim(); - expect(text.length).toBeGreaterThan(0); + await expect(locator).toHaveAttribute('aria-label', expected); + await expect(locator).toHaveAttribute('title', expected); + await expect(locator).toHaveAttribute('rel', 'noopener'); + const innerSpan = locator.locator('span.exporttype'); + await expect(innerSpan).toHaveAttribute('aria-hidden', 'true'); } });