From 2f3b99a755f1e47f13d2289e21691f29d06cab8a Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 9 Aug 2017 18:51:51 -0400 Subject: [PATCH 1/6] Utils: Add focusable search utilities --- utils/focus.js | 92 ++++++++++++++++++++++++++ utils/index.js | 2 + utils/test/focus.js | 158 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 utils/focus.js create mode 100644 utils/test/focus.js diff --git a/utils/focus.js b/utils/focus.js new file mode 100644 index 0000000000000..276bac88c7e79 --- /dev/null +++ b/utils/focus.js @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * Node names for elements which can receive focus + * + * @type {Array} + */ +const FOCUSABLE_NODE_NAMES = [ + 'INPUT', + 'SELECT', + 'TEXTAREA', + 'BUTTON', + 'OBJECT', +]; + +/** + * Returns true if the specified node can receive focus, or false otherwise. + * + * @param {HTMLElement} node Node to check + * @return {Boolean} Whether the node can receive focus + */ +export function isFocusable( node ) { + if ( ! node ) { + return false; + } + + if ( node.tabIndex < 0 ) { + return false; + } else if ( includes( FOCUSABLE_NODE_NAMES, node.nodeName ) ) { + return ! node.disabled; + } else if ( 'A' === node.nodeName || 'AREA' === node.nodeName ) { + return node.hasAttribute( 'href' ); + } + + return node.tabIndex >= 0; +} + +/** + * Returns the first focusable element within the specified node, or undefined + * if there are no focusable elements. + * + * @param {HTMLElement} node Context in which to find focusable + * @return {HTMLElement} First focusable element + */ +export function findFocusable( node ) { + return findFirstFocusable( node.firstChild, node ); +} + +/** + * Returns the first focusable element starting from specified node: + * - If node is focusable, returns node + * - If node contains a descendant which is focusable, returns descendant + * - If node has a focusable sibling, returns sibling + * - Otherwise, continues through parents following same guidelines until + * reaching context. If context is reached, the function returns undefined. + * + * @param {HTMLElement} node Starting node + * @param {?HTMLElement} context Context in which to find focusable + * @return {HTMLElement} First focusable element + */ +export function findFirstFocusable( node, context = document.body ) { + if ( ! node || node === context ) { + return; + } + + // Starting node is focusable? + if ( isFocusable( node ) ) { + return node; + } + + // Traverse into children + if ( node.firstChild ) { + return findFirstFocusable( node.firstChild, context ); + } + + // Find next sibling or parent + let nextSibling; + while ( ! ( nextSibling = node.nextSibling ) ) { + node = node.parentNode; + + // Terminate if reached context + if ( node === context ) { + return; + } + } + + // Find in sibling or parent's sibling + return findFirstFocusable( nextSibling, context ); +} 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 }; diff --git a/utils/test/focus.js b/utils/test/focus.js new file mode 100644 index 0000000000000..b43fecd3978b2 --- /dev/null +++ b/utils/test/focus.js @@ -0,0 +1,158 @@ +/** + * Internal dependencies + */ +import { isFocusable, findFocusable, findFirstFocusable } from '../focus'; + +describe( 'focus', () => { + beforeEach( () => { + document.body.innerHTML = ''; + } ); + + describe( 'isFocusable()', () => { + it( 'returns false if passed falsey value', () => { + expect( isFocusable() ).toBe( false ); + } ); + + it( 'returns false if node has negative tabIndex', () => { + const node = document.createElement( 'input' ); + node.tabIndex = -1; + + expect( isFocusable( node ) ).toBe( false ); + } ); + + it( 'returns false if node is focusable input type and disabled', () => { + const node = document.createElement( 'input' ); + node.disabled = true; + + expect( isFocusable( node ) ).toBe( false ); + } ); + + it( 'returns true if node is focusable input type and not disabled', () => { + const node = document.createElement( 'input' ); + + expect( isFocusable( node ) ).toBe( true ); + } ); + + it( 'returns false if anchor without href', () => { + const node = document.createElement( 'a' ); + + expect( isFocusable( node ) ).toBe( false ); + } ); + + it( 'returns true if anchor with href', () => { + const node = document.createElement( 'a' ); + node.href = 'https://wordpress.org'; + + expect( isFocusable( node ) ).toBe( true ); + } ); + + it( 'returns true if tabindex 0', () => { + const node = document.createElement( 'div' ); + node.tabIndex = 0; + + expect( isFocusable( node ) ).toBe( true ); + } ); + + it( 'returns true if positive tabindex', () => { + const node = document.createElement( 'div' ); + node.tabIndex = 1; + + expect( isFocusable( node ) ).toBe( true ); + } ); + } ); + + describe( 'findFocusable()', () => { + it( 'returns undefined if no children', () => { + const node = document.createElement( 'div' ); + + expect( findFocusable( node ) ).toBe( undefined ); + } ); + + it( 'returns undefined if no focusable children', () => { + const node = document.createElement( 'div' ); + node.appendChild( document.createElement( 'div' ) ); + + expect( findFocusable( node ) ).toBe( undefined ); + } ); + + it( 'finds first focusable child', () => { + const node = document.createElement( 'div' ); + node.appendChild( document.createElement( 'input' ) ); + + expect( findFocusable( node ).nodeName ).toBe( 'INPUT' ); + } ); + + it( 'finds nested first focusable child', () => { + const node = document.createElement( 'div' ); + node.appendChild( document.createElement( 'div' ) ); + node.firstChild.appendChild( document.createElement( 'input' ) ); + + expect( findFocusable( node ).nodeName ).toBe( 'INPUT' ); + } ); + + it( 'does not traverse up to body', () => { + const node = document.createElement( 'div' ); + document.body.appendChild( node ); + document.body.appendChild( document.createElement( 'input' ) ); + + expect( findFocusable( node ) ).toBe( undefined ); + } ); + + it( 'does not return context even if focusable', () => { + const node = document.createElement( 'div' ); + node.tabIndex = 0; + + expect( findFocusable( node ) ).toBe( undefined ); + } ); + } ); + + describe( 'findFirstFocusable()', () => { + it( 'returns undefined if falsey node', () => { + expect( findFirstFocusable() ).toBe( undefined ); + } ); + + it( 'returns node if focusable', () => { + const node = document.createElement( 'input' ); + + expect( findFirstFocusable( node ) ).toBe( node ); + } ); + + it( 'traverses into children to find focusable', () => { + const node = document.createElement( 'div' ); + node.appendChild( document.createElement( 'div' ) ); + node.firstChild.appendChild( document.createElement( 'input' ) ); + + expect( findFirstFocusable( node ).nodeName ).toBe( 'INPUT' ); + } ); + + it( 'traverses through siblings to find focusable', () => { + const node = document.createElement( 'div' ); + node.appendChild( document.createElement( 'div' ) ); + node.appendChild( document.createElement( 'input' ) ); + + expect( findFirstFocusable( node ).nodeName ).toBe( 'INPUT' ); + } ); + + it( 'traverses through parent siblings to find focusable', () => { + const node = document.createElement( 'div' ); + node.appendChild( document.createElement( 'div' ) ); + document.body.appendChild( node ); + document.body.appendChild( document.createElement( 'input' ) ); + + expect( findFirstFocusable( node ).nodeName ).toBe( 'INPUT' ); + } ); + + it( 'returns undefined if nothing focusable', () => { + expect( findFirstFocusable( document.body ) ).toBe( undefined ); + } ); + + it( 'limits found focusables to specific context', () => { + const node = document.createElement( 'div' ); + node.appendChild( document.createElement( 'div' ) ); + document.body.appendChild( node ); + document.body.appendChild( document.createElement( 'input' ) ); + + expect( findFirstFocusable( node, node ) ).toBe( undefined ); + } ); + } ); +} ); From d259b2b177387ba1aab7a44ff13d5b8ed6729f2d Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 9 Aug 2017 18:52:07 -0400 Subject: [PATCH 2/6] Components: Shift focus into popover when opened --- components/popover/index.js | 13 +++++++++++++ utils/focus.js | 4 ++++ utils/test/focus.js | 4 ++++ 3 files changed, 21 insertions(+) diff --git a/components/popover/index.js b/components/popover/index.js index 3deaf18bd8523..c5cb6a747df50 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,13 @@ export class Popover extends Component { window[ handler ]( 'scroll', this.throttledSetOffset ); } + focus() { + const firstFocusable = focus.findFocusable( this.nodes.content ); + if ( firstFocusable ) { + firstFocusable.focus(); + } + } + throttledSetOffset() { this.rafHandle = window.requestAnimationFrame( this.setOffset ); } diff --git a/utils/focus.js b/utils/focus.js index 276bac88c7e79..f63a99aca8b75 100644 --- a/utils/focus.js +++ b/utils/focus.js @@ -46,6 +46,10 @@ export function isFocusable( node ) { * @return {HTMLElement} First focusable element */ export function findFocusable( node ) { + if ( ! node ) { + return; + } + return findFirstFocusable( node.firstChild, node ); } diff --git a/utils/test/focus.js b/utils/test/focus.js index b43fecd3978b2..d1f4758e6d2d2 100644 --- a/utils/test/focus.js +++ b/utils/test/focus.js @@ -62,6 +62,10 @@ describe( 'focus', () => { } ); describe( 'findFocusable()', () => { + it( 'returns undefined if falsey argument passed', () => { + expect( findFocusable() ).toBe( undefined ); + } ); + it( 'returns undefined if no children', () => { const node = document.createElement( 'div' ); From 974e2d5af85c3422bcfe35954bf4a51a8f49537e Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 9 Aug 2017 19:03:01 -0400 Subject: [PATCH 3/6] Inserter: Leverage Popover isOpen to focus search --- editor/inserter/menu.js | 2 -- 1 file changed, 2 deletions(-) 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 }> From 14ddec72d711ea011ad9b645aa3f99cc71ea61f0 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Mon, 14 Aug 2017 15:40:07 -0400 Subject: [PATCH 4/6] Components: Use ally.js for first tabbable selection --- components/popover/index.js | 9 +- package-lock.json | 19 +++++ package.json | 1 + utils/focus.js | 96 --------------------- utils/index.js | 2 - utils/test/focus.js | 162 ------------------------------------ 6 files changed, 25 insertions(+), 264 deletions(-) delete mode 100644 utils/focus.js delete mode 100644 utils/test/focus.js diff --git a/components/popover/index.js b/components/popover/index.js index c5cb6a747df50..30dde60672e0b 100644 --- a/components/popover/index.js +++ b/components/popover/index.js @@ -3,12 +3,12 @@ */ import classnames from 'classnames'; import { isEqual, noop } from 'lodash'; +import queryFirstTabbable from 'ally.js/esm/query/first-tabbable'; /** * WordPress dependencies */ import { createPortal, Component } from '@wordpress/element'; -import { focus } from '@wordpress/utils'; /** * Internal dependencies @@ -92,9 +92,10 @@ export class Popover extends Component { } focus() { - const firstFocusable = focus.findFocusable( this.nodes.content ); - if ( firstFocusable ) { - firstFocusable.focus(); + const context = this.nodes.content; + const firstTabbable = queryFirstTabbable( { context } ); + if ( firstTabbable ) { + firstTabbable.focus(); } } diff --git a/package-lock.json b/package-lock.json index 67463d73d9aa9..3c41af88b1df1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -117,6 +117,15 @@ "repeat-string": "1.6.1" } }, + "ally.js": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/ally.js/-/ally.js-1.4.1.tgz", + "integrity": "sha1-n7fmuljvrE7pExyymqnuO1QLzx4=", + "requires": { + "css.escape": "1.5.1", + "platform": "1.3.3" + } + }, "amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", @@ -1978,6 +1987,11 @@ "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=", "dev": true }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=" + }, "cssom": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.2.tgz", @@ -6179,6 +6193,11 @@ "find-up": "1.1.2" } }, + "platform": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.3.tgz", + "integrity": "sha1-ZGx3ARiZhwtqCQPnXpl+jlHadGE=" + }, "pluralize": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz", diff --git a/package.json b/package.json index f3450843f5339..486abaf77a293 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/focus.js b/utils/focus.js deleted file mode 100644 index f63a99aca8b75..0000000000000 --- a/utils/focus.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * External dependencies - */ -import { includes } from 'lodash'; - -/** - * Node names for elements which can receive focus - * - * @type {Array} - */ -const FOCUSABLE_NODE_NAMES = [ - 'INPUT', - 'SELECT', - 'TEXTAREA', - 'BUTTON', - 'OBJECT', -]; - -/** - * Returns true if the specified node can receive focus, or false otherwise. - * - * @param {HTMLElement} node Node to check - * @return {Boolean} Whether the node can receive focus - */ -export function isFocusable( node ) { - if ( ! node ) { - return false; - } - - if ( node.tabIndex < 0 ) { - return false; - } else if ( includes( FOCUSABLE_NODE_NAMES, node.nodeName ) ) { - return ! node.disabled; - } else if ( 'A' === node.nodeName || 'AREA' === node.nodeName ) { - return node.hasAttribute( 'href' ); - } - - return node.tabIndex >= 0; -} - -/** - * Returns the first focusable element within the specified node, or undefined - * if there are no focusable elements. - * - * @param {HTMLElement} node Context in which to find focusable - * @return {HTMLElement} First focusable element - */ -export function findFocusable( node ) { - if ( ! node ) { - return; - } - - return findFirstFocusable( node.firstChild, node ); -} - -/** - * Returns the first focusable element starting from specified node: - * - If node is focusable, returns node - * - If node contains a descendant which is focusable, returns descendant - * - If node has a focusable sibling, returns sibling - * - Otherwise, continues through parents following same guidelines until - * reaching context. If context is reached, the function returns undefined. - * - * @param {HTMLElement} node Starting node - * @param {?HTMLElement} context Context in which to find focusable - * @return {HTMLElement} First focusable element - */ -export function findFirstFocusable( node, context = document.body ) { - if ( ! node || node === context ) { - return; - } - - // Starting node is focusable? - if ( isFocusable( node ) ) { - return node; - } - - // Traverse into children - if ( node.firstChild ) { - return findFirstFocusable( node.firstChild, context ); - } - - // Find next sibling or parent - let nextSibling; - while ( ! ( nextSibling = node.nextSibling ) ) { - node = node.parentNode; - - // Terminate if reached context - if ( node === context ) { - return; - } - } - - // Find in sibling or parent's sibling - return findFirstFocusable( nextSibling, context ); -} diff --git a/utils/index.js b/utils/index.js index e8bb659b0fd99..6ca563f14c4f6 100644 --- a/utils/index.js +++ b/utils/index.js @@ -1,8 +1,6 @@ -import * as focus from './focus'; import * as keycodes from './keycodes'; import { decodeEntities } from './entities'; -export { focus }; export { keycodes }; export { decodeEntities }; diff --git a/utils/test/focus.js b/utils/test/focus.js deleted file mode 100644 index d1f4758e6d2d2..0000000000000 --- a/utils/test/focus.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Internal dependencies - */ -import { isFocusable, findFocusable, findFirstFocusable } from '../focus'; - -describe( 'focus', () => { - beforeEach( () => { - document.body.innerHTML = ''; - } ); - - describe( 'isFocusable()', () => { - it( 'returns false if passed falsey value', () => { - expect( isFocusable() ).toBe( false ); - } ); - - it( 'returns false if node has negative tabIndex', () => { - const node = document.createElement( 'input' ); - node.tabIndex = -1; - - expect( isFocusable( node ) ).toBe( false ); - } ); - - it( 'returns false if node is focusable input type and disabled', () => { - const node = document.createElement( 'input' ); - node.disabled = true; - - expect( isFocusable( node ) ).toBe( false ); - } ); - - it( 'returns true if node is focusable input type and not disabled', () => { - const node = document.createElement( 'input' ); - - expect( isFocusable( node ) ).toBe( true ); - } ); - - it( 'returns false if anchor without href', () => { - const node = document.createElement( 'a' ); - - expect( isFocusable( node ) ).toBe( false ); - } ); - - it( 'returns true if anchor with href', () => { - const node = document.createElement( 'a' ); - node.href = 'https://wordpress.org'; - - expect( isFocusable( node ) ).toBe( true ); - } ); - - it( 'returns true if tabindex 0', () => { - const node = document.createElement( 'div' ); - node.tabIndex = 0; - - expect( isFocusable( node ) ).toBe( true ); - } ); - - it( 'returns true if positive tabindex', () => { - const node = document.createElement( 'div' ); - node.tabIndex = 1; - - expect( isFocusable( node ) ).toBe( true ); - } ); - } ); - - describe( 'findFocusable()', () => { - it( 'returns undefined if falsey argument passed', () => { - expect( findFocusable() ).toBe( undefined ); - } ); - - it( 'returns undefined if no children', () => { - const node = document.createElement( 'div' ); - - expect( findFocusable( node ) ).toBe( undefined ); - } ); - - it( 'returns undefined if no focusable children', () => { - const node = document.createElement( 'div' ); - node.appendChild( document.createElement( 'div' ) ); - - expect( findFocusable( node ) ).toBe( undefined ); - } ); - - it( 'finds first focusable child', () => { - const node = document.createElement( 'div' ); - node.appendChild( document.createElement( 'input' ) ); - - expect( findFocusable( node ).nodeName ).toBe( 'INPUT' ); - } ); - - it( 'finds nested first focusable child', () => { - const node = document.createElement( 'div' ); - node.appendChild( document.createElement( 'div' ) ); - node.firstChild.appendChild( document.createElement( 'input' ) ); - - expect( findFocusable( node ).nodeName ).toBe( 'INPUT' ); - } ); - - it( 'does not traverse up to body', () => { - const node = document.createElement( 'div' ); - document.body.appendChild( node ); - document.body.appendChild( document.createElement( 'input' ) ); - - expect( findFocusable( node ) ).toBe( undefined ); - } ); - - it( 'does not return context even if focusable', () => { - const node = document.createElement( 'div' ); - node.tabIndex = 0; - - expect( findFocusable( node ) ).toBe( undefined ); - } ); - } ); - - describe( 'findFirstFocusable()', () => { - it( 'returns undefined if falsey node', () => { - expect( findFirstFocusable() ).toBe( undefined ); - } ); - - it( 'returns node if focusable', () => { - const node = document.createElement( 'input' ); - - expect( findFirstFocusable( node ) ).toBe( node ); - } ); - - it( 'traverses into children to find focusable', () => { - const node = document.createElement( 'div' ); - node.appendChild( document.createElement( 'div' ) ); - node.firstChild.appendChild( document.createElement( 'input' ) ); - - expect( findFirstFocusable( node ).nodeName ).toBe( 'INPUT' ); - } ); - - it( 'traverses through siblings to find focusable', () => { - const node = document.createElement( 'div' ); - node.appendChild( document.createElement( 'div' ) ); - node.appendChild( document.createElement( 'input' ) ); - - expect( findFirstFocusable( node ).nodeName ).toBe( 'INPUT' ); - } ); - - it( 'traverses through parent siblings to find focusable', () => { - const node = document.createElement( 'div' ); - node.appendChild( document.createElement( 'div' ) ); - document.body.appendChild( node ); - document.body.appendChild( document.createElement( 'input' ) ); - - expect( findFirstFocusable( node ).nodeName ).toBe( 'INPUT' ); - } ); - - it( 'returns undefined if nothing focusable', () => { - expect( findFirstFocusable( document.body ) ).toBe( undefined ); - } ); - - it( 'limits found focusables to specific context', () => { - const node = document.createElement( 'div' ); - node.appendChild( document.createElement( 'div' ) ); - document.body.appendChild( node ); - document.body.appendChild( document.createElement( 'input' ) ); - - expect( findFirstFocusable( node, node ) ).toBe( undefined ); - } ); - } ); -} ); From 8c86b966f8ea815d5203c5dfdae5e27208b9d60c Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 29 Aug 2017 17:49:16 -0400 Subject: [PATCH 5/6] Utils: Implement focusable utilities by selector --- components/popover/index.js | 10 +- package-lock.json | 19 --- package.json | 1 - utils/focus/focusable.js | 93 +++++++++++++++ utils/focus/index.js | 4 + utils/focus/tabbable.js | 64 +++++++++++ utils/focus/test/focusable.js | 140 +++++++++++++++++++++++ utils/focus/test/tabbable.js | 36 ++++++ utils/focus/test/utils/create-element.js | 45 ++++++++ utils/index.js | 2 + 10 files changed, 391 insertions(+), 23 deletions(-) create mode 100644 utils/focus/focusable.js create mode 100644 utils/focus/index.js create mode 100644 utils/focus/tabbable.js create mode 100644 utils/focus/test/focusable.js create mode 100644 utils/focus/test/tabbable.js create mode 100644 utils/focus/test/utils/create-element.js diff --git a/components/popover/index.js b/components/popover/index.js index 30dde60672e0b..3b268926722f3 100644 --- a/components/popover/index.js +++ b/components/popover/index.js @@ -3,12 +3,12 @@ */ import classnames from 'classnames'; import { isEqual, noop } from 'lodash'; -import queryFirstTabbable from 'ally.js/esm/query/first-tabbable'; /** * WordPress dependencies */ import { createPortal, Component } from '@wordpress/element'; +import { focus } from '@wordpress/utils'; /** * Internal dependencies @@ -92,8 +92,12 @@ export class Popover extends Component { } focus() { - const context = this.nodes.content; - const firstTabbable = queryFirstTabbable( { context } ); + const { content } = this.nodes; + if ( ! content ) { + return; + } + + const firstTabbable = focus.tabbable.find( content )[ 0 ]; if ( firstTabbable ) { firstTabbable.focus(); } diff --git a/package-lock.json b/package-lock.json index 3c41af88b1df1..67463d73d9aa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -117,15 +117,6 @@ "repeat-string": "1.6.1" } }, - "ally.js": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/ally.js/-/ally.js-1.4.1.tgz", - "integrity": "sha1-n7fmuljvrE7pExyymqnuO1QLzx4=", - "requires": { - "css.escape": "1.5.1", - "platform": "1.3.3" - } - }, "amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", @@ -1987,11 +1978,6 @@ "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=", "dev": true }, - "css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=" - }, "cssom": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.2.tgz", @@ -6193,11 +6179,6 @@ "find-up": "1.1.2" } }, - "platform": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.3.tgz", - "integrity": "sha1-ZGx3ARiZhwtqCQPnXpl+jlHadGE=" - }, "pluralize": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz", diff --git a/package.json b/package.json index 486abaf77a293..f3450843f5339 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "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/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 }; From 76875eec10ec6e93121c73dcbdb98a7655280b0c Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 7 Sep 2017 08:43:27 -0400 Subject: [PATCH 6/6] Components: Use wrapper as fallback for popover focus --- components/popover/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/popover/index.js b/components/popover/index.js index 3b268926722f3..027d52ec0c43c 100644 --- a/components/popover/index.js +++ b/components/popover/index.js @@ -92,14 +92,18 @@ export class Popover extends Component { } focus() { - const { content } = this.nodes; + 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(); } }