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:
parent
19a69b7789
commit
1563971836
|
@ -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' ) }
|
||||
/>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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" },
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
) );
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
|
@ -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';
|
||||
|
|
|
@ -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 |
|
|
@ -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;
|
|
@ -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>
|
||||
)
|
||||
);
|
|
@ -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 );
|
|
@ -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.
|
||||
*/
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
} )
|
||||
),
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
|
@ -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.
|
|
@ -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 }
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 ) );
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue