Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Editor: Move block focus restore to Block Toolbar #10529

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 78 additions & 16 deletions packages/editor/src/components/block-toolbar/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
/**
* External dependencies
*/
import { cond, matchesProperty } from 'lodash';

/**
* WordPress Dependencies
*/
import { withSelect } from '@wordpress/data';
import { Component, createRef, Fragment } from '@wordpress/element';
import { focus } from '@wordpress/dom';
import { KeyboardShortcuts } from '@wordpress/components';
import { ESCAPE } from '@wordpress/keycodes';

/**
* Internal Dependencies
Expand All @@ -17,7 +24,15 @@ import BlockSettingsMenu from '../block-settings-menu';
class BlockToolbar extends Component {
constructor() {
super( ...arguments );

this.container = createRef();

this.focusContainer = this.focusContainer.bind( this );
this.restoreFocus = this.restoreFocus.bind( this );
this.resetActiveElementBeforeFocus = this.resetActiveElementBeforeFocus.bind( this );
this.switchOnKeyDown = cond( [
[ matchesProperty( [ 'keyCode' ], ESCAPE ), this.restoreFocus ],
] );
}

componentDidMount() {
Expand All @@ -35,41 +50,88 @@ class BlockToolbar extends Component {
}
}

/**
* Shifts focus to the first tabbable element within the toolbar container,
* if one exists.
*/
focusContainer() {
const tabbables = focus.tabbable.find( this.container.current );
if ( tabbables.length ) {
tabbables[ 0 ].focus();
if ( ! tabbables.length ) {
return;
}

// Track the original active element prior to shifting focus, so that
// focus can be returned if the user presses Escape while in toolbar.
this.activeElementBeforeFocus = document.activeElement;

tabbables[ 0 ].focus();
}

/**
* Restores focus to the active element at the time focus was
* programattically shifted to the toolbar, if one exists.
*/
restoreFocus() {
if ( this.activeElementBeforeFocus ) {
this.activeElementBeforeFocus.focus();
this.resetActiveElementBeforeFocus();
}
}

/**
* Clears the assigned active element which would be otherwise used in
* restoreFocus.
*/
resetActiveElementBeforeFocus() {
delete this.activeElementBeforeFocus;
}

render() {
const { blockClientIds, isValid, mode } = this.props;

if ( blockClientIds.length === 0 ) {
if ( ! blockClientIds.length ) {
return null;
}

let controls;
if ( blockClientIds.length > 1 ) {
return (
<div className="editor-block-toolbar" ref={ this.container }>
<MultiBlocksSwitcher />
<BlockSettingsMenu clientIds={ blockClientIds } />
</div>
controls = <MultiBlocksSwitcher />;
} else if ( mode === 'visual' && isValid ) {
controls = (
<Fragment>
<BlockSwitcher clientIds={ blockClientIds } />
<BlockControls.Slot />
<BlockFormatControls.Slot />
</Fragment>
);
}

// Disable reason: The div is not intended to be interactable, but it
// observes bubbled keypresses from its interactable children to infer
// blur intent.

/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div className="editor-block-toolbar">
{ mode === 'visual' && isValid && (
<Fragment>
<BlockSwitcher clientIds={ blockClientIds } />
<BlockControls.Slot />
<BlockFormatControls.Slot />
</Fragment>
) }
<div
ref={ this.container }
onKeyDown={ this.switchOnKeyDown }
onBlur={ this.resetActiveElementBeforeFocus }
className="editor-block-toolbar"
>
<KeyboardShortcuts
bindGlobal
// Use the same event that TinyMCE uses in the Classic
// block for its own `alt+f10` shortcut.
eventName="keydown"
shortcuts={ {
'alt+f10': this.focusContainer,
} }
/>
{ controls }
<BlockSettingsMenu clientIds={ blockClientIds } />
</div>
);
/* eslint-disable jsx-a11y/no-static-element-interactions */
}
}

Expand Down
101 changes: 12 additions & 89 deletions packages/editor/src/components/navigable-toolbar/index.js
Original file line number Diff line number Diff line change
@@ -1,95 +1,18 @@
/**
* External dependencies
*/
import { cond, matchesProperty } from 'lodash';

/**
* WordPress dependencies
*/
import { NavigableMenu, KeyboardShortcuts } from '@wordpress/components';
import { Component, findDOMNode } from '@wordpress/element';
import { focus } from '@wordpress/dom';
import { ESCAPE } from '@wordpress/keycodes';

/**
* Browser dependencies
*/

const { Node, getSelection } = window;

class NavigableToolbar extends Component {
constructor() {
super( ...arguments );

this.bindNode = this.bindNode.bind( this );
this.focusToolbar = this.focusToolbar.bind( this );
this.focusSelection = this.focusSelection.bind( this );

this.switchOnKeyDown = cond( [
[ matchesProperty( [ 'keyCode' ], ESCAPE ), this.focusSelection ],
] );
}

bindNode( ref ) {
// Disable reason: Need DOM node for finding first focusable element
// on keyboard interaction to shift to toolbar.
// eslint-disable-next-line react/no-find-dom-node
this.toolbar = findDOMNode( ref );
}

focusToolbar() {
const tabbables = focus.tabbable.find( this.toolbar );
if ( tabbables.length ) {
tabbables[ 0 ].focus();
}
}

/**
* Programmatically shifts focus to the element where the current selection
* exists, if there is a selection.
*/
focusSelection() {
// Ensure that a selection exists.
const selection = getSelection();
if ( ! selection ) {
return;
}

// Focus node may be a text node, which cannot be focused directly.
// Find its parent element instead.
const { focusNode } = selection;
let focusElement = focusNode;
if ( focusElement.nodeType !== Node.ELEMENT_NODE ) {
focusElement = focusElement.parentElement;
}

if ( focusElement ) {
focusElement.focus();
}
}

render() {
const { children, ...props } = this.props;
return (
<NavigableMenu
orientation="horizontal"
role="toolbar"
ref={ this.bindNode }
onKeyDown={ this.switchOnKeyDown }
{ ...props }
>
<KeyboardShortcuts
bindGlobal
// Use the same event that TinyMCE uses in the Classic block for its own `alt+f10` shortcut.
eventName="keydown"
shortcuts={ {
'alt+f10': this.focusToolbar,
} }
/>
{ children }
</NavigableMenu>
);
}
import { NavigableMenu } from '@wordpress/components';

function NavigableToolbar( { children, ...props } ) {
return (
<NavigableMenu
orientation="horizontal"
role="toolbar"
{ ...props }
>
{ children }
</NavigableMenu>
);
}

export default NavigableToolbar;
58 changes: 58 additions & 0 deletions test/e2e/specs/block-toolbar.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { forEach } from 'lodash';

/**
* Internal dependencies
*/
import {
newPost,
clickBlockAppender,
pressWithModifier,
} from '../support/utils';

describe( 'block toolbar', () => {
forEach( {
unified: true,
'not unified': false,
}, ( isUnifiedToolbar, label ) => {
beforeEach( async () => {
await newPost();

await page.evaluate( ( _isUnifiedToolbar ) => {
const { select, dispatch } = wp.data;
const isCurrentlyUnified = select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' );
if ( isCurrentlyUnified !== _isUnifiedToolbar ) {
dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' );
}
}, isUnifiedToolbar );
} );

describe( label, () => {
it( 'navigates in and out of toolbar by keyboard (Alt+F10, Escape)', async () => {
await clickBlockAppender();

// [TEMPORARY/BUG]: Unless in unified toolbar mode, the toolbar
// does not appear until the user types some text and changes
// the selection.
await page.keyboard.type( 'a' );
await pressWithModifier( 'Shift', 'ArrowLeft' );

await pressWithModifier( 'Alt', 'F10' );
const isInToolbar = await page.evaluate( () => (
!! document.activeElement.closest( '.editor-block-toolbar' )
) );

expect( isInToolbar ).toBe( true );

await page.keyboard.press( 'Escape' );
const isInBlockEdit = await page.evaluate( () => (
!! document.activeElement.closest( '.editor-block-list__block-edit' )
) );

expect( isInBlockEdit ).toBe( true );
} );
} );
} );
} );