Skip to content

Commit

Permalink
Merge pull request #578 from WordPress/keyboard-inserter-navigation
Browse files Browse the repository at this point in the history
Add keyboard navigation to the inserter.
  • Loading branch information
jasmussen authored May 8, 2017
2 parents a9c8a66 + 1e9551a commit a72b370
Show file tree
Hide file tree
Showing 4 changed files with 257 additions and 22 deletions.
3 changes: 2 additions & 1 deletion editor/components/button/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import './style.scss';
import classnames from 'classnames';

function Button( { isPrimary, isLarge, isToggled, className, ...additionalProps } ) {
function Button( { isPrimary, isLarge, isToggled, className, buttonRef, ...additionalProps } ) {
const classes = classnames( 'editor-button', className, {
button: ( isPrimary || isLarge ),
'button-primary': isPrimary,
Expand All @@ -16,6 +16,7 @@ function Button( { isPrimary, isLarge, isToggled, className, ...additionalProps
<button
type="button"
{ ...additionalProps }
ref={ buttonRef }
className={ classes } />
);
}
Expand Down
7 changes: 6 additions & 1 deletion editor/components/inserter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ class Inserter extends wp.element.Component {
}

toggle() {
if ( this.state.opened ) {
this.toggleNode.focus();
}

this.setState( {
opened: ! this.state.opened
} );
Expand Down Expand Up @@ -51,8 +55,9 @@ class Inserter extends wp.element.Component {
onClick={ this.toggle }
className="editor-inserter__toggle"
aria-haspopup="true"
buttonRef={ ( node ) => this.toggleNode = node }
aria-expanded={ opened ? 'true' : 'false' } />
{ opened && <InserterMenu position={ position } onSelect={ this.close } /> }
{ opened && <InserterMenu position={ position } onSelect={ this.close } closeMenu={ this.toggle } /> }
</div>
);
}
Expand Down
265 changes: 246 additions & 19 deletions editor/components/inserter/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* External dependencies
*/
import { connect } from 'react-redux';
import { flow } from 'lodash';

/**
* Internal dependencies
Expand All @@ -12,11 +13,58 @@ import Dashicon from 'components/dashicon';
class InserterMenu extends wp.element.Component {
constructor() {
super( ...arguments );
this.blockTypes = wp.blocks.getBlocks();
this.categories = wp.blocks.getCategories();
this.nodes = {};
this.state = {
filterValue: ''
filterValue: '',
currentFocus: null
};
this.filter = this.filter.bind( this );
this.instanceId = this.constructor.instances++;
this.isShownBlock = this.isShownBlock.bind( this );
this.setSearchFocus = this.setSearchFocus.bind( this );
this.onKeyDown = this.onKeyDown.bind( this );
this.getVisibleBlocks = this.getVisibleBlocks.bind( this );
this.sortBlocksByCategory = this.sortBlocksByCategory.bind( this );
}

componentDidMount() {
document.addEventListener( 'keydown', this.onKeyDown );
}

componentWillUnmount() {
document.removeEventListener( 'keydown', this.onKeyDown );
}

isShownBlock( block ) {
return block.title.toLowerCase().indexOf( this.state.filterValue.toLowerCase() ) !== -1;
}

bindReferenceNode( nodeName ) {
return ( node ) => this.nodes[ nodeName ] = node;
}

isNextKeydown( keydown ) {
return keydown.code === 'ArrowDown'
|| ( keydown.code === 'Tab' && keydown.shiftKey === false );
}

isArrowRight( keydown ) {
return keydown.code === 'ArrowRight';
}

isArrowLeft( keydown ) {
return keydown.code === 'ArrowLeft';
}

isPreviousKeydown( keydown ) {
return keydown.code === 'ArrowUp'
|| ( keydown.code === 'Tab' && keydown.shiftKey === true );
}

isEscapeKey( keydown ) {
return keydown.code === 'Escape';
}

filter( event ) {
Expand All @@ -29,32 +77,206 @@ class InserterMenu extends wp.element.Component {
return () => {
this.props.onInsertBlock( slug );
this.props.onSelect();
this.setState( { filterValue: '' } );
this.setState( {
filterValue: '',
currentFocus: null
} );
};
}

render() {
const { position = 'top' } = this.props;
const blocks = wp.blocks.getBlocks();
const isShownBlock = block => block.title.toLowerCase().indexOf( this.state.filterValue.toLowerCase() ) !== -1;
const blocksByCategory = blocks.reduce( ( groups, block ) => {
if ( ! isShownBlock( block ) ) {
return groups;
}
if ( ! groups[ block.category ] ) {
groups[ block.category ] = [];
getVisibleBlocks( blockTypes ) {
return blockTypes.filter( this.isShownBlock );
}

sortBlocksByCategory( blockTypes ) {
const getCategoryIndex = ( item ) => {
return this.categories.findIndex( ( category ) => category.slug === item.slug );
};

return blockTypes.sort( ( a, b ) => {
return getCategoryIndex( a ) - getCategoryIndex( b );
} );
}

groupByCategory( blockTypes ) {
return blockTypes.reduce( ( accumulator, block ) => {
// If already an array push block on else add array of block.
if ( Array.isArray( accumulator[ block.category ] ) ) {
accumulator[ block.category ].push( block );
return accumulator;
}
groups[ block.category ].push( block );
return groups;

accumulator[ block.category ] = [ block ];
return accumulator;
}, {} );
const categories = wp.blocks.getCategories();
}

getVisibleBlocksByCategory( blockTypes ) {
return flow(
this.getVisibleBlocks,
this.sortBlocksByCategory,
this.groupByCategory
)( blockTypes );
}

findNext( currentBlock, blockTypes ) {
/**
* null is the value that will trigger iterating back to
* the top of the list of block types.
*/
if ( null === currentBlock ) {
return blockTypes[ 0 ].slug;
}

const currentIndex = blockTypes.findIndex( ( blockType ) => currentBlock === blockType.slug );
const nextIndex = currentIndex + 1;
const highestIndex = blockTypes.length - 1;

/**
* Default currently for going past the blocks is search, may need to be
* revised in the future as more focusable elements are added. This
* returns a null value, which currently implies that search will be set
* as the next focus.
*/
if ( nextIndex > highestIndex ) {
return null;
}

// Return the slug of the next block type.
return blockTypes[ nextIndex ].slug;
}

findPrevious( currentBlock, blockTypes ) {
/**
* null will trigger iterating back to the top of the list of block
* types.
*/
if ( null === currentBlock ) {
return blockTypes[ 0 ].slug;
}

const highestIndex = blockTypes.length - 1;

// If the search bar is focused navigate to the bottom of the block list.
if ( 'search' === currentBlock ) {
return blockTypes[ highestIndex ].slug;
}

const currentIndex = blockTypes.findIndex( ( blockType ) => currentBlock === blockType.slug );
const previousIndex = currentIndex - 1;
const lowestIndex = 0;

/**
* Default currently for going past the blocks is search, may need to be
* revised in the future as more focusable elements are added. This
* returns a null value, which currently implies that search will be set
* as the next focus.
*/
if ( previousIndex < lowestIndex ) {
return null;
}

// Return the slug of the next block type.
return blockTypes[ previousIndex ].slug;
}

focusNext( component ) {
const sortedByCategory = flow(
this.getVisibleBlocks,
this.sortBlocksByCategory,
)( component.blockTypes );

// If the block list is empty return early.
if ( ! sortedByCategory.length ) {
return;
}

const currentBlock = component.state.currentFocus;

const nextBlock = this.findNext( currentBlock, sortedByCategory );
this.changeMenuSelection( nextBlock );
}

focusPrevious( component ) {
const sortedByCategory = flow(
this.getVisibleBlocks,
this.sortBlocksByCategory,
)( component.blockTypes );
const currentBlock = component.state.currentFocus;

// If the block list is empty return early.
if ( ! sortedByCategory.length ) {
return;
}

const nextBlock = this.findPrevious( currentBlock, sortedByCategory );
this.changeMenuSelection( nextBlock );
}

onKeyDown( keydown ) {
if ( this.isNextKeydown( keydown ) ) {
keydown.preventDefault();
this.focusNext( this );
}

if ( this.isPreviousKeydown( keydown ) ) {
keydown.preventDefault();
this.focusPrevious( this );
}

/**
* Left and right arrow keys need to be handled seperately so that
* default cursor behavior can be handled in the search field.
*/
if ( this.isArrowRight( keydown ) ) {
if ( this.state.currentFocus === 'search' ) {
return;
}
this.focusNext( this );
}

if ( this.isArrowLeft( keydown ) ) {
if ( this.state.currentFocus === 'search' ) {
return;
}
this.focusPrevious( this );
}

if ( this.isEscapeKey( keydown ) ) {
keydown.preventDefault();
this.props.closeMenu();
}
}

changeMenuSelection( refName ) {
if ( refName === null ) {
refName = 'search';
}

this.setState( {
currentFocus: refName
} );

// Focus the DOM node.
this.nodes[ refName ].focus();
}

setSearchFocus() {
this.changeMenuSelection( 'search' );
}

render() {
const { position = 'top' } = this.props;
const blocks = this.blockTypes;
const visibleBlocksByCategory = this.getVisibleBlocksByCategory( blocks );
const categories = this.categories;

return (
<div className={ `editor-inserter__menu is-${ position }` }>
<div className={ `editor-inserter__menu is-${ position }` } tabIndex="0">
<div className="editor-inserter__arrow" />
<div className="editor-inserter__content">
<div role="menu" className="editor-inserter__content">
{ categories
.map( ( category ) => !! blocksByCategory[ category.slug ] && (
.map( ( category ) => !! visibleBlocksByCategory[ category.slug ] && (
<div key={ category.slug }>
<div
className="editor-inserter__separator"
Expand All @@ -69,12 +291,14 @@ class InserterMenu extends wp.element.Component {
tabIndex="0"
aria-labelledby={ `editor-inserter__separator-${ category.slug }-${ this.instanceId }` }
>
{ blocksByCategory[ category.slug ].map( ( { slug, title, icon } ) => (
{ visibleBlocksByCategory[ category.slug ].map( ( { slug, title, icon } ) => (
<button
role="menuitem"
key={ slug }
className="editor-inserter__block"
onClick={ this.selectBlock( slug ) }
ref={ this.bindReferenceNode( slug ) }
tabIndex="-1"
>
<Dashicon icon={ icon } />
{ title }
Expand All @@ -94,6 +318,9 @@ class InserterMenu extends wp.element.Component {
placeholder={ wp.i18n.__( 'Search…' ) }
className="editor-inserter__search"
onChange={ this.filter }
onClick={ this.setSearchFocus }
ref={ this.bindReferenceNode( 'search' ) }
tabIndex="-1"
/>
</div>
);
Expand Down
4 changes: 3 additions & 1 deletion editor/components/inserter/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
outline: none;
transition: color .2s ease;

&:hover {
&:hover,
&:focus {
color: $blue-medium;
}
}
Expand Down Expand Up @@ -145,6 +146,7 @@ input[type=search].editor-inserter__search {
&:focus {
border: 1px solid $dark-gray-500;
outline: none;
position: relative;
}

&:active,
Expand Down

0 comments on commit a72b370

Please sign in to comment.