Skip to content

Commit

Permalink
DOM: Limit single tabbable radio input by name (#14128)
Browse files Browse the repository at this point in the history
* DOM: Limit single tabbable radio input by name

* DOM: Avoid consolidating unnamed radio inputs
  • Loading branch information
aduth authored Mar 15, 2019
1 parent 7779964 commit 1019c17
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/dom/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
49 changes: 48 additions & 1 deletion packages/dom/src/tabbable.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* External dependencies
*/
import { without } from 'lodash';

/**
* Internal dependencies
*/
Expand Down Expand Up @@ -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' || ! name ) {
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
Expand Down Expand Up @@ -84,5 +130,6 @@ export function find( context ) {
.filter( isTabbableIndex )
.map( mapElementToObjectTabbable )
.sort( compareObjectTabbables )
.map( mapObjectTabbableToElement );
.map( mapObjectTabbableToElement )
.reduce( createStatefulCollapseRadioGroup(), [] );
}
96 changes: 96 additions & 0 deletions packages/dom/src/test/tabbable.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,101 @@ 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,
] );
} );

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,
] );
} );
} );
} );

0 comments on commit 1019c17

Please sign in to comment.