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 cec5d4699c7a2f..25de5483f5192e 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 @@ -19,6 +19,7 @@ import { SPACE, ENTER, BACKSPACE, DELETE } from '@wordpress/keycodes'; import { useSelect, useDispatch } from '@wordpress/data'; import { __unstableUseShortcutEventMatch as useShortcutEventMatch } from '@wordpress/keyboard-shortcuts'; import { __, sprintf } from '@wordpress/i18n'; +import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -30,6 +31,7 @@ import ListViewExpander from './expander'; import { useBlockLock } from '../block-lock'; import { store as blockEditorStore } from '../../store'; import useListViewImages from './use-list-view-images'; +import { useListViewContext } from './context'; function ListViewBlockSelectButton( { @@ -64,10 +66,12 @@ function ListViewBlockSelectButton( getBlocksByClientId, canRemoveBlocks, } = useSelect( blockEditorStore ); - const { duplicateBlocks, removeBlocks } = useDispatch( blockEditorStore ); + const { duplicateBlocks, multiSelect, removeBlocks } = + useDispatch( blockEditorStore ); const isMatch = useShortcutEventMatch(); const isSticky = blockInformation?.positionType === 'sticky'; const images = useListViewImages( { clientId, isExpanded } ); + const { rootClientId } = useListViewContext(); const positionLabel = blockInformation?.positionLabel ? sprintf( @@ -183,6 +187,45 @@ function ListViewBlockSelectButton( updateFocusAndSelection( updatedBlocks[ 0 ], false ); } } + } else if ( isMatch( 'core/block-editor/select-all', event ) ) { + if ( event.defaultPrevented ) { + return; + } + event.preventDefault(); + + const { firstBlockRootClientId, selectedBlockClientIds } = + getBlocksToUpdate(); + const blockClientIds = getBlockOrder( firstBlockRootClientId ); + if ( ! blockClientIds.length ) { + return; + } + + // If we have selected all sibling nested blocks, try selecting up a level. + // This is a similar implementation to that used by `useSelectAll`. + // `isShallowEqual` is used for the list view instead of a length check, + // as the array of siblings of the currently focused block may be a different + // set of blocks from the current block selection if the user is focused + // on a different part of the list view from the block selection. + if ( isShallowEqual( selectedBlockClientIds, blockClientIds ) ) { + // Only select up a level if the first block is not the root block. + // This ensures that the block selection can't break out of the root block + // used by the list view, if the list view is only showing a partial hierarchy. + if ( + firstBlockRootClientId && + firstBlockRootClientId !== rootClientId + ) { + updateFocusAndSelection( firstBlockRootClientId, true ); + return; + } + } + + // Select all while passing `null` to skip focusing to the editor canvas, + // and retain focus within the list view. + multiSelect( + blockClientIds[ 0 ], + blockClientIds[ blockClientIds.length - 1 ], + null + ); } } diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 1d21c28643a73c..315a6153839d16 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -222,6 +222,7 @@ function ListViewComponent( insertedBlock, setInsertedBlock, treeGridElementRef: elementRef, + rootClientId, } ), [ draggedClientIds, @@ -233,6 +234,7 @@ function ListViewComponent( AdditionalBlockContent, insertedBlock, setInsertedBlock, + rootClientId, ] ); diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index 222d743acdf395..f05b7760c4cc7a 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -421,7 +421,7 @@ test.describe( 'List View', () => { ).toBeFocused(); } ); - test( 'should duplicate, delete, and deselect blocks using keyboard', async ( { + test( 'should select, duplicate, delete, and deselect blocks using keyboard', async ( { editor, page, pageUtils, @@ -464,6 +464,116 @@ test.describe( 'List View', () => { { name: 'core/file', selected: true, focused: true }, ] ); + // Move up to columns block, expand, and then move to the first column block. + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowDown' ); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'The last inserted block should be selected, while the first column block should be focused.' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + innerBlocks: [ + { name: 'core/column', selected: false, focused: true }, + { name: 'core/column' }, + ], + }, + { name: 'core/file', selected: true, focused: false }, + ] ); + + // Select all sibling column blocks at current level. + await pageUtils.pressKeys( 'primary+a' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'All column blocks should be selected, with the first one focused.' + ) + .toMatchObject( [ + { name: 'core/group', selected: false, focused: false }, + { + name: 'core/columns', + innerBlocks: [ + { name: 'core/column', selected: true, focused: true }, + { name: 'core/column', selected: true, focused: false }, + ], + selected: false, + }, + { name: 'core/file', selected: false, focused: false }, + ] ); + + // Select next parent (the columns block). + await pageUtils.pressKeys( 'primary+a' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'The columns block should be selected and focused.' + ) + .toMatchObject( [ + { name: 'core/group', selected: false, focused: false }, + { + name: 'core/columns', + innerBlocks: [ + { name: 'core/column' }, + { name: 'core/column' }, + ], + selected: true, + focused: true, + }, + { name: 'core/file', selected: false, focused: false }, + ] ); + + // Select all siblings at root level. + await pageUtils.pressKeys( 'primary+a' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'All blocks should be selected.' + ) + .toMatchObject( [ + { name: 'core/group', selected: true, focused: false }, + { + name: 'core/columns', + innerBlocks: [ + { name: 'core/column' }, + { name: 'core/column' }, + ], + selected: true, + focused: true, + }, + { name: 'core/file', selected: true, focused: false }, + ] ); + + // Deselect blocks via Escape key. + await page.keyboard.press( 'Escape' ); + // Collapse the columns block. + await page.keyboard.press( 'ArrowLeft' ); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'All blocks should be deselected, with focus on the Columns block.' + ) + .toMatchObject( [ + { name: 'core/group', selected: false, focused: false }, + { + name: 'core/columns', + selected: false, + focused: true, + }, + { name: 'core/file', selected: false, focused: false }, + ] ); + + // Move focus and selection to the file block to set up for testing duplication. + await listView + .getByRole( 'gridcell', { name: 'File', exact: true } ) + .dblclick(); + + // Test duplication behaviour. await pageUtils.pressKeys( 'primaryShift+d' ); await expect