Make WordPress Core

Changeset 57708


Ignore:
Timestamp:
02/26/2024 12:50:22 AM (2 years ago)
Author:
joedolson
Message:

Toolbar: Accessibility: Keyboard navigation for screen readers.

Change the admin toolbar to have role="menu" and support opening for screen readers. Remove screen reader only log out link and collapse duplicate profile links into one link. This is an imperfect solution to a complex problem in the adminbar, but the lack of screen reader access to submenus is a major accessibility problem, and this fix provides access, even if the mechanism is imperfect.

Screen reader log out added in [21452].

Props abletec, Cheffheid, sabernhardt, alexstine, joedolson, afercia, sparklingrobots, danieltj, swissspidy, netweb, dionysous.
Fixes #34668, #43633.

Location:
trunk
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/js/_enqueues/lib/admin-bar.js

    r48650 r57708  
    3232                topMenuItems = adminBar.querySelectorAll( 'li.menupop' );
    3333                allMenuItems = adminBar.querySelectorAll( '.ab-item' );
    34                 adminBarLogout = document.getElementById( 'wp-admin-bar-logout' );
     34                adminBarLogout = document.querySelector( '#wp-admin-bar-logout a' );
    3535                adminBarSearchForm = document.getElementById( 'adminbarsearch' );
    3636                shortlink = document.getElementById( 'wp-admin-bar-get-shortlink' );
     
    150150                var wrapper;
    151151
    152                 if ( event.which !== 13 ) {
     152                // Follow link if pressing Ctrl and/or Shift with Enter (opening in a new tab or window).
     153                if ( event.which !== 13 || event.ctrlKey || event.shiftKey ) {
    153154                        return;
    154155                }
     
    337338                        element.className += className;
    338339                }
     340
     341                var menuItemToggle = element.querySelector( 'a' );
     342                if ( className === 'hover' && menuItemToggle && menuItemToggle.hasAttribute( 'aria-expanded' ) ) {
     343                        menuItemToggle.setAttribute( 'aria-expanded', 'true' );
     344                }
    339345        }
    340346
     
    366372
    367373                        element.className = classes.replace( /^[\s]+|[\s]+$/g, '' );
     374                }
     375
     376                var menuItemToggle = element.querySelector( 'a' );
     377                if ( className === 'hover' && menuItemToggle && menuItemToggle.hasAttribute( 'aria-expanded' ) ) {
     378                        menuItemToggle.setAttribute( 'aria-expanded', 'false' );
    368379                }
    369380        }
  • trunk/src/wp-includes/admin-bar.php

    r57600 r57708  
    140140                        '</span>',
    141141                'href'  => $about_url,
     142                'meta' => array(
     143                        'menu_title' => __( 'About WordPress' ),
     144                ),
    142145        );
    143146
     
    283286                        'href'   => $profile_url,
    284287                        'meta'   => array(
    285                                 'class' => $class,
     288                                'class'      => $class,
     289                                'menu_title' => sprintf( __( 'Howdy, %s' ), $current_user->display_name ),
    286290                        ),
    287291                )
     
    326330        }
    327331
     332        $user_info .= "<span class='edit-profile'>" . __( 'Edit Profile' ) . '</span>';
     333
    328334        $wp_admin_bar->add_node(
    329335                array(
     
    332338                        'title'  => $user_info,
    333339                        'href'   => $profile_url,
    334                         'meta'   => array(
    335                                 'tabindex' => -1,
    336                         ),
    337                 )
    338         );
    339 
    340         if ( false !== $profile_url ) {
    341                 $wp_admin_bar->add_node(
    342                         array(
    343                                 'parent' => 'user-actions',
    344                                 'id'     => 'edit-profile',
    345                                 'title'  => __( 'Edit Profile' ),
    346                                 'href'   => $profile_url,
    347                         )
    348                 );
    349         }
     340                )
     341        );
    350342
    351343        $wp_admin_bar->add_node(
     
    398390                        'title' => $title,
    399391                        'href'  => ( is_admin() || ! current_user_can( 'read' ) ) ? home_url( '/' ) : admin_url(),
     392                        'meta' => array(
     393                                'menu_title' => $title,
     394                        ),
    400395                )
    401396        );
     
    995990                        'title' => $title,
    996991                        'href'  => admin_url( current( array_keys( $actions ) ) ),
     992                        'meta' => array(
     993                                'menu_title' => _x( 'New', 'admin bar menu group label' ),
     994                        ),
    997995                )
    998996        );
  • trunk/src/wp-includes/class-wp-admin-bar.php

    r56177 r57708  
    108108         * @since 3.1.0
    109109         * @since 4.5.0 Added the ability to pass 'lang' and 'dir' meta data.
     110         * @since 6.5.0 Added the ability to pass 'menu_title' for an ARIA menu name.
    110111         *
    111112         * @param array $args {
     
    118119         *     @type bool   $group  Optional. Whether or not the node is a group. Default false.
    119120         *     @type array  $meta   Meta data including the following keys: 'html', 'class', 'rel', 'lang', 'dir',
    120          *                          'onclick', 'target', 'title', 'tabindex'. Default empty.
     121         *                          'onclick', 'target', 'title', 'tabindex', 'menu_title'. Default empty.
    121122         * }
    122123         */
     
    479480                                ?>
    480481                        </div>
    481                         <?php if ( is_user_logged_in() ) : ?>
    482                         <a class="screen-reader-shortcut" href="<?php echo esc_url( wp_logout_url() ); ?>"><?php _e( 'Log Out' ); ?></a>
    483                         <?php endif; ?>
    484482                </div>
    485483
     
    506504        /**
    507505         * @since 3.3.0
     506         * @since 6.5.0 Added `$menu_title` parameter to allow an ARIA menu name.
    508507         *
    509508         * @param object $node
    510          */
    511         final protected function _render_group( $node ) {
     509         * @param string|bool $menu_title The accessible name of this aria menu or false if not provided.
     510         */
     511        final protected function _render_group( $node, $menu_title = false ) {
    512512                if ( 'container' === $node->type ) {
    513513                        $this->_render_container( $node );
     
    524524                }
    525525
    526                 echo "<ul id='" . esc_attr( 'wp-admin-bar-' . $node->id ) . "'$class>";
     526                if ( empty( $menu_title ) ) {
     527                        echo "<ul role='menu' id='" . esc_attr( 'wp-admin-bar-' . $node->id ) . "'$class>";
     528                } else {
     529                        echo "<ul role='menu' aria-label='" . esc_attr( $menu_title ) . "' id='" . esc_attr( 'wp-admin-bar-' . $node->id ) . "'$class>";
     530                }
    527531                foreach ( $node->children as $item ) {
    528532                        $this->_render_item( $item );
     
    547551
    548552                // Allow only numeric values, then casted to integers, and allow a tabindex value of `0` for a11y.
    549                 $tabindex        = ( isset( $node->meta['tabindex'] ) && is_numeric( $node->meta['tabindex'] ) ) ? (int) $node->meta['tabindex'] : '';
    550                 $aria_attributes = ( '' !== $tabindex ) ? ' tabindex="' . $tabindex . '"' : '';
     553                $tabindex         = ( isset( $node->meta['tabindex'] ) && is_numeric( $node->meta['tabindex'] ) ) ? (int) $node->meta['tabindex'] : '';
     554                $aria_attributes  = ( '' !== $tabindex ) ? ' tabindex="' . $tabindex . '"' : '';
     555                $aria_attributes .= ' role="menuitem"';
    551556
    552557                $menuclass = '';
     
    555560                if ( $is_parent ) {
    556561                        $menuclass        = 'menupop ';
    557                         $aria_attributes .= ' aria-haspopup="true"';
     562                        $aria_attributes .= ' aria-expanded="false"';
    558563                }
    559564
     
    604609                        echo '<div class="ab-sub-wrapper">';
    605610                        foreach ( $node->children as $group ) {
    606                                 $this->_render_group( $group );
     611                                if ( empty( $node->meta['menu_title'] ) ) {
     612                                        $this->_render_group( $group, false );
     613                                } else {
     614                                        $this->_render_group( $group, $node->meta['menu_title'] );
     615                                }
    607616                        }
    608617                        echo '</div>';
  • trunk/src/wp-includes/css/admin-bar.css

    r56956 r57708  
    446446        height: auto;
    447447        background: none;
     448}
     449
     450#wpadminbar #wp-admin-bar-user-info a {
     451        display: grid;
     452        row-gap: 12px;
    448453}
    449454
  • trunk/tests/phpunit/tests/adminbar.php

    r56227 r57708  
    9898                $node_my_account   = $wp_admin_bar->get_node( 'my-account' );
    9999                $node_user_info    = $wp_admin_bar->get_node( 'user-info' );
    100                 $node_edit_profile = $wp_admin_bar->get_node( 'edit-profile' );
    101100
    102101                // Site menu points to the home page instead of the admin URL.
     
    106105                $this->assertFalse( $node_my_account->href );
    107106                $this->assertFalse( $node_user_info->href );
    108                 $this->assertNull( $node_edit_profile );
    109107        }
    110108
     
    117115
    118116                wp_set_current_user( self::$editor_id );
     117
     118                $wp_admin_bar = $this->get_standard_admin_bar();
     119
     120                $node_site_name  = $wp_admin_bar->get_node( 'site-name' );
     121                $node_my_account = $wp_admin_bar->get_node( 'my-account' );
     122                $node_user_info  = $wp_admin_bar->get_node( 'user-info' );
     123
     124                // Site menu points to the admin URL.
     125                $this->assertSame( admin_url( '/' ), $node_site_name->href );
     126
     127                $profile_url = admin_url( 'profile.php' );
     128
     129                // Profile URLs point to profile.php.
     130                $this->assertSame( $profile_url, $node_my_account->href );
     131                $this->assertSame( $profile_url, $node_user_info->href );
     132        }
     133
     134        /**
     135         * @ticket 25162
     136         * @group multisite
     137         * @group ms-required
     138         */
     139        public function test_admin_bar_contains_correct_links_for_users_with_no_role_on_blog() {
     140                $blog_id = self::factory()->blog->create(
     141                        array(
     142                                'user_id' => self::$admin_id,
     143                        )
     144                );
     145
     146                $this->assertTrue( user_can( self::$admin_id, 'read' ) );
     147                $this->assertTrue( user_can( self::$editor_id, 'read' ) );
     148
     149                $this->assertTrue( is_user_member_of_blog( self::$admin_id, $blog_id ) );
     150                $this->assertFalse( is_user_member_of_blog( self::$editor_id, $blog_id ) );
     151
     152                wp_set_current_user( self::$editor_id );
     153
     154                switch_to_blog( $blog_id );
    119155
    120156                $wp_admin_bar = $this->get_standard_admin_bar();
     
    123159                $node_my_account   = $wp_admin_bar->get_node( 'my-account' );
    124160                $node_user_info    = $wp_admin_bar->get_node( 'user-info' );
    125                 $node_edit_profile = $wp_admin_bar->get_node( 'edit-profile' );
    126 
    127                 // Site menu points to the admin URL.
    128                 $this->assertSame( admin_url( '/' ), $node_site_name->href );
    129 
    130                 $profile_url = admin_url( 'profile.php' );
    131 
    132                 // Profile URLs point to profile.php.
    133                 $this->assertSame( $profile_url, $node_my_account->href );
    134                 $this->assertSame( $profile_url, $node_user_info->href );
    135                 $this->assertSame( $profile_url, $node_edit_profile->href );
     161
     162                // Get primary blog.
     163                $primary = get_active_blog_for_user( self::$editor_id );
     164                $this->assertIsObject( $primary );
     165
     166                // No Site menu as the user isn't a member of this blog.
     167                $this->assertNull( $node_site_name );
     168
     169                $primary_profile_url = get_admin_url( $primary->blog_id, 'profile.php' );
     170
     171                // Ensure the user's primary blog is not the same as the main site.
     172                $this->assertNotEquals( $primary_profile_url, admin_url( 'profile.php' ) );
     173
     174                // Profile URLs should go to the user's primary blog.
     175                $this->assertSame( $primary_profile_url, $node_my_account->href );
     176                $this->assertSame( $primary_profile_url, $node_user_info->href );
     177
     178                restore_current_blog();
    136179        }
    137180
     
    141184         * @group ms-required
    142185         */
    143         public function test_admin_bar_contains_correct_links_for_users_with_no_role_on_blog() {
     186        public function test_admin_bar_contains_correct_links_for_users_with_no_role_on_network() {
     187                $this->assertTrue( user_can( self::$admin_id, 'read' ) );
     188                $this->assertFalse( user_can( self::$no_role_id, 'read' ) );
     189
    144190                $blog_id = self::factory()->blog->create(
    145191                        array(
     
    148194                );
    149195
    150                 $this->assertTrue( user_can( self::$admin_id, 'read' ) );
    151                 $this->assertTrue( user_can( self::$editor_id, 'read' ) );
    152 
    153196                $this->assertTrue( is_user_member_of_blog( self::$admin_id, $blog_id ) );
    154                 $this->assertFalse( is_user_member_of_blog( self::$editor_id, $blog_id ) );
    155 
    156                 wp_set_current_user( self::$editor_id );
     197                $this->assertFalse( is_user_member_of_blog( self::$no_role_id, $blog_id ) );
     198                $this->assertTrue( is_user_member_of_blog( self::$no_role_id, get_current_blog_id() ) );
     199
     200                // Remove `$nobody` from the current blog, so they're not a member of any blog.
     201                $removed = remove_user_from_blog( self::$no_role_id, get_current_blog_id() );
     202
     203                $this->assertTrue( $removed );
     204                $this->assertFalse( is_user_member_of_blog( self::$no_role_id, get_current_blog_id() ) );
     205
     206                wp_set_current_user( self::$no_role_id );
    157207
    158208                switch_to_blog( $blog_id );
     
    163213                $node_my_account   = $wp_admin_bar->get_node( 'my-account' );
    164214                $node_user_info    = $wp_admin_bar->get_node( 'user-info' );
    165                 $node_edit_profile = $wp_admin_bar->get_node( 'edit-profile' );
    166 
    167                 // Get primary blog.
    168                 $primary = get_active_blog_for_user( self::$editor_id );
    169                 $this->assertIsObject( $primary );
    170 
    171                 // No Site menu as the user isn't a member of this blog.
    172                 $this->assertNull( $node_site_name );
    173 
    174                 $primary_profile_url = get_admin_url( $primary->blog_id, 'profile.php' );
    175 
    176                 // Ensure the user's primary blog is not the same as the main site.
    177                 $this->assertNotEquals( $primary_profile_url, admin_url( 'profile.php' ) );
    178 
    179                 // Profile URLs should go to the user's primary blog.
    180                 $this->assertSame( $primary_profile_url, $node_my_account->href );
    181                 $this->assertSame( $primary_profile_url, $node_user_info->href );
    182                 $this->assertSame( $primary_profile_url, $node_edit_profile->href );
    183 
    184                 restore_current_blog();
    185         }
    186 
    187         /**
    188          * @ticket 25162
    189          * @group multisite
    190          * @group ms-required
    191          */
    192         public function test_admin_bar_contains_correct_links_for_users_with_no_role_on_network() {
    193                 $this->assertTrue( user_can( self::$admin_id, 'read' ) );
    194                 $this->assertFalse( user_can( self::$no_role_id, 'read' ) );
    195 
    196                 $blog_id = self::factory()->blog->create(
    197                         array(
    198                                 'user_id' => self::$admin_id,
    199                         )
    200                 );
    201 
    202                 $this->assertTrue( is_user_member_of_blog( self::$admin_id, $blog_id ) );
    203                 $this->assertFalse( is_user_member_of_blog( self::$no_role_id, $blog_id ) );
    204                 $this->assertTrue( is_user_member_of_blog( self::$no_role_id, get_current_blog_id() ) );
    205 
    206                 // Remove `$nobody` from the current blog, so they're not a member of any blog.
    207                 $removed = remove_user_from_blog( self::$no_role_id, get_current_blog_id() );
    208 
    209                 $this->assertTrue( $removed );
    210                 $this->assertFalse( is_user_member_of_blog( self::$no_role_id, get_current_blog_id() ) );
    211 
    212                 wp_set_current_user( self::$no_role_id );
    213 
    214                 switch_to_blog( $blog_id );
    215 
    216                 $wp_admin_bar = $this->get_standard_admin_bar();
    217 
    218                 $node_site_name    = $wp_admin_bar->get_node( 'site-name' );
    219                 $node_my_account   = $wp_admin_bar->get_node( 'my-account' );
    220                 $node_user_info    = $wp_admin_bar->get_node( 'user-info' );
    221                 $node_edit_profile = $wp_admin_bar->get_node( 'edit-profile' );
    222215
    223216                // Get primary blog.
     
    235228                $this->assertSame( $user_profile_url, $node_my_account->href );
    236229                $this->assertSame( $user_profile_url, $node_user_info->href );
    237                 $this->assertSame( $user_profile_url, $node_edit_profile->href );
    238230
    239231                restore_current_blog();
     
    285277                                        'id' => 'test-node',
    286278                                ),
    287                                 '<div class="ab-item ab-empty-item">',
     279                                '<div class="ab-item ab-empty-item" role="menuitem">',
    288280                        ),
    289281                        array(
     
    293285                                        'meta' => array( 'tabindex' => '' ),
    294286                                ),
    295                                 '<div class="ab-item ab-empty-item">',
     287                                '<div class="ab-item ab-empty-item" role="menuitem">',
    296288                        ),
    297289                        array(
     
    301293                                        'meta' => array( 'tabindex' => '1' ),
    302294                                ),
    303                                 '<div class="ab-item ab-empty-item" tabindex="1">',
     295                                '<div class="ab-item ab-empty-item" tabindex="1" role="menuitem">',
    304296                        ),
    305297                        array(
     
    309301                                        'meta' => array( 'tabindex' => '-1' ),
    310302                                ),
    311                                 '<div class="ab-item ab-empty-item" tabindex="-1">',
     303                                '<div class="ab-item ab-empty-item" tabindex="-1" role="menuitem">',
    312304                        ),
    313305                        array(
     
    317309                                        'meta' => array( 'tabindex' => '0' ),
    318310                                ),
    319                                 '<div class="ab-item ab-empty-item" tabindex="0">',
     311                                '<div class="ab-item ab-empty-item" tabindex="0" role="menuitem">',
    320312                        ),
    321313                        array(
     
    325317                                        'meta' => array( 'tabindex' => 0 ),
    326318                                ),
    327                                 '<div class="ab-item ab-empty-item" tabindex="0">',
     319                                '<div class="ab-item ab-empty-item" tabindex="0" role="menuitem">',
    328320                        ),
    329321                        array(
     
    333325                                        'meta' => array( 'tabindex' => 2 ),
    334326                                ),
    335                                 '<div class="ab-item ab-empty-item" tabindex="2">',
     327                                '<div class="ab-item ab-empty-item" tabindex="2" role="menuitem">',
    336328                        ),
    337329                        array(
     
    341333                                        'meta' => array( 'tabindex' => false ),
    342334                                ),
    343                                 '<div class="ab-item ab-empty-item">',
     335                                '<div class="ab-item ab-empty-item" role="menuitem">',
    344336                        ),
    345337                );
Note: See TracChangeset for help on using the changeset viewer.