diff --git a/packages/block-editor/src/components/list-view/block-select-button.js b/packages/block-editor/src/components/list-view/block-select-button.js
index 3afbf3f5b5bc1..1775798852d5e 100644
--- a/packages/block-editor/src/components/list-view/block-select-button.js
+++ b/packages/block-editor/src/components/list-view/block-select-button.js
@@ -44,6 +44,7 @@ function ListViewBlockSelectButton(
draggable,
isExpanded,
ariaDescribedBy,
+ isSelected,
},
ref
) {
@@ -102,6 +103,7 @@ function ListViewBlockSelectButton(
href={ `#block-${ clientId }` }
aria-describedby={ ariaDescribedBy }
aria-expanded={ isExpanded }
+ data-is-selected={ isSelected ? true : undefined }
>
{
- // If a blocks are already selected when the list view is initially
+ // If any blocks are already selected when the list view is initially
// mounted, shift focus to the first selected block.
- if ( selectedClientIds?.length ) {
- focusListItem( selectedClientIds[ 0 ], elementRef?.current );
- }
+ // The ListView may render within other components that already manage
+ // initial focus via `useFocusOnMount` e.g. the `ListViewSidebar`. As
+ // `useFocusOnMount` uses a timeout internally, it runs last and may steal
+ // focus from the selected item. We use another timeout to make ListView
+ // set its own initial focus last.
+ timerIdRef.current = setTimeout( () => {
+ if ( selectedClientIds?.length ) {
+ focusListItem( selectedClientIds[ 0 ], elementRef?.current );
+ }
+ }, 0 );
+
+ return () => {
+ if ( timerIdRef.current ) {
+ clearTimeout( timerIdRef.current );
+ }
+ };
// Only focus on the selected item when the list view is mounted.
}, [] );
diff --git a/packages/components/src/tree-grid/README.md b/packages/components/src/tree-grid/README.md
index d6e861a7b9b18..5e4ac28b0eb0e 100644
--- a/packages/components/src/tree-grid/README.md
+++ b/packages/components/src/tree-grid/README.md
@@ -12,6 +12,8 @@ A tree grid is a hierarchical 2 dimensional UI component, for example it could b
A tree grid allows the user to navigate using arrow keys. Up/down to navigate vertically across rows, and left/right to navigate horizontally between focusables in a row.
+To make the keyboard navigation and roving tabindex behaviors work as expected it is important to avoid programmatically setting focus on any of the focusable items in the tree grid. In fact, `RovingTabIndexItem` handles the logic to make only one item navigable with the Tab key at a time. The other items can be navigated with the arrow keys. Triggering a focus event may conflict with the `RovingTabIndexItem` internal logic.
+
For more information on a tree grid, see the following links:
- https://www.w3.org/TR/wai-aria-practices/examples/treegrid/treegrid-1.html
diff --git a/packages/editor/src/components/global-keyboard-shortcuts/index.js b/packages/editor/src/components/global-keyboard-shortcuts/index.js
index a46d4b55a7bfd..18d96d822f8e0 100644
--- a/packages/editor/src/components/global-keyboard-shortcuts/index.js
+++ b/packages/editor/src/components/global-keyboard-shortcuts/index.js
@@ -91,9 +91,15 @@ export default function EditorKeyboardShortcuts() {
savePost();
} );
- // Only opens the list view. Other functionality for this shortcut happens in the rendered sidebar.
+ // Only opens the list view. Other functionality for this shortcut happens
+ // in the rendered sidebar. When the `showListViewByDefault` preference is
+ // enabled, the sidebar is rendered by default. As such, we need to prevent
+ // the callback from running twice by using an additional check for
+ // `event.defaultPrevented` otherwise the shortcut:
+ // 1. It will first be invoked in the sidebar, thus closing it.
+ // 2. It will then run again here, reopening the sidebar unexpectedly.
useShortcut( 'core/editor/toggle-list-view', ( event ) => {
- if ( ! isListViewOpened() ) {
+ if ( ! isListViewOpened() && ! event.defaultPrevented ) {
event.preventDefault();
setIsListViewOpened( true );
}
diff --git a/packages/editor/src/components/list-view-sidebar/index.js b/packages/editor/src/components/list-view-sidebar/index.js
index c90479c23ec70..1310dd6d7f955 100644
--- a/packages/editor/src/components/list-view-sidebar/index.js
+++ b/packages/editor/src/components/list-view-sidebar/index.js
@@ -8,10 +8,11 @@ import {
import { useFocusOnMount, useMergeRefs } from '@wordpress/compose';
import { useDispatch, useSelect } from '@wordpress/data';
import { focus } from '@wordpress/dom';
-import { useCallback, useRef, useState } from '@wordpress/element';
+import { useCallback, useRef, useState, useEffect } from '@wordpress/element';
import { __, _x } from '@wordpress/i18n';
import { useShortcut } from '@wordpress/keyboard-shortcuts';
import { ESCAPE } from '@wordpress/keycodes';
+import { store as preferencesStore } from '@wordpress/preferences';
/**
* Internal dependencies
@@ -22,9 +23,27 @@ import { store as editorStore } from '../../store';
const { TabbedSidebar } = unlock( blockEditorPrivateApis );
+// Used to count how many times the component renders and determine the initial focus logic.
+let renderCounter = 0;
+
export default function ListViewSidebar() {
const { setIsListViewOpened } = useDispatch( editorStore );
- const { getListViewToggleRef } = unlock( useSelect( editorStore ) );
+
+ const { listViewToggleRef, showListViewByDefault } = useSelect(
+ ( select ) => {
+ const { getListViewToggleRef } = unlock( select( editorStore ) );
+ const _showListViewByDefault = select( preferencesStore ).get(
+ 'core',
+ 'showListViewByDefault'
+ );
+
+ return {
+ listViewToggleRef: getListViewToggleRef(),
+ showListViewByDefault: _showListViewByDefault,
+ };
+ },
+ []
+ );
// This hook handles focus when the sidebar first renders.
const focusOnMountRef = useFocusOnMount( 'firstElement' );
@@ -32,12 +51,15 @@ export default function ListViewSidebar() {
// When closing the list view, focus should return to the toggle button.
const closeListView = useCallback( () => {
setIsListViewOpened( false );
- getListViewToggleRef().current?.focus();
- }, [ getListViewToggleRef, setIsListViewOpened ] );
+ listViewToggleRef.current?.focus();
+ }, [ listViewToggleRef, setIsListViewOpened ] );
const closeOnEscape = useCallback(
( event ) => {
if ( event.keyCode === ESCAPE && ! event.defaultPrevented ) {
+ // Always use `event.preventDefault` before calling `closeListView`.
+ // This is important to prevent the `core/editor/toggle-list-view`
+ // shortcut callback from being twice.
event.preventDefault();
closeListView();
}
@@ -45,6 +67,19 @@ export default function ListViewSidebar() {
[ closeListView ]
);
+ const firstRenderCheckRef = useRef( false );
+
+ useEffect( () => {
+ // This extra check avoids duplicate updates of the counter in development
+ // mode (React.StrictMode) or because of potential re-renders triggered
+ // by components higher up the tree.
+ if ( firstRenderCheckRef.current ) {
+ return;
+ }
+ renderCounter++;
+ firstRenderCheckRef.current = true;
+ }, [] );
+
// Use internal state instead of a ref to make sure that the component
// re-renders when the dropZoneElement updates.
const [ dropZoneElement, setDropZoneElement ] = useState( null );
@@ -53,18 +88,29 @@ export default function ListViewSidebar() {
// This ref refers to the sidebar as a whole.
const sidebarRef = useRef();
- // This ref refers to the tab panel.
+ // This ref refers to the tablist.
const tabsRef = useRef();
// This ref refers to the list view application area.
const listViewRef = useRef();
// Must merge the refs together so focus can be handled properly in the next function.
const listViewContainerRef = useMergeRefs( [
- focusOnMountRef,
listViewRef,
setDropZoneElement,
] );
+ // focusOnMountRef ref is used to set initial focus to the first tab in the
+ // ListViewSidebar while the tabsRef is used to manage focus for the ARIA tabs UI.
+ let tabsPanelRef = useMergeRefs( [ focusOnMountRef, tabsRef ] );
+
+ // When the 'Always open List View' preference is enabled and the ListViewSidebar
+ // renders for the first time on page load, initial focus should not be managed.
+ // Rather, the tab sequence should normally start from the document root. In
+ // this case, we only pass the tabsRef and omit the focusOnMountRef.
+ if ( showListViewByDefault && renderCounter === 1 ) {
+ tabsPanelRef = tabsRef;
+ }
+
/*
* Callback function to handle list view or outline focus.
*
@@ -73,43 +119,68 @@ export default function ListViewSidebar() {
* @return void
*/
function handleSidebarFocus( currentTab ) {
- // Tab panel focus.
- const tabPanelFocus = focus.tabbable.find( tabsRef.current )[ 0 ];
+ // Active tab in the tablist.
+ const activeTab = focus.tabbable.find( tabsRef.current )[ 0 ];
// List view tab is selected.
if ( currentTab === 'list-view' ) {
- // Either focus the list view or the tab panel. 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
+ // Either focus the list view selected item or the active tab in the
+ // tablist. Must have a fallback because the list view does not
+ // render when there are no blocks.
+ // Important: The `core/editor/toggle-list-view` keyboard shortcut
+ // callback runs when the `keydown` event fires. At that point the
+ // ListView hasn't received focus yet and its internal mechanism to
+ // handle the tabindex attribute hasn't run yet. As such, there may
+ // be an additional item that is 'tabbable' but it's not the
+ // selected item. Filtering based on the `data-is-selected` attribute
+ // makes sure to target the selected item.
+ const listViewSelectedItem = focus.tabbable
+ .find( listViewRef.current )
+ .filter( ( item ) =>
+ item.hasAttribute( 'data-is-selected' )
+ )[ 0 ];
+ const listViewFocusTarget = sidebarRef.current.contains(
+ listViewSelectedItem
)
- ? listViewApplicationFocus
- : tabPanelFocus;
- listViewFocusArea.focus();
+ ? listViewSelectedItem
+ : activeTab;
+
+ listViewFocusTarget.focus();
// Outline tab is selected.
} else {
- tabPanelFocus.focus();
+ activeTab.focus();
}
}
- const handleToggleListViewShortcut = useCallback( () => {
- // If the sidebar has focus, it is safe to close.
- if (
- sidebarRef.current.contains(
- sidebarRef.current.ownerDocument.activeElement
- )
- ) {
- closeListView();
- } else {
- // If the list view or outline does not have focus, focus should be moved to it.
- handleSidebarFocus( tab );
- }
- }, [ closeListView, tab ] );
+ const handleToggleListViewShortcut = useCallback(
+ ( event ) => {
+ // If the sidebar has focus, it is safe to close.
+ if (
+ sidebarRef.current.contains(
+ sidebarRef.current.ownerDocument.activeElement
+ )
+ ) {
+ // Always use `event.preventDefault` before calling `closeListView`.
+ // This is important to prevent the `core/editor/toggle-list-view`
+ // shortcut callback from running twice.
+ event.preventDefault();
+ closeListView();
+ } else {
+ // If the list view or outline does not have focus, focus should be moved to it.
+ handleSidebarFocus( tab );
+ }
+ },
+ [ closeListView, tab ]
+ );
// 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/editor/toggle-list-view', handleToggleListViewShortcut );
+ // It is the same shortcut to open the sidebar but that is defined as a global
+ // shortcut. However, when the `showListViewByDefault` preference is enabled,
+ // the sidebar is open by default and the shortcut callback would be invoked
+ // twice (here and in the global shortcut). To prevent that, we pass the event
+ // for some additional logic in the global shortcut based on `event.defaultPrevented`.
+ useShortcut( 'core/editor/toggle-list-view', ( event ) => {
+ handleToggleListViewShortcut( event );
+ } );
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
@@ -147,7 +218,7 @@ export default function ListViewSidebar() {
onClose={ closeListView }
onSelect={ ( tabName ) => setTab( tabName ) }
defaultTabId="list-view"
- ref={ tabsRef }
+ ref={ tabsPanelRef }
closeButtonLabel={ __( 'Close' ) }
/>
diff --git a/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js b/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js
index f0bfe5bff203f..00e08432c472e 100644
--- a/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js
+++ b/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js
@@ -240,6 +240,9 @@ test.describe( 'Navigating the block hierarchy', () => {
name: 'Block navigation structure',
} )
).toBeVisible();
+ // Move focus to the first item in the List view,
+ // which happens to be the Group block.
+ await page.keyboard.press( 'Tab' );
await page.keyboard.press( 'Enter' );
await expect(
diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js
index 98dfe5e304f80..80f895e6d6333 100644
--- a/test/e2e/specs/editor/various/list-view.spec.js
+++ b/test/e2e/specs/editor/various/list-view.spec.js
@@ -1175,6 +1175,271 @@ test.describe( 'List View', () => {
'The dropdown menu should also be visible'
).toBeVisible();
} );
+
+ test( 'should set initial focus to the first tab when the Document Overview panel opens and no blocks are selected', async ( {
+ page,
+ editor,
+ pageUtils,
+ } ) => {
+ const notice = page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Draft saved' } );
+
+ // Enter some content, save and reload page.
+ await page.keyboard.type( 'Hello' );
+ await editor.insertBlock( { name: 'core/heading' } );
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: { content: 'Paragraph' },
+ } );
+ await pageUtils.pressKeys( 'primary+s' );
+ await notice.waitFor();
+ // After page reload, no blocks are selected.
+ await page.reload();
+
+ const documentOverviewPanel = page.getByRole( 'region', {
+ name: 'Document Overview',
+ } );
+ await expect( documentOverviewPanel ).toBeHidden();
+
+ // Open List View.
+ await pageUtils.pressKeys( 'access+o' );
+
+ // The Document Overview panel should now be open.
+ await expect( documentOverviewPanel ).toBeVisible();
+
+ // Initial focus should be on the first tab.
+ await expect(
+ page.getByRole( 'tab', { name: 'List View' } )
+ ).toBeFocused();
+ } );
+
+ test( 'should conditionally set initial focus to the Document Overview panel when the Always open List View preference is enabled', async ( {
+ page,
+ editor,
+ pageUtils,
+ } ) => {
+ const documentOverviewPanel = page.getByRole( 'region', {
+ name: 'Document Overview',
+ } );
+ await expect( documentOverviewPanel ).toBeHidden();
+
+ // Turn on block list view open by default and full screen mode.
+ await editor.setPreferences( 'core', {
+ showListViewByDefault: true,
+ } );
+ await editor.setPreferences( 'core/edit-post', {
+ fullscreenMode: true,
+ } );
+
+ const notice = page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Draft saved' } );
+
+ // Enter a post title and save so that after page reload initial focus
+ // is not set to the post title.
+ await page.keyboard.type( 'Hello' );
+ await pageUtils.pressKeys( 'primary+s' );
+ await notice.waitFor();
+ // After page reload, no blocks are selected§.
+ await page.reload();
+
+ // The Document Overview panel should be open by default.
+ await expect( documentOverviewPanel ).toBeVisible();
+
+ // On first page load, the panel is open by default and initial focus
+ // should not be set. The tab sequence should start from the document root
+ // i.e. from the View Posts link.
+ await pageUtils.pressKeys( 'Tab' );
+ const viewPostsLink = page.getByRole( 'link', {
+ name: 'View Posts',
+ exact: true,
+ } );
+ await expect( viewPostsLink ).toBeFocused();
+
+ // Move focus to the first tab in the panel.
+ await pageUtils.pressKeys( 'access+o' );
+ // Focus should be on the first tab.
+ await expect(
+ page.getByRole( 'tab', { name: 'List View' } )
+ ).toBeFocused();
+
+ // Close the panel.
+ await pageUtils.pressKeys( 'access+o' );
+ await expect( documentOverviewPanel ).toBeHidden();
+
+ // Reopen the panel.
+ await pageUtils.pressKeys( 'access+o' );
+ await expect( documentOverviewPanel ).toBeVisible();
+
+ // When manually closing and reopening again the panel, initial focus
+ // should be normally set to the first tab in the panel.
+ await expect(
+ page.getByRole( 'tab', { name: 'List View' } )
+ ).toBeFocused();
+
+ // Reset preferences.
+ await editor.setPreferences( 'core', {
+ showListViewByDefault: false,
+ } );
+ await editor.setPreferences( 'core/edit-post', {
+ fullscreenMode: false,
+ } );
+ } );
+
+ test( 'should set focus to the selected item when using the keyboard shortcut after the list view opens and sets initial focus', async ( {
+ page,
+ editor,
+ pageUtils,
+ } ) => {
+ const documentOverviewPanel = page.getByRole( 'region', {
+ name: 'Document Overview',
+ } );
+ await expect( documentOverviewPanel ).toBeHidden();
+
+ // Add a Heading block and a Paragraph block.
+ await editor.insertBlock( { name: 'core/heading' } );
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: { content: 'Paragraph' },
+ } );
+
+ const postTitleField = editor.canvas.getByRole( 'textbox', {
+ name: 'Add title',
+ } );
+ // Focus the post title field to unselect any blocks.
+ await postTitleField.click();
+ await expect( postTitleField ).toBeFocused();
+
+ // Open List View.
+ await pageUtils.pressKeys( 'access+o' );
+
+ // The Document Overview panel should now be open.
+ await expect( documentOverviewPanel ).toBeVisible();
+ // Initial focus should be on the first tab.
+ await expect(
+ page.getByRole( 'tab', { name: 'List View' } )
+ ).toBeFocused();
+
+ // Select the second block in the editor canvas.
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await editor.selectBlocks( paragraphBlock );
+ // The Paragraph block should now be focused.
+ await expect( paragraphBlock ).toBeFocused();
+
+ // Press the keyboard shortcut again to move focus to the List View.
+ await pageUtils.pressKeys( 'access+o' );
+
+ // Ths selected item in the List View should now be focused.
+ await expect(
+ documentOverviewPanel.getByRole( 'link', {
+ name: 'Paragraph',
+ } )
+ ).toBeFocused();
+ } );
+
+ test( 'should set focus to the nested selected item when using the keyboard shortcut from an inner block', async ( {
+ page,
+ editor,
+ pageUtils,
+ } ) => {
+ const documentOverviewPanel = page.getByRole( 'region', {
+ name: 'Document Overview',
+ } );
+ await expect( documentOverviewPanel ).toBeHidden();
+
+ // Add a Heading block and a Paragraph block.
+ await editor.insertBlock( { name: 'core/heading' } );
+ await editor.insertBlock( {
+ name: 'core/group',
+ innerBlocks: [
+ {
+ name: 'core/paragraph',
+ attributes: { content: 'Inner paragraph' },
+ },
+ ],
+ } );
+ await editor.insertBlock( { name: 'core/heading' } );
+
+ // Select the paragraph block within the group block.
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await editor.selectBlocks( paragraphBlock );
+ // The Paragraph block should now be focused.
+ await expect( paragraphBlock ).toBeFocused();
+
+ // Open List View.
+ await pageUtils.pressKeys( 'access+o' );
+
+ // The Document Overview panel should now be open.
+ await expect( documentOverviewPanel ).toBeVisible();
+ // Initial focus should be on the inner selected item within the group item.
+ await expect(
+ documentOverviewPanel.getByRole( 'link', {
+ name: 'Paragraph',
+ } )
+ ).toBeFocused();
+ } );
+
+ test( 'should toggle the panel with the keyboard shortcut when the Always open List View preference is enabled', async ( {
+ page,
+ editor,
+ pageUtils,
+ } ) => {
+ const documentOverviewPanel = page.getByRole( 'region', {
+ name: 'Document Overview',
+ } );
+ await expect( documentOverviewPanel ).toBeHidden();
+
+ // Turn on block list view open by default and full screen mode.
+ await editor.setPreferences( 'core', {
+ showListViewByDefault: true,
+ } );
+
+ // After page reload, the panel is open by default.
+ await page.reload();
+ // The Document Overview panel should be open by default.
+ await expect( documentOverviewPanel ).toBeVisible();
+
+ const postTitleField = editor.canvas.getByRole( 'textbox', {
+ name: 'Add title',
+ } );
+ // The post title field should be focused on a new empty post.
+ await expect( postTitleField ).toBeFocused();
+
+ // Move focus to the first tab in the panel.
+ await pageUtils.pressKeys( 'access+o' );
+ // Focus should be on the first tab.
+ await expect(
+ page.getByRole( 'tab', { name: 'List View' } )
+ ).toBeFocused();
+
+ // Close the panel.
+ await pageUtils.pressKeys( 'access+o' );
+ await expect( documentOverviewPanel ).toBeHidden();
+
+ // Focus should now be on the list view toggle button.
+ await expect(
+ page.getByRole( 'button', { name: 'Document Overview' } )
+ ).toBeFocused();
+
+ // Reopen the panel.
+ await pageUtils.pressKeys( 'access+o' );
+ await expect( documentOverviewPanel ).toBeVisible();
+
+ // Focus should be on the first tab.
+ await expect(
+ page.getByRole( 'tab', { name: 'List View' } )
+ ).toBeFocused();
+
+ // Reset preferences.
+ await editor.setPreferences( 'core', {
+ showListViewByDefault: false,
+ } );
+ } );
} );
/** @typedef {import('@playwright/test').Locator} Locator */
diff --git a/test/e2e/specs/site-editor/list-view.spec.js b/test/e2e/specs/site-editor/list-view.spec.js
index db514463a73d7..414e163d33abe 100644
--- a/test/e2e/specs/site-editor/list-view.spec.js
+++ b/test/e2e/specs/site-editor/list-view.spec.js
@@ -69,6 +69,9 @@ test.describe( 'Site Editor List View', () => {
} );
await expect( listView ).toBeVisible();
+ // Move focus to the first item in the List view,
+ // which happens to be the site title block.
+ await page.keyboard.press( 'Tab' );
// The site title block should have focus.
await expect(
listView.getByRole( 'link', {