Make WordPress Core

Changeset 57708


Ignore:
Timestamp:
02/26/2024 12:50:22 AM (7 months 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.