Skip to content

Administration: Clean up counter item accessible names - #12850

Open
sanketio wants to merge 6 commits into
WordPress:trunkfrom
sanketio:feat/65793
Open

Administration: Clean up counter item accessible names#12850
sanketio wants to merge 6 commits into
WordPress:trunkfrom
sanketio:feat/65793

Conversation

@sanketio

@sanketio sanketio commented Aug 5, 2026

Copy link
Copy Markdown

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-hidden screen-reader-text element with a unique id; the link references it with aria-describedby.

  • Admin menu (menu.php): each counter item carries its description id and markup explicitly via a count_description entry (array( 'id' => …, 'html' => … )). menu-header.php reads that entry to set aria-describedby on the link (top-level and submenu anchors) and to render the description. Because the description is no longer part of the title string, the repeated wp-submenu-head never duplicates its id — no HTML parsing of the title is involved, so the association doesn't depend on the title's markup shape.
  • Toolbar (admin-bar.php): Comments/Updates get an explicit hidden name plus a described-by count span; class-wp-admin-bar.php allows aria-describedby as a node meta attribute.
  • Live updates (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):

  • Replaced the title-HTML regex in menu-header.php with the explicit count_description entry, so the aria-describedby association can't silently break if a title's attribute order differs or a plugin builds its own counter markup.
  • Fixed a real regression: with the visible bubble now 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.
  • Confirmed the aria-hidden on the description span is correct and intentional: an element referenced by aria-describedby is still exposed to assistive technologies even when hidden, and aria-hidden is what keeps the count out of the link's accessible name.

How to test

  1. As an admin, load the Dashboard.
  2. Inspect the menu links for Updates, Comments, Themes, Plugins, Tools → Site Health, and the toolbar Comments/Updates items.
  3. Confirm each link has aria-describedby → a hidden screen-reader-text span, the number span is aria-hidden="true", and each description id appears exactly once in the DOM.
  4. With a screen reader, confirm the link is announced as its name plus the count as a description.
  5. Update or delete a plugin/theme in place (no reload) and confirm the described count for the Updates/Plugins/Themes menu items changes with the visible bubble, and clears when it reaches zero.

Automated teststests/phpunit/tests/admin/wpMenuOutput.php (describedby wiring, duplicate-id regression, and independence from the title's HTML shape) and additions to tests/phpunit/tests/adminbar.php (toolbar + meta whitelist). The refreshCount() 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.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

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 props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props sanketparmar, irozum, afercia.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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-describedby support to WP_Admin_Bar node meta and 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.

Comment thread src/wp-admin/menu.php
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>';
Comment on lines +1117 to 1128
$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',
),
)
Comment on lines +1236 to 1247
$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',
),
)
Comment on lines 146 to +158
$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>',
Comment on lines +374 to +377
$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 );
}
@afercia

afercia commented Aug 5, 2026

Copy link
Copy Markdown
Member

I asked Copilot to review this PR. I see potential problems both in the PR and in the Copilot review.

  • I'm concerned about the regex approach. I doesn't seem solid to me and that's up to Claude, used for the initial implementation of this PR.
  • Copilot completely ignored this potential problem.
  • Instead, it repeatedly warned about the element referenced by aria-describedby being hidden from assistive technology by the means of aria-hidden. That is plain wrong. The content of elements referenced by aria-describedby is exposed in the accessibility tree even if the element is aria-hidden and even if it's totally hidden with display: none.

So it appears that, at least in this PR, usage of AI is not only misleading but also unnecessarily time consuming.

Screenshot 2026-08-05 at 22 42 03

@irozum irozum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sanketio

sanketio commented Aug 6, 2026

Copy link
Copy Markdown
Author

Thanks @afercia — you're right on both points.

On the Copilot flags: agreed, they're incorrect. An element referenced by aria-describedby is included in the description computation even when it's aria-hidden (or display:none) — per the Accessible Name and Description Computation, a hidden node that's directly referenced isn't skipped. The aria-hidden on the description span is deliberate: it sits inside the link, so aria-hidden keeps its text out of the link's accessible name while aria-describedby still exposes it as the description. So there's nothing to action there.

On the regex: agreed, that's the fragile part. I've reworked menu-header.php so the description id is passed through explicitly (a count_description entry on the menu item) rather than parsed out of the title HTML — the association no longer depends on attribute order or a plugin's markup shape, and the submenu-head dedup regex is gone too.

Separately, @irozum flagged a real regression I've now fixed in the same push: with the visible bubble aria-hidden, the Updates/Themes/Plugins menu descriptions would go stale after an AJAX update — wp.updates.refreshCount() now keeps them in sync (and clears them at zero).

@afercia

afercia commented Aug 6, 2026

Copy link
Copy Markdown
Member

Let's see if Copilot gets it right now. I'm curious.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 (without html), this will throw an "Undefined index: html" notice and create a dangling aria-describedby. Gate on both id and html before 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() assumes count_description['html'] exists whenever an id is present. If a plugin sets only the id (or omits html), this will trigger an "Undefined index: html" notice and emit aria-describedby pointing to non-existent markup. Consider requiring both keys before using count_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants