diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 87d88b8396651..81a8d46c67c33 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -1261,6 +1261,7 @@ _Parameters_ - _start_ `string`: First block of the multi selection. - _end_ `string`: Last block of the multiselection. +- _\_\_experimentalInitialPosition_ `number|null`: Optional initial position. Pass as null to skip focus within editor canvas. ### receiveBlocks diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index af57ce5bc4bfb..57153e73a2927 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -12,6 +12,10 @@ - Removed unused `@wordpress/block-serialization-default-parser`, `css-mediaquery`, `memize` and `redux-multi` dependencies ([#38388](https://github.com/WordPress/gutenberg/pull/38388)). +### New Features + +- List View now supports selecting and dragging multiple blocks via `SHIFT` clicking items in the list [#38314](https://github.com/WordPress/gutenberg/pull/38314). + ## 8.1.0 (2022-01-27) ## 8.0.0 (2021-11-07) diff --git a/packages/block-editor/src/components/list-view/README.md b/packages/block-editor/src/components/list-view/README.md index f702c28702f8e..25fb7b61ac6c0 100644 --- a/packages/block-editor/src/components/list-view/README.md +++ b/packages/block-editor/src/components/list-view/README.md @@ -4,7 +4,7 @@ The ListView component provides an overview of the hierarchical structure of all Blocks that have child blocks (such as group or column blocks) are presented with the parent at the top and the nested children below. -In addition to presenting the structure of the blocks in the editor, the ListView component lets users navigate to each block by clicking on its line in the hierarchy tree. +In addition to presenting the structure of the blocks in the editor, the ListView component lets users navigate to each block by clicking on its line in the hierarchy tree. Multiple blocks at the same level of nesting can be selected by holding down the `SHIFT` key and clicking blocks within the list. ![List view](https://make.wordpress.org/core/files/2020/08/block-navigation.png) ![View of a group list view](https://make.wordpress.org/core/files/2020/08/view-of-group-block-navigation.png) @@ -23,7 +23,7 @@ Renders a list view with default syles. ```jsx import { ListView } from '@wordpress/block-editor'; -const MyNavigation = () => ; +const MyNavigation = () => ; ``` ## Related components diff --git a/packages/block-editor/src/components/list-view/block-contents.js b/packages/block-editor/src/components/list-view/block-contents.js index 071cd11cd70b2..2fb787dac3137 100644 --- a/packages/block-editor/src/components/list-view/block-contents.js +++ b/packages/block-editor/src/components/list-view/block-contents.js @@ -27,6 +27,7 @@ const ListViewBlockContents = forwardRef( siblingBlockCount, level, isExpanded, + selectedClientIds, ...props }, ref @@ -36,12 +37,10 @@ const ListViewBlockContents = forwardRef( const { blockMovingClientId, selectedBlockInBlockEditor } = useSelect( ( select ) => { const { - getBlockRootClientId, hasBlockMovingClientId, getSelectedBlockClientId, } = select( blockEditorStore ); return { - rootClientId: getBlockRootClientId( clientId ) || '', blockMovingClientId: hasBlockMovingClientId(), selectedBlockInBlockEditor: getSelectedBlockClientId(), }; @@ -56,8 +55,16 @@ const ListViewBlockContents = forwardRef( 'is-dropping-before': isBlockMoveTarget, } ); + // Only include all selected blocks if the currently clicked on block + // is one of the selected blocks. This ensures that if a user attempts + // to drag a block that isn't part of the selection, they're still able + // to drag it and rearrange its position. + const draggableClientIds = selectedClientIds.includes( clientId ) + ? selectedClientIds + : [ clientId ]; + return ( - + { ( { draggable, onDragStart, onDragEnd } ) => ( { - event.stopPropagation(); - selectBlock( clientId ); + selectBlock( event, clientId ); }, [ clientId, selectBlock ] ); + const selectDuplicatedBlock = useCallback( + ( newClientId ) => { + selectBlock( undefined, newClientId ); + }, + [ selectBlock ] + ); + const toggleExpanded = useCallback( ( event ) => { + // Prevent shift+click from opening link in a new window when toggling. + event.preventDefault(); event.stopPropagation(); if ( isExpanded === true ) { collapse( clientId ); @@ -154,6 +163,14 @@ function ListViewBlock( { ) : __( 'Options' ); + // Only include all selected blocks if the currently clicked on block + // is one of the selected blocks. This ensures that if a user attempts + // to alter a block that isn't part of the selection, they're still able + // to do so. + const dropdownClientIds = selectedClientIds.includes( clientId ) + ? selectedClientIds + : [ clientId ]; + return ( ) } @@ -228,7 +246,7 @@ function ListViewBlock( { { ( { ref, tabIndex, onFocus } ) => ( ) } diff --git a/packages/block-editor/src/components/list-view/branch.js b/packages/block-editor/src/components/list-view/branch.js index 007e20df4fa72..71518a1e289f3 100644 --- a/packages/block-editor/src/components/list-view/branch.js +++ b/packages/block-editor/src/components/list-view/branch.js @@ -145,6 +145,7 @@ function ListViewBranch( props ) { path={ updatedPath } isExpanded={ isExpanded } listPosition={ nextPosition } + selectedClientIds={ selectedClientIds } /> ) } { ! showBlock && ( diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index dfe3fbcf94ee3..4f7c931334893 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -6,7 +6,7 @@ import { __experimentalUseFixedWindowList as useFixedWindowList, } from '@wordpress/compose'; import { __experimentalTreeGrid as TreeGrid } from '@wordpress/components'; -import { AsyncModeProvider, useDispatch, useSelect } from '@wordpress/data'; +import { AsyncModeProvider, useSelect } from '@wordpress/data'; import { useCallback, useEffect, @@ -23,11 +23,11 @@ import { __ } from '@wordpress/i18n'; import ListViewBranch from './branch'; import { ListViewContext } from './context'; import ListViewDropIndicator from './drop-indicator'; +import useBlockSelection from './use-block-selection'; import useListViewClientIds from './use-list-view-client-ids'; import useListViewDropZone from './use-list-view-drop-zone'; import { store as blockEditorStore } from '../../store'; -const noop = () => {}; const expanded = ( state, action ) => { switch ( action.type ) { case 'expand': @@ -44,20 +44,18 @@ const expanded = ( state, action ) => { * recursive component (it renders itself), so this ensures TreeGrid is only * present at the very top of the navigation grid. * - * @param {Object} props Components props. - * @param {Array} props.blocks Custom subset of block client IDs to be used instead of the default hierarchy. - * @param {Function} props.onSelect Block selection callback. - * @param {boolean} props.showNestedBlocks Flag to enable displaying nested blocks. - * @param {boolean} props.showBlockMovers Flag to enable block movers - * @param {boolean} props.__experimentalFeatures Flag to enable experimental features. - * @param {boolean} props.__experimentalPersistentListViewFeatures Flag to enable features for the Persistent List View experiment. - * @param {boolean} props.__experimentalHideContainerBlockActions Flag to hide actions of top level blocks (like core/widget-area) - * @param {Object} ref Forwarded ref + * @param {Object} props Components props. + * @param {Array} props.blocks Custom subset of block client IDs to be used instead of the default hierarchy. + * @param {boolean} props.showNestedBlocks Flag to enable displaying nested blocks. + * @param {boolean} props.showBlockMovers Flag to enable block movers + * @param {boolean} props.__experimentalFeatures Flag to enable experimental features. + * @param {boolean} props.__experimentalPersistentListViewFeatures Flag to enable features for the Persistent List View experiment. + * @param {boolean} props.__experimentalHideContainerBlockActions Flag to hide actions of top level blocks (like core/widget-area) + * @param {Object} ref Forwarded ref */ function ListView( { blocks, - onSelect = noop, __experimentalFeatures, __experimentalPersistentListViewFeatures, __experimentalHideContainerBlockActions, @@ -72,7 +70,7 @@ function ListView( draggedClientIds, selectedClientIds, } = useListViewClientIds( blocks ); - const { selectBlock } = useDispatch( blockEditorStore ); + const { visibleBlockCount } = useSelect( ( select ) => { const { getGlobalBlockCount, getClientIdsOfDescendants } = select( @@ -88,13 +86,9 @@ function ListView( }, [ draggedClientIds ] ); - const selectEditorBlock = useCallback( - ( clientId ) => { - selectBlock( clientId ); - onSelect( clientId ); - }, - [ selectBlock, onSelect ] - ); + + const { updateBlockSelection } = useBlockSelection(); + const [ expandedState, setExpandedState ] = useReducer( expanded, {} ); const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone(); @@ -149,6 +143,18 @@ function ListView( }, [ collapse ] ); + const focusRow = useCallback( + ( event, startRow, endRow ) => { + if ( event.shiftKey ) { + updateBlockSelection( + event, + startRow?.dataset?.block, + endRow?.dataset?.block + ); + } + }, + [ updateBlockSelection ] + ); const contextValue = useMemo( () => ( { @@ -185,11 +191,12 @@ function ListView( ref={ treeGridRef } onCollapseRow={ collapseRow } onExpandRow={ expandRow } + onFocusRow={ focusRow } > { + it( 'should return start and end when no depth is provided', () => { + const result = getCommonDepthClientIds( + 'start-id', + 'clicked-id', + [], + [] + ); + + expect( result ).toEqual( { start: 'start-id', end: 'clicked-id' } ); + } ); + + it( 'should return deepest start and end when depths match', () => { + const result = getCommonDepthClientIds( + 'start-id', + 'clicked-id', + [ 'start-1', 'start-2', 'start-3' ], + [ 'end-1', 'end-2', 'end-3' ] + ); + + expect( result ).toEqual( { start: 'start-id', end: 'clicked-id' } ); + } ); + + it( 'should return shallower ids when start is shallower', () => { + const result = getCommonDepthClientIds( + 'start-id', + 'clicked-id', + [ 'start-1' ], + [ 'end-1', 'end-2', 'end-3' ] + ); + + expect( result ).toEqual( { start: 'start-id', end: 'end-2' } ); + } ); + + it( 'should return shallower ids when end is shallower', () => { + const result = getCommonDepthClientIds( + 'start-id', + 'clicked-id', + [ 'start-1', 'start-2', 'start-3' ], + [ 'end-1', 'end-2' ] + ); + + expect( result ).toEqual( { start: 'start-3', end: 'clicked-id' } ); + } ); +} ); 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 new file mode 100644 index 0000000000000..b6ac6e1825d41 --- /dev/null +++ b/packages/block-editor/src/components/list-view/use-block-selection.js @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import { difference } from 'lodash'; + +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; +import { UP, DOWN } from '@wordpress/keycodes'; +import { store as blocksStore } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { getCommonDepthClientIds } from './utils'; + +export default function useBlockSelection() { + const { clearSelectedBlock, multiSelect, selectBlock } = useDispatch( + blockEditorStore + ); + const { + getBlockName, + getBlockParents, + getBlockSelectionStart, + getBlockSelectionEnd, + getSelectedBlockClientIds, + hasMultiSelection, + hasSelectedBlock, + } = useSelect( blockEditorStore ); + + const { getBlockType } = useSelect( blocksStore ); + + const updateBlockSelection = useCallback( + async ( event, clientId, destinationClientId ) => { + if ( ! event?.shiftKey ) { + await clearSelectedBlock(); + selectBlock( clientId ); + return; + } + + // To handle multiple block selection via the `SHIFT` key, prevent + // the browser default behavior of opening the link in a new window. + event.preventDefault(); + + const isKeyPress = + event.type === 'keydown' && + ( event.keyCode === UP || event.keyCode === DOWN ); + + // Handle clicking on a block when no blocks are selected, and return early. + if ( + ! isKeyPress && + ! hasSelectedBlock() && + ! hasMultiSelection() + ) { + selectBlock( clientId, null ); + return; + } + + const selectedBlocks = getSelectedBlockClientIds(); + const clientIdWithParents = [ + ...getBlockParents( clientId ), + clientId, + ]; + + if ( + isKeyPress && + ! selectedBlocks.some( ( blockId ) => + clientIdWithParents.includes( blockId ) + ) + ) { + // Ensure that shift-selecting blocks via the keyboard only + // expands the current selection if focusing over already + // selected blocks. Otherwise, clear the selection so that + // a user can create a new selection entirely by keyboard. + await clearSelectedBlock(); + } + + let startTarget = getBlockSelectionStart(); + let endTarget = clientId; + + // Handle keyboard behavior for selecting multiple blocks. + if ( isKeyPress ) { + if ( ! hasSelectedBlock() && ! hasMultiSelection() ) { + // Set the starting point of the selection to the currently + // focused block, if there are no blocks currently selected. + // This ensures that as the selection is expanded or contracted, + // the starting point of the selection is anchored to that block. + startTarget = clientId; + } + if ( destinationClientId ) { + // If the user presses UP or DOWN, we want to ensure that the block they're + // moving to is the target for selection, and not the currently focused one. + endTarget = destinationClientId; + } + } + + const startParents = getBlockParents( startTarget ); + const endParents = getBlockParents( endTarget ); + + const { start, end } = getCommonDepthClientIds( + startTarget, + endTarget, + startParents, + endParents + ); + await multiSelect( start, end, null ); + + // Announce deselected block, or number of deselected blocks if + // the total number of blocks deselected is greater than one. + const updatedSelectedBlocks = getSelectedBlockClientIds(); + + const selectionDiff = difference( + selectedBlocks, + updatedSelectedBlocks + ); + + let label; + if ( selectionDiff.length === 1 ) { + const title = getBlockType( getBlockName( selectionDiff[ 0 ] ) ) + ?.title; + if ( title ) { + label = sprintf( + /* translators: %s: block name */ + __( '%s deselected.' ), + title + ); + } + } else if ( selectionDiff.length > 1 ) { + label = sprintf( + /* translators: %s: number of deselected blocks */ + __( '%s blocks deselected.' ), + selectionDiff.length + ); + } + + if ( label ) { + speak( label ); + } + }, + [ + clearSelectedBlock, + getBlockName, + getBlockType, + getBlockParents, + getBlockSelectionStart, + getBlockSelectionEnd, + getSelectedBlockClientIds, + hasMultiSelection, + hasSelectedBlock, + multiSelect, + selectBlock, + ] + ); + + return { + updateBlockSelection, + }; +} diff --git a/packages/block-editor/src/components/list-view/utils.js b/packages/block-editor/src/components/list-view/utils.js index 50bb561e42a8a..8ef1b23fadb78 100644 --- a/packages/block-editor/src/components/list-view/utils.js +++ b/packages/block-editor/src/components/list-view/utils.js @@ -30,3 +30,34 @@ export const isClientIdSelected = ( clientId, selectedBlockClientIds ) => isArray( selectedBlockClientIds ) && selectedBlockClientIds.length ? selectedBlockClientIds.indexOf( clientId ) !== -1 : selectedBlockClientIds === clientId; + +/** + * From a start and end clientId of potentially different nesting levels, + * return the nearest-depth ids that have a common level of depth in the + * nesting hierarchy. For multiple block selection, this ensure that the + * selection is always at the same nesting level, and not split across + * separate levels. + * + * @param {string} startId The first id of a selection. + * @param {string} endId The end id of a selection, usually one that has been clicked on. + * @param {string[]} startParents An array of ancestor ids for the start id, in descending order. + * @param {string[]} endParents An array of ancestor ids for the end id, in descending order. + * @return {Object} An object containing the start and end ids. + */ +export function getCommonDepthClientIds( + startId, + endId, + startParents, + endParents +) { + const startPath = [ ...startParents, startId ]; + const endPath = [ ...endParents, endId ]; + const depth = Math.min( startPath.length, endPath.length ) - 1; + const start = startPath[ depth ]; + const end = endPath[ depth ]; + + return { + start, + end, + }; +} diff --git a/packages/block-editor/src/components/writing-flow/use-multi-selection.js b/packages/block-editor/src/components/writing-flow/use-multi-selection.js index 01f30fdae5339..e8d11d528a440 100644 --- a/packages/block-editor/src/components/writing-flow/use-multi-selection.js +++ b/packages/block-editor/src/components/writing-flow/use-multi-selection.js @@ -62,6 +62,7 @@ function selector( select ) { getMultiSelectedBlockClientIds, hasMultiSelection, getSelectedBlockClientId, + getSelectedBlocksInitialCaretPosition, } = select( blockEditorStore ); return { @@ -69,11 +70,13 @@ function selector( select ) { multiSelectedBlockClientIds: getMultiSelectedBlockClientIds(), hasMultiSelection: hasMultiSelection(), selectedBlockClientId: getSelectedBlockClientId(), + initialPosition: getSelectedBlocksInitialCaretPosition(), }; } export default function useMultiSelection() { const { + initialPosition, isMultiSelecting, multiSelectedBlockClientIds, hasMultiSelection, @@ -93,6 +96,13 @@ export default function useMultiSelection() { const { ownerDocument } = node; const { defaultView } = ownerDocument; + // Allow initialPosition to bypass focus behavior. This is useful + // for the list view or other areas where we don't want to transfer + // focus to the editor canvas. + if ( initialPosition === undefined || initialPosition === null ) { + return; + } + if ( ! hasMultiSelection || isMultiSelecting ) { if ( ! selectedBlockClientId || isMultiSelecting ) { return; @@ -160,6 +170,7 @@ export default function useMultiSelection() { isMultiSelecting, multiSelectedBlockClientIds, selectedBlockClientId, + initialPosition, ] ); } diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 673fa6ae71f74..a467c6e8a7b19 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -259,10 +259,15 @@ export function stopMultiSelect() { /** * Action that changes block multi-selection. * - * @param {string} start First block of the multi selection. - * @param {string} end Last block of the multiselection. + * @param {string} start First block of the multi selection. + * @param {string} end Last block of the multiselection. + * @param {number|null} __experimentalInitialPosition Optional initial position. Pass as null to skip focus within editor canvas. */ -export const multiSelect = ( start, end ) => ( { select, dispatch } ) => { +export const multiSelect = ( + start, + end, + __experimentalInitialPosition = 0 +) => ( { select, dispatch } ) => { const startBlockRootClientId = select.getBlockRootClientId( start ); const endBlockRootClientId = select.getBlockRootClientId( end ); @@ -271,7 +276,12 @@ export const multiSelect = ( start, end ) => ( { select, dispatch } ) => { return; } - dispatch( { type: 'MULTI_SELECT', start, end } ); + dispatch( { + type: 'MULTI_SELECT', + start, + end, + initialPosition: __experimentalInitialPosition, + } ); const blockCount = select.getSelectedBlockCount(); diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 7aa91bd85b1f0..70600cd12482f 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1380,7 +1380,7 @@ export function isSelectionEnabled( state = true, action ) { } /** - * Reducer returning the intial block selection. + * Reducer returning the initial block selection. * * Currently this in only used to restore the selection after block deletion and * pasting new content.This reducer should eventually be removed in favour of setting @@ -1399,6 +1399,7 @@ export function initialPosition( state = null, action ) { return action.initialPosition; } else if ( [ + 'MULTI_SELECT', 'SELECT_BLOCK', 'RESET_SELECTION', 'INSERT_BLOCKS', diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index 619e65ed01929..f13d7b3f3a898 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -165,6 +165,7 @@ describe( 'actions', () => { type: 'MULTI_SELECT', start, end, + initialPosition: 0, } ); } ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 66f3e98533ee8..388b2fb5801f1 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -2187,6 +2187,15 @@ describe( 'state', () => { expect( state ).toBe( -1 ); } ); + + it( 'should allow setting null value in multi selection', () => { + const state = initialPosition( undefined, { + type: 'MULTI_SELECT', + initialPosition: null, + } ); + + expect( state ).toBe( null ); + } ); } ); describe( 'isMultiSelecting()', () => { diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index ed497aaa24914..b3c3691137e1a 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -22,6 +22,7 @@ - `TreeGrid` accessibility: improve browser support for Left Arrow focus to parent row in child row. ([#38639](https://github.com/WordPress/gutenberg/pull/38639)) - `TreeGrid` accessibility: Add Home/End keys for better keyboard navigation. ([#38679](https://github.com/WordPress/gutenberg/pull/38679)) - Add `resolvePoint` prop to `FocalPointPicker` to allow updating the value of the picker after a user interaction ([#38247](https://github.com/WordPress/gutenberg/pull/38247)) +- `TreeGrid`: Allow SHIFT key to be held, and add `onFocusRow` callback to the `TreeGrid` component, fired when focus is shifted from one row to another via Up and Down arrow keys. ([#38314](https://github.com/WordPress/gutenberg/pull/38314)) ### Experimental diff --git a/packages/components/src/tree-grid/README.md b/packages/components/src/tree-grid/README.md index 3806d7cc66fd3..66264a0104b7f 100644 --- a/packages/components/src/tree-grid/README.md +++ b/packages/components/src/tree-grid/README.md @@ -108,10 +108,33 @@ function TreeMenu() { ##### Props -`TreeGrid` accepts no specific props. Any props specified will be passed to the `table` element rendered by `TreeGrid`. +Aside from the documented callback functions, any props specified will be passed to the `table` element rendered by `TreeGrid`. `TreeGrid` should always have children. +###### onFocusRow( event: Event, startRow: HTMLElement, destinationRow: HTMLElement ) + +Callback that fires when focus is shifted from one row to another via the UP and DOWN keys. +The callback is passed the event, the start row element that the focus was on originally, and +the destination row element after the focus has moved. + +- Type: `Function` +- Required: No + +###### onCollapseRow( row: HTMLElement ) + +A callback that passes in the row element to be collapsed. + +- Type: `Function` +- Required: No + +###### onExpandRow( row: HTMLElement ) + +A callback that passes in the row element to be expanded. + +- Type: `Function` +- Required: No + #### TreeGridRow ##### Props diff --git a/packages/components/src/tree-grid/index.js b/packages/components/src/tree-grid/index.js index 34bdeabfebc68..cf84160d060d0 100644 --- a/packages/components/src/tree-grid/index.js +++ b/packages/components/src/tree-grid/index.js @@ -45,18 +45,26 @@ function getRowFocusables( rowElement ) { * @param {WPElement} props.children Children to be rendered. * @param {Function} props.onExpandRow Callback to fire when row is expanded. * @param {Function} props.onCollapseRow Callback to fire when row is collapsed. + * @param {Function} props.onFocusRow Callback to fire when moving focus to a different row. * @param {Object} ref A ref to the underlying DOM table element. */ function TreeGrid( - { children, onExpandRow = () => {}, onCollapseRow = () => {}, ...props }, + { + children, + onExpandRow = () => {}, + onCollapseRow = () => {}, + onFocusRow = () => {}, + ...props + }, ref ) { const onKeyDown = useCallback( ( event ) => { - const { keyCode, metaKey, ctrlKey, altKey, shiftKey } = event; + const { keyCode, metaKey, ctrlKey, altKey } = event; - const hasModifierKeyPressed = - metaKey || ctrlKey || altKey || shiftKey; + // The shift key is intentionally absent from the following list, + // to enable shift + up/down to select items from the list. + const hasModifierKeyPressed = metaKey || ctrlKey || altKey; if ( hasModifierKeyPressed || @@ -216,6 +224,10 @@ function TreeGrid( ); focusablesInNextRow[ nextIndex ].focus(); + // Let consumers know the row that was originally focused, + // and the row that is now in focus. + onFocusRow( event, activeRow, rows[ nextRowIndex ] ); + // Prevent key use for anything else. This ensures Voiceover // doesn't try to handle key navigation. event.preventDefault(); @@ -268,7 +280,7 @@ function TreeGrid( event.preventDefault(); } }, - [ onExpandRow, onCollapseRow ] + [ onExpandRow, onCollapseRow, onFocusRow ] ); /* Disable reason: A treegrid is implemented using a table element. */ diff --git a/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js b/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js index c87700e23d3e1..9c25e55d6d334 100644 --- a/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js +++ b/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js @@ -10,6 +10,7 @@ import { clickBlockToolbarButton, clickButton, clickMenuItem, + openListView, saveDraft, transformBlockTo, } from '@wordpress/e2e-test-utils'; @@ -747,4 +748,87 @@ describe( 'Multi-block selection', () => { } ); expect( selectedText ).toEqual( 'Post title' ); } ); + + it( 'should multi-select in the ListView component with shift + click', async () => { + // Create four blocks. + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '3' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '4' ); + + // Open up the list view, and get a reference to each of the list items. + await openListView(); + const navButtons = await page.$$( + '.block-editor-list-view-block-select-button' + ); + + // Clicking on the second list item should result in the second block being selected. + await navButtons[ 1 ].click(); + expect( await getSelectedFlatIndices() ).toEqual( 2 ); + + // Shift clicking the fourth list item should result in blocks 2 through 4 being selected. + await page.keyboard.down( 'Shift' ); + await navButtons[ 3 ].click(); + expect( await getSelectedFlatIndices() ).toEqual( [ 2, 3, 4 ] ); + + // With the shift key still held down, clicking the first block should result in + // the first two blocks being selected. + await navButtons[ 0 ].click(); + expect( await getSelectedFlatIndices() ).toEqual( [ 1, 2 ] ); + + // With the shift key up, clicking the fourth block should result in only that block + // being selected. + await page.keyboard.up( 'Shift' ); + await navButtons[ 3 ].click(); + expect( await getSelectedFlatIndices() ).toEqual( 4 ); + } ); + + it( 'should multi-select in the ListView component with shift + up and down keys', async () => { + // Create four blocks. + await clickBlockAppender(); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '3' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '4' ); + + // Open up the list view. The fourth block button will be focused. + await openListView(); + + // Press Up twice to focus over the second block. + await pressKeyTimes( 'ArrowUp', 2 ); + + // Shift + press Down to select the 2nd and 3rd blocks. + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowDown' ); + expect( await getSelectedFlatIndices() ).toEqual( [ 2, 3 ] ); + + // Press Down once more to also select the 4th block. + await page.keyboard.press( 'ArrowDown' ); + expect( await getSelectedFlatIndices() ).toEqual( [ 2, 3, 4 ] ); + + // Press Up three times to adjust the selection to only include the first two blocks. + await pressKeyTimes( 'ArrowUp', 3 ); + expect( await getSelectedFlatIndices() ).toEqual( [ 1, 2 ] ); + + // Raise the shift key + await page.keyboard.up( 'Shift' ); + + // Navigate to the bottom of the list of blocks. + await pressKeyTimes( 'ArrowDown', 3 ); + + // Shift + press UP to select the 3rd and 4th blocks. + // This tests that shift selecting blocks by keyboard that are not adjacent + // to an existing selection resets the selection. + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.up( 'Shift' ); + expect( await getSelectedFlatIndices() ).toEqual( [ 3, 4 ] ); + } ); } ); diff --git a/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js index 9076101bc399b..cc09ac47ae7ae 100644 --- a/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js +++ b/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js @@ -1,10 +1,7 @@ /** * WordPress dependencies */ -import { - __experimentalListView as ListView, - store as blockEditorStore, -} from '@wordpress/block-editor'; +import { __experimentalListView as ListView } from '@wordpress/block-editor'; import { Button } from '@wordpress/components'; import { useFocusOnMount, @@ -25,12 +22,6 @@ import { store as editPostStore } from '../../store'; export default function ListViewSidebar() { const { setIsListViewOpened } = useDispatch( editPostStore ); - const { clearSelectedBlock, selectBlock } = useDispatch( blockEditorStore ); - async function selectEditorBlock( clientId ) { - await clearSelectedBlock(); - selectBlock( clientId, -1 ); - } - const focusOnMountRef = useFocusOnMount( 'firstElement' ); const headerFocusReturnRef = useFocusReturn(); const contentFocusReturnRef = useFocusReturn(); @@ -70,7 +61,6 @@ export default function ListViewSidebar() { ] ) } >