From 8e26379b3eb517d198a0a9d034f0bdeb29590c51 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Thu, 21 May 2026 19:30:16 +0530 Subject: [PATCH] Administration: Improve admin notices accessibility. Wrap PHP admin notices in a complementary landmark, prepend the notice count to the admin document title, add a conditional skip link, and assign ARIA live region roles to standardized and JavaScript-generated notices. Props afercia, joedolson. Fixes #50486. --- src/js/_enqueues/admin/common.js | 6 +- src/js/_enqueues/wp/updates.js | 2 + src/wp-admin/admin-header.php | 32 +--- src/wp-admin/includes/admin-filters.php | 3 + src/wp-admin/includes/admin-notices.php | 163 ++++++++++++++++++ src/wp-admin/includes/admin.php | 3 + src/wp-admin/includes/update.php | 4 +- src/wp-admin/menu-header.php | 3 + src/wp-includes/functions.php | 10 ++ tests/phpunit/tests/admin/wpAdminNotices.php | 121 +++++++++++++ .../phpunit/tests/functions/wpAdminNotice.php | 18 +- .../tests/functions/wpGetAdminNotice.php | 18 +- 12 files changed, 333 insertions(+), 50 deletions(-) create mode 100644 src/wp-admin/includes/admin-notices.php create mode 100644 tests/phpunit/tests/admin/wpAdminNotices.php diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index 2a7daba0d2dc4..57d6fa927b27c 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -1087,7 +1087,11 @@ $( function() { if ( ! $headerEnd.length ) { $headerEnd = $( '.wrap h1, .wrap h2' ).first(); } - $( 'div.updated, div.error, div.notice' ).not( '.inline, .below-h2' ).insertAfter( $headerEnd ); + if ( $( '#wp-admin-notices' ).length ) { + $( '#wp-admin-notices' ).insertAfter( $headerEnd ); + } else { + $( 'div.updated, div.error, div.notice' ).not( '.inline, .below-h2' ).insertAfter( $headerEnd ); + } /** * Makes notices dismissible. diff --git a/src/js/_enqueues/wp/updates.js b/src/js/_enqueues/wp/updates.js index ef4b47e66093e..504e3a2a5e1db 100644 --- a/src/js/_enqueues/wp/updates.js +++ b/src/js/_enqueues/wp/updates.js @@ -267,6 +267,8 @@ $notice.replaceWith( $adminNotice ); } else if ( $headerEnd.length ) { $headerEnd.after( $adminNotice ); + } else if ( $( '#wp-admin-notices' ).length ) { + $( '#wp-admin-notices' ).append( $adminNotice ); } else { if ( 'customize' === pagenow ) { $( '.customize-themes-notifications' ).append( $adminNotice ); diff --git a/src/wp-admin/admin-header.php b/src/wp-admin/admin-header.php index e1e9ba0f6562b..f70edb156359d 100644 --- a/src/wp-admin/admin-header.php +++ b/src/wp-admin/admin-header.php @@ -78,6 +78,8 @@ $admin_title = sprintf( __( 'Recovery Mode — %s' ), $admin_title ); } +wp_capture_admin_notices(); + /** * Filters the title tag content for an admin page. * @@ -290,35 +292,7 @@ $current_screen->render_screen_meta(); -if ( is_network_admin() ) { - /** - * Prints network admin screen notices. - * - * @since 3.1.0 - */ - do_action( 'network_admin_notices' ); -} elseif ( is_user_admin() ) { - /** - * Prints user admin screen notices. - * - * @since 3.1.0 - */ - do_action( 'user_admin_notices' ); -} else { - /** - * Prints admin screen notices. - * - * @since 3.1.0 - */ - do_action( 'admin_notices' ); -} - -/** - * Prints generic admin screen notices. - * - * @since 3.1.0 - */ -do_action( 'all_admin_notices' ); +wp_render_admin_notices(); if ( 'options-general.php' === $parent_file ) { require ABSPATH . 'wp-admin/options-head.php'; diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php index 5337cc02c88c9..cb0a0779077dd 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -116,6 +116,9 @@ // Theme Install hooks. add_action( 'install_themes_pre_theme-information', 'install_theme_information' ); +// Admin notices hooks. +add_filter( 'admin_title', 'wp_prepend_admin_notices_count_to_admin_title' ); + // User hooks. add_action( 'admin_init', 'default_password_nag_handler' ); diff --git a/src/wp-admin/includes/admin-notices.php b/src/wp-admin/includes/admin-notices.php new file mode 100644 index 0000000000000..d4df1629bd1bd --- /dev/null +++ b/src/wp-admin/includes/admin-notices.php @@ -0,0 +1,163 @@ + $html, + 'count' => wp_count_admin_notices_from_html( $html ), + ); + + return $wp_captured_admin_notices; +} + +/** + * Determines whether admin notices are present for the current screen. + * + * @since 7.1.0 + * + * @return bool Whether admin notices are present. + */ +function wp_has_admin_notices() { + $notices = wp_capture_admin_notices(); + + return $notices['count'] > 0; +} + +/** + * Returns the number of admin notices for the current screen. + * + * @since 7.1.0 + * + * @return int The number of admin notices. + */ +function wp_get_admin_notices_count() { + $notices = wp_capture_admin_notices(); + + return $notices['count']; +} + +/** + * Counts non-inline admin notices in the given HTML markup. + * + * @since 7.1.0 + * + * @param string $html Admin notices HTML markup. + * @return int The number of admin notices. + */ +function wp_count_admin_notices_from_html( $html ) { + if ( '' === trim( $html ) ) { + return 0; + } + + $count = 0; + + if ( preg_match_all( '/]*\bclass=["\'][^"\']*\b(?:notice|updated|error)\b[^"\']*["\'][^>]*>/i', $html, $matches ) ) { + foreach ( $matches[0] as $opening_tag ) { + if ( preg_match( '/\b(?:inline|below-h2)\b/', $opening_tag ) ) { + continue; + } + + ++$count; + } + } + + return $count; +} + +/** + * Prepends the admin notices count to the admin page title. + * + * @since 7.1.0 + * + * @param string $admin_title The page title, with extra context added. + * @return string The filtered page title. + */ +function wp_prepend_admin_notices_count_to_admin_title( $admin_title ) { + $count = wp_get_admin_notices_count(); + + if ( $count < 1 ) { + return $admin_title; + } + + return sprintf( + /* translators: 1: Number of admin notices, 2: Admin page title. */ + _n( '(%1$s notice) %2$s', '(%1$s notices) %2$s', $count ), + number_format_i18n( $count ), + $admin_title + ); +} + +/** + * Renders captured admin notices within an accessible landmark container. + * + * @since 7.1.0 + */ +function wp_render_admin_notices() { + $notices = wp_capture_admin_notices(); + + if ( '' === trim( $notices['html'] ) ) { + return; + } + + printf( + '', + esc_attr__( 'Administrative notices' ), + $notices['html'] // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); +} diff --git a/src/wp-admin/includes/admin.php b/src/wp-admin/includes/admin.php index ce2ec0c68b855..b22ffb9eb442d 100644 --- a/src/wp-admin/includes/admin.php +++ b/src/wp-admin/includes/admin.php @@ -64,6 +64,9 @@ /** WordPress Template Administration API */ require_once ABSPATH . 'wp-admin/includes/template.php'; +/** WordPress Administration Notices API */ +require_once ABSPATH . 'wp-admin/includes/admin-notices.php'; + /** WordPress List Table Administration API and base class */ require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-list-table-compat.php'; diff --git a/src/wp-admin/includes/update.php b/src/wp-admin/includes/update.php index f5aeea835bd12..7c30d783b6b9e 100644 --- a/src/wp-admin/includes/update.php +++ b/src/wp-admin/includes/update.php @@ -916,10 +916,10 @@ function maintenance_nag() { function wp_print_admin_notice_templates() { ?>