Skip to content

Commit

Permalink
Merge pull request #2323 from WordPress/fix/popover-open-focus
Browse files Browse the repository at this point in the history
Components: Shift focus into popover when opened
  • Loading branch information
aduth authored Sep 7, 2017
2 parents 344e700 + 76875ee commit c6ceb8b
Show file tree
Hide file tree
Showing 9 changed files with 406 additions and 2 deletions.
22 changes: 22 additions & 0 deletions components/popover/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isEqual, noop } from 'lodash';
* WordPress dependencies
*/
import { createPortal, Component } from '@wordpress/element';
import { focus } from '@wordpress/utils';

/**
* Internal dependencies
Expand All @@ -24,6 +25,7 @@ export class Popover extends Component {
constructor() {
super( ...arguments );

this.focus = this.focus.bind( this );
this.bindNode = this.bindNode.bind( this );
this.setOffset = this.setOffset.bind( this );
this.throttledSetOffset = this.throttledSetOffset.bind( this );
Expand Down Expand Up @@ -58,6 +60,10 @@ export class Popover extends Component {
const { isOpen: prevIsOpen, position: prevPosition } = prevProps;
if ( isOpen !== prevIsOpen ) {
this.toggleWindowEvents( isOpen );

if ( isOpen ) {
this.focus();
}
}

if ( ! isOpen ) {
Expand Down Expand Up @@ -85,6 +91,22 @@ export class Popover extends Component {
window[ handler ]( 'scroll', this.throttledSetOffset );
}

focus() {
const { content, popover } = this.nodes;
if ( ! content ) {
return;
}

// Find first tabbable node within content and shift focus, falling
// back to the popover panel itself.
const firstTabbable = focus.tabbable.find( content )[ 0 ];
if ( firstTabbable ) {
firstTabbable.focus();
} else if ( popover ) {
popover.focus();
}
}

throttledSetOffset() {
this.rafHandle = window.requestAnimationFrame( this.setOffset );
}
Expand Down
2 changes: 0 additions & 2 deletions editor/inserter/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,15 +356,13 @@ export class InserterMenu extends Component {
{ __( 'Search blocks' ) }
</label>
<input
autoFocus
id={ `editor-inserter__search-${ instanceId }` }
type="search"
placeholder={ __( 'Search…' ) }
className="editor-inserter__search"
onChange={ this.filter }
onClick={ this.setSearchFocus }
ref={ this.bindReferenceNode( 'search' ) }
tabIndex="-1"
/>
<div role="menu" className="editor-inserter__content"
ref={ ( ref ) => this.tabContainer = ref }>
Expand Down
93 changes: 93 additions & 0 deletions utils/focus/focusable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* External dependencies
*/
import 'element-closest';

/**
* References:
*
* Focusable:
* - https://www.w3.org/TR/html5/editing.html#focus-management
*
* Sequential focus navigation:
* - https://www.w3.org/TR/html5/editing.html#sequential-focus-navigation-and-the-tabindex-attribute
*
* Disabled elements:
* - https://www.w3.org/TR/html5/disabled-elements.html#disabled-elements
*
* getClientRects algorithm (requiring layout box):
* - https://www.w3.org/TR/cssom-view-1/#extension-to-the-element-interface
*
* AREA elements associated with an IMG:
* - https://w3c.github.io/html/editing.html#data-model
*/

const SELECTOR = [
'[tabindex]',
'a[href]',
'button:not([disabled])',
'input:not([type="hidden"]):not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'iframe',
'object',
'embed',
'area[href]',
'[contenteditable]',
].join( ',' );

/**
* Returns true if the specified element is visible (i.e. neither display: none
* nor visibility: hidden).
*
* @param {Element} element DOM element to test
* @return {Boolean} Whether element is visible
*/
function isVisible( element ) {
return (
element.offsetWidth > 0 ||
element.offsetHeight > 0 ||
element.getClientRects().length > 0
);
}

/**
* Returns true if the specified area element is a valid focusable element, or
* false otherwise. Area is only focusable if within a map where a named map
* referenced by an image somewhere in the document.
*
* @param {Element} element DOM area element to test
* @return {Boolean} Whether area element is valid for focus
*/
function isValidFocusableArea( element ) {
const map = element.closest( 'map[name]' );
if ( ! map ) {
return false;
}

const img = document.querySelector( 'img[usemap="#' + map.name + '"]' );
return !! img && isVisible( img );
}

/**
* Returns all focusable elements within a given context.
*
* @param {Element} context Element in which to search
* @return {Element[]} Focusable elements
*/
export function find( context ) {
const elements = context.querySelectorAll( SELECTOR );

return [ ...elements ].filter( ( element ) => {
if ( ! isVisible( element ) ) {
return false;
}

const { nodeName } = element;
if ( 'AREA' === nodeName ) {
return isValidFocusableArea( element );
}

return true;
} );
}
4 changes: 4 additions & 0 deletions utils/focus/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import * as focusable from './focusable';
import * as tabbable from './tabbable';

export { focusable, tabbable };
64 changes: 64 additions & 0 deletions utils/focus/tabbable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Internal dependencies
*/
import { find as findFocusable } from './focusable';

/**
* Returns true if the specified element is tabbable, or false otherwise.
*
* @param {Element} element Element to test
* @return {Boolean} Whether element is tabbable
*/
function isTabbableIndex( element ) {
return element.tabIndex !== -1;
}

/**
* An array map callback, returning an object with the element value and its
* array index location as properties. This is used to emulate a proper stable
* sort where equal tabIndex should be left in order of their occurrence in the
* document.
*
* @param {Element} element Element
* @param {Number} index Array index of element
* @return {Object} Mapped object with element, index
*/
function mapElementToObjectTabbable( element, index ) {
return { element, index };
}

/**
* An array map callback, returning an element of the given mapped object's
* element value.
*
* @param {Object} object Mapped object with index
* @return {Element} Mapped object element
*/
function mapObjectTabbableToElement( object ) {
return object.element;
}

/**
* A sort comparator function used in comparing two objects of mapped elements.
*
* @see mapElementToObjectTabbable
*
* @param {Object} a First object to compare
* @param {Object} b Second object to compare
* @return {Number} Comparator result
*/
function compareObjectTabbables( a, b ) {
if ( a.element.tabIndex === b.element.tabIndex ) {
return a.index - b.index;
}

return a.element.tabIndex - b.element.tabIndex;
}

export function find( context ) {
return findFocusable( context )
.filter( isTabbableIndex )
.map( mapElementToObjectTabbable )
.sort( compareObjectTabbables )
.map( mapObjectTabbableToElement );
}
140 changes: 140 additions & 0 deletions utils/focus/test/focusable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Internal dependencies
*/
import createElement from './utils/create-element';
import { find } from '../focusable';

describe( 'focusable', () => {
beforeEach( () => {
document.body.innerHTML = '';
} );

describe( 'find()', () => {
it( 'returns empty array if no children', () => {
const node = createElement( 'div' );

expect( find( node ) ).toEqual( [] );
} );

it( 'returns empty array if no focusable children', () => {
const node = createElement( 'div' );
node.appendChild( createElement( 'div' ) );

expect( find( node ) ).toEqual( [] );
} );

it( 'returns array of focusable children', () => {
const node = createElement( 'div' );
node.appendChild( createElement( 'input' ) );

const focusable = find( node );

expect( focusable ).toHaveLength( 1 );
expect( focusable[ 0 ].nodeName ).toBe( 'INPUT' );
} );

it( 'finds nested focusable child', () => {
const node = createElement( 'div' );
node.appendChild( createElement( 'div' ) );
node.firstChild.appendChild( createElement( 'input' ) );

const focusable = find( node );

expect( focusable ).toHaveLength( 1 );
expect( focusable[ 0 ].nodeName ).toBe( 'INPUT' );
} );

it( 'finds link with no href but tabindex', () => {
const node = createElement( 'div' );
const link = createElement( 'a' );
link.tabIndex = 0;
node.appendChild( link );

expect( find( node ) ).toEqual( [ link ] );
} );

it( 'finds valid area focusable', () => {
const map = createElement( 'map' );
map.name = 'testfocus';
const area = createElement( 'area' );
area.href = '';
map.appendChild( area );
const img = createElement( 'img' );
img.setAttribute( 'usemap', '#testfocus' );
document.body.appendChild( map );
document.body.appendChild( img );

const focusable = find( map );

expect( focusable ).toHaveLength( 1 );
expect( focusable[ 0 ].nodeName ).toBe( 'AREA' );
} );

it( 'ignores invalid area focusable', () => {
const map = createElement( 'map' );
map.name = 'testfocus';
const area = createElement( 'area' );
area.href = '';
map.appendChild( area );
const img = createElement( 'img' );
img.setAttribute( 'usemap', '#testfocus' );
img.style.display = 'none';
document.body.appendChild( map );
document.body.appendChild( img );

expect( find( map ) ).toEqual( [] );
} );

it( 'ignores invisible inputs', () => {
const node = createElement( 'div' );
const input = createElement( 'input' );
node.appendChild( input );

input.style.visibility = 'hidden';
expect( find( node ) ).toEqual( [] );

input.style.visibility = 'visible';
input.style.display = 'none';
expect( find( node ) ).toEqual( [] );

input.style.display = 'inline-block';
const focusable = find( node );
expect( focusable ).toHaveLength( 1 );
expect( focusable[ 0 ].nodeName ).toBe( 'INPUT' );
} );

it( 'ignores inputs in invisible ancestors', () => {
const node = createElement( 'div' );
const input = createElement( 'input' );
node.appendChild( input );

node.style.visibility = 'hidden';
expect( find( node ) ).toEqual( [] );

node.style.visibility = 'visible';
node.style.display = 'none';
expect( find( node ) ).toEqual( [] );

node.style.display = 'block';
const focusable = find( node );
expect( focusable ).toHaveLength( 1 );
expect( focusable[ 0 ].nodeName ).toBe( 'INPUT' );
} );

it( 'does not return context even if focusable', () => {
const node = createElement( 'div' );
node.tabIndex = 0;

expect( find( node ) ).toEqual( [] );
} );

it( 'limits found focusables to specific context', () => {
const node = createElement( 'div' );
node.appendChild( createElement( 'div' ) );
document.body.appendChild( node );
document.body.appendChild( createElement( 'input' ) );

expect( find( node ) ).toEqual( [] );
} );
} );
} );
Loading

0 comments on commit c6ceb8b

Please sign in to comment.