From 0c86d8878e4d6abe0d2503135f193d31b6b5d537 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Tue, 11 Feb 2025 16:39:28 +0100 Subject: [PATCH 01/10] Improve list view focus management first pass. --- .../list-view/block-select-button.js | 2 ++ .../src/components/list-view-sidebar/index.js | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) 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 3afbf3f5b5bc16..1775798852d5e1 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 } > + item.hasAttribute( 'data-is-selected' ) + )[ 0 ]; const listViewFocusArea = sidebarRef.current.contains( - listViewApplicationFocus + listViewSelectedItem ) - ? listViewApplicationFocus + ? listViewSelectedItem : tabPanelFocus; + listViewFocusArea.focus(); // Outline tab is selected. } else { @@ -147,7 +152,7 @@ export default function ListViewSidebar() { onClose={ closeListView } onSelect={ ( tabName ) => setTab( tabName ) } defaultTabId="list-view" - ref={ tabsRef } + ref={ tabsPanelRef } closeButtonLabel={ __( 'Close' ) } /> From 8842bd65399849ab7f74e0e2017d3263f4910652 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Wed, 12 Feb 2025 16:18:59 +0100 Subject: [PATCH 02/10] Improve docs and inline comment. --- packages/components/src/tree-grid/README.md | 2 ++ packages/editor/src/components/list-view-sidebar/index.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/components/src/tree-grid/README.md b/packages/components/src/tree-grid/README.md index d6e861a7b9b18b..5e4ac28b0eb0e4 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/list-view-sidebar/index.js b/packages/editor/src/components/list-view-sidebar/index.js index 651151a13232f0..b2af24bc39ecbb 100644 --- a/packages/editor/src/components/list-view-sidebar/index.js +++ b/packages/editor/src/components/list-view-sidebar/index.js @@ -53,7 +53,7 @@ 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(); From 29b43c8ca9b426052820510c3231a9d0f07b310b Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Thu, 13 Feb 2025 15:30:38 +0100 Subject: [PATCH 03/10] Use a timeout for the ListView initial focus. --- .../src/components/list-view/index.js | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index d7961bd6c02f3d..e02254cfea2e9b 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -179,12 +179,27 @@ function ListViewComponent( ref, ] ); + const timerIdRef = useRef(); + useEffect( () => { - // 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. }, [] ); From 36b81f9b49c67a419914372e6dfd5c230982d3ba Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Thu, 13 Feb 2025 16:51:41 +0100 Subject: [PATCH 04/10] Improve inline comments and variable names. --- .../src/components/list-view-sidebar/index.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/list-view-sidebar/index.js b/packages/editor/src/components/list-view-sidebar/index.js index b2af24bc39ecbb..3b4967664ad975 100644 --- a/packages/editor/src/components/list-view-sidebar/index.js +++ b/packages/editor/src/components/list-view-sidebar/index.js @@ -74,27 +74,28 @@ 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. + // 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. const listViewSelectedItem = focus.tabbable .find( listViewRef.current ) .filter( ( item ) => item.hasAttribute( 'data-is-selected' ) )[ 0 ]; - const listViewFocusArea = sidebarRef.current.contains( + const listViewFocusTarget = sidebarRef.current.contains( listViewSelectedItem ) ? listViewSelectedItem - : tabPanelFocus; + : activeTab; - listViewFocusArea.focus(); + listViewFocusTarget.focus(); // Outline tab is selected. } else { - tabPanelFocus.focus(); + activeTab.focus(); } } From de25492b9fc6c747764c8914a19bafa7ee077bf2 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Fri, 14 Feb 2025 14:19:25 +0100 Subject: [PATCH 05/10] Add inline comment. --- packages/editor/src/components/list-view-sidebar/index.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/editor/src/components/list-view-sidebar/index.js b/packages/editor/src/components/list-view-sidebar/index.js index 3b4967664ad975..1975f4d053b22e 100644 --- a/packages/editor/src/components/list-view-sidebar/index.js +++ b/packages/editor/src/components/list-view-sidebar/index.js @@ -81,6 +81,13 @@ export default function ListViewSidebar() { // 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 + // attribute makes sure to target the selected item. const listViewSelectedItem = focus.tabbable .find( listViewRef.current ) .filter( ( item ) => From dd24f5d0868c91b8f7f4ac21d2df5ecd61b056b9 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Fri, 14 Feb 2025 16:40:32 +0100 Subject: [PATCH 06/10] Adjust tests. --- .../specs/editor/various/block-hierarchy-navigation.spec.js | 3 +++ test/e2e/specs/site-editor/list-view.spec.js | 3 +++ 2 files changed, 6 insertions(+) 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 f0bfe5bff203fb..00e08432c472e1 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/site-editor/list-view.spec.js b/test/e2e/specs/site-editor/list-view.spec.js index db514463a73d76..414e163d33abea 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', { From d4e9febe7151975813c05e196716f1bfbd93930a Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Tue, 18 Feb 2025 16:26:41 +0100 Subject: [PATCH 07/10] Do not set initial focus when Always open List View is enabled. --- .../src/components/list-view-sidebar/index.js | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/components/list-view-sidebar/index.js b/packages/editor/src/components/list-view-sidebar/index.js index 1975f4d053b22e..50c9e7e3c54c71 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,8 +51,8 @@ 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 ) => { @@ -45,6 +64,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 ); @@ -64,7 +96,17 @@ export default function ListViewSidebar() { setDropZoneElement, ] ); - const tabsPanelRef = useMergeRefs( [ focusOnMountRef, tabsRef ] ); + // 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. From 5f1ee83baf62e1672b7956cef2fef1be7efd5152 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Wed, 19 Feb 2025 15:51:18 +0100 Subject: [PATCH 08/10] Fix toggle list view shortcut when showListViewByDefault is enabled. --- .../global-keyboard-shortcuts/index.js | 10 +++- .../src/components/list-view-sidebar/index.js | 46 +++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/editor/src/components/global-keyboard-shortcuts/index.js b/packages/editor/src/components/global-keyboard-shortcuts/index.js index a46d4b55a7bfd8..18d96d822f8e06 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 50c9e7e3c54c71..c67faf6fa2bff7 100644 --- a/packages/editor/src/components/list-view-sidebar/index.js +++ b/packages/editor/src/components/list-view-sidebar/index.js @@ -57,6 +57,9 @@ export default function ListViewSidebar() { 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(); } @@ -148,23 +151,36 @@ export default function ListViewSidebar() { } } - 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 From dc3ac1f1cb12fc6e8067cda9c7f636d92a0d1743 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Thu, 20 Feb 2025 13:56:21 +0100 Subject: [PATCH 09/10] Fix typo. --- packages/editor/src/components/list-view-sidebar/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/list-view-sidebar/index.js b/packages/editor/src/components/list-view-sidebar/index.js index c67faf6fa2bff7..1310dd6d7f9550 100644 --- a/packages/editor/src/components/list-view-sidebar/index.js +++ b/packages/editor/src/components/list-view-sidebar/index.js @@ -132,7 +132,7 @@ export default function ListViewSidebar() { // 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 - // attribute makes sure to target the selected item. + // makes sure to target the selected item. const listViewSelectedItem = focus.tabbable .find( listViewRef.current ) .filter( ( item ) => From 263f3797b2daab98684388e8c4114423c099b2e0 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Fri, 21 Feb 2025 12:43:02 +0100 Subject: [PATCH 10/10] Add e2e tests. --- .../specs/editor/various/list-view.spec.js | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index 98dfe5e304f802..80f895e6d63335 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 */