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

View File

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

View File

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

View File

@ -10,7 +10,6 @@
* [Package components](components/packages/) * [Package components](components/packages/)
* [AdvancedFilters](components/packages/advanced-filters/README.md) * [AdvancedFilters](components/packages/advanced-filters/README.md)
* [AnimationSlider](components/packages/animation-slider/README.md) * [AnimationSlider](components/packages/animation-slider/README.md)
* [Autocomplete](components/packages/autocomplete/README.md)
* [Calendar](components/packages/calendar/README.md) * [Calendar](components/packages/calendar/README.md)
* [Card](components/packages/card/README.md) * [Card](components/packages/card/README.md)
* [Chart](components/packages/chart/README.md) * [Chart](components/packages/chart/README.md)
@ -39,7 +38,7 @@
* [SectionHeader](components/packages/section-header/README.md) * [SectionHeader](components/packages/section-header/README.md)
* [Section](components/packages/section/README.md) * [Section](components/packages/section/README.md)
* [SegmentedSelection](components/packages/segmented-selection/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) * [Spinner](components/packages/spinner/README.md)
* [SplitButton](components/packages/split-button/README.md) * [SplitButton](components/packages/split-button/README.md)
* [Stepper](components/packages/stepper/README.md) * [Stepper](components/packages/stepper/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 # 4.0.0
- Added a new `<ScrollTo />` component. - Added a new `<ScrollTo />` component.
- Changed the `<List />` `description` prop to `content` and allowed content nodes to be passed in addition to strings. - 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", "name": "@woocommerce/components",
"version": "3.2.0", "version": "4.1.0",
"description": "UI components for WooCommerce.", "description": "UI components for WooCommerce.",
"author": "Automattic", "author": "Automattic",
"license": "GPL-3.0-or-later", "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 AdvancedFilters } from './advanced-filters';
export { default as AnimationSlider } from './animation-slider'; export { default as AnimationSlider } from './animation-slider';
export { default as Autocomplete } from './autocomplete';
export { default as Chart } from './chart'; export { default as Chart } from './chart';
export { default as ChartPlaceholder } from './chart/placeholder'; export { default as ChartPlaceholder } from './chart/placeholder';
export { default as Card } from './card'; 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 SearchListItem } from './search-list-control/item';
export { default as SectionHeader } from './section-header'; export { default as SectionHeader } from './section-header';
export { default as SegmentedSelection } from './segmented-selection'; export { default as SegmentedSelection } from './segmented-selection';
export { default as SelectControl } from './select-control';
export { default as ScrollTo } from './scroll-to'; 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 SplitButton } from './split-button';
export { default as Spinner } from './spinner'; export { default as Spinner } from './spinner';
export { default as Stepper } from './stepper'; 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 * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { BACKSPACE } from '@wordpress/keycodes'; import { BACKSPACE, DOWN, UP } from '@wordpress/keycodes';
import { Component, createRef } from '@wordpress/element'; import { Component, createRef } from '@wordpress/element';
import classnames from 'classnames'; import classnames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -16,7 +16,7 @@ import Tags from './tags';
/** /**
* A search control to allow user input to filter the options. * A search control to allow user input to filter the options.
*/ */
class SearchControl extends Component { class Control extends Component {
constructor( props ) { constructor( props ) {
super( props ); super( props );
this.state = { this.state = {
@ -38,9 +38,15 @@ class SearchControl extends Component {
} }
onFocus( onSearch ) { onFocus( onSearch ) {
const { isSearchable, setExpanded } = this.props;
return event => { return event => {
this.setState( { isActive: true } ); this.setState( { isActive: true } );
if ( isSearchable ) {
onSearch( event.target.value ); onSearch( event.target.value );
} else {
setExpanded( true );
}
}; };
} }
@ -49,11 +55,42 @@ class SearchControl extends Component {
} }
onKeyDown( event ) { onKeyDown( event ) {
const { selected, onChange, query } = this.props; const {
decrementSelectedIndex,
incrementSelectedIndex,
selected,
onChange,
query,
setExpanded,
} = this.props;
if ( BACKSPACE === event.keyCode && ! query && selected.length ) { if ( BACKSPACE === event.keyCode && ! query && selected.length ) {
onChange( [ ...selected.slice( 0, -1 ) ] ); 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() { renderInput() {
@ -63,19 +100,20 @@ class SearchControl extends Component {
inlineTags, inlineTags,
instanceId, instanceId,
isExpanded, isExpanded,
isSearchable,
listboxId, listboxId,
onSearch, onSearch,
placeholder, placeholder,
query,
} = this.props; } = this.props;
const { isActive } = this.state; const { isActive } = this.state;
return <input return (
className="woocommerce-autocomplete__control-input" <input
id={ `woocommerce-autocomplete-${ instanceId }__control-input` } className="woocommerce-select-control__control-input"
id={ `woocommerce-select-control-${ instanceId }__control-input` }
ref={ this.input } ref={ this.input }
type={ 'search' } type={ isSearchable ? 'search' : 'button' }
value={ query } value={ this.getInputValue() }
placeholder={ isActive ? placeholder : '' } placeholder={ isActive ? placeholder : '' }
onChange={ this.updateSearch( onSearch ) } onChange={ this.updateSearch( onSearch ) }
onFocus={ this.onFocus( onSearch ) } onFocus={ this.onFocus( onSearch ) }
@ -88,21 +126,24 @@ class SearchControl extends Component {
aria-owns={ listboxId } aria-owns={ listboxId }
aria-controls={ listboxId } aria-controls={ listboxId }
aria-activedescendant={ activeId } aria-activedescendant={ activeId }
aria-describedby={ aria-describedby={ hasTags && inlineTags ? `search-inline-input-${ instanceId }` : null }
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() { render() {
const { const { hasTags, help, inlineTags, instanceId, isSearchable, label, query } = this.props;
hasTags,
help,
inlineTags,
instanceId,
label,
query,
} = this.props;
const { isActive } = this.state; const { isActive } = this.state;
return ( return (
@ -113,40 +154,42 @@ class SearchControl extends Component {
// for the benefit of sighted users. // for the benefit of sighted users.
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div <div
className={ classnames( 'components-base-control', 'woocommerce-autocomplete__control', { className={ classnames( 'components-base-control', 'woocommerce-select-control__control', {
empty: ! query.length, empty: ! query.length,
'is-active': isActive, 'is-active': isActive,
'has-tags': inlineTags && hasTags, 'has-tags': inlineTags && hasTags,
'with-value': query.length, 'with-value': this.getInputValue().length,
} ) } } ) }
onClick={ () => { onClick={ () => {
this.input.current.focus(); this.input.current.focus();
} } } }
> >
<i className="material-icons-outlined">search</i> { isSearchable && <i className="material-icons-outlined">search</i> }
{ inlineTags && <Tags { ...this.props } /> } { inlineTags && <Tags { ...this.props } /> }
<div className="components-base-control__field"> <div className="components-base-control__field">
{ !! label && { !! label && (
<label <label
htmlFor={ `woocommerce-autocomplete-${ instanceId }__control-input` } htmlFor={ `woocommerce-select-control-${ instanceId }__control-input` }
className="components-base-control__label" className="components-base-control__label"
> >
{ label } { label }
</label> </label>
} ) }
{ this.renderInput() } { this.renderInput() }
{ inlineTags && <span id={ `search-inline-input-${ instanceId }` } className="screen-reader-text"> { inlineTags && (
<span id={ `search-inline-input-${ instanceId }` } className="screen-reader-text">
{ __( 'Move backward for selected items', 'woocommerce-admin' ) } { __( 'Move backward for selected items', 'woocommerce-admin' ) }
</span> } </span>
{ !! help && ) }
{ !! help && (
<p <p
id={ `woocommerce-autocomplete-${ instanceId }__help` } id={ `woocommerce-select-control-${ instanceId }__help` }
className="components-base-control__help" className="components-base-control__help"
> >
{ help } { help }
</p> </p>
} ) }
</div> </div>
</div> </div>
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ /* 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. * Bool to determine if tags should be rendered.
*/ */
@ -162,16 +205,17 @@ SearchControl.propTypes = {
/** /**
* Help text to be appended beneath the input. * Help text to be appended beneath the input.
*/ */
help: PropTypes.oneOfType( [ help: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
PropTypes.string,
PropTypes.node,
] ),
/** /**
* Render tags inside input, otherwise render below input. * Render tags inside input, otherwise render below input.
*/ */
inlineTags: PropTypes.bool, 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, instanceId: PropTypes.number,
/** /**
@ -205,13 +249,10 @@ SearchControl.propTypes = {
*/ */
selected: PropTypes.arrayOf( selected: PropTypes.arrayOf(
PropTypes.shape( { PropTypes.shape( {
key: PropTypes.oneOfType( [ key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
PropTypes.number,
PropTypes.string,
] ).isRequired,
label: PropTypes.string, 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 List from './list';
import Tags from './tags'; import Tags from './tags';
import SearchControl from './control'; import Control from './control';
/** /**
* A search box which filters options while typing, * A search box which filters options while typing,
* allowing a user to select from an option from a filtered list. * allowing a user to select from an option from a filtered list.
*/ */
export class Autocomplete extends Component { export class SelectControl extends Component {
static getInitialState() { static getInitialState() {
return { return {
filteredOptions: [], isExpanded: false,
selectedIndex: 0,
query: '', query: '',
}; };
} }
constructor( props ) { constructor( props ) {
super( props ); super( props );
this.state = this.constructor.getInitialState(); this.state = {
...this.constructor.getInitialState(),
filteredOptions: [],
selectedIndex: 0,
};
this.bindNode = this.bindNode.bind( this ); 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.search = this.search.bind( this );
this.selectOption = this.selectOption.bind( this ); this.selectOption = this.selectOption.bind( this );
this.updateSelectedIndex = this.updateSelectedIndex.bind( this ); this.setExpanded = this.setExpanded.bind( this );
} }
bindNode( node ) { bindNode( node ) {
this.node = node; this.node = node;
} }
reset( selected = this.props.selected ) { reset( selected = this.getSelected() ) {
const { multiple } = this.props; const { multiple } = this.props;
const initialState = this.constructor.getInitialState(); const initialState = this.constructor.getInitialState();
@ -60,12 +65,6 @@ export class Autocomplete extends Component {
this.reset(); this.reset();
} }
isExpanded() {
const { filteredOptions, query } = this.state;
return filteredOptions.length > 0 || query;
}
hasTags() { hasTags() {
const { multiple, selected } = this.props; const { multiple, selected } = this.props;
@ -76,22 +75,54 @@ export class Autocomplete extends Component {
return selected.some( item => Boolean( item.label ) ); 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 ) { selectOption( option ) {
const { multiple, onChange, selected } = this.props; const { multiple, onChange, selected } = this.props;
const { query } = this.state; const { query } = this.state;
const newSelected = multiple ? [ ...selected, option ] : [ option ]; const newSelected = multiple ? [ ...selected, option ] : [ option ];
// Check if this is already selected // 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 } ); const isSelected = findIndex( selected, { key: option.key } );
if ( -1 === isSelected ) { if ( -1 === isSelected ) {
onChange( newSelected, query ); onChange( newSelected, query );
} }
} else if ( selected !== option.key ) {
onChange( option.key, query );
}
this.reset( newSelected ); this.reset( newSelected );
} }
updateSelectedIndex( value ) { decrementSelectedIndex() {
this.setState( { selectedIndex: value } ); 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 ) { 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 ) { getFilteredOptions( query ) {
const { excludeSelectedOptions, getSearchExpression, maxResults, onFilter, options, selected } = this.props; const {
const selectedKeys = selected.map( option => option.key ); excludeSelectedOptions,
getSearchExpression,
maxResults,
onFilter,
options,
} = this.props;
const selectedKeys = this.getSelected().map( option => option.key );
const filtered = []; const filtered = [];
// Create a regular expression to filter the options. // Create a regular expression to filter the options.
@ -155,42 +198,50 @@ export class Autocomplete extends Component {
return onFilter( filtered ); return onFilter( filtered );
} }
setExpanded( value ) {
this.setState( { isExpanded: value } );
}
search( query ) { search( query ) {
const { hideBeforeSearch, onSearch, options } = this.props; const { hideBeforeSearch, onSearch, options } = this.props;
onSearch( query ); onSearch( query );
// Get all options if `hideBeforeSearch` is enabled and query is not null. // Get all options if `hideBeforeSearch` is enabled and query is not null.
const filteredOptions = null !== query && ! query.length && ! hideBeforeSearch const filteredOptions =
null !== query && ! query.length && ! hideBeforeSearch
? options ? options
: this.getFilteredOptions( query ); : this.getFilteredOptions( query );
this.setState( { selectedIndex: 0, filteredOptions, query: query || '' }, () => this.announce( filteredOptions ) ); this.setState(
{
selectedIndex: 0,
filteredOptions,
isExpanded: Boolean( filteredOptions.length ),
query: query || '',
},
() => this.announce( filteredOptions )
);
} }
render() { render() {
const { const { className, inlineTags, instanceId, isSearchable, options } = this.props;
className, const { isExpanded, selectedIndex } = this.state;
inlineTags,
instanceId,
options,
} = this.props;
const { selectedIndex } = this.state;
const isExpanded = this.isExpanded();
const hasTags = this.hasTags(); const hasTags = this.hasTags();
const { key: selectedKey = '' } = options[ selectedIndex ] || {}; 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 const activeId = isExpanded
? `woocommerce-autocomplete__option-${ instanceId }-${ selectedKey }` ? `woocommerce-select-control__option-${ instanceId }-${ selectedKey }`
: null; : null;
return ( return (
<div <div
className={ classnames( 'woocommerce-autocomplete', className, { className={ classnames( 'woocommerce-select-control', className, {
'has-inline-tags': hasTags && inlineTags, 'has-inline-tags': hasTags && inlineTags,
'is-searchable': isSearchable,
} ) } } ) }
ref={ this.bindNode } ref={ this.bindNode }
> >
<SearchControl <Control
{ ...this.props } { ...this.props }
{ ...this.state } { ...this.state }
activeId={ activeId } activeId={ activeId }
@ -198,26 +249,33 @@ export class Autocomplete extends Component {
isExpanded={ isExpanded } isExpanded={ isExpanded }
listboxId={ listboxId } listboxId={ listboxId }
onSearch={ this.search } onSearch={ this.search }
selected={ this.getSelected() }
setExpanded={ this.setExpanded }
decrementSelectedIndex={ this.decrementSelectedIndex }
incrementSelectedIndex={ this.incrementSelectedIndex }
/> />
{ ! inlineTags && hasTags && <Tags { ...this.props } /> } { ! inlineTags && hasTags && <Tags { ...this.props } selected={ this.getSelected() } /> }
{ isExpanded && { isExpanded && (
<List <List
{ ...this.props } { ...this.props }
{ ...this.state } { ...this.state }
activeId={ activeId } activeId={ activeId }
listboxId={ listboxId } listboxId={ listboxId }
node={ this.node } node={ this.node }
onChange={ this.updateSelectedIndex }
onSelect={ this.selectOption } onSelect={ this.selectOption }
onSearch={ this.search } onSearch={ this.search }
options={ this.getOptions() }
decrementSelectedIndex={ this.decrementSelectedIndex }
incrementSelectedIndex={ this.incrementSelectedIndex }
setExpanded={ this.setExpanded }
/> />
} ) }
</div> </div>
); );
} }
} }
Autocomplete.propTypes = { SelectControl.propTypes = {
/** /**
* Class name applied to parent div. * Class name applied to parent div.
*/ */
@ -238,14 +296,15 @@ Autocomplete.propTypes = {
/** /**
* Help text to be appended beneath the input. * Help text to be appended beneath the input.
*/ */
help: PropTypes.oneOfType( [ help: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
PropTypes.string,
PropTypes.node,
] ),
/** /**
* Render tags inside input, otherwise render below input. * Render tags inside input, otherwise render below input.
*/ */
inlineTags: PropTypes.bool, inlineTags: PropTypes.bool,
/**
* Allow the select options to be filtered by search input.
*/
isSearchable: PropTypes.bool,
/** /**
* A label to use for the main input. * A label to use for the main input.
*/ */
@ -265,10 +324,7 @@ Autocomplete.propTypes = {
options: PropTypes.arrayOf( options: PropTypes.arrayOf(
PropTypes.shape( { PropTypes.shape( {
isDisabled: PropTypes.bool, isDisabled: PropTypes.bool,
key: PropTypes.oneOfType( [ key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
PropTypes.number,
PropTypes.string,
] ).isRequired,
keywords: PropTypes.arrayOf( PropTypes.string ), keywords: PropTypes.arrayOf( PropTypes.string ),
label: PropTypes.string, label: PropTypes.string,
value: PropTypes.any, value: PropTypes.any,
@ -279,19 +335,19 @@ Autocomplete.propTypes = {
*/ */
placeholder: PropTypes.string, placeholder: PropTypes.string,
/** /**
* An array of objects describing selected values. If the label of the selected * An array of objects describing selected values or optionally a string for a single value.
* value is omitted, the Tag of that value will not be rendered inside the * If the label of the selected value is omitted, the Tag of that value will not
* search box. * be rendered inside the search box.
*/ */
selected: PropTypes.arrayOf( selected: PropTypes.oneOfType( [
PropTypes.shape( {
key: PropTypes.oneOfType( [
PropTypes.number,
PropTypes.string, PropTypes.string,
] ).isRequired, PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
label: PropTypes.string, label: PropTypes.string,
} ) } )
), ),
] ),
/** /**
* A limit for the number of results shown in the options menu. Set to 0 for no limit. * 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, staticList: PropTypes.bool,
}; };
Autocomplete.defaultProps = { SelectControl.defaultProps = {
excludeSelectedOptions: true, excludeSelectedOptions: true,
getSearchExpression: identity, getSearchExpression: identity,
inlineTags: false, inlineTags: false,
isSearchable: false,
onChange: noop, onChange: noop,
onFilter: identity, onFilter: identity,
onSearch: noop, onSearch: noop,
@ -333,4 +390,4 @@ export default compose( [
withSpokenMessages, withSpokenMessages,
withInstanceId, withInstanceId,
withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside 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 ) { componentDidUpdate( prevProps ) {
const { filteredOptions } = this.props; const { options, selectedIndex } = this.props;
// Remove old option refs to avoid memory leaks. // Remove old option refs to avoid memory leaks.
if ( ! isEqual( filteredOptions, prevProps.filteredOptions ) ) { if ( ! isEqual( options, prevProps.options ) ) {
this.optionRefs = {}; this.optionRefs = {};
} }
if ( selectedIndex !== prevProps.selectedIndex ) {
this.scrollToOption( selectedIndex );
}
} }
getOptionRef( index ) { getOptionRef( index ) {
@ -52,7 +56,14 @@ class List extends Component {
scrollToOption( index ) { scrollToOption( index ) {
const listbox = this.listbox.current; const listbox = this.listbox.current;
if ( listbox.scrollHeight > listbox.clientHeight ) { if ( listbox.scrollHeight <= listbox.clientHeight ) {
return;
}
if ( ! this.optionRefs[ index ] ) {
return;
}
const option = this.optionRefs[ index ].current; const option = this.optionRefs[ index ].current;
const scrollBottom = listbox.clientHeight + listbox.scrollTop; const scrollBottom = listbox.clientHeight + listbox.scrollTop;
const elementBottom = option.offsetTop + option.offsetHeight; const elementBottom = option.offsetTop + option.offsetHeight;
@ -62,49 +73,46 @@ class List extends Component {
listbox.scrollTop = option.offsetTop; listbox.scrollTop = option.offsetTop;
} }
} }
}
handleKeyDown( event ) { handleKeyDown( event ) {
const { filteredOptions, onChange, onSearch, selectedIndex } = this.props; const {
if ( filteredOptions.length === 0 ) { decrementSelectedIndex,
incrementSelectedIndex,
options,
onSearch,
selectedIndex,
setExpanded,
} = this.props;
if ( options.length === 0 ) {
return; return;
} }
let nextSelectedIndex;
switch ( event.keyCode ) { switch ( event.keyCode ) {
case UP: case UP:
nextSelectedIndex = null !== selectedIndex decrementSelectedIndex();
? ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1
: filteredOptions.length - 1;
onChange( nextSelectedIndex );
this.scrollToOption( nextSelectedIndex );
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
break; break;
case DOWN: case DOWN:
nextSelectedIndex = null !== selectedIndex incrementSelectedIndex();
? ( selectedIndex + 1 ) % filteredOptions.length
: 0;
onChange( nextSelectedIndex );
this.scrollToOption( nextSelectedIndex );
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
break; break;
case ENTER: case ENTER:
this.select( filteredOptions[ selectedIndex ] ); this.select( options[ selectedIndex ] );
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
break; break;
case LEFT: case LEFT:
case RIGHT: case RIGHT:
onChange( null ); setExpanded( false );
break; break;
case ESCAPE: case ESCAPE:
onChange( null ); setExpanded( false );
onSearch( null ); onSearch( null );
return; return;
@ -133,22 +141,22 @@ class List extends Component {
} }
render() { render() {
const { filteredOptions, instanceId, listboxId, selectedIndex, staticList } = this.props; const { instanceId, listboxId, options, selectedIndex, staticList } = this.props;
const listboxClasses = classnames( 'woocommerce-autocomplete__listbox', { const listboxClasses = classnames( 'woocommerce-select-control__listbox', {
'is-static': staticList, 'is-static': staticList,
} ); } );
return ( return (
<div ref={ this.listbox } id={ listboxId } role="listbox" className={ listboxClasses }> <div ref={ this.listbox } id={ listboxId } role="listbox" className={ listboxClasses }>
{ filteredOptions.map( ( option, index ) => ( { options.map( ( option, index ) => (
<Button <Button
ref={ this.getOptionRef( index ) } ref={ this.getOptionRef( index ) }
key={ option.key } key={ option.key }
id={ `woocommerce-autocomplete__option-${ instanceId }-${ option.key }` } id={ `woocommerce-select-control__option-${ instanceId }-${ option.key }` }
role="option" role="option"
aria-selected={ index === selectedIndex } aria-selected={ index === selectedIndex }
disabled={ option.isDisabled } disabled={ option.isDisabled }
className={ classnames( 'woocommerce-autocomplete__option', { className={ classnames( 'woocommerce-select-control__option', {
'is-selected': index === selectedIndex, 'is-selected': index === selectedIndex,
} ) } } ) }
onClick={ () => this.select( option ) } onClick={ () => this.select( option ) }
@ -164,22 +172,7 @@ class List extends Component {
List.propTypes = { List.propTypes = {
/** /**
* Array of filtered options to display. * ID of the main SelectControl instance.
*/
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.
*/ */
instanceId: PropTypes.number, instanceId: PropTypes.number,
/** /**
@ -190,14 +183,22 @@ List.propTypes = {
* Parent node to bind keyboard events to. * Parent node to bind keyboard events to.
*/ */
node: PropTypes.instanceOf( Element ).isRequired, 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. * Function to execute when an option is selected.
*/ */
onSelect: PropTypes.func, 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. * Integer for the currently selected item.
*/ */

View File

@ -1,4 +1,6 @@
.woocommerce-autocomplete { /** @format */
.woocommerce-select-control {
position: relative; position: relative;
.components-base-control { .components-base-control {
@ -11,7 +13,7 @@
padding: $gap-small $gap; padding: $gap-small $gap;
position: relative; position: relative;
.woocommerce-autocomplete__tags { .woocommerce-select-control__tags {
margin: $gap-small $gap-smallest 0 0; margin: $gap-small $gap-smallest 0 0;
} }
@ -24,16 +26,16 @@
align-items: center; align-items: center;
flex: 1; flex: 1;
margin-bottom: 0; margin-bottom: 0;
max-width: 100%;
} }
.components-base-control__label { .components-base-control__label {
left: 52px;
position: absolute; position: absolute;
color: $studio-gray-50; color: $studio-gray-50;
font-size: 16px; font-size: 16px;
} }
.woocommerce-autocomplete__control-input { .woocommerce-select-control__control-input {
font-size: 16px; font-size: 16px;
border: 0; border: 0;
box-shadow: none; box-shadow: none;
@ -43,10 +45,15 @@
padding-right: 0; padding-right: 0;
width: 100%; width: 100%;
line-height: 24px; line-height: 24px;
text-align: left;
&::-webkit-search-cancel-button { &::-webkit-search-cancel-button {
display: none; display: none;
} }
&:focus {
outline: none;
}
} }
i { i {
@ -61,14 +68,13 @@
} }
&.with-value .components-base-control__label, &.with-value .components-base-control__label,
&.is-active .components-base-control__label,
&.has-tags .components-base-control__label { &.has-tags .components-base-control__label {
font-size: 12px; font-size: 12px;
margin-top: -$gap-small; margin-top: -$gap-small;
} }
} }
.woocommerce-autocomplete__tags { .woocommerce-select-control__tags {
position: relative; position: relative;
margin: $gap-small 0; margin: $gap-small 0;
@ -81,7 +87,7 @@
max-height: 24px; max-height: 24px;
} }
.woocommerce-autocomplete__clear { .woocommerce-select-control__clear {
position: absolute; position: absolute;
right: 0; right: 0;
top: calc(50% - 10px); top: calc(50% - 10px);
@ -91,7 +97,7 @@
} }
} }
.woocommerce-autocomplete__listbox { .woocommerce-select-control__listbox {
background: $studio-white; background: $studio-white;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -111,7 +117,7 @@
} }
} }
.woocommerce-autocomplete__option { .woocommerce-select-control__option {
padding: $gap; padding: $gap;
min-height: 56px; min-height: 56px;
font-size: 16px; font-size: 16px;
@ -121,4 +127,15 @@
background: $studio-gray-0; 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; return null;
} }
const classes = classnames( 'woocommerce-autocomplete__tags', { const classes = classnames( 'woocommerce-select-control__tags', {
'has-clear': showClearButton, 'has-clear': showClearButton,
} ); } );
@ -69,14 +69,12 @@ class Tags extends Component {
/> />
); );
} ) } } ) }
{ showClearButton && <Button { showClearButton && (
className="woocommerce-autocomplete__clear" <Button className="woocommerce-select-control__clear" isLink onClick={ this.removeAll }>
isLink
onClick={ this.removeAll }
>
<Icon icon="dismiss" /> <Icon icon="dismiss" />
<span className="screen-reader-text">{ __( 'Clear all', 'woocommerce-admin' ) }</span> <span className="screen-reader-text">{ __( 'Clear all', 'woocommerce-admin' ) }</span>
</Button> } </Button>
) }
</div> </div>
); );
} }
@ -98,10 +96,7 @@ Tags.propTypes = {
*/ */
selected: PropTypes.arrayOf( selected: PropTypes.arrayOf(
PropTypes.shape( { PropTypes.shape( {
key: PropTypes.oneOfType( [ key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ).isRequired,
PropTypes.number,
PropTypes.string,
] ).isRequired,
label: PropTypes.string, 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 * Internal Dependencies
*/ */
@import 'animation-slider/style.scss'; @import 'animation-slider/style.scss';
@import 'autocomplete/style.scss';
@import 'calendar/style.scss'; @import 'calendar/style.scss';
@import 'card/style.scss'; @import 'card/style.scss';
@import 'chart/style.scss'; @import 'chart/style.scss';
@ -27,7 +26,7 @@
@import 'search-list-control/style.scss'; @import 'search-list-control/style.scss';
@import 'section-header/style.scss'; @import 'section-header/style.scss';
@import 'segmented-selection/style.scss'; @import 'segmented-selection/style.scss';
@import 'simple-select-control/style.scss'; @import 'select-control/style.scss';
@import 'split-button/style.scss'; @import 'split-button/style.scss';
@import 'stepper/style.scss'; @import 'stepper/style.scss';
@import 'spinner/style.scss'; @import 'spinner/style.scss';