diff --git a/blocks/api/registration.js b/blocks/api/registration.js index 689f9ffe297a00..06d284b5d3dd6f 100644 --- a/blocks/api/registration.js +++ b/blocks/api/registration.js @@ -92,7 +92,7 @@ export function registerBlockType( name, settings ) { ); return; } - const block = Object.assign( { name }, settings ); + const block = { name, ...settings }; blocks[ name ] = block; return block; } diff --git a/blocks/library/code/index.js b/blocks/library/code/index.js index d11ff4128aebbc..e2f9bf4cca8ec5 100644 --- a/blocks/library/code/index.js +++ b/blocks/library/code/index.js @@ -49,12 +49,13 @@ registerBlockType( 'core/code', { ], }, - edit( { attributes, setAttributes, className } ) { + edit( { attributes, setAttributes, className, setFocus } ) { return ( setAttributes( { content: event.target.value } ) } + onFocus={ ( ) => setFocus() } placeholder={ __( 'Write codeā€¦' ) } /> ); diff --git a/blocks/library/gallery/index.js b/blocks/library/gallery/index.js index 49ca753fbee6bf..456b02cff402f9 100644 --- a/blocks/library/gallery/index.js +++ b/blocks/library/gallery/index.js @@ -101,6 +101,7 @@ registerBlockType( 'core/gallery', { buttonProps={ { className: 'components-icon-button components-toolbar__control', 'aria-label': __( 'Edit Gallery' ), + tabIndex: -1, } } onSelect={ onSelectImages } type="image" diff --git a/blocks/library/image/block.js b/blocks/library/image/block.js index e6bd5a8f2ad698..571368fc86c8d6 100644 --- a/blocks/library/image/block.js +++ b/blocks/library/image/block.js @@ -116,6 +116,7 @@ class ImageBlock extends Component { buttonProps={ { className: 'components-icon-button components-toolbar__control', 'aria-label': __( 'Edit image' ), + tabIndex: '-1', } } onSelect={ this.onSelectImage } type="image" @@ -124,7 +125,7 @@ class ImageBlock extends Component { - + ) diff --git a/blocks/library/table/table-block.js b/blocks/library/table/table-block.js index 68aef712ca950f..14a28253a89257 100644 --- a/blocks/library/table/table-block.js +++ b/blocks/library/table/table-block.js @@ -122,6 +122,7 @@ export default class TableBlock extends Component { ...control, onClick: () => control.onClick( this.state.editor ), } ) ) } + tabIndex="-1" /> diff --git a/blocks/url-input/button.js b/blocks/url-input/button.js index d58b4c153aee87..ee231efd514036 100644 --- a/blocks/url-input/button.js +++ b/blocks/url-input/button.js @@ -36,7 +36,7 @@ class UrlInputButton extends Component { } render() { - const { url, onChange } = this.props; + const { url, onChange, tabIndex } = this.props; const { expanded } = this.state; return ( @@ -48,6 +48,7 @@ class UrlInputButton extends Component { className={ classnames( 'components-toolbar__control', { 'is-active': url, } ) } + tabIndex={ tabIndex } /> { expanded &&
diff --git a/components/dropdown-menu/test/index.js b/components/dropdown-menu/test/index.js index b52a259b6ef05a..bfb9dafbf1cade 100644 --- a/components/dropdown-menu/test/index.js +++ b/components/dropdown-menu/test/index.js @@ -131,11 +131,12 @@ describe( 'DropdownMenu', () => { assertKeyDown( RIGHT, 1 ); assertKeyDown( DOWN, 2 ); - assertKeyDown( DOWN, 3 ); + assertKeyDown( TAB, 3 ); assertKeyDown( DOWN, 0 ); // Reset to beginning assertKeyDown( DOWN, 1 ); assertKeyDown( LEFT, 0 ); assertKeyDown( UP, 3 ); // Reset to end + assertKeyDown( TAB, 0 ); } ); it( 'should close menu on escape', () => { @@ -166,21 +167,5 @@ describe( 'DropdownMenu', () => { expect( wrapper.state( 'open' ) ).toBe( false ); } ); - - it( 'should close menu on tab', () => { - const wrapper = shallow( ); - - // Open menu - wrapper.find( '> IconButton' ).simulate( 'click' ); - - // Close menu by tab - wrapper.simulate( 'keydown', { - stopPropagation: () => {}, - preventDefault: () => {}, - keyCode: TAB, - } ); - - expect( wrapper.state( 'open' ) ).toBe( false ); - } ); } ); } ); diff --git a/components/toolbar/index.js b/components/toolbar/index.js index 21f8bd1c84f980..46cf84c1cb073a 100644 --- a/components/toolbar/index.js +++ b/components/toolbar/index.js @@ -33,6 +33,7 @@ function Toolbar( { controls = [], children, className } ) { className={ setIndex > 0 && controlIndex === 0 ? 'has-left-divider' : null } > ); diff --git a/editor/block-settings-menu/index.js b/editor/block-settings-menu/index.js index d6121c7c290b4b..21928d8ff418ec 100644 --- a/editor/block-settings-menu/index.js +++ b/editor/block-settings-menu/index.js @@ -32,12 +32,14 @@ function BlockSettingsMenu( { onDelete, onSelect, isSidebarOpened, toggleSidebar onClick={ toggleInspector } icon="admin-generic" label={ __( 'Show inspector' ) } + tabIndex="-1" /> ); diff --git a/editor/block-switcher/index.js b/editor/block-switcher/index.js index 1dc86eccf5a670..f8a5516d5fe4ad 100644 --- a/editor/block-switcher/index.js +++ b/editor/block-switcher/index.js @@ -3,14 +3,13 @@ */ import { connect } from 'react-redux'; import { uniq, get, reduce, find } from 'lodash'; -import clickOutside from 'react-click-outside'; /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; -import { Dashicon, IconButton } from '@wordpress/components'; +import { Toolbar, DropdownMenu } from '@wordpress/components'; import { getBlockType, getBlockTypes, switchToBlockType } from '@wordpress/blocks'; /** @@ -21,33 +20,9 @@ import { replaceBlocks } from '../actions'; import { getBlock } from '../selectors'; class BlockSwitcher extends Component { - constructor() { - super( ...arguments ); - this.toggleMenu = this.toggleMenu.bind( this ); - this.state = { - open: false, - }; - } - - handleClickOutside() { - if ( ! this.state.open ) { - return; - } - - this.toggleMenu(); - } - - toggleMenu() { - this.setState( ( state ) => ( { - open: ! state.open, - } ) ); - } switchBlockType( name ) { return () => { - this.setState( { - open: false, - } ); this.props.onTransform( this.props.block, name ); }; } @@ -72,38 +47,21 @@ class BlockSwitcher extends Component { } return ( -
- - - - { this.state.open && -
- { allowedBlocks.map( ( { name, title, icon } ) => ( - - { title } - - ) ) } -
- } -
+ +
  • + ( { + icon, + title, + onClick: this.switchBlockType( name ), + } ) ) } + tabIndex="-1" + /> +
  • +
    ); } } @@ -120,4 +78,4 @@ export default connect( ) ); }, } ) -)( clickOutside( BlockSwitcher ) ); +)( BlockSwitcher ); diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index e4860699490ed1..d272e074936270 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -4,8 +4,9 @@ import { connect } from 'react-redux'; import classnames from 'classnames'; import { Slot } from 'react-slot-fill'; -import { partial } from 'lodash'; +import { filter, findIndex, flatMap, partial } from 'lodash'; import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'; +import 'element-closest'; /** * WordPress dependencies @@ -15,6 +16,7 @@ import { IconButton, Toolbar } from '@wordpress/components'; import { keycodes } from '@wordpress/utils'; import { getBlockType, getBlockDefaultClassname, createBlock } from '@wordpress/blocks'; import { __, sprintf } from '@wordpress/i18n'; +import queryFirstTabbable from 'ally.js/esm/query/first-tabbable'; /** * Internal dependencies @@ -52,13 +54,27 @@ import { getMultiSelectedBlockUids, } from '../../selectors'; -const { BACKSPACE, ESCAPE, DELETE, ENTER } = keycodes; +const { BACKSPACE, ESCAPE, DELETE, ENTER, TAB, F10, UP, LEFT, DOWN, RIGHT } = keycodes; function FirstChild( { children } ) { const childrenArray = Children.toArray( children ); return childrenArray[ 0 ] || null; } +function queryFirstTabbableChild( elem ) { + for ( let i = 0; i < elem.childNodes.length; i++ ) { + const tabbableNode = queryFirstTabbable( { context: elem.childNodes[ i ] } ); + if ( tabbableNode ) { + return tabbableNode; + } + } + return null; +} + +function isToolbar( target ) { + return target.closest( '.components-toolbar, .editor-block-settings-menu, .editor-block-mover' ) !== null; +} + class VisualEditorBlock extends Component { constructor() { super( ...arguments ); @@ -81,11 +97,23 @@ class VisualEditorBlock extends Component { this.previousOffset = null; this.state = { + focusableBorder: true, showMobileControls: false, error: null, }; } + updateBlockBorderInFocusCycle() { + if ( ! this.node ) { + return; + } + const nextTabItem = queryFirstTabbableChild( this.node ); + const needsFocusableBorder = nextTabItem === null; + if ( this.state.focusableBorder !== needsFocusableBorder ) { + this.setState( { focusableBorder: needsFocusableBorder } ); + } + } + componentDidMount() { if ( this.props.focus ) { this.node.focus(); @@ -94,6 +122,7 @@ class VisualEditorBlock extends Component { if ( this.props.isTyping ) { document.addEventListener( 'mousemove', this.stopTypingOnMouseMove ); } + this.updateBlockBorderInFocusCycle(); } componentWillReceiveProps( newProps ) { @@ -117,8 +146,18 @@ class VisualEditorBlock extends Component { } // Focus node when focus state is programmatically transferred. - if ( this.props.focus && ! prevProps.focus && ! this.node.contains( document.activeElement ) ) { - this.node.focus(); + if ( this.props.focus ) { + const prevFocusToolbar = prevProps.focus && prevProps.focus.toolbar; + if ( this.props.focus.toolbar ) { + if ( ! prevFocusToolbar ) { + // we want the first toolbar item that is focusable but not in the default tab cycle + this.focusToolbarItem(); + } + } else if ( ! prevProps.focus && ! this.node.contains( document.activeElement ) ) { + this.node.focus(); + } else if ( prevFocusToolbar ) { + this.node.focus(); + } } // Bind or unbind mousemove from page when user starts or stops typing @@ -129,6 +168,7 @@ class VisualEditorBlock extends Component { this.removeStopTypingListener(); } } + this.updateBlockBorderInFocusCycle(); } componentWillUnmount() { @@ -195,6 +235,7 @@ class VisualEditorBlock extends Component { onRemove, onFocus, onDeselect, + onStartTyping, } = this.props; // Remove block on backspace. @@ -212,7 +253,13 @@ class VisualEditorBlock extends Component { // Deselect on escape. if ( ESCAPE === keyCode ) { - onDeselect(); + if ( isToolbar( target ) ) { + onFocus( uid, {} ); + } else if ( ! this.props.isTyping ) { + onStartTyping(); + } else { + onDeselect(); + } } } @@ -257,6 +304,10 @@ class VisualEditorBlock extends Component { onKeyDown( event ) { const { keyCode, target } = event; + this.handleToolbarTabCycle( event ); + this.handleToolbarArrowCycle( event ); + this.handleToolbarF10Selection( event ); + if ( ENTER === keyCode && target === this.node ) { event.preventDefault(); @@ -270,6 +321,104 @@ class VisualEditorBlock extends Component { this.removeOrDeselect( event ); } + focusToolbarItem( after, reverseOrder ) { + const block = this.node; + const isVisible = ( elem ) => elem && ! ( elem.offsetWidth <= 0 || elem.offsetHeight <= 0 ); + const allToolbars = filter( block.querySelectorAll( '.components-toolbar' ), isVisible ); + const settingsMenu = filter( [ block.querySelector( '.editor-block-settings-menu' ) ], isVisible ); + const moverMenu = filter( [ block.querySelector( '.editor-block-mover' ) ], isVisible ); + const allCycle = [ ...allToolbars, ...settingsMenu, ...moverMenu ]; + + if ( reverseOrder ) { + allCycle.reverse(); + } + + let startIndex = 0; + if ( after ) { + const currIndex = findIndex( allCycle, ( node ) => node.contains( after ) ); + if ( currIndex === -1 ) { + return false; + } + startIndex = currIndex + 1; + } + + // try all toolbars after this one to find the first with a focusable item + for ( let i = 0; i < allCycle.length; i++ ) { + const nextToolbar = allCycle[ ( startIndex + i ) % allCycle.length ]; + + const firstFocusableItem = nextToolbar.querySelector( '*[tabindex="-1"]:not(:disabled)' ); + + if ( firstFocusableItem && isVisible( firstFocusableItem ) ) { + firstFocusableItem.focus(); + return true; + } + } + // nothing focusable + return false; + } + + handleToolbarTabCycle( event ) { + const { keyCode, shiftKey, target } = event; + if ( keyCode !== TAB || ! this.node ) { + return; + } + + if ( this.focusToolbarItem( target, shiftKey ) ) { + // we have a toolbar selected so we should be tabbing between toolbars + event.preventDefault(); + event.stopPropagation(); + } + } + + handleToolbarArrowCycle( event ) { + const { keyCode, target } = event; + if ( ! ( keyCode === UP || keyCode === LEFT || keyCode === DOWN || keyCode === RIGHT ) || ! this.node ) { + return; + } + const forward = keyCode === DOWN || keyCode === RIGHT; + const block = this.node; + const isVisible = ( elem ) => elem && ! ( elem.offsetWidth <= 0 || elem.offsetHeight <= 0 ); + const allToolbars = filter( block.querySelectorAll( '.components-toolbar' ), isVisible ); + const settingsMenu = filter( [ block.querySelector( '.editor-block-settings-menu' ) ], isVisible ); + const moverMenu = filter( [ block.querySelector( '.editor-block-mover' ) ], isVisible ); + const allCycle = flatMap( [ ...allToolbars, ...settingsMenu, ...moverMenu ], ( toolbar ) => { + return filter( toolbar.querySelectorAll( '*[tabindex="-1"]:not(:disabled)' ), isVisible ); + } ); + + if ( ! forward ) { + allCycle.reverse(); + } + + const targetIndex = allCycle.indexOf( target ); + + if ( targetIndex === -1 ) { + return; + } + + // we have a toolbar selected so we should be moving between toolbars + event.preventDefault(); + event.stopPropagation(); + + if ( allCycle.length > 1 ) { + const nextItem = ( targetIndex + 1 >= allCycle.length ) ? allCycle[ 0 ] : allCycle[ targetIndex + 1 ]; + if ( nextItem ) { + nextItem.focus(); + } + } + } + + handleToolbarF10Selection( event ) { + const { keyCode } = event; + if ( keyCode === F10 && ! ( event.altKey || event.ctrlKey || event.shiftKey || event.metaKey ) ) { + event.preventDefault(); + event.stopPropagation(); + if ( this.props.isTyping ) { + this.props.onStopTyping(); + } + this.props.onFocus( this.props.uid, { toolbar: true } ); + } + } + toggleMobileControls() { this.setState( { showMobileControls: ! this.state.showMobileControls, @@ -305,7 +454,7 @@ class VisualEditorBlock extends Component { // Generate the wrapper class names handling the different states of the block. const { isHovered, isSelected, isMultiSelected, isFirstMultiSelected, focus } = this.props; const showUI = isSelected && ( ! this.props.isTyping || focus.collapsed === false ); - const { error, showMobileControls } = this.state; + const { error, showMobileControls, focusableBorder } = this.state; const wrapperClassname = classnames( 'editor-visual-editor__block', { 'has-warning': ! isValid || !! error, 'is-selected': showUI, @@ -339,7 +488,7 @@ class VisualEditorBlock extends Component { onMouseLeave={ onMouseLeave } className={ wrapperClassname } data-type={ block.name } - tabIndex="0" + tabIndex={ focusableBorder ? 0 : null } aria-label={ blockLabel } { ...wrapperProps } > diff --git a/package.json b/package.json index 37a1140f54ecd6..68bb9557ca9b99 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@wordpress/a11y": "0.1.0-beta.1", "@wordpress/url": "0.1.0-beta.1", + "ally.js": "^1.4.1", "classnames": "2.2.5", "clipboard": "1.7.1", "dom-react": "2.2.0", diff --git a/utils/keycodes.js b/utils/keycodes.js index fdafef005fd969..485e71fb2a7041 100644 --- a/utils/keycodes.js +++ b/utils/keycodes.js @@ -8,3 +8,4 @@ export const UP = 38; export const RIGHT = 39; export const DOWN = 40; export const DELETE = 46; +export const F10 = 121;