Skip to content

Commit

Permalink
Fixes #515. Add keyboard navigation to the inserter. This introduces
Browse files Browse the repository at this point in the history
RxJS as a library, which could be very useful for other
aspects of this project. Accessibility review would be awesome.
  • Loading branch information
BE-Webdesign committed May 2, 2017
1 parent 955656e commit c655685
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 26 deletions.
1 change: 1 addition & 0 deletions editor/components/button/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function Button( { isPrimary, isLarge, isToggled, className, ...additionalProps
<button
type="button"
{ ...additionalProps }
ref={ additionalProps.buttonRef }
className={ classes } />
);
}
Expand Down
83 changes: 83 additions & 0 deletions editor/components/inserter/helpers/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Helpers associated with the Inserter Component.
*
* This file contains a group of functions that help in managing the inserter.
*/

export const isShownBlock = state => block => block.title.toLowerCase().indexOf( state.filterValue.toLowerCase() ) !== -1;

export const listVisibleBlocksByCategory = ( state ) => ( blocks ) => {
return blocks.reduce( ( groups, block ) => {
if ( ! isShownBlock( state )( block ) ) {
return groups;
}
if ( ! groups[ block.category ] ) {
groups[ block.category ] = [];
}
groups[ block.category ].push( block );
return groups;
}, {} );
};

const flattenObjectIntoArray = ( object ) => {
let list = [];
for ( const key in object ) {
list.push( object[ key ] );
}
list = list.reduce( ( values, objectValue ) => values.concat( objectValue ), [] );

return list;
};

const nextFocusableRef = ( component ) => {
const visibleBlocksByCategory = flattenObjectIntoArray( listVisibleBlocksByCategory( component.state )( component.blockTypes ) );
const focusables = visibleBlocksByCategory.map( blockType => blockType.slug ).concat( 'search' );

if ( null === component.state.focusedElementRef ) {
return focusables[ 0 ];
}

const currentIndex = focusables.findIndex( ( elementRef ) => elementRef === component.state.focusedElementRef );
const nextIndex = currentIndex + 1;
const highestIndex = focusables.length - 1;

// Check boundary so that the index is does not exceed the length.
if ( nextIndex > highestIndex ) {
// Cycle back to other end.
return focusables[ 0 ];
}

return focusables[ nextIndex ];
};

const previousFocusableRef = ( component ) => {
const visibleBlocksByCategory = flattenObjectIntoArray( listVisibleBlocksByCategory( component.state )( component.blockTypes ) );
const focusables = visibleBlocksByCategory.map( blockType => blockType.slug ).concat( 'search' );

// Initiate the menu with the first block.
if ( null === component.state.focusedElementRef ) {
return focusables[ 0 ];
}

const currentIndex = focusables.findIndex( ( elementRef ) => elementRef === component.state.focusedElementRef );
const nextIndex = currentIndex - 1;
const lowestIndex = 0;

// Check boundary so that the index is does not exceed the length.
if ( nextIndex < lowestIndex ) {
// Cycle back to other end.
return focusables[ focusables.length - 1 ];
}

return focusables[ nextIndex ];
};

export const focusNextFocusableRef = ( component ) => {
const next = nextFocusableRef( component );
component.changeMenuSelection( next );
};

export const focusPreviousFocusableRef = ( component ) => {
const previous = previousFocusableRef( component );
component.changeMenuSelection( previous );
};
13 changes: 11 additions & 2 deletions editor/components/inserter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import IconButton from 'components/icon-button';
class Inserter extends wp.element.Component {
constructor() {
super( ...arguments );
this.nodes = {};
this.toggle = this.toggle.bind( this );
this.close = this.close.bind( this );
this.state = {
Expand All @@ -15,6 +16,10 @@ class Inserter extends wp.element.Component {
}

toggle() {
if ( this.state.opened === true ) {
this.nodes.toggle.focus();
}

this.setState( {
opened: ! this.state.opened
} );
Expand All @@ -36,8 +41,12 @@ class Inserter extends wp.element.Component {
icon="insert"
label={ wp.i18n.__( 'Insert block' ) }
onClick={ this.toggle }
className="editor-inserter__toggle" />
{ opened && <InserterMenu position={ position } onSelect={ this.close } /> }
className="editor-inserter__toggle"
aria-haspopup="true"
id="inserter-toggle"
buttonRef={ ( node ) => this.nodes.toggle = node }
/>
{ opened && <InserterMenu position={ position } onSelect={ this.close } closeMenu={ this.toggle } /> }
</div>
);
}
Expand Down
116 changes: 97 additions & 19 deletions editor/components/inserter/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,83 @@
* External dependencies
*/
import { connect } from 'react-redux';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/fromEvent';
import 'rxjs/add/operator/filter';

/**
* Internal dependencies
*/
import './style.scss';
import Dashicon from 'components/dashicon';
import { focusNextFocusableRef, focusPreviousFocusableRef, listVisibleBlocksByCategory } from './helpers';

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: '',
focusedElementRef: null
};
this.filter = this.filter.bind( this );
this.isShownBlock = this.isShownBlock.bind( this );
this.changeMenuSelection = this.changeMenuSelection.bind( this );
this.setSearchFocus = this.setSearchFocus.bind( this );
this.bindReferenceNode = this.bindReferenceNode.bind( this );
}

bindReferenceNode( nodeName ) {
const binder = ( node ) => this.nodes[ nodeName ] = node;
binder.bind( this );

return binder;
}

componentDidMount() {
// Build a stream of keydown events on component mount.
this.keyStream$ = Observable.fromEvent( document, 'keydown' );

// Out of keydown stream build a stream matching arrow down and arrow right.
this.focusNextFocusableRef$ = this.keyStream$.filter( keydown => keydown.code === 'ArrowDown' || keydown.code === 'ArrowRight' || ( keydown.code === 'Tab' && keydown.shiftKey === false ) );

// Out of keydown stream build a stream matching arrow left and arrow up.
this.focusPreviousFocusableRef$ = this.keyStream$.filter( keydown => keydown.code === 'ArrowUp' || keydown.code === 'ArrowLeft' || ( keydown.code === 'Tab' && keydown.shiftKey === true ) );

// Get escape key presses.
this.closeInserter$ = this.keyStream$.filter( keydown => keydown.code === 'Escape' );

// Create subscriptions for our next and previous blockType focus actions.
this.nextBlockSubscription = this.focusNextFocusableRef$.subscribe( ( keydown ) => {
keydown.preventDefault();
focusNextFocusableRef( this );
} );
this.previousBlockSubscription = this.focusPreviousFocusableRef$.subscribe( ( keydown ) => {
keydown.preventDefault();
focusPreviousFocusableRef( this );
} );
this.closeInserterSubscription = this.closeInserter$.subscribe( () => {
this.props.closeMenu();
} );
}

componentWillUnmount() {
// Unsubscribe from the stream.
this.nextBlockSubscription.unsubscribe();
this.previousBlockSubscription.unsubscribe();
this.closeInserterSubscription.unsubscribe();

// These deletes are most likely not necessary but memory leaks can be pretty ugly.
delete this.nextBlockSubscription;
delete this.previousBlockSubscription;
delete this.closeInserterSubscription;
delete this.closeInserter$;
delete this.focusPreviousFocusableRef$;
delete this.focusNextFocusableRef$;
delete this.keyStreamSubscription;
delete this.keyStream$;
}

filter( event ) {
Expand All @@ -24,44 +87,57 @@ class InserterMenu extends wp.element.Component {
} );
}

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

selectBlock( slug ) {
return () => {
this.props.onInsertBlock( slug );
this.props.onSelect();
this.setState( { filterValue: '' } );
this.setState( {
filterValue: '',
focusedElementRef: null
} );
};
}

changeMenuSelection( refName ) {
this.setState( {
focusedElementRef: refName
} );

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

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

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 ] = [];
}
groups[ block.category ].push( block );
return groups;
}, {} );
const categories = wp.blocks.getCategories();
const blocks = this.blockTypes;
const visibleBlocksByCategory = listVisibleBlocksByCategory( this.state )( 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">{ category.title }</div>
<div className="editor-inserter__category-blocks">
{ blocksByCategory[ category.slug ].map( ( { slug, title, icon } ) => (
{ visibleBlocksByCategory[ category.slug ].map( ( { slug, title, icon } ) => (
<button
key={ slug }
className="editor-inserter__block"
onClick={ this.selectBlock( slug ) }
role="menuitem"
ref={ this.bindReferenceNode( slug ) }
tabIndex="-1"
>
<Dashicon icon={ icon } />
{ title }
Expand All @@ -77,6 +153,8 @@ class InserterMenu extends wp.element.Component {
placeholder={ wp.i18n.__( 'Search…' ) }
className="editor-inserter__search"
onChange={ this.filter }
onClick={ this.setSearchFocus }
ref={ this.bindReferenceNode( 'search' ) }
/>
</div>
);
Expand Down
7 changes: 5 additions & 2 deletions editor/components/inserter/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
padding: 0;
transition: color .2s ease;

&:hover {
&:hover,
&:focus {
color: $blue-medium;
}
}
Expand Down Expand Up @@ -142,9 +143,11 @@ input[type=search].editor-inserter__search {
border: 1px solid transparent;
background: none;

&:hover {
&:hover,
&:focus {
border: 1px solid $dark-gray-500;
position: relative;
outline: 0;
}

&:active,
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@
"style-loader": "^0.14.1",
"tinymce": "^4.5.6",
"webpack": "^2.2.1",
"webpack-node-externals": "^1.5.4"
"webpack-node-externals": "^1.5.4",
"webpack-rxjs-externals": "^1.0.0"
},
"dependencies": {
"classnames": "^2.2.5",
Expand All @@ -69,6 +70,7 @@
"react-redux": "^5.0.4",
"react-slot-fill": "^1.0.0-alpha.11",
"redux": "^3.6.0",
"rxjs": "^5.3.0",
"uuid": "^3.0.1"
}
}
7 changes: 5 additions & 2 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const config = {
externals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-dom/server': 'ReactDOMServer'
'react-dom/server': 'ReactDOMServer',
},
resolve: {
alias: {
Expand Down Expand Up @@ -104,7 +104,10 @@ switch ( process.env.NODE_ENV ) {
'./editor/index.js',
...glob.sync( `./{${ Object.keys( config.entry ).join() }}/**/test/*.js` )
];
config.externals = [ require( 'webpack-node-externals' )() ];
config.externals = [
require( 'webpack-node-externals' )(),
require( 'webpack-rxjs-externals' )()
];
config.output = {
filename: 'build/test.js',
path: __dirname
Expand Down

0 comments on commit c655685

Please sign in to comment.