* Add SearchListControl as exported component

* Add entry to changelog (and fix versioning)

* Fix repeating character mixin

* Update textdomain
This commit is contained in:
Kelly Dwan 2019-02-20 15:18:27 -05:00 committed by GitHub
parent 00ff9fa8b5
commit b98c05c331
21 changed files with 3762 additions and 9 deletions

View File

@ -18,6 +18,7 @@
{ "component": "ProductImage" },
{ "component": "Rating" },
{ "component": "Search" },
{ "component": "SearchListControl" },
{ "component": "Section" },
{ "component": "SegmentedSelection" },
{ "component": "SplitButton" },

View File

@ -59,3 +59,38 @@
}
}
}
// Hide an element from sighted users, but availble to screen reader users.
@mixin visually-hidden() {
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
width: 1px;
margin: -1px;
overflow: hidden;
/* Many screen reader and browser combinations announce broken words as they would appear visually. */
overflow-wrap: normal !important;
word-wrap: normal !important;
}
// Unhide a visually hidden element
@mixin visually-shown() {
clip: auto;
clip-path: none;
height: auto;
width: auto;
margin: unset;
overflow: hidden;
}
// Create a string-repeat function
@function str-repeat($character, $n) {
@if $n == 0 {
@return '';
}
$c: '';
@for $i from 1 through $n {
$c: $c + $character;
}
@return $c;
}

View File

@ -26,6 +26,7 @@
* [Pagination](components/packages/pagination.md)
* [ProductImage](components/packages/product-image.md)
* [Rating](components/packages/rating.md)
* [SearchListControl](components/packages/search-list-control.md)
* [Search](components/packages/search.md)
* [SectionHeader](components/packages/section-header.md)
* [Section](components/packages/section.md)

View File

@ -21,6 +21,13 @@ Allowed intervals to show in a dropdown.
Base chart value. If no data value is different than the baseValue, the
`emptyMessage` will be displayed if provided.
### `chartType`
- Type: One of: 'bar', 'line'
- Default: `'line'`
Chart type of either `line` or `bar`.
### `data`
- Type: Array
@ -143,13 +150,6 @@ A number formatting string or function to format the value displayed in the tool
A string to use as a title for the tooltip. Takes preference over `tooltipLabelFormat`.
### `type`
- Type: One of: 'bar', 'line'
- Default: `'line'`
Chart type of either `line` or `bar`.
### `valueType`
- Type: String

View File

@ -0,0 +1,176 @@
`SearchListControl` (component)
===============================
Component to display a searchable, selectable list of items.
Props
-----
### `className`
- Type: String
- Default: null
Additional CSS classes.
### `isHierarchical`
- Type: Boolean
- Default: null
Whether the list of items is hierarchical or not. If true, each list item is expected to
have a parent property.
### `isLoading`
- Type: Boolean
- Default: null
Whether the list of items is still loading.
### `isSingle`
- Type: Boolean
- Default: null
Restrict selections to one item.
### `list`
- Type: Array
- id: Number
- name: String
- Default: null
A complete list of item objects, each with id, name properties. This is displayed as a
clickable/keyboard-able list, and possibly filtered by the search term (searches name).
### `messages`
- Type: Object
- clear: String - A more detailed label for the "Clear all" button, read to screen reader users.
- list: String - Label for the list of selectable items, only read to screen reader users.
- noItems: String - Message to display when the list is empty (implies nothing loaded from the server
or parent component).
- noResults: String - Message to display when no matching results are found. %s is the search term.
- search: String - Label for the search input
- selected: Function - Label for the selected items. This is actually a function, so that we can pass
through the count of currently selected items.
- updated: String - Label indicating that search results have changed, read to screen reader users.
- Default: null
Messages displayed or read to the user. Configure these to reflect your object type.
See `defaultMessages` above for examples.
### `onChange`
- **Required**
- Type: Function
- Default: null
Callback fired when selected items change, whether added, cleared, or removed.
Passed an array of item objects (as passed in via props.list).
### `renderItem`
- Type: Function
- Default: null
Callback to render each item in the selection list, allows any custom object-type rendering.
### `selected`
- **Required**
- Type: Array
- Default: null
The list of currently selected items.
### `search`
- Type: String
- Default: null
### `setState`
- Type: Function
- Default: null
### `debouncedSpeak`
- Type: Function
- Default: null
### `instanceId`
- Type: Number
- Default: null
`SearchListItem` (component)
============================
Props
-----
### `className`
- Type: String
- Default: null
Additional CSS classes.
### `depth`
- Type: Number
- Default: `0`
Depth, non-zero if the list is hierarchical.
### `item`
- Type: Object
- Default: null
Current item to display.
### `isSelected`
- Type: Boolean
- Default: null
Whether this item is selected.
### `isSingle`
- Type: Boolean
- Default: null
Whether this should only display a single item (controls radio vs checkbox icon).
### `onSelect`
- Type: Function
- Default: null
Callback for selecting the item.
### `search`
- Type: String
- Default: `''`
Search string, used to highlight the substring in the item name.
### `showCount`
- Type: Boolean
- Default: `false`
Toggles the "count" bubble on/off.

View File

@ -1,15 +1,18 @@
# 1.5.0 (unreleased)
# 1.6.0 (unreleased)
- Chart component: new props `emptyMessage` and `baseValue`. When an empty message is provided, it will be displayed on top of the chart if there are no values different than `baseValue`.
- Chart component: remove d3-array dependency.
- Chart component: fix display when there is no data.
- Chart component: change chart type query parameter to `chartType`.
- Bug fix for `<StockReportTable />` returning N/A instead of zero.
- Add new component: SearchListControl for displaying and filtering a selectable list of items.
# 1.5.0
- Improves display of charts where all values are 0.
- Fix X-axis labels in hourly bar charts.
- New `<Search>` prop named `showClearButton`, that will display a 'Clear' button when the search box contains one or more tags.
- Number of selectable chart elements is now limited to 5.
- Color scale logic for charts with lots of items has been fixed.
- Update `@woocommerce/navigation` to v2.0.0
- Bug fix for `<StockReportTable />` returning N/A instead of zero.
# 1.4.2
- Add emoji-flags dependency

View File

@ -35,6 +35,8 @@ export { default as Rating } from './rating';
export { default as ReportFilters } from './filters';
export { default as ReviewRating } from './rating/review';
export { default as Search } from './search';
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 SplitButton } from './split-button';

View File

@ -0,0 +1,29 @@
```jsx
import { SearchListControl } from '@woocommerce/components';
const MySearchListControl = withState( {
selected: [],
loading: true,
} )( ( { selected, loading, setState } ) => {
const list = [
{ id: 1, name: 'Apricots' },
{ id: 2, name: 'Clementine' },
{ id: 3, name: 'Elderberry' },
{ id: 4, name: 'Guava' },
{ id: 5, name: 'Lychee' },
{ id: 6, name: 'Mulberry' },
];
return (
<div>
<button onClick={ () => setState( { loading: ! loading } ) }>Toggle loading state</button>
<SearchListControl
list={ list }
isLoading={ loading }
selected={ selected }
onChange={ items => setState( { selected: items } ) }
/>
</div>
);
} );
```

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { forEach, groupBy, keyBy } from 'lodash';
/**
* Returns terms in a tree form.
*
* @param {Array} filteredList Array of terms, possibly a subset of all terms, in flat format.
* @param {Array} list Array of the full list of terms, defaults to the filteredList.
*
* @return {Array} Array of terms in tree format.
*/
export function buildTermsTree( filteredList, list = filteredList ) {
const termsByParent = groupBy( filteredList, 'parent' );
const listById = keyBy( list, 'id' );
const getParentsName = ( term = {} ) => {
if ( ! term.parent ) {
return term.name ? [ term.name ] : [];
}
const parentName = getParentsName( listById[ term.parent ] );
return [ ...parentName, term.name ];
};
const fillWithChildren = ( terms ) => {
return terms.map( ( term ) => {
const children = termsByParent[ term.id ];
delete termsByParent[ term.id ];
return {
...term,
breadcrumbs: getParentsName( listById[ term.parent ] ),
children: children && children.length ? fillWithChildren( children ) : [],
};
} );
};
const tree = fillWithChildren( termsByParent[ '0' ] || [] );
delete termsByParent[ '0' ];
// anything left in termsByParent has no visible parent
forEach( termsByParent, ( terms ) => {
tree.push( ...fillWithChildren( terms || [] ) );
} );
return tree;
}

View File

@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { Icon } from '@wordpress/components';
export default () => (
<Icon
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="#1E8CBE"
d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /* eslint-disable-line max-len */
/>
</svg>
}
/>
);

View File

@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { Icon } from '@wordpress/components';
export default () => (
<Icon
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="#6C7781"
d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"
/>
</svg>
}
/>
);

View File

@ -0,0 +1,5 @@
// Export each icon as a named component.
export { default as IconCheckChecked } from './checkbox-checked';
export { default as IconCheckUnchecked } from './checkbox-unchecked';
export { default as IconRadioSelected } from './radio-selected';
export { default as IconRadioUnselected } from './radio-unselected';

View File

@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { Icon } from '@wordpress/components';
export default () => (
<Icon
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="#1E8CBE"
d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" /* eslint-disable-line max-len */
/>
</svg>
}
/>
);

View File

@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { Icon } from '@wordpress/components';
export default () => (
<Icon
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="#6C7781"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" /* eslint-disable-line max-len */
/>
</svg>
}
/>
);

View File

@ -0,0 +1,311 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import {
Button,
MenuGroup,
Spinner,
TextControl,
withSpokenMessages,
} from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import { compose, withInstanceId, withState } from '@wordpress/compose';
import { escapeRegExp, findIndex } from 'lodash';
import Gridicon from 'gridicons';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import { buildTermsTree } from './hierarchy';
import SearchListItem from './item';
import Tag from '../tag';
const defaultMessages = {
clear: __( 'Clear all selected items', 'wc-admin' ),
list: __( 'Results', 'wc-admin' ),
noItems: __( 'No items found.', 'wc-admin' ),
noResults: __( 'No results for %s', 'wc-admin' ),
search: __( 'Search for items', 'wc-admin' ),
selected: ( n ) =>
sprintf( _n( '%d item selected', '%d items selected', n, 'wc-admin' ), n ),
updated: __( 'Search results updated.', 'wc-admin' ),
};
/**
* Component to display a searchable, selectable list of items.
*/
export class SearchListControl extends Component {
constructor() {
super( ...arguments );
this.onSelect = this.onSelect.bind( this );
this.onRemove = this.onRemove.bind( this );
this.onClear = this.onClear.bind( this );
this.isSelected = this.isSelected.bind( this );
this.defaultRenderItem = this.defaultRenderItem.bind( this );
this.renderList = this.renderList.bind( this );
}
onRemove( id ) {
const { isSingle, onChange, selected } = this.props;
return () => {
if ( isSingle ) {
onChange( [] );
}
const i = findIndex( selected, { id } );
onChange( [ ...selected.slice( 0, i ), ...selected.slice( i + 1 ) ] );
};
}
onSelect( item ) {
const { isSingle, onChange, selected } = this.props;
return () => {
if ( this.isSelected( item ) ) {
this.onRemove( item.id )();
return;
}
if ( isSingle ) {
onChange( [ item ] );
} else {
onChange( [ ...selected, item ] );
}
};
}
onClear() {
this.props.onChange( [] );
}
isSelected( item ) {
return -1 !== findIndex( this.props.selected, { id: item.id } );
}
getFilteredList( list, search ) {
const { isHierarchical } = this.props;
if ( ! search ) {
return isHierarchical ? buildTermsTree( list ) : list;
}
const messages = { ...defaultMessages, ...this.props.messages };
const re = new RegExp( escapeRegExp( search ), 'i' );
this.props.debouncedSpeak( messages.updated );
const filteredList = list
.map( ( item ) => ( re.test( item.name ) ? item : false ) )
.filter( Boolean );
return isHierarchical ? buildTermsTree( filteredList, list ) : filteredList;
}
defaultRenderItem( args ) {
return <SearchListItem { ...args } />;
}
renderList( list, depth = 0 ) {
const { isSingle, search } = this.props;
const renderItem = this.props.renderItem || this.defaultRenderItem;
if ( ! list ) {
return null;
}
return list.map( ( item ) => (
<Fragment key={ item.id }>
{ renderItem( {
item,
isSelected: this.isSelected( item ),
onSelect: this.onSelect,
isSingle,
search,
depth,
} ) }
{ this.renderList( item.children, depth + 1 ) }
</Fragment>
) );
}
renderListSection() {
const { isLoading, search } = this.props;
const list = this.getFilteredList( this.props.list, search );
const messages = { ...defaultMessages, ...this.props.messages };
if ( isLoading ) {
return (
<div className="woocommerce-search-list__list is-loading">
<Spinner />
</div>
);
}
if ( ! list.length ) {
return (
<div className="woocommerce-search-list__list is-not-found">
<span className="woocommerce-search-list__not-found-icon">
<Gridicon
icon="notice-outline"
role="img"
aria-hidden="true"
focusable="false"
/>
</span>
<span className="woocommerce-search-list__not-found-text">
{ search ? sprintf( messages.noResults, search ) : messages.noItems }
</span>
</div>
);
}
return (
<MenuGroup
label={ messages.list }
className="woocommerce-search-list__list"
>
{ this.renderList( list ) }
</MenuGroup>
);
}
renderSelectedSection() {
const { isLoading, isSingle, selected } = this.props;
const messages = { ...defaultMessages, ...this.props.messages };
if ( isLoading || isSingle || ! selected ) {
return null;
}
const selectedCount = selected.length;
return (
<div className="woocommerce-search-list__selected">
<div className="woocommerce-search-list__selected-header">
<strong>{ messages.selected( selectedCount ) }</strong>
{ selectedCount > 0 ? (
<Button
isLink
isDestructive
onClick={ this.onClear }
aria-label={ messages.clear }
>
{ __( 'Clear all', 'wc-admin' ) }
</Button>
) : null }
</div>
{ selected.map( ( item, i ) => (
<Tag key={ i } label={ item.name } id={ item.id } remove={ this.onRemove } />
) ) }
</div>
);
}
render() {
const { className = '', search, setState } = this.props;
const messages = { ...defaultMessages, ...this.props.messages };
return (
<div className={ `woocommerce-search-list ${ className }` }>
{ this.renderSelectedSection() }
<div className="woocommerce-search-list__search">
<TextControl
label={ messages.search }
type="search"
value={ search }
onChange={ ( value ) => setState( { search: value } ) }
/>
</div>
{ this.renderListSection() }
</div>
);
}
}
SearchListControl.propTypes = {
/**
* Additional CSS classes.
*/
className: PropTypes.string,
/**
* Whether the list of items is hierarchical or not. If true, each list item is expected to
* have a parent property.
*/
isHierarchical: PropTypes.bool,
/**
* Whether the list of items is still loading.
*/
isLoading: PropTypes.bool,
/**
* Restrict selections to one item.
*/
isSingle: PropTypes.bool,
/**
* A complete list of item objects, each with id, name properties. This is displayed as a
* clickable/keyboard-able list, and possibly filtered by the search term (searches name).
*/
list: PropTypes.arrayOf(
PropTypes.shape( {
id: PropTypes.number,
name: PropTypes.string,
} )
),
/**
* Messages displayed or read to the user. Configure these to reflect your object type.
* See `defaultMessages` above for examples.
*/
messages: PropTypes.shape( {
/**
* A more detailed label for the "Clear all" button, read to screen reader users.
*/
clear: PropTypes.string,
/**
* Label for the list of selectable items, only read to screen reader users.
*/
list: PropTypes.string,
/**
* Message to display when the list is empty (implies nothing loaded from the server
* or parent component).
*/
noItems: PropTypes.string,
/**
* Message to display when no matching results are found. %s is the search term.
*/
noResults: PropTypes.string,
/**
* Label for the search input
*/
search: PropTypes.string,
/**
* Label for the selected items. This is actually a function, so that we can pass
* through the count of currently selected items.
*/
selected: PropTypes.func,
/**
* Label indicating that search results have changed, read to screen reader users.
*/
updated: PropTypes.string,
} ),
/**
* Callback fired when selected items change, whether added, cleared, or removed.
* Passed an array of item objects (as passed in via props.list).
*/
onChange: PropTypes.func.isRequired,
/**
* Callback to render each item in the selection list, allows any custom object-type rendering.
*/
renderItem: PropTypes.func,
/**
* The list of currently selected items.
*/
selected: PropTypes.array.isRequired,
// from withState
search: PropTypes.string,
setState: PropTypes.func,
// from withSpokenMessages
debouncedSpeak: PropTypes.func,
// from withInstanceId
instanceId: PropTypes.number,
};
export default compose( [
withState( {
search: '',
} ),
withSpokenMessages,
withInstanceId,
] )( SearchListControl );

View File

@ -0,0 +1,132 @@
/**
* External dependencies
*/
import { escapeRegExp, first, last } from 'lodash';
import { MenuItem } from '@wordpress/components';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import {
IconCheckChecked,
IconCheckUnchecked,
IconRadioSelected,
IconRadioUnselected,
} from './icons';
function getHighlightedName( name, search ) {
if ( ! search ) {
return name;
}
const re = new RegExp( escapeRegExp( search ), 'ig' );
return name.replace( re, '<strong>$&</strong>' );
}
function getBreadcrumbsForDisplay( breadcrumbs ) {
if ( breadcrumbs.length === 1 ) {
return first( breadcrumbs );
}
if ( breadcrumbs.length === 2 ) {
return first( breadcrumbs ) + ' ' + last( breadcrumbs );
}
return first( breadcrumbs ) + ' … ' + last( breadcrumbs );
}
const getInteractionIcon = ( isSingle = false, isSelected = false ) => {
if ( isSingle ) {
return isSelected ? <IconRadioSelected /> : <IconRadioUnselected />;
}
return isSelected ? <IconCheckChecked /> : <IconCheckUnchecked />;
};
const SearchListItem = ( {
className,
depth = 0,
item,
isSelected,
isSingle,
onSelect,
search = '',
showCount = false,
...props
} ) => {
const classes = [ className, 'woocommerce-search-list__item' ];
classes.push( `depth-${ depth }` );
if ( isSingle ) {
classes.push( 'is-radio-button' );
}
const hasBreadcrumbs = item.breadcrumbs && item.breadcrumbs.length;
return (
<MenuItem
role={ isSingle ? 'menuitemradio' : 'menuitemcheckbox' }
className={ classes.join( ' ' ) }
onClick={ onSelect( item ) }
isSelected={ isSelected }
{ ...props }
>
<span className="woocommerce-search-list__item-state">
{ getInteractionIcon( isSingle, isSelected ) }
</span>
<span className="woocommerce-search-list__item-label">
{ hasBreadcrumbs ? (
<span className="woocommerce-search-list__item-prefix">
{ getBreadcrumbsForDisplay( item.breadcrumbs ) }
</span>
) : null }
<span
className="woocommerce-search-list__item-name"
dangerouslySetInnerHTML={ {
__html: getHighlightedName( item.name, search ),
} }
/>
</span>
{ !! showCount && (
<span className="woocommerce-search-list__item-count">
{ item.count }
</span>
) }
</MenuItem>
);
};
SearchListItem.propTypes = {
/**
* Additional CSS classes.
*/
className: PropTypes.string,
/**
* Depth, non-zero if the list is hierarchical.
*/
depth: PropTypes.number,
/**
* Current item to display.
*/
item: PropTypes.object,
/**
* Whether this item is selected.
*/
isSelected: PropTypes.bool,
/**
* Whether this should only display a single item (controls radio vs checkbox icon).
*/
isSingle: PropTypes.bool,
/**
* Callback for selecting the item.
*/
onSelect: PropTypes.func,
/**
* Search string, used to highlight the substring in the item name.
*/
search: PropTypes.string,
/**
* Toggles the "count" bubble on/off.
*/
showCount: PropTypes.bool,
};
export default SearchListItem;

View File

@ -0,0 +1,208 @@
.woocommerce-search-list {
width: 100%;
padding: 0 0 $gap;
text-align: left;
}
.woocommerce-search-list__selected {
margin: $gap 0;
padding: $gap 0 0;
// 76px is the height of 1 row of tags.
min-height: 76px;
border-top: 1px solid $core-grey-light-500;
.woocommerce-search-list__selected-header {
margin-bottom: $gap-smaller;
button {
margin-left: $gap-small;
}
}
.woocommerce-tag__text {
max-width: 13em;
}
}
.woocommerce-search-list__search {
margin: $gap 0;
padding: $gap 0 0;
border-top: 1px solid $core-grey-light-500;
.components-base-control__field {
margin-bottom: $gap;
}
}
.woocommerce-search-list__list {
padding: 0;
max-height: 17em;
overflow-x: hidden;
overflow-y: auto;
border-top: 1px solid $core-grey-light-500;
border-bottom: 1px solid $core-grey-light-500;
&.is-loading {
padding: $gap-small 0;
text-align: center;
border: none;
}
&.is-not-found {
padding: $gap-small 0;
text-align: center;
border: none;
.woocommerce-search-list__not-found-icon,
.woocommerce-search-list__not-found-text {
display: inline-block;
}
.woocommerce-search-list__not-found-icon {
margin-right: $gap;
.gridicon {
vertical-align: top;
margin-top: -1px;
}
}
}
.components-spinner {
float: none;
margin: 0 auto;
}
.components-menu-group__label {
@include visually-hidden;
}
& > [role="menu"] {
border: 1px solid $core-grey-light-500;
border-bottom: none;
}
.woocommerce-search-list__item {
display: flex;
align-items: center;
margin-bottom: 0;
padding: $gap-small $gap;
background: $white;
// !important to keep the border around on hover
border-bottom: 1px solid $core-grey-light-500 !important;
color: $core-grey-dark-500;
@include hover-state {
background: $core-grey-light-100;
}
&:last-child {
border-bottom: none !important;
}
.woocommerce-search-list__item-state {
flex: 0 0 16px;
margin-right: $gap-smaller;
// Set an explicit height to ensure vertical alignment
height: 24px;
}
.woocommerce-search-list__item-label {
display: flex;
flex: 1;
}
&.depth-0 + .depth-1 {
// Hide the border on the preceding list item
margin-top: -1px;
}
&:not(.depth-0) {
border-bottom: 0 !important;
}
&:not(.depth-0) + .depth-0 {
border-top: 1px solid $core-grey-light-500;
}
// Anything deeper than 5 levels will use this fallback depth
&[class*="depth-"] .woocommerce-search-list__item-label:before {
margin-right: $gap-smallest;
content: str-repeat( '', 5 );
}
&.depth-0 .woocommerce-search-list__item-label:before {
margin-right: 0;
content: '';
}
@for $i from 1 to 5 {
&.depth-#{$i} .woocommerce-search-list__item-label:before {
content: str-repeat( '', $i );
}
}
.woocommerce-search-list__item-name {
display: inline-block;
}
.woocommerce-search-list__item-prefix {
display: none;
color: $core-grey-dark-300;
}
&.is-searching,
&.is-skip-level {
.woocommerce-search-list__item-label {
// Un-flex the label, so the prefix (breadcrumbs) and name are aligned.
display: inline-block;
}
.woocommerce-search-list__item-prefix {
display: inline;
&:after {
margin-right: $gap-smallest;
content: " ";
}
}
}
&.is-searching {
.woocommerce-search-list__item-name {
color: $core-grey-dark-900;
}
}
.woocommerce-search-list__item-count {
flex: 0;
padding: $gap-smallest/2 $gap-smaller;
border: 1px solid $core-grey-light-500;
border-radius: 12px;
font-size: 0.8em;
line-height: 1.4;
color: $core-grey-dark-300;
background: $white;
}
}
}
.components-panel {
.woocommerce-search-list {
padding: 0;
}
.woocommerce-search-list__selected {
margin: 0 0 $gap;
padding: 0;
border-top: none;
// 54px is the height of 1 row of tags in the sidebar.
min-height: 54px;
}
.woocommerce-search-list__search {
margin: 0 0 $gap;
padding: 0;
border-top: none;
}
}

View File

@ -0,0 +1,152 @@
/**
* Internal dependencies
*/
import { buildTermsTree } from '../hierarchy';
const list = [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 2, name: 'Clementine', parent: 0 },
{ id: 3, name: 'Elderberry', parent: 2 },
{ id: 4, name: 'Guava', parent: 2 },
{ id: 5, name: 'Lychee', parent: 3 },
{ id: 6, name: 'Mulberry', parent: 0 },
{ id: 7, name: 'Tamarind', parent: 5 },
];
describe( 'buildTermsTree', () => {
test( 'should return an empty array on empty input', () => {
const tree = buildTermsTree( [] );
expect( tree ).toEqual( [] );
} );
test( 'should return a flat array when there are no parent relationships', () => {
const tree = buildTermsTree( [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 2, name: 'Clementine', parent: 0 },
] );
expect( tree ).toEqual( [
{ id: 1, name: 'Apricots', parent: 0, breadcrumbs: [], children: [] },
{ id: 2, name: 'Clementine', parent: 0, breadcrumbs: [], children: [] },
] );
} );
test( 'should return a tree of items', () => {
const tree = buildTermsTree( list );
expect( tree ).toEqual( [
{ id: 1, name: 'Apricots', parent: 0, breadcrumbs: [], children: [] },
{
id: 2,
name: 'Clementine',
parent: 0,
breadcrumbs: [],
children: [
{
id: 3,
name: 'Elderberry',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [
{
id: 5,
name: 'Lychee',
parent: 3,
breadcrumbs: [ 'Clementine', 'Elderberry' ],
children: [
{
id: 7,
name: 'Tamarind',
parent: 5,
breadcrumbs: [ 'Clementine', 'Elderberry', 'Lychee' ],
children: [],
},
],
},
],
},
{
id: 4,
name: 'Guava',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [],
},
],
},
{ id: 6, name: 'Mulberry', parent: 0, breadcrumbs: [], children: [] },
] );
} );
test( 'should return a tree of items, with orphan categories appended to the end', () => {
const filteredList = [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 2, name: 'Clementine', parent: 0 },
{ id: 4, name: 'Guava', parent: 2 },
{ id: 5, name: 'Lychee', parent: 3 },
{ id: 6, name: 'Mulberry', parent: 0 },
];
const tree = buildTermsTree( filteredList, list );
expect( tree ).toEqual( [
{ id: 1, name: 'Apricots', parent: 0, breadcrumbs: [], children: [] },
{
id: 2,
name: 'Clementine',
parent: 0,
breadcrumbs: [],
children: [
{
id: 4,
name: 'Guava',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [],
},
],
},
{ id: 6, name: 'Mulberry', parent: 0, breadcrumbs: [], children: [] },
{
id: 5,
name: 'Lychee',
parent: 3,
breadcrumbs: [ 'Clementine', 'Elderberry' ],
children: [],
},
] );
} );
test( 'should return a tree of items, with orphan categories appended to the end, with children of thier own', () => {
const filteredList = [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 3, name: 'Elderberry', parent: 2 },
{ id: 4, name: 'Guava', parent: 2 },
{ id: 5, name: 'Lychee', parent: 3 },
{ id: 6, name: 'Mulberry', parent: 0 },
];
const tree = buildTermsTree( filteredList, list );
expect( tree ).toEqual( [
{ id: 1, name: 'Apricots', parent: 0, breadcrumbs: [], children: [] },
{ id: 6, name: 'Mulberry', parent: 0, breadcrumbs: [], children: [] },
{
id: 3,
name: 'Elderberry',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [
{
id: 5,
name: 'Lychee',
parent: 3,
breadcrumbs: [ 'Clementine', 'Elderberry' ],
children: [],
},
],
},
{
id: 4,
name: 'Guava',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [],
},
] );
} );
} );

View File

@ -0,0 +1,174 @@
/**
* External dependencies
*/
import renderer from 'react-test-renderer';
import { noop } from 'lodash';
/**
* Internal dependencies
*/
import { SearchListControl } from '../';
const list = [
{ id: 1, name: 'Apricots' },
{ id: 2, name: 'Clementine' },
{ id: 3, name: 'Elderberry' },
{ id: 4, name: 'Guava' },
{ id: 5, name: 'Lychee' },
{ id: 6, name: 'Mulberry' },
];
const hierarchicalList = [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 2, name: 'Clementine', parent: 1 },
{ id: 3, name: 'Elderberry', parent: 1 },
{ id: 4, name: 'Guava', parent: 3 },
{ id: 5, name: 'Lychee', parent: 0 },
{ id: 6, name: 'Mulberry', parent: 0 },
];
describe( 'SearchListControl', () => {
test( 'should render a search box and list of options', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [] }
onChange={ noop }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box and list of options with a custom className', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
className="test-search"
list={ list }
selected={ [] }
onChange={ noop }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box, a list of options, and 1 selected item', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [ list[ 1 ] ] }
onChange={ noop }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box, a list of options, and 2 selected item', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [ list[ 1 ], list[ 3 ] ] }
onChange={ noop }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box and no options', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ [] }
selected={ [] }
onChange={ noop }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box with a search term, and only matching options', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ list }
search="berry"
selected={ [] }
onChange={ noop }
debouncedSpeak={ noop }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box with a search term, and only matching options, regardless of case sensitivity', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ list }
search="bERry"
selected={ [] }
onChange={ noop }
debouncedSpeak={ noop }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box with a search term, and no matching options', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ list }
search="no matches"
selected={ [] }
onChange={ noop }
debouncedSpeak={ noop }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box and list of options, with a custom search input message', () => {
const messages = { search: 'Testing search label' };
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [] }
onChange={ noop }
messages={ messages }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box and list of options, with a custom render callback for each item', () => {
const renderItem = ({ item }) => <div key={item.id}>{item.name}!</div>; // eslint-disable-line
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [] }
onChange={ noop }
renderItem={ renderItem }
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a search box and list of hierarchical options', () => {
const component = renderer.create(
<SearchListControl
instanceId={ 1 }
list={ hierarchicalList }
selected={ [] }
onChange={ noop }
isHierarchical
/>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
} );

View File

@ -22,6 +22,7 @@
@import 'product-image/style.scss';
@import 'rating/style.scss';
@import 'search/style.scss';
@import 'search-list-control/style.scss';
@import 'section-header/style.scss';
@import 'segmented-selection/style.scss';
@import 'split-button/style.scss';