Skip to content

Commit 9240592

Browse files
JohnMcLearclaude
andauthored
fix(a11y): name role=toolbar regions, hide linemetricsdiv from AT (#7255) (#7777)
* fix(a11y): name role=toolbar regions, hide linemetricsdiv from AT (#7255) Two regressions called out in the 2026-05-16 follow-up on #7255, after the firefox accessibility inspector flagged them: (1) The "Ether X" announcement between the editor and chat button was the outer ace iframe (titled "Ether") plus a single 'x' text leaf the renderer appends to outerdocbody for line-height measurement (linemetricsdiv in ace.ts). Add aria-hidden=true on creation so AT skips the measurement node entirely. Same approach we used for sidediv in PR #7758. (2) The two formatting/actions <ul role="toolbar"> regions and the history-mode role=toolbar div had no accessible name. Lighthouse + the firefox a11y panel both flagged this. Putting data-l10n-id directly on the <ul> would either destroy its <li> children (textContent branch) or not populate aria-label (the html10n auto-aria-label code path skips non-form-control elements), and a hidden <span> child inside the <ul> would be invalid HTML. Solution: three visually-hidden <span> labels sitting just before #editbar, each carrying data-l10n-id for translation, referenced from the toolbars via aria-labelledby. Apply the same treatment to .show-more-icon-btn, whose aria-label was previously hardcoded English (no data-l10n-id, so untranslated). Adds Playwright assertions for linemetricsdiv aria-hidden and the resolved accessible-name text of each toolbar. Updates the existing show-more test to expect aria-labelledby (it previously asserted hardcoded English aria-label). Refs #7255 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(a11y): reuse translated history-controls label key (Qodo PR review) Qodo flagged: adding `aria-labelledby="editbar-history-label"` on #history-controls overrode the `aria-label` that pad_mode.ts sets from `pad.historyMode.controlsLabel`. That key is already translated in multiple locales (en/de/nl/...); the new `pad.editor.toolbar.history` key was English-only, so non-English users would regress from a localized history-toolbar name to the English fallback. Point the hidden label span at the existing translated key instead of minting a new one, drop the new key from en.json, and update the Playwright expectation to match the translated string ("Pad history controls"). The aria-label that pad_mode.ts still writes to #history-controls is now redundant (aria-labelledby wins) but harmless, and leaving it preserves the runtime relocalization path. Refs #7777 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(a11y): mark toolbar li/a wrappers presentational (Lighthouse, #7255) Lighthouse's axe-core `listitem` rule fires on every toolbar button because role="toolbar" on the <ul> overrides its implicit role="list", leaving the <li> children "orphaned" by axe's heuristic. Murphy's 2026-05-16 follow-up on #7255 attached the Chrome DevTools Lighthouse panel screenshot of this exact failure. Marking the <li>+<a> wrappers role="presentation" tells axe-core they are layout scaffolding for the toolbar role, while the inner <button> keeps its semantics for AT. Same treatment for SelectButton's <li> wrapper. The Separator's <li> also gets aria-hidden=true so AT does not announce an empty list item between toolbar buttons. CSS and JS selectors that still target `.toolbar ul li` continue to work — role="presentation" only affects the accessibility tree, not the DOM tree. No visual or behavioral change for sighted users. Adds a Playwright spec that walks every rendered toolbar <li>/<a> and asserts role="presentation" so future toolbar.ts tweaks can't silently re-introduce the Lighthouse failure. Refs #7255 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(a11y): label #online_count for AT (#7255 - "number next to the user icon") Murphy's 2026-05-16 follow-up cut off mid-bullet ("It's not clear what the number next to the …"). Best guess: the user-count badge in the showusers toolbar button. Currently it exposes a bare digit to AT — "5" with no context — because the visible badge text is also the entire accessible content. Append a localized aria-label generated from a new pad.userlist.onlineCount key (plural macro for one / other) whenever the count updates, so AT announces "5 connected users" instead of the bare digit. Add role=status and aria-live=polite so the count change is announced inline without forcing the user to refocus the button. Visible badge digit unchanged. html10n.get is null-safe (falls back to an English template so AT never gets back "undefined" before the locale bundle has loaded). Adds a Playwright spec verifying role/aria-live and that the aria-label contains "connected user". Refs #7255 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(a11y): tighten #online_count + plugin-emitted toolbar <li>s (#7255) Two small follow-ups on top of the toolbar/online-count work: (1) #online_count had no accessible label on solo-author pads. updateNumberOfOnlineUsers — which writes the localized aria-label — only fires on userJoin/userLeave/status change, never on initial single-author load. Call it at the end of init() so the badge ships with its label on first paint. Also bind html10n's 'localized' event so non-English users get the translated label after the locale bundle arrives (matches the keyboard-hint / history-toolbar pattern). Harden the Playwright spec to use polling toHaveAttribute instead of one-shot getAttribute. (2) Sweep role="presentation" onto plugin-emitted toolbar <li>s in pad_editbar.ts init(). Core's toolbar.ts emits its <li>s with the role already, but plugins (ep_headings2, ep_align, ep_font_*, ep_print, ...) ship their own editbarButtons.ejs templates that emit <li> directly, so Lighthouse's listitem rule kept firing on the "with plugins" test runs. Runtime sweep covers anything in the editbar at init time, no plugin coordination needed. Refs #7255 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 04045fe commit 9240592

7 files changed

Lines changed: 186 additions & 10 deletions

File tree

src/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,9 @@
362362
"pad.historyMode.users.authorsHeader": "Authors at this revision",
363363
"pad.editor.skipToContent": "Skip to editor",
364364
"pad.editor.keyboardHint": "Press Escape to exit the editor. Press Alt+F9 to access the toolbar.",
365+
"pad.editor.toolbar.formatting": "Formatting toolbar",
366+
"pad.editor.toolbar.actions": "Pad actions toolbar",
367+
"pad.editor.toolbar.showMore": "Show more toolbar buttons",
365368
"timeslider.toolbar.authors": "Authors:",
366369
"timeslider.toolbar.authorsList": "No Authors",
367370
"timeslider.toolbar.exportlink.title": "Export",
@@ -398,6 +401,7 @@
398401
"pad.savedrevs.timeslider": "You can see saved revisions by visiting the timeslider",
399402
"pad.userlist.entername": "Enter your name",
400403
"pad.userlist.unnamed": "unnamed",
404+
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} connected user, other: {{count}} connected users ]}",
401405
"pad.editbar.clearcolors": "Clear authorship colors on entire document? This cannot be undone",
402406

403407
"pad.impexp.importbutton": "Import Now",

src/node/utils/toolbar.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,23 @@ class Button {
113113
}
114114

115115
render() {
116+
// role="presentation" on the <li> + <a> wrappers tells axe-core /
117+
// Lighthouse they are layout scaffolding for the role="toolbar"
118+
// parent, not list items. Without it the listitem rule fires
119+
// because role="toolbar" overrides the implicit role="list" on
120+
// the <ul>, leaving every <li> "orphaned" by axe's heuristic.
121+
// The inner <button> keeps its semantics. See ether/etherpad#7255.
116122
const liAttributes = {
117123
'data-type': 'button',
118124
'data-key': this.attributes.command,
125+
'role': 'presentation',
119126
};
120127
return tag('li', liAttributes,
121-
tag('a', {'class': this.grouping, 'data-l10n-id': this.attributes.localizationId},
128+
tag('a', {
129+
'class': this.grouping,
130+
'role': 'presentation',
131+
'data-l10n-id': this.attributes.localizationId,
132+
},
122133
tag('button', {
123134
'class': ` ${this.attributes.class}`,
124135
'data-l10n-id': this.attributes.localizationId,
@@ -167,6 +178,8 @@ class SelectButton extends Button {
167178
'id': this.attributes.id,
168179
'data-key': this.attributes.command,
169180
'data-type': 'select',
181+
// See Button.render() above for the role="presentation" rationale.
182+
'role': 'presentation',
170183
};
171184
return tag('li', attributes, this.select({id: this.attributes.selectId}));
172185
}
@@ -184,7 +197,14 @@ class Separator {
184197
}
185198

186199
public render() {
187-
return tag('li', {class: 'separator'});
200+
// See Button.render() above for the role="presentation" rationale.
201+
// The separator is purely visual; aria-hidden makes that explicit so
202+
// AT does not announce an empty list item between toolbar buttons.
203+
return tag('li', {
204+
'class': 'separator',
205+
'role': 'presentation',
206+
'aria-hidden': 'true',
207+
});
188208

189209
}
190210
}

src/static/js/ace.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ const Ace2Editor = function () {
243243
sideDiv.appendChild(sideDivInner);
244244
const lineMetricsDiv = outerDocument.createElement('div');
245245
lineMetricsDiv.id = 'linemetricsdiv';
246+
// Measurement-only node: holds a single "x" so the renderer can read
247+
// its computed line height. Without aria-hidden, AT exposes the stray
248+
// glyph as a "text leaf" sandwiched between the editor iframe and the
249+
// chat button — see ether/etherpad#7255 (comment "Ether X" announcement).
250+
lineMetricsDiv.setAttribute('aria-hidden', 'true');
246251
lineMetricsDiv.appendChild(outerDocument.createTextNode('x'));
247252
outerDocument.body.appendChild(lineMetricsDiv);
248253

src/static/js/pad_editbar.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,24 @@ exports.padeditbar = new class {
155155
});
156156
});
157157

158+
// Lighthouse / axe-core's `listitem` rule fires on every <li> child of an
159+
// element whose role isn't `list` — and role="toolbar" on our <ul>s
160+
// overrides the implicit list role. Core's toolbar.ts emits its <li>s
161+
// with role="presentation" already; plugins ship their own editbarButtons
162+
// EJS templates (ep_headings2, ep_align, ep_font_*, ep_print, ...) that
163+
// emit <li> directly without the role, which kept the Lighthouse error
164+
// alive. Sweep here once the toolbar DOM is fully built — runs before
165+
// postToolbarInit hooks so anything plugins add even later still has the
166+
// role attribute added to it the moment it lands (covered by the same
167+
// sweep on the next init() call). role="presentation" only affects the
168+
// accessibility tree, so CSS / JS selectors keep working. #7255.
169+
$('#editbar .menu_left > li, #editbar .menu_right > li, #history-controls > li')
170+
.filter((_i, el) => !el.hasAttribute('role'))
171+
.attr('role', 'presentation');
172+
$('#editbar .menu_left > li > a, #editbar .menu_right > li > a, #history-controls > li > a')
173+
.filter((_i, el) => !el.hasAttribute('role'))
174+
.attr('role', 'presentation');
175+
158176
$('body:not(#editorcontainerbox)').on('keydown', (evt) => {
159177
this._bodyKeyEvent(evt);
160178
});

src/static/js/pad_userlist.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,25 @@ const paduserlist = (() => {
354354
self.setMyUserInfo(myInitialUserInfo);
355355

356356
if ($('#online_count').length === 0) {
357-
$('#editbar [data-key=showusers] > a').append('<span id="online_count">1</span>');
357+
// role="status" + aria-live="polite" announces the count when it
358+
// changes; the localized aria-label (set by updateNumberOfOnlineUsers
359+
// below) turns the bare badge digit into "N connected users" so AT
360+
// users get context, not a stray "5". See ether/etherpad#7255.
361+
$('#editbar [data-key=showusers] > a').append(
362+
'<span id="online_count" role="status" aria-live="polite">1</span>');
363+
}
364+
// Set the initial aria-label. updateNumberOfOnlineUsers otherwise only
365+
// fires on userJoin / userLeave / status change — on a solo-author pad
366+
// those events never come, and the badge would ship to AT with no
367+
// accessible name (the regression Playwright caught on PR #7777).
368+
self.updateNumberOfOnlineUsers();
369+
// Re-localize on runtime language changes. updateNumberOfOnlineUsers
370+
// reads html10n.get, which returns undefined before the bundle loads
371+
// and returns translated text afterwards; binding here keeps the
372+
// accessible name in sync with applyLanguage(). Same pattern used
373+
// by the keyboard hint in ace.ts and the history toolbar labels.
374+
if (html10n && typeof (html10n as any).bind === 'function') {
375+
(html10n as any).bind('localized', () => self.updateNumberOfOnlineUsers());
358376
}
359377

360378
$('#otheruserstable tr').remove();
@@ -547,7 +565,15 @@ const paduserlist = (() => {
547565
localStorage.setItem('recentPads', JSON.stringify(recentPadsList));
548566
}
549567

550-
$('#online_count').text(online);
568+
// Set both visible text (the badge digit) and the accessible name in
569+
// one place so they can't drift. html10n.get returns undefined if the
570+
// locale bundle hasn't loaded yet — fall back to an English template
571+
// so AT never reads back "undefined".
572+
const $count = $('#online_count');
573+
$count.text(online);
574+
const label = html10n.get('pad.userlist.onlineCount', {count: online})
575+
|| `${online} connected user${online === 1 ? '' : 's'}`;
576+
$count.attr('aria-label', label);
551577

552578
return online;
553579
},

src/templates/pad.html

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,27 @@
8080
<!----------------------------->
8181
<!--------- TOOLBAR ----------->
8282
<!----------------------------->
83+
<!-- Visually-hidden labels for the role=toolbar regions. Sitting outside the
84+
<ul>s keeps the toolbar markup valid (HTML disallows non-<li>/<script>
85+
children inside <ul>) while still giving each toolbar a translated
86+
accessible name via aria-labelledby. See ether/etherpad#7255. -->
87+
<span id="editbar-formatting-label" class="sr-only"
88+
data-l10n-id="pad.editor.toolbar.formatting">Formatting toolbar</span>
89+
<!-- Reuses pad.historyMode.controlsLabel rather than minting a new key:
90+
pad_mode.ts already set this as aria-label on #history-controls via JS,
91+
and the existing key has translations in nl/de/etc. A new English-only
92+
key would cause non-English locales to regress to fallback English. -->
93+
<span id="editbar-history-label" class="sr-only"
94+
data-l10n-id="pad.historyMode.controlsLabel">Pad history controls</span>
95+
<span id="editbar-actions-label" class="sr-only"
96+
data-l10n-id="pad.editor.toolbar.actions">Pad actions toolbar</span>
97+
<span id="editbar-showmore-label" class="sr-only"
98+
data-l10n-id="pad.editor.toolbar.showMore">Show more toolbar buttons</span>
99+
83100
<div id="editbar" class="toolbar">
84101
<div id="toolbar-overlay"></div>
85102

86-
<ul class="menu_left" role="toolbar">
103+
<ul class="menu_left" role="toolbar" aria-labelledby="editbar-formatting-label">
87104
<% e.begin_block("editbarMenuLeft"); %>
88105
<%- toolbar.menu(settings.toolbar.left, isReadOnly, 'left', 'pad') %>
89106
<% e.end_block(); %>
@@ -100,7 +117,8 @@
100117
translation keys. We deliberately do NOT put data-l10n-id on
101118
the icon buttons — that would set their textContent, which on
102119
a buttonicon glyph would draw the localized string on screen. -->
103-
<div id="history-controls" class="history-controls" role="toolbar" hidden>
120+
<div id="history-controls" class="history-controls" role="toolbar"
121+
aria-labelledby="editbar-history-label" hidden>
104122
<button type="button" id="history-playpause" class="buttonicon buttonicon-play"
105123
aria-pressed="false"></button>
106124
<button type="button" id="history-leftstep" class="buttonicon buttonicon-step-backward">
@@ -137,12 +155,14 @@
137155
<option value="1000" data-l10n-id="timeslider.settings.playbackSpeed.1000ms">1000 ms</option>
138156
</select>
139157
</div>
140-
<ul class="menu_right" role="toolbar">
158+
<ul class="menu_right" role="toolbar" aria-labelledby="editbar-actions-label">
141159
<% e.begin_block("editbarMenuRight"); %>
142160
<%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %>
143161
<% e.end_block(); %>
144162
</ul>
145-
<button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
163+
<button type="button" class="show-more-icon-btn"
164+
aria-labelledby="editbar-showmore-label"
165+
aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
146166
</div>
147167

148168
<% e.begin_block("afterEditbar"); %><% e.end_block(); %>

src/tests/frontend-new/specs/a11y_dialogs.spec.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,97 @@ test('otherusers region has aria-live and aria-label (no aria-role typo)', async
139139
expect(ariaRole).toBeNull();
140140
});
141141

142-
test('show-more toolbar button has aria-label and aria-expanded', async ({page}) => {
142+
test('show-more toolbar button has an accessible name and aria-expanded', async ({page}) => {
143143
const btn = page.locator('.show-more-icon-btn');
144144
const tag = await btn.evaluate((el) => el.tagName.toLowerCase());
145145
expect(tag).toBe('button');
146-
await expect(btn).toHaveAttribute('aria-label', 'Show more toolbar buttons');
146+
// The accessible name is supplied by aria-labelledby pointing at a hidden
147+
// localized span (so html10n can translate it). Verify the relationship
148+
// resolves and produces the expected English string with locale=en-US.
149+
await expect(btn).toHaveAttribute('aria-labelledby', 'editbar-showmore-label');
150+
await expect(page.locator('#editbar-showmore-label')).toHaveText('Show more toolbar buttons');
147151
await expect(btn).toHaveAttribute('aria-expanded', 'false');
148152
});
149153

154+
test('editbar toolbars have role=toolbar with accessible names (#7255)', async ({page}) => {
155+
// Lighthouse + AT tooling (firefox a11y inspector) flagged both <ul> toolbars
156+
// as unnamed in the 2026-05-16 follow-up on #7255. Each toolbar role now
157+
// points to a hidden localized span via aria-labelledby; if either span is
158+
// ever removed, getAttribute returns an id with no matching element and the
159+
// toolbar becomes unnamed again — so assert the resolved string, not just
160+
// the attribute wiring.
161+
const cases: Array<[string, string, string]> = [
162+
['.menu_left', 'editbar-formatting-label', 'Formatting toolbar'],
163+
['.menu_right', 'editbar-actions-label', 'Pad actions toolbar'],
164+
// History toolbar reuses pad.historyMode.controlsLabel (already
165+
// translated in multiple locales) instead of a new English-only key.
166+
['#history-controls', 'editbar-history-label', 'Pad history controls'],
167+
];
168+
for (const [sel, labelId, expected] of cases) {
169+
const toolbar = page.locator(sel);
170+
await expect(toolbar).toHaveAttribute('role', 'toolbar');
171+
await expect(toolbar).toHaveAttribute('aria-labelledby', labelId);
172+
await expect(page.locator(`#${labelId}`)).toHaveText(expected);
173+
}
174+
});
175+
176+
test('toolbar <li>/<a> wrappers are presentational (Lighthouse listitem rule, #7255)', async ({page}) => {
177+
// Lighthouse / axe-core's `listitem` rule flags <li> children of any
178+
// element whose role isn't `list` — and role="toolbar" on the <ul>
179+
// overrides the implicit list role. Murphy's #7255 follow-up included
180+
// the Lighthouse screenshot of this exact failure. role="presentation"
181+
// tells axe-core the <li>+<a> wrappers are layout scaffolding, while
182+
// the inner <button> retains button semantics for AT.
183+
const listItems = page.locator('.menu_left > li, .menu_right > li');
184+
const count = await listItems.count();
185+
expect(count).toBeGreaterThan(0);
186+
for (let i = 0; i < count; i++) {
187+
await expect(listItems.nth(i)).toHaveAttribute('role', 'presentation');
188+
}
189+
// Core's toolbar.ts emits items as <li><a><button>...</button></a></li>;
190+
// for those, the wrapping <a> is presentational so AT focus lands on the
191+
// <button>, not the empty link. Plugins may emit anchors with their own
192+
// role (e.g. ep_subscript_and_superscript renders <a role="button">), so
193+
// scope this assertion to core's button-wrappers only — `:has(> button)`
194+
// matches the <a> that contain a <button> child, which is what core emits.
195+
const anchors = page.locator(
196+
'.menu_left > li:not(.separator) > a:has(> button), ' +
197+
'.menu_right > li:not(.separator) > a:has(> button)');
198+
const aCount = await anchors.count();
199+
expect(aCount).toBeGreaterThan(0);
200+
for (let i = 0; i < aCount; i++) {
201+
await expect(anchors.nth(i)).toHaveAttribute('role', 'presentation');
202+
}
203+
});
204+
205+
test('online_count badge has a localized accessible label (#7255)', async ({page}) => {
206+
// The user-count badge in the showusers toolbar button used to expose a
207+
// bare digit ("5") to AT, with no clue it was a user count. Now the badge
208+
// carries an aria-label generated from pad.userlist.onlineCount that
209+
// updates whenever the count changes. role=status + aria-live=polite
210+
// means AT announces the change without the user having to refocus.
211+
const badge = page.locator('#online_count');
212+
await expect(badge).toHaveAttribute('role', 'status');
213+
await expect(badge).toHaveAttribute('aria-live', 'polite');
214+
// toHaveText / toHaveAttribute poll so the assertions survive the
215+
// initial userlist init() pass (which appends the span and then sets
216+
// its aria-label asynchronously after html10n + setMyUserInfo land).
217+
await expect(badge).toHaveText(/^\d+$/);
218+
// English plural form contains "connected user" — covers both singular
219+
// and plural without baking the exact count into the test.
220+
await expect(badge).toHaveAttribute('aria-label', /connected user/);
221+
});
222+
223+
test('linemetricsdiv is hidden from screen readers (#7255)', async ({page}) => {
224+
// The "Ether X" announcement in the issue's a11y-inspector screenshot was
225+
// the outer iframe (titled "Ether") plus a single "x" text leaf from
226+
// ace.ts's linemetricsdiv. linemetricsdiv is a measurement-only node — it
227+
// holds a single "x" so the renderer can read its computed line height —
228+
// and must stay out of the AT tree.
229+
const outerFrame = page.frameLocator('iframe[name="ace_outer"]');
230+
await expect(outerFrame.locator('#linemetricsdiv')).toHaveAttribute('aria-hidden', 'true');
231+
});
232+
150233
test('skip-to-content link bypasses toolbar (WCAG 2.4.1, #7255)', async ({page}) => {
151234
const skip = page.locator('#skip-to-content');
152235
// It exists in the DOM and is hidden from sighted users by default —

0 commit comments

Comments
 (0)