diff --git a/components/popover/index.js b/components/popover/index.js
index 3deaf18bd8523..027d52ec0c43c 100644
--- a/components/popover/index.js
+++ b/components/popover/index.js
@@ -8,6 +8,7 @@ import { isEqual, noop } from 'lodash';
* WordPress dependencies
*/
import { createPortal, Component } from '@wordpress/element';
+import { focus } from '@wordpress/utils';
/**
* Internal dependencies
@@ -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 );
@@ -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 ) {
@@ -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 );
}
diff --git a/editor/inserter/menu.js b/editor/inserter/menu.js
index a249cfc9cfb9c..5ad4ec4ee0dba 100644
--- a/editor/inserter/menu.js
+++ b/editor/inserter/menu.js
@@ -356,7 +356,6 @@ export class InserterMenu extends Component {
{ __( 'Search blocks' ) }
this.tabContainer = ref }>
diff --git a/utils/focus/focusable.js b/utils/focus/focusable.js
new file mode 100644
index 0000000000000..58e29c58d968d
--- /dev/null
+++ b/utils/focus/focusable.js
@@ -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;
+ } );
+}
diff --git a/utils/focus/index.js b/utils/focus/index.js
new file mode 100644
index 0000000000000..5dfbfba81bfa7
--- /dev/null
+++ b/utils/focus/index.js
@@ -0,0 +1,4 @@
+import * as focusable from './focusable';
+import * as tabbable from './tabbable';
+
+export { focusable, tabbable };
diff --git a/utils/focus/tabbable.js b/utils/focus/tabbable.js
new file mode 100644
index 0000000000000..72ce5981d4488
--- /dev/null
+++ b/utils/focus/tabbable.js
@@ -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 );
+}
diff --git a/utils/focus/test/focusable.js b/utils/focus/test/focusable.js
new file mode 100644
index 0000000000000..f21991936a163
--- /dev/null
+++ b/utils/focus/test/focusable.js
@@ -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( [] );
+ } );
+ } );
+} );
diff --git a/utils/focus/test/tabbable.js b/utils/focus/test/tabbable.js
new file mode 100644
index 0000000000000..992b9a052ff4e
--- /dev/null
+++ b/utils/focus/test/tabbable.js
@@ -0,0 +1,36 @@
+/**
+ * Internal dependencies
+ */
+import createElement from './utils/create-element';
+import { find } from '../tabbable';
+
+describe( 'tabbable', () => {
+ beforeEach( () => {
+ document.body.innerHTML = '';
+ } );
+
+ describe( 'find()', () => {
+ it( 'returns focusables in order of tabindex', () => {
+ const node = createElement( 'div' );
+ const absent = createElement( 'input' );
+ absent.tabIndex = -1;
+ const first = createElement( 'input' );
+ const second = createElement( 'span' );
+ second.tabIndex = 0;
+ const third = createElement( 'input' );
+ third.tabIndex = 1;
+ node.appendChild( third );
+ node.appendChild( first );
+ node.appendChild( second );
+ node.appendChild( absent );
+
+ const tabbables = find( node );
+
+ expect( tabbables ).toEqual( [
+ first,
+ second,
+ third,
+ ] );
+ } );
+ } );
+} );
diff --git a/utils/focus/test/utils/create-element.js b/utils/focus/test/utils/create-element.js
new file mode 100644
index 0000000000000..c1ef419c7def2
--- /dev/null
+++ b/utils/focus/test/utils/create-element.js
@@ -0,0 +1,45 @@
+/**
+ * Given an element type, returns an HTMLElement with an emulated layout,
+ * since JSDOM does have its own internal layout engine.
+ *
+ * @param {String} type Element type
+ * @return {HTMLElement} Layout-emulated element
+ */
+export default function createElement( type ) {
+ const element = document.createElement( type );
+
+ const ifNotHidden = ( value, elseValue ) => function() {
+ let isHidden = false;
+ let node = this;
+ do {
+ isHidden = (
+ node.style.display === 'none' ||
+ node.style.visibility === 'hidden'
+ );
+
+ node = node.parentNode;
+ } while ( ! isHidden && node && node.nodeType === window.Node.ELEMENT_NODE );
+
+ return isHidden ? elseValue : value;
+ };
+
+ Object.defineProperties( element, {
+ offsetHeight: {
+ get: ifNotHidden( 10, 0 ),
+ },
+ offsetWidth: {
+ get: ifNotHidden( 10, 0 ),
+ },
+ } );
+
+ element.getClientRects = ifNotHidden( [ {
+ width: 10,
+ height: 10,
+ top: 0,
+ right: 10,
+ bottom: 10,
+ left: 0,
+ } ], [] );
+
+ return element;
+}
diff --git a/utils/index.js b/utils/index.js
index 6ca563f14c4f6..e8bb659b0fd99 100644
--- a/utils/index.js
+++ b/utils/index.js
@@ -1,6 +1,8 @@
+import * as focus from './focus';
import * as keycodes from './keycodes';
import { decodeEntities } from './entities';
+export { focus };
export { keycodes };
export { decodeEntities };