Administration: Clean up counter item accessible names - #12850
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
There was a problem hiding this comment.
🟡 Changes recommended
Elements referenced by aria-describedby are currently marked aria-hidden="true" (and rendered inside links), which can prevent assistive technologies from exposing the count descriptions and undermines the intended accessibility behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR standardizes how admin menu and toolbar counter “bubbles” are exposed to assistive technologies by keeping counts out of each link’s accessible name and instead exposing the count via aria-describedby.
Changes:
- Adds
aria-describedbysupport toWP_Admin_Barnodemetaand updates toolbar Updates/Comments nodes to use described-by count text. - Updates admin menu counter markup and
_wp_menu_output()to wire count descriptions onto top-level and submenu links and avoid duplicate IDs. - Introduces/expands PHPUnit coverage for the described-by wiring and duplicate-id regression.
File summaries
| File | Description |
|---|---|
src/wp-includes/class-wp-admin-bar.php |
Allows aria-describedby in admin bar node meta attributes during render. |
src/wp-includes/admin-bar.php |
Updates toolbar Comments/Updates node markup to separate accessible name from count description. |
src/wp-admin/menu.php |
Refactors admin menu counter markup to add hidden description spans intended for aria-describedby. |
src/wp-admin/menu-header.php |
Extracts count-description IDs and applies aria-describedby to admin menu links; strips duplicate IDs from submenu head. |
tests/phpunit/tests/adminbar.php |
Adds assertions around toolbar described-by behavior and meta whitelist coverage. |
tests/phpunit/tests/admin/wpMenuOutput.php |
New tests for admin menu described-by wiring and duplicate-id prevention. |
Review details
Suppressed comments (11)
src/wp-admin/menu.php:120
- The wp-menu-comments-count-description span is referenced via aria-describedby but is marked aria-hidden="true", which can prevent assistive technologies from exposing the description.
) . '<span id="wp-menu-comments-count-description" class="wp-menu-count-description comments-in-moderation-text screen-reader-text" aria-hidden="true">' . $awaiting_moderation_text . '</span>',
src/wp-admin/menu.php:243
- The wp-menu-themes-count-description span is referenced via aria-describedby but is marked aria-hidden="true", which can prevent the described count from being announced.
$description = '<span id="wp-menu-themes-count-description" class="wp-menu-count-description screen-reader-text" aria-hidden="true">' . $themes_text . '</span>';
src/wp-admin/menu.php:350
- The wp-menu-plugins-count-description span is referenced via aria-describedby but is marked aria-hidden="true", which can prevent assistive technologies from exposing the description.
$description = '<span id="wp-menu-plugins-count-description" class="wp-menu-count-description screen-reader-text" aria-hidden="true">' . $plugins_text . '</span>';
src/wp-admin/menu.php:426
- The wp-menu-site-health-count-description span is referenced via aria-describedby but is marked aria-hidden="true", which can prevent the description from being exposed to assistive technologies.
$site_health_count .= '<span id="wp-menu-site-health-count-description" class="wp-menu-count-description screen-reader-text" aria-hidden="true">' . $site_health_text . '</span>';
src/wp-admin/menu-header.php:188
- After extracting the count-description span for aria-describedby, it should be rendered outside the element; otherwise it either won’t be exposed (if aria-hidden) or it will become part of the link’s accessible name (if not aria-hidden).
echo "<a href='admin.php?page={$submenu_items[0][2]}'$class $aria_attributes$describedby><div class='wp-menu-image$img_class'$img_style aria-hidden='true'>$img</div><div class='wp-menu-name'>$title</div></a>";
} else {
echo "\n\t<a href='{$submenu_items[0][2]}'$class $aria_attributes$describedby><div class='wp-menu-image$img_class'$img_style aria-hidden='true'>$img</div><div class='wp-menu-name'>$title</div></a>";
}
src/wp-admin/menu-header.php:206
- These branches still render {$item[0]} inside the link markup; if it contains the count-description span, it will be part of the link’s accessible name. Use the stripped title (without the description span) in the link, and output the description span as a sibling after the link.
echo "\n\t<a href='admin.php?page={$item[2]}'$class $aria_attributes$describedby><div class='wp-menu-image$img_class'$img_style aria-hidden='true'>$img</div><div class='wp-menu-name'>{$item[0]}</div></a>";
} else {
echo "\n\t<a href='{$item[2]}'$class $aria_attributes$describedby><div class='wp-menu-image$img_class'$img_style aria-hidden='true'>$img</div><div class='wp-menu-name'>{$item[0]}</div></a>";
src/wp-admin/menu-header.php:284
- Submenu links have the same issue as top-level items: the count-description span is currently rendered inside the . To keep the accessible name matching the visible label while still exposing the count via aria-describedby, extract the span out of the link and render it as a sibling.
$title = wptexturize( $sub_item[0] );
// Associate a hidden count description with the submenu link. See above.
$sub_describedby = '';
if ( preg_match( '/id="([^"]+)" class="wp-menu-count-description/', $sub_item[0], $matches ) ) {
$sub_describedby = ' aria-describedby="' . esc_attr( $matches[1] ) . '"';
}
src/wp-admin/menu-header.php:301
- Once the submenu count-description span is extracted for aria-describedby, it should be output outside the element so it doesn’t affect the accessible name.
echo "<li$class><a href='$sub_item_url'$class$aria_attributes$sub_describedby>$title</a></li>";
} else {
echo "<li$class><a href='{$sub_item[2]}'$class$aria_attributes$sub_describedby>$title</a></li>";
tests/phpunit/tests/admin/wpMenuOutput.php:75
- The described-by target span in this submenu fixture is aria-hidden="true". Elements referenced by aria-describedby should not be aria-hidden, otherwise assistive technologies may ignore the description.
'Updates <span id="wp-menu-updates-count-description" class="wp-menu-count-description screen-reader-text" aria-hidden="true">2 updates available</span>',
tests/phpunit/tests/admin/wpMenuOutput.php:96
- This fixture uses aria-hidden="true" on the element intended for aria-describedby. For described-by content to be exposed, the referenced element should remain accessible (no aria-hidden).
'Plugins <span id="wp-menu-plugins-count-description" class="wp-menu-count-description screen-reader-text" aria-hidden="true">2 plugin updates available</span>',
tests/phpunit/tests/adminbar.php:410
- This test checks aria-describedby but does not assert that the referenced description element is not aria-hidden. Without that, the test can pass even if assistive technologies won’t announce the description.
$this->assertStringContainsString( "aria-describedby='wp-admin-bar-updates-count-description'", $html );
$this->assertStringContainsString( '<span class="screen-reader-text">Updates</span>', $html );
}
- Files reviewed: 6/6 changed files
- Comments generated: 6
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| number_format_i18n( $update_data['counts']['total'] ) | ||
| ); | ||
|
|
||
| $updates_description = '<span id="wp-menu-updates-count-description" class="wp-menu-count-description screen-reader-text" aria-hidden="true">' . $updates_text . '</span>'; |
| $title .= '<span class="screen-reader-text">' . __( 'Comments' ) . '</span>'; | ||
| $title .= '<span id="wp-admin-bar-comments-count-description" class="screen-reader-text comments-in-moderation-text" aria-hidden="true">' . $awaiting_text . '</span>'; | ||
|
|
||
| $wp_admin_bar->add_node( | ||
| array( | ||
| 'id' => 'comments', | ||
| 'title' => $icon . $title, | ||
| 'href' => admin_url( 'edit-comments.php' ), | ||
| 'meta' => array( | ||
| 'aria-describedby' => 'wp-admin-bar-comments-count-description', | ||
| ), | ||
| ) |
| $title .= '<span class="screen-reader-text">' . __( 'Updates' ) . '</span>'; | ||
| $title .= '<span id="wp-admin-bar-updates-count-description" class="screen-reader-text updates-available-text" aria-hidden="true">' . $updates_text . '</span>'; | ||
|
|
||
| $wp_admin_bar->add_node( | ||
| array( | ||
| 'id' => 'updates', | ||
| 'title' => $icon . $title, | ||
| 'href' => network_admin_url( 'update-core.php' ), | ||
| 'meta' => array( | ||
| 'aria-describedby' => 'wp-admin-bar-updates-count-description', | ||
| ), | ||
| ) |
| $title = wptexturize( $item[0] ); | ||
|
|
||
| /* | ||
| * When the menu title carries a hidden count description (e.g. pending | ||
| * updates or comments awaiting moderation), associate it with the link | ||
| * via aria-describedby. This keeps the count out of the link's accessible | ||
| * name so voice control users can operate it by its visible label, while | ||
| * the count is still announced by assistive technologies. | ||
| */ | ||
| $describedby = ''; | ||
| if ( preg_match( '/id="([^"]+)" class="wp-menu-count-description/', $item[0], $matches ) ) { | ||
| $describedby = ' aria-describedby="' . esc_attr( $matches[1] ) . '"'; | ||
| } |
| public function test_top_level_count_description_is_associated_via_aria_describedby() { | ||
| $menu = array( | ||
| array( | ||
| 'Plugins <span id="wp-menu-plugins-count-description" class="wp-menu-count-description screen-reader-text" aria-hidden="true">2 plugin updates available</span>', |
| $this->assertStringContainsString( "aria-describedby='wp-admin-bar-comments-count-description'", $html ); | ||
| $this->assertStringContainsString( 'id="wp-admin-bar-comments-count-description"', $html ); | ||
| $this->assertStringContainsString( '<span class="screen-reader-text">Comments</span>', $html ); | ||
| } |
irozum
left a comment
There was a problem hiding this comment.
Nice fix for a real a11y bug — standardizing the accessible name across the counter badges and moving the count to an aria-describedby target is the right shape for this, and the WP_Admin_Bar allowlist/meta approach for the toolbar items is clean. Ran the new test file plus the full adminbar.php suite and the broader admin group (960 tests) — all green, plus PHPCS on the changed files and PHPStan, no errors.
One thing worth resolving before commit, echoing @afercia's regex concern with the specific failure mode: menu-header.php's _wp_menu_output() recovers the description id by regex-matching the literal string id="..." class="wp-menu-count-description inside the rendered title HTML (menu-header.php:17, :68), and the submenu-head dedup does the same with preg_replace (menu-header.php:57). This only works because core happens to emit that exact attribute order every time; any menu item whose title HTML doesn't match that exact shape (a plugin building its own counter markup, or a future core change that reorders class/id) silently loses the aria-describedby association with no error — the link just quietly reverts to the old inconsistent-name behavior. Passing the description id through the menu/submenu array structure explicitly, instead of encoding it in the title string and parsing it back out at render time, would make this robust rather than shape-dependent.
Also worth flagging as a real (not just theoretical) regression: for the three left-nav menu items (Updates, Themes, Plugins — wp-menu-updates-count-description, -themes-, -plugins- in menu.php), the new hidden description span doesn't carry any of the classes updates.js targets (.update-count, .theme-count, .plugin-count — only the now-aria-hidden visible bubble does). Comments and the two toolbar items are fine since comments-in-moderation-text/updates-available-text are preserved on the description span itself. So after an AJAX-driven count change, screen reader users will hear a stale count for those three menu items indefinitely (until reload) where previously the accessible name did track the live DOM update. The PR body already flags this as an open question, but since the visible number is now aria-hidden, it goes from "cosmetic" to "the only way AT users get this information," so it reads more like a blocker for this ticket than a follow-up.
|
Thanks @afercia — you're right on both points. On the Copilot flags: agreed, they're incorrect. An element referenced by On the regex: agreed, that's the fragile part. I've reworked Separately, @irozum flagged a real regression I've now fixed in the same push: with the visible bubble |
|
Let's see if Copilot gets it right now. I'm curious. |
There was a problem hiding this comment.
🟡 Changes recommended
menu-header.php currently assumes count_description['html'] exists when an id is present, which can trigger PHP notices and output dangling aria-describedby attributes if only an id is provided.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (2)
src/wp-admin/menu-header.php:291
- Submenu rendering has the same issue as the top-level menu: if only
count_description['id']is set (withouthtml), this will throw an "Undefined index: html" notice and create a danglingaria-describedby. Gate on bothidandhtmlbefore outputting either.
$sub_describedby = '';
$sub_count_description = '';
if ( ! empty( $sub_item['count_description']['id'] ) ) {
$sub_describedby = ' aria-describedby="' . esc_attr( $sub_item['count_description']['id'] ) . '"';
$sub_count_description = $sub_item['count_description']['html'];
src/wp-admin/menu-header.php:161
_wp_menu_output()assumescount_description['html']exists whenever anidis present. If a plugin sets only theid(or omitshtml), this will trigger an "Undefined index: html" notice and emitaria-describedbypointing to non-existent markup. Consider requiring both keys before usingcount_description.
This issue also appears on line 287 of the same file.
$describedby = '';
$count_description = '';
if ( ! empty( $item['count_description']['id'] ) ) {
$describedby = ' aria-describedby="' . esc_attr( $item['count_description']['id'] ) . '"';
$count_description = $item['count_description']['html'];
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

In the admin menu and toolbar, several items show a count in a circle (Updates, Comments, Themes, Plugins, Site Health). Today the count is exposed inconsistently in each item's accessible name — Updates/Themes/Plugins/Site Health leak the raw number ("Updates 2"), Comments folds descriptive text into the name ("Comments 3 Comments in moderation"), and the icon-only toolbar items use the count text as the whole name. The result: the accessible name doesn't match the visible label, so voice-control users can't activate the item by name, and announcements are inconsistent.
This standardizes them so each link's accessible name is just the item name ("Updates", "Comments", …) and the count is exposed as a description via
aria-describedby, per the approach in the ticket.How it works — for every counter: the visible bubble is
aria-hidden="true"; the count text lives in a visually-hiddenscreen-reader-textelement with a uniqueid; the link references it witharia-describedby.menu.php): each counter item carries its descriptionidand markup explicitly via acount_descriptionentry (array( 'id' => …, 'html' => … )).menu-header.phpreads that entry to setaria-describedbyon the link (top-level and submenu anchors) and to render the description. Because the description is no longer part of the title string, the repeatedwp-submenu-headnever duplicates itsid— no HTML parsing of the title is involved, so the association doesn't depend on the title's markup shape.admin-bar.php): Comments/Updates get an explicit hidden name plus a described-by count span;class-wp-admin-bar.phpallowsaria-describedbyas a nodemetaattribute.updates.js): the AJAX count updater (wp.updates.refreshCount()) now also refreshes the Updates/Themes/Plugins menu descriptions (and clears them at zero), so the described count stays in sync after an in-place update or delete. Comments and the toolbar already stayed in sync via their preserved live-update classes.JS-updated classes (
comments-in-moderation-text,updates-available-text,pending-count,*-count) are preserved so the visible count updates keep working.Changes since first review (thanks @afercia and @irozum):
menu-header.phpwith the explicitcount_descriptionentry, so thearia-describedbyassociation can't silently break if a title's attribute order differs or a plugin builds its own counter markup.aria-hidden, the described count for the Updates/Themes/Plugins menu items would have gone stale after an AJAX update;refreshCount()now keeps those descriptions current.aria-hiddenon the description span is correct and intentional: an element referenced byaria-describedbyis still exposed to assistive technologies even when hidden, andaria-hiddenis what keeps the count out of the link's accessible name.How to test
aria-describedby→ a hiddenscreen-reader-textspan, the number span isaria-hidden="true", and each descriptionidappears exactly once in the DOM.Automated tests —
tests/phpunit/tests/admin/wpMenuOutput.php(describedby wiring, duplicate-id regression, and independence from the title's HTML shape) and additions totests/phpunit/tests/adminbar.php(toolbar +metawhitelist). TherefreshCount()description sync is covered by manual/browser testing.Open questions — exact wording of the new hidden strings.
Trac ticket: https://core.trac.wordpress.org/ticket/65793
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Root cause analysis, locating the affected menu/toolbar rendering paths, initial patch and test implementation, and the review-driven rework (explicit description-id passing and the live-count sync); final implementation, accessibility markup review, and correctness of the capability guards reviewed and edited by me.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.