Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DOM: Limit single tabbable radio input by name #14128

Merged
merged 2 commits into from
Mar 15, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}

/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: An empty line above maybe

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