This commit is contained in:
Joshua T Flowers 2019-08-21 14:41:42 +08:00 committed by GitHub
parent 234e4d513c
commit 7fbc4cc0df
13 changed files with 1395 additions and 1 deletions

View File

@ -1,5 +1,6 @@
[
{ "component": "AnimationSlider" },
{ "component": "Autocomplete" },
{ "component": "Calendar", "render": "MyDateRange" },
{ "component": "Card" },
{ "component": "Chart" },

View File

@ -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;

View File

@ -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)

View File

@ -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.

View File

@ -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;

View File

@ -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>
) );
```

View File

@ -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 );

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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 );
} );
} );

View File

@ -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';

View File

@ -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';