diff --git a/packages/edit-site/src/components/keyboard-shortcuts/edit-mode.js b/packages/edit-site/src/components/keyboard-shortcuts/edit-mode.js index 7d491d54932f53..7b8befdd414a6b 100644 --- a/packages/edit-site/src/components/keyboard-shortcuts/edit-mode.js +++ b/packages/edit-site/src/components/keyboard-shortcuts/edit-mode.js @@ -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 ) => { diff --git a/packages/edit-site/src/components/keyboard-shortcuts/index.js b/packages/edit-site/src/components/keyboard-shortcuts/index.js index fa4b1aa6833dc3..a2f06b85d6082b 100644 --- a/packages/edit-site/src/components/keyboard-shortcuts/index.js +++ b/packages/edit-site/src/components/keyboard-shortcuts/index.js @@ -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 ) => { diff --git a/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js index 7a19cdc08db114..0e46e99decd2f5 100644 --- a/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js +++ b/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js @@ -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 @@ -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(); @@ -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
setIsListViewOpened( false ) } + ref={ sidebarCloseButtonRef } />
diff --git a/test/e2e/specs/site-editor/list-view.spec.js b/test/e2e/specs/site-editor/list-view.spec.js new file mode 100644 index 00000000000000..37d9082bdb3983 --- /dev/null +++ b/test/e2e/specs/site-editor/list-view.spec.js @@ -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} 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 ); + }; +}