diff --git a/packages/block-editor/src/components/block-navigation/block-contents.js b/packages/block-editor/src/components/block-navigation/block-contents.js
index a877e7023425dc..e941a5661b6b9d 100644
--- a/packages/block-editor/src/components/block-navigation/block-contents.js
+++ b/packages/block-editor/src/components/block-navigation/block-contents.js
@@ -22,6 +22,7 @@ const BlockNavigationBlockContents = forwardRef(
(
{
onClick,
+ onToggleExpanded,
block,
isSelected,
position,
@@ -98,7 +99,7 @@ const BlockNavigationBlockContents = forwardRef(
ref={ ref }
className={ className }
block={ block }
- onClick={ onClick }
+ onToggleExpanded={ onToggleExpanded }
isSelected={ isSelected }
position={ position }
siblingBlockCount={ siblingBlockCount }
@@ -114,6 +115,7 @@ const BlockNavigationBlockContents = forwardRef(
className={ className }
block={ block }
onClick={ onClick }
+ onToggleExpanded={ onToggleExpanded }
isSelected={ isSelected }
position={ position }
siblingBlockCount={ siblingBlockCount }
diff --git a/packages/block-editor/src/components/block-navigation/block-select-button.js b/packages/block-editor/src/components/block-navigation/block-select-button.js
index 60b0cf50300f1b..72a29d33cbe43f 100644
--- a/packages/block-editor/src/components/block-navigation/block-select-button.js
+++ b/packages/block-editor/src/components/block-navigation/block-select-button.js
@@ -18,6 +18,7 @@ import BlockIcon from '../block-icon';
import useBlockDisplayInformation from '../use-block-display-information';
import { getBlockPositionDescription } from './utils';
import BlockTitle from '../block-title';
+import BlockNavigationExpander from './expander';
function BlockNavigationBlockSelectButton(
{
@@ -25,6 +26,7 @@ function BlockNavigationBlockSelectButton(
block: { clientId },
isSelected,
onClick,
+ onToggleExpanded,
position,
siblingBlockCount,
level,
@@ -61,6 +63,7 @@ function BlockNavigationBlockSelectButton(
onDragEnd={ onDragEnd }
draggable={ draggable }
>
+
{ blockInformation?.anchor && (
diff --git a/packages/block-editor/src/components/block-navigation/block-slot.js b/packages/block-editor/src/components/block-navigation/block-slot.js
index 9615d214b2c1b4..10e01be8230332 100644
--- a/packages/block-editor/src/components/block-navigation/block-slot.js
+++ b/packages/block-editor/src/components/block-navigation/block-slot.js
@@ -26,6 +26,7 @@ import { BlockListBlockContext } from '../block-list/block';
import BlockNavigationBlockSelectButton from './block-select-button';
import { getBlockPositionDescription } from './utils';
import { store as blockEditorStore } from '../../store';
+import BlockNavigationExpander from './expander';
const getSlotName = ( clientId ) => `BlockNavigationBlock-${ clientId }`;
@@ -57,6 +58,7 @@ function BlockNavigationBlockSlot( props, ref ) {
level,
tabIndex,
onFocus,
+ onToggleExpanded,
} = props;
const blockType = getBlockType( name );
@@ -86,6 +88,9 @@ function BlockNavigationBlockSlot( props, ref ) {
className
) }
>
+
{ Children.map( fills, ( fill ) =>
cloneElement( fill, {
diff --git a/packages/block-editor/src/components/block-navigation/block.js b/packages/block-editor/src/components/block-navigation/block.js
index 42e267e460eb30..d15480fede10b3 100644
--- a/packages/block-editor/src/components/block-navigation/block.js
+++ b/packages/block-editor/src/components/block-navigation/block.js
@@ -36,12 +36,14 @@ export default function BlockNavigationBlock( {
isBranchSelected,
isLastOfSelectedBranch,
onClick,
+ onToggleExpanded,
position,
level,
rowCount,
siblingBlockCount,
showBlockMovers,
path,
+ isExpanded,
} ) {
const cellRef = useRef( null );
const [ isHovered, setIsHovered ] = useState( false );
@@ -142,6 +144,7 @@ export default function BlockNavigationBlock( {
path={ path }
id={ `block-navigation-block-${ clientId }` }
data-block={ clientId }
+ isExpanded={ isExpanded }
>
onClick( block.clientId ) }
+ onClick={ onClick }
+ onToggleExpanded={ onToggleExpanded }
isSelected={ isSelected }
position={ position }
siblingBlockCount={ siblingBlockCount }
diff --git a/packages/block-editor/src/components/block-navigation/branch.js b/packages/block-editor/src/components/block-navigation/branch.js
index 6b2c766f2a4d4f..163eedbfb0a80b 100644
--- a/packages/block-editor/src/components/block-navigation/branch.js
+++ b/packages/block-editor/src/components/block-navigation/branch.js
@@ -14,6 +14,8 @@ import { Fragment } from '@wordpress/element';
import BlockNavigationBlock from './block';
import BlockNavigationAppender from './appender';
import { isClientIdSelected } from './utils';
+import { useBlockNavigationContext } from './context';
+
export default function BlockNavigationBranch( props ) {
const {
blocks,
@@ -42,6 +44,8 @@ export default function BlockNavigationBranch( props ) {
const rowCount = hasAppender ? blockCount + 1 : blockCount;
const appenderPosition = rowCount;
+ const { expandedState, expand, collapse } = useBlockNavigationContext();
+
return (
<>
{ map( filteredBlocks, ( block, index ) => {
@@ -71,11 +75,30 @@ export default function BlockNavigationBranch( props ) {
const isLastOfSelectedBranch =
isLastOfBranch && ! hasNestedBranch && isLastBlock;
+ const isExpanded = hasNestedBranch
+ ? expandedState[ clientId ] ?? true
+ : undefined;
+
+ const selectBlockWithClientId = ( event ) => {
+ event.stopPropagation();
+ selectBlock( clientId );
+ };
+
+ const toggleExpanded = ( event ) => {
+ event.stopPropagation();
+ if ( isExpanded === true ) {
+ collapse( clientId );
+ } else if ( isExpanded === false ) {
+ expand( clientId );
+ }
+ };
+
return (
- { hasNestedBranch && (
+ { hasNestedBranch && isExpanded && (
onClick( event, { forceToggle: true } ) }
+ aria-hidden="true"
+ >
+
+
+ );
+}
diff --git a/packages/block-editor/src/components/block-navigation/style.scss b/packages/block-editor/src/components/block-navigation/style.scss
index 902c2a92dc91fa..37da180afafc03 100644
--- a/packages/block-editor/src/components/block-navigation/style.scss
+++ b/packages/block-editor/src/components/block-navigation/style.scss
@@ -40,6 +40,12 @@
&.is-branch-selected.is-selected .block-editor-block-navigation-block-contents {
border-radius: 2px 2px 0 0;
}
+
+ &[aria-expanded="false"] {
+ &.is-branch-selected.is-selected .block-editor-block-navigation-block-contents {
+ border-radius: 2px;
+ }
+ }
&.is-branch-selected:not(.is-selected) .block-editor-block-navigation-block-contents {
// Lighten a CSS variable without introducing a new SASS variable
background:
@@ -60,7 +66,7 @@
align-items: center;
width: 100%;
height: auto;
- padding: ($grid-unit-15 / 2) $grid-unit-15;
+ padding: ($grid-unit-15 / 2) $grid-unit-15 ($grid-unit-15 / 2) 0;
text-align: left;
color: $gray-900;
border-radius: 2px;
@@ -121,8 +127,8 @@
.block-editor-block-icon {
align-self: flex-start;
- margin-right: ( $grid-unit-05 * 2.5 ); // 10px is off the 4px grid, but required for visual alignment between block name and subsequent nested icon
- width: 20px;
+ margin-right: $grid-unit-10;
+ width: $icon-size;
}
.block-editor-block-navigation-block__menu-cell,
@@ -274,15 +280,53 @@
}
}
+// Chevron container metrics.
+.block-editor-block-navigation__expander {
+ height: $icon-size;
+ margin-left: $grid-unit-05;
+ width: $icon-size;
+}
+
// First level of indentation is aria-level 2, max indent is 8.
// Indent is a full icon size, plus 4px which optically aligns child icons to the text label above.
$block-navigation-max-indent: 8;
-.block-editor-block-navigation-leaf[aria-level] .block-editor-block-icon {
- margin-left: ( $grid-unit-30 + $grid-unit-05 ) * $block-navigation-max-indent;
+.block-editor-block-navigation-leaf[aria-level] .block-editor-block-navigation__expander {
+ margin-left: ( $icon-size ) * $block-navigation-max-indent + 4 * ( $block-navigation-max-indent - 1 );
+}
+
+.block-editor-block-navigation-leaf:not([aria-level="1"]) {
+ .block-editor-block-navigation__expander {
+ margin-right: 4px;
+ }
}
-@for $i from 0 through $block-navigation-max-indent {
- .block-editor-block-navigation-leaf[aria-level="#{ $i + 1 }"] .block-editor-block-icon {
- margin-left: ( $grid-unit-30 + $grid-unit-05 ) * $i;
+
+@for $i from 0 to $block-navigation-max-indent {
+ .block-editor-block-navigation-leaf[aria-level="#{ $i + 1 }"] .block-editor-block-navigation__expander {
+ @if $i - 1 >= 0 {
+ margin-left: ( $icon-size * $i ) + 4 * ($i - 1);
+ }
+ @else {
+ margin-left: ( $icon-size * $i );
+ }
}
}
+.block-editor-block-navigation-leaf .block-editor-block-navigation__expander {
+ visibility: hidden;
+}
+
+// Point downwards when open.
+.block-editor-block-navigation-leaf[aria-expanded="true"] .block-editor-block-navigation__expander svg {
+ visibility: visible;
+ transition: transform 0.2s ease;
+ transform: rotate(90deg);
+ @include reduce-motion("transition");
+}
+
+// Point rightwards when closed
+.block-editor-block-navigation-leaf[aria-expanded="false"] .block-editor-block-navigation__expander svg {
+ visibility: visible;
+ transform: rotate(0deg);
+ transition: transform 0.2s ease;
+ @include reduce-motion("transition");
+}
diff --git a/packages/block-editor/src/components/block-navigation/tree.js b/packages/block-editor/src/components/block-navigation/tree.js
index f8e64233935b6b..ec0954d8dcbfc0 100644
--- a/packages/block-editor/src/components/block-navigation/tree.js
+++ b/packages/block-editor/src/components/block-navigation/tree.js
@@ -4,7 +4,13 @@
import { __experimentalTreeGrid as TreeGrid } from '@wordpress/components';
import { useDispatch } from '@wordpress/data';
-import { useCallback, useEffect, useMemo, useRef } from '@wordpress/element';
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useReducer,
+} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
@@ -17,6 +23,16 @@ import useBlockNavigationDropZone from './use-block-navigation-drop-zone';
import { store as blockEditorStore } from '../../store';
const noop = () => {};
+const expanded = ( state, action ) => {
+ switch ( action.type ) {
+ case 'expand':
+ return { ...state, ...{ [ action.clientId ]: true } };
+ case 'collapse':
+ return { ...state, ...{ [ action.clientId ]: false } };
+ default:
+ return state;
+ }
+};
/**
* Wrap `BlockNavigationRows` with `TreeGrid`. BlockNavigationRows is a
@@ -52,6 +68,7 @@ export default function BlockNavigationTree( {
},
[ selectBlock, onSelect ]
);
+ const [ expandedState, setExpandedState ] = useReducer( expanded, {} );
let {
ref: treeGridRef,
@@ -67,18 +84,43 @@ export default function BlockNavigationTree( {
blockDropTarget = undefined;
}
+ const expand = ( clientId ) => {
+ if ( ! clientId ) {
+ return;
+ }
+ setExpandedState( { type: 'expand', clientId } );
+ };
+ const collapse = ( clientId ) => {
+ if ( ! clientId ) {
+ return;
+ }
+ setExpandedState( { type: 'collapse', clientId } );
+ };
+ const expandRow = ( row ) => {
+ expand( row?.dataset?.block );
+ };
+ const collapseRow = ( row ) => {
+ collapse( row?.dataset?.block );
+ };
+
const contextValue = useMemo(
() => ( {
__experimentalFeatures,
__experimentalPersistentListViewFeatures,
blockDropTarget,
isTreeGridMounted: isMounted.current,
+ expandedState,
+ expand,
+ collapse,
} ),
[
__experimentalFeatures,
__experimentalPersistentListViewFeatures,
blockDropTarget,
isMounted.current,
+ expandedState,
+ expand,
+ collapse,
]
);
@@ -87,6 +129,8 @@ export default function BlockNavigationTree( {
className="block-editor-block-navigation-tree"
aria-label={ __( 'Block navigation structure' ) }
ref={ treeGridRef }
+ onCollapseRow={ collapseRow }
+ onExpandRow={ expandRow }
>
{}, onCollapseRow = () => {}, ...props },
+ ref
+) {
const onKeyDown = useCallback( ( event ) => {
const { keyCode, metaKey, ctrlKey, altKey, shiftKey } = event;
@@ -82,8 +87,47 @@ function TreeGrid( { children, ...props }, ref ) {
);
}
- // Focus is either at the left or right edge of the grid. Do nothing.
+ // Focus is either at the left or right edge of the grid.
if ( nextIndex === currentColumnIndex ) {
+ if ( keyCode === LEFT ) {
+ // Left:
+ // If a row is focused, and it is expanded, collapses the current row.
+ if ( activeRow?.ariaExpanded === 'true' ) {
+ onCollapseRow( activeRow );
+ event.preventDefault();
+ return;
+ }
+ // If a row is focused, and it is collapsed, moves to the parent row (if there is one).
+ const level = Math.max(
+ parseInt( activeRow?.ariaLevel ?? 1, 10 ) - 1,
+ 1
+ );
+ const rows = Array.from(
+ treeGridElement.querySelectorAll( '[role="row"]' )
+ );
+ let parentRow = activeRow;
+ const currentRowIndex = rows.indexOf( activeRow );
+ for ( let i = currentRowIndex; i >= 0; i-- ) {
+ if ( parseInt( rows[ i ].ariaLevel, 10 ) === level ) {
+ parentRow = rows[ i ];
+ break;
+ }
+ }
+ getRowFocusables( parentRow )?.[ 0 ]?.focus();
+ } else {
+ // Right:
+ // If a row is focused, and it is collapsed, expands the current row.
+ if ( activeRow?.ariaExpanded === 'false' ) {
+ onExpandRow( activeRow );
+ event.preventDefault();
+ return;
+ }
+ // If a row is focused, and it is expanded, focuses the rightmost cell in the row.
+ const focusableItems = getRowFocusables( activeRow );
+ if ( focusableItems.length > 0 ) {
+ focusableItems[ focusableItems.length - 1 ]?.focus();
+ }
+ }
// Prevent key use for anything else. For example, Voiceover
// will start reading text on continued use of left/right arrow
// keys.
diff --git a/packages/edit-post/src/components/secondary-sidebar/style.scss b/packages/edit-post/src/components/secondary-sidebar/style.scss
index 635ea084e15bb7..710319654a357c 100644
--- a/packages/edit-post/src/components/secondary-sidebar/style.scss
+++ b/packages/edit-post/src/components/secondary-sidebar/style.scss
@@ -46,5 +46,6 @@
.edit-post-editor__list-view-panel-content {
overflow-y: auto;
- padding: $grid-unit-10;
+ // The table cells use an extra pixels of space left and right. We compensate for that here.
+ padding: $grid-unit-10 ($grid-unit-10 - $border-width - $border-width);
}
diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js
index f6862c18cbe37c..d98147331d0845 100644
--- a/packages/icons/src/index.js
+++ b/packages/icons/src/index.js
@@ -33,6 +33,7 @@ export { default as check } from './library/check';
export { default as chevronDown } from './library/chevron-down';
export { default as chevronLeft } from './library/chevron-left';
export { default as chevronRight } from './library/chevron-right';
+export { default as chevronRightSmall } from './library/chevron-right-small';
export { default as chevronUp } from './library/chevron-up';
export { default as classic } from './library/classic';
export { default as close } from './library/close';
diff --git a/packages/icons/src/library/chevron-right-small.js b/packages/icons/src/library/chevron-right-small.js
new file mode 100644
index 00000000000000..5618cc0fce99ec
--- /dev/null
+++ b/packages/icons/src/library/chevron-right-small.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const chevronRightSmall = (
+
+);
+
+export default chevronRightSmall;