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 };