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 ( {