From 1563971836bf3bb93363c17196a497cea33b1376 Mon Sep 17 00:00:00 2001 From: Joshua T Flowers Date: Tue, 8 Oct 2019 06:42:32 +0800 Subject: [PATCH] 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 --- .../settings/general/store-address.js | 14 +- .../profile-wizard/steps/business-details.js | 48 ++--- .../dashboard/profile-wizard/style.scss | 29 +++ .../client/devdocs/examples.json | 3 +- .../docs/components/_sidebar.md | 5 +- .../packages/components/CHANGELOG.md | 5 + .../packages/components/package.json | 2 +- .../components/src/autocomplete/README.md | 52 ----- .../src/autocomplete/docs/example.js | 90 --------- .../components/src/autocomplete/test/index.js | 161 --------------- .../packages/components/src/index.js | 3 +- .../components/src/select-control/README.md | 51 +++++ .../control.js | 157 +++++++++------ .../src/select-control/docs/example.js | 121 +++++++++++ .../{autocomplete => select-control}/index.js | 185 +++++++++++------ .../{autocomplete => select-control}/list.js | 133 ++++++------ .../style.scss | 35 +++- .../{autocomplete => select-control}/tags.js | 21 +- .../src/select-control/test/index.js | 159 +++++++++++++++ .../src/simple-select-control/README.md | 45 ----- .../src/simple-select-control/docs/example.js | 56 ------ .../src/simple-select-control/index.js | 190 ------------------ .../src/simple-select-control/style.scss | 125 ------------ .../packages/components/src/style.scss | 3 +- 24 files changed, 725 insertions(+), 968 deletions(-) delete mode 100644 plugins/woocommerce-admin/packages/components/src/autocomplete/README.md delete mode 100644 plugins/woocommerce-admin/packages/components/src/autocomplete/docs/example.js delete mode 100644 plugins/woocommerce-admin/packages/components/src/autocomplete/test/index.js create mode 100644 plugins/woocommerce-admin/packages/components/src/select-control/README.md rename plugins/woocommerce-admin/packages/components/src/{autocomplete => select-control}/control.js (55%) create mode 100644 plugins/woocommerce-admin/packages/components/src/select-control/docs/example.js rename plugins/woocommerce-admin/packages/components/src/{autocomplete => select-control}/index.js (61%) rename plugins/woocommerce-admin/packages/components/src/{autocomplete => select-control}/list.js (57%) rename plugins/woocommerce-admin/packages/components/src/{autocomplete => select-control}/style.scss (78%) rename plugins/woocommerce-admin/packages/components/src/{autocomplete => select-control}/tags.js (83%) create mode 100644 plugins/woocommerce-admin/packages/components/src/select-control/test/index.js delete mode 100644 plugins/woocommerce-admin/packages/components/src/simple-select-control/README.md delete mode 100644 plugins/woocommerce-admin/packages/components/src/simple-select-control/docs/example.js delete mode 100644 plugins/woocommerce-admin/packages/components/src/simple-select-control/index.js delete mode 100644 plugins/woocommerce-admin/packages/components/src/simple-select-control/style.scss diff --git a/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.js b/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.js index d28162f9690..657c417c588 100644 --- a/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.js +++ b/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.js @@ -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' ) } /> diff --git a/plugins/woocommerce-admin/client/dashboard/profile-wizard/steps/business-details.js b/plugins/woocommerce-admin/client/dashboard/profile-wizard/steps/business-details.js index 388146fc34e..f3032aa1f09 100644 --- a/plugins/woocommerce-admin/client/dashboard/profile-wizard/steps/business-details.js +++ b/plugins/woocommerce-admin/client/dashboard/profile-wizard/steps/business-details.js @@ -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 {

- - ` component to ``. +- Added `isSearchable` prop to `` to allow simple select dropdowns. +- Removed the `` component. + # 4.0.0 - Added a new `` component. - Changed the `` `description` prop to `content` and allowed content nodes to be passed in addition to strings. diff --git a/plugins/woocommerce-admin/packages/components/package.json b/plugins/woocommerce-admin/packages/components/package.json index 0fb3d84953f..25662e996c8 100644 --- a/plugins/woocommerce-admin/packages/components/package.json +++ b/plugins/woocommerce-admin/packages/components/package.json @@ -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", diff --git a/plugins/woocommerce-admin/packages/components/src/autocomplete/README.md b/plugins/woocommerce-admin/packages/components/src/autocomplete/README.md deleted file mode 100644 index 7c8e1d5b0ff..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/autocomplete/README.md +++ /dev/null @@ -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' }, - }, -]; - - 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 diff --git a/plugins/woocommerce-admin/packages/components/src/autocomplete/docs/example.js b/plugins/woocommerce-admin/packages/components/src/autocomplete/docs/example.js deleted file mode 100644 index d842eee2bd2..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/autocomplete/docs/example.js +++ /dev/null @@ -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 } ) => ( -
- setState( { singleSelected: selected } ) } - options={ options } - placeholder="Start typing to filter options..." - selected={ singleSelected } - /> -
- setState( { inlineSelected: selected } ) } - options={ options } - placeholder="Start typing to filter options..." - selected={ inlineSelected } - /> -
- setState( { multipleSelected: selected } ) } - options={ options } - placeholder="Start typing to filter options..." - selected={ multipleSelected } - showClearButton - /> -
-) ); diff --git a/plugins/woocommerce-admin/packages/components/src/autocomplete/test/index.js b/plugins/woocommerce-admin/packages/components/src/autocomplete/test/index.js deleted file mode 100644 index 5daffca5c82..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/autocomplete/test/index.js +++ /dev/null @@ -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.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.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.setState( { - query, - } ); - - autocomplete.instance().search( ' ' + query + ' ' ); - autocomplete.update(); - - expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 2 ); - } ); - - it( 'limits results', () => { - const autocomplete = mount( - - ); - 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.instance().search( '' ); - autocomplete.update(); - - expect( autocomplete.find( Button ).filter( '.' + optionClassname ).length ).toBe( 3 ); - } ); - - it( 'shows options after query', () => { - const autocomplete = mount( - - ); - - 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( - 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( - 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 ); - } ); -} ); diff --git a/plugins/woocommerce-admin/packages/components/src/index.js b/plugins/woocommerce-admin/packages/components/src/index.js index 88f60b2a779..c713cd661e9 100644 --- a/plugins/woocommerce-admin/packages/components/src/index.js +++ b/plugins/woocommerce-admin/packages/components/src/index.js @@ -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'; diff --git a/plugins/woocommerce-admin/packages/components/src/select-control/README.md b/plugins/woocommerce-admin/packages/components/src/select-control/README.md new file mode 100644 index 00000000000..9ce0bc7b9d9 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/select-control/README.md @@ -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' }, + }, +]; + + 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 | diff --git a/plugins/woocommerce-admin/packages/components/src/autocomplete/control.js b/plugins/woocommerce-admin/packages/components/src/select-control/control.js similarity index 55% rename from plugins/woocommerce-admin/packages/components/src/autocomplete/control.js rename to plugins/woocommerce-admin/packages/components/src/select-control/control.js index 96e5675d12f..83e6734be9b 100644 --- a/plugins/woocommerce-admin/packages/components/src/autocomplete/control.js +++ b/plugins/woocommerce-admin/packages/components/src/select-control/control.js @@ -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
{ selected[ 0 ].label }
; } renderInput() { @@ -63,46 +100,50 @@ class SearchControl extends Component { inlineTags, instanceId, isExpanded, + isSearchable, listboxId, onSearch, placeholder, - query, } = this.props; const { isActive } = this.state; - return ; + return ( + + ); + } + + 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 */
{ this.input.current.focus(); } } > - search + { isSearchable && search } { inlineTags && }
- { !! label && + { !! label && ( - } + ) } { this.renderInput() } - { inlineTags && - { __( 'Move backward for selected items', 'woocommerce-admin' ) } - } - { !! help && + { inlineTags && ( + + { __( 'Move backward for selected items', 'woocommerce-admin' ) } + + ) } + { !! help && (

{ help }

- } + ) }
/* 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; diff --git a/plugins/woocommerce-admin/packages/components/src/select-control/docs/example.js b/plugins/woocommerce-admin/packages/components/src/select-control/docs/example.js new file mode 100644 index 00000000000..b0123bb5d91 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/select-control/docs/example.js @@ -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, + } ) => ( +
+ setState( { simpleSelected: selected } ) } + options={ options } + placeholder="Start typing to filter options..." + selected={ simpleSelected } + /> +
+ setState( { simpleMultipleSelected: selected } ) } + options={ options } + placeholder="Start typing to filter options..." + selected={ simpleMultipleSelected } + /> +
+ setState( { singleSelected: selected } ) } + options={ options } + placeholder="Start typing to filter options..." + selected={ singleSelected } + /> +
+ setState( { inlineSelected: selected } ) } + options={ options } + placeholder="Start typing to filter options..." + selected={ inlineSelected } + /> +
+ setState( { multipleSelected: selected } ) } + options={ options } + placeholder="Start typing to filter options..." + selected={ multipleSelected } + showClearButton + /> +
+ ) +); diff --git a/plugins/woocommerce-admin/packages/components/src/autocomplete/index.js b/plugins/woocommerce-admin/packages/components/src/select-control/index.js similarity index 61% rename from plugins/woocommerce-admin/packages/components/src/autocomplete/index.js rename to plugins/woocommerce-admin/packages/components/src/select-control/index.js index 3f4e1bbf1cf..1ab0ff3ecbf 100644 --- a/plugins/woocommerce-admin/packages/components/src/autocomplete/index.js +++ b/plugins/woocommerce-admin/packages/components/src/select-control/index.js @@ -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 (
- - { ! inlineTags && hasTags && } - { isExpanded && + { ! inlineTags && hasTags && } + { isExpanded && ( - } + ) }
); } } -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 ); diff --git a/plugins/woocommerce-admin/packages/components/src/autocomplete/list.js b/plugins/woocommerce-admin/packages/components/src/select-control/list.js similarity index 57% rename from plugins/woocommerce-admin/packages/components/src/autocomplete/list.js rename to plugins/woocommerce-admin/packages/components/src/select-control/list.js index e045e58f1e7..e4e07b598ba 100644 --- a/plugins/woocommerce-admin/packages/components/src/autocomplete/list.js +++ b/plugins/woocommerce-admin/packages/components/src/select-control/list.js @@ -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 (
- { filteredOptions.map( ( option, index ) => ( - - ) ) } + { options.map( ( option, index ) => ( + + ) ) }
); } @@ -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. */ diff --git a/plugins/woocommerce-admin/packages/components/src/autocomplete/style.scss b/plugins/woocommerce-admin/packages/components/src/select-control/style.scss similarity index 78% rename from plugins/woocommerce-admin/packages/components/src/autocomplete/style.scss rename to plugins/woocommerce-admin/packages/components/src/select-control/style.scss index 98b5c6be494..d2e4a18ecad 100644 --- a/plugins/woocommerce-admin/packages/components/src/autocomplete/style.scss +++ b/plugins/woocommerce-admin/packages/components/src/select-control/style.scss @@ -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; + } + } } diff --git a/plugins/woocommerce-admin/packages/components/src/autocomplete/tags.js b/plugins/woocommerce-admin/packages/components/src/select-control/tags.js similarity index 83% rename from plugins/woocommerce-admin/packages/components/src/autocomplete/tags.js rename to plugins/woocommerce-admin/packages/components/src/select-control/tags.js index e47c9b86224..295a3458b5c 100644 --- a/plugins/woocommerce-admin/packages/components/src/autocomplete/tags.js +++ b/plugins/woocommerce-admin/packages/components/src/select-control/tags.js @@ -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 && } + { showClearButton && ( + + ) } ); } @@ -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, } ) ), diff --git a/plugins/woocommerce-admin/packages/components/src/select-control/test/index.js b/plugins/woocommerce-admin/packages/components/src/select-control/test/index.js new file mode 100644 index 00000000000..60fde02bdb5 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/select-control/test/index.js @@ -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.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.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.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.setState( { + query, + } ); + + selectControl.instance().search( ' ' + query + ' ' ); + selectControl.update(); + + expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 2 ); + } ); + + it( 'limits results', () => { + const selectControl = mount( + + ); + 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.instance().search( '' ); + selectControl.update(); + + expect( selectControl.find( Button ).filter( '.' + optionClassname ).length ).toBe( 3 ); + } ); + + it( 'shows options after query', () => { + const selectControl = mount( + + ); + + 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( + + 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( + 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 ); + } ); +} ); diff --git a/plugins/woocommerce-admin/packages/components/src/simple-select-control/README.md b/plugins/woocommerce-admin/packages/components/src/simple-select-control/README.md deleted file mode 100644 index 38a97a9c5d0..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/simple-select-control/README.md +++ /dev/null @@ -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', - }, -]; - - 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. \ No newline at end of file diff --git a/plugins/woocommerce-admin/packages/components/src/simple-select-control/docs/example.js b/plugins/woocommerce-admin/packages/components/src/simple-select-control/docs/example.js deleted file mode 100644 index 4090f6c8b84..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/simple-select-control/docs/example.js +++ /dev/null @@ -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 ( - this.setState( { pet: value } ) } - options={ petOptions } - value={ pet } - /> - ); - } -} diff --git a/plugins/woocommerce-admin/packages/components/src/simple-select-control/index.js b/plugins/woocommerce-admin/packages/components/src/simple-select-control/index.js deleted file mode 100644 index cd322083c19..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/simple-select-control/index.js +++ /dev/null @@ -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 ( - ( - - - { !! help &&

{ help }

} -
- ) } - renderContent={ ( { onClose } ) => ( - - { map( options, ( option ) => { - const optionValue = option.value; - const optionLabel = option.label; - const optionDisabled = option.disabled || false; - const isSelected = ( currentValue === optionValue ); - return ( - - ); - } ) } - - ) } - /> - ); - } -} - -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 ) ); diff --git a/plugins/woocommerce-admin/packages/components/src/simple-select-control/style.scss b/plugins/woocommerce-admin/packages/components/src/simple-select-control/style.scss deleted file mode 100644 index f35519070c2..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/simple-select-control/style.scss +++ /dev/null @@ -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; - } - } -} diff --git a/plugins/woocommerce-admin/packages/components/src/style.scss b/plugins/woocommerce-admin/packages/components/src/style.scss index 48c6a40f8f3..e7256d57564 100644 --- a/plugins/woocommerce-admin/packages/components/src/style.scss +++ b/plugins/woocommerce-admin/packages/components/src/style.scss @@ -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';