a11y: localize aria-label on form-control elements (<select>, <input>, <textarea>) (#7713)

#7584 introduced auto-population of aria-label from a translation
when an element has data-l10n-id and no author-supplied aria-label.
That branch only fires for elements with no children (or with
data-l10n-id ending in a recognized attribute suffix like .title).

Form-control elements break the assumption: a <select> always has
<option> children, an <input>/<textarea> may have implicit value
content. The textContent branch handles them, but the aria-label
fallback wasn't called from there. Plugins like ep_font_size,
ep_headings2, and ep_hljs end up with a localized translation
applied to a <select> but no accessible name on the element itself.

Calls populateAriaLabel() from the textContent branch when the node
is a <select>, <input>, or <textarea>. Keeps the same
"author-supplied aria-label wins on first pass; the
data-l10n-aria-label marker lets us refresh values we wrote"
semantics from #7584.

Adds Playwright coverage in
src/tests/frontend-new/specs/html10n_form_controls_aria.spec.ts:
- aria-label is populated on <select> with data-l10n-id
- aria-label is populated on <textarea> with data-l10n-id
- author-supplied aria-label is preserved on first pass
This commit is contained in:
John McLear 2026-05-10 19:23:33 +08:00 committed by GitHub
parent 2adc228e61
commit df0ecec834
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 113 additions and 13 deletions

View file

@ -658,24 +658,28 @@ export class Html10n {
prop = document.body.textContent ? 'textContent' : 'innerText'
}
// Apply translation
if (node.children.length === 0 || prop != 'textContent') {
// @ts-ignore
node[prop] = str.str!
// Populate aria-label from the translation so screen readers get a
// localized accessible name. Preserve an author-supplied aria-label
// (one present in the template without a marker), but keep our own
// html10n-generated values in sync across language changes by
// overwriting them. The `data-l10n-aria-label` marker distinguishes
// the two: set when we populate it, checked on subsequent passes so
// `pad.applyLanguage()` refreshes the accessible name.
// See PR #7584 review feedback.
const generatedMarker = 'data-l10n-aria-label';
// Populate aria-label from the translation so screen readers get a
// localized accessible name. Preserve an author-supplied aria-label
// (one present in the template without a marker), but keep our own
// html10n-generated values in sync across language changes by
// overwriting them. The `data-l10n-aria-label` marker distinguishes
// the two: set when we populate it, checked on subsequent passes so
// `pad.applyLanguage()` refreshes the accessible name.
// See PR #7584 review feedback.
const generatedMarker = 'data-l10n-aria-label';
const populateAriaLabel = () => {
if (!node.hasAttribute('aria-label') ||
node.getAttribute(generatedMarker) === 'true') {
node.setAttribute('aria-label', str.str!);
node.setAttribute(generatedMarker, 'true');
}
}
// Apply translation
if (node.children.length === 0 || prop != 'textContent') {
// @ts-ignore
node[prop] = str.str!
populateAriaLabel()
} else {
let children = node.childNodes,
found = false
@ -693,6 +697,17 @@ export class Html10n {
if (!found) {
console.warn('Unexpected error: could not translate element content for key '+str.id, node)
}
// Form-controllable elements (<select>, <input>, <textarea>) carry their
// accessible name on aria-label rather than as text content (a <select>'s
// text is its <option> labels, not its own name). The textContent branch
// above doesn't fall through to populateAriaLabel(), so plugins that put
// data-l10n-id on a <select> and rely on the auto-population (introduced
// in #7584) end up with no accessible name. Populate aria-label here so
// those controls stay localized too. See ether/ep_align#182 review.
const tag = node.tagName;
if (tag === 'SELECT' || tag === 'INPUT' || tag === 'TEXTAREA') {
populateAriaLabel()
}
}
}

View file

@ -0,0 +1,85 @@
// Regression test for html10n's aria-label auto-population on form
// controls (<select>, <input>, <textarea>) — these have child <option>s
// or implicit value content, so the textContent branch in translateNode
// applies, and the aria-label population that lives in the no-children
// branch was being skipped. This left plugins like ep_font_size and
// ep_headings2 with a <select data-l10n-id="..."> but no accessible
// name once a hardcoded English aria-label was removed.
//
// See ether/etherpad PR that added this behavior (linked from
// ether/ep_align#182 review).
import {expect, test} from '@playwright/test';
import {goToNewPad} from '../helper/padHelper';
test.use({locale: 'en-US'});
test.beforeEach(async ({page}) => {
await goToNewPad(page);
});
test('html10n auto-populates aria-label on <select> with data-l10n-id', async ({page}) => {
// Inject a <select> with options into the toolbar, tagged with an
// existing translation key (`pad.toolbar.bold.title` is shipped in
// every locale).
await page.evaluate(() => {
const sel = document.createElement('select');
sel.id = 'html10n-test-select';
sel.setAttribute('data-l10n-id', 'pad.toolbar.bold.title');
const opt = document.createElement('option');
opt.value = 'a';
opt.textContent = 'A';
sel.appendChild(opt);
document.body.appendChild(sel);
// Trigger a re-translate so html10n sees the new node.
// @ts-ignore window.html10n is exposed by pad.ts
window.html10n.localize(['en']);
});
const sel = page.locator('#html10n-test-select');
// After translation, aria-label should be set from the localized
// string, and the data-l10n-aria-label marker should signal that
// html10n owns it (so applyLanguage refreshes it on language change).
await expect(sel).toHaveAttribute('aria-label', /.+/);
await expect(sel).toHaveAttribute('data-l10n-aria-label', 'true');
});
test('html10n auto-populates aria-label on <textarea> with data-l10n-id', async ({page}) => {
await page.evaluate(() => {
const ta = document.createElement('textarea');
ta.id = 'html10n-test-textarea';
ta.setAttribute('data-l10n-id', 'pad.toolbar.bold.title');
document.body.appendChild(ta);
// @ts-ignore
window.html10n.localize(['en']);
});
const ta = page.locator('#html10n-test-textarea');
await expect(ta).toHaveAttribute('aria-label', /.+/);
await expect(ta).toHaveAttribute('data-l10n-aria-label', 'true');
});
test('an author-supplied aria-label on a form control is preserved', async ({page}) => {
// Mirror the existing semantics for non-form-control elements: if the
// template author wrote their own aria-label, html10n must not
// overwrite it on the first pass (it can only refresh values it
// previously wrote, identified by the data-l10n-aria-label marker).
await page.evaluate(() => {
const sel = document.createElement('select');
sel.id = 'html10n-test-author-aria';
sel.setAttribute('data-l10n-id', 'pad.toolbar.bold.title');
sel.setAttribute('aria-label', 'Custom author label');
const opt = document.createElement('option');
opt.value = 'a';
opt.textContent = 'A';
sel.appendChild(opt);
document.body.appendChild(sel);
// @ts-ignore
window.html10n.localize(['en']);
});
const sel = page.locator('#html10n-test-author-aria');
await expect(sel).toHaveAttribute('aria-label', 'Custom author label');
// No marker is set — html10n didn't write this one.
await expect(sel).not.toHaveAttribute('data-l10n-aria-label', 'true');
});