Skip to content

Commit 09d0db6

Browse files
JohnMcLearclaude
andcommitted
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>
1 parent 2274539 commit 09d0db6

2 files changed

Lines changed: 45 additions & 2 deletions

File tree

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/tests/frontend-new/specs/a11y_dialogs.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,29 @@ test('editbar toolbars have role=toolbar with accessible names (#7255)', async (
173173
}
174174
});
175175

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+
// Non-separator items wrap their <button> in an <a> — that <a> is also
190+
// presentational so AT focus lands on the <button>, not the empty link.
191+
const anchors = page.locator('.menu_left > li:not(.separator) > a, .menu_right > li:not(.separator) > a');
192+
const aCount = await anchors.count();
193+
expect(aCount).toBeGreaterThan(0);
194+
for (let i = 0; i < aCount; i++) {
195+
await expect(anchors.nth(i)).toHaveAttribute('role', 'presentation');
196+
}
197+
});
198+
176199
test('linemetricsdiv is hidden from screen readers (#7255)', async ({page}) => {
177200
// The "Ether X" announcement in the issue's a11y-inspector screenshot was
178201
// the outer iframe (titled "Ether") plus a single "x" text leaf from

0 commit comments

Comments
 (0)