diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 7b0bd386daaf4..38a93552bcbef 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/block-editor/src/components/block-tools/block-contextual-toolbar.js b/packages/block-editor/src/components/block-tools/block-contextual-toolbar.js index fcec9d56b24a8..52a625a06be43 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 @@ -8,6 +8,7 @@ import classnames from 'classnames'; */ import { __ } from '@wordpress/i18n'; import { + forwardRef, useLayoutEffect, useEffect, useRef, @@ -31,7 +32,10 @@ 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 } ) { +function UnforwardBlockContextualToolbar( + { focusOnMount, isFixed, ...props }, + ref +) { // When the toolbar is fixed it can be collapsed const [ isCollapsed, setIsCollapsed ] = useState( false ); const toolbarButtonRef = useRef(); @@ -184,7 +188,9 @@ function BlockContextualToolbar( { focusOnMount, isFixed, ...props } ) { return ( + ) } { showEmptyBlockSideInserter && ( @@ -177,6 +191,7 @@ export default function BlockTools( { needed for navigation and zoom-out mode. */ } { ! showEmptyBlockSideInserter && hasSelectedBlock && ( diff --git a/packages/block-editor/src/components/block-tools/selected-block-popover.js b/packages/block-editor/src/components/block-tools/selected-block-popover.js new file mode 100644 index 0000000000000..f456d01e267d0 --- /dev/null +++ b/packages/block-editor/src/components/block-tools/selected-block-popover.js @@ -0,0 +1,274 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { forwardRef, useRef, useEffect } from '@wordpress/element'; +import { isUnmodifiedDefaultBlock } from '@wordpress/blocks'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useShortcut } from '@wordpress/keyboard-shortcuts'; + +/** + * 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 Inserter from '../inserter'; +import { useShouldContextualToolbarShow } from '../../utils/use-should-contextual-toolbar-show'; + +function selector( select ) { + const { + __unstableGetEditorMode, + hasMultiSelection, + isTyping, + getLastMultiSelectedBlockClientId, + } = select( blockEditorStore ); + + return { + editorMode: __unstableGetEditorMode(), + hasMultiSelection: hasMultiSelection(), + isTyping: isTyping(), + lastClientId: hasMultiSelection() + ? getLastMultiSelectedBlockClientId() + : null, + }; +} + +function UnforwardSelectedBlockPopover( + { + clientId, + rootClientId, + isEmptyDefaultBlock, + capturingClientId, + __unstablePopoverSlot, + __unstableContentRef, + }, + ref +) { + const { editorMode, hasMultiSelection, isTyping, lastClientId } = useSelect( + selector, + [] + ); + + const isInsertionPointVisible = useSelect( + ( select ) => { + 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; +} + +const SelectedBlockPopover = forwardRef( UnforwardSelectedBlockPopover ); + +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, + }; +} + +function UnforwardWrappedBlockPopover( + { __unstablePopoverSlot, __unstableContentRef }, + ref +) { + const selected = useSelect( wrapperSelector, [] ); + + if ( ! selected ) { + return null; + } + + const { + clientId, + rootClientId, + name, + isEmptyDefaultBlock, + capturingClientId, + } = selected; + + if ( ! name ) { + return null; + } + + return ( + + ); +} + +export default forwardRef( UnforwardWrappedBlockPopover ); 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 index 8fa0ac97d5c94..48c446f579581 100644 --- a/packages/block-editor/src/components/block-tools/selected-block-tools.js +++ b/packages/block-editor/src/components/block-tools/selected-block-tools.js @@ -6,7 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useRef, useEffect } from '@wordpress/element'; +import { forwardRef, useRef, useEffect } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { useShortcut } from '@wordpress/keyboard-shortcuts'; @@ -21,11 +21,10 @@ 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'; -export default function SelectedBlockTools( { - clientId, - showEmptyBlockSideInserter, - __unstableContentRef, -} ) { +function UnforwardSelectedBlockTools( + { clientId, showEmptyBlockSideInserter, __unstableContentRef }, + ref +) { const { capturingClientId, isInsertionPointVisible, @@ -102,6 +101,7 @@ export default function SelectedBlockTools( { > { shouldShowContextualToolbar && ( { - 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 +82,7 @@ function useIsAccessibleToolbar( ref ) { } ); } setIsAccessibleToolbar( onlyToolbarItem ); - }, [] ); + }, [ toolbarRef ] ); useLayoutEffect( () => { // Toolbar buttons may be rendered asynchronously, so we use @@ -81,28 +90,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 ] ); + }, [ determineIsAccessibleToolbar, isAccessibleToolbar, toolbarRef ] ); return isAccessibleToolbar; } -function useToolbarFocus( - ref, +function useToolbarFocus( { + toolbarRef, focusOnMount, isAccessibleToolbar, defaultIndex, onIndexChange, - shouldUseKeyboardFocusShortcut -) { + shouldUseKeyboardFocusShortcut, + focusEditorOnEscape, +} ) { // 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 +134,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 +163,73 @@ function useToolbarFocus( const index = items.findIndex( ( item ) => item.tabIndex === 0 ); onIndexChange( index ); }; - }, [ initialIndex, initialFocusOnMount ] ); + }, [ initialIndex, initialFocusOnMount, toolbarRef ] ); + + 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, + ...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 +239,7 @@ function NavigableToolbar( { { children } @@ -193,4 +247,4 @@ function NavigableToolbar( { ); } -export default NavigableToolbar; +export default forwardRef( UnforwardNavigableToolbar ); 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 616da1bc75813..b1fb1800a53ea 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; @@ -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 2975a41dbb9d9..4f6ce7b5b044c 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 4373182d98662..d5ff85e9e4257 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 cb1f8ef49809d..e9d17d86a2672 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/test/e2e/specs/editor/various/navigable-toolbar.spec.js b/test/e2e/specs/editor/various/navigable-toolbar.spec.js index abdb1800d150a..0f9438ac946dd 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,6 +50,67 @@ test.describe( 'Block Toolbar', () => { } ); expect( scrollTopBefore ).toBe( scrollTopAfter ); } ); + + 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' ); + } ); } ); test( 'should focus with Shift+Tab', async ( { @@ -61,3 +128,31 @@ test.describe( 'Block Toolbar', () => { ).toBeFocused(); } ); } ); + +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 ); + } +}