Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0789f14
Experiment with admin notices
joedolson Sep 22, 2026
f890f4c
Capture notices & add to title.
joedolson Sep 22, 2026
6a28fd6
Move notices inside an `aside`.
joedolson Sep 22, 2026
a38a974
Merge branch 'trunk' into admin-notices-accessibility
joedolson Sep 22, 2026
ff55f84
Ha. That's not PHP...
joedolson Sep 22, 2026
1d25d9a
Update class-wp-admin-notice-collector.php
joedolson Sep 22, 2026
d283e3d
Merge branch 'admin-notices-accessibility' of https://github.com/joed…
joedolson Sep 22, 2026
814bf75
Merge branch 'trunk' into admin-notices-accessibility
joedolson Sep 24, 2026
272f591
Merge remote-tracking branch 'upstream/trunk' into admin-notices-acce…
joedolson Sep 24, 2026
300b858
Make moveAdminNotices() as a named function.
joedolson Sep 25, 2026
001aabd
Add a unique ID to all admin notices.
joedolson Sep 25, 2026
bd5a321
Key the notice array by unique ID.
joedolson Sep 25, 2026
0f7fc50
Merge remote-tracking branch 'upstream/trunk' into admin-notices-acce…
joedolson Oct 2, 2026
8701035
Code standards
joedolson Oct 2, 2026
4ce6f06
Merge branch 'trunk' into admin-notices-accessibility
joedolson Oct 5, 2026
bfea6d7
Merge branch 'trunk' into admin-notices-accessibility
joedolson Oct 5, 2026
13b034c
Include id and data-id in test expectations.
peterwilsoncc Oct 7, 2026
4a56af7
Merge branch 'trunk' into admin-notices-accessibility
peterwilsoncc Oct 7, 2026
47c2c5a
Fix alignment.
afercia Oct 7, 2026
3fe0e38
Adjust test.
afercia Oct 7, 2026
129d943
Fix CS.
afercia Oct 7, 2026
057341f
Merge branch 'trunk' into admin-notices-accessibility
joedolson Oct 10, 2026
1136028
If the passed ID not a string, fallback to generated ID.
joedolson Oct 10, 2026
86eec42
Add @since versioning to collector.
joedolson Oct 10, 2026
4221e09
Order of arguments is wrong
joedolson Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions src/js/_enqueues/admin/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -1075,17 +1075,6 @@ $( function() {
});
}

/*
* The `.below-h2` class is here just for backward compatibility with plugins
* that are (incorrectly) using it. Do not use. Use `.inline` instead. See #34570.
* If '.wp-header-end' is found, append the notices after it otherwise
* after the first h1 or h2 heading found within the main content.
*/
if ( ! $headerEnd.length ) {
$headerEnd = $( '.wrap h1, .wrap h2' ).first();
}
$( 'div.updated, div.error, div.notice' ).not( '.inline, .below-h2' ).insertAfter( $headerEnd );

/**
* Makes notices dismissible.
*
Expand Down Expand Up @@ -1525,6 +1514,32 @@ $( function() {
input.on('change', toggleUploadButton);
})();

/**
* Moves the admin notices into an `aside` element after the first `h1`.
* Prior to 7.2.0, admin notices were only moved after the first `h1`;
* The `aside` wrapper was added in 7.2.0.
*
* @since 7.2.0
*
* @return {void}
*/
function moveAdminNotices() {
/*
* The `.below-h2` class is here just for backward compatibility with plugins
* that are (incorrectly) using it. Do not use. Use `.inline` instead. See #34570.
* If '.wp-header-end' is found, append the notices after it otherwise
* after the first h1 or h2 heading found within the main content.
*/
if ( ! $headerEnd.length ) {
$headerEnd = $( '.wrap h1, .wrap h2' ).first();
}
const $notices = $( 'div.updated, div.error, div.notice' ).not( '.inline, .below-h2' );
if ( $notices.length ) {
const $wpAdminNotices = $( '<aside id="wp-admin-notices"></aside>' ).attr( 'aria-label', __( 'Admin Notices' ) ).insertAfter( $headerEnd );
$notices.appendTo( $wpAdminNotices );
}
}

/**
* Pins the menu while distraction-free writing is enabled.
*
Expand Down Expand Up @@ -2127,6 +2142,7 @@ $( function() {
});

window.wpResponsive.init();
moveAdminNotices();
setPinMenu();
setMenuState();
makeNoticesDismissible();
Expand Down
2 changes: 2 additions & 0 deletions src/wp-admin/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@

require_once ABSPATH . 'wp-admin/includes/admin.php';

WP_Admin_Notice_Collector::init();

auth_redirect();

// Schedule Trash collection.
Expand Down
165 changes: 165 additions & 0 deletions src/wp-includes/class-wp-admin-notice-collector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php
/**
* Administration API: WP_Admin_Notice_Collector class
*
* @package WordPress
* @subpackage Administration
* @since x.x.x

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's push an additional commit to fix this to the correct version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I could have sworn I'd gotten all of those... apparently not!

*/

/**
* Capture admin notices generated in PHP during a request.
*
* Notices are captured via the {@see 'wp_admin_notice_markup'} filter.
* a title placeholder is inserted into the admin page's `<title>` tag,
* the admin page title is modified via the {@see 'admin_title'}
* filter, the admin page is wrapped in an output buffer, and an
* `ob_start()` callback replaces the placeholder with the final count once
* the buffer is flushed.
*
* @since 7.2.0
*/
class WP_Admin_Notice_Collector {

/**
* Placeholder inserted into the admin title, replaced with the
* actual notice count once it is known.
*
* @since 7.2.0
* @var string
*/
const TITLE_PLACEHOLDER = '%%wp_admin_notice_count%%';

/**
* Notices captured during the current request.
*
* @since 7.2.0
* @var array[]
*/
protected static $notices = array();

/**
* Whether the collector's hooks have already been registered.
*
* @since 7.2.0
* @var bool
*/
protected static $hooked = false;

/**
* Registers the hooks used to collect notices and to annotate the admin title.
*
* @since 7.2.0
*/
public static function init() {
if ( self::$hooked ) {
return;
}

self::$hooked = true;

add_filter( 'wp_admin_notice_markup', array( __CLASS__, 'capture_notice' ), 10, 3 );

// Ajax/REST admin requests don't render a <title>, so skip the buffer there.
if ( is_admin() && ! wp_doing_ajax() ) {
add_filter( 'admin_title', array( __CLASS__, 'add_title_placeholder' ), 10, 2 );
ob_start( array( __CLASS__, 'inject_notice_count' ) );
}
}

/**
* Captures a single admin notice without altering its markup.
*
* Hooked to the {@see 'wp_admin_notice_markup'} filter.
*
* @since 7.2.0
*
* @param string $markup The HTML markup for the admin notice.
* @param string $message The message for the admin notice.
* @param array $args The arguments for the admin notice.
* @return string The unmodified markup, so the filter remains transparent.
*/
public static function capture_notice( $markup, $message, $args ) {
if ( '' !== trim( wp_strip_all_tags( $message ) ) ) {
self::$notices[ $args['notice_id'] ] = array(
'markup' => $markup,
'message' => $message,
'args' => $args,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I understand this class and method have been designed for a generic implementation but why it is building an object with the markup, message, and args? To my understanding, they are never used. If I'm not missing anything, the $notices array is only used to count the notices.
It so, there is no need to get the markup/message/args. We could also consider to not use the wp_admin_notice_markup filter and use the wp_admin_notice action hook instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was hypothesizing that this could also be used to build out a list of notices that could be triggered, e.g. from the admin bar, to be able to browse notices. That's not something that's critical to implement in this PR, but I was experimenting with the implementation.

The wp_admin_notice action isn't viable, because it doesn't catch anything that uses a echo wp_get_admin_notice() or $message = wp_get_admin_notice() pattern.

}

return $markup;
}

/**
* Returns all notices captured during the current request.
*
* @since 7.2.0
*
* @return array[] Array of captured notices. Each entry contains the
* notice's 'markup', 'message', and 'args'.
*/
public static function get_notices() {
return self::$notices;
}

/**
* Returns the number of notices captured during the current request.
*
* @since 7.2.0
*
* @return int Notice count.
*/
public static function get_notice_count() {
return count( self::$notices );
}

/**
* Clears all previously captured notices.
*
* @since 7.2.0
*/
public static function reset() {
self::$notices = array();
}

/**
* Prepends a title placeholder via the {@see 'admin_title'} filter.
*
* The real count isn't known yet at this point, since notices are usually
* produced later in the page. inject_notice_count() resolves it.
*
* @since 7.2.0
*
* @param string $admin_title The page title, with extra context added.
* @param string $title The original page title.
* @return string The admin title with the placeholder prepended.
*/
public static function add_title_placeholder( $admin_title, $title ) {
return self::TITLE_PLACEHOLDER . $admin_title;
}

/**
* Replaces the title placeholder with the final notice count.
*
* Used as an `ob_start()` callback so it runs once the full page - including
* any notices printed after `<title>` - has been buffered.
*
* @since 7.2.0
*
* @param string $buffer The full buffered page output.
* @return string The page output with the placeholder resolved.
*/
public static function inject_notice_count( $buffer ) {
$count = self::get_notice_count();

if ( 0 === $count ) {
return str_replace( self::TITLE_PLACEHOLDER, '', $buffer );
}

/* translators: %d: Number of admin notices on the current page. */
$prefix = sprintf( _n( '(%d notice) ', '(%d notices) ', $count ), $count );

return str_replace( self::TITLE_PLACEHOLDER, $prefix, $buffer );
}
}
10 changes: 10 additions & 0 deletions src/wp-includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -9342,6 +9342,10 @@ function wp_get_admin_notice( $message, $args = array() ) {
*/
$args = apply_filters( 'wp_admin_notice_args', $args, $message );

// Unique ID is intended for internal use, and is not a filterable argument.
$unique_id = wp_unique_prefixed_id( 'wp-admin-notice-' );
$args['notice_id'] = $unique_id;

$wrap_with_p = false !== $args['paragraph_wrap'];
$wrap_opener = $wrap_with_p ? '<p>' : '';
$wrap_closer = $wrap_with_p ? '</p>' : '';
Expand All @@ -9351,9 +9355,15 @@ function wp_get_admin_notice( $message, $args = array() ) {
if ( is_string( $args['id'] ) ) {
$trimmed_id = trim( $args['id'] );

$html_builder->set_attribute( 'data-id', $unique_id );
if ( '' !== $trimmed_id ) {
$html_builder->set_attribute( 'id', $trimmed_id );
} else {
$html_builder->set_attribute( 'id', $unique_id );
}
} else {
$html_builder->set_attribute( 'data-id', $unique_id );
$html_builder->set_attribute( 'id', $unique_id );
}

if ( is_string( $args['type'] ) ) {
Expand Down
1 change: 1 addition & 0 deletions src/wp-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
require ABSPATH . WPINC . '/formatting.php';
require ABSPATH . WPINC . '/meta.php';
require ABSPATH . WPINC . '/functions.php';
require ABSPATH . WPINC . '/class-wp-admin-notice-collector.php';
require ABSPATH . WPINC . '/class-wp-meta-query.php';
require ABSPATH . WPINC . '/class-wp-matchesmapregex.php';
require ABSPATH . WPINC . '/class-wp.php';
Expand Down
Loading
Loading