mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
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:
parent
2adc228e61
commit
df0ecec834
2 changed files with 113 additions and 13 deletions
41
src/static/js/vendors/html10n.ts
vendored
41
src/static/js/vendors/html10n.ts
vendored
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue