Create a reusable search + list control for category selection (https://github.com/woocommerce/woocommerce-blocks/pull/166)

* Add new components for ProductCategoryControl, using a reusable SearchListControl

* Add sass variables from wc-admin

* Finish styling

* Save selected categories as selected for the block

* Style sidebar version of control

* Filter the categories list, highlight search term in result

* Filter out selected items in the filter list function

* Add spacing in the placeholder, remove unnecessary stylesheet

* Add a more descriptive label for screen readers

* Remove category references from list item options

* Switch to a configurable object of messages, so SearchListControl can be more customizable

* Add screen-reader message for toggling “done” and moving into preview

* Remove call to getProductCategoryControl

The component is simple enough to just drop in now :)

* Add documentation for all props

* Add padding to placeholder

* Rename fallbackrenderItem to defaultRenderItem

* Add a variable to save the selected count

* Add `isDestructive` to make link red

* Update item style

* Add a hover/focus background color
This commit is contained in:
Kelly Dwan 2018-11-29 13:10:08 -05:00 committed by GitHub
parent fbdc17d532
commit 4ac52b3cf3
9 changed files with 516 additions and 55 deletions

View File

@ -34,3 +34,16 @@
transition: none; transition: none;
} }
} }
// Hide an element from sighted users, but availble to screen reader users.
@mixin visually-hidden() {
border: 0;
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. */
word-wrap: normal !important;
}

View File

@ -0,0 +1,7 @@
$gap-largest: 40px;
$gap-larger: 36px;
$gap-large: 24px;
$gap: 16px;
$gap-small: 12px;
$gap-smaller: 8px;
$gap-smallest: 4px;

View File

@ -4,15 +4,28 @@
.wc-block-products-category { .wc-block-products-category {
overflow: hidden; overflow: hidden;
&.components-placeholder {
padding: 2em 1em;
}
} }
.wc-block-products-category__selection { .wc-block-products-category__selection {
margin-top: 16px;
padding: 16px;
width: 100%; width: 100%;
border-top: 1px solid $core-grey-light-500; }
.components-spinner { .components-panel {
float: none; .woocommerce-search-list {
padding: 0;
}
.woocommerce-search-list__selected {
margin: 0 0 $gap;
padding: 0;
border-top: none;
}
.woocommerce-search-list__search {
margin: 0 0 $gap;
padding: 0;
border-top: none;
} }
} }

View File

@ -0,0 +1,114 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { Component } from '@wordpress/element';
import { find } from 'lodash';
import { MenuItem } from '@wordpress/components';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import './style.scss';
import SearchListControl from '../search-list-control';
class ProductCategoryControl extends Component {
constructor() {
super( ...arguments );
this.state = {
list: [],
};
this.renderItem = this.renderItem.bind( this );
}
componentDidMount() {
apiFetch( {
path: addQueryArgs( '/wc/v3/products/categories', { per_page: -1 } ),
} )
.then( ( list ) => {
this.setState( { list } );
} )
.catch( () => {
this.setState( { list: [] } );
} );
}
renderItem( { getHighlightedName, item, onSelect, search } ) {
return (
<MenuItem
key={ item.id }
className="woocommerce-product-categories__item woocommerce-search-list__item"
onClick={ onSelect( item ) }
aria-label={ sprintf(
_n(
'%s, has %d product',
'%s, has %d products',
item.count,
'woocommerce'
),
item.name,
item.count
) }
>
<span
className="woocommerce-product-categories__item-name"
dangerouslySetInnerHTML={ {
__html: getHighlightedName( item.name, search ),
} }
/>
<span className="woocommerce-product-categories__item-count">
{ item.count }
</span>
</MenuItem>
);
}
render() {
const { list } = this.state;
const { selected, onChange } = this.props;
const messages = {
clear: __( 'Clear all product categories', 'woocommerce' ),
list: __( 'Product Categories', 'woocommerce' ),
search: __( 'Search for product categories', 'woocommerce' ),
selected: ( n ) =>
sprintf(
_n(
'%d category selected',
'%d categories selected',
n,
'woocommerce'
),
n
),
updated: __( 'Category search results updated.', 'woocommerce' ),
};
return (
<SearchListControl
className="woocommerce-product-categories"
list={ list }
selected={ selected.map( ( id ) => find( list, { id } ) ).filter( Boolean ) }
onChange={ onChange }
renderItem={ this.renderItem }
messages={ messages }
/>
);
}
}
ProductCategoryControl.propTypes = {
/**
* Callback to update the selected product categories.
*/
onChange: PropTypes.func.isRequired,
/**
* The list of currently selected category IDs.
*/
selected: PropTypes.array.isRequired,
};
export default ProductCategoryControl;

View File

@ -0,0 +1,21 @@
.woocommerce-product-categories {
.woocommerce-product-categories__item {
display: flex;
align-items: center;
}
.woocommerce-product-categories__item-name {
flex: 1;
}
.woocommerce-product-categories__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;
}
}

View File

@ -0,0 +1,240 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import {
Button,
MenuItem,
MenuGroup,
TextControl,
withSpokenMessages,
} from '@wordpress/components';
import { Component } from '@wordpress/element';
import { compose, withInstanceId, withState } from '@wordpress/compose';
import { escapeRegExp, findIndex } from 'lodash';
import PropTypes from 'prop-types';
import { Tag } from '@woocommerce/components';
/**
* Internal dependencies
*/
import './style.scss';
const defaultMessages = {
clear: __( 'Clear all selected items', 'woocommerce' ),
list: __( 'Results', 'woocommerce' ),
noResults: __( 'No results for %s', 'woocommerce' ),
search: __( 'Search for items', 'woocommerce' ),
selected: ( n ) =>
sprintf( _n( '%d item selected', '%d items selected', n, 'woocommerce' ), n ),
updated: __( 'Search results updated.', 'woocommerce' ),
};
/**
* 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.defaultRenderItem = this.defaultRenderItem.bind( this );
}
onRemove( id ) {
const { selected, onChange } = this.props;
return () => {
const i = findIndex( selected, { id } );
onChange( [ ...selected.slice( 0, i ), ...selected.slice( i + 1 ) ] );
};
}
onSelect( item ) {
const { selected, onChange } = this.props;
return () => {
onChange( [ ...selected, item ] );
};
}
onClear() {
this.props.onChange( [] );
}
isSelected( item ) {
return -1 !== findIndex( this.props.selected, { id: item.id } );
}
getFilteredList( list, search ) {
if ( ! search ) {
return list.filter( ( item ) => item && ! this.isSelected( item ) );
}
const messages = { ...defaultMessages, ...this.props.messages };
const re = new RegExp( escapeRegExp( search ), 'i' );
this.props.debouncedSpeak( messages.updated );
return list
.map( ( item ) => ( re.test( item.name ) ? item : false ) )
.filter( ( item ) => item && ! this.isSelected( item ) );
}
getHighlightedName( name, search ) {
if ( ! search ) {
return name;
}
const re = new RegExp( escapeRegExp( search ), 'ig' );
return name.replace( re, '<strong>$&</strong>' );
}
defaultRenderItem( { getHighlightedName, item, onSelect, search } ) {
return (
<MenuItem
key={ item.id }
className="woocommerce-search-list__item"
onClick={ onSelect( item ) }
>
<span
className="woocommerce-search-list__item-name"
dangerouslySetInnerHTML={ {
__html: getHighlightedName( item.name, search ),
} }
/>
</MenuItem>
);
}
render() {
const { className, search, selected, setState } = this.props;
const messages = { ...defaultMessages, ...this.props.messages };
const list = this.getFilteredList( this.props.list, search );
const noResults = search ? sprintf( messages.noResults, search ) : null;
const renderItem = this.props.renderItem || this.defaultRenderItem;
const selectedCount = selected.length;
return (
<div className={ `woocommerce-search-list ${ className }` }>
{ selectedCount > 0 ? (
<div className="woocommerce-search-list__selected">
<div className="woocommerce-search-list__selected-header">
<strong>{ messages.selected( selectedCount ) }</strong>
<Button isLink isDestructive onClick={ this.onClear } aria-label={ messages.clear }>
{ __( 'Clear all', 'woocommerce' ) }
</Button>
</div>
{ selected.map( ( item, i ) => (
<Tag
key={ i }
label={ item.name }
id={ item.id }
remove={ this.onRemove }
/>
) ) }
</div>
) : null }
<div className="woocommerce-search-list__search">
<TextControl
label={ messages.search }
type="search"
value={ search }
onChange={ ( value ) => setState( { search: value } ) }
/>
</div>
{ ! list.length ? (
noResults
) : (
<MenuGroup
label={ messages.list }
className="woocommerce-search-list__list"
>
{ list.map( ( item ) =>
renderItem( {
getHighlightedName: this.getHighlightedName,
item,
onSelect: this.onSelect,
search,
} )
) }
</MenuGroup>
) }
</div>
);
}
}
SearchListControl.propTypes = {
/**
* Additional CSS classes.
*/
className: PropTypes.string,
/**
* 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 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,67 @@
.woocommerce-search-list {
width: 100%;
padding: 0 0 $gap;
text-align: left;
}
.woocommerce-search-list__selected {
margin: $gap 0;
padding: $gap 0 0;
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: 20em;
overflow-x: scroll;
border-top: 1px solid $core-grey-light-500;
border-bottom: 1px solid $core-grey-light-500;
.components-menu-group__label {
@include visually-hidden;
}
& > [role="menu"] {
border: 1px solid $core-grey-light-500;
border-bottom: none;
}
.woocommerce-search-list__item {
margin-bottom: 0;
padding: $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-of-type {
border-bottom: none !important;
}
}
}

View File

@ -2,8 +2,8 @@
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url'; import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { Component, Fragment, RawHTML } from '@wordpress/element'; import { Component, Fragment, RawHTML } from '@wordpress/element';
import { import {
BlockAlignmentToolbar, BlockAlignmentToolbar,
@ -18,7 +18,7 @@ import {
SelectControl, SelectControl,
Spinner, Spinner,
Toolbar, Toolbar,
TreeSelect, withSpokenMessages,
} from '@wordpress/components'; } from '@wordpress/components';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { registerBlockType } from '@wordpress/blocks'; import { registerBlockType } from '@wordpress/blocks';
@ -29,6 +29,7 @@ import { registerBlockType } from '@wordpress/blocks';
import '../css/product-category-block.scss'; import '../css/product-category-block.scss';
import getQuery from './utils/get-query'; import getQuery from './utils/get-query';
import getShortcode from './utils/get-shortcode'; import getShortcode from './utils/get-shortcode';
import ProductCategoryControl from './components/product-category-control';
import ProductPreview from './components/product-preview'; import ProductPreview from './components/product-preview';
import sharedAttributes from './utils/shared-attributes'; import sharedAttributes from './utils/shared-attributes';
@ -38,26 +39,16 @@ const validAlignments = [ 'center', 'wide', 'full' ];
/** /**
* Component to handle edit mode of "Products by Category". * Component to handle edit mode of "Products by Category".
*/ */
class ProductByCategoryBlock extends Component { export default class ProductByCategoryBlock extends Component {
constructor() { constructor() {
super( ...arguments ); super( ...arguments );
this.state = { this.state = {
categoriesList: [],
products: [], products: [],
loaded: false, loaded: false,
}; };
} }
componentDidMount() { componentDidMount() {
apiFetch( {
path: addQueryArgs( '/wc/v3/products/categories', { per_page: -1 } ),
} )
.then( ( categoriesList ) => {
this.setState( { categoriesList } );
} )
.catch( () => {
this.setState( { categoriesList: [] } );
} );
if ( this.props.attributes.categories ) { if ( this.props.attributes.categories ) {
this.getProducts(); this.getProducts();
} }
@ -90,19 +81,16 @@ class ProductByCategoryBlock extends Component {
getInspectorControls() { getInspectorControls() {
const { attributes, setAttributes } = this.props; const { attributes, setAttributes } = this.props;
const { columns, orderby, rows, categories } = attributes; const { columns, orderby, rows } = attributes;
const { categoriesList } = this.state;
return ( return (
<InspectorControls key="inspector"> <InspectorControls key="inspector">
<PanelBody title={ __( 'Product Category', 'woocommerce' ) } initialOpen> <PanelBody title={ __( 'Product Category', 'woocommerce' ) } initialOpen>
<TreeSelect <ProductCategoryControl
label={ __( 'Product Category', 'woocommerce' ) } selected={ attributes.categories }
tree={ categoriesList } onChange={ ( value = [] ) => {
selectedId={ categories } const ids = value.map( ( { id } ) => id );
multiple setAttributes( { categories: ids } );
onChange={ ( value ) => {
setAttributes( { categories: value ? value : [] } );
} } } }
/> />
</PanelBody> </PanelBody>
@ -164,42 +152,37 @@ class ProductByCategoryBlock extends Component {
} }
renderEditMode() { renderEditMode() {
const { setAttributes } = this.props; const { attributes, debouncedSpeak, setAttributes } = this.props;
const { categories } = this.props.attributes; const onDone = () => {
const { categoriesList } = this.state; setAttributes( { editMode: false } );
debouncedSpeak( __( 'Showing product block preview.', 'woocommerce' ) );
};
return ( return (
<Placeholder <Placeholder
icon="category" icon="category"
label={ __( 'Products by Category', 'woocommerce' ) } label={ __( 'Products by Category', 'woocommerce' ) }
className="wc-block-products-category"
> >
{ __( { __(
'Display a grid of products from your selected categories', 'Display a grid of products from your selected categories',
'woocommerce' 'woocommerce'
) } ) }
{ categoriesList.length ? (
<div className="wc-block-products-category__selection"> <div className="wc-block-products-category__selection">
<TreeSelect <ProductCategoryControl
label={ __( 'Product Category', 'woocommerce' ) } selected={ attributes.categories }
tree={ categoriesList } onChange={ ( value = [] ) => {
selectedId={ categories } const ids = value.map( ( { id } ) => id );
multiple setAttributes( { categories: ids } );
onChange={ ( value ) => {
setAttributes( { categories: value ? value : [] } );
} } } }
/> />
<Button <Button
isDefault isDefault
onClick={ () => setAttributes( { editMode: false } ) } onClick={ onDone }
> >
{ __( 'Done', 'woocommerce' ) } { __( 'Done', 'woocommerce' ) }
</Button> </Button>
</div> </div>
) : (
<div className="wc-block-products-category__selection">
<Spinner />
</div>
) }
</Placeholder> </Placeholder>
); );
} }
@ -265,9 +248,11 @@ ProductByCategoryBlock.propTypes = {
* A callback to update attributes * A callback to update attributes
*/ */
setAttributes: PropTypes.func.isRequired, setAttributes: PropTypes.func.isRequired,
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
}; };
export default ProductByCategoryBlock; const WrappedProductByCategoryBlock = withSpokenMessages( ProductByCategoryBlock );
/** /**
* Register and run the "Products by Category" block. * Register and run the "Products by Category" block.
@ -303,7 +288,7 @@ registerBlockType( 'woocommerce/product-category', {
* Renders and manages the block. * Renders and manages the block.
*/ */
edit( props ) { edit( props ) {
return <ProductByCategoryBlock { ...props } />; return <WrappedProductByCategoryBlock { ...props } />;
}, },
/** /**

View File

@ -58,6 +58,7 @@ const GutenbergBlocksConfig = {
includePaths: [ 'assets/css/abstracts' ], includePaths: [ 'assets/css/abstracts' ],
data: data:
'@import "_colors"; ' + '@import "_colors"; ' +
'@import "_variables"; ' +
'@import "_breakpoints"; ' + '@import "_breakpoints"; ' +
'@import "_mixins"; ', '@import "_mixins"; ',
}, },