* Fix select-control component to preserve currently selected item on focus and de-focus

* No longer auto select option on initial render, pressing tab would only select if selection exists

* Fix to reflect the proper suggested changes

* Attempt to fix unnecessary search reset on pressing tab

* Fix for keyboard interactions and added accompanying tests
This commit is contained in:
Ilyas Foo 2021-05-06 15:07:45 +08:00 committed by GitHub
parent a41fb35045
commit 81573c2b68
6 changed files with 288 additions and 8 deletions

View File

@ -1,5 +1,7 @@
# Unreleased
- SelectControl: automatically scroll to selected options when list is displayed. #6906
- SelectControl: no longer auto selects on rendering list. #6906
- Make `Search` accept synchronous `autocompleter.options`. #6884
- Add new (experimental) collapsible list item to collapse list items. #6869
- SelectControl: fix display of multiple selections without inline tags. #6862

View File

@ -25,10 +25,15 @@ const initialState = { isExpanded: false, isFocused: false, query: '' };
export class SelectControl extends Component {
constructor( props ) {
super( props );
const { selected, options, excludeSelectedOptions } = props;
this.state = {
...initialState,
searchOptions: [],
selectedIndex: 0,
selectedIndex:
selected && options?.length && ! excludeSelectedOptions
? options.findIndex( ( option ) => option.key === selected )
: null,
};
this.bindNode = this.bindNode.bind( this );
@ -50,11 +55,16 @@ export class SelectControl extends Component {
}
reset( selected = this.getSelected() ) {
const { multiple } = this.props;
const { multiple, excludeSelectedOptions } = this.props;
const newState = { ...initialState };
// Reset to the option label if single selection.
// Reset to the option label and selectedIndex if single selection.
if ( ! multiple && selected.length && selected[ 0 ].label ) {
newState.query = selected[ 0 ].label;
newState.selectedIndex = ! excludeSelectedOptions
? this.props.options.findIndex(
( i ) => i.key === selected[ 0 ].key
)
: null;
}
this.setState( newState );
@ -107,6 +117,17 @@ export class SelectControl extends Component {
if ( isSelected === -1 ) {
this.setNewValue( newSelected );
}
// After selecting option, the list will reset and we'd need to correct selectedIndex.
const newSelectedIndex = this.props.excludeSelectedOptions
? // Since we're excluding the selected option, invalidate selection
// so re-focusing wont immediately set it to the neigbouring option.
null
: this.getOptions().findIndex( ( i ) => i.key === option.key );
this.setState( {
selectedIndex: newSelectedIndex,
} );
}
setNewValue( newValue ) {
@ -233,8 +254,9 @@ export class SelectControl extends Component {
{
query,
isFocused: true,
selectedIndex: 0,
searchOptions,
selectedIndex:
query?.length > 0 ? null : this.state.selectedIndex, // Only reset selectedIndex if we're actually searching.
},
() => {
this.setState( {
@ -268,8 +290,9 @@ export class SelectControl extends Component {
this.setState(
{
selectedIndex: 0,
searchOptions,
selectedIndex:
query?.length > 0 ? null : this.state.selectedIndex, // Only reset selectedIndex if we're actually searching.
},
() => {
this.setState( {

View File

@ -100,7 +100,9 @@ class List extends Component {
break;
case ENTER:
this.select( options[ selectedIndex ] );
if ( options[ selectedIndex ] ) {
this.select( options[ selectedIndex ] );
}
event.preventDefault();
event.stopPropagation();
break;
@ -116,9 +118,10 @@ class List extends Component {
return;
case TAB:
this.select( options[ selectedIndex ] );
if ( options[ selectedIndex ] ) {
this.select( options[ selectedIndex ] );
}
setExpanded( false );
onSearch( null );
break;
default:
@ -139,6 +142,10 @@ class List extends Component {
}
componentDidMount() {
const { selectedIndex } = this.props;
if ( selectedIndex > -1 ) {
this.scrollToOption( selectedIndex );
}
this.toggleKeyEvents( true );
}

View File

@ -59,6 +59,7 @@ const SelectControlExample = withState( {
singleSelectedShowAll: [],
multipleSelected: [],
inlineSelected: [],
allOptionsIncludingSelected: options[ options.length - 1 ].key,
} )(
( {
simpleSelected,
@ -67,6 +68,7 @@ const SelectControlExample = withState( {
singleSelectedShowAll,
multipleSelected,
inlineSelected,
allOptionsIncludingSelected,
setState,
} ) => (
<div>
@ -91,6 +93,19 @@ const SelectControlExample = withState( {
selected={ simpleMultipleSelected }
/>
<br />
<SelectControl
label="Show all options with default selected"
onChange={ ( selected ) =>
setState( { allOptionsIncludingSelected: selected } )
}
options={ options }
placeholder="Start typing to filter options..."
selected={ allOptionsIncludingSelected }
showAllOnFocus
isSearchable
excludeSelectedOptions={ false }
/>
<br />
<SelectControl
label="Single value searchable"
isSearchable

View File

@ -201,6 +201,25 @@ describe( 'SelectControl', () => {
expect( getAllByRole( 'option' ) ).toHaveLength( 1 );
} );
it( 'should not automatically select first option on focus', async () => {
const onChangeMock = jest.fn();
const { getByRole, queryByRole } = render(
<SelectControl
isSearchable
showAllOnFocus
options={ options }
onSearch={ () => options }
onFilter={ () => options }
onChange={ onChangeMock }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect( getByRole( 'option', { name: 'bar' } ) ).toBeInTheDocument()
);
expect( queryByRole( 'option', { selected: true } ) ).toBeNull();
} );
describe( 'selected value', () => {
it( 'should return an array if array', async () => {
const onChangeMock = jest.fn();
@ -260,6 +279,72 @@ describe( 'SelectControl', () => {
);
} );
describe( 'prop excludeSelectedOptions', () => {
it( 'should preserve selected option when focused', async () => {
const onChangeMock = jest.fn();
const { getByRole } = render(
<SelectControl
isSearchable
showAllOnFocus
selected={ options[ 2 ].key }
options={ options }
onSearch={ () => options }
onFilter={ () => options }
onChange={ onChangeMock }
excludeSelectedOptions={ false }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
// In browser, the <Button> in <List> component is automatically "selected" when <Control> lost focus.
// I was not able to produce the same behaviour with unit test, but a click on the currently
// selected option should be sufficient to simulate the logic in this test.
userEvent.click( getByRole( 'option', { selected: true } ) );
expect( onChangeMock ).toHaveBeenCalledTimes( 0 );
} );
it( 'should reset selected option if searching', async () => {
const onChangeMock = jest.fn();
const { getByRole, queryByRole } = render(
<SelectControl
isSearchable
showAllOnFocus
selected={ options[ 2 ].key }
options={ options }
onSearch={ () => options }
onFilter={ () => options }
onChange={ onChangeMock }
excludeSelectedOptions={ false }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
userEvent.click( getByRole( 'option', { name: 'bar' } ) );
userEvent.clear( getByRole( 'combobox' ) );
userEvent.type( getByRole( 'combobox' ), 'bar' );
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
expect(
queryByRole( 'option', { selected: true } )
).toBeNull();
} );
} );
it( 'disables the component', async () => {
const { getByRole } = render(
<SelectControl disabled options={ options } />
@ -324,4 +409,151 @@ describe( 'SelectControl', () => {
expect( getByText( options[ 1 ].label ) ).toBeInTheDocument();
} );
describe( 'keyboard interaction', () => {
it( 'pressing keydown on combobox should highlight first option', async () => {
const { getByRole } = render(
<SelectControl
options={ options }
onSearch={ () => options }
onFilter={ () => options }
selected={ null }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
userEvent.type( getByRole( 'combobox' ), '{arrowdown}' );
expect(
getByRole( 'option', { selected: true } ).textContent
).toEqual( options[ 0 ].label );
} );
it( 'pressing keydown on combobox with selected should highlight the next option', async () => {
const { getByRole } = render(
<SelectControl
options={ options }
onSearch={ () => options }
onFilter={ () => options }
selected={ options[ 1 ].key }
excludeSelectedOptions={ false }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
userEvent.type( getByRole( 'combobox' ), '{arrowdown}' );
expect(
getByRole( 'option', { selected: true } ).textContent
).toEqual( options[ 2 ].label );
} );
it( 'pressing keydown on combobox with selected last option should rotate to first option', async () => {
const { getByRole } = render(
<SelectControl
options={ options }
onSearch={ () => options }
onFilter={ () => options }
selected={ options[ options.length - 1 ].key }
excludeSelectedOptions={ false }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
userEvent.type( getByRole( 'combobox' ), '{arrowdown}' );
expect(
getByRole( 'option', { selected: true } ).textContent
).toEqual( options[ 0 ].label );
} );
it( 'pressing keyup on combobox should highlight last option', async () => {
const { getByRole } = render(
<SelectControl
options={ options }
onSearch={ () => options }
onFilter={ () => options }
selected={ null }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
userEvent.type( getByRole( 'combobox' ), '{arrowup}' );
expect(
getByRole( 'option', { selected: true } ).textContent
).toEqual( options[ options.length - 1 ].label );
} );
it( 'pressing tab on combobox should hide option list', async () => {
const { getByRole, queryByRole } = render(
<SelectControl
options={ options }
onSearch={ () => options }
onFilter={ () => options }
selected={ null }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
userEvent.type( getByRole( 'combobox' ), '{tab}' );
expect( queryByRole( 'option', { selected: true } ) ).toBeNull();
} );
it( 'pressing enter should select highlighted option', async () => {
const onChangeMock = jest.fn();
const { getByRole } = render(
<SelectControl
options={ options }
onSearch={ () => options }
onFilter={ () => options }
selected={ null }
excludeSelectedOptions={ false }
onChange={ onChangeMock }
/>
);
getByRole( 'combobox' ).focus();
await waitFor( () =>
expect(
getByRole( 'option', { name: 'bar' } )
).toBeInTheDocument()
);
userEvent.type( getByRole( 'combobox' ), '{arrowdown}{enter}' );
expect( onChangeMock ).toHaveBeenCalled();
} );
} );
} );

View File

@ -119,6 +119,7 @@ Release and roadmap notes are available on the [WooCommerce Developers Blog](htt
- Fix: Get currency from CurrencyContext #6723
- Fix: Correct the left position of transient notices when the new nav is used. #6914
- Fix: Exclude WC Shipping for store that are only offering downloadable products #6917
- Fix: SelectControl focus and de-focus bug #6906
- Performance: Avoid updating customer info synchronously from the front end. #6765
- Tweak: Add settings_section event prop for CES #6762
- Tweak: Refactor payments to allow management of methods #6786