From 298758ce763fcd9d52f598e12f9fba97d0ec6bd7 Mon Sep 17 00:00:00 2001 From: sanketio Date: Wed, 5 Aug 2026 11:39:24 +0530 Subject: [PATCH 1/5] Administration: Clean up counter item accessible names --- src/wp-admin/menu-header.php | 39 ++++-- src/wp-admin/menu.php | 69 ++++++++--- src/wp-includes/admin-bar.php | 12 +- src/wp-includes/class-wp-admin-bar.php | 7 +- tests/phpunit/tests/admin/wpMenuOutput.php | 133 +++++++++++++++++++++ tests/phpunit/tests/adminbar.php | 67 +++++++++++ 6 files changed, 300 insertions(+), 27 deletions(-) create mode 100644 tests/phpunit/tests/admin/wpMenuOutput.php diff --git a/src/wp-admin/menu-header.php b/src/wp-admin/menu-header.php index dcdf58d6391ce..eba8827bba3d6 100644 --- a/src/wp-admin/menu-header.php +++ b/src/wp-admin/menu-header.php @@ -145,6 +145,18 @@ function _wp_menu_output( $menu, $submenu, $submenu_as_parent = true ) { $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] ) . '"'; + } + // Hide separators from screen readers. if ( $is_separator ) { $aria_hidden = ' aria-hidden="true"'; @@ -170,9 +182,9 @@ function _wp_menu_output( $menu, $submenu, $submenu_as_parent = true ) { && ! file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) ) { $admin_is_parent = true; - echo ""; + echo ""; } else { - echo "\n\t"; + echo "\n\t"; } } elseif ( ! empty( $item[2] ) && current_user_can( $item[1] ) ) { $menu_hook = get_plugin_page_hook( $item[2], 'admin.php' ); @@ -189,15 +201,22 @@ function _wp_menu_output( $menu, $submenu, $submenu_as_parent = true ) { && ! file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) ) { $admin_is_parent = true; - echo "\n\t"; + echo "\n\t"; } else { - echo "\n\t"; + echo "\n\t"; } } if ( ! empty( $submenu_items ) ) { echo "\n\t'; diff --git a/src/wp-admin/menu.php b/src/wp-admin/menu.php index 57d94c75e26f2..f0b6863e6bec0 100644 --- a/src/wp-admin/menu.php +++ b/src/wp-admin/menu.php @@ -49,16 +49,26 @@ $capability = 'update_languages'; } + $updates_count = sprintf( + '', + $update_data['counts']['total'], + number_format_i18n( $update_data['counts']['total'] ) + ); + + $updates_text = sprintf( + /* translators: Hidden accessibility text. %s: Number of updates available. */ + _n( '%s update available', '%s updates available', $update_data['counts']['total'] ), + number_format_i18n( $update_data['counts']['total'] ) + ); + + $updates_description = ''; + $submenu['index.php'][10] = array( sprintf( /* translators: %s: Number of pending updates. */ __( 'Updates %s' ), - sprintf( - '%s', - $update_data['counts']['total'], - number_format_i18n( $update_data['counts']['total'] ) - ) - ), + $updates_count + ) . $updates_description, $capability, 'update-core.php', ); @@ -103,8 +113,11 @@ $awaiting_moderation_text = sprintf( _n( '%s Comment in moderation', '%s Comments in moderation', $awaiting_moderation ), $awaiting_moderation_i18n ); $menu[25] = array( - /* translators: %s: Number of comments. */ - sprintf( __( 'Comments %s' ), '' . $awaiting_moderation_text . '' ), + sprintf( + /* translators: %s: Number of comments. */ + __( 'Comments %s' ), + '' + ) . '', 'edit_posts', 'edit-comments.php', '', @@ -208,21 +221,30 @@ $menu[60] = array( __( 'Appearance' ), $appearance_capability, 'themes.php', '', 'menu-top menu-icon-appearance', 'menu-appearance', 'dashicons-admin-appearance' ); -$count = ''; +$count = ''; +$description = ''; if ( ! is_multisite() && current_user_can( 'update_themes' ) ) { if ( ! isset( $update_data ) ) { $update_data = wp_get_update_data(); } $count = sprintf( - '%s', + '', $update_data['counts']['themes'], number_format_i18n( $update_data['counts']['themes'] ) ); + + $themes_text = sprintf( + /* translators: Hidden accessibility text. %s: Number of available theme updates. */ + _n( '%s theme update available', '%s theme updates available', $update_data['counts']['themes'] ), + number_format_i18n( $update_data['counts']['themes'] ) + ); + + $description = ''; } /* translators: %s: Number of available theme updates. */ - $submenu['themes.php'][5] = array( sprintf( __( 'Themes %s' ), $count ), $appearance_capability, 'themes.php' ); + $submenu['themes.php'][5] = array( sprintf( __( 'Themes %s' ), $count ) . $description, $appearance_capability, 'themes.php' ); if ( wp_is_block_theme() ) { $submenu['themes.php'][6] = array( _x( 'Editor', 'site editor menu item' ), 'edit_theme_options', 'site-editor.php' ); @@ -307,20 +329,29 @@ function _add_plugin_file_editor_to_tools() { ); } -$count = ''; +$count = ''; +$description = ''; if ( ! is_multisite() && current_user_can( 'update_plugins' ) ) { if ( ! isset( $update_data ) ) { $update_data = wp_get_update_data(); } $count = sprintf( - '%s', + '', $update_data['counts']['plugins'], number_format_i18n( $update_data['counts']['plugins'] ) ); + + $plugins_text = sprintf( + /* translators: Hidden accessibility text. %s: Number of available plugin updates. */ + _n( '%s plugin update available', '%s plugin updates available', $update_data['counts']['plugins'] ), + number_format_i18n( $update_data['counts']['plugins'] ) + ); + + $description = ''; } /* translators: %s: Number of available plugin updates. */ -$menu[65] = array( sprintf( __( 'Plugins %s' ), $count ), 'activate_plugins', 'plugins.php', '', 'menu-top menu-icon-plugins', 'menu-plugins', 'dashicons-admin-plugins' ); +$menu[65] = array( sprintf( __( 'Plugins %s' ), $count ) . $description, 'activate_plugins', 'plugins.php', '', 'menu-top menu-icon-plugins', 'menu-plugins', 'dashicons-admin-plugins' ); $submenu['plugins.php'][5] = array( __( 'Installed Plugins' ), 'activate_plugins', 'plugins.php' ); @@ -381,10 +412,18 @@ function _add_plugin_file_editor_to_tools() { } $site_health_count = sprintf( - '%s', + '', $issue_counts['critical'], number_format_i18n( $issue_counts['critical'] ) ); + + $site_health_text = sprintf( + /* translators: Hidden accessibility text. %s: Number of critical Site Health checks. */ + _n( '%s critical issue', '%s critical issues', $issue_counts['critical'] ), + number_format_i18n( $issue_counts['critical'] ) + ); + + $site_health_count .= ''; } $menu[75] = array( __( 'Tools' ), 'edit_posts', 'tools.php', '', 'menu-top menu-icon-tools', 'menu-tools', 'dashicons-admin-tools' ); diff --git a/src/wp-includes/admin-bar.php b/src/wp-includes/admin-bar.php index 50868b11a2870..0a9da8f41fa76 100644 --- a/src/wp-includes/admin-bar.php +++ b/src/wp-includes/admin-bar.php @@ -1114,13 +1114,17 @@ function wp_admin_bar_comments_menu( $wp_admin_bar ) { $icon = ''; $title = ''; - $title .= '' . $awaiting_text . ''; + $title .= '' . __( 'Comments' ) . ''; + $title .= ''; $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', + ), ) ); } @@ -1229,13 +1233,17 @@ function wp_admin_bar_updates_menu( $wp_admin_bar ) { $icon = ''; $title = ''; - $title .= '' . $updates_text . ''; + $title .= '' . __( 'Updates' ) . ''; + $title .= ''; $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', + ), ) ); } diff --git a/src/wp-includes/class-wp-admin-bar.php b/src/wp-includes/class-wp-admin-bar.php index 0cf2cad3fcf24..5fa9ede5eb242 100644 --- a/src/wp-includes/class-wp-admin-bar.php +++ b/src/wp-includes/class-wp-admin-bar.php @@ -118,7 +118,8 @@ public function remove_menu( $id ) { * @type string $href Optional. Link for the item. * @type bool $group Optional. Whether or not the node is a group. Default false. * @type array $meta Meta data including the following keys: 'html', 'class', 'rel', 'lang', 'dir', - * 'onclick', 'target', 'title', 'tabindex', 'menu_title'. Default empty. + * 'onclick', 'target', 'title', 'tabindex', 'menu_title', 'aria-describedby'. + * Default empty. * } */ public function add_node( $args ) { @@ -577,10 +578,10 @@ final protected function _render_item( $node ) { echo "
  • id ) . "'$menuclass>"; if ( $has_link ) { - $attributes = array( 'onclick', 'target', 'title', 'rel', 'lang', 'dir' ); + $attributes = array( 'onclick', 'target', 'title', 'rel', 'lang', 'dir', 'aria-describedby' ); echo "user->create( array( 'role' => 'administrator' ) ); + } + + public function set_up() { + parent::set_up(); + wp_set_current_user( self::$admin_id ); + } + + /** + * A hidden count description on a top-level menu title is associated with the + * link via aria-describedby, keeping the count out of the accessible name. + * + * @ticket 65793 + */ + public function test_top_level_count_description_is_associated_via_aria_describedby() { + $menu = array( + array( + 'Plugins ', + 'activate_plugins', + 'plugins.php', + '', + 'menu-top menu-icon-plugins', + 'menu-plugins', + 'dashicons-admin-plugins', + ), + ); + + $output = get_echo( '_wp_menu_output', array( $menu, array() ) ); + + $this->assertStringContainsString( 'aria-describedby="wp-menu-plugins-count-description"', $output ); + $this->assertStringContainsString( 'id="wp-menu-plugins-count-description"', $output ); + } + + /** + * A hidden count description on a submenu title is associated with the submenu link. + * + * @ticket 65793 + */ + public function test_submenu_count_description_is_associated_via_aria_describedby() { + $menu = array( + array( 'Dashboard', 'read', 'index.php', '', 'menu-top', 'menu-dashboard', 'dashicons-dashboard' ), + ); + + $submenu = array( + 'index.php' => array( + 10 => array( + 'Updates ', + 'read', + 'update-core.php', + ), + ), + ); + + $output = get_echo( '_wp_menu_output', array( $menu, $submenu ) ); + + $this->assertStringContainsString( 'aria-describedby="wp-menu-updates-count-description"', $output ); + } + + /** + * The count description id must appear once even when the top-level item has a + * submenu, since the submenu head repeats the top-level title. + * + * @ticket 65793 + */ + public function test_count_description_id_is_not_duplicated_by_submenu_head() { + $menu = array( + array( + 'Plugins ', + 'activate_plugins', + 'plugins.php', + '', + 'menu-top menu-icon-plugins', + 'menu-plugins', + 'dashicons-admin-plugins', + ), + ); + + $submenu = array( + 'plugins.php' => array( + 5 => array( 'Installed Plugins', 'activate_plugins', 'plugins.php' ), + 10 => array( 'Add New Plugin', 'install_plugins', 'plugin-install.php' ), + ), + ); + + $output = get_echo( '_wp_menu_output', array( $menu, $submenu ) ); + + $this->assertSame( 1, substr_count( $output, 'id="wp-menu-plugins-count-description"' ) ); + $this->assertStringContainsString( 'aria-describedby="wp-menu-plugins-count-description"', $output ); + } + + /** + * Menu items without a count description do not receive an aria-describedby attribute. + * + * @ticket 65793 + */ + public function test_menu_without_count_description_has_no_aria_describedby() { + $menu = array( + array( 'Media', 'upload_files', 'upload.php', '', 'menu-top menu-icon-media', 'menu-media', 'dashicons-admin-media' ), + ); + + $output = get_echo( '_wp_menu_output', array( $menu, array() ) ); + + $this->assertStringNotContainsString( 'aria-describedby', $output ); + } +} diff --git a/tests/phpunit/tests/adminbar.php b/tests/phpunit/tests/adminbar.php index 27308bd82760e..d0a87aa7658a4 100644 --- a/tests/phpunit/tests/adminbar.php +++ b/tests/phpunit/tests/adminbar.php @@ -337,6 +337,73 @@ public function data_admin_bar_nodes_with_tabindex_meta() { ); } + /** + * A node can render an aria-describedby attribute on its link via meta. + * + * @ticket 65793 + */ + public function test_admin_bar_meta_renders_aria_describedby() { + $admin_bar = new WP_Admin_Bar(); + $admin_bar->add_node( + array( + 'id' => 'test-node', + 'href' => 'https://example.org/', + 'title' => 'Test Extra description', + 'meta' => array( 'aria-describedby' => 'test-node-description' ), + ) + ); + + $html = get_echo( array( $admin_bar, 'render' ) ); + + $this->assertStringContainsString( "aria-describedby='test-node-description'", $html ); + } + + /** + * The Comments toolbar item exposes its count as a description, keeping the + * link's accessible name free of the count so voice control can operate it. + * + * @ticket 65793 + */ + public function test_admin_bar_comments_menu_associates_count_via_describedby() { + wp_set_current_user( self::$editor_id ); + + $admin_bar = new WP_Admin_Bar(); + wp_admin_bar_comments_menu( $admin_bar ); + $html = get_echo( array( $admin_bar, 'render' ) ); + + $this->assertStringContainsString( "aria-describedby='wp-admin-bar-comments-count-description'", $html ); + $this->assertStringContainsString( 'id="wp-admin-bar-comments-count-description"', $html ); + $this->assertStringContainsString( 'Comments', $html ); + } + + /** + * The Updates toolbar item exposes its count as a description. + * + * @ticket 65793 + */ + public function test_admin_bar_updates_menu_associates_count_via_describedby() { + wp_set_current_user( self::$admin_id ); + + // Seed a pending plugin update so the Updates node is added with a count. + set_site_transient( + 'update_plugins', + (object) array( + 'response' => array( + 'fake/fake.php' => (object) array( 'new_version' => '2.0' ), + ), + ) + ); + + $admin_bar = new WP_Admin_Bar(); + wp_admin_bar_updates_menu( $admin_bar ); + $html = get_echo( array( $admin_bar, 'render' ) ); + + delete_site_transient( 'update_plugins' ); + + $this->assertStringContainsString( "aria-describedby='wp-admin-bar-updates-count-description'", $html ); + $this->assertStringContainsString( 'Updates', $html ); + } + /** * @ticket 22247 */ From d30ba34d9cfc9c92d49851dce824e814ed6a7ea0 Mon Sep 17 00:00:00 2001 From: sanketio Date: Wed, 5 Aug 2026 12:10:57 +0530 Subject: [PATCH 2/5] Administration: Make counter a11y tests multisite-safe --- tests/phpunit/tests/admin/wpMenuOutput.php | 4 ++-- tests/phpunit/tests/adminbar.php | 23 +++++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/phpunit/tests/admin/wpMenuOutput.php b/tests/phpunit/tests/admin/wpMenuOutput.php index 5aecdac9123e7..497fbd5476b42 100644 --- a/tests/phpunit/tests/admin/wpMenuOutput.php +++ b/tests/phpunit/tests/admin/wpMenuOutput.php @@ -44,7 +44,7 @@ public function test_top_level_count_description_is_associated_via_aria_describe $menu = array( array( 'Plugins ', - 'activate_plugins', + 'read', 'plugins.php', '', 'menu-top menu-icon-plugins', @@ -94,7 +94,7 @@ public function test_count_description_id_is_not_duplicated_by_submenu_head() { $menu = array( array( 'Plugins ', - 'activate_plugins', + 'read', 'plugins.php', '', 'menu-top menu-icon-plugins', diff --git a/tests/phpunit/tests/adminbar.php b/tests/phpunit/tests/adminbar.php index d0a87aa7658a4..e64fb96705f31 100644 --- a/tests/phpunit/tests/adminbar.php +++ b/tests/phpunit/tests/adminbar.php @@ -384,21 +384,26 @@ public function test_admin_bar_comments_menu_associates_count_via_describedby() public function test_admin_bar_updates_menu_associates_count_via_describedby() { wp_set_current_user( self::$admin_id ); - // Seed a pending plugin update so the Updates node is added with a count. - set_site_transient( - 'update_plugins', - (object) array( - 'response' => array( - 'fake/fake.php' => (object) array( 'new_version' => '2.0' ), + // Force a pending update so the node is added regardless of environment or capabilities. + $force_update_data = static function () { + return array( + 'counts' => array( + 'plugins' => 1, + 'themes' => 0, + 'wordpress' => 0, + 'translations' => 0, + 'total' => 1, ), - ) - ); + 'title' => '', + ); + }; + add_filter( 'wp_get_update_data', $force_update_data ); $admin_bar = new WP_Admin_Bar(); wp_admin_bar_updates_menu( $admin_bar ); $html = get_echo( array( $admin_bar, 'render' ) ); - delete_site_transient( 'update_plugins' ); + remove_filter( 'wp_get_update_data', $force_update_data ); $this->assertStringContainsString( "aria-describedby='wp-admin-bar-updates-count-description'", $html ); $this->assertStringContainsString( 'Updates', $html ); From 2a4366afc97fc3f5e030f9b766410eccad3ecc65 Mon Sep 17 00:00:00 2001 From: sanketio Date: Thu, 6 Aug 2026 11:16:38 +0530 Subject: [PATCH 3/5] Administration: Wire counter descriptions without title parsing --- src/wp-admin/menu-header.php | 50 +++++++++++--------- src/wp-admin/menu.php | 49 +++++++++++++++++--- tests/phpunit/tests/admin/wpMenuOutput.php | 54 ++++++++++++++++++++-- 3 files changed, 123 insertions(+), 30 deletions(-) diff --git a/src/wp-admin/menu-header.php b/src/wp-admin/menu-header.php index eba8827bba3d6..a308919ccd42d 100644 --- a/src/wp-admin/menu-header.php +++ b/src/wp-admin/menu-header.php @@ -75,6 +75,7 @@ function _wp_menu_output( $menu, $submenu, $submenu_as_parent = true ) { $first = true; // 0 = menu_title, 1 = capability, 2 = menu_slug, 3 = page_title, 4 = classes, 5 = hookname, 6 = icon_url. + // Optional 'count_description' = array( 'id' => string, 'html' => string ) for a counter's hidden description. foreach ( $menu as $key => $item ) { $admin_is_parent = false; $class = array(); @@ -146,15 +147,20 @@ function _wp_menu_output( $menu, $submenu, $submenu_as_parent = true ) { $title = wptexturize( $item[0] ); /* - * When the menu title carries a hidden count description (e.g. pending + * When a menu item 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. + * via aria-describedby and render it inside the link. 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. The id and markup are supplied explicitly by + * the menu item's 'count_description' entry rather than parsed from the + * title, so the association does not depend on the title's HTML shape. */ - $describedby = ''; - if ( preg_match( '/id="([^"]+)" class="wp-menu-count-description/', $item[0], $matches ) ) { - $describedby = ' aria-describedby="' . esc_attr( $matches[1] ) . '"'; + $describedby = ''; + $count_description = ''; + if ( ! empty( $item['count_description']['id'] ) ) { + $describedby = ' aria-describedby="' . esc_attr( $item['count_description']['id'] ) . '"'; + $count_description = $item['count_description']['html']; } // Hide separators from screen readers. @@ -182,9 +188,9 @@ function _wp_menu_output( $menu, $submenu, $submenu_as_parent = true ) { && ! file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) ) { $admin_is_parent = true; - echo ""; + echo ""; } else { - echo "\n\t"; + echo "\n\t"; } } elseif ( ! empty( $item[2] ) && current_user_can( $item[1] ) ) { $menu_hook = get_plugin_page_hook( $item[2], 'admin.php' ); @@ -201,9 +207,9 @@ function _wp_menu_output( $menu, $submenu, $submenu_as_parent = true ) { && ! file_exists( ABSPATH . "/wp-admin/$menu_file" ) ) ) { $admin_is_parent = true; - echo "\n\t"; + echo "\n\t"; } else { - echo "\n\t"; + echo "\n\t"; } } @@ -211,16 +217,16 @@ function _wp_menu_output( $menu, $submenu, $submenu_as_parent = true ) { echo "\n\t'; diff --git a/src/wp-admin/menu.php b/src/wp-admin/menu.php index f0b6863e6bec0..7c1e4237f321b 100644 --- a/src/wp-admin/menu.php +++ b/src/wp-admin/menu.php @@ -68,11 +68,17 @@ /* translators: %s: Number of pending updates. */ __( 'Updates %s' ), $updates_count - ) . $updates_description, + ), $capability, 'update-core.php', ); + // Associate the hidden count description with the link. See _wp_menu_output(). + $submenu['index.php'][10]['count_description'] = array( + 'id' => 'wp-menu-updates-count-description', + 'html' => $updates_description, + ); + unset( $capability ); } @@ -117,7 +123,7 @@ /* translators: %s: Number of comments. */ __( 'Comments %s' ), '' - ) . '', + ), 'edit_posts', 'edit-comments.php', '', @@ -126,6 +132,12 @@ 'dashicons-admin-comments', ); + // Associate the hidden count description with the link. See _wp_menu_output(). + $menu[25]['count_description'] = array( + 'id' => 'wp-menu-comments-count-description', + 'html' => '', + ); + unset( $awaiting_moderation ); } @@ -244,7 +256,15 @@ } /* translators: %s: Number of available theme updates. */ - $submenu['themes.php'][5] = array( sprintf( __( 'Themes %s' ), $count ) . $description, $appearance_capability, 'themes.php' ); + $submenu['themes.php'][5] = array( sprintf( __( 'Themes %s' ), $count ), $appearance_capability, 'themes.php' ); + + // Associate the hidden count description with the link. See _wp_menu_output(). +if ( '' !== $description ) { + $submenu['themes.php'][5]['count_description'] = array( + 'id' => 'wp-menu-themes-count-description', + 'html' => $description, + ); +} if ( wp_is_block_theme() ) { $submenu['themes.php'][6] = array( _x( 'Editor', 'site editor menu item' ), 'edit_theme_options', 'site-editor.php' ); @@ -351,7 +371,15 @@ function _add_plugin_file_editor_to_tools() { } /* translators: %s: Number of available plugin updates. */ -$menu[65] = array( sprintf( __( 'Plugins %s' ), $count ) . $description, 'activate_plugins', 'plugins.php', '', 'menu-top menu-icon-plugins', 'menu-plugins', 'dashicons-admin-plugins' ); +$menu[65] = array( sprintf( __( 'Plugins %s' ), $count ), 'activate_plugins', 'plugins.php', '', 'menu-top menu-icon-plugins', 'menu-plugins', 'dashicons-admin-plugins' ); + +// Associate the hidden count description with the link. See _wp_menu_output(). +if ( '' !== $description ) { + $menu[65]['count_description'] = array( + 'id' => 'wp-menu-plugins-count-description', + 'html' => $description, + ); +} $submenu['plugins.php'][5] = array( __( 'Installed Plugins' ), 'activate_plugins', 'plugins.php' ); @@ -393,7 +421,8 @@ function _add_plugin_file_editor_to_tools() { } } -$site_health_count = ''; +$site_health_count = ''; +$site_health_description = ''; if ( ! is_multisite() && current_user_can( 'view_site_health_checks' ) ) { $get_issues = get_transient( 'health-check-site-status-result' ); @@ -423,7 +452,7 @@ function _add_plugin_file_editor_to_tools() { number_format_i18n( $issue_counts['critical'] ) ); - $site_health_count .= ''; + $site_health_description = ''; } $menu[75] = array( __( 'Tools' ), 'edit_posts', 'tools.php', '', 'menu-top menu-icon-tools', 'menu-tools', 'dashicons-admin-tools' ); @@ -432,6 +461,14 @@ function _add_plugin_file_editor_to_tools() { $submenu['tools.php'][15] = array( __( 'Export' ), 'export', 'export.php' ); /* translators: %s: Number of critical Site Health checks. */ $submenu['tools.php'][20] = array( sprintf( __( 'Site Health %s' ), $site_health_count ), 'view_site_health_checks', 'site-health.php' ); + + // Associate the hidden count description with the link. See _wp_menu_output(). +if ( '' !== $site_health_description ) { + $submenu['tools.php'][20]['count_description'] = array( + 'id' => 'wp-menu-site-health-count-description', + 'html' => $site_health_description, + ); +} $submenu['tools.php'][25] = array( __( 'Export Personal Data' ), 'export_others_personal_data', 'export-personal-data.php' ); $submenu['tools.php'][30] = array( __( 'Erase Personal Data' ), 'erase_others_personal_data', 'erase-personal-data.php' ); if ( is_multisite() && ! is_main_site() && '1' !== get_site()->deleted ) { diff --git a/tests/phpunit/tests/admin/wpMenuOutput.php b/tests/phpunit/tests/admin/wpMenuOutput.php index 497fbd5476b42..b8119ffb51e29 100644 --- a/tests/phpunit/tests/admin/wpMenuOutput.php +++ b/tests/phpunit/tests/admin/wpMenuOutput.php @@ -43,7 +43,7 @@ public function set_up() { public function test_top_level_count_description_is_associated_via_aria_describedby() { $menu = array( array( - 'Plugins ', + 'Plugins ', 'read', 'plugins.php', '', @@ -53,10 +53,16 @@ public function test_top_level_count_description_is_associated_via_aria_describe ), ); + $menu[0]['count_description'] = array( + 'id' => 'wp-menu-plugins-count-description', + 'html' => '', + ); + $output = get_echo( '_wp_menu_output', array( $menu, array() ) ); $this->assertStringContainsString( 'aria-describedby="wp-menu-plugins-count-description"', $output ); $this->assertStringContainsString( 'id="wp-menu-plugins-count-description"', $output ); + $this->assertStringContainsString( '2 plugin updates available', $output ); } /** @@ -72,16 +78,22 @@ public function test_submenu_count_description_is_associated_via_aria_describedb $submenu = array( 'index.php' => array( 10 => array( - 'Updates ', + 'Updates ', 'read', 'update-core.php', ), ), ); + $submenu['index.php'][10]['count_description'] = array( + 'id' => 'wp-menu-updates-count-description', + 'html' => '', + ); + $output = get_echo( '_wp_menu_output', array( $menu, $submenu ) ); $this->assertStringContainsString( 'aria-describedby="wp-menu-updates-count-description"', $output ); + $this->assertStringContainsString( '2 updates available', $output ); } /** @@ -93,7 +105,7 @@ public function test_submenu_count_description_is_associated_via_aria_describedb public function test_count_description_id_is_not_duplicated_by_submenu_head() { $menu = array( array( - 'Plugins ', + 'Plugins ', 'read', 'plugins.php', '', @@ -103,6 +115,11 @@ public function test_count_description_id_is_not_duplicated_by_submenu_head() { ), ); + $menu[0]['count_description'] = array( + 'id' => 'wp-menu-plugins-count-description', + 'html' => '', + ); + $submenu = array( 'plugins.php' => array( 5 => array( 'Installed Plugins', 'activate_plugins', 'plugins.php' ), @@ -116,6 +133,37 @@ public function test_count_description_id_is_not_duplicated_by_submenu_head() { $this->assertStringContainsString( 'aria-describedby="wp-menu-plugins-count-description"', $output ); } + /** + * The count description association is driven by the item's explicit + * 'count_description' entry, not by parsing the title HTML, so it works + * regardless of the markup shape of the title. + * + * @ticket 65793 + */ + public function test_count_description_is_independent_of_title_html_shape() { + $menu = array( + array( + // Title markup that would not match a fixed id-then-class regex. + 'Plugins 2', + 'read', + 'plugins.php', + '', + 'menu-top menu-icon-plugins', + 'menu-plugins', + 'dashicons-admin-plugins', + ), + ); + + $menu[0]['count_description'] = array( + 'id' => 'wp-menu-plugins-count-description', + 'html' => '', + ); + + $output = get_echo( '_wp_menu_output', array( $menu, array() ) ); + + $this->assertStringContainsString( 'aria-describedby="wp-menu-plugins-count-description"', $output ); + } + /** * Menu items without a count description do not receive an aria-describedby attribute. * From 90c2d217c902fb008f3fa5d4de777fcaa10b1446 Mon Sep 17 00:00:00 2001 From: sanketio Date: Thu, 6 Aug 2026 11:16:38 +0530 Subject: [PATCH 4/5] Administration: Sync menu counter descriptions on live updates --- src/js/_enqueues/wp/updates.js | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/js/_enqueues/wp/updates.js b/src/js/_enqueues/wp/updates.js index ef4b47e66093e..1a61c11a49f98 100644 --- a/src/js/_enqueues/wp/updates.js +++ b/src/js/_enqueues/wp/updates.js @@ -387,6 +387,21 @@ $dashboardNavMenuUpdateCount.remove(); } + /* + * Keep the menu item's hidden count description in sync. The visible + * count bubble is aria-hidden, so this description is what assistive + * technologies announce via aria-describedby. + */ + $( '#wp-menu-updates-count-description' ).text( + settings.totals.counts.total > 0 + ? sprintf( + /* translators: %s: Number of updates available. */ + _n( '%s update available', '%s updates available', settings.totals.counts.total ), + settings.totals.counts.total + ) + : '' + ); + // Update the "Plugins" menu item. $pluginsNavMenuUpdateCount.each( function( index, element ) { element.className = element.className.replace( /count-\d+/, 'count-' + settings.totals.counts.plugins ); @@ -397,6 +412,17 @@ $pluginsNavMenuUpdateCount.remove(); } + // Keep the menu item's hidden count description in sync. See above. + $( '#wp-menu-plugins-count-description' ).text( + settings.totals.counts.plugins > 0 + ? sprintf( + /* translators: %s: Number of available plugin updates. */ + _n( '%s plugin update available', '%s plugin updates available', settings.totals.counts.plugins ), + settings.totals.counts.plugins + ) + : '' + ); + // Update the "Appearance" menu item. $appearanceNavMenuUpdateCount.each( function( index, element ) { element.className = element.className.replace( /count-\d+/, 'count-' + settings.totals.counts.themes ); @@ -407,6 +433,17 @@ $appearanceNavMenuUpdateCount.remove(); } + // Keep the menu item's hidden count description in sync. See above. + $( '#wp-menu-themes-count-description' ).text( + settings.totals.counts.themes > 0 + ? sprintf( + /* translators: %s: Number of available theme updates. */ + _n( '%s theme update available', '%s theme updates available', settings.totals.counts.themes ), + settings.totals.counts.themes + ) + : '' + ); + // Update list table filter navigation. if ( 'plugins' === pagenow || 'plugins-network' === pagenow ) { itemCount = settings.totals.counts.plugins; From 7f440ab98dab11a3fb9ea146355775c0c718d369 Mon Sep 17 00:00:00 2001 From: sanketio Date: Thu, 6 Aug 2026 11:21:02 +0530 Subject: [PATCH 5/5] Administration: Satisfy JSHint in counter description sync --- src/js/_enqueues/wp/updates.js | 36 ++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/js/_enqueues/wp/updates.js b/src/js/_enqueues/wp/updates.js index 1a61c11a49f98..02007e1d8f6ab 100644 --- a/src/js/_enqueues/wp/updates.js +++ b/src/js/_enqueues/wp/updates.js @@ -392,15 +392,17 @@ * count bubble is aria-hidden, so this description is what assistive * technologies announce via aria-describedby. */ - $( '#wp-menu-updates-count-description' ).text( - settings.totals.counts.total > 0 - ? sprintf( + if ( settings.totals.counts.total > 0 ) { + $( '#wp-menu-updates-count-description' ).text( + sprintf( /* translators: %s: Number of updates available. */ _n( '%s update available', '%s updates available', settings.totals.counts.total ), settings.totals.counts.total ) - : '' - ); + ); + } else { + $( '#wp-menu-updates-count-description' ).text( '' ); + } // Update the "Plugins" menu item. $pluginsNavMenuUpdateCount.each( function( index, element ) { @@ -413,15 +415,17 @@ } // Keep the menu item's hidden count description in sync. See above. - $( '#wp-menu-plugins-count-description' ).text( - settings.totals.counts.plugins > 0 - ? sprintf( + if ( settings.totals.counts.plugins > 0 ) { + $( '#wp-menu-plugins-count-description' ).text( + sprintf( /* translators: %s: Number of available plugin updates. */ _n( '%s plugin update available', '%s plugin updates available', settings.totals.counts.plugins ), settings.totals.counts.plugins ) - : '' - ); + ); + } else { + $( '#wp-menu-plugins-count-description' ).text( '' ); + } // Update the "Appearance" menu item. $appearanceNavMenuUpdateCount.each( function( index, element ) { @@ -434,15 +438,17 @@ } // Keep the menu item's hidden count description in sync. See above. - $( '#wp-menu-themes-count-description' ).text( - settings.totals.counts.themes > 0 - ? sprintf( + if ( settings.totals.counts.themes > 0 ) { + $( '#wp-menu-themes-count-description' ).text( + sprintf( /* translators: %s: Number of available theme updates. */ _n( '%s theme update available', '%s theme updates available', settings.totals.counts.themes ), settings.totals.counts.themes ) - : '' - ); + ); + } else { + $( '#wp-menu-themes-count-description' ).text( '' ); + } // Update list table filter navigation. if ( 'plugins' === pagenow || 'plugins-network' === pagenow ) {