diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 7b0bd386daaf48..38a93552bcbef2 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -588,6 +588,18 @@ _Properties_ - _isDisabled_ `boolean`: Whether or not the user should be prevented from inserting this item. - _frecency_ `number`: Heuristic that combines frequency and recency. +### getLastFocus + +Returns the element of the last element that had focus when focus left the editor canvas. + +_Parameters_ + +- _state_ `Object`: Block editor state. + +_Returns_ + +- `Object`: Element. + ### getLastMultiSelectedBlockClientId Returns the client ID of the last block in the multi-selection set, or null if there is no multi-selection. @@ -1651,6 +1663,18 @@ _Parameters_ - _clientId_ `string`: The block's clientId. - _hasControlledInnerBlocks_ `boolean`: True if the block's inner blocks are controlled. +### setLastFocus + +Action that sets the element that had focus when focus leaves the editor canvas. + +_Parameters_ + +- _lastFocus_ `Object`: The last focused element. + +_Returns_ + +- `Object`: Action object. + ### setNavigationMode Action that enables or disables the navigation mode. diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index cbe495d3787cd9..958838b081a93c 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -106,9 +106,8 @@ $z-layers: ( // Show interface skeleton footer above interface skeleton drawer ".interface-interface-skeleton__footer": 90, - // Above the block list and the header. + // Below the header background so it can be hidden behind the header. ".block-editor-block-popover": 31, - // Show snackbars above everything (similar to popovers) ".components-snackbar-list": 100000, diff --git a/packages/block-editor/src/components/block-tools/back-compat.js b/packages/block-editor/src/components/block-tools/back-compat.js index 3597bc07b07f79..029419926e9ed5 100644 --- a/packages/block-editor/src/components/block-tools/back-compat.js +++ b/packages/block-editor/src/components/block-tools/back-compat.js @@ -9,7 +9,7 @@ import deprecated from '@wordpress/deprecated'; * Internal dependencies */ import InsertionPoint, { InsertionPointOpenRef } from './insertion-point'; -import BlockPopover from './selected-block-popover'; +import BlockPopover from './selected-block-tools'; export default function BlockToolsBackCompat( { children } ) { const openRef = useContext( InsertionPointOpenRef ); diff --git a/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js b/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js index fcec9d56b24a8a..470ea08436f54f 100644 --- a/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js +++ b/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js @@ -7,21 +7,9 @@ import classnames from 'classnames'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { - useLayoutEffect, - useEffect, - useRef, - useState, -} from '@wordpress/element'; +import { forwardRef } from '@wordpress/element'; import { hasBlockSupport, store as blocksStore } from '@wordpress/blocks'; import { useSelect } from '@wordpress/data'; -import { - ToolbarItem, - ToolbarButton, - ToolbarGroup, -} from '@wordpress/components'; -import { next, previous } from '@wordpress/icons'; -import { useViewportMatch } from '@wordpress/compose'; /** * Internal dependencies @@ -31,138 +19,44 @@ import BlockToolbar from '../block-toolbar'; import { store as blockEditorStore } from '../../store'; import { useHasAnyBlockControls } from '../block-controls/use-has-block-controls'; -function BlockContextualToolbar( { focusOnMount, isFixed, ...props } ) { - // When the toolbar is fixed it can be collapsed - const [ isCollapsed, setIsCollapsed ] = useState( false ); - const toolbarButtonRef = useRef(); - - const isLargeViewport = useViewportMatch( 'medium' ); - const { - blockType, - blockEditingMode, - hasParents, - showParentSelector, - selectedBlockClientId, - } = useSelect( ( select ) => { - const { - getBlockName, - getBlockParents, - getSelectedBlockClientIds, - getBlockEditingMode, - } = select( blockEditorStore ); - const { getBlockType } = select( blocksStore ); - const selectedBlockClientIds = getSelectedBlockClientIds(); - const _selectedBlockClientId = selectedBlockClientIds[ 0 ]; - const parents = getBlockParents( _selectedBlockClientId ); - const firstParentClientId = parents[ parents.length - 1 ]; - const parentBlockName = getBlockName( firstParentClientId ); - const parentBlockType = getBlockType( parentBlockName ); - - return { - selectedBlockClientId: _selectedBlockClientId, - blockType: - _selectedBlockClientId && - getBlockType( getBlockName( _selectedBlockClientId ) ), - blockEditingMode: getBlockEditingMode( _selectedBlockClientId ), - hasParents: parents.length, - showParentSelector: - parentBlockType && - getBlockEditingMode( firstParentClientId ) === 'default' && - hasBlockSupport( - parentBlockType, - '__experimentalParentSelector', - true - ) && - selectedBlockClientIds.length <= 1 && - getBlockEditingMode( _selectedBlockClientId ) === 'default', - }; - }, [] ); - - useEffect( () => { - setIsCollapsed( false ); - }, [ selectedBlockClientId ] ); - - const isLargerThanTabletViewport = useViewportMatch( 'large', '>=' ); - const isFullscreen = - document.body.classList.contains( 'is-fullscreen-mode' ); - - /** - * The following code is a workaround to fix the width of the toolbar - * it should be removed when the toolbar will be rendered inline - * FIXME: remove this layout effect when the toolbar is no longer - * absolutely positioned - */ - useLayoutEffect( () => { - // don't do anything if not fixed toolbar - if ( ! isFixed ) { - return; - } - - const blockToolbar = document.querySelector( - '.block-editor-block-contextual-toolbar' - ); - - if ( ! blockToolbar ) { - return; - } - - if ( ! blockType ) { - blockToolbar.style.width = 'initial'; - return; - } - - if ( ! isLargerThanTabletViewport ) { - // set the width of the toolbar to auto - blockToolbar.style = {}; - return; - } - - if ( isCollapsed ) { - // set the width of the toolbar to auto - blockToolbar.style.width = 'auto'; - return; - } - - // get the width of the pinned items in the post editor or widget editor - const pinnedItems = document.querySelector( - '.edit-post-header__settings, .edit-widgets-header__actions' - ); - // get the width of the left header in the site editor - const leftHeader = document.querySelector( - '.edit-site-header-edit-mode__end' - ); - - const computedToolbarStyle = window.getComputedStyle( blockToolbar ); - const computedPinnedItemsStyle = pinnedItems - ? window.getComputedStyle( pinnedItems ) - : false; - const computedLeftHeaderStyle = leftHeader - ? window.getComputedStyle( leftHeader ) - : false; - - const marginLeft = parseFloat( computedToolbarStyle.marginLeft ); - const pinnedItemsWidth = computedPinnedItemsStyle - ? parseFloat( computedPinnedItemsStyle.width ) - : 0; - const leftHeaderWidth = computedLeftHeaderStyle - ? parseFloat( computedLeftHeaderStyle.width ) - : 0; - - // set the new witdth of the toolbar - blockToolbar.style.width = `calc(100% - ${ - leftHeaderWidth + - pinnedItemsWidth + - marginLeft + - ( pinnedItems || leftHeader ? 2 : 0 ) + // Prevents button focus border from being cut off - ( isFullscreen ? 0 : 160 ) // the width of the admin sidebar expanded - }px)`; - }, [ - isFixed, - isLargerThanTabletViewport, - isCollapsed, - isFullscreen, - blockType, - ] ); +function UnforwardBlockContextualToolbar( + { focusOnMount, isFixed, ...props }, + ref +) { + const { blockType, blockEditingMode, hasParents, showParentSelector } = + useSelect( ( select ) => { + const { + getBlockName, + getBlockParents, + getSelectedBlockClientIds, + getBlockEditingMode, + } = select( blockEditorStore ); + const { getBlockType } = select( blocksStore ); + const selectedBlockClientIds = getSelectedBlockClientIds(); + const _selectedBlockClientId = selectedBlockClientIds[ 0 ]; + const parents = getBlockParents( _selectedBlockClientId ); + const firstParentClientId = parents[ parents.length - 1 ]; + const parentBlockName = getBlockName( firstParentClientId ); + const parentBlockType = getBlockType( parentBlockName ); + + return { + blockType: + _selectedBlockClientId && + getBlockType( getBlockName( _selectedBlockClientId ) ), + blockEditingMode: getBlockEditingMode( _selectedBlockClientId ), + hasParents: parents.length, + showParentSelector: + parentBlockType && + getBlockEditingMode( firstParentClientId ) === 'default' && + hasBlockSupport( + parentBlockType, + '__experimentalParentSelector', + true + ) && + selectedBlockClientIds.length <= 1 && + getBlockEditingMode( _selectedBlockClientId ) === 'default', + }; + }, [] ); const isToolbarEnabled = ! blockType || @@ -179,45 +73,28 @@ function BlockContextualToolbar( { focusOnMount, isFixed, ...props } ) { const classes = classnames( 'block-editor-block-contextual-toolbar', { 'has-parent': hasParents && showParentSelector, 'is-fixed': isFixed, - 'is-collapsed': isCollapsed, } ); return ( { + if ( event.keyCode === ESCAPE && lastFocus?.current ) { + event.preventDefault(); + lastFocus.current.focus(); + } + } } + focusEditorOnEscape { ...props } > - { ! isCollapsed && } - { isFixed && isLargeViewport && blockType && ( - - { - setIsCollapsed( ( collapsed ) => ! collapsed ); - toolbarButtonRef.current.focus(); - } } - label={ - isCollapsed - ? __( 'Show block tools' ) - : __( 'Hide block tools' ) - } - /> - - ) } + ); } -export default BlockContextualToolbar; +export default forwardRef( UnforwardBlockContextualToolbar ); diff --git a/packages/block-editor/src/components/block-tools/empty-block-inserter.js b/packages/block-editor/src/components/block-tools/empty-block-inserter.js new file mode 100644 index 00000000000000..2aeb386d083ecb --- /dev/null +++ b/packages/block-editor/src/components/block-tools/empty-block-inserter.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import BlockPopover from '../block-popover'; +import useBlockToolbarPopoverProps from './use-block-toolbar-popover-props'; +import Inserter from '../inserter'; +import useSelectedBlockToolProps from './use-selected-block-tool-props'; + +export default function EmptyBlockInserter( { + clientId, + __unstableContentRef, +} ) { + const { + capturingClientId, + isInsertionPointVisible, + lastClientId, + rootClientId, + } = useSelectedBlockToolProps( clientId ); + const popoverProps = useBlockToolbarPopoverProps( { + contentElement: __unstableContentRef?.current, + clientId, + } ); + + return ( + +
+ +
+
+ ); +} diff --git a/packages/block-editor/src/components/block-tools/index.js b/packages/block-editor/src/components/block-tools/index.js index 8e3b240838fd04..8947d8bf9b4ca5 100644 --- a/packages/block-editor/src/components/block-tools/index.js +++ b/packages/block-editor/src/components/block-tools/index.js @@ -2,10 +2,14 @@ * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; -import { useViewportMatch } from '@wordpress/compose'; -import { Popover } from '@wordpress/components'; +import { + Popover, + Fill, + __experimentalUseSlot as useSlot, +} from '@wordpress/components'; import { __unstableUseShortcutEventMatch as useShortcutEventMatch } from '@wordpress/keyboard-shortcuts'; import { useRef } from '@wordpress/element'; +import { isUnmodifiedDefaultBlock } from '@wordpress/blocks'; /** * Internal dependencies @@ -14,20 +18,36 @@ import { InsertionPointOpenRef, default as InsertionPoint, } from './insertion-point'; -import SelectedBlockPopover from './selected-block-popover'; import { store as blockEditorStore } from '../../store'; -import BlockContextualToolbar from './block-contextual-toolbar'; import usePopoverScroll from '../block-popover/use-popover-scroll'; import ZoomOutModeInserters from './zoom-out-mode-inserters'; +import EmptyBlockInserter from './empty-block-inserter'; +import SelectedBlockTools from './selected-block-tools'; function selector( select ) { - const { __unstableGetEditorMode, getSettings, isTyping } = - select( blockEditorStore ); + const { + getSelectedBlockClientId, + getFirstMultiSelectedBlockClientId, + getBlock, + __unstableGetEditorMode, + isTyping, + } = select( blockEditorStore ); + + const clientId = + getSelectedBlockClientId() || getFirstMultiSelectedBlockClientId(); + + const { name = '', attributes = {} } = getBlock( clientId ) || {}; return { - isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', - hasFixedToolbar: getSettings().hasFixedToolbar, + clientId, + hasSelectedBlock: clientId && name, isTyping: isTyping(), + isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', + showEmptyBlockSideInserter: + clientId && + ! isTyping() && + __unstableGetEditorMode() === 'edit' && + isUnmodifiedDefaultBlock( { name, attributes } ), }; } @@ -45,14 +65,23 @@ export default function BlockTools( { __unstableContentRef, ...props } ) { - const isLargeViewport = useViewportMatch( 'medium' ); - const { hasFixedToolbar, isZoomOutMode, isTyping } = useSelect( - selector, - [] - ); + const { + clientId, + hasSelectedBlock, + isTyping, + isZoomOutMode, + showEmptyBlockSideInserter, + } = useSelect( selector, [] ); const isMatch = useShortcutEventMatch(); const { getSelectedBlockClientIds, getBlockRootClientId } = useSelect( blockEditorStore ); + + const blockToolbarRef = usePopoverScroll( __unstableContentRef ); + + // TODO: Import this from somewhere so it can be used in the post editor and site editor headers consistently. + const selectedBlockToolsSlotName = '__experimentalSelectedBlockTools'; + const blockToolsSlot = useSlot( selectedBlockToolsSlotName ); + const { duplicateBlocks, removeBlocks, @@ -64,6 +93,8 @@ export default function BlockTools( { moveBlocksDown, } = useDispatch( blockEditorStore ); + const selectedBlockToolsRef = useRef( null ); + function onKeyDown( event ) { if ( event.defaultPrevented ) return; @@ -106,6 +137,15 @@ export default function BlockTools( { insertBeforeBlock( clientIds[ 0 ] ); } } else if ( isMatch( 'core/block-editor/unselect', event ) ) { + if ( selectedBlockToolsRef?.current?.contains( event.target ) ) { + // This shouldn't be necessary, but we have a combination of a few things all combining to create a situation where: + // - Because the block toolbar uses createPortal to populate the block toolbar fills, we can't rely on the React event bubbling to hit the onKeyDown listener for the block toolbar + // - Since we can't use the React tree, we use the DOM tree which _should_ handle the event bubbling correctly from a `createPortal` element. + // - This bubbles via the React tree, which hits this `unselect` escape keypress before the block toolbar DOM event listener has access to it. + // An alternative would be to remove the addEventListener on the navigableToolbar and use this event to handle it directly right here. That feels hacky too though. + return; + } + const clientIds = getSelectedBlockClientIds(); if ( clientIds.length ) { event.preventDefault(); @@ -126,7 +166,6 @@ export default function BlockTools( { } } - const blockToolbarRef = usePopoverScroll( __unstableContentRef ); const blockToolbarAfterRef = usePopoverScroll( __unstableContentRef ); return ( @@ -138,17 +177,53 @@ export default function BlockTools( { __unstableContentRef={ __unstableContentRef } /> ) } - { ! isZoomOutMode && - ( hasFixedToolbar || ! isLargeViewport ) && ( - - ) } - { /* Even if the toolbar is fixed, the block popover is still - needed for navigation and zoom-out mode. */ } - - { /* Used for the inline rich text toolbar. */ } - + { showEmptyBlockSideInserter && ( + + ) } + + { /* If there is no slot available, such as in the standalone block editor, render within the editor */ } + { blockToolsSlot?.ref?.current ? ( + + { hasSelectedBlock && ( + + ) } + { /* Used for the inline rich text toolbar. */ } + + + ) : ( + <> + { hasSelectedBlock && ( + + ) } + { /* Used for the inline rich text toolbar. */ } + + + ) } + { children } { /* Used for inline rich text popovers. */ } { - const { - isBlockInsertionPointVisible, - getBlockInsertionPoint, - getBlockOrder, - } = select( blockEditorStore ); - - if ( ! isBlockInsertionPointVisible() ) { - return false; - } - - const insertionPoint = getBlockInsertionPoint(); - const order = getBlockOrder( insertionPoint.rootClientId ); - return order[ insertionPoint.index ] === clientId; - }, - [ clientId ] - ); - const isToolbarForced = useRef( false ); - const { shouldShowContextualToolbar, canFocusHiddenToolbar } = - useShouldContextualToolbarShow(); - - const { stopTyping } = useDispatch( blockEditorStore ); - - const showEmptyBlockSideInserter = - ! isTyping && editorMode === 'edit' && isEmptyDefaultBlock; - const shouldShowBreadcrumb = - ! hasMultiSelection && - ( editorMode === 'navigation' || editorMode === 'zoom-out' ); - - useShortcut( - 'core/block-editor/focus-toolbar', - () => { - isToolbarForced.current = true; - stopTyping( true ); - }, - { - isDisabled: ! canFocusHiddenToolbar, - } - ); - - useEffect( () => { - isToolbarForced.current = false; - } ); - - // Stores the active toolbar item index so the block toolbar can return focus - // to it when re-mounting. - const initialToolbarItemIndexRef = useRef(); - - useEffect( () => { - // Resets the index whenever the active block changes so this is not - // persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 - initialToolbarItemIndexRef.current = undefined; - }, [ clientId ] ); - - const popoverProps = useBlockToolbarPopoverProps( { - contentElement: __unstableContentRef?.current, - clientId, - } ); - - if ( showEmptyBlockSideInserter ) { - return ( - -
- -
-
- ); - } - - if ( shouldShowBreadcrumb || shouldShowContextualToolbar ) { - return ( - - { shouldShowContextualToolbar && ( - { - initialToolbarItemIndexRef.current = index; - } } - // Resets the index whenever the active block changes so - // this is not persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 - key={ clientId } - /> - ) } - { shouldShowBreadcrumb && ( - - ) } - - ); - } - - return null; -} - -function wrapperSelector( select ) { - const { - getSelectedBlockClientId, - getFirstMultiSelectedBlockClientId, - getBlockRootClientId, - getBlock, - getBlockParents, - __experimentalGetBlockListSettingsForBlocks, - } = select( blockEditorStore ); - - const clientId = - getSelectedBlockClientId() || getFirstMultiSelectedBlockClientId(); - - if ( ! clientId ) { - return; - } - - const { name, attributes = {} } = getBlock( clientId ) || {}; - const blockParentsClientIds = getBlockParents( clientId ); - - // Get Block List Settings for all ancestors of the current Block clientId. - const parentBlockListSettings = __experimentalGetBlockListSettingsForBlocks( - blockParentsClientIds - ); - - // Get the clientId of the topmost parent with the capture toolbars setting. - const capturingClientId = blockParentsClientIds.find( - ( parentClientId ) => - parentBlockListSettings[ parentClientId ] - ?.__experimentalCaptureToolbars - ); - - return { - clientId, - rootClientId: getBlockRootClientId( clientId ), - name, - isEmptyDefaultBlock: - name && isUnmodifiedDefaultBlock( { name, attributes } ), - capturingClientId, - }; -} - -export default function WrappedBlockPopover( { - __unstablePopoverSlot, - __unstableContentRef, -} ) { - const selected = useSelect( wrapperSelector, [] ); - - if ( ! selected ) { - return null; - } - - const { - clientId, - rootClientId, - name, - isEmptyDefaultBlock, - capturingClientId, - } = selected; - - if ( ! name ) { - return null; - } - - return ( - - ); -} diff --git a/packages/block-editor/src/components/block-tools/selected-block-tools.js b/packages/block-editor/src/components/block-tools/selected-block-tools.js new file mode 100644 index 00000000000000..a8b843ca13c4a5 --- /dev/null +++ b/packages/block-editor/src/components/block-tools/selected-block-tools.js @@ -0,0 +1,201 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { forwardRef, useRef, useEffect } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useInstanceId, useViewportMatch } from '@wordpress/compose'; +import { useShortcut } from '@wordpress/keyboard-shortcuts'; +import { __ } from '@wordpress/i18n'; +import { getScrollContainer } from '@wordpress/dom'; +import { VisuallyHidden } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import BlockSelectionButton from './block-selection-button'; +import BlockContextualToolbar from './block-contextual-toolbar'; +import { store as blockEditorStore } from '../../store'; +import BlockPopover from '../block-popover'; +import useBlockToolbarPopoverProps from './use-block-toolbar-popover-props'; +import useSelectedBlockToolProps from './use-selected-block-tool-props'; +import { useShouldContextualToolbarShow } from '../../utils/use-should-contextual-toolbar-show'; + +function UnforwardSelectedBlockTools( + { clientId, showEmptyBlockSideInserter, shiftPadding = {} }, + ref +) { + const { + capturingClientId, + isInsertionPointVisible, + lastClientId, + rootClientId, + } = useSelectedBlockToolProps( clientId ); + + const { isFixed, shouldShowBreadcrumb } = useSelect( ( select ) => { + const { getSettings, hasMultiSelection, __unstableGetEditorMode } = + select( blockEditorStore ); + + const editorMode = __unstableGetEditorMode(); + + return { + isFixed: getSettings().hasFixedToolbar, + shouldShowBreadcrumb: + ! hasMultiSelection() && + ( editorMode === 'navigation' || editorMode === 'zoom-out' ), + }; + }, [] ); + + const isLargeViewport = useViewportMatch( 'medium' ); + const instanceId = useInstanceId( UnforwardSelectedBlockTools ); + const descriptionId = `block-editor-block-contextual-toolbar--${ instanceId }`; + + const isToolbarForced = useRef( false ); + const { shouldShowContextualToolbar, canFocusHiddenToolbar } = + useShouldContextualToolbarShow(); + + const { stopTyping } = useDispatch( blockEditorStore ); + + useShortcut( + 'core/block-editor/focus-toolbar', + () => { + isToolbarForced.current = true; + stopTyping( true ); + }, + { + isDisabled: ! canFocusHiddenToolbar, + } + ); + + useEffect( () => { + isToolbarForced.current = false; + } ); + + // Stores the active toolbar item index so the block toolbar can return focus + // to it when re-mounting. + const initialToolbarItemIndexRef = useRef(); + + useEffect( () => { + // Resets the index whenever the active block changes so this is not + // persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 + initialToolbarItemIndexRef.current = undefined; + }, [ clientId ] ); + + const popoverProps = useBlockToolbarPopoverProps( { + contentElement: getScrollContainer(), // This is what useBlockToolbarPopoverProps does when the contentRef is undefined. This likely works by accident. It was being passed in via the BlockTools + clientId, + } ); + + const KeyboardInstructions = () => { + return ( + + { __( + 'Press Tab or Shift+Tab to navigate to other toolbars, and press Escape to return focus to the editor.' + ) } + + ); + }; + + if ( isFixed || ! isLargeViewport ) { + return ( + <> + + { + initialToolbarItemIndexRef.current = index; + } } + // Resets the index whenever the active block changes so + // this is not persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 + key={ clientId } + /> + { shouldShowBreadcrumb && ( + + + + ) } + + ); + } + + if ( showEmptyBlockSideInserter ) { + return null; + } + + if ( shouldShowBreadcrumb || shouldShowContextualToolbar ) { + return ( + <> + + { shouldShowContextualToolbar && ( + <> + + { + initialToolbarItemIndexRef.current = index; + } } + // Resets the index whenever the active block changes so + // this is not persisted. See https://github.com/WordPress/gutenberg/pull/25760#issuecomment-717906169 + key={ clientId } + /> + + ) } + { shouldShowBreadcrumb && ( + + ) } + + + ); + } + + return null; +} + +export default forwardRef( UnforwardSelectedBlockTools ); diff --git a/packages/block-editor/src/components/block-tools/use-selected-block-tool-props.js b/packages/block-editor/src/components/block-tools/use-selected-block-tool-props.js new file mode 100644 index 00000000000000..6eaf3dffff8ebc --- /dev/null +++ b/packages/block-editor/src/components/block-tools/use-selected-block-tool-props.js @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +/** + * Returns props for the selected block tools and empty block inserter. + * + * @param {string} clientId Selected block client ID. + */ +export default function useSelectedBlockToolProps( clientId ) { + const selectedBlockProps = useSelect( + ( select ) => { + const { + getBlockRootClientId, + getBlockParents, + __experimentalGetBlockListSettingsForBlocks, + isBlockInsertionPointVisible, + getBlockInsertionPoint, + getBlockOrder, + hasMultiSelection, + getLastMultiSelectedBlockClientId, + } = select( blockEditorStore ); + + const blockParentsClientIds = getBlockParents( clientId ); + + // Get Block List Settings for all ancestors of the current Block clientId. + const parentBlockListSettings = + __experimentalGetBlockListSettingsForBlocks( + blockParentsClientIds + ); + + // Get the clientId of the topmost parent with the capture toolbars setting. + const capturingClientId = blockParentsClientIds.find( + ( parentClientId ) => + parentBlockListSettings[ parentClientId ] + ?.__experimentalCaptureToolbars + ); + + let isInsertionPointVisible = false; + + if ( isBlockInsertionPointVisible() ) { + const insertionPoint = getBlockInsertionPoint(); + const order = getBlockOrder( insertionPoint.rootClientId ); + isInsertionPointVisible = + order[ insertionPoint.index ] === clientId; + } + + return { + capturingClientId, + isInsertionPointVisible, + lastClientId: hasMultiSelection() + ? getLastMultiSelectedBlockClientId() + : null, + rootClientId: getBlockRootClientId( clientId ), + }; + }, + [ clientId ] + ); + + return selectedBlockProps; +} diff --git a/packages/block-editor/src/components/navigable-toolbar/index.js b/packages/block-editor/src/components/navigable-toolbar/index.js index 3e531c93c11989..c1a02ca1967b00 100644 --- a/packages/block-editor/src/components/navigable-toolbar/index.js +++ b/packages/block-editor/src/components/navigable-toolbar/index.js @@ -3,15 +3,23 @@ */ import { NavigableMenu, Toolbar } from '@wordpress/components'; import { + forwardRef, useState, useRef, useLayoutEffect, useEffect, useCallback, } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; import deprecated from '@wordpress/deprecated'; import { focus } from '@wordpress/dom'; import { useShortcut } from '@wordpress/keyboard-shortcuts'; +import { ESCAPE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; function hasOnlyToolbarItem( elements ) { const dataProp = 'toolbarItem'; @@ -38,7 +46,7 @@ function focusFirstTabbableIn( container ) { } } -function useIsAccessibleToolbar( ref ) { +function useIsAccessibleToolbar( toolbarRef ) { /* * By default, we'll assume the starting accessible state of the Toolbar * is true, as it seems to be the most common case. @@ -62,7 +70,7 @@ function useIsAccessibleToolbar( ref ) { ); const determineIsAccessibleToolbar = useCallback( () => { - const tabbables = focus.tabbable.find( ref.current ); + const tabbables = focus.tabbable.find( toolbarRef.current ); const onlyToolbarItem = hasOnlyToolbarItem( tabbables ); if ( ! onlyToolbarItem ) { deprecated( 'Using custom components as toolbar controls', { @@ -73,7 +81,7 @@ function useIsAccessibleToolbar( ref ) { } ); } setIsAccessibleToolbar( onlyToolbarItem ); - }, [] ); + }, [ toolbarRef ] ); useLayoutEffect( () => { // Toolbar buttons may be rendered asynchronously, so we use @@ -81,28 +89,32 @@ function useIsAccessibleToolbar( ref ) { const observer = new window.MutationObserver( determineIsAccessibleToolbar ); - observer.observe( ref.current, { childList: true, subtree: true } ); + observer.observe( toolbarRef.current, { + childList: true, + subtree: true, + } ); return () => observer.disconnect(); - }, [ isAccessibleToolbar ] ); + }, [ isAccessibleToolbar, determineIsAccessibleToolbar, toolbarRef ] ); return isAccessibleToolbar; } -function useToolbarFocus( - ref, +function useToolbarFocus( { + toolbarRef, focusOnMount, isAccessibleToolbar, defaultIndex, onIndexChange, - shouldUseKeyboardFocusShortcut -) { + shouldUseKeyboardFocusShortcut, + focusEditorOnEscape = true, +} ) { // Make sure we don't use modified versions of this prop. const [ initialFocusOnMount ] = useState( focusOnMount ); const [ initialIndex ] = useState( defaultIndex ); const focusToolbar = useCallback( () => { - focusFirstTabbableIn( ref.current ); - }, [] ); + focusFirstTabbableIn( toolbarRef.current ); + }, [ toolbarRef ] ); const focusToolbarViaShortcut = () => { if ( shouldUseKeyboardFocusShortcut ) { @@ -121,7 +133,7 @@ function useToolbarFocus( useEffect( () => { // Store ref so we have access on useEffect cleanup: https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html#effect-cleanup-timing - const navigableToolbarRef = ref.current; + const navigableToolbarRef = toolbarRef.current; // If initialIndex is passed, we focus on that toolbar item when the // toolbar gets mounted and initial focus is not forced. // We have to wait for the next browser paint because block controls aren't @@ -150,32 +162,77 @@ function useToolbarFocus( const index = items.findIndex( ( item ) => item.tabIndex === 0 ); onIndexChange( index ); }; - }, [ initialIndex, initialFocusOnMount ] ); + }, [ initialIndex, initialFocusOnMount, toolbarRef, onIndexChange ] ); + + const { lastFocus } = useSelect( ( select ) => { + const { getLastFocus } = select( blockEditorStore ); + return { + lastFocus: getLastFocus(), + }; + }, [] ); + + /** + * Handles returning focus to the block editor canvas when pressing escape. + */ + useEffect( () => { + const navigableToolbarRef = toolbarRef.current; + + if ( focusEditorOnEscape ) { + const handleKeyDown = ( event ) => { + if ( event.keyCode === ESCAPE && lastFocus?.current ) { + // Focus the last focused element when pressing escape. + event.preventDefault(); + lastFocus.current.focus(); + } + }; + + navigableToolbarRef.addEventListener( 'keydown', handleKeyDown ); + + return () => { + navigableToolbarRef.removeEventListener( + 'keydown', + handleKeyDown + ); + }; + } + }, [ focusEditorOnEscape, lastFocus, toolbarRef ] ); } -function NavigableToolbar( { - children, - focusOnMount, - shouldUseKeyboardFocusShortcut = true, - __experimentalInitialIndex: initialIndex, - __experimentalOnIndexChange: onIndexChange, - ...props -} ) { - const ref = useRef(); - const isAccessibleToolbar = useIsAccessibleToolbar( ref ); +function UnforwardNavigableToolbar( + { + children, + focusOnMount, + focusEditorOnEscape = false, + shouldUseKeyboardFocusShortcut = true, + __experimentalInitialIndex: initialIndex, + __experimentalOnIndexChange: onIndexChange, + handleOnKeyDown, + ...props + }, + ref +) { + const maybeRef = useRef(); + // If a ref was not forwarded, we create one. + const toolbarRef = ref || maybeRef; + const isAccessibleToolbar = useIsAccessibleToolbar( toolbarRef ); - useToolbarFocus( - ref, + useToolbarFocus( { + toolbarRef, focusOnMount, isAccessibleToolbar, - initialIndex, + defaultIndex: initialIndex, onIndexChange, - shouldUseKeyboardFocusShortcut - ); + shouldUseKeyboardFocusShortcut, + focusEditorOnEscape, + } ); if ( isAccessibleToolbar ) { return ( - + { children } ); @@ -185,7 +242,7 @@ function NavigableToolbar( { { children } @@ -193,4 +250,4 @@ function NavigableToolbar( { ); } -export default NavigableToolbar; +export default forwardRef( UnforwardNavigableToolbar ); diff --git a/packages/block-editor/src/components/resizable-box-popover/index.js b/packages/block-editor/src/components/resizable-box-popover/index.js index c2400dbe07f912..12a61aceaaf38e 100644 --- a/packages/block-editor/src/components/resizable-box-popover/index.js +++ b/packages/block-editor/src/components/resizable-box-popover/index.js @@ -17,7 +17,7 @@ export default function ResizableBoxPopover( { diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js index 616da1bc758136..bf6fe14a3d0e5f 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js @@ -17,15 +17,20 @@ export default function useTabNav() { const container = useRef(); const focusCaptureBeforeRef = useRef(); const focusCaptureAfterRef = useRef(); - const lastFocus = useRef(); + const { hasMultiSelection, getSelectedBlockClientId, getBlockCount } = useSelect( blockEditorStore ); - const { setNavigationMode } = useDispatch( blockEditorStore ); + const { setNavigationMode, setLastFocus } = useDispatch( blockEditorStore ); const isNavigationMode = useSelect( ( select ) => select( blockEditorStore ).isNavigationMode(), [] ); + const lastFocus = useSelect( + ( select ) => select( blockEditorStore ).getLastFocus(), + [] + ); + // Don't allow tabbing to this element in Navigation mode. const focusCaptureTabIndex = ! isNavigationMode ? '0' : undefined; @@ -122,8 +127,8 @@ export default function useTabNav() { // We want to constrain the tabbing to the block and its child blocks. // If the preceding form element is within a different block, // such as two sibling image blocks in the placeholder state, - // we want shift + tab from the first form element to move to the image - // block toolbar and not the previous image block's form element. + // we want shift + tab from the first form element to move to the block + // editor chrome. const currentBlock = event.target.closest( '[data-block]' ); const isElementPartOfSelectedBlock = currentBlock && @@ -158,7 +163,7 @@ export default function useTabNav() { } function onFocusOut( event ) { - lastFocus.current = event.target; + setLastFocus( { ...lastFocus, current: event.target } ); const { ownerDocument } = node; diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 2975a41dbb9d99..4f6ce7b5b044c7 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1980,3 +1980,18 @@ export function unsetBlockEditingMode( clientId = '' ) { clientId, }; } + +/** + * Action that sets the element that had focus when focus leaves the editor canvas. + * + * @param {Object} lastFocus The last focused element. + * + * + * @return {Object} Action object. + */ +export function setLastFocus( lastFocus = null ) { + return { + type: 'LAST_FOCUS', + lastFocus, + }; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 4373182d986622..d5ff85e9e4257b 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1962,6 +1962,24 @@ export function registeredInserterMediaCategories( state = [], action ) { case 'REGISTER_INSERTER_MEDIA_CATEGORY': return [ ...state, action.category ]; } + + return state; +} + +/** + * Reducer setting last focused element + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function lastFocus( state = false, action ) { + switch ( action.type ) { + case 'LAST_FOCUS': + return action.lastFocus; + } + return state; } @@ -1981,6 +1999,7 @@ const combinedReducers = combineReducers( { settings, preferences, lastBlockAttributesChange, + lastFocus, editorMode, hasBlockMovingClientId, highlightedBlock, diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index cb1f8ef49809d8..e9d17d86a26725 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -3022,3 +3022,14 @@ export const isGroupable = createRegistrySelector( ); } ); + +/** + * Returns the element of the last element that had focus when focus left the editor canvas. + * + * @param {Object} state Block editor state. + * + * @return {Object} Element. + */ +export function getLastFocus( state ) { + return state.lastFocus; +} diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 4f17a60a696b0d..61c1362c64a037 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -36,6 +36,7 @@ - `Modal`: add a new `size` prop to support preset widths, including a `fill` option to eventually replace the `isFullScreen` prop ([#54471](https://github.com/WordPress/gutenberg/pull/54471)). - Wrapped `TextareaControl` in a `forwardRef` call ([#54975](https://github.com/WordPress/gutenberg/pull/54975)). - `Composite`/`AlignmentMatrixControl`/`CircularOptionPicker`: Starts the `Composite` migration from `reakit` to `ariakit` ([#54225](https://github.com/WordPress/gutenberg/pull/54225)). +- `Popover`: add `shiftPadding` prop to allow granular positioning of the popover shift behavior ([#54513](https://github.com/WordPress/gutenberg/pull/54513/)). ### Bug Fix diff --git a/packages/components/src/popover/index.tsx b/packages/components/src/popover/index.tsx index 709d4b9884b5ec..5909b7b9aa3a8f 100644 --- a/packages/components/src/popover/index.tsx +++ b/packages/components/src/popover/index.tsx @@ -143,7 +143,7 @@ const UnconnectedPopover = ( shift = false, inline = false, variant, - + shiftPadding = {}, // Deprecated props __unstableForcePosition, anchorRef, @@ -218,6 +218,15 @@ const UnconnectedPopover = ( ? positionToPlacement( position ) : placementProp; + // 1px padding necessary to avoid flickering at the edge of the viewport. + const shiftPaddingProp = { + top: 1, + bottom: 1, + left: 1, + right: 1, + ...shiftPadding, + }; + const middleware = [ ...( placementProp === 'overlay' ? overlayMiddlewares() : [] ), offsetMiddleware( offsetProp ), @@ -242,7 +251,7 @@ const UnconnectedPopover = ( shiftMiddleware( { crossAxis: true, limiter: limitShift(), - padding: 1, // Necessary to avoid flickering at the edge of the viewport. + padding: shiftPaddingProp, } ), arrow( { element: arrowRef } ), ]; diff --git a/packages/components/src/popover/types.ts b/packages/components/src/popover/types.ts index c4250b22ba8349..b83f02f1f78ab9 100644 --- a/packages/components/src/popover/types.ts +++ b/packages/components/src/popover/types.ts @@ -131,6 +131,15 @@ export type PopoverProps = { * @default false */ shift?: boolean; + /** + * Sets the virtual padding around the boundary to check for overflow. + */ + shiftPadding?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + }; /** * Specifies the popover's style. * diff --git a/packages/customize-widgets/src/style.scss b/packages/customize-widgets/src/style.scss index bd6d16b89c7fa7..3bf341c34c0eb1 100644 --- a/packages/customize-widgets/src/style.scss +++ b/packages/customize-widgets/src/style.scss @@ -17,34 +17,3 @@ .customize-widgets-popover { @include reset; } - -/** - Fixed bloock toolbar overrides. We can't detect each editor instance - in the styles of the block editor component so we need to override - the fixed styles here because the breakpoint css does not fire in the - customizer's left panel. -*/ -.block-editor-block-contextual-toolbar { - &.is-fixed { - position: sticky; - top: 0; - left: 0; - z-index: z-index(".block-editor-block-list__insertion-point"); - width: calc(100% + 2 * 12px); //12px is the padding of customizer sidebar content - - overflow-y: hidden; - - border: none; - border-bottom: $border-width solid $gray-200; - border-radius: 0; - - .block-editor-block-toolbar .components-toolbar-group, - .block-editor-block-toolbar .components-toolbar { - border-right-color: $gray-200; - } - - &.is-collapsed { - margin-left: -12px; //12px is the padding of customizer sidebar content - } - } -} diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index b86e66af7a849e..72c212445b20e3 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -15,7 +15,7 @@ import { EditorHistoryUndo, store as editorStore, } from '@wordpress/editor'; -import { Button, ToolbarItem } from '@wordpress/components'; +import { Button, Slot, ToolbarItem } from '@wordpress/components'; import { listView, plus } from '@wordpress/icons'; import { useRef, useCallback } from '@wordpress/element'; import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; @@ -132,6 +132,7 @@ function HeaderToolbar( { setListViewToggleElement } ) { const shortLabel = ! isInserterOpened ? __( 'Add' ) : __( 'Close' ); return ( + <> { isLargeViewport && ! hasFixedToolbar && ( + + ) } - ) } - - - { overflowItems } - - ) } - - + + { overflowItems } + + ) } + + + + ); } diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index 2e0d470818fecd..5d034d389bfded 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -50,7 +50,9 @@ function Header( { ); return ( -
+
+ { /* Floating header style to be able to hide the block popovers behind the header */ } +
*:not(.selected-block-tools-wrapper), + .edit-post-header > *:not(.edit-post-header__toolbar) { + z-index: 4; + } + + // The block tools slide behind our floating header background + .selected-block-tools-wrapper { + z-index: 2; + } +} + .edit-post-header__toolbar { display: flex; - flex-grow: 1; + // Allow this area to shrink to fit the toolbar buttons. + flex-shrink: 8; + // Take up the space of the toolbar so it can be justified to the left side of the toolbar. + flex-grow: 3; + // Hide the overflow so flex will limit its width. Block toolbar will allow scrolling on fixed toolbar. + overflow: hidden; + // Leave enough room for the focus ring to show. + padding: 2px 0; .table-of-contents { display: none; @@ -42,6 +78,31 @@ display: block; } } + + .block-editor-block-contextual-toolbar.is-fixed { + position: fixed; + top: $admin-bar-height-big + $header-height + 1px; // +1px to avoid overlap with the header border + left: 0; + width: 100%; + + @include break-medium() { + width: auto; + position: static; + // remove the border + border: none; + flex-shrink: 2; + display: flex; // Allow for flex-shrink on the block toolbar + } + + .block-editor-block-toolbar { + overflow-x: auto; // Allow only the block tools to overflow scroll to make room in the header. + } + } + + .selected-block-tools-wrapper { + flex-shrink: 2; + overflow-x: hidden; + } } .edit-post-header__center { @@ -57,7 +118,7 @@ .edit-post-header__settings { display: inline-flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; padding-right: $grid-unit-05; @include break-small () { @@ -137,7 +198,7 @@ .edit-post-fullscreen-mode-close.has-icon { width: $header-height; } - // Don't hide MenuItemsChoice check icons + // Don"t hide MenuItemsChoice check icons .components-menu-items-choice .components-menu-items__item-icon.components-menu-items__item-icon { display: block; } diff --git a/packages/edit-post/src/components/layout/style.scss b/packages/edit-post/src/components/layout/style.scss index 5e8cdb4fb76e16..229ab58a4e14b0 100644 --- a/packages/edit-post/src/components/layout/style.scss +++ b/packages/edit-post/src/components/layout/style.scss @@ -99,14 +99,3 @@ .edit-post-layout .entities-saved-states__panel-header { height: $header-height + $border-width; } - -.edit-post-layout.has-fixed-toolbar { - // making the header be lower than the content - // so the fixed toolbar can be positioned on top of it - // but only on desktop - @include break-medium() { - .interface-interface-skeleton__header:not(:focus-within) { - z-index: 19; - } - } -} diff --git a/packages/edit-post/src/components/visual-editor/style.scss b/packages/edit-post/src/components/visual-editor/style.scss index 40043958fcaad5..fa61cc9889cf9c 100644 --- a/packages/edit-post/src/components/visual-editor/style.scss +++ b/packages/edit-post/src/components/visual-editor/style.scss @@ -67,92 +67,3 @@ // See also https://www.w3.org/TR/CSS22/visudet.html#the-height-property flex-grow: 1; } - -// Fixed contextual toolbar -@include editor-left(".edit-post-visual-editor .block-editor-block-contextual-toolbar.is-fixed"); - -.edit-post-visual-editor .block-editor-block-contextual-toolbar.is-fixed { - position: sticky; - top: 0; - z-index: z-index(".block-editor-block-popover"); - display: block; - width: 100%; - - // on desktop and tablet viewports the toolbar is fixed - // on top of interface header - $toolbar-margin: $grid-unit-80 * 3 - 2 * $grid-unit + $grid-unit-05; - - @include break-medium() { - // leave room for block inserter, undo and redo, list view - margin-left: $toolbar-margin; - // position on top of interface header - position: fixed; - top: $admin-bar-height; - // Don't fill up when empty - min-height: initial; - // remove the border - border-bottom: none; - // has to be flex for collapse button to fit - display: flex; - - // Mimic the height of the parent, vertically align center, and provide a max-height. - height: $header-height; - align-items: center; - - - // on tablet viewports the toolbar is fixed - // on top of interface header and covers the whole header - // except for the inserter on the left - width: calc(100% - #{$toolbar-margin}); - - &.is-collapsed { - width: initial; - } - - &:empty { - width: initial; - } - - .is-fullscreen-mode & { - // leave room for block inserter, undo and redo, list view - // and some margin left - margin-left: $grid-unit-80 * 4 - 2 * $grid-unit; - - top: 0; - - &.is-collapsed { - width: initial; - } - - &:empty { - width: initial; - } - } - - .show-icon-labels & { - width: calc(100% + 40px - #{$toolbar-margin}); //there are no undo, redo and list view buttons - margin-left: $grid-unit-80 + 2 * $grid-unit; // inserter and margin - - .is-fullscreen-mode & { - margin-left: $grid-unit * 18; // site hub, inserter and margin - } - } - } - - // on desktop viewports the toolbar is fixed - // on top of interface header and leaves room - // for the block inserter the publish button - @include break-large() { - width: auto; - .show-icon-labels & { - width: auto; //there are no undo, redo and list view buttons - } - - .is-fullscreen-mode & { - // in full screen mode we need to account for - // the combined with of the tools at the right of the header and the margin left - // of the toolbar which includes four buttons - width: calc(100% - 280px - #{4 * $grid-unit-80}); - } - } -} diff --git a/packages/edit-site/src/components/block-editor/style.scss b/packages/edit-site/src/components/block-editor/style.scss index ad058b2dd7be9e..b110b1c274e779 100644 --- a/packages/edit-site/src/components/block-editor/style.scss +++ b/packages/edit-site/src/components/block-editor/style.scss @@ -172,90 +172,3 @@ box-shadow: inset 0 0 0 2px var(--wp-admin-theme-color); } } - -// Fixed contextual toolbar -@include editor-left(".edit-site-visual-editor .block-editor-block-contextual-toolbar.is-fixed"); - -.edit-site-visual-editor .block-editor-block-contextual-toolbar.is-fixed { - position: sticky; - top: 0; - z-index: z-index(".block-editor-block-popover"); - display: block; - width: 100%; - - // on desktop and tablet viewports the toolbar is fixed - // on top of interface header - $toolbar-margin: $grid-unit-80 * 3 - 2 * $grid-unit + $grid-unit-05; - - @include break-medium() { - // leave room for block inserter, undo and redo, list view - margin-left: $toolbar-margin; - // position on top of interface header - position: fixed; - top: $admin-bar-height; - // Don't fill up when empty - min-height: initial; - // has to be flex for collapse button to fit - display: flex; - - // Mimic the height of the parent, vertically align center, and provide a max-height. - height: $header-height; - align-items: center; - - - // on tablet viewports the toolbar is fixed - // on top of interface header and covers the whole header - // except for the inserter on the left - width: calc(100% - #{$toolbar-margin}); - - &.is-collapsed { - width: initial; - } - - &:empty { - width: initial; - } - - .is-fullscreen-mode & { - // leave room for block inserter, undo and redo, list view - // and some margin left - margin-left: $grid-unit-80 * 4 - 2 * $grid-unit; - - top: 0; - - &.is-collapsed { - width: initial; - } - - &:empty { - width: initial; - } - } - - .show-icon-labels & { - margin-left: $grid-unit-80 + 2 * $grid-unit; // inserter and margin - width: calc(100% + 40px - #{$toolbar-margin}); //there are no undo, redo and list view buttons - - .is-fullscreen-mode & { - margin-left: $grid-unit * 18; // site hub, inserter and margin - } - } - } - - // on desktop viewports the toolbar is fixed - // on top of interface header and leaves room - // for the block inserter the publish button - @include break-large() { - width: auto; - .show-icon-labels & { - width: auto; //there are no undo, redo and list view buttons - } - - .is-fullscreen-mode & { - // in full screen mode we need to account for - // the combined with of the tools at the right of the header and the margin left - // of the toolbar which includes four buttons - width: calc(100% - 280px - #{4 * $grid-unit-80}); - } - } -} diff --git a/packages/edit-site/src/components/header-edit-mode/index.js b/packages/edit-site/src/components/header-edit-mode/index.js index 3528e0623fc7d5..134fe70cf94b5f 100644 --- a/packages/edit-site/src/components/header-edit-mode/index.js +++ b/packages/edit-site/src/components/header-edit-mode/index.js @@ -6,7 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useCallback, useRef } from '@wordpress/element'; +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; import { useViewportMatch, useReducedMotion } from '@wordpress/compose'; import { store as coreStore } from '@wordpress/core-data'; import { @@ -19,13 +19,22 @@ import { import { useSelect, useDispatch } from '@wordpress/data'; import { PinnedItems } from '@wordpress/interface'; import { _x, __ } from '@wordpress/i18n'; -import { listView, plus, external, chevronUpDown } from '@wordpress/icons'; +import { + chevronUpDown, + external, + listView, + next, + plus, + previous, +} from '@wordpress/icons'; + import { __unstableMotion as motion, Button, ToolbarItem, MenuGroup, MenuItem, + Slot, VisuallyHidden, } from '@wordpress/components'; import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; @@ -68,6 +77,7 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { showIconLabels, editorCanvasView, hasFixedToolbar, + blockSelectionStart, } = useSelect( ( select ) => { const { __experimentalGetPreviewDeviceType, @@ -77,7 +87,8 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { getEditorMode, } = select( editSiteStore ); const { getShortcutRepresentation } = select( keyboardShortcutsStore ); - const { __unstableGetEditorMode } = select( blockEditorStore ); + const { getBlockSelectionStart, __unstableGetEditorMode } = + select( blockEditorStore ); const postType = getEditedPostType(); @@ -113,6 +124,7 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { editSiteStore.name, 'fixedToolbar' ), + blockSelectionStart: getBlockSelectionStart(), }; }, [] ); @@ -126,6 +138,17 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { const isLargeViewport = useViewportMatch( 'medium' ); + const [ isBlockToolsCollapsed, setIsBlockToolsCollapsed ] = + useState( true ); + const hasBlockSelected = !! blockSelectionStart; + + useEffect( () => { + // If we have a new block selection, show the block tools + if ( blockSelectionStart ) { + setIsBlockToolsCollapsed( false ); + } + }, [ blockSelectionStart ] ); + const toggleInserter = useCallback( () => { if ( isInserterOpen ) { // Focusing the inserter button should close the inserter popover. @@ -185,121 +208,169 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { return (
+ { /* Floating header style to be able to hide the block popovers behind the header */ } +
{ hasDefaultEditorCanvasView && ( - -
- { ! isDistractionFree && ( - - ) } - { isLargeViewport && ( - <> - { ! hasFixedToolbar && ( +
+ +
+ { ! isDistractionFree && ( + + ) } + { isLargeViewport && ( + <> + { ! hasFixedToolbar && ( + + ) } - ) } - - - { ! isDistractionFree && ( - ) } - { isZoomedOutViewExperimentEnabled && - ! isDistractionFree && - ! hasFixedToolbar && ( + { ! isDistractionFree && ( { - setPreviewDeviceType( - 'Desktop' - ); - __unstableSetEditorMode( - isZoomedOutView - ? 'edit' - : 'zoom-out' - ); - } } + label={ __( 'List View' ) } + onClick={ toggleListView } + ref={ setListViewToggleElement } + shortcut={ listViewShortcut } + showTooltip={ ! showIconLabels } + variant={ + showIconLabels + ? 'tertiary' + : undefined + } + aria-expanded={ isListViewOpen } /> ) } - + { isZoomedOutViewExperimentEnabled && + ! isDistractionFree && + ! hasFixedToolbar && ( + { + setPreviewDeviceType( + 'Desktop' + ); + __unstableSetEditorMode( + isZoomedOutView + ? 'edit' + : 'zoom-out' + ); + } } + /> + ) } + + ) } +
+
+ - + name="__experimentalSelectedBlockTools" + bubblesVirtually + /> + { hasFixedToolbar && + isLargeViewport && + hasBlockSelected && ( +
) } { ! isDistractionFree && ( -
+
{ ! hasDefaultEditorCanvasView ? ( getEditorCanvasContainerTitle( editorCanvasView ) ) : ( diff --git a/packages/edit-site/src/components/header-edit-mode/style.scss b/packages/edit-site/src/components/header-edit-mode/style.scss index 26b1716a28b865..3885b0a61d68c6 100644 --- a/packages/edit-site/src/components/header-edit-mode/style.scss +++ b/packages/edit-site/src/components/header-edit-mode/style.scss @@ -15,6 +15,17 @@ $header-toolbar-min-width: 335px; .edit-site-header-edit-mode__start { display: flex; border: none; + align-items: center; + flex-shrink: 2; + // We need this to be overflow hidden so the block toolbar can + // overflow scroll. If the overflow is visible, flexbox allows + // the toolbar to grow outside of the allowed container space. + overflow: hidden; + // Take up the full height of the header so the border focus + // is visible on toolbar buttons. + height: 100%; + // Allow focus ring to be fully visible on furthest right button. + padding-right: 2px; } .edit-site-header-edit-mode__end { @@ -37,6 +48,35 @@ $header-toolbar-min-width: 335px; } } +.edit-site-header-edit-mode__background-style { + position: absolute; // Remove it from the flex calculations so space-between works appropriately +} + +// Hacks to allow the block toolbar popover to slide behind the header +.edit-site-layout:not(.has-fixed-toolbar) { + // Create a full-width and height background that floats over the whole div + .edit-site-header-edit-mode__background-style { + top: 0; + left: 0; + bottom: -1px; // -1px to overlap the header border + right: 0; + background: $white; + border-bottom: $border-width solid $gray-200; + } + + // We want everything except the parent of the selected-block-tools-wrapper and + // the selected-block-tools-wrapper + .edit-site-header-edit-mode__start > *:not(.selected-block-tools-wrapper), + .edit-site-header-edit-mode > *:not(.edit-site-header-edit-mode__start) { + z-index: 4; + } + + // The block tools slide behind our floating header background + .selected-block-tools-wrapper { + z-index: 2; + } +} + .edit-site-header-edit-mode__toolbar { display: flex; align-items: center; @@ -70,6 +110,21 @@ $header-toolbar-min-width: 335px; } } +.edit-site-header-edit-mode .block-editor-block-contextual-toolbar.is-fixed { + position: fixed; + top: $header-height; // +1px to avoid overlap with the header border + left: 0; + width: 100%; + + @include break-medium() { + width: auto; + position: relative; + top: 0; + // remove the border + border: none; + } +} + /** * Buttons on the right side */ @@ -99,7 +154,7 @@ $header-toolbar-min-width: 335px; } } -.edit-site-header-edit-mode__start { +.edit-site-header-edit-mode__document-toolbar { display: flex; border: none; @@ -183,7 +238,26 @@ $header-toolbar-min-width: 335px; padding: 0 $grid-unit-10; } - .edit-site-header-edit-mode__start .edit-site-header-edit-mode__toolbar > * + * { + .edit-site-header-edit-mode__document-toolbar .edit-site-header-edit-mode__toolbar > * + * { margin-left: $grid-unit-10; } } + +.has-fixed-toolbar { + .selected-block-tools-wrapper { + flex-shrink: 4; + overflow-x: scroll; + + &.is-collapsed { + display: none; + } + } + + .edit-site-header-edit-mode__center.is-collapsed { + display: none; + } +} + +.edit-site-header-edit-mode__block-tools-toggle { + margin-left: 2px; // Allow focus ring to be fully visible +} diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js index c03ad2cd05ecd8..1ba9bfe1100c83 100644 --- a/packages/edit-site/src/components/layout/index.js +++ b/packages/edit-site/src/components/layout/index.js @@ -28,6 +28,7 @@ import { import { store as preferencesStore } from '@wordpress/preferences'; import { privateApis as blockEditorPrivateApis, + store as blockEditorStore, useBlockCommands, } from '@wordpress/block-editor'; import { privateApis as routerPrivateApis } from '@wordpress/router'; @@ -83,11 +84,13 @@ export default function Layout() { canvasMode, previousShortcut, nextShortcut, + hasBlockSelected, } = useSelect( ( select ) => { const { getAllShortcutKeyCombinations } = select( keyboardShortcutsStore ); const { getCanvasMode } = unlock( select( editSiteStore ) ); + const { getBlockSelectionStart } = unlock( select( blockEditorStore ) ); return { canvasMode: getCanvasMode(), previousShortcut: getAllShortcutKeyCombinations( @@ -104,6 +107,7 @@ export default function Layout() { 'core/edit-site', 'distractionFree' ), + hasBlockSelected: !! getBlockSelectionStart(), }; }, [] ); const isEditing = canvasMode === 'edit'; @@ -185,6 +189,7 @@ export default function Layout() { 'is-full-canvas': isFullCanvas, 'is-edit-mode': isEditing, 'has-fixed-toolbar': hasFixedToolbar, + 'is-block-toolbar-visible': hasBlockSelected, } ) } > diff --git a/packages/edit-site/src/components/layout/style.scss b/packages/edit-site/src/components/layout/style.scss index 11c7bdeeaf2a19..27ad972ded03d4 100644 --- a/packages/edit-site/src/components/layout/style.scss +++ b/packages/edit-site/src/components/layout/style.scss @@ -40,6 +40,15 @@ z-index: z-index(".edit-site-layout__header-container"); } +// Make room for the header when a block is selected. +.is-block-toolbar-visible .edit-site-layout__header-container { + padding-bottom: $admin-bar-height-big + 1px; + + @include break-medium() { + padding-bottom: 0; + } +} + .edit-site-layout__header { height: $header-height; display: flex; @@ -249,23 +258,6 @@ } } -.edit-site-layout.has-fixed-toolbar { - // making the header be lower than the content - // so the fixed toolbar can be positioned on top of it - // but only on desktop - @include break-medium() { - .edit-site-layout__canvas-container { - z-index: 5; - } - .edit-site-site-hub { - z-index: 4; - } - .edit-site-layout__header:focus-within { - z-index: 3; - } - } -} - .is-edit-mode.is-distraction-free { .edit-site-layout__header-container { diff --git a/packages/edit-widgets/src/components/header/index.js b/packages/edit-widgets/src/components/header/index.js index 0308c2c2171e24..a279a0c8e7c3bc 100644 --- a/packages/edit-widgets/src/components/header/index.js +++ b/packages/edit-widgets/src/components/header/index.js @@ -1,9 +1,19 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; import { __, _x } from '@wordpress/i18n'; -import { Button, ToolbarItem, VisuallyHidden } from '@wordpress/components'; +import { + Button, + Slot, + ToolbarItem, + VisuallyHidden, +} from '@wordpress/components'; import { NavigableToolbar, store as blockEditorStore, @@ -13,6 +23,7 @@ import { PinnedItems } from '@wordpress/interface'; import { listView, plus } from '@wordpress/icons'; import { useCallback, useRef } from '@wordpress/element'; import { useViewportMatch } from '@wordpress/compose'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies @@ -31,6 +42,15 @@ function Header( { setListViewToggleElement } ) { const isMediumViewport = useViewportMatch( 'medium' ); const inserterButton = useRef(); const widgetAreaClientId = useLastSelectedWidgetArea(); + const { isFixedToolbarActive } = useSelect( + ( select ) => ( { + isFixedToolbarActive: !! select( preferencesStore ).get( + 'core/edit-widgets', + 'fixedToolbar' + ), + } ), + [] + ); const isLastSelectedWidgetAreaOpen = useSelect( ( select ) => select( editWidgetsStore ).getIsWidgetAreaOpen( @@ -88,7 +108,13 @@ function Header( { setListViewToggleElement } ) { return ( <> -
+
+ { /* Floating header style to be able to hide the block popovers behind the header */ } +
{ isMediumViewport && (

@@ -103,48 +129,58 @@ function Header( { setListViewToggleElement } ) { { __( 'Widgets' ) } ) } - - { - event.preventDefault(); - } } - onClick={ handleClick } - icon={ plus } - /* translators: button label text should, if possible, be under 16 - characters. */ - label={ _x( - 'Toggle block inserter', - 'Generic label for block inserter button' + + { + event.preventDefault(); + } } + onClick={ handleClick } + icon={ plus } + /* translators: button label text should, if possible, be under 16 + characters. */ + label={ _x( + 'Toggle block inserter', + 'Generic label for block inserter button' + ) } + /> + { isMediumViewport && ( + <> + + + + ) } + + - { isMediumViewport && ( - <> - - - - - ) } - +

diff --git a/packages/edit-widgets/src/components/header/style.scss b/packages/edit-widgets/src/components/header/style.scss index 64a9f124bb7502..26a3e226717b4f 100644 --- a/packages/edit-widgets/src/components/header/style.scss +++ b/packages/edit-widgets/src/components/header/style.scss @@ -9,6 +9,33 @@ @include break-small { overflow: visible; } + + .edit-widgets-header__background-style { + position: absolute; // Remove it from the flex calculations so space-between works appropriately + } + + &:not(.has-fixed-toolbar) { + .edit-widgets-header__background-style { + top: 0; + left: 0; + bottom: -1px; // -1px to overlap the header border + right: 0; + background: $white; + border-bottom: $border-width solid $gray-200; + z-index: 3; // This z-index needs to be higher than the block popover but lower than the other items within the block toolbar + } + + // We want everything except the parent of the selected-block-tools-wrapper and the selected-block-tools-wrapper to have a higher z-index than the floating background-style + & > *:not(.edit-widgets-header__navigable-toolbar-wrapper), + .edit-widgets-header__navigable-toolbar-wrapper > *:not(.edit-widgets-header__menubar), + .edit-widgets-header__menubar > *:not(.selected-block-tools-wrapper) { + z-index: 4; + } + + .selected-block-tools-wrapper { + z-index: 2; + } + } } .edit-widgets-header__navigable-toolbar-wrapper { @@ -86,3 +113,24 @@ } } } + +.edit-widgets-header__menubar { + display: flex; + flex-grow: 1; + align-items: center; + flex-wrap: nowrap; +} + +.edit-widgets-header__menubar .block-editor-block-contextual-toolbar.is-fixed { + position: fixed; + top: $admin-bar-height-big + $header-height + 1px; // +1px to avoid overlap with the header border + left: 0; + width: 100%; + + @include break-medium() { + width: auto; + position: static; + // remove the border + border: none; + } +} diff --git a/packages/edit-widgets/src/components/layout/style.scss b/packages/edit-widgets/src/components/layout/style.scss index fe1edbae232951..1aed3d3eefc86f 100644 --- a/packages/edit-widgets/src/components/layout/style.scss +++ b/packages/edit-widgets/src/components/layout/style.scss @@ -22,15 +22,3 @@ height: 100%; } } - -.blocks-widgets-container { - // making the header be lower than the content - // so the fixed toolbar can be positioned on top of it - // but only on desktop - @include break-medium() { - .interface-interface-skeleton__header:not(:focus-within) { - z-index: 19; - } - } - -} diff --git a/packages/edit-widgets/src/components/widget-areas-block-editor-content/style.scss b/packages/edit-widgets/src/components/widget-areas-block-editor-content/style.scss index 062214ef147bf1..35493cad130cfd 100644 --- a/packages/edit-widgets/src/components/widget-areas-block-editor-content/style.scss +++ b/packages/edit-widgets/src/components/widget-areas-block-editor-content/style.scss @@ -34,101 +34,3 @@ } } } - -// Fixed contextual toolbar -@include editor-left(".edit-widgets-block-editor .block-editor-block-contextual-toolbar.is-fixed"); - - -.edit-widgets-block-editor .block-editor-block-contextual-toolbar.is-fixed { - position: sticky; - top: 0; - z-index: z-index(".block-editor-block-popover"); - display: block; - width: 100%; - - // on desktop and tablet viewports the toolbar is fixed - // on top of interface header - $toolbar-margin: $grid-unit-80 * 3 - 2 * $grid-unit + $grid-unit-05; - - @include break-medium() { - // leave room for block inserter, undo and redo, list view - margin-left: $toolbar-margin; - // position on top of interface header - position: fixed; - top: $admin-bar-height; - // Don't fill up when empty - min-height: initial; - // remove the border - border-bottom: none; - // has to be flex for collapse button to fit - display: flex; - - // Mimic the height of the parent, vertically align center, and provide a max-height. - height: $header-height; - align-items: center; - - - // on tablet viewports the toolbar is fixed - // on top of interface header and covers the whole header - // except for the inserter on the left - width: calc(100% - #{$toolbar-margin}); - - &.is-collapsed { - width: initial; - } - - &:empty { - width: initial; - } - - .is-fullscreen-mode & { - // leave room for block inserter, undo and redo, list view - // and some margin left - margin-left: $grid-unit-80 * 4 - 2 * $grid-unit; - - top: 0; - - &.is-collapsed { - width: initial; - } - - &:empty { - width: initial; - } - } - - .show-icon-labels & { - margin-left: $grid-unit-80 + 2 * $grid-unit; // inserter and margin - width: calc(100% + 40px - #{$toolbar-margin}); //there are no undo, redo and list view buttons - - .is-fullscreen-mode & { - margin-left: $grid-unit * 18; // site hub, inserter and margin - } - } - - .blocks-widgets-container & { - margin-left: $grid-unit-80 * 2.4; - - &.is-collapsed { - margin-left: $grid-unit-80 * 4.2; - } - } - } - - // on desktop viewports the toolbar is fixed - // on top of interface header and leaves room - // for the block inserter the publish button - @include break-large() { - width: auto; - .show-icon-labels & { - width: auto; //there are no undo, redo and list view buttons - } - - .is-fullscreen-mode & { - // in full screen mode we need to account for - // the combined with of the tools at the right of the header and the margin left - // of the toolbar which includes four buttons - width: calc(100% - 280px - #{4 * $grid-unit-80}); - } - } -} diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index db3ff72e3ab6eb..e1db27095f4587 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -140,7 +140,7 @@ test.describe( 'Image', () => { // Add caption and navigate to inline toolbar. await editor.clickBlockToolbarButton( 'Add caption' ); - await pageUtils.pressKeys( 'shift+Tab' ); + await pageUtils.pressKeys( 'alt+F10' ); expect( await page.evaluate( () => document.activeElement.getAttribute( 'aria-label' ) diff --git a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js index 080abe011206a7..273307126e64a3 100644 --- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js +++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js @@ -6,8 +6,8 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.use( { - KeyboardNavigableBlocks: async ( { editor, page, pageUtils }, use ) => { - await use( new KeyboardNavigableBlocks( { editor, page, pageUtils } ) ); + KeyboardNavigableBlocks: async ( { page }, use ) => { + await use( new KeyboardNavigableBlocks( { page } ) ); }, } ); @@ -16,11 +16,12 @@ test.describe( 'Order of block keyboard navigation', () => { await admin.createNewPost(); } ); - test( 'permits tabbing through paragraph blocks in the expected order', async ( { + test( 'permits arrowing through paragraph blocks in the expected order', async ( { editor, - KeyboardNavigableBlocks, page, } ) => { + // Add a title + await page.keyboard.type( 'Post Title' ); const paragraphBlocks = [ 'Paragraph 0', 'Paragraph 1', 'Paragraph 2' ]; // Create 3 paragraphs blocks with some content. @@ -29,58 +30,31 @@ test.describe( 'Order of block keyboard navigation', () => { await page.keyboard.type( paragraphBlock ); } - // Select the middle block. - await page.keyboard.press( 'ArrowUp' ); - await editor.showBlockToolbar(); - await KeyboardNavigableBlocks.navigateToContentEditorTop(); - await KeyboardNavigableBlocks.tabThroughParagraphBlock( 'Paragraph 1' ); - - // Repeat the same steps to ensure that there is no change introduced in how the focus is handled. - // This prevents the previous regression explained in: https://github.com/WordPress/gutenberg/issues/11773. - await KeyboardNavigableBlocks.navigateToContentEditorTop(); - await KeyboardNavigableBlocks.tabThroughParagraphBlock( 'Paragraph 1' ); - } ); - - test( 'allows tabbing in navigation mode if no block is selected', async ( { - editor, - KeyboardNavigableBlocks, - page, - } ) => { - const paragraphBlocks = [ '0', '1' ]; + // Focus should be on the last pargraph block. + const activeElement = editor.canvas.locator( ':focus' ); + await expect( activeElement ).toHaveText( 'Paragraph 2' ); - // Create 2 paragraphs blocks with some content. - for ( const paragraphBlock of paragraphBlocks ) { - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( paragraphBlock ); - } + await page.keyboard.press( 'ArrowUp' ); + await expect( activeElement ).toHaveText( 'Paragraph 1' ); - // Clear the selected block. - const paragraph = editor.canvas - .locator( '[data-type="core/paragraph"]' ) - .getByText( '1' ); - const box = await paragraph.boundingBox(); - await page.mouse.click( box.x - 1, box.y ); + await page.keyboard.press( 'ArrowUp' ); + await expect( activeElement ).toHaveText( 'Paragraph 0' ); - await page.keyboard.press( 'Tab' ); - await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Add title' ); + await page.keyboard.press( 'ArrowUp' ); + await expect( activeElement ).toHaveText( 'Post Title' ); - await page.keyboard.press( 'Tab' ); - await KeyboardNavigableBlocks.expectLabelToHaveFocus( - 'Paragraph Block. Row 1. 0' - ); + // Go back down + await page.keyboard.press( 'ArrowDown' ); + await expect( activeElement ).toHaveText( 'Paragraph 0' ); - await page.keyboard.press( 'Tab' ); - await KeyboardNavigableBlocks.expectLabelToHaveFocus( - 'Paragraph Block. Row 2. 1' - ); + await page.keyboard.press( 'ArrowDown' ); + await expect( activeElement ).toHaveText( 'Paragraph 1' ); - await page.keyboard.press( 'Tab' ); - await KeyboardNavigableBlocks.expectLabelToHaveFocus( - 'Post (selected)' - ); + await page.keyboard.press( 'ArrowDown' ); + await expect( activeElement ).toHaveText( 'Paragraph 2' ); } ); - test( 'allows tabbing in navigation mode if no block is selected (reverse)', async ( { + test( 'allows tabbing in navigation mode if no block is selected', async ( { editor, KeyboardNavigableBlocks, page, @@ -94,19 +68,12 @@ test.describe( 'Order of block keyboard navigation', () => { await page.keyboard.type( paragraphBlock ); } - // Clear the selected block. - const paragraph = editor.canvas - .locator( '[data-type="core/paragraph"]' ) - .getByText( '1' ); - const box = await paragraph.boundingBox(); - await page.mouse.click( box.x - 1, box.y ); - - // Put focus behind the block list. - await page.evaluate( () => { - document - .querySelector( '.interface-interface-skeleton__sidebar' ) - .focus(); - } ); + // Clear the selected block and switch to Select mode + await page.keyboard.press( 'Escape' ); + // Move focus into the sidebar + await page.keyboard.press( 'Tab' ); + const activeElement = page.locator( ':focus' ); + await expect( activeElement ).toHaveText( 'Post' ); await pageUtils.pressKeys( 'shift+Tab' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Add block' ); @@ -128,6 +95,17 @@ test.describe( 'Order of block keyboard navigation', () => { await pageUtils.pressKeys( 'shift+Tab' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Add title' ); + + // Make sure it works tabbing back through in sequence + await pageUtils.pressKeys( 'Tab' ); + await KeyboardNavigableBlocks.expectLabelToHaveFocus( + 'Paragraph Block. Row 1. 0' + ); + + await pageUtils.pressKeys( 'Tab' ); + await KeyboardNavigableBlocks.expectLabelToHaveFocus( + 'Paragraph Block. Row 2. 1' + ); } ); test( 'should navigate correctly with multi selection', async ( { @@ -158,9 +136,14 @@ test.describe( 'Order of block keyboard navigation', () => { 'Multiple selected blocks' ); - await pageUtils.pressKeys( 'shift+Tab' ); + await pageUtils.pressKeys( 'alt+F10' ); await page.keyboard.press( 'ArrowRight' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Move up' ); + // Return focus to the editor + await page.keyboard.press( 'Escape' ); + await KeyboardNavigableBlocks.expectLabelToHaveFocus( + 'Multiple selected blocks' + ); } ); test( 'allows the first element within a block to receive focus', async ( { @@ -181,27 +164,31 @@ test.describe( 'Order of block keyboard navigation', () => { test( 'allows the block wrapper to gain focus for a group block instead of the first element', async ( { editor, - KeyboardNavigableBlocks, + page, } ) => { // Insert a group block. await editor.insertBlock( { name: 'core/group' } ); - // Select the default, selected Group layout from the variation picker. - const groupButton = editor.canvas.locator( - 'button[aria-label="Group: Gather blocks in a container."]' + + const activeElement = editor.canvas.locator( ':focus' ); + + await expect( activeElement ).toHaveAttribute( + 'aria-label', + 'Group: Gather blocks in a container.' ); - await groupButton.click(); + await page.keyboard.press( 'Enter' ); // If active label matches, that means focus did not change from group block wrapper. - await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Block: Group' ); + await expect( activeElement ).toHaveAttribute( + 'aria-label', + 'Block: Group' + ); } ); } ); class KeyboardNavigableBlocks { - constructor( { editor, page, pageUtils } ) { - this.editor = editor; + constructor( { page } ) { this.page = page; - this.pageUtils = pageUtils; } async expectLabelToHaveFocus( label ) { @@ -216,61 +203,6 @@ class KeyboardNavigableBlocks { expect( ariaLabel ).toBe( label ); } - - async navigateToContentEditorTop() { - // Use 'Ctrl+`' to return to the top of the editor. - await this.pageUtils.pressKeys( 'ctrl+`', { times: 5 } ); - } - - async tabThroughParagraphBlock( paragraphText ) { - await this.tabThroughBlockToolbar(); - - await this.page.keyboard.press( 'Tab' ); - await this.expectLabelToHaveFocus( 'Block: Paragraph' ); - - const activeElement = this.editor.canvas.locator( ':focus' ); - - await expect( activeElement ).toHaveText( paragraphText ); - - await this.page.keyboard.press( 'Tab' ); - await this.expectLabelToHaveFocus( 'Post' ); - - // Need to shift+tab here to end back in the block. If not, we'll be in the next region and it will only require 4 region jumps instead of 5. - await this.pageUtils.pressKeys( 'shift+Tab' ); - await this.expectLabelToHaveFocus( 'Block: Paragraph' ); - } - - async tabThroughBlockToolbar() { - await this.page.keyboard.press( 'Tab' ); - await this.expectLabelToHaveFocus( 'Paragraph' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'Move up' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'Move down' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'Align text' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'Bold' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'Italic' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'Link' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'More' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'Options' ); - - await this.page.keyboard.press( 'ArrowRight' ); - await this.expectLabelToHaveFocus( 'Paragraph' ); - } } /* eslint-enable playwright/expect-expect */ diff --git a/test/e2e/specs/editor/various/navigable-toolbar.spec.js b/test/e2e/specs/editor/various/navigable-toolbar.spec.js index abdb1800d150a2..d549a1a242b21b 100644 --- a/test/e2e/specs/editor/various/navigable-toolbar.spec.js +++ b/test/e2e/specs/editor/various/navigable-toolbar.spec.js @@ -3,6 +3,12 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +test.use( { + BlockToolbarUtils: async ( { page, pageUtils }, use ) => { + await use( new BlockToolbarUtils( { page, pageUtils } ) ); + }, +} ); + test.describe( 'Block Toolbar', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); @@ -44,20 +50,94 @@ test.describe( 'Block Toolbar', () => { } ); expect( scrollTopBefore ).toBe( scrollTopAfter ); } ); - } ); - test( 'should focus with Shift+Tab', async ( { - editor, - page, - pageUtils, - } ) => { - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( 'a' ); - await pageUtils.pressKeys( 'shift+Tab' ); - await expect( - page - .getByRole( 'toolbar', { name: 'Block Tools' } ) - .getByRole( 'button', { name: 'Paragraph' } ) - ).toBeFocused(); + test( 'can navigate to the block toolbar and back to block using the keyboard', async ( { + BlockToolbarUtils, + editor, + page, + pageUtils, + } ) => { + // Test navigating to block toolbar + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'Paragraph' ); + await BlockToolbarUtils.focusBlockToolbar(); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Paragraph' ); + // // Navigate to Align Text + await page.keyboard.press( 'ArrowRight' ); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Align text' ); + // // Open the dropdown + await page.keyboard.press( 'Enter' ); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Align text left' ); + await page.keyboard.press( 'ArrowDown' ); + await BlockToolbarUtils.expectLabelToHaveFocus( + 'Align text center' + ); + await page.keyboard.press( 'Escape' ); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Align text' ); + + // Navigate to the Bold item. Testing items via the fills within the block toolbar are especially important + await page.keyboard.press( 'ArrowRight' ); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Bold' ); + + await BlockToolbarUtils.focusBlock(); + await BlockToolbarUtils.expectLabelToHaveFocus( + 'Block: Paragraph' + ); + + await BlockToolbarUtils.focusBlockToolbar(); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Bold' ); + + await BlockToolbarUtils.focusBlock(); + + // Try selecting text and navigating to block toolbar + await pageUtils.pressKeys( 'Shift+ArrowLeft', { + times: 4, + delay: 50, + } ); + expect( + await editor.canvas + .locator( ':root' ) + .evaluate( () => window.getSelection().toString() ) + ).toBe( 'raph' ); + + // Go back to the toolbar and apply a formatting option + await BlockToolbarUtils.focusBlockToolbar(); + await BlockToolbarUtils.expectLabelToHaveFocus( 'Bold' ); + await page.keyboard.press( 'Enter' ); + // Should focus the selected text again + expect( + await editor.canvas + .locator( ':root' ) + .evaluate( () => window.getSelection().toString() ) + ).toBe( 'raph' ); + } ); } ); } ); + +class BlockToolbarUtils { + constructor( { page, pageUtils } ) { + this.page = page; + this.pageUtils = pageUtils; + } + + async focusBlockToolbar() { + await this.pageUtils.pressKeys( 'alt+F10' ); + } + + async focusBlock() { + await this.pageUtils.pressKeys( 'Escape' ); + } + + async expectLabelToHaveFocus( label ) { + const ariaLabel = await this.page.evaluate( () => { + const { activeElement } = + document.activeElement.contentDocument ?? document; + return ( + activeElement.getAttribute( 'aria-label' ) || + activeElement.innerText + ); + } ); + + expect( ariaLabel ).toBe( label ); + } +} diff --git a/test/e2e/specs/editor/various/preview.spec.js b/test/e2e/specs/editor/various/preview.spec.js index cfec384adba9bf..4783eb5b9b056c 100644 --- a/test/e2e/specs/editor/various/preview.spec.js +++ b/test/e2e/specs/editor/various/preview.spec.js @@ -324,11 +324,13 @@ class PreviewUtils { } async toggleCustomFieldsOption( shouldBeChecked ) { - // Open preferences dialog. - - await this.page.click( - 'role=region[name="Editor top bar"i] >> role=button[name="Options"i]' - ); + // Open preferences dialog. We need the `.last()` because if a block is selected, there are multiple Options buttons within the header. + await this.page + .locator( + 'role=region[name="Editor top bar"i] >> role=button[name="Options"i]' + ) + .last() + .click(); await this.page.click( 'role=menuitem[name="Preferences"i]' ); // Navigate to panels section. diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index 2969a33d254852..0373bd47354b16 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -647,7 +647,7 @@ test.describe( 'RichText', () => { await page.keyboard.press( 'Escape' ); // Navigate to the block. - await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Escape' ); // Copy the colored text. await pageUtils.pressKeys( 'primary+c' ); diff --git a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js index e706dfc3607dc3..7ae5379f0e8240 100644 --- a/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js +++ b/test/e2e/specs/editor/various/toolbar-roving-tabindex.spec.js @@ -133,19 +133,8 @@ test.describe( 'Toolbar roving tabindex', () => { await page.keyboard.press( 'ArrowRight' ); await ToolbarRovingTabindexUtils.expectLabelToHaveFocus( 'Move up' ); await pageUtils.pressKeys( 'Tab' ); - await pageUtils.pressKeys( 'shift+Tab' ); - await ToolbarRovingTabindexUtils.expectLabelToHaveFocus( 'Move up' ); - } ); - - test( 'can reach toolbar items with arrow keys after pressing alt+F10', async ( { - page, - pageUtils, - ToolbarRovingTabindexUtils, - } ) => { await pageUtils.pressKeys( 'alt+F10' ); - await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.press( 'ArrowRight' ); - await ToolbarRovingTabindexUtils.expectLabelToHaveFocus( 'Bold' ); + await ToolbarRovingTabindexUtils.expectLabelToHaveFocus( 'Move up' ); } ); } ); @@ -167,9 +156,9 @@ class ToolbarRovingTabindexUtils { await this.expectLabelToHaveFocus( currentBlockTitle ); await this.page.keyboard.press( 'ArrowRight' ); await this.expectLabelToHaveFocus( 'Move up' ); - await this.pageUtils.pressKeys( 'Tab' ); + await this.pageUtils.pressKeys( 'Escape' ); await this.expectLabelToHaveFocus( currentBlockLabel ); - await this.pageUtils.pressKeys( 'shift+Tab' ); + await this.pageUtils.pressKeys( 'alt+F10' ); await this.expectLabelToHaveFocus( 'Move up' ); } @@ -200,7 +189,7 @@ class ToolbarRovingTabindexUtils { await this.expectLabelToHaveFocus( 'Block: Group' ); await this.page.keyboard.press( 'ArrowRight' ); await this.expectLabelToHaveFocus( currentBlockLabel ); - await this.pageUtils.pressKeys( 'shift+Tab' ); + await this.pageUtils.pressKeys( 'alt+F10' ); await this.expectLabelToHaveFocus( 'Select Group' ); await this.page.keyboard.press( 'ArrowRight' ); await this.expectLabelToHaveFocus( currentBlockTitle ); diff --git a/test/e2e/specs/site-editor/writing-flow.spec.js b/test/e2e/specs/site-editor/writing-flow.spec.js index 8ee6ce0e565572..3bb6b4aa8ba328 100644 --- a/test/e2e/specs/site-editor/writing-flow.spec.js +++ b/test/e2e/specs/site-editor/writing-flow.spec.js @@ -13,7 +13,7 @@ test.describe( 'Site editor writing flow', () => { } ); // Check for regressions of https://github.com/WordPress/gutenberg/issues/41811. - test( 'allows shift tabbing to the block toolbar from the first block', async ( { + test( 'allows shift tabbing out of the editor canvas from the first block', async ( { admin, editor, page, @@ -32,11 +32,11 @@ test.describe( 'Site editor writing flow', () => { await expect( siteTitleBlock ).toBeVisible(); await editor.selectBlocks( siteTitleBlock ); - // Shift tab to the toolbar. + // Shift tab to the first tabstop outside of the editor canvas await pageUtils.pressKeys( 'shift+Tab' ); - const blockToolbarButton = page.locator( - 'role=toolbar[name="Block tools"i] >> role=button[name="Site Title"i]' - ); + const blockToolbarButton = page.getByRole( 'button', { + name: 'Open save panel', + } ); await expect( blockToolbarButton ).toBeFocused(); } );