Replace select controls with new SelectControl (Autocomplete) component (https://github.com/woocommerce/woocommerce-admin/pull/2997)

* Rename Autocomplete component to SelectControl

* Add isSearchable prop to SelectControl

* Remove SimpleSelectControl component

* Refactor list expansion and key behavior

* Bump changelog and version
This commit is contained in:
Joshua T Flowers 2019-10-08 06:42:32 +08:00 committed by GitHub
parent 19a69b7789
commit 1563971836
24 changed files with 725 additions and 968 deletions

View File

@ -4,10 +4,15 @@
*/
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import { SelectControl, TextControl } from 'newspack-components';
import { TextControl } from 'newspack-components';
import { useMemo } from 'react';
import { getSetting } from '@woocommerce/wc-admin-settings';
/**
* Internal depdencies
*/
import { SelectControl } from '@woocommerce/components';
const { countries } = getSetting( 'dataEndpoints', { countries: {} } );
/**
* Form validation.
@ -43,7 +48,7 @@ export function getCountryStateOptions() {
const countryStateOptions = countries.reduce( ( acc, country ) => {
if ( ! country.states.length ) {
acc.push( {
value: country.code,
key: country.code,
label: decodeEntities( country.name ),
} );
@ -52,7 +57,7 @@ export function getCountryStateOptions() {
const countryStates = country.states.map( state => {
return {
value: country.code + ':' + state.code,
key: country.code + ':' + state.code,
label: decodeEntities( country.name ) + ' -- ' + decodeEntities( state.name ),
};
} );
@ -62,8 +67,6 @@ export function getCountryStateOptions() {
return acc;
}, [] );
countryStateOptions.unshift( { value: '', label: '' } );
return countryStateOptions;
}
@ -95,6 +98,7 @@ export function StoreAddress( props ) {
label={ __( 'Country / State', 'woocommerce-admin' ) }
required
options={ countryStateOptions }
isSearchable
{ ...getInputProps( 'countryState' ) }
/>

View File

@ -19,7 +19,7 @@ import { getSetting, CURRENCY as currency } from '@woocommerce/wc-admin-settings
/**
* Internal dependencies
*/
import { H, Card, SimpleSelectControl, Form } from '@woocommerce/components';
import { H, Card, SelectControl, Form } from '@woocommerce/components';
import withSelect from 'wc-api/with-select';
import { recordEvent } from 'lib/tracks';
import { formatCurrency } from '@woocommerce/currency';
@ -200,52 +200,52 @@ class BusinessDetails extends Component {
render() {
const productCountOptions = [
{
value: '1-10',
key: '1-10',
label: this.getNumberRangeString( 1, 10 ),
},
{
value: '11-100',
key: '11-100',
label: this.getNumberRangeString( 11, 100 ),
},
{
value: '101-1000',
key: '101-1000',
label: this.getNumberRangeString( 101, 1000 ),
},
{
value: '1000+',
key: '1000+',
label: this.getNumberRangeString( 1000 ),
},
];
const revenueOptions = [
{
value: 'none',
key: 'none',
label: sprintf(
_x( "%s (I'm just getting started)", '$0 revenue amount', 'woocommerce-admin' ),
formatCurrency( 0 )
),
},
{
value: 'up-to-2500',
key: 'up-to-2500',
label: sprintf(
_x( 'Up to %s', 'Up to a certain revenue amount', 'woocommerce-admin' ),
formatCurrency( 2500 )
),
},
{
value: '2500-10000',
key: '2500-10000',
label: this.getNumberRangeString( 2500, 10000, formatCurrency ),
},
{
value: '10000-50000',
key: '10000-50000',
label: this.getNumberRangeString( 10000, 50000, formatCurrency ),
},
{
value: '50000-250000',
key: '50000-250000',
label: this.getNumberRangeString( 50000, 250000, formatCurrency ),
},
{
value: 'more-than-250000',
key: 'more-than-250000',
label: sprintf(
_x( 'More than %s', 'More than a certain revenue amount', 'woocommerce-admin' ),
formatCurrency( 250000 )
@ -255,19 +255,19 @@ class BusinessDetails extends Component {
const sellingVenueOptions = [
{
value: 'no',
key: 'no',
label: __( 'No', 'woocommerce-admin' ),
},
{
value: 'other',
key: 'other',
label: __( 'Yes, on another platform', 'woocommerce-admin' ),
},
{
value: 'brick-mortar',
key: 'brick-mortar',
label: __( 'Yes, in person at physical stores and/or events', 'woocommerce-admin' ),
},
{
value: 'brick-mortar-other',
key: 'brick-mortar-other',
label: __(
'Yes, on another platform and in person at physical stores and/or events',
'woocommerce-admin'
@ -277,23 +277,23 @@ class BusinessDetails extends Component {
const otherPlatformOptions = [
{
value: 'shopify',
key: 'shopify',
label: __( 'Shopify', 'woocommerce-admin' ),
},
{
value: 'bigcommerce',
key: 'bigcommerce',
label: __( 'BigCommerce', 'woocommerce-admin' ),
},
{
value: 'magento',
key: 'magento',
label: __( 'Magento', 'woocommerce-admin' ),
},
{
value: 'wix',
key: 'wix',
label: __( 'Wix', 'woocommerce-admin' ),
},
{
value: 'other',
key: 'other',
label: __( 'Other', 'woocommerce-admin' ),
},
];
@ -320,14 +320,14 @@ class BusinessDetails extends Component {
</p>
<Card>
<Fragment>
<SimpleSelectControl
<SelectControl
label={ __( 'How many products do you plan to add?', 'woocommerce-admin' ) }
options={ productCountOptions }
required
{ ...getInputProps( 'product_count' ) }
/>
<SimpleSelectControl
<SelectControl
label={ __( 'Currently selling elsewhere?', 'woocommerce-admin' ) }
options={ sellingVenueOptions }
required
@ -337,7 +337,7 @@ class BusinessDetails extends Component {
{ [ 'other', 'brick-mortar', 'brick-mortar-other' ].includes(
values.selling_venues
) && (
<SimpleSelectControl
<SelectControl
label={ __( "What's your current annual revenue?", 'woocommerce-admin' ) }
options={ revenueOptions }
required
@ -346,7 +346,7 @@ class BusinessDetails extends Component {
) }
{ [ 'other', 'brick-mortar-other' ].includes( values.selling_venues ) && (
<SimpleSelectControl
<SelectControl
label={ __( 'Which platform is the store using?', 'woocommerce-admin' ) }
options={ otherPlatformOptions }
required

View File

@ -263,6 +263,35 @@
}
}
.woocommerce-select-control__control {
margin: $gap 0;
padding-right: $gap + 24px;
&.is-active {
box-shadow: 0 0 0 1px $studio-woocommerce-purple-60;
}
&::after {
display: block;
pointer-events: none;
cursor: pointer;
position: absolute;
float: right;
line-height: 56px;
font-family: dashicons, sans-serif;
font-size: 20px;
content: '\f140';
z-index: 1;
height: 24px;
width: 24px;
margin-top: 0;
top: 0;
right: $gap;
bottom: $gap;
color: $studio-black;
}
}
#wpadminbar {
display: none;
}

View File

@ -1,7 +1,6 @@
[
{ "component": "AdvancedFilters" },
{ "component": "AnimationSlider" },
{ "component": "Autocomplete" },
{ "component": "Calendar" },
{ "component": "Card" },
{ "component": "Chart" },
@ -28,7 +27,7 @@
{ "component": "SearchListControl" },
{ "component": "Section" },
{ "component": "SegmentedSelection" },
{ "component": "SimpleSelectControl" },
{ "component": "SelectControl" },
{ "component": "Spinner" },
{ "component": "SplitButton" },
{ "component": "Stepper" },

View File

@ -10,7 +10,6 @@
* [Package components](components/packages/)
* [AdvancedFilters](components/packages/advanced-filters/README.md)
* [AnimationSlider](components/packages/animation-slider/README.md)
* [Autocomplete](components/packages/autocomplete/README.md)
* [Calendar](components/packages/calendar/README.md)
* [Card](components/packages/card/README.md)
* [Chart](components/packages/chart/README.md)
@ -39,7 +38,7 @@
* [SectionHeader](components/packages/section-header/README.md)
* [Section](components/packages/section/README.md)
* [SegmentedSelection](components/packages/segmented-selection/README.md)
* [SimpleSelectControl](components/packages/simple-select-control/README.md)
* [SelectControl](components/packages/select-control/README.md)
* [Spinner](components/packages/spinner/README.md)
* [SplitButton](components/packages/split-button/README.md)
* [Stepper](components/packages/stepper/README.md)
@ -48,4 +47,4 @@
* [Tag](components/packages/tag/README.md)
* [TextControlWithAffixes](components/packages/text-control-with-affixes/README.md)
* [ViewMoreList](components/packages/view-more-list/README.md)
* [WebPreview](components/packages/web-preview/README.md)
* [WebPreview](components/packages/web-preview/README.md)

View File

@ -1,3 +1,8 @@
# 4.1.0 (Unreleased)
- Renamed the `<Autocomplete />` component to `<SelectControl />`.
- Added `isSearchable` prop to `<SelectControl />` to allow simple select dropdowns.
- Removed the `<SimpleSelectControl />` component.
# 4.0.0
- Added a new `<ScrollTo />` component.
- Changed the `<List />` `description` prop to `content` and allowed content nodes to be passed in addition to strings.

View File

@ -1,6 +1,6 @@
{
"name": "@woocommerce/components",
"version": "3.2.0",
"version": "4.1.0",
"description": "UI components for WooCommerce.",
"author": "Automattic",
"license": "GPL-3.0-or-later",

View File

@ -1,52 +0,0 @@
Autocomplete
===
A search box which filters options while typing,
allowing a user to select from an option from a filtered list.
## Usage
```jsx
const options = [
{
key: 'apple',
label: 'Apple',
value: { id: 'apple' },
},
{
key: 'apricot',
label: 'Apricot',
value: { id: 'apricot' },
},
];
<Autocomplete
label="Single value"
onChange={ ( selected ) => setState( { singleSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ singleSelected }
/>
```
### Props
Name | Type | Default | Description
--- | --- | --- | ---
`className` | string | `null` | Class name applied to parent div
`excludeSelectedOptions` | boolean | `true` | Exclude already selected options from the options list
`onFilter` | function | `identity` | Add or remove items to the list of options after filtering, passed the array of filtered options and should return an array of options.
`getSearchExpression` | function | `identity` | Function to add regex expression to the filter the results, passed the search query
`help` | string\|node | `null` | Help text to be appended beneath the input
`inlineTags` | boolean | `false` | Render tags inside input, otherwise render below input
`label` | string | `null` | A label to use for the main input
`onChange` | function | `noop` | Function called when selected results change, passed result list
`onSearch` | function | `noop` | Function to run after the search query is updated, passed the search query
`options` | array | `null` | (required) An array of objects for the options list. The option along with its key, label and value will be returned in the onChange event
`placeholder` | string | `null` | A placeholder for the search input
`selected` | array | `[]` | An array of objects describing selected values. If the label of the selected value is omitted, the Tag of that value will not be rendered inside the search box
`maxResults` | number | `0` | A limit for the number of results shown in the options menu. Set to 0 for no limit
`multiple` | boolean | `false` | Allow multiple option selections
`showClearButton` | boolean | `false` | Render a 'Clear' button next to the input box to remove its contents
`hideBeforeSearch` | boolean | `false` | Only show list options after typing a search query
`staticList` | boolean | `false` | Render results list positioned statically instead of absolutely

View File

@ -1,90 +0,0 @@
/**
* Internal dependencies
*/
import { Autocomplete } from '@woocommerce/components';
/**
* External dependencies
*/
import { withState } from '@wordpress/compose';
const options = [
{
key: 'apple',
label: 'Apple',
value: { id: 'apple' },
},
{
key: 'apricot',
label: 'Apricot',
value: { id: 'apricot' },
},
{
key: 'banana',
label: 'Banana',
keywords: [ 'best', 'fruit' ],
value: { id: 'banana' },
},
{
key: 'blueberry',
label: 'Blueberry',
value: { id: 'blueberry' },
},
{
key: 'cherry',
label: 'Cherry',
value: { id: 'cherry' },
},
{
key: 'cantaloupe',
label: 'Cantaloupe',
value: { id: 'cantaloupe' },
},
{
key: 'dragonfruit',
label: 'Dragon Fruit',
value: { id: 'dragonfruit' },
},
{
key: 'elderberry',
label: 'Elderberry',
value: { id: 'elderberry' },
},
];
export default withState( {
singleSelected: [],
multipleSelected: [],
inlineSelected: [],
} )( ( { singleSelected, multipleSelected, inlineSelected, setState } ) => (
<div>
<Autocomplete
label="Single value"
onChange={ ( selected ) => setState( { singleSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ singleSelected }
/>
<br />
<Autocomplete
label="Inline tags"
multiple
inlineTags
onChange={ ( selected ) => setState( { inlineSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ inlineSelected }
/>
<br />
<Autocomplete
hideBeforeSearch
label="Hidden options before search"
multiple
onChange={ ( selected ) => setState( { multipleSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ multipleSelected }
showClearButton
/>
</div>
) );

View File

@ -1,161 +0,0 @@
/** @format */
/**
* External dependencies
*/
import { mount } from 'enzyme';
import { Button } from '@wordpress/components';
/**
* Internal dependencies
*/
import { Autocomplete } from '../index';
describe( 'Autocomplete', () => {
const optionClassname = 'woocommerce-autocomplete__option';
const query = 'lorem';
const options = [
{ key: '1', label: 'lorem 1', value: { id: '1' } },
{ key: '2', label: 'lorem 2', value: { id: '2' } },
{ key: '3', label: 'bar', value: { id: '3' } },
];
it( 'returns matching elements', () => {
const autocomplete = mount(
<Autocomplete
options={ options }
/>
);
autocomplete.setState( {
query,
} );
autocomplete.instance().search( query );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 2 );
} );
it( 'doesn\'t return matching excluded elements', () => {
const autocomplete = mount(
<Autocomplete
options={ options }
selected={ [ options[ 1 ] ] }
excludeSelectedOptions
multiple
/>
);
autocomplete.setState( {
query,
} );
autocomplete.instance().search( query );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 1 );
} );
it( 'trims spaces from input', () => {
const autocomplete = mount(
<Autocomplete
options={ options }
/>
);
autocomplete.setState( {
query,
} );
autocomplete.instance().search( ' ' + query + ' ' );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 2 );
} );
it( 'limits results', () => {
const autocomplete = mount(
<Autocomplete
options={ options }
maxResults={ 1 }
/>
);
autocomplete.setState( {
query,
} );
autocomplete.instance().search( query );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 1 );
} );
it( 'shows options initially', () => {
const autocomplete = mount(
<Autocomplete
options={ options }
/>
);
autocomplete.instance().search( '' );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 3 );
} );
it( 'shows options after query', () => {
const autocomplete = mount(
<Autocomplete
options={ options }
hideBeforeSearch
/>
);
autocomplete.instance().search( '' );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 0 );
autocomplete.instance().search( query );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 2 );
} );
it( 'appends an option after filtering', () => {
const autocomplete = mount(
<Autocomplete
options={ options }
onFilter={ ( filteredOptions ) => filteredOptions.concat( [ { key: 'new-option', label: 'New options' } ] ) }
/>
);
autocomplete.instance().search( query );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 3 );
} );
it( 'changes the options on search', () => {
const queriedOptions = [];
const queryOptions = ( searchedQuery ) => {
if ( searchedQuery === 'test' ) {
queriedOptions.push( { key: 'test-option', label: 'Test option' } );
}
};
const autocomplete = mount(
<Autocomplete
options={ queriedOptions }
onSearch={ queryOptions }
onFilter={ () => queriedOptions }
/>
);
autocomplete.instance().search( '' );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 0 );
autocomplete.instance().search( 'test' );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 1 );
} );
} );

View File

@ -7,7 +7,6 @@ import 'react-dates/initialize';
export { default as AdvancedFilters } from './advanced-filters';
export { default as AnimationSlider } from './animation-slider';
export { default as Autocomplete } from './autocomplete';
export { default as Chart } from './chart';
export { default as ChartPlaceholder } from './chart/placeholder';
export { default as Card } from './card';
@ -42,8 +41,8 @@ export { default as SearchListControl } from './search-list-control';
export { default as SearchListItem } from './search-list-control/item';
export { default as SectionHeader } from './section-header';
export { default as SegmentedSelection } from './segmented-selection';
export { default as SelectControl } from './select-control';
export { default as ScrollTo } from './scroll-to';
export { default as SimpleSelectControl } from './simple-select-control';
export { default as SplitButton } from './split-button';
export { default as Spinner } from './spinner';
export { default as Stepper } from './stepper';

View File

@ -0,0 +1,51 @@
# SelectControl
A search box which filters options while typing,
allowing a user to select from an option from a filtered list.
## Usage
```jsx
const options = [
{
key: 'apple',
label: 'Apple',
value: { id: 'apple' },
},
{
key: 'apricot',
label: 'Apricot',
value: { id: 'apricot' },
},
];
<SelectControl
label="Single value"
onChange={ selected => setState( { singleSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ singleSelected }
/>;
```
### Props
| Name | Type | Default | Description |
| ------------------------ | ------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `className` | string | `null` | Class name applied to parent div |
| `excludeSelectedOptions` | boolean | `true` | Exclude already selected options from the options list |
| `onFilter` | function | `identity` | Add or remove items to the list of options after filtering, passed the array of filtered options and should return an array of options. |
| `getSearchExpression` | function | `identity` | Function to add regex expression to the filter the results, passed the search query |
| `help` | string\|node | `null` | Help text to be appended beneath the input |
| `inlineTags` | boolean | `false` | Render tags inside input, otherwise render below input |
| `label` | string | `null` | A label to use for the main input |
| `onChange` | function | `noop` | Function called when selected results change, passed result list |
| `onSearch` | function | `noop` | Function to run after the search query is updated, passed the search query |
| `options` | array | `null` | (required) An array of objects for the options list. The option along with its key, label and value will be returned in the onChange event |
| `placeholder` | string | `null` | A placeholder for the search input |
| `selected` | array | `[]` | An array of objects describing selected values. If the label of the selected value is omitted, the Tag of that value will not be rendered inside the search box |
| `maxResults` | number | `0` | A limit for the number of results shown in the options menu. Set to 0 for no limit |
| `multiple` | boolean | `false` | Allow multiple option selections |
| `showClearButton` | boolean | `false` | Render a 'Clear' button next to the input box to remove its contents |
| `hideBeforeSearch` | boolean | `false` | Only show list options after typing a search query |
| `staticList` | boolean | `false` | Render results list positioned statically instead of absolutely |

View File

@ -3,7 +3,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { BACKSPACE } from '@wordpress/keycodes';
import { BACKSPACE, DOWN, UP } from '@wordpress/keycodes';
import { Component, createRef } from '@wordpress/element';
import classnames from 'classnames';
import PropTypes from 'prop-types';
@ -16,7 +16,7 @@ import Tags from './tags';
/**
* A search control to allow user input to filter the options.
*/
class SearchControl extends Component {
class Control extends Component {
constructor( props ) {
super( props );
this.state = {
@ -38,9 +38,15 @@ class SearchControl extends Component {
}
onFocus( onSearch ) {
const { isSearchable, setExpanded } = this.props;
return event => {
this.setState( { isActive: true } );
onSearch( event.target.value );
if ( isSearchable ) {
onSearch( event.target.value );
} else {
setExpanded( true );
}
};
}
@ -49,11 +55,42 @@ class SearchControl extends Component {
}
onKeyDown( event ) {
const { selected, onChange, query } = this.props;
const {
decrementSelectedIndex,
incrementSelectedIndex,
selected,
onChange,
query,
setExpanded,
} = this.props;
if ( BACKSPACE === event.keyCode && ! query && selected.length ) {
onChange( [ ...selected.slice( 0, -1 ) ] );
}
if ( DOWN === event.keyCode ) {
incrementSelectedIndex();
setExpanded( true );
event.preventDefault();
event.stopPropagation();
}
if ( UP === event.keyCode ) {
decrementSelectedIndex();
setExpanded( true );
event.preventDefault();
event.stopPropagation();
}
}
renderButton() {
const { multiple, selected } = this.props;
if ( multiple || ! selected.length ) {
return null;
}
return <div className="woocommerce-select-control__control-value">{ selected[ 0 ].label }</div>;
}
renderInput() {
@ -63,46 +100,50 @@ class SearchControl extends Component {
inlineTags,
instanceId,
isExpanded,
isSearchable,
listboxId,
onSearch,
placeholder,
query,
} = this.props;
const { isActive } = this.state;
return <input
className="woocommerce-autocomplete__control-input"
id={ `woocommerce-autocomplete-${ instanceId }__control-input` }
ref={ this.input }
type={ 'search' }
value={ query }
placeholder={ isActive ? placeholder : '' }
onChange={ this.updateSearch( onSearch ) }
onFocus={ this.onFocus( onSearch ) }
onBlur={ this.onBlur }
onKeyDown={ this.onKeyDown }
role="combobox"
aria-autocomplete="list"
aria-expanded={ isExpanded }
aria-haspopup="true"
aria-owns={ listboxId }
aria-controls={ listboxId }
aria-activedescendant={ activeId }
aria-describedby={
hasTags && inlineTags ? `search-inline-input-${ instanceId }` : null
}
/>;
return (
<input
className="woocommerce-select-control__control-input"
id={ `woocommerce-select-control-${ instanceId }__control-input` }
ref={ this.input }
type={ isSearchable ? 'search' : 'button' }
value={ this.getInputValue() }
placeholder={ isActive ? placeholder : '' }
onChange={ this.updateSearch( onSearch ) }
onFocus={ this.onFocus( onSearch ) }
onBlur={ this.onBlur }
onKeyDown={ this.onKeyDown }
role="combobox"
aria-autocomplete="list"
aria-expanded={ isExpanded }
aria-haspopup="true"
aria-owns={ listboxId }
aria-controls={ listboxId }
aria-activedescendant={ activeId }
aria-describedby={ hasTags && inlineTags ? `search-inline-input-${ instanceId }` : null }
/>
);
}
getInputValue() {
const { isSearchable, multiple, query, selected } = this.props;
const selectedValue = selected.length ? selected[ 0 ].label : '';
if ( ! isSearchable && multiple ) {
return '';
}
return isSearchable ? query : selectedValue;
}
render() {
const {
hasTags,
help,
inlineTags,
instanceId,
label,
query,
} = this.props;
const { hasTags, help, inlineTags, instanceId, isSearchable, label, query } = this.props;
const { isActive } = this.state;
return (
@ -113,40 +154,42 @@ class SearchControl extends Component {
// for the benefit of sighted users.
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div
className={ classnames( 'components-base-control', 'woocommerce-autocomplete__control', {
className={ classnames( 'components-base-control', 'woocommerce-select-control__control', {
empty: ! query.length,
'is-active': isActive,
'has-tags': inlineTags && hasTags,
'with-value': query.length,
'with-value': this.getInputValue().length,
} ) }
onClick={ () => {
this.input.current.focus();
} }
>
<i className="material-icons-outlined">search</i>
{ isSearchable && <i className="material-icons-outlined">search</i> }
{ inlineTags && <Tags { ...this.props } /> }
<div className="components-base-control__field">
{ !! label &&
{ !! label && (
<label
htmlFor={ `woocommerce-autocomplete-${ instanceId }__control-input` }
htmlFor={ `woocommerce-select-control-${ instanceId }__control-input` }
className="components-base-control__label"
>
{ label }
</label>
}
) }
{ this.renderInput() }
{ inlineTags && <span id={ `search-inline-input-${ instanceId }` } className="screen-reader-text">
{ __( 'Move backward for selected items', 'woocommerce-admin' ) }
</span> }
{ !! help &&
{ inlineTags && (
<span id={ `search-inline-input-${ instanceId }` } className="screen-reader-text">
{ __( 'Move backward for selected items', 'woocommerce-admin' ) }
</span>
) }
{ !! help && (
<p
id={ `woocommerce-autocomplete-${ instanceId }__help` }
id={ `woocommerce-select-control-${ instanceId }__help` }
className="components-base-control__help"
>
{ help }
</p>
}
) }
</div>
</div>
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
@ -154,7 +197,7 @@ class SearchControl extends Component {
}
}
SearchControl.propTypes = {
Control.propTypes = {
/**
* Bool to determine if tags should be rendered.
*/
@ -162,16 +205,17 @@ SearchControl.propTypes = {
/**
* Help text to be appended beneath the input.
*/
help: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.node,
] ),
help: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
/**
* Render tags inside input, otherwise render below input.
*/
inlineTags: PropTypes.bool,
/**
* ID of the main Autocomplete instance.
* Allow the select options to be filtered by search input.
*/
isSearchable: PropTypes.bool,
/**
* ID of the main SelectControl instance.
*/
instanceId: PropTypes.number,
/**
@ -205,13 +249,10 @@ SearchControl.propTypes = {
*/
selected: PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.oneOfType( [
PropTypes.number,
PropTypes.string,
] ).isRequired,
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
label: PropTypes.string,
} )
),
};
export default SearchControl;
export default Control;

View File

@ -0,0 +1,121 @@
/**
* Internal dependencies
*/
import { SelectControl } from '@woocommerce/components';
/**
* External dependencies
*/
import { withState } from '@wordpress/compose';
const options = [
{
key: 'apple',
label: 'Apple',
value: { id: 'apple' },
},
{
key: 'apricot',
label: 'Apricot',
value: { id: 'apricot' },
},
{
key: 'banana',
label: 'Banana',
keywords: [ 'best', 'fruit' ],
value: { id: 'banana' },
},
{
key: 'blueberry',
label: 'Blueberry',
value: { id: 'blueberry' },
},
{
key: 'cherry',
label: 'Cherry',
value: { id: 'cherry' },
},
{
key: 'cantaloupe',
label: 'Cantaloupe',
value: { id: 'cantaloupe' },
},
{
key: 'dragonfruit',
label: 'Dragon Fruit',
value: { id: 'dragonfruit' },
},
{
key: 'elderberry',
label: 'Elderberry',
value: { id: 'elderberry' },
},
];
export default withState( {
simpleSelected: [],
simpleMultipleSelected: [],
singleSelected: [],
multipleSelected: [],
inlineSelected: [],
} )(
( {
simpleSelected,
simpleMultipleSelected,
singleSelected,
multipleSelected,
inlineSelected,
setState,
} ) => (
<div>
<SelectControl
label="Simple single value"
onChange={ selected => setState( { simpleSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ simpleSelected }
/>
<br />
<SelectControl
label="Multiple values"
multiple
onChange={ selected => setState( { simpleMultipleSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ simpleMultipleSelected }
/>
<br />
<SelectControl
label="Single value searchable"
isSearchable
onChange={ selected => setState( { singleSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ singleSelected }
/>
<br />
<SelectControl
label="Inline tags searchable"
isSearchable
multiple
inlineTags
onChange={ selected => setState( { inlineSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ inlineSelected }
/>
<br />
<SelectControl
hideBeforeSearch
isSearchable
label="Hidden options before search"
multiple
onChange={ selected => setState( { multipleSelected: selected } ) }
options={ options }
placeholder="Start typing to filter options..."
selected={ multipleSelected }
showClearButton
/>
</div>
)
);

View File

@ -15,36 +15,41 @@ import { withInstanceId, compose } from '@wordpress/compose';
*/
import List from './list';
import Tags from './tags';
import SearchControl from './control';
import Control from './control';
/**
* A search box which filters options while typing,
* allowing a user to select from an option from a filtered list.
*/
export class Autocomplete extends Component {
export class SelectControl extends Component {
static getInitialState() {
return {
filteredOptions: [],
selectedIndex: 0,
isExpanded: false,
query: '',
};
}
constructor( props ) {
super( props );
this.state = this.constructor.getInitialState();
this.state = {
...this.constructor.getInitialState(),
filteredOptions: [],
selectedIndex: 0,
};
this.bindNode = this.bindNode.bind( this );
this.decrementSelectedIndex = this.decrementSelectedIndex.bind( this );
this.incrementSelectedIndex = this.incrementSelectedIndex.bind( this );
this.search = this.search.bind( this );
this.selectOption = this.selectOption.bind( this );
this.updateSelectedIndex = this.updateSelectedIndex.bind( this );
this.setExpanded = this.setExpanded.bind( this );
}
bindNode( node ) {
this.node = node;
}
reset( selected = this.props.selected ) {
reset( selected = this.getSelected() ) {
const { multiple } = this.props;
const initialState = this.constructor.getInitialState();
@ -60,12 +65,6 @@ export class Autocomplete extends Component {
this.reset();
}
isExpanded() {
const { filteredOptions, query } = this.state;
return filteredOptions.length > 0 || query;
}
hasTags() {
const { multiple, selected } = this.props;
@ -76,22 +75,54 @@ export class Autocomplete extends Component {
return selected.some( item => Boolean( item.label ) );
}
getSelected() {
const { multiple, options, selected } = this.props;
// Return the passed value if an array is provided.
if ( multiple || Array.isArray( selected ) ) {
return selected;
}
const selectedOption = options.find( option => option.key === selected );
return selectedOption ? [ selectedOption ] : [];
}
selectOption( option ) {
const { multiple, onChange, selected } = this.props;
const { query } = this.state;
const newSelected = multiple ? [ ...selected, option ] : [ option ];
// Check if this is already selected
const isSelected = findIndex( selected, { key: option.key } );
if ( -1 === isSelected ) {
onChange( newSelected, query );
// Trigger a change if the selected value is different and pass back
// an array or string depending on the original value.
if ( Array.isArray( selected ) ) {
const isSelected = findIndex( selected, { key: option.key } );
if ( -1 === isSelected ) {
onChange( newSelected, query );
}
} else if ( selected !== option.key ) {
onChange( option.key, query );
}
this.reset( newSelected );
}
updateSelectedIndex( value ) {
this.setState( { selectedIndex: value } );
decrementSelectedIndex() {
const { selectedIndex } = this.state;
const options = this.getOptions();
const nextSelectedIndex =
null !== selectedIndex
? ( selectedIndex === 0 ? options.length : selectedIndex ) - 1
: options.length - 1;
this.setState( { selectedIndex: nextSelectedIndex } );
}
incrementSelectedIndex() {
const { selectedIndex } = this.state;
const options = this.getOptions();
const nextSelectedIndex = null !== selectedIndex ? ( selectedIndex + 1 ) % options.length : 0;
this.setState( { selectedIndex: nextSelectedIndex } );
}
announce( filteredOptions ) {
@ -117,9 +148,21 @@ export class Autocomplete extends Component {
}
}
getOptions() {
const { isSearchable, options } = this.props;
const { filteredOptions } = this.state;
return isSearchable ? filteredOptions : options;
}
getFilteredOptions( query ) {
const { excludeSelectedOptions, getSearchExpression, maxResults, onFilter, options, selected } = this.props;
const selectedKeys = selected.map( option => option.key );
const {
excludeSelectedOptions,
getSearchExpression,
maxResults,
onFilter,
options,
} = this.props;
const selectedKeys = this.getSelected().map( option => option.key );
const filtered = [];
// Create a regular expression to filter the options.
@ -155,42 +198,50 @@ export class Autocomplete extends Component {
return onFilter( filtered );
}
setExpanded( value ) {
this.setState( { isExpanded: value } );
}
search( query ) {
const { hideBeforeSearch, onSearch, options } = this.props;
onSearch( query );
// Get all options if `hideBeforeSearch` is enabled and query is not null.
const filteredOptions = null !== query && ! query.length && ! hideBeforeSearch
? options
: this.getFilteredOptions( query );
this.setState( { selectedIndex: 0, filteredOptions, query: query || '' }, () => this.announce( filteredOptions ) );
const filteredOptions =
null !== query && ! query.length && ! hideBeforeSearch
? options
: this.getFilteredOptions( query );
this.setState(
{
selectedIndex: 0,
filteredOptions,
isExpanded: Boolean( filteredOptions.length ),
query: query || '',
},
() => this.announce( filteredOptions )
);
}
render() {
const {
className,
inlineTags,
instanceId,
options,
} = this.props;
const { selectedIndex } = this.state;
const { className, inlineTags, instanceId, isSearchable, options } = this.props;
const { isExpanded, selectedIndex } = this.state;
const isExpanded = this.isExpanded();
const hasTags = this.hasTags();
const { key: selectedKey = '' } = options[ selectedIndex ] || {};
const listboxId = isExpanded ? `woocommerce-autocomplete__listbox-${ instanceId }` : null;
const listboxId = isExpanded ? `woocommerce-select-control__listbox-${ instanceId }` : null;
const activeId = isExpanded
? `woocommerce-autocomplete__option-${ instanceId }-${ selectedKey }`
? `woocommerce-select-control__option-${ instanceId }-${ selectedKey }`
: null;
return (
<div
className={ classnames( 'woocommerce-autocomplete', className, {
className={ classnames( 'woocommerce-select-control', className, {
'has-inline-tags': hasTags && inlineTags,
'is-searchable': isSearchable,
} ) }
ref={ this.bindNode }
>
<SearchControl
<Control
{ ...this.props }
{ ...this.state }
activeId={ activeId }
@ -198,26 +249,33 @@ export class Autocomplete extends Component {
isExpanded={ isExpanded }
listboxId={ listboxId }
onSearch={ this.search }
selected={ this.getSelected() }
setExpanded={ this.setExpanded }
decrementSelectedIndex={ this.decrementSelectedIndex }
incrementSelectedIndex={ this.incrementSelectedIndex }
/>
{ ! inlineTags && hasTags && <Tags { ...this.props } /> }
{ isExpanded &&
{ ! inlineTags && hasTags && <Tags { ...this.props } selected={ this.getSelected() } /> }
{ isExpanded && (
<List
{ ...this.props }
{ ...this.state }
activeId={ activeId }
listboxId={ listboxId }
node={ this.node }
onChange={ this.updateSelectedIndex }
onSelect={ this.selectOption }
onSearch={ this.search }
options={ this.getOptions() }
decrementSelectedIndex={ this.decrementSelectedIndex }
incrementSelectedIndex={ this.incrementSelectedIndex }
setExpanded={ this.setExpanded }
/>
}
) }
</div>
);
}
}
Autocomplete.propTypes = {
SelectControl.propTypes = {
/**
* Class name applied to parent div.
*/
@ -238,14 +296,15 @@ Autocomplete.propTypes = {
/**
* Help text to be appended beneath the input.
*/
help: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.node,
] ),
help: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
/**
* Render tags inside input, otherwise render below input.
*/
inlineTags: PropTypes.bool,
/**
* Allow the select options to be filtered by search input.
*/
isSearchable: PropTypes.bool,
/**
* A label to use for the main input.
*/
@ -265,10 +324,7 @@ Autocomplete.propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape( {
isDisabled: PropTypes.bool,
key: PropTypes.oneOfType( [
PropTypes.number,
PropTypes.string,
] ).isRequired,
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
keywords: PropTypes.arrayOf( PropTypes.string ),
label: PropTypes.string,
value: PropTypes.any,
@ -279,19 +335,19 @@ Autocomplete.propTypes = {
*/
placeholder: PropTypes.string,
/**
* An array of objects describing selected values. If the label of the selected
* value is omitted, the Tag of that value will not be rendered inside the
* search box.
* An array of objects describing selected values or optionally a string for a single value.
* If the label of the selected value is omitted, the Tag of that value will not
* be rendered inside the search box.
*/
selected: PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.oneOfType( [
PropTypes.number,
PropTypes.string,
] ).isRequired,
label: PropTypes.string,
} )
),
selected: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
label: PropTypes.string,
} )
),
] ),
/**
* A limit for the number of results shown in the options menu. Set to 0 for no limit.
*/
@ -314,10 +370,11 @@ Autocomplete.propTypes = {
staticList: PropTypes.bool,
};
Autocomplete.defaultProps = {
SelectControl.defaultProps = {
excludeSelectedOptions: true,
getSearchExpression: identity,
inlineTags: false,
isSearchable: false,
onChange: noop,
onFilter: identity,
onSearch: noop,
@ -333,4 +390,4 @@ export default compose( [
withSpokenMessages,
withInstanceId,
withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside
] )( Autocomplete );
] )( SelectControl );

View File

@ -23,12 +23,16 @@ class List extends Component {
}
componentDidUpdate( prevProps ) {
const { filteredOptions } = this.props;
const { options, selectedIndex } = this.props;
// Remove old option refs to avoid memory leaks.
if ( ! isEqual( filteredOptions, prevProps.filteredOptions ) ) {
if ( ! isEqual( options, prevProps.options ) ) {
this.optionRefs = {};
}
if ( selectedIndex !== prevProps.selectedIndex ) {
this.scrollToOption( selectedIndex );
}
}
getOptionRef( index ) {
@ -52,59 +56,63 @@ class List extends Component {
scrollToOption( index ) {
const listbox = this.listbox.current;
if ( listbox.scrollHeight > listbox.clientHeight ) {
const option = this.optionRefs[ index ].current;
const scrollBottom = listbox.clientHeight + listbox.scrollTop;
const elementBottom = option.offsetTop + option.offsetHeight;
if ( elementBottom > scrollBottom ) {
listbox.scrollTop = elementBottom - listbox.clientHeight;
} else if ( option.offsetTop < listbox.scrollTop ) {
listbox.scrollTop = option.offsetTop;
}
if ( listbox.scrollHeight <= listbox.clientHeight ) {
return;
}
if ( ! this.optionRefs[ index ] ) {
return;
}
const option = this.optionRefs[ index ].current;
const scrollBottom = listbox.clientHeight + listbox.scrollTop;
const elementBottom = option.offsetTop + option.offsetHeight;
if ( elementBottom > scrollBottom ) {
listbox.scrollTop = elementBottom - listbox.clientHeight;
} else if ( option.offsetTop < listbox.scrollTop ) {
listbox.scrollTop = option.offsetTop;
}
}
handleKeyDown( event ) {
const { filteredOptions, onChange, onSearch, selectedIndex } = this.props;
if ( filteredOptions.length === 0 ) {
const {
decrementSelectedIndex,
incrementSelectedIndex,
options,
onSearch,
selectedIndex,
setExpanded,
} = this.props;
if ( options.length === 0 ) {
return;
}
let nextSelectedIndex;
switch ( event.keyCode ) {
case UP:
nextSelectedIndex = null !== selectedIndex
? ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1
: filteredOptions.length - 1;
onChange( nextSelectedIndex );
this.scrollToOption( nextSelectedIndex );
decrementSelectedIndex();
event.preventDefault();
event.stopPropagation();
break;
case DOWN:
nextSelectedIndex = null !== selectedIndex
? ( selectedIndex + 1 ) % filteredOptions.length
: 0;
onChange( nextSelectedIndex );
this.scrollToOption( nextSelectedIndex );
incrementSelectedIndex();
event.preventDefault();
event.stopPropagation();
break;
case ENTER:
this.select( filteredOptions[ selectedIndex ] );
this.select( options[ selectedIndex ] );
event.preventDefault();
event.stopPropagation();
break;
case LEFT:
case RIGHT:
onChange( null );
setExpanded( false );
break;
case ESCAPE:
onChange( null );
setExpanded( false );
onSearch( null );
return;
@ -133,30 +141,30 @@ class List extends Component {
}
render() {
const { filteredOptions, instanceId, listboxId, selectedIndex, staticList } = this.props;
const listboxClasses = classnames( 'woocommerce-autocomplete__listbox', {
const { instanceId, listboxId, options, selectedIndex, staticList } = this.props;
const listboxClasses = classnames( 'woocommerce-select-control__listbox', {
'is-static': staticList,
} );
return (
<div ref={ this.listbox } id={ listboxId } role="listbox" className={ listboxClasses }>
{ filteredOptions.map( ( option, index ) => (
<Button
ref={ this.getOptionRef( index ) }
key={ option.key }
id={ `woocommerce-autocomplete__option-${ instanceId }-${ option.key }` }
role="option"
aria-selected={ index === selectedIndex }
disabled={ option.isDisabled }
className={ classnames( 'woocommerce-autocomplete__option', {
'is-selected': index === selectedIndex,
} ) }
onClick={ () => this.select( option ) }
tabIndex="-1"
>
{ option.label }
</Button>
) ) }
{ options.map( ( option, index ) => (
<Button
ref={ this.getOptionRef( index ) }
key={ option.key }
id={ `woocommerce-select-control__option-${ instanceId }-${ option.key }` }
role="option"
aria-selected={ index === selectedIndex }
disabled={ option.isDisabled }
className={ classnames( 'woocommerce-select-control__option', {
'is-selected': index === selectedIndex,
} ) }
onClick={ () => this.select( option ) }
tabIndex="-1"
>
{ option.label }
</Button>
) ) }
</div>
);
}
@ -164,22 +172,7 @@ class List extends Component {
List.propTypes = {
/**
* Array of filtered options to display.
*/
filteredOptions: PropTypes.arrayOf(
PropTypes.shape( {
isDisabled: PropTypes.bool,
key: PropTypes.oneOfType( [
PropTypes.number,
PropTypes.string,
] ).isRequired,
keywords: PropTypes.arrayOf( PropTypes.string ),
label: PropTypes.string,
value: PropTypes.any,
} )
).isRequired,
/**
* ID of the main Autocomplete instance.
* ID of the main SelectControl instance.
*/
instanceId: PropTypes.number,
/**
@ -190,14 +183,22 @@ List.propTypes = {
* Parent node to bind keyboard events to.
*/
node: PropTypes.instanceOf( Element ).isRequired,
/**
* Function called when selected results change, passed result list.
*/
onChange: PropTypes.func,
/**
* Function to execute when an option is selected.
*/
onSelect: PropTypes.func,
/**
* Array of options to display.
*/
options: PropTypes.arrayOf(
PropTypes.shape( {
isDisabled: PropTypes.bool,
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
keywords: PropTypes.arrayOf( PropTypes.string ),
label: PropTypes.string,
value: PropTypes.any,
} )
).isRequired,
/**
* Integer for the currently selected item.
*/

View File

@ -1,4 +1,6 @@
.woocommerce-autocomplete {
/** @format */
.woocommerce-select-control {
position: relative;
.components-base-control {
@ -11,7 +13,7 @@
padding: $gap-small $gap;
position: relative;
.woocommerce-autocomplete__tags {
.woocommerce-select-control__tags {
margin: $gap-small $gap-smallest 0 0;
}
@ -24,16 +26,16 @@
align-items: center;
flex: 1;
margin-bottom: 0;
max-width: 100%;
}
.components-base-control__label {
left: 52px;
position: absolute;
color: $studio-gray-50;
font-size: 16px;
}
.woocommerce-autocomplete__control-input {
.woocommerce-select-control__control-input {
font-size: 16px;
border: 0;
box-shadow: none;
@ -43,10 +45,15 @@
padding-right: 0;
width: 100%;
line-height: 24px;
text-align: left;
&::-webkit-search-cancel-button {
display: none;
}
&:focus {
outline: none;
}
}
i {
@ -61,14 +68,13 @@
}
&.with-value .components-base-control__label,
&.is-active .components-base-control__label,
&.has-tags .components-base-control__label {
font-size: 12px;
margin-top: -$gap-small;
}
}
.woocommerce-autocomplete__tags {
.woocommerce-select-control__tags {
position: relative;
margin: $gap-small 0;
@ -81,7 +87,7 @@
max-height: 24px;
}
.woocommerce-autocomplete__clear {
.woocommerce-select-control__clear {
position: absolute;
right: 0;
top: calc(50% - 10px);
@ -91,7 +97,7 @@
}
}
.woocommerce-autocomplete__listbox {
.woocommerce-select-control__listbox {
background: $studio-white;
display: flex;
flex-direction: column;
@ -111,7 +117,7 @@
}
}
.woocommerce-autocomplete__option {
.woocommerce-select-control__option {
padding: $gap;
min-height: 56px;
font-size: 16px;
@ -121,4 +127,15 @@
background: $studio-gray-0;
}
}
&.is-searchable {
.components-base-control__label {
left: 52px;
}
.components-base-control.is-active .components-base-control__label {
font-size: 12px;
margin-top: -$gap-small;
}
}
}

View File

@ -43,7 +43,7 @@ class Tags extends Component {
return null;
}
const classes = classnames( 'woocommerce-autocomplete__tags', {
const classes = classnames( 'woocommerce-select-control__tags', {
'has-clear': showClearButton,
} );
@ -69,14 +69,12 @@ class Tags extends Component {
/>
);
} ) }
{ showClearButton && <Button
className="woocommerce-autocomplete__clear"
isLink
onClick={ this.removeAll }
>
<Icon icon="dismiss" />
<span className="screen-reader-text">{ __( 'Clear all', 'woocommerce-admin' ) }</span>
</Button> }
{ showClearButton && (
<Button className="woocommerce-select-control__clear" isLink onClick={ this.removeAll }>
<Icon icon="dismiss" />
<span className="screen-reader-text">{ __( 'Clear all', 'woocommerce-admin' ) }</span>
</Button>
) }
</div>
);
}
@ -98,10 +96,7 @@ Tags.propTypes = {
*/
selected: PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.oneOfType( [
PropTypes.number,
PropTypes.string,
] ).isRequired,
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
label: PropTypes.string,
} )
),

View File

@ -0,0 +1,159 @@
/** @format */
/**
* External dependencies
*/
import { mount } from 'enzyme';
import { Button } from '@wordpress/components';
/**
* Internal dependencies
*/
import { SelectControl } from '../index';
describe( 'SelectControl', () => {
const optionClassname = 'woocommerce-select-control__option';
const query = 'lorem';
const options = [
{ key: '1', label: 'lorem 1', value: { id: '1' } },
{ key: '2', label: 'lorem 2', value: { id: '2' } },
{ key: '3', label: 'bar', value: { id: '3' } },
];
it( 'returns all elements', () => {
const selectControl = mount( <SelectControl options={ options } /> );
selectControl.setState( {
query,
} );
selectControl.instance().search( query );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 3 );
} );
it( 'returns matching elements', () => {
const selectControl = mount( <SelectControl isSearchable options={ options } /> );
selectControl.setState( {
query,
} );
selectControl.instance().search( query );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 2 );
} );
it( "doesn't return matching excluded elements", () => {
const selectControl = mount(
<SelectControl
isSearchable
options={ options }
selected={ [ options[ 1 ] ] }
excludeSelectedOptions
multiple
/>
);
selectControl.setState( {
query,
} );
selectControl.instance().search( query );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 1 );
} );
it( 'trims spaces from input', () => {
const selectControl = mount( <SelectControl isSearchable options={ options } /> );
selectControl.setState( {
query,
} );
selectControl.instance().search( ' ' + query + ' ' );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 2 );
} );
it( 'limits results', () => {
const selectControl = mount(
<SelectControl isSearchable options={ options } maxResults={ 1 } />
);
selectControl.setState( {
query,
} );
selectControl.instance().search( query );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 1 );
} );
it( 'shows options initially', () => {
const selectControl = mount( <SelectControl isSearchable options={ options } /> );
selectControl.instance().search( '' );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 3 );
} );
it( 'shows options after query', () => {
const selectControl = mount(
<SelectControl isSearchable options={ options } hideBeforeSearch />
);
selectControl.instance().search( '' );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 0 );
selectControl.instance().search( query );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 2 );
} );
it( 'appends an option after filtering', () => {
const selectControl = mount(
<SelectControl
options={ options }
onFilter={ filteredOptions =>
filteredOptions.concat( [ { key: 'new-option', label: 'New options' } ] )
}
/>
);
selectControl.instance().search( query );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 3 );
} );
it( 'changes the options on search', () => {
const queriedOptions = [];
const queryOptions = searchedQuery => {
if ( searchedQuery === 'test' ) {
queriedOptions.push( { key: 'test-option', label: 'Test option' } );
}
};
const selectControl = mount(
<SelectControl
isSearchable
options={ queriedOptions }
onSearch={ queryOptions }
onFilter={ () => queriedOptions }
/>
);
selectControl.instance().search( '' );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 0 );
selectControl.instance().search( 'test' );
selectControl.update();
expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 1 );
} );
} );

View File

@ -1,45 +0,0 @@
SimpleSelectControl
===
A component for displaying a material styled 'simple' select control.
## Usage
```jsx
const petOptions = [
{
value: 'cat',
label: 'Cat',
},
{
value: 'dog',
label: 'Dog',
},
];
<SimpleSelectControl
label="What is your favorite pet?"
onChange={ value => this.setState( { pet: value } ) }
options={ petOptions }
value={ pet }
/>
```
### Props
Name | Type | Default | Description
--- | --- | --- | ---
`className` | String | `null` | Additional class name to style the component
`label` | String | `null` | A label to use for the main select element
`options` | Array | `null` | An array of options to use for the dropddown
`onChange` | Function | `null` | A function that receives the value of the new option that is being selected as input
`value` | String | `null` | The currently value of the select element
`help` | One of type: string, node | `null` | If this property is added, a help text will be generated using help property as the content
### `options` structure
The `options` array needs to be composed of objects with properties:
- `value`: String - Input value for this option.
- `label`: String - Label for this option.
- `disabled`: Boolean - Disable the option.

View File

@ -1,56 +0,0 @@
/** @format */
/**
* Internal dependencies
*/
import { SimpleSelectControl } from '@woocommerce/components';
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
export default class MySimpleSelectControl extends Component {
constructor() {
super();
this.state = {
pet: '',
};
}
render() {
const { pet } = this.state;
const petOptions = [
{
value: 'cat',
label: 'Cat',
},
{
value: 'dog',
label: 'Dog',
},
{
value: 'bunny',
label: 'Bunny',
},
{
value: 'snake',
label: 'Snake',
},
{
value: 'chinchilla',
label: 'Chinchilla',
disabled: true,
},
];
return (
<SimpleSelectControl
label="What is your favorite pet?"
onChange={ value => this.setState( { pet: value } ) }
options={ petOptions }
value={ pet }
/>
);
}
}

View File

@ -1,190 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Dropdown, Button, NavigableMenu, withFocusOutside } from '@wordpress/components';
import { Fragment, Component } from '@wordpress/element';
import { map, find } from 'lodash';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { withInstanceId } from '@wordpress/compose';
/**
* A component for displaying a material styled 'simple' select control.
*/
class SimpleSelectControl extends Component {
constructor( props ) {
super( props );
this.state = {
currentValue: props.value,
isFocused: false,
};
}
componentDidUpdate( prevProps ) {
if (
this.props.value !== prevProps.value &&
this.state.currentValue !== this.props.value
) {
/* eslint-disable react/no-did-update-set-state */
this.setState( {
currentValue: this.props.value,
} );
/* eslint-enable react/no-did-update-set-state */
}
}
handleFocusOutside() {
this.setState( { isFocused: false } );
}
handleOnClick( onToggle ) {
this.setState( { isFocused: true } );
if ( 'function' === typeof onToggle ) {
onToggle();
}
const { onClick } = this.props;
if ( 'function' === typeof onClick ) {
onClick();
}
}
handleOnFocus() {
this.setState( { isFocused: true } );
const { onFocus } = this.props;
if ( 'function' === typeof onFocus ) {
onFocus();
}
}
onChange( value ) {
this.props.onChange( value );
this.setState( { currentValue: value } );
}
render() {
const { options, label, className, instanceId, help } = this.props;
const { currentValue, isFocused } = this.state;
const onChange = ( value ) => {
this.onChange( value );
this.handleFocusOutside();
};
const isEmpty = currentValue === '' || currentValue === null;
const currentOption = find( options, ( { value } ) => value === currentValue );
const id = `simple-select-control-${ instanceId }`;
return (
<Dropdown
id={ id }
className={ classNames(
'woocommerce-simple-select-control__dropdown',
'components-base-control',
className,
{
'is-empty': isEmpty,
'has-value': ! isEmpty,
'is-active': isFocused,
}
) }
contentClassName="woocommerce-simple-select-control__dropdown-content"
position="center"
renderToggle={ ( { isOpen, onToggle } ) => (
<Fragment>
<Button
className="woocommerce-simple-select-control__selector"
onClick={ () => this.handleOnClick( onToggle ) }
onFocus={ () => this.handleOnFocus() }
aria-expanded={ isOpen }
aria-label={ ! isEmpty ? sprintf(
/* translators: Label: Current Value for a Select Dropddown */
__( '%s: %s' ), label, currentOption && currentOption.label
) : label }
>
<span className="woocommerce-simple-select-control__label">{ label }</span>
<span className="woocommerce-simple-select-control__value">{ currentOption && currentOption.label }</span>
</Button>
{ !! help && <p id={ id + '__help' } className="components-base-control__help">{ help }</p> }
</Fragment>
) }
renderContent={ ( { onClose } ) => (
<NavigableMenu>
{ map( options, ( option ) => {
const optionValue = option.value;
const optionLabel = option.label;
const optionDisabled = option.disabled || false;
const isSelected = ( currentValue === optionValue );
return (
<Button
key={ optionValue }
onClick={ () => {
onChange( optionValue );
onClose();
} }
className={ classNames( {
'is-selected': isSelected,
} ) }
disabled={ optionDisabled }
role="menuitemradio"
aria-checked={ isSelected }
>
<span>
{ optionLabel }
</span>
</Button>
);
} ) }
</NavigableMenu>
) }
/>
);
}
}
SimpleSelectControl.propTypes = {
/**
* Additional class name to style the component.
*/
className: PropTypes.string,
/**
* A label to use for the main select element.
*/
label: PropTypes.string,
/**
* An array of options to use for the dropddown.
*/
options: PropTypes.arrayOf(
PropTypes.shape( {
/**
* Input value for this option.
*/
value: PropTypes.string,
/**
* Label for this option.
*/
label: PropTypes.string,
/**
* Disable the option.
*/
disabled: PropTypes.bool,
} )
),
/**
* A function that receives the value of the new option that is being selected as input.
*/
onChange: PropTypes.func,
/**
* The currently value of the select element.
*/
value: PropTypes.string,
/**
* If this property is added, a help text will be generated using help property as the content.
*/
help: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.node,
] ),
};
export default withFocusOutside( withInstanceId( SimpleSelectControl ) );

View File

@ -1,125 +0,0 @@
.woocommerce-simple-select-control__dropdown {
position: relative;
width: 100%;
height: 56px;
border: 1px solid #b0b5b8;
box-shadow: none;
box-sizing: border-box;
border-radius: 3px;
background: $studio-white;
font-size: 16px;
line-height: 54px;
font-weight: 400;
margin-top: $gap;
margin-bottom: $gap;
.woocommerce-simple-select-control__selector {
&.components-button {
border: 0;
background: transparent;
box-shadow: none;
justify-content: unset;
margin-left: 0;
position: absolute;
top: 0;
height: 100%;
width: 100%;
left: 0;
padding-left: $gap;
padding-right: $gap-largest;
&:focus:not(:disabled) {
box-shadow: none;
outline: none;
background-color: transparent;
outline-offset: initial;
}
&::after {
display: block;
pointer-events: none;
position: absolute;
float: right;
line-height: 56px;
font-family: dashicons, sans-serif;
font-size: 20px;
content: '\f140';
z-index: 101;
height: 24px;
width: 24px;
margin-top: 0;
top: 0;
right: $gap;
bottom: 16px;
color: $studio-black;
}
}
}
.woocommerce-simple-select-control__dropdown-content {
&.components-popover.is-bottom {
margin-top: -$gap-largest * 2;
z-index: 999;
&::before,
&::after {
display: none;
}
}
.components-button {
display: block;
position: relative;
padding: 10px 20px 10px 40px;
width: 100%;
text-align: left;
&.is-selected {
background: $studio-gray-100;
}
}
}
&.is-empty {
.woocommerce-simple-select-control__selector {
&.components-button {
font-size: 16px;
line-height: 54px;
height: 54px;
z-index: 100;
color: #636d75;
}
}
}
&.is-active {
box-shadow: 0 0 0 2px #673d99;
border-color: transparent;
}
&.has-value {
.woocommerce-simple-select-control__selector {
display: flex;
flex-direction: column;
&.components-button {
&::after {
bottom: 23px;
}
}
}
.woocommerce-simple-select-control__label {
margin-top: -$gap-smallest;
font-size: 12px;
line-height: 16px;
margin-top: 8px;
color: #636d75;
}
.woocommerce-simple-select-control__value {
color: #2b2d2f;
font-size: 16px;
white-space: nowrap;
}
}
}

View File

@ -2,7 +2,6 @@
* Internal Dependencies
*/
@import 'animation-slider/style.scss';
@import 'autocomplete/style.scss';
@import 'calendar/style.scss';
@import 'card/style.scss';
@import 'chart/style.scss';
@ -27,7 +26,7 @@
@import 'search-list-control/style.scss';
@import 'section-header/style.scss';
@import 'segmented-selection/style.scss';
@import 'simple-select-control/style.scss';
@import 'select-control/style.scss';
@import 'split-button/style.scss';
@import 'stepper/style.scss';
@import 'spinner/style.scss';