Skip to content

Commit

Permalink
Escape on Block Toolbar returns focus to Editor Canvas (#55712)
Browse files Browse the repository at this point in the history
* Move lastFocus into redux store

Stores the element that had last focus from the editor when focus leaves the editor canvas

* ForwardRef for selected block tools

Adding the escape keypress from the navigable block tools will require forwarding the ref from the BlockTools all the way down to the NavigableToolbar.

* Handle escape on toolbar via toolbar event listener

This method requires forwarding a ref from the editor level down to the navigable toolbar so that the escape unselect shortcut can be blocked and the navigable toolbar event listener will still fire.

Blocking the global escape event 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.

Also, this is better than attaching it to all of the children in the block toolbar because when new items are attached to the toolbar (such as via the bubblesVirtually slots or the apply/cancel buttons when cropping an image) we can still catch the events. Otherwise, those buttons are added after the mount, and the children don't receive the listener.

* Refactor to object named notation

* Pass selectedBlockToolsRef to fixed toolbar

* Add keyboard navigation test to cover escape and alt+10 keypresses

* Remove onIndexChange from useEffect dependency array

I added it since it was passed in, but it is a callback and doesn't change. It was causing unnecssary rerenders that was messing with the focus position when selecting an item from a dropdown, such as when changing the heading level on a site title block.
  • Loading branch information
jeryj committed Nov 2, 2023
1 parent 196c132 commit ec83e45
Show file tree
Hide file tree
Showing 11 changed files with 563 additions and 43 deletions.
24 changes: 24 additions & 0 deletions docs/reference-guides/data/data-core-block-editor.md
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Expand Up @@ -8,6 +8,7 @@ import classnames from 'classnames';
*/
import { __ } from '@wordpress/i18n';
import {
forwardRef,
useLayoutEffect,
useEffect,
useRef,
Expand All @@ -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();
Expand Down Expand Up @@ -184,7 +188,9 @@ function BlockContextualToolbar( { focusOnMount, isFixed, ...props } ) {

return (
<NavigableToolbar
ref={ ref }
focusOnMount={ focusOnMount }
focusEditorOnEscape
className={ classes }
/* translators: accessibility text for the block toolbar */
aria-label={ __( 'Block tools' ) }
Expand Down Expand Up @@ -220,4 +226,4 @@ function BlockContextualToolbar( { focusOnMount, isFixed, ...props } ) {
);
}

export default BlockContextualToolbar;
export default forwardRef( UnforwardBlockContextualToolbar );
17 changes: 16 additions & 1 deletion packages/block-editor/src/components/block-tools/index.js
Expand Up @@ -88,6 +88,8 @@ export default function BlockTools( {
moveBlocksDown,
} = useDispatch( blockEditorStore );

const selectedBlockToolsRef = useRef( null );

function onKeyDown( event ) {
if ( event.defaultPrevented ) return;

Expand Down Expand Up @@ -130,6 +132,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();
Expand Down Expand Up @@ -164,7 +175,10 @@ export default function BlockTools( {
) }
{ ! isZoomOutMode &&
( hasFixedToolbar || ! isLargeViewport ) && (
<BlockContextualToolbar isFixed />
<BlockContextualToolbar
ref={ selectedBlockToolsRef }
isFixed
/>
) }

{ showEmptyBlockSideInserter && (
Expand All @@ -177,6 +191,7 @@ export default function BlockTools( {
needed for navigation and zoom-out mode. */ }
{ ! showEmptyBlockSideInserter && hasSelectedBlock && (
<SelectedBlockTools
ref={ selectedBlockToolsRef }
__unstableContentRef={ __unstableContentRef }
clientId={ clientId }
/>
Expand Down
@@ -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 (
<BlockPopover
clientId={ capturingClientId || clientId }
__unstableCoverTarget
bottomClientId={ lastClientId }
className={ classnames(
'block-editor-block-list__block-side-inserter-popover',
{
'is-insertion-point-visible': isInsertionPointVisible,
}
) }
__unstablePopoverSlot={ __unstablePopoverSlot }
__unstableContentRef={ __unstableContentRef }
resize={ false }
shift={ false }
{ ...popoverProps }
>
<div className="block-editor-block-list__empty-block-inserter">
<Inserter
position="bottom right"
rootClientId={ rootClientId }
clientId={ clientId }
__experimentalIsQuick
/>
</div>
</BlockPopover>
);
}

if ( shouldShowBreadcrumb || shouldShowContextualToolbar ) {
return (
<BlockPopover
clientId={ capturingClientId || clientId }
bottomClientId={ lastClientId }
className={ classnames(
'block-editor-block-list__block-popover',
{
'is-insertion-point-visible': isInsertionPointVisible,
}
) }
__unstablePopoverSlot={ __unstablePopoverSlot }
__unstableContentRef={ __unstableContentRef }
resize={ false }
{ ...popoverProps }
>
{ shouldShowContextualToolbar && (
<BlockContextualToolbar
ref={ ref }
// If the toolbar is being shown because of being forced
// it should focus the toolbar right after the mount.
focusOnMount={ isToolbarForced.current }
__experimentalInitialIndex={
initialToolbarItemIndexRef.current
}
__experimentalOnIndexChange={ ( index ) => {
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 && (
<BlockSelectionButton
clientId={ clientId }
rootClientId={ rootClientId }
/>
) }
</BlockPopover>
);
}

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 (
<SelectedBlockPopover
ref={ ref }
clientId={ clientId }
rootClientId={ rootClientId }
isEmptyDefaultBlock={ isEmptyDefaultBlock }
capturingClientId={ capturingClientId }
__unstablePopoverSlot={ __unstablePopoverSlot }
__unstableContentRef={ __unstableContentRef }
/>
);
}

export default forwardRef( UnforwardWrappedBlockPopover );

1 comment on commit ec83e45

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaky tests detected in ec83e45.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/6735338786
📝 Reported issues:

Please sign in to comment.