diff --git a/packages/block-editor/src/components/block-tools/index.js b/packages/block-editor/src/components/block-tools/index.js index 3959257ecf4e86..17e4e026cbbab0 100644 --- a/packages/block-editor/src/components/block-tools/index.js +++ b/packages/block-editor/src/components/block-tools/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; +import { isTextField } from '@wordpress/dom'; import { Popover } from '@wordpress/components'; import { __unstableUseShortcutEventMatch as useShortcutEventMatch } from '@wordpress/keyboard-shortcuts'; import { useRef } from '@wordpress/element'; @@ -20,6 +21,7 @@ import { store as blockEditorStore } from '../../store'; import usePopoverScroll from '../block-popover/use-popover-scroll'; import ZoomOutModeInserters from './zoom-out-mode-inserters'; import { useShowBlockTools } from './use-show-block-tools'; +import { unlock } from '../../lock-unlock'; function selector( select ) { const { @@ -79,7 +81,8 @@ export default function BlockTools( { selectBlock, moveBlocksUp, moveBlocksDown, - } = useDispatch( blockEditorStore ); + expandBlock, + } = unlock( useDispatch( blockEditorStore ) ); function onKeyDown( event ) { if ( event.defaultPrevented ) return; @@ -140,6 +143,20 @@ export default function BlockTools( { // In effect, to the user this feels like deselecting the multi-selection. selectBlock( clientIds[ 0 ] ); } + } else if ( isMatch( 'core/block-editor/collapse-list-view', event ) ) { + // If focus is currently within a text field, such as a rich text block or other editable field, + // skip collapsing the list view, and allow the keyboard shortcut to be handled by the text field. + // This condition checks for both the active element and the active element within an iframed editor. + if ( + isTextField( event.target ) || + isTextField( + event.target?.contentWindow?.document?.activeElement + ) + ) { + return; + } + event.preventDefault(); + expandBlock( clientId ); } } diff --git a/packages/block-editor/src/components/keyboard-shortcuts/index.js b/packages/block-editor/src/components/keyboard-shortcuts/index.js index 0e0a57257becca..7ea36a14aa7a8b 100644 --- a/packages/block-editor/src/components/keyboard-shortcuts/index.js +++ b/packages/block-editor/src/components/keyboard-shortcuts/index.js @@ -132,6 +132,17 @@ function KeyboardShortcutsRegister() { character: 'y', }, } ); + + // List view shortcuts. + registerShortcut( { + name: 'core/block-editor/collapse-list-view', + category: 'list-view', + description: __( 'Collapse all other items.' ), + keyCombination: { + modifier: 'alt', + character: 'l', + }, + } ); }, [ registerShortcut ] ); return null; 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 6b9de943ea0bf2..91faae5e5097ff 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 @@ -65,6 +65,7 @@ function ListViewBlockSelectButton( getPreviousBlockClientId, getBlockRootClientId, getBlockOrder, + getBlockParents, getBlocksByClientId, canRemoveBlocks, } = useSelect( blockEditorStore ); @@ -73,7 +74,7 @@ function ListViewBlockSelectButton( const isMatch = useShortcutEventMatch(); const isSticky = blockInformation?.positionType === 'sticky'; const images = useListViewImages( { clientId, isExpanded } ); - const { rootClientId } = useListViewContext(); + const { collapseAll, expand, rootClientId } = useListViewContext(); const positionLabel = blockInformation?.positionLabel ? sprintf( @@ -228,6 +229,17 @@ function ListViewBlockSelectButton( blockClientIds[ blockClientIds.length - 1 ], null ); + } else if ( isMatch( 'core/block-editor/collapse-list-view', event ) ) { + if ( event.defaultPrevented ) { + return; + } + event.preventDefault(); + const { firstBlockClientId } = getBlocksToUpdate(); + const blockParents = getBlockParents( firstBlockClientId, false ); + // Collapse all blocks. + collapseAll(); + // Expand all parents of the current block. + expand( blockParents ); } } diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 03942d72a74b5b..8a696c6f56c241 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -37,6 +37,7 @@ import ListViewDropIndicatorPreview from './drop-indicator'; import useBlockSelection from './use-block-selection'; import useListViewBlockIndexes from './use-list-view-block-indexes'; import useListViewClientIds from './use-list-view-client-ids'; +import useListViewCollapseItems from './use-list-view-collapse-items'; import useListViewDropZone from './use-list-view-drop-zone'; import useListViewExpandSelectedItem from './use-list-view-expand-selected-item'; import { store as blockEditorStore } from '../../store'; @@ -45,6 +46,9 @@ import { focusListItem } from './utils'; import useClipboardHandler from './use-clipboard-handler'; const expanded = ( state, action ) => { + if ( action.type === 'clear' ) { + return {}; + } if ( Array.isArray( action.clientIds ) ) { return { ...state, @@ -194,7 +198,10 @@ function ListViewComponent( if ( ! clientId ) { return; } - setExpandedState( { type: 'expand', clientIds: [ clientId ] } ); + const clientIds = Array.isArray( clientId ) + ? clientId + : [ clientId ]; + setExpandedState( { type: 'expand', clientIds } ); }, [ setExpandedState ] ); @@ -207,6 +214,9 @@ function ListViewComponent( }, [ setExpandedState ] ); + const collapseAll = useCallback( () => { + setExpandedState( { type: 'clear' } ); + }, [ setExpandedState ] ); const expandRow = useCallback( ( row ) => { expand( row?.dataset?.block ); @@ -232,6 +242,11 @@ function ListViewComponent( [ updateBlockSelection ] ); + useListViewCollapseItems( { + collapseAll, + expand, + } ); + const firstDraggedBlockClientId = draggedClientIds?.[ 0 ]; // Convert a blockDropTarget into indexes relative to the blocks in the list view. @@ -282,6 +297,7 @@ function ListViewComponent( expand, firstDraggedBlockIndex, collapse, + collapseAll, BlockSettingsMenu, listViewInstanceId: instanceId, AdditionalBlockContent, @@ -299,6 +315,7 @@ function ListViewComponent( expand, firstDraggedBlockIndex, collapse, + collapseAll, BlockSettingsMenu, instanceId, AdditionalBlockContent, diff --git a/packages/block-editor/src/components/list-view/use-list-view-collapse-items.js b/packages/block-editor/src/components/list-view/use-list-view-collapse-items.js new file mode 100644 index 00000000000000..930c6424c3b0be --- /dev/null +++ b/packages/block-editor/src/components/list-view/use-list-view-collapse-items.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { useEffect } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +export default function useListViewCollapseItems( { collapseAll, expand } ) { + const { expandedBlock, getBlockParents } = useSelect( ( select ) => { + const { getBlockParents: _getBlockParents, getExpandedBlock } = unlock( + select( blockEditorStore ) + ); + return { + expandedBlock: getExpandedBlock(), + getBlockParents: _getBlockParents, + }; + }, [] ); + + // Collapse all but the specified block when the expanded block client Id changes. + useEffect( () => { + if ( expandedBlock ) { + const blockParents = getBlockParents( expandedBlock, false ); + // Collapse all blocks and expand the block's parents. + collapseAll(); + expand( blockParents ); + } + }, [ collapseAll, expand, expandedBlock, getBlockParents ] ); +} diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index d402d45657704c..85d624e048318f 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -376,3 +376,15 @@ export function stopDragging() { type: 'STOP_DRAGGING', }; } + +/** + * @param {string|null} clientId The block's clientId, or `null` to clear. + * + * @return {Object} Action object. + */ +export function expandBlock( clientId ) { + return { + type: 'SET_BLOCK_EXPANDED_IN_LIST_VIEW', + clientId, + }; +} diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index e4885cbbd9e1e1..45a45fe947582a 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -353,3 +353,14 @@ export function getLastFocus( state ) { export function isDragging( state ) { return state.isDragging; } + +/** + * Retrieves the expanded block from the state. + * + * @param {Object} state Block editor state. + * + * @return {string|null} The client ID of the expanded block, if set. + */ +export function getExpandedBlock( state ) { + return state.expandedBlock; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index e836a44e12012f..13024d4d2e8fac 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1889,6 +1889,27 @@ export function highlightedBlock( state, action ) { return state; } +/** + * Reducer returning current expanded block in the list view. + * + * @param {string|null} state Current expanded block. + * @param {Object} action Dispatched action. + * + * @return {string|null} Updated state. + */ +export function expandedBlock( state = null, action ) { + switch ( action.type ) { + case 'SET_BLOCK_EXPANDED_IN_LIST_VIEW': + return action.clientId; + case 'SELECT_BLOCK': + if ( action.clientId !== state ) { + return null; + } + } + + return state; +} + /** * Reducer returning the block insertion event list state. * @@ -2064,6 +2085,7 @@ const combinedReducers = combineReducers( { lastFocus, editorMode, hasBlockMovingClientId, + expandedBlock, highlightedBlock, lastBlockInserted, temporarilyEditingAsBlocks, diff --git a/packages/block-editor/src/store/test/private-actions.js b/packages/block-editor/src/store/test/private-actions.js index 08370f731902d2..7576b95866306a 100644 --- a/packages/block-editor/src/store/test/private-actions.js +++ b/packages/block-editor/src/store/test/private-actions.js @@ -4,6 +4,7 @@ import { hideBlockInterface, showBlockInterface, + expandBlock, __experimentalUpdateSettings, setOpenedBlockSettingsMenu, startDragging, @@ -113,4 +114,13 @@ describe( 'private actions', () => { } ); } ); } ); + + describe( 'expandBlock', () => { + it( 'should return the SET_BLOCK_EXPANDED_IN_LIST_VIEW action', () => { + expect( expandBlock( 'block-1' ) ).toEqual( { + type: 'SET_BLOCK_EXPANDED_IN_LIST_VIEW', + clientId: 'block-1', + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index f661271b570b4b..185da1ffb98046 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -7,6 +7,7 @@ import { isBlockSubtreeDisabled, getEnabledClientIdsTree, getEnabledBlockParents, + getExpandedBlock, isDragging, } from '../private-selectors'; import { getBlockEditingMode } from '../selectors'; @@ -496,4 +497,16 @@ describe( 'private selectors', () => { expect( isDragging( state ) ).toBe( false ); } ); } ); + + describe( 'getExpandedBlock', () => { + it( 'should return the expanded block', () => { + const state = { + expandedBlock: '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + }; + + expect( getExpandedBlock( state ) ).toBe( + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f' + ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index c99d914ba21755..cd472fa59ac724 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -35,6 +35,7 @@ import { lastBlockInserted, blockEditingModes, openedBlockSettingsMenu, + expandedBlock, } from '../reducer'; const noop = () => {}; @@ -3459,4 +3460,29 @@ describe( 'state', () => { expect( state ).toBe( null ); } ); } ); + + describe( 'expandedBlock', () => { + it( 'should return null by default', () => { + expect( expandedBlock( undefined, {} ) ).toBe( null ); + } ); + + it( 'should set client id for expanded block', () => { + const state = expandedBlock( null, { + type: 'SET_BLOCK_EXPANDED_IN_LIST_VIEW', + clientId: '14501cc2-90a6-4f52-aa36-ab6e896135d1', + } ); + expect( state ).toBe( '14501cc2-90a6-4f52-aa36-ab6e896135d1' ); + } ); + + it( 'should clear the state when a block is selected', () => { + const state = expandedBlock( + '14501cc2-90a6-4f52-aa36-ab6e896135d1', + { + type: 'SELECT_BLOCK', + clientId: 'a-different-block', + } + ); + expect( state ).toBe( null ); + } ); + } ); } ); diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js b/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js index 9a7ce46704d479..666a24f2df4fc7 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/index.js @@ -136,6 +136,10 @@ export function KeyboardShortcutHelpModal( { isModalActive, toggleModal } ) { title={ __( 'Text formatting' ) } shortcuts={ textFormattingShortcuts } /> + ); } diff --git a/packages/edit-site/src/components/keyboard-shortcut-help-modal/index.js b/packages/edit-site/src/components/keyboard-shortcut-help-modal/index.js index 1da14a7cccbe77..b1d85f88dd91ef 100644 --- a/packages/edit-site/src/components/keyboard-shortcut-help-modal/index.js +++ b/packages/edit-site/src/components/keyboard-shortcut-help-modal/index.js @@ -142,6 +142,10 @@ export default function KeyboardShortcutHelpModal() { title={ __( 'Text formatting' ) } shortcuts={ textFormattingShortcuts } /> + ); } diff --git a/packages/edit-widgets/src/components/keyboard-shortcut-help-modal/index.js b/packages/edit-widgets/src/components/keyboard-shortcut-help-modal/index.js index 228aba170af8f6..91da16b995fa13 100644 --- a/packages/edit-widgets/src/components/keyboard-shortcut-help-modal/index.js +++ b/packages/edit-widgets/src/components/keyboard-shortcut-help-modal/index.js @@ -135,6 +135,10 @@ export default function KeyboardShortcutHelpModal( { title={ __( 'Text formatting' ) } shortcuts={ textFormattingShortcuts } /> + ); } diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index cb15c12c84b490..8a73911d106dc5 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -150,12 +150,13 @@ test.describe( 'List View', () => { await expect( listView.getByRole( 'row' ) ).toHaveCount( 2 ); } ); - test( 'expands nested list items', async ( { + test( 'expands and collapses nested list items', async ( { editor, page, pageUtils, } ) => { await editor.insertBlock( { name: 'core/cover' } ); + await editor.insertBlock( { name: 'core/group' } ); // Click first color option from the block placeholder's color picker to // make the inner blocks appear. @@ -196,8 +197,9 @@ test.describe( 'List View', () => { // intentionally aria-hidden. See the implementation for details. .click( { force: true } ); - // Check that we're collapsed. - await expect( listView.getByRole( 'row' ) ).toHaveCount( 1 ); + // Check that blocks are collapsed: + // 2 blocks: (one Cover block, one Group block). + await expect( listView.getByRole( 'row' ) ).toHaveCount( 2 ); // Click the Cover block List View item. await listView @@ -221,6 +223,32 @@ test.describe( 'List View', () => { selected: true, } ) ).toBeVisible(); + + // Check that blocks are expanded: + // 3 blocks: (one Cover block containing a Paragraph block, one Group block). + await expect( listView.getByRole( 'row' ) ).toHaveCount( 3 ); + + await listView + .getByRole( 'gridcell', { name: 'Paragraph', exact: true } ) + .click(); + + // Move down to the Group block. + await page.keyboard.press( 'ArrowDown' ); + + // Collapse all but the Group block. + await pageUtils.pressKeys( 'alt+l' ); + + // Check that the Cover block is collapsed. + await expect( + listView.getByRole( 'link', { + name: 'Cover', + expanded: false, + } ) + ).toBeVisible(); + + // Check that blocks are collapsed: + // 2 blocks: (one Cover block, one Group block). + await expect( listView.getByRole( 'row' ) ).toHaveCount( 2 ); } ); test( 'moves focus to start/end of list with Home/End keys', async ( {