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 );
+ }
+}