diff --git a/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js b/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js index 2ee452b4fca96f..a4087ed84cee3c 100644 --- a/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js +++ b/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js @@ -16,7 +16,10 @@ import { useRef, } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; +import { + store as keyboardShortcutsStore, + __unstableUseShortcutEventMatch, +} from '@wordpress/keyboard-shortcuts'; import { pipe, useCopyToClipboard } from '@wordpress/compose'; /** @@ -30,7 +33,6 @@ import BlockSettingsMenuControls from '../block-settings-menu-controls'; import { store as blockEditorStore } from '../../store'; import { useShowMoversGestures } from '../block-toolbar/utils'; -const noop = () => {}; const POPOVER_PROPS = { className: 'block-editor-block-settings-menu__popover', position: 'bottom right', @@ -63,7 +65,6 @@ export function BlockSettingsDropdown( { onlyBlock, parentBlockType, previousBlockClientId, - nextBlockClientId, selectedBlockClientIds, } = useSelect( ( select ) => { @@ -72,7 +73,6 @@ export function BlockSettingsDropdown( { getBlockName, getBlockRootClientId, getPreviousBlockClientId, - getNextBlockClientId, getSelectedBlockClientIds, getSettings, getBlockAttributes, @@ -98,12 +98,13 @@ export function BlockSettingsDropdown( { getBlockType( parentBlockName ) ), previousBlockClientId: getPreviousBlockClientId( firstBlockClientId ), - nextBlockClientId: getNextBlockClientId( firstBlockClientId ), selectedBlockClientIds: getSelectedBlockClientIds(), }; }, [ firstBlockClientId ] ); + const { getBlockOrder, getSelectedBlockClientIds } = + useSelect( blockEditorStore ); const shortcuts = useSelect( ( select ) => { const { getShortcutRepresentation } = select( keyboardShortcutsStore ); @@ -120,51 +121,47 @@ export function BlockSettingsDropdown( { ), }; }, [] ); + const isMatch = __unstableUseShortcutEventMatch(); const { selectBlock, toggleBlockHighlight } = useDispatch( blockEditorStore ); + const hasSelectedBlocks = selectedBlockClientIds.length > 0; const updateSelectionAfterDuplicate = useCallback( - __experimentalSelectBlock - ? async ( clientIdsPromise ) => { - const ids = await clientIdsPromise; - if ( ids && ids[ 0 ] ) { - __experimentalSelectBlock( ids[ 0 ] ); - } - } - : noop, + async ( clientIdsPromise ) => { + if ( __experimentalSelectBlock ) { + const ids = await clientIdsPromise; + if ( ids && ids[ 0 ] ) { + __experimentalSelectBlock( ids[ 0 ], false ); + } + } + }, [ __experimentalSelectBlock ] ); - const updateSelectionAfterRemove = useCallback( - __experimentalSelectBlock - ? () => { - const blockToSelect = - previousBlockClientId || - nextBlockClientId || - firstParentClientId; + const updateSelectionAfterRemove = useCallback( () => { + if ( __experimentalSelectBlock ) { + let blockToFocus = previousBlockClientId || firstParentClientId; - if ( - blockToSelect && - // From the block options dropdown, it's possible to remove a block that is not selected, - // in this case, it's not necessary to update the selection since the selected block wasn't removed. - selectedBlockClientIds.includes( firstBlockClientId ) && - // Don't update selection when next/prev block also is in the selection ( and gets removed ), - // In case someone selects all blocks and removes them at once. - ! selectedBlockClientIds.includes( blockToSelect ) - ) { - __experimentalSelectBlock( blockToSelect ); - } - } - : noop, - [ - __experimentalSelectBlock, - previousBlockClientId, - nextBlockClientId, - firstParentClientId, - selectedBlockClientIds, - ] - ); + // Focus the first block if there's no previous block nor parent block. + if ( ! blockToFocus ) { + blockToFocus = getBlockOrder()[ 0 ]; + } + + // Only update the selection if the original selection is removed. + const shouldUpdateSelection = + hasSelectedBlocks && getSelectedBlockClientIds().length === 0; + + __experimentalSelectBlock( blockToFocus, shouldUpdateSelection ); + } + }, [ + __experimentalSelectBlock, + previousBlockClientId, + firstParentClientId, + getBlockOrder, + hasSelectedBlocks, + getSelectedBlockClientIds, + ] ); const removeBlockLabel = count === 1 ? __( 'Delete' ) : __( 'Delete blocks' ); @@ -212,6 +209,49 @@ export function BlockSettingsDropdown( { className="block-editor-block-settings-menu" popoverProps={ POPOVER_PROPS } noIcons + menuProps={ { + /** + * @param {KeyboardEvent} event + */ + onKeyDown( event ) { + if ( event.defaultPrevented ) return; + + if ( + isMatch( 'core/block-editor/remove', event ) && + canRemove + ) { + event.preventDefault(); + updateSelectionAfterRemove( onRemove() ); + } else if ( + isMatch( + 'core/block-editor/duplicate', + event + ) && + canDuplicate + ) { + event.preventDefault(); + updateSelectionAfterDuplicate( onDuplicate() ); + } else if ( + isMatch( + 'core/block-editor/insert-after', + event + ) && + canInsertDefaultBlock + ) { + event.preventDefault(); + onInsertAfter(); + } else if ( + isMatch( + 'core/block-editor/insert-before', + event + ) && + canInsertDefaultBlock + ) { + event.preventDefault(); + onInsertBefore(); + } + }, + } } { ...props } > { ( { onClose } ) => ( 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 068688a7d56030..ca5e414ae65769 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 @@ -13,7 +13,9 @@ import { } from '@wordpress/components'; import { forwardRef } from '@wordpress/element'; import { Icon, lockSmall as lock } from '@wordpress/icons'; -import { SPACE, ENTER } from '@wordpress/keycodes'; +import { SPACE, ENTER, BACKSPACE, DELETE } from '@wordpress/keycodes'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { __unstableUseShortcutEventMatch as useShortcutEventMatch } from '@wordpress/keyboard-shortcuts'; /** * Internal dependencies @@ -23,6 +25,7 @@ import useBlockDisplayInformation from '../use-block-display-information'; import useBlockDisplayTitle from '../block-title/use-block-display-title'; import ListViewExpander from './expander'; import { useBlockLock } from '../block-lock'; +import { store as blockEditorStore } from '../../store'; function ListViewBlockSelectButton( { @@ -38,6 +41,7 @@ function ListViewBlockSelectButton( isExpanded, ariaLabel, ariaDescribedBy, + updateFocusAndSelection, }, ref ) { @@ -47,6 +51,15 @@ function ListViewBlockSelectButton( context: 'list-view', } ); const { isLocked } = useBlockLock( clientId ); + const { + getSelectedBlockClientIds, + getPreviousBlockClientId, + getBlockRootClientId, + getBlockOrder, + canRemoveBlocks, + } = useSelect( blockEditorStore ); + const { removeBlocks } = useDispatch( blockEditorStore ); + const isMatch = useShortcutEventMatch(); // The `href` attribute triggers the browser's native HTML drag operations. // When the link is dragged, the element's outerHTML is set in DataTransfer object as text/html. @@ -57,9 +70,54 @@ function ListViewBlockSelectButton( onDragStart?.( event ); }; + /** + * @param {KeyboardEvent} event + */ function onKeyDownHandler( event ) { if ( event.keyCode === ENTER || event.keyCode === SPACE ) { onClick( event ); + } else if ( + event.keyCode === BACKSPACE || + event.keyCode === DELETE || + isMatch( 'core/block-editor/remove', event ) + ) { + const selectedBlockClientIds = getSelectedBlockClientIds(); + const isDeletingSelectedBlocks = + selectedBlockClientIds.includes( clientId ); + const firstBlockClientId = isDeletingSelectedBlocks + ? selectedBlockClientIds[ 0 ] + : clientId; + const firstBlockRootClientId = + getBlockRootClientId( firstBlockClientId ); + + const blocksToDelete = isDeletingSelectedBlocks + ? selectedBlockClientIds + : [ clientId ]; + + // Don't update the selection if the blocks cannot be deleted. + if ( ! canRemoveBlocks( blocksToDelete, firstBlockRootClientId ) ) { + return; + } + + let blockToFocus = + getPreviousBlockClientId( firstBlockClientId ) ?? + // If the previous block is not found (when the first block is deleted), + // fallback to focus the parent block. + firstBlockRootClientId; + + removeBlocks( blocksToDelete, false ); + + // Update the selection if the original selection has been removed. + const shouldUpdateSelection = + selectedBlockClientIds.length > 0 && + getSelectedBlockClientIds().length === 0; + + // If there's no previous block nor parent block, focus the first block. + if ( ! blockToFocus ) { + blockToFocus = getBlockOrder()[ 0 ]; + } + + updateFocusAndSelection( blockToFocus, shouldUpdateSelection ); } } diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index dc863dd337c0c3..20a385537f9b8e 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -22,6 +22,7 @@ import { } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { sprintf, __ } from '@wordpress/i18n'; +import { focus } from '@wordpress/dom'; /** * Internal dependencies @@ -125,6 +126,7 @@ function ListViewBlock( { listViewInstanceId, expandedState, setInsertedBlock, + treeGridElementRef, } = useListViewContext(); const hasSiblings = siblingBlockCount > 0; @@ -165,11 +167,38 @@ function ListViewBlock( { [ clientId, selectBlock ] ); - const updateSelection = useCallback( - ( newClientId ) => { - selectBlock( undefined, newClientId ); + const updateFocusAndSelection = useCallback( + ( focusClientId, shouldSelectBlock ) => { + if ( shouldSelectBlock ) { + selectBlock( undefined, focusClientId, null, null ); + } + + const getFocusElement = () => { + const row = treeGridElementRef.current?.querySelector( + `[role=row][data-block="${ focusClientId }"]` + ); + if ( ! row ) return null; + // Focus the first focusable in the row, which is the ListViewBlockSelectButton. + return focus.focusable.find( row )[ 0 ]; + }; + + let focusElement = getFocusElement(); + if ( focusElement ) { + focusElement.focus(); + } else { + // The element hasn't been painted yet. Defer focusing on the next frame. + // This could happen when all blocks have been deleted and the default block + // hasn't been added to the editor yet. + window.requestAnimationFrame( () => { + focusElement = getFocusElement(); + // Ignore if the element still doesn't exist. + if ( focusElement ) { + focusElement.focus(); + } + } ); + } }, - [ selectBlock ] + [ selectBlock, treeGridElementRef ] ); const toggleExpanded = useCallback( @@ -266,6 +295,7 @@ function ListViewBlock( { selectedClientIds={ selectedClientIds } ariaLabel={ blockAriaLabel } ariaDescribedBy={ descriptionId } + updateFocusAndSelection={ updateFocusAndSelection } />
) } diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 90d85ae8422def..ea637f0fe3131a 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -141,8 +141,13 @@ function ListViewComponent( setExpandedState, } ); const selectEditorBlock = useCallback( - ( event, blockClientId ) => { - updateBlockSelection( event, blockClientId ); + /** + * @param {MouseEvent | KeyboardEvent | undefined} event + * @param {string} blockClientId + * @param {null | undefined | -1 | 1} focusPosition + */ + ( event, blockClientId, focusPosition ) => { + updateBlockSelection( event, blockClientId, null, focusPosition ); setSelectedTreeId( blockClientId ); if ( onSelect ) { onSelect( getBlock( blockClientId ) ); @@ -222,6 +227,7 @@ function ListViewComponent( renderAdditionalBlockUI, insertedBlock, setInsertedBlock, + treeGridElementRef: elementRef, } ), [ draggedClientIds, diff --git a/packages/block-editor/src/components/list-view/use-block-selection.js b/packages/block-editor/src/components/list-view/use-block-selection.js index 716995edbdd53f..d1bf465d10a9c2 100644 --- a/packages/block-editor/src/components/list-view/use-block-selection.js +++ b/packages/block-editor/src/components/list-view/use-block-selection.js @@ -29,9 +29,9 @@ export default function useBlockSelection() { const { getBlockType } = useSelect( blocksStore ); const updateBlockSelection = useCallback( - async ( event, clientId, destinationClientId ) => { + async ( event, clientId, destinationClientId, focusPosition ) => { if ( ! event?.shiftKey ) { - selectBlock( clientId ); + selectBlock( clientId, focusPosition ); return; } diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index b7021752ea8cfb..971d571128bce7 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -4,6 +4,12 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.describe( 'List View', () => { + test.use( { + listViewUtils: async ( { page, pageUtils, editor }, use ) => { + await use( new ListViewUtils( { page, pageUtils, editor } ) ); + }, + } ); + test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); } ); @@ -115,146 +121,6 @@ test.describe( 'List View', () => { await expect( listView.getByRole( 'row' ) ).toHaveCount( 2 ); } ); - // Check for regression of https://github.com/WordPress/gutenberg/issues/39026. - test( 'selects the previous block after removing the selected one', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - await editor.insertBlock( { name: 'core/paragraph' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Paragraph', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Remove the Paragraph block via its options menu in List View. - await listView - .getByRole( 'button', { name: 'Options for Paragraph' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete/i } ).click(); - - // Heading block should be selected as previous block. - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Heading', - } ) - ).toBeFocused(); - } ); - - // Check for regression of https://github.com/WordPress/gutenberg/issues/39026. - test( 'selects the next block after removing the very first block', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - await editor.insertBlock( { name: 'core/paragraph' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Paragraph', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Select the image block in List View. - await pageUtils.pressKeys( 'ArrowUp', { times: 2 } ); - await expect( - listView.getByRole( 'link', { - name: 'Image', - } ) - ).toBeFocused(); - await page.keyboard.press( 'Enter' ); - - // Remove the Image block via its options menu in List View. - await listView - .getByRole( 'button', { name: 'Options for Image' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete/i } ).click(); - - // Heading block should be selected as previous block. - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Heading', - } ) - ).toBeFocused(); - } ); - - /** - * When all the blocks gets removed from the editor, it inserts a default - * paragraph block; make sure that paragraph block gets selected after - * removing blocks from ListView. - */ - test( 'selects the default paragraph block after removing all blocks', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Heading', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Select the Image block as well. - await pageUtils.pressKeys( 'shift+ArrowUp' ); - await expect( - listView.getByRole( 'gridcell', { - name: 'Image', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Remove both blocks. - await listView - .getByRole( 'button', { name: 'Options for Image' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete blocks/i } ).click(); - - // Newly created paragraph block should be selected. - await expect( - editor.canvas.getByRole( 'document', { name: /Empty block/i } ) - ).toBeFocused(); - } ); - test( 'expands nested list items', async ( { editor, page, @@ -557,4 +423,418 @@ test.describe( 'List View', () => { } ) ).toBeFocused(); } ); + + test( 'should delete blocks using keyboard', async ( { + editor, + page, + pageUtils, + listViewUtils, + } ) => { + // Insert some blocks of different types. + await editor.insertBlock( { + name: 'core/group', + innerBlocks: [ { name: 'core/pullquote' } ], + } ); + await editor.insertBlock( { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { name: 'core/heading' }, + { name: 'core/paragraph' }, + ], + }, + { + name: 'core/column', + innerBlocks: [ { name: 'core/verse' } ], + }, + ], + } ); + await editor.insertBlock( { name: 'core/file' } ); + + // Open List View. + const listView = await listViewUtils.openListView(); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'The last inserted block should be selected and focused.' + ) + .toMatchObject( [ + { name: 'core/group' }, + { name: 'core/columns' }, + { name: 'core/file', selected: true, focused: true }, + ] ); + + await page.keyboard.press( 'Delete' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting a block should move focus and selection to the previous block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { name: 'core/columns', selected: true, focused: true }, + ] ); + + // Expand the current column. + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Move focus but do not select the second column' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + selected: true, + innerBlocks: [ + { name: 'core/column' }, + { name: 'core/column', focused: true }, + ], + }, + ] ); + + await page.keyboard.press( 'Delete' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting a inner block moves focus to the previous inner block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + selected: true, + innerBlocks: [ + { + name: 'core/column', + selected: false, + focused: true, + }, + ], + }, + ] ); + + // Expand the current column. + await page.keyboard.press( 'ArrowRight' ); + // Move focus and select the Heading block. + await listView + .getByRole( 'gridcell', { name: 'Heading', exact: true } ) + .dblclick(); + // Select both inner blocks in the column. + await page.keyboard.press( 'Shift+ArrowDown' ); + + await page.keyboard.press( 'Backspace' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting multiple blocks moves focus to the parent block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + selected: true, + focused: true, + innerBlocks: [], + }, + ], + }, + ] ); + + // Move focus and select the first block. + await listView + .getByRole( 'gridcell', { name: 'Group', exact: true } ) + .dblclick(); + await page.keyboard.press( 'Backspace' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting the first block moves focus to the second block' + ) + .toMatchObject( [ + { + name: 'core/columns', + selected: true, + focused: true, + }, + ] ); + + // Keyboard shortcut should also work. + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting the only block left will create a default block and focus/select it' + ) + .toMatchObject( [ + { + name: 'core/paragraph', + selected: true, + focused: true, + }, + ] ); + + await editor.insertBlock( { name: 'core/heading' } ); + await page.evaluate( () => + window.wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock() + ); + await listView + .getByRole( 'gridcell', { name: 'Paragraph' } ) + .getByRole( 'link' ) + .focus(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Block selection is cleared and focus is on the paragraph block' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: false, focused: true }, + { name: 'core/heading', selected: false }, + ] ); + + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting blocks without existing selection will not select blocks' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false, focused: true }, + ] ); + + // Insert a block that is locked and cannot be removed. + await editor.insertBlock( { + name: 'core/file', + attributes: { lock: { move: false, remove: true } }, + } ); + // Click on the Heading block to select it. + await listView + .getByRole( 'gridcell', { name: 'Heading', exact: true } ) + .click(); + await listView + .getByRole( 'gridcell', { name: 'File' } ) + .getByRole( 'link' ) + .focus(); + for ( const keys of [ 'Delete', 'Backspace', 'access+z' ] ) { + await pageUtils.pressKeys( keys ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Trying to delete locked blocks should not do anything' + ) + .toMatchObject( [ + { name: 'core/heading', selected: true, focused: false }, + { name: 'core/file', selected: false, focused: true }, + ] ); + } + } ); + + test( 'block settings dropdown menu', async ( { + editor, + page, + pageUtils, + listViewUtils, + } ) => { + // Insert some blocks of different types. + await editor.insertBlock( { name: 'core/heading' } ); + await editor.insertBlock( { name: 'core/file' } ); + + // Open List View. + const listView = await listViewUtils.openListView(); + + await listView + .getByRole( 'button', { name: 'Options for Heading' } ) + .click(); + + await page + .getByRole( 'menu', { name: 'Options for Heading' } ) + .getByRole( 'menuitem', { name: 'Duplicate' } ) + .click(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should duplicate a block and move focus' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false }, + { name: 'core/heading', selected: false, focused: true }, + { name: 'core/file', selected: true }, + ] ); + + await page.keyboard.press( 'Shift+ArrowUp' ); + await listView + .getByRole( 'button', { name: 'Options for Heading' } ) + .first() + .click(); + await page + .getByRole( 'menu', { name: 'Options for Heading' } ) + .getByRole( 'menuitem', { name: 'Delete blocks' } ) + .click(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should delete multiple selected blocks using the dropdown menu' + ) + .toMatchObject( [ + { name: 'core/file', selected: true, focused: true }, + ] ); + + await page.keyboard.press( 'ArrowRight' ); + const optionsForFileToggle = listView + .getByRole( 'row' ) + .filter( { + has: page.getByRole( 'gridcell', { name: 'File' } ), + } ) + .getByRole( 'button', { name: 'Options for File' } ); + const optionsForFileMenu = page.getByRole( 'menu', { + name: 'Options for File', + } ); + await expect( + optionsForFileToggle, + 'Pressing arrow right should move focus to the menu dropdown toggle button' + ).toBeFocused(); + + await page.keyboard.press( 'Enter' ); + await expect( + optionsForFileMenu, + 'Pressing Enter should open the menu dropdown' + ).toBeVisible(); + + await page.keyboard.press( 'Escape' ); + await expect( + optionsForFileMenu, + 'Pressing Escape should close the menu dropdown' + ).toBeHidden(); + await expect( + optionsForFileToggle, + 'Should move focus back to the toggle button' + ).toBeFocused(); + + await page.keyboard.press( 'Space' ); + await expect( + optionsForFileMenu, + 'Pressing Space should also open the menu dropdown' + ).toBeVisible(); + + await pageUtils.pressKeys( 'primaryAlt+t' ); // Keyboard shortcut for Insert before. + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Pressing keyboard shortcut should also work when the menu is opened and focused' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: true, focused: false }, + { name: 'core/file', selected: false, focused: false }, + ] ); + await expect( + optionsForFileMenu, + 'The menu should be closed after pressing keyboard shortcut' + ).toBeHidden(); + + await optionsForFileToggle.click(); + await pageUtils.pressKeys( 'access+z' ); // Keyboard shortcut for Delete. + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting blocks should move focus and selection' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: true, focused: true }, + ] ); + + // Insert a block that is locked and cannot be removed. + await editor.insertBlock( { + name: 'core/file', + attributes: { lock: { move: false, remove: true } }, + } ); + await optionsForFileToggle.click(); + await expect( + optionsForFileMenu.getByRole( 'menuitem', { name: 'Delete' } ), + 'The delete menu item should be hidden for locked blocks' + ).toBeHidden(); + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Pressing keyboard shortcut should not delete locked blocks either' + ) + .toMatchObject( [ + { name: 'core/paragraph' }, + { name: 'core/file', selected: true }, + ] ); + await expect( + optionsForFileMenu, + 'The dropdown menu should also be visible' + ).toBeVisible(); + } ); } ); + +/** @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 ); + }; +}