From 19055b2d71d1f94f8325825ef78f354e120663d3 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 26 Feb 2019 16:00:45 -0500 Subject: [PATCH 1/2] DOM: Limit single tabbable radio input by name --- packages/dom/CHANGELOG.md | 1 + packages/dom/src/tabbable.js | 49 ++++++++++++++++++++- packages/dom/src/test/tabbable.js | 72 +++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/packages/dom/CHANGELOG.md b/packages/dom/CHANGELOG.md index eeca6c818528d7..47785fe2827faa 100644 --- a/packages/dom/CHANGELOG.md +++ b/packages/dom/CHANGELOG.md @@ -3,6 +3,7 @@ ### Bug Fix - Update `isHorizontalEdge` to account for empty text nodes. +- `tabbables.find` considers at most a single radio input for a given name. The checked input is given priority, falling back to the first in the tabindex-sorted set if there is no checked input. ## 2.0.8 (2019-01-03) diff --git a/packages/dom/src/tabbable.js b/packages/dom/src/tabbable.js index e70cba256401d1..7a5cbd51aa9231 100644 --- a/packages/dom/src/tabbable.js +++ b/packages/dom/src/tabbable.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { without } from 'lodash'; + /** * Internal dependencies */ @@ -31,6 +36,47 @@ export function isTabbableIndex( element ) { return getTabIndex( element ) !== -1; } +/** + * Returns a stateful reducer function which constructs a filtered array of + * tabbable elements, where at most one radio input is selected for a given + * name, giving priority to checked input, falling back to the first + * encountered. + * + * @return {Function} Radio group collapse reducer. + */ +function createStatefulCollapseRadioGroup() { + const CHOSEN_RADIO_BY_NAME = {}; + + return function collapseRadioGroup( result, element ) { + const { nodeName, type, checked, name } = element; + + // For all non-radio tabbables, construct to array by concatenating. + if ( nodeName !== 'INPUT' || type !== 'radio' ) { + return result.concat( element ); + } + + const hasChosen = CHOSEN_RADIO_BY_NAME.hasOwnProperty( name ); + + // Omit by skipping concatenation if the radio element is not chosen. + const isChosen = checked || ! hasChosen; + if ( ! isChosen ) { + return result; + } + + // At this point, if there had been a chosen element, the current + // element is checked and should take priority. Retroactively remove + // the element which had previously been considered the chosen one. + if ( hasChosen ) { + const hadChosenElement = CHOSEN_RADIO_BY_NAME[ name ]; + result = without( result, hadChosenElement ); + } + + CHOSEN_RADIO_BY_NAME[ name ] = element; + + return result.concat( element ); + }; +} + /** * 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 @@ -84,5 +130,6 @@ export function find( context ) { .filter( isTabbableIndex ) .map( mapElementToObjectTabbable ) .sort( compareObjectTabbables ) - .map( mapObjectTabbableToElement ); + .map( mapObjectTabbableToElement ) + .reduce( createStatefulCollapseRadioGroup(), [] ); } diff --git a/packages/dom/src/test/tabbable.js b/packages/dom/src/test/tabbable.js index 992b9a052ff4e1..4f0955777be248 100644 --- a/packages/dom/src/test/tabbable.js +++ b/packages/dom/src/test/tabbable.js @@ -32,5 +32,77 @@ describe( 'tabbable', () => { third, ] ); } ); + + it( 'consolidates radio group to the first, if unchecked', () => { + const node = createElement( 'div' ); + const firstRadio = createElement( 'input' ); + firstRadio.type = 'radio'; + firstRadio.name = 'a'; + firstRadio.value = 'firstRadio'; + const secondRadio = createElement( 'input' ); + secondRadio.type = 'radio'; + secondRadio.name = 'a'; + secondRadio.value = 'secondRadio'; + const text = createElement( 'input' ); + text.type = 'text'; + text.name = 'b'; + const thirdRadio = createElement( 'input' ); + thirdRadio.type = 'radio'; + thirdRadio.name = 'a'; + thirdRadio.value = 'thirdRadio'; + const fourthRadio = createElement( 'input' ); + fourthRadio.type = 'radio'; + fourthRadio.name = 'b'; + fourthRadio.value = 'fourthRadio'; + const fifthRadio = createElement( 'input' ); + fifthRadio.type = 'radio'; + fifthRadio.name = 'b'; + fifthRadio.value = 'fifthRadio'; + node.appendChild( firstRadio ); + node.appendChild( secondRadio ); + node.appendChild( text ); + node.appendChild( thirdRadio ); + node.appendChild( fourthRadio ); + node.appendChild( fifthRadio ); + + const tabbables = find( node ); + + expect( tabbables ).toEqual( [ + firstRadio, + text, + fourthRadio, + ] ); + } ); + + it( 'consolidates radio group to the checked', () => { + const node = createElement( 'div' ); + const firstRadio = createElement( 'input' ); + firstRadio.type = 'radio'; + firstRadio.name = 'a'; + firstRadio.value = 'firstRadio'; + const secondRadio = createElement( 'input' ); + secondRadio.type = 'radio'; + secondRadio.name = 'a'; + secondRadio.value = 'secondRadio'; + const text = createElement( 'input' ); + text.type = 'text'; + text.name = 'b'; + const thirdRadio = createElement( 'input' ); + thirdRadio.type = 'radio'; + thirdRadio.name = 'a'; + thirdRadio.value = 'thirdRadio'; + thirdRadio.checked = true; + node.appendChild( firstRadio ); + node.appendChild( secondRadio ); + node.appendChild( text ); + node.appendChild( thirdRadio ); + + const tabbables = find( node ); + + expect( tabbables ).toEqual( [ + text, + thirdRadio, + ] ); + } ); } ); } ); From 7f9bbdaa8644fe659860bbe28709b2e1fef32f6d Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Tue, 26 Feb 2019 16:07:11 -0500 Subject: [PATCH 2/2] DOM: Avoid consolidating unnamed radio inputs --- packages/dom/src/tabbable.js | 2 +- packages/dom/src/test/tabbable.js | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/dom/src/tabbable.js b/packages/dom/src/tabbable.js index 7a5cbd51aa9231..315a98b8b2a8e3 100644 --- a/packages/dom/src/tabbable.js +++ b/packages/dom/src/tabbable.js @@ -51,7 +51,7 @@ function createStatefulCollapseRadioGroup() { const { nodeName, type, checked, name } = element; // For all non-radio tabbables, construct to array by concatenating. - if ( nodeName !== 'INPUT' || type !== 'radio' ) { + if ( nodeName !== 'INPUT' || type !== 'radio' || ! name ) { return result.concat( element ); } diff --git a/packages/dom/src/test/tabbable.js b/packages/dom/src/test/tabbable.js index 4f0955777be248..e262fccd86d65e 100644 --- a/packages/dom/src/test/tabbable.js +++ b/packages/dom/src/test/tabbable.js @@ -104,5 +104,29 @@ describe( 'tabbable', () => { thirdRadio, ] ); } ); + + it( 'not consolidate unnamed radio inputs', () => { + const node = createElement( 'div' ); + const firstRadio = createElement( 'input' ); + firstRadio.type = 'radio'; + firstRadio.value = 'firstRadio'; + const text = createElement( 'input' ); + text.type = 'text'; + text.name = 'b'; + const secondRadio = createElement( 'input' ); + secondRadio.type = 'radio'; + secondRadio.value = 'secondRadio'; + node.appendChild( firstRadio ); + node.appendChild( text ); + node.appendChild( secondRadio ); + + const tabbables = find( node ); + + expect( tabbables ).toEqual( [ + firstRadio, + text, + secondRadio, + ] ); + } ); } ); } );