Add Autocomplete component (https://github.com/woocommerce/woocommerce-admin/pull/2808)
This commit is contained in:
parent
234e4d513c
commit
7fbc4cc0df
|
@ -1,5 +1,6 @@
|
|||
[
|
||||
{ "component": "AnimationSlider" },
|
||||
{ "component": "Autocomplete" },
|
||||
{ "component": "Calendar", "render": "MyDateRange" },
|
||||
{ "component": "Card" },
|
||||
{ "component": "Chart" },
|
||||
|
|
|
@ -80,9 +80,13 @@ $adminbar-height-mobile: 46px;
|
|||
// Muriel
|
||||
$muriel-box-shadow-1dp: 0 2px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 1px 0 rgba(0, 0, 0, 0.14),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.12);
|
||||
$muriel-box-shadow-6dp: 0 3px 5px rgba(0, 0, 0, 0.2), 0 1px 18px rgba(0, 0, 0, 0.12),
|
||||
0 6px 10px rgba(0, 0, 0, 0.14);
|
||||
$muriel-box-shadow-8dp: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
// @todo These can be removed once color-studio is updated to >= 2.0.0.
|
||||
$new-muriel-gray-50: #676a74;
|
||||
$new-muriel-gray-5: #e3dfe2;
|
||||
$new-muriel-gray-20: #a7aaad;
|
||||
$new-muriel-gray-50: #646970;
|
||||
$new-muriel-gray-80: #2c3338;
|
||||
$new-muriel-primary-500: #005fb7;
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
* [ReportTable](components/analytics/report-table.md)
|
||||
* [Package components](components/packages/)
|
||||
* [AnimationSlider](components/packages/animation-slider.md)
|
||||
* [Autocomplete](components/packages/autocomplete.md)
|
||||
* [Calendar](components/packages/calendar.md)
|
||||
* [Card](components/packages/card.md)
|
||||
* [Chart](components/packages/chart.md)
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
`Autocomplete` (component)
|
||||
==========================
|
||||
|
||||
A search box which filters options while typing,
|
||||
allowing a user to select from an option from a filtered list.
|
||||
|
||||
Props
|
||||
-----
|
||||
|
||||
### `className`
|
||||
|
||||
- Type: String
|
||||
- Default: null
|
||||
|
||||
Class name applied to parent div.
|
||||
|
||||
### `excludeSelectedOptions`
|
||||
|
||||
- Type: Boolean
|
||||
- Default: `true`
|
||||
|
||||
Exclude already selected options from the options list.
|
||||
|
||||
### `onFilter`
|
||||
|
||||
- Type: Function
|
||||
- Default: `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`
|
||||
|
||||
- Type: Function
|
||||
- Default: `identity`
|
||||
|
||||
Function to add regex expression to the filter the results, passed the search query.
|
||||
|
||||
### `help`
|
||||
|
||||
- Type: One of type: string, node
|
||||
- Default: null
|
||||
|
||||
Help text to be appended beneath the input.
|
||||
|
||||
### `inlineTags`
|
||||
|
||||
- Type: Boolean
|
||||
- Default: `false`
|
||||
|
||||
Render tags inside input, otherwise render below input.
|
||||
|
||||
### `label`
|
||||
|
||||
- Type: String
|
||||
- Default: null
|
||||
|
||||
A label to use for the main input.
|
||||
|
||||
### `onChange`
|
||||
|
||||
- Type: Function
|
||||
- Default: `noop`
|
||||
|
||||
Function called when selected results change, passed result list.
|
||||
|
||||
### `onSearch`
|
||||
|
||||
- Type: Function
|
||||
- Default: `noop`
|
||||
|
||||
Function to run after the search query is updated, passed the search query.
|
||||
|
||||
### `options`
|
||||
|
||||
- **Required**
|
||||
- Type: Array
|
||||
- isDisabled: Boolean
|
||||
- key: One of type: number, string
|
||||
- keywords: Array
|
||||
String
|
||||
- label: String
|
||||
- value: *
|
||||
- Default: null
|
||||
|
||||
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`
|
||||
|
||||
- Type: String
|
||||
- Default: null
|
||||
|
||||
A placeholder for the search input.
|
||||
|
||||
### `selected`
|
||||
|
||||
- Type: Array
|
||||
- key: One of type: number, string
|
||||
- label: String
|
||||
- Default: `[]`
|
||||
|
||||
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`
|
||||
|
||||
- Type: Number
|
||||
- Default: `0`
|
||||
|
||||
A limit for the number of results shown in the options menu. Set to 0 for no limit.
|
||||
|
||||
### `multiple`
|
||||
|
||||
- Type: Boolean
|
||||
- Default: `false`
|
||||
|
||||
Allow multiple option selections.
|
||||
|
||||
### `showClearButton`
|
||||
|
||||
- Type: Boolean
|
||||
- Default: `false`
|
||||
|
||||
Render a 'Clear' button next to the input box to remove its contents.
|
||||
|
||||
### `hideBeforeSearch`
|
||||
|
||||
- Type: Boolean
|
||||
- Default: `false`
|
||||
|
||||
Only show list options after typing a search query.
|
||||
|
||||
### `staticList`
|
||||
|
||||
- Type: Boolean
|
||||
- Default: `false`
|
||||
|
||||
Render results list positioned statically instead of absolutely.
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { BACKSPACE } from '@wordpress/keycodes';
|
||||
import { Component, createRef } from '@wordpress/element';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Tags from './tags';
|
||||
|
||||
/**
|
||||
* A search control to allow user input to filter the options.
|
||||
*/
|
||||
class SearchControl extends Component {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
this.state = {
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
this.input = createRef();
|
||||
|
||||
this.updateSearch = this.updateSearch.bind( this );
|
||||
this.onFocus = this.onFocus.bind( this );
|
||||
this.onBlur = this.onBlur.bind( this );
|
||||
this.onKeyDown = this.onKeyDown.bind( this );
|
||||
}
|
||||
|
||||
updateSearch( onSearch ) {
|
||||
return event => {
|
||||
onSearch( event.target.value );
|
||||
};
|
||||
}
|
||||
|
||||
onFocus( onSearch ) {
|
||||
return event => {
|
||||
this.setState( { isActive: true } );
|
||||
onSearch( event.target.value );
|
||||
};
|
||||
}
|
||||
|
||||
onBlur() {
|
||||
this.setState( { isActive: false } );
|
||||
}
|
||||
|
||||
onKeyDown( event ) {
|
||||
const { selected, onChange, query } = this.props;
|
||||
|
||||
if ( BACKSPACE === event.keyCode && ! query && selected.length ) {
|
||||
onChange( [ ...selected.slice( 0, -1 ) ] );
|
||||
}
|
||||
}
|
||||
|
||||
renderInput() {
|
||||
const {
|
||||
activeId,
|
||||
hasTags,
|
||||
inlineTags,
|
||||
instanceId,
|
||||
isExpanded,
|
||||
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
|
||||
}
|
||||
/>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
hasTags,
|
||||
help,
|
||||
inlineTags,
|
||||
instanceId,
|
||||
label,
|
||||
query,
|
||||
} = this.props;
|
||||
const { isActive } = this.state;
|
||||
|
||||
return (
|
||||
// Disable reason: The div below visually simulates an input field. Its
|
||||
// child input is the actual input and responds accordingly to all keyboard
|
||||
// events, but click events need to be passed onto the child input. There
|
||||
// is no appropriate aria role for describing this situation, which is only
|
||||
// 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', {
|
||||
empty: ! query.length,
|
||||
'is-active': isActive,
|
||||
'has-tags': inlineTags && hasTags,
|
||||
'with-value': query.length,
|
||||
} ) }
|
||||
onClick={ () => {
|
||||
this.input.current.focus();
|
||||
} }
|
||||
>
|
||||
<i className="material-icons-outlined">search</i>
|
||||
{ inlineTags && <Tags { ...this.props } /> }
|
||||
|
||||
<div className="components-base-control__field">
|
||||
{ !! label &&
|
||||
<label
|
||||
htmlFor={ `woocommerce-autocomplete-${ 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 &&
|
||||
<p
|
||||
id={ `woocommerce-autocomplete-${ 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 */
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchControl.propTypes = {
|
||||
/**
|
||||
* Bool to determine if tags should be rendered.
|
||||
*/
|
||||
hasTags: PropTypes.bool,
|
||||
/**
|
||||
* Help text to be appended beneath the input.
|
||||
*/
|
||||
help: PropTypes.oneOfType( [
|
||||
PropTypes.string,
|
||||
PropTypes.node,
|
||||
] ),
|
||||
/**
|
||||
* Render tags inside input, otherwise render below input.
|
||||
*/
|
||||
inlineTags: PropTypes.bool,
|
||||
/**
|
||||
* ID of the main Autocomplete instance.
|
||||
*/
|
||||
instanceId: PropTypes.number,
|
||||
/**
|
||||
* A label to use for the main input.
|
||||
*/
|
||||
label: PropTypes.string,
|
||||
/**
|
||||
* ID used for a11y in the listbox.
|
||||
*/
|
||||
listboxId: PropTypes.string,
|
||||
/**
|
||||
* Function called when selected results change, passed result list.
|
||||
*/
|
||||
onChange: PropTypes.func,
|
||||
/**
|
||||
* Function called when input field is changed or focused.
|
||||
*/
|
||||
onSearch: PropTypes.func,
|
||||
/**
|
||||
* A placeholder for the search input.
|
||||
*/
|
||||
placeholder: PropTypes.string,
|
||||
/**
|
||||
* Search query entered by user.
|
||||
*/
|
||||
query: 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.
|
||||
*/
|
||||
selected: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
key: PropTypes.oneOfType( [
|
||||
PropTypes.number,
|
||||
PropTypes.string,
|
||||
] ).isRequired,
|
||||
label: PropTypes.string,
|
||||
} )
|
||||
),
|
||||
};
|
||||
|
||||
export default SearchControl;
|
|
@ -0,0 +1,87 @@
|
|||
```jsx
|
||||
import { Autocomplete } from '@woocommerce/components';
|
||||
|
||||
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' }
|
||||
},
|
||||
];
|
||||
const onChange = (selected) => {
|
||||
console.log( selected );
|
||||
}
|
||||
|
||||
const MyAutocomplete = 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>
|
||||
) );
|
||||
```
|
|
@ -0,0 +1,336 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import classnames from 'classnames';
|
||||
import { Component } from '@wordpress/element';
|
||||
import { escapeRegExp, findIndex, identity, noop } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withFocusOutside, withSpokenMessages } from '@wordpress/components';
|
||||
import { withInstanceId, compose } from '@wordpress/compose';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import List from './list';
|
||||
import Tags from './tags';
|
||||
import SearchControl 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 {
|
||||
static getInitialState() {
|
||||
return {
|
||||
filteredOptions: [],
|
||||
selectedIndex: 0,
|
||||
query: '',
|
||||
};
|
||||
}
|
||||
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
this.state = this.constructor.getInitialState();
|
||||
|
||||
this.bindNode = this.bindNode.bind( this );
|
||||
this.search = this.search.bind( this );
|
||||
this.selectOption = this.selectOption.bind( this );
|
||||
this.updateSelectedIndex = this.updateSelectedIndex.bind( this );
|
||||
}
|
||||
|
||||
bindNode( node ) {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
reset( selected = this.props.selected ) {
|
||||
const { multiple } = this.props;
|
||||
const initialState = this.constructor.getInitialState();
|
||||
|
||||
// Reset to the option label if not using tags.
|
||||
if ( ! multiple && selected.length && selected[ 0 ].label ) {
|
||||
initialState.query = selected[ 0 ].label;
|
||||
}
|
||||
|
||||
this.setState( initialState );
|
||||
}
|
||||
|
||||
handleFocusOutside() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
isExpanded() {
|
||||
const { filteredOptions, query } = this.state;
|
||||
|
||||
return filteredOptions.length > 0 || query;
|
||||
}
|
||||
|
||||
hasTags() {
|
||||
const { multiple, selected } = this.props;
|
||||
|
||||
if ( ! multiple ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selected.some( item => Boolean( item.label ) );
|
||||
}
|
||||
|
||||
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 );
|
||||
}
|
||||
|
||||
this.reset( newSelected );
|
||||
}
|
||||
|
||||
updateSelectedIndex( value ) {
|
||||
this.setState( { selectedIndex: value } );
|
||||
}
|
||||
|
||||
announce( filteredOptions ) {
|
||||
const { debouncedSpeak } = this.props;
|
||||
if ( ! debouncedSpeak ) {
|
||||
return;
|
||||
}
|
||||
if ( !! filteredOptions.length ) {
|
||||
debouncedSpeak(
|
||||
sprintf(
|
||||
_n(
|
||||
'%d result found, use up and down arrow keys to navigate.',
|
||||
'%d results found, use up and down arrow keys to navigate.',
|
||||
filteredOptions.length,
|
||||
'woocommerce-admin'
|
||||
),
|
||||
filteredOptions.length
|
||||
),
|
||||
'assertive'
|
||||
);
|
||||
} else {
|
||||
debouncedSpeak( __( 'No results.', 'woocommerce-admin' ), 'assertive' );
|
||||
}
|
||||
}
|
||||
|
||||
getFilteredOptions( query ) {
|
||||
const { excludeSelectedOptions, getSearchExpression, maxResults, onFilter, options, selected } = this.props;
|
||||
const selectedKeys = selected.map( option => option.key );
|
||||
const filtered = [];
|
||||
|
||||
// Create a regular expression to filter the options.
|
||||
const expression = getSearchExpression( escapeRegExp( query.trim() ) );
|
||||
const search = expression ? new RegExp( expression, 'i' ) : /^$/;
|
||||
|
||||
for ( let i = 0; i < options.length; i++ ) {
|
||||
const option = options[ i ];
|
||||
|
||||
if ( excludeSelectedOptions && selectedKeys.includes( option.key ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge label into keywords
|
||||
let { keywords = [] } = option;
|
||||
if ( 'string' === typeof option.label ) {
|
||||
keywords = [ ...keywords, option.label ];
|
||||
}
|
||||
|
||||
const isMatch = keywords.some( keyword => search.test( keyword ) );
|
||||
if ( ! isMatch ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered.push( option );
|
||||
|
||||
// Abort early if max reached
|
||||
if ( maxResults && filtered.length === maxResults ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return onFilter( filtered );
|
||||
}
|
||||
|
||||
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 ) );
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
inlineTags,
|
||||
instanceId,
|
||||
options,
|
||||
} = this.props;
|
||||
const { 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 activeId = isExpanded
|
||||
? `woocommerce-autocomplete__option-${ instanceId }-${ selectedKey }`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classnames( 'woocommerce-autocomplete', className, {
|
||||
'has-inline-tags': hasTags && inlineTags,
|
||||
} ) }
|
||||
ref={ this.bindNode }
|
||||
>
|
||||
<SearchControl
|
||||
{ ...this.props }
|
||||
{ ...this.state }
|
||||
activeId={ activeId }
|
||||
hasTags={ hasTags }
|
||||
isExpanded={ isExpanded }
|
||||
listboxId={ listboxId }
|
||||
onSearch={ this.search }
|
||||
/>
|
||||
{ ! inlineTags && hasTags && <Tags { ...this.props } /> }
|
||||
{ isExpanded &&
|
||||
<List
|
||||
{ ...this.props }
|
||||
{ ...this.state }
|
||||
activeId={ activeId }
|
||||
listboxId={ listboxId }
|
||||
node={ this.node }
|
||||
onChange={ this.updateSelectedIndex }
|
||||
onSelect={ this.selectOption }
|
||||
onSearch={ this.search }
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Autocomplete.propTypes = {
|
||||
/**
|
||||
* Class name applied to parent div.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* Exclude already selected options from the options list.
|
||||
*/
|
||||
excludeSelectedOptions: PropTypes.bool,
|
||||
/**
|
||||
* Add or remove items to the list of options after filtering,
|
||||
* passed the array of filtered options and should return an array of options.
|
||||
*/
|
||||
onFilter: PropTypes.func,
|
||||
/**
|
||||
* Function to add regex expression to the filter the results, passed the search query.
|
||||
*/
|
||||
getSearchExpression: PropTypes.func,
|
||||
/**
|
||||
* Help text to be appended beneath the input.
|
||||
*/
|
||||
help: PropTypes.oneOfType( [
|
||||
PropTypes.string,
|
||||
PropTypes.node,
|
||||
] ),
|
||||
/**
|
||||
* Render tags inside input, otherwise render below input.
|
||||
*/
|
||||
inlineTags: PropTypes.bool,
|
||||
/**
|
||||
* A label to use for the main input.
|
||||
*/
|
||||
label: PropTypes.string,
|
||||
/**
|
||||
* Function called when selected results change, passed result list.
|
||||
*/
|
||||
onChange: PropTypes.func,
|
||||
/**
|
||||
* Function to run after the search query is updated, passed the search query.
|
||||
*/
|
||||
onSearch: PropTypes.func,
|
||||
/**
|
||||
* An array of objects for the options list. The option along with its key, label and
|
||||
* value will be returned in the onChange event.
|
||||
*/
|
||||
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,
|
||||
/**
|
||||
* A placeholder for the search input.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
selected: 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.
|
||||
*/
|
||||
maxResults: PropTypes.number,
|
||||
/**
|
||||
* Allow multiple option selections.
|
||||
*/
|
||||
multiple: PropTypes.bool,
|
||||
/**
|
||||
* Render a 'Clear' button next to the input box to remove its contents.
|
||||
*/
|
||||
showClearButton: PropTypes.bool,
|
||||
/**
|
||||
* Only show list options after typing a search query.
|
||||
*/
|
||||
hideBeforeSearch: PropTypes.bool,
|
||||
/**
|
||||
* Render results list positioned statically instead of absolutely.
|
||||
*/
|
||||
staticList: PropTypes.bool,
|
||||
};
|
||||
|
||||
Autocomplete.defaultProps = {
|
||||
excludeSelectedOptions: true,
|
||||
getSearchExpression: identity,
|
||||
inlineTags: false,
|
||||
onChange: noop,
|
||||
onFilter: identity,
|
||||
onSearch: noop,
|
||||
maxResults: 0,
|
||||
multiple: false,
|
||||
selected: [],
|
||||
showClearButton: false,
|
||||
hideBeforeSearch: false,
|
||||
staticList: false,
|
||||
};
|
||||
|
||||
export default compose( [
|
||||
withSpokenMessages,
|
||||
withInstanceId,
|
||||
withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside
|
||||
] )( Autocomplete );
|
|
@ -0,0 +1,211 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Button } from '@wordpress/components';
|
||||
import classnames from 'classnames';
|
||||
import { Component, createRef } from '@wordpress/element';
|
||||
import { isEqual } from 'lodash';
|
||||
import { ENTER, ESCAPE, UP, DOWN, LEFT, TAB, RIGHT } from '@wordpress/keycodes';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* A list box that displays filtered options after search.
|
||||
*/
|
||||
class List extends Component {
|
||||
constructor() {
|
||||
super( ...arguments );
|
||||
|
||||
this.handleKeyDown = this.handleKeyDown.bind( this );
|
||||
this.select = this.select.bind( this );
|
||||
this.optionRefs = {};
|
||||
this.listbox = createRef();
|
||||
}
|
||||
|
||||
componentDidUpdate( prevProps ) {
|
||||
const { filteredOptions } = this.props;
|
||||
|
||||
// Remove old option refs to avoid memory leaks.
|
||||
if ( ! isEqual( filteredOptions, prevProps.filteredOptions ) ) {
|
||||
this.optionRefs = {};
|
||||
}
|
||||
}
|
||||
|
||||
getOptionRef( index ) {
|
||||
if ( ! this.optionRefs.hasOwnProperty( index ) ) {
|
||||
this.optionRefs[ index ] = createRef();
|
||||
}
|
||||
|
||||
return this.optionRefs[ index ];
|
||||
}
|
||||
|
||||
select( option ) {
|
||||
const { onSelect } = this.props;
|
||||
|
||||
if ( option.isDisabled ) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect( option );
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown( event ) {
|
||||
const { filteredOptions, onChange, onSearch, selectedIndex } = this.props;
|
||||
if ( filteredOptions.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 );
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
|
||||
case TAB:
|
||||
case DOWN:
|
||||
nextSelectedIndex = null !== selectedIndex
|
||||
? ( selectedIndex + 1 ) % filteredOptions.length
|
||||
: 0;
|
||||
onChange( nextSelectedIndex );
|
||||
this.scrollToOption( nextSelectedIndex );
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
|
||||
case ENTER:
|
||||
this.select( filteredOptions[ selectedIndex ] );
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
|
||||
case LEFT:
|
||||
case RIGHT:
|
||||
onChange( null );
|
||||
break;
|
||||
|
||||
case ESCAPE:
|
||||
onChange( null );
|
||||
onSearch( null );
|
||||
return;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toggleKeyEvents( isListening ) {
|
||||
const { node } = this.props;
|
||||
// This exists because we must capture ENTER key presses before RichText.
|
||||
// It seems that react fires the simulated capturing events after the
|
||||
// native browser event has already bubbled so we can't stopPropagation
|
||||
// and avoid RichText getting the event from TinyMCE, hence we must
|
||||
// register a native event handler.
|
||||
const handler = isListening ? 'addEventListener' : 'removeEventListener';
|
||||
node[ handler ]( 'keydown', this.handleKeyDown, true );
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.toggleKeyEvents( true );
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.toggleKeyEvents( false );
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filteredOptions, instanceId, listboxId, selectedIndex, staticList } = this.props;
|
||||
const listboxClasses = classnames( 'woocommerce-autocomplete__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 ) }
|
||||
>
|
||||
{ option.label }
|
||||
</Button>
|
||||
) ) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
instanceId: PropTypes.number,
|
||||
/**
|
||||
* ID used for a11y in the listbox.
|
||||
*/
|
||||
listboxId: PropTypes.string,
|
||||
/**
|
||||
* 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,
|
||||
/**
|
||||
* Integer for the currently selected item.
|
||||
*/
|
||||
selectedIndex: PropTypes.number,
|
||||
/**
|
||||
* Bool to determine if the list should be positioned absolutely or staticly.
|
||||
*/
|
||||
staticList: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default List;
|
|
@ -0,0 +1,119 @@
|
|||
.woocommerce-autocomplete {
|
||||
position: relative;
|
||||
|
||||
.components-base-control {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid $new-muriel-gray-20;
|
||||
border-radius: 3px;
|
||||
background: $white;
|
||||
padding: $gap-small $gap;
|
||||
position: relative;
|
||||
|
||||
.woocommerce-autocomplete__tags {
|
||||
margin: $gap-small $gap-smallest 0 0;
|
||||
}
|
||||
|
||||
.woocommerce-tag {
|
||||
max-height: 20px;
|
||||
}
|
||||
|
||||
.components-base-control__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.components-base-control__label {
|
||||
left: 52px;
|
||||
position: absolute;
|
||||
color: $new-muriel-gray-50;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.woocommerce-autocomplete__control-input {
|
||||
font-size: 16px;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
color: $new-muriel-gray-80;
|
||||
margin: $gap-small 0 0 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
width: 100%;
|
||||
line-height: 24px;
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
color: #636d75;
|
||||
margin-right: $gap-small;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
&.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 {
|
||||
position: relative;
|
||||
margin: $gap-small 0;
|
||||
|
||||
&.has-clear {
|
||||
padding-right: $gap-large;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-tag {
|
||||
max-height: 24px;
|
||||
}
|
||||
|
||||
.woocommerce-autocomplete__clear {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(50% - 10px);
|
||||
|
||||
& > .dashicon {
|
||||
color: #c9c9c9;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-autocomplete__listbox {
|
||||
background: $white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
box-shadow: $muriel-box-shadow-6dp;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 56px;
|
||||
z-index: 10;
|
||||
overflow: scroll;
|
||||
max-height: 350px;
|
||||
|
||||
&.is-static {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-autocomplete__option {
|
||||
padding: $gap;
|
||||
min-height: 56px;
|
||||
font-size: 16px;
|
||||
|
||||
&.is-selected,
|
||||
&:hover {
|
||||
background: $muriel-gray-0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { Button, Icon } from '@wordpress/components';
|
||||
import classnames from 'classnames';
|
||||
import { Component } from '@wordpress/element';
|
||||
import { findIndex } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Tag from '../tag';
|
||||
|
||||
/**
|
||||
* A list of tags to display selected items.
|
||||
*/
|
||||
class Tags extends Component {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
this.removeAll = this.removeAll.bind( this );
|
||||
this.removeResult = this.removeResult.bind( this );
|
||||
}
|
||||
|
||||
removeAll() {
|
||||
const { onChange } = this.props;
|
||||
onChange( [] );
|
||||
}
|
||||
|
||||
removeResult( key ) {
|
||||
return () => {
|
||||
const { selected, onChange } = this.props;
|
||||
const i = findIndex( selected, { key } );
|
||||
onChange( [ ...selected.slice( 0, i ), ...selected.slice( i + 1 ) ] );
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selected, showClearButton } = this.props;
|
||||
if ( ! selected.length ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classes = classnames( 'woocommerce-autocomplete__tags', {
|
||||
'has-clear': showClearButton,
|
||||
} );
|
||||
|
||||
return (
|
||||
<div className={ classes }>
|
||||
{ selected.map( ( item, i ) => {
|
||||
if ( ! item.label ) {
|
||||
return null;
|
||||
}
|
||||
const screenReaderLabel = sprintf(
|
||||
__( '%1$s (%2$s of %3$s)', 'woocommerce-admin' ),
|
||||
item.label,
|
||||
i + 1,
|
||||
selected.length
|
||||
);
|
||||
return (
|
||||
<Tag
|
||||
key={ item.key }
|
||||
id={ item.key }
|
||||
label={ item.label }
|
||||
remove={ this.removeResult }
|
||||
screenReaderLabel={ screenReaderLabel }
|
||||
/>
|
||||
);
|
||||
} ) }
|
||||
{ showClearButton && <Button
|
||||
className="woocommerce-autocomplete__clear"
|
||||
isLink
|
||||
onClick={ this.removeAll }
|
||||
>
|
||||
<Icon icon="dismiss" />
|
||||
<span className="screen-reader-text">{ __( 'Clear all', 'woocommerce-admin' ) }</span>
|
||||
</Button> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Tags.propTypes = {
|
||||
/**
|
||||
* Function called when selected results change, passed result list.
|
||||
*/
|
||||
onChange: PropTypes.func,
|
||||
/**
|
||||
* Function to execute when an option is selected.
|
||||
*/
|
||||
onSelect: PropTypes.func,
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
selected: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
key: PropTypes.oneOfType( [
|
||||
PropTypes.number,
|
||||
PropTypes.string,
|
||||
] ).isRequired,
|
||||
label: PropTypes.string,
|
||||
} )
|
||||
),
|
||||
/**
|
||||
* Render a 'Clear' button next to the input box to remove its contents.
|
||||
*/
|
||||
showClearButton: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Tags;
|
|
@ -0,0 +1,161 @@
|
|||
/** @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,6 +7,7 @@ import 'react-dates/initialize';
|
|||
|
||||
export { default as AdvancedFilters } from './filters/advanced';
|
||||
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';
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* Internal Dependencies
|
||||
*/
|
||||
@import 'animation-slider/style.scss';
|
||||
@import 'autocomplete/style.scss';
|
||||
@import 'calendar/style.scss';
|
||||
@import 'card/style.scss';
|
||||
@import 'chart/style.scss';
|
||||
|
|
Loading…
Reference in New Issue