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;