From 0a94db4a6c23eb8d9301f210f7985045985887f2 Mon Sep 17 00:00:00 2001 From: Shadi Sharaf Date: Thu, 10 Sep 2026 15:56:21 +0300 Subject: [PATCH] feat(user-picker): add accessible Ajax user combobox over the preload cap (XWPENG-55) User pickers (records filter, settings exclude rules, alert author trigger) now share one decision point, Admin::get_preload_users_max() (new wp_stream_preload_users_max filter, default 50): - Under the cap: the native ` elements across the admin (records filters, settings exclude rules, alert triggers). Author/role filters offer every user in grouped native selects; IP exclusion rules accept a comma-separated list in a plain text field. Relative timestamps now use `Intl.RelativeTimeFormat` (locale-aware) with the same bold relative + absolute date presentation as before. The `select2` and `jquery-timeago` dependencies and their bundled vendor copies are gone, and the unused `stream_get_users` and `stream_get_ips` Ajax actions were removed. Editing an alert now also regenerates its list title ("Author > Context > Action") so it matches the saved triggers instead of keeping the creation-time summary. +- Replace Select2 with native `` while the site stays under the new `wp_stream_preload_users_max` filter (default 50); larger sites get an accessible Ajax combobox (keyboard navigation, `wp.a11y` announcements) backed by the same `wp_stream_filters` endpoint. Quick-editing an alert no longer drops its author trigger; `WP-CLI` shows as itself instead of "N/A" in pickers and alert summaries; and a stored `WP-CLI` author trigger now actually matches only WP-CLI records (the numeric string `"0"` was previously treated as unset). Editing an alert now also regenerates its list title ("Author > Context > Action") so it matches the saved triggers instead of keeping the creation-time summary. - Stop tracking Jetpack modules that no longer exist (Google+ authorship, Mobile theme, Custom CSS). Remaining Jetpack module logging was audited against Jetpack 15.5 on 2026-09-07. ### Development diff --git a/classes/class-admin-ajax.php b/classes/class-admin-ajax.php index fb561888d..f6b8bd092 100644 --- a/classes/class-admin-ajax.php +++ b/classes/class-admin-ajax.php @@ -137,34 +137,15 @@ public function ajax_filters() { check_ajax_referer( 'stream_filters_user_search_nonce', 'nonce' ); switch ( wp_stream_filter_input( INPUT_GET, 'filter' ) ) { + // User picker search: shared by the records filter, settings exclude + // rules, and alert author trigger comboboxes. See the DB user picker + // section for the option-source contract. case 'user_id': - $users = array_merge( - array( - 0 => (object) array( - 'display_name' => 'WP-CLI', - ), - ), - get_users() - ); - $search = wp_stream_filter_input( INPUT_GET, 'q' ); - if ( is_string( $search ) && '' !== $search ) { - // `search` arg for get_users() is not enough. - $filtered = array(); - foreach ( $users as $key => $user ) { - if ( self::user_display_name_contains( $user, $search ) ) { - $filtered[ $key ] = $user; - } - } - $users = $filtered; - } - - if ( count( $users ) > $this->admin->preload_users_max ) { - $users = array_slice( $users, 0, $this->admin->preload_users_max ); - } - - // Get gravatar / roles for final result set. - $results = $this->get_users_record_meta( $users ); + $search = is_string( $search ) ? $search : ''; + $limit = max( 20, $this->admin->get_preload_users_max() ); + + $results = $this->admin->plugin->user_picker->search( $search, $limit ); break; } @@ -175,31 +156,6 @@ public function ajax_filters() { die(); } - - /** - * Return relevant user meta data for Ajax filter results. - * - * @param array $authors Author data keyed by user ID. - * @return array - */ - public function get_users_record_meta( $authors ) { - $authors_records = array(); - - foreach ( $authors as $user_id => $args ) { - $author = new Author( $args->ID ); - - $authors_records[ $user_id ] = array( - 'text' => $author->get_display_name(), - 'id' => $author->id, - 'label' => $author->get_display_name(), - 'icon' => $author->get_avatar_src( 32 ), - 'title' => '', - ); - } - - return $authors_records; - } - /** * Render confirmation notices keyed by the wp_stream_message query arg. * @@ -230,15 +186,4 @@ public function maybe_display_message() { esc_html( $notices[ $message ] ) ); } - - /** - * Whether a user display name contains the search needle. - * - * @param object $user User-like object with display_name. - * @param string $search Search needle. - * @return bool - */ - private static function user_display_name_contains( $user, string $search ): bool { - return false !== mb_strpos( mb_strtolower( $user->display_name ), mb_strtolower( $search ) ); - } } diff --git a/classes/class-admin-assets.php b/classes/class-admin-assets.php index d1df9f9b4..05fe03401 100644 --- a/classes/class-admin-assets.php +++ b/classes/class-admin-assets.php @@ -30,6 +30,29 @@ private function register_hooks(): void { add_action( 'admin_enqueue_scripts', array( $this, 'admin_menu_css' ) ); } + /** + * Script data shared by every screen that renders the user combobox. + * + * @return array{userSearchNonce: string, userSearchI18n: array} + */ + public function user_combobox_l10n(): array { + return array( + 'userSearchNonce' => wp_create_nonce( 'stream_filters_user_search_nonce' ), + 'userSearchI18n' => array( + 'noUsers' => __( 'No users found.', 'stream' ), + 'searchError' => __( 'Unable to search users.', 'stream' ), + 'minChars' => __( 'Type at least 2 characters to search users.', 'stream' ), + /* translators: %d: number of matching users */ + 'foundSingular' => _n( '%d user found.', '%d users found.', 1, 'stream' ), + /* translators: %d: number of matching users */ + 'foundPlural' => _n( '%d user found.', '%d users found.', 2, 'stream' ), + 'rolesHeader' => __( 'Roles', 'stream' ), + 'usersHeader' => __( 'Users', 'stream' ), + 'searchUsersHint' => __( 'Start typing to search users.', 'stream' ), + ), + ); + } + /** * Enqueue scripts/styles for admin screen * @@ -43,22 +66,32 @@ public function admin_enqueue_scripts( $hook ) { if ( in_array( $hook, $this->admin->menu->screen_id, true ) ) { $this->admin->plugin->enqueue_asset( 'admin', - array(), array( - 'i18n' => array( - 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ), - 'confirm_defaults' => __( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ), + 'wp-a11y', + ), + array_merge( + array( + 'i18n' => array( + 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ), + 'confirm_defaults' => __( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ), + ), + 'locale' => strtolower( substr( get_locale(), 0, 2 ) ), + 'gmt_offset' => get_option( 'gmt_offset' ), ), - 'locale' => strtolower( substr( get_locale(), 0, 2 ) ), - 'gmt_offset' => get_option( 'gmt_offset' ), + $this->user_combobox_l10n() ) ); $this->admin->plugin->enqueue_asset( 'admin-exclude', - array(), array( - 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ), + 'wp-a11y', + ), + array_merge( + array( + 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ), + ), + $this->user_combobox_l10n() ) ); diff --git a/classes/class-admin.php b/classes/class-admin.php index efbacc79b..00a68dfca 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -128,6 +128,26 @@ class Admin { */ public int $preload_users_max = 50; + /** + * Filtered preload cap for user pickers. + * + * @return int + */ + public function get_preload_users_max(): int { + /** + * Filters the maximum number of users preloaded into a picker. + * + * Above this cap the picker switches to Ajax search. Zero forces Ajax. + * + * @since 5.0.0 + * + * @param int $preload_users_max Default cap (50). + */ + $max = apply_filters( 'wp_stream_preload_users_max', $this->preload_users_max ); + + return max( 0, (int) $max ); + } + /** * Admin notices, collected and displayed on proper action */ diff --git a/classes/class-alert.php b/classes/class-alert.php index 11d7d3131..e9cbe95e8 100644 --- a/classes/class-alert.php +++ b/classes/class-alert.php @@ -52,9 +52,9 @@ class Alert { /** * Alert meta data * - * @var int + * @var array */ - public $alert_meta; + public array $alert_meta; /** * Class constructor @@ -71,7 +71,11 @@ public function __construct( $item, public $plugin ) { $this->author = isset( $item->author ) ? $item->author : null; $this->alert_type = isset( $item->alert_type ) ? $item->alert_type : null; - $this->alert_meta = isset( $item->alert_meta ) ? $item->alert_meta : array(); + // get_post_meta() returns an empty string for posts with no stored + // meta; only accept real arrays so the typed property stays honest. + $this->alert_meta = isset( $item->alert_meta ) && is_array( $item->alert_meta ) + ? $item->alert_meta + : array(); } /** diff --git a/classes/class-alerts-admin-ui.php b/classes/class-alerts-admin-ui.php index 850afeda0..4ca0f1d59 100644 --- a/classes/class-alerts-admin-ui.php +++ b/classes/class-alerts-admin-ui.php @@ -101,11 +101,15 @@ public function register_scripts() { 'alerts', array( 'inline-edit-post', + 'wp-a11y', ), - array( - 'any' => __( 'Any', 'stream' ), - 'anyContext' => __( 'Any Context', 'stream' ), - 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ), + array_merge( + array( + 'any' => __( 'Any', 'stream' ), + 'anyContext' => __( 'Any Context', 'stream' ), + 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ), + ), + $this->plugin->admin->assets->user_combobox_l10n() ) ); } diff --git a/classes/class-alerts-list.php b/classes/class-alerts-list.php index dfecc1f79..a9dfff5d1 100644 --- a/classes/class-alerts-list.php +++ b/classes/class-alerts-list.php @@ -141,6 +141,13 @@ public function column_data( $column_name, $post_id ) { alert_meta['trigger_author'] ) + ? (string) $alert->alert_meta['trigger_author'] + : ''; + ?> + + + custom_column_actions( $post_id ) ); break; case 'alert_type': diff --git a/classes/class-form-generator.php b/classes/class-form-generator.php index a9f58be95..e46dff58e 100644 --- a/classes/class-form-generator.php +++ b/classes/class-form-generator.php @@ -197,6 +197,9 @@ public function render_field( $field_type, $args, $echo_output = true ) { $output .= ''; break; + case 'user_combobox': + $output = $this->render_user_combobox( $args ); + break; case 'checkbox': $output = sprintf( '%3$s', @@ -221,6 +224,73 @@ public function render_field( $field_type, $args, $echo_output = true ) { echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } + /** + * Accessible user search combobox (hidden value + search input + listbox). + * + * Optional `role_options` render as a "Roles" group inside the listbox + * (single control), mirroring the previous Select2 dropdown's grouping. + * The hidden input carries the submitted value: a user id or role slug. + * + * @param array $args Field args. + * @return string Markup. + */ + private function render_user_combobox( $args ) { + $placeholder = ''; + if ( ! empty( $args['data']['placeholder'] ) ) { + $placeholder = (string) $args['data']['placeholder']; + } + + $selected_label = isset( $args['selected_label'] ) ? (string) $args['selected_label'] : ''; + $search_label = $placeholder ? $placeholder : __( 'Search users', 'stream' ); + $value = (string) $args['value']; + $hidden_classes = trim( 'stream-user-combobox__value ' . (string) $args['classes'] ); + + $role_options_attr = ''; + if ( ! empty( $args['role_options'] ) && is_array( $args['role_options'] ) ) { + $role_data = array(); + foreach ( $args['role_options'] as $role_option ) { + $role_option = wp_parse_args( + $role_option, + array( + 'value' => '', + 'text' => '', + ) + ); + if ( '' === (string) $role_option['value'] ) { + continue; + } + $role_data[] = array( + 'value' => (string) $role_option['value'], + 'label' => (string) $role_option['text'], + ); + } + $role_options_attr = sprintf( + ' data-role-options="%s"', + esc_attr( wp_json_encode( $role_data ) ) + ); + } + + $hidden_id = ''; + if ( ! empty( $args['id'] ) ) { + $hidden_id = (string) $args['id']; + } elseif ( false === strpos( (string) $args['name'], '[' ) ) { + $hidden_id = (string) $args['name']; + } + $id_attr = '' !== $hidden_id ? sprintf( ' id="%s"', esc_attr( $hidden_id ) ) : ''; + + return sprintf( + '
', + esc_attr( $placeholder ), + esc_attr( $selected_label ), + $role_options_attr, + esc_attr( $search_label ), + esc_attr( $args['name'] ), + $id_attr, + esc_attr( $hidden_classes ), + esc_attr( $value ) + ); + } + /** * Render a single