Skip to content

Commit

Permalink
Bring A11Y changes to site editor list view from the post editor. Add…
Browse files Browse the repository at this point in the history
… E2E testing.
  • Loading branch information
alexstine committed Jun 11, 2023
1 parent 259c8e7 commit b4add25
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ function KeyboardShortcutsEditMode() {
} );

useShortcut( 'core/edit-site/toggle-list-view', () => {
setIsListViewOpened( ! isListViewOpen );
if ( isListViewOpen ) {
return;
}
setIsListViewOpened( true );
} );

useShortcut( 'core/edit-site/toggle-block-settings-sidebar', ( event ) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ function KeyboardShortcuts() {
} );

useShortcut( 'core/edit-site/toggle-list-view', () => {
setIsListViewOpened( ! isListViewOpen );
if ( ! isListViewOpen ) {
return;
}
setIsListViewOpened( true );
} );

useShortcut( 'core/edit-site/toggle-block-settings-sidebar', ( event ) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
useMergeRefs,
} from '@wordpress/compose';
import { useDispatch } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { closeSmall } from '@wordpress/icons';
import { ESCAPE } from '@wordpress/keycodes';
import { focus } from '@wordpress/dom';
import { useShortcut } from '@wordpress/keyboard-shortcuts';

/**
* Internal dependencies
Expand All @@ -25,11 +27,9 @@ const { PrivateListView } = unlock( blockEditorPrivateApis );
export default function ListViewSidebar() {
const { setIsListViewOpened } = useDispatch( editSiteStore );

// Use internal state instead of a ref to make sure that the component
// re-renders when the dropZoneElement updates.
const [ dropZoneElement, setDropZoneElement ] = useState( null );

// This hook handles focus when the sidebar first renders.
const focusOnMountRef = useFocusOnMount( 'firstElement' );
// The next 2 hooks handle focus for when the sidebar closes and returning focus to the element that had focus before sidebar opened.
const headerFocusReturnRef = useFocusReturn();
const contentFocusReturnRef = useFocusReturn();

Expand All @@ -39,11 +39,56 @@ export default function ListViewSidebar() {
}
}

// Use internal state instead of a ref to make sure that the component
// re-renders when the dropZoneElement updates.
const [ dropZoneElement, setDropZoneElement ] = useState( null );

// This ref refers to the sidebar as a whole.
const sidebarRef = useRef();
// This ref refers to the close button.
const sidebarCloseButtonRef = useRef();
// This ref refers to the list view application area.
const listViewRef = useRef();

/*
* Callback function to handle list view or close button focus.
*
* @return void
*/
function handleSidebarFocus() {
// Either focus the list view or the sidebar close button. Must have a fallback because the list view does not render when there are no blocks.
const listViewApplicationFocus = focus.tabbable.find(
listViewRef.current
)[ 0 ];
const listViewFocusArea = sidebarRef.current.contains(
listViewApplicationFocus
)
? listViewApplicationFocus
: sidebarCloseButtonRef.current;
listViewFocusArea.focus();
}

// This only fires when the sidebar is open because of the conditional rendering. It is the same shortcut to open but that is defined as a global shortcut and only fires when the sidebar is closed.
useShortcut( 'core/edit-site/toggle-list-view', () => {
// If the sidebar has focus, it is safe to close.
if (
sidebarRef.current.contains(
sidebarRef.current.ownerDocument.activeElement
)
) {
setIsListViewOpened( false );
// If the list view or close button does not have focus, focus should be moved to it.
} else {
handleSidebarFocus();
}
} );

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="edit-site-editor__list-view-panel"
onKeyDown={ closeOnEscape }
ref={ sidebarRef }
>
<div
className="edit-site-editor__list-view-panel-header"
Expand All @@ -54,6 +99,7 @@ export default function ListViewSidebar() {
icon={ closeSmall }
label={ __( 'Close' ) }
onClick={ () => setIsListViewOpened( false ) }
ref={ sidebarCloseButtonRef }
/>
</div>
<div
Expand All @@ -62,6 +108,7 @@ export default function ListViewSidebar() {
contentFocusReturnRef,
focusOnMountRef,
setDropZoneElement,
listViewRef,
] ) }
>
<PrivateListView dropZoneElement={ dropZoneElement } />
Expand Down
168 changes: 168 additions & 0 deletions test/e2e/specs/site-editor/list-view.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* WordPress dependencies
*/
const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );

test.describe( 'Site Editor List View', () => {
test.use( {
listViewUtils: async ( { page, pageUtils, editor }, use ) => {
await use( new ListViewUtils( { page, pageUtils, editor } ) );
},
} );

test.beforeAll( async ( { requestUtils } ) => {
await requestUtils.activateTheme( 'emptytheme' );
} );

test.afterAll( async ( { requestUtils } ) => {
await requestUtils.activateTheme( 'twentytwentyone' );
} );

test.beforeEach( async ( { admin, editor } ) => {
// Select a template part with a few blocks.
await admin.visitSiteEditor( {
postId: 'emptytheme//header',
postType: 'wp_template_part',
} );
await editor.canvas.click( 'body' );
} );

// If list view sidebar is open and focus is not inside the sidebar, move
// focus to the sidebar when using the shortcut. If focus is inside the
// sidebar, shortcut should close the sidebar.
test( 'ensures List View global shortcut works properly', async ( {
editor,
page,
pageUtils,
} ) => {
// Current starting focus should be at Open Navigation button.
const openNavigationButton = page.getByRole( 'button', {
name: 'Open Navigation',
exact: true,
} );
await openNavigationButton.focus();
await expect( openNavigationButton ).toBeFocused();

// Open List View.
await pageUtils.pressKeys( 'access+o' );
const listView = page.getByRole( 'treegrid', {
name: 'Block navigation structure',
} );
await expect( listView ).toBeVisible();

// The site title block should have focus.
await expect(
listView.getByRole( 'link', {
name: 'Site Title',
exact: true,
} )
).toBeFocused();

// Navigate to the site tagline block.
await page.keyboard.press( 'ArrowDown' );
const siteTaglineItem = listView.getByRole( 'link', {
name: 'Site Tagline',
exact: true,
} );
await expect( siteTaglineItem ).toBeFocused();

// Hit enter to focus the site tagline block in the canvas.
await page.keyboard.press( 'Enter' );
await expect(
editor.canvas.getByRole( 'document', {
name: 'Block: Site Tagline',
} )
).toBeFocused();

// Since focus is now at the site tagline block in the canvas,
// pressing the list view shortcut should bring focus back to the site tagline
// block in the list view.
await pageUtils.pressKeys( 'access+o' );
await expect( siteTaglineItem ).toBeFocused();

// Since focus is now inside the list view, the shortcut should close
// the sidebar.
await pageUtils.pressKeys( 'access+o' );
await expect( listView ).not.toBeVisible();

// Focus should now be on the Open Navigation button since that is
// where we opened the list view sidebar. This is not a perfect
// solution, but current functionality prevents a better way at
// the moment.
await expect( openNavigationButton ).toBeFocused();

// Open List View.
await pageUtils.pressKeys( 'access+o' );
await expect( listView ).toBeVisible();

// Focus the list view close button and make sure the shortcut will
// close the list view. This is to catch a bug where elements could be
// out of range of the sidebar region. Must shift+tab 1 time to reach
// close button before list view area.
await pageUtils.pressKeys( 'shift+Tab' );
await page.keyboard.press( 'Enter' );
await expect( listView ).not.toBeVisible();
await expect( openNavigationButton ).toBeFocused();
} );
} );

/** @typedef {import('@playwright/test').Locator} Locator */
class ListViewUtils {
#page;
#pageUtils;
#editor;

constructor( { page, pageUtils, editor } ) {
this.#page = page;
this.#pageUtils = pageUtils;
this.#editor = editor;

/** @type {Locator} */
this.listView = page.getByRole( 'treegrid', {
name: 'Block navigation structure',
} );
}

/**
* @return {Promise<Locator>} The list view locator.
*/
openListView = async () => {
await this.#pageUtils.pressKeys( 'access+o' );
return this.listView;
};

getBlocksWithA11yAttributes = async () => {
const selectedRows = await this.listView
.getByRole( 'row' )
.filter( {
has: this.#page.getByRole( 'gridcell', { selected: true } ),
} )
.all();
const selectedClientIds = await Promise.all(
selectedRows.map( ( row ) => row.getAttribute( 'data-block' ) )
);
const focusedRows = await this.listView
.getByRole( 'row' )
.filter( { has: this.#page.locator( ':focus' ) } )
.all();
const focusedClientId =
focusedRows.length > 0
? await focusedRows[ focusedRows.length - 1 ].getAttribute(
'data-block'
)
: null;
// Don't use the util to get the unmodified default block when it's empty.
const blocks = await this.#page.evaluate( () =>
window.wp.data.select( 'core/block-editor' ).getBlocks()
);
function recursivelyApplyAttributes( _blocks ) {
return _blocks.map( ( block ) => ( {
name: block.name,
selected: selectedClientIds.includes( block.clientId ),
focused: block.clientId === focusedClientId,
innerBlocks: recursivelyApplyAttributes( block.innerBlocks ),
} ) );
}
return recursivelyApplyAttributes( blocks );
};
}

0 comments on commit b4add25

Please sign in to comment.