From 4ac52b3cf38718af7115691054b66a80656d395e Mon Sep 17 00:00:00 2001 From: Kelly Dwan Date: Thu, 29 Nov 2018 13:10:08 -0500 Subject: [PATCH] Create a reusable search + list control for category selection (https://github.com/woocommerce/woocommerce-blocks/pull/166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../assets/css/abstracts/_mixins.scss | 13 + .../assets/css/abstracts/_variables.scss | 7 + .../assets/css/product-category-block.scss | 23 +- .../product-category-control/index.js | 114 +++++++++ .../product-category-control/style.scss | 21 ++ .../components/search-list-control/index.js | 240 ++++++++++++++++++ .../components/search-list-control/style.scss | 67 +++++ .../assets/js/product-category-block.js | 85 +++---- plugins/woocommerce-blocks/webpack.config.js | 1 + 9 files changed, 516 insertions(+), 55 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/css/abstracts/_variables.scss create mode 100644 plugins/woocommerce-blocks/assets/js/components/product-category-control/index.js create mode 100644 plugins/woocommerce-blocks/assets/js/components/product-category-control/style.scss create mode 100644 plugins/woocommerce-blocks/assets/js/components/search-list-control/index.js create mode 100644 plugins/woocommerce-blocks/assets/js/components/search-list-control/style.scss diff --git a/plugins/woocommerce-blocks/assets/css/abstracts/_mixins.scss b/plugins/woocommerce-blocks/assets/css/abstracts/_mixins.scss index 1443afa2662..5a9f12f1ea7 100644 --- a/plugins/woocommerce-blocks/assets/css/abstracts/_mixins.scss +++ b/plugins/woocommerce-blocks/assets/css/abstracts/_mixins.scss @@ -34,3 +34,16 @@ 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; +} diff --git a/plugins/woocommerce-blocks/assets/css/abstracts/_variables.scss b/plugins/woocommerce-blocks/assets/css/abstracts/_variables.scss new file mode 100644 index 00000000000..fc0355275b4 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/css/abstracts/_variables.scss @@ -0,0 +1,7 @@ +$gap-largest: 40px; +$gap-larger: 36px; +$gap-large: 24px; +$gap: 16px; +$gap-small: 12px; +$gap-smaller: 8px; +$gap-smallest: 4px; diff --git a/plugins/woocommerce-blocks/assets/css/product-category-block.scss b/plugins/woocommerce-blocks/assets/css/product-category-block.scss index ccffe0b8794..5317b271230 100644 --- a/plugins/woocommerce-blocks/assets/css/product-category-block.scss +++ b/plugins/woocommerce-blocks/assets/css/product-category-block.scss @@ -4,15 +4,28 @@ .wc-block-products-category { overflow: hidden; + + &.components-placeholder { + padding: 2em 1em; + } } .wc-block-products-category__selection { - margin-top: 16px; - padding: 16px; width: 100%; - border-top: 1px solid $core-grey-light-500; +} - .components-spinner { - float: none; +.components-panel { + .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; } } diff --git a/plugins/woocommerce-blocks/assets/js/components/product-category-control/index.js b/plugins/woocommerce-blocks/assets/js/components/product-category-control/index.js new file mode 100644 index 00000000000..1f8ffebd884 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/components/product-category-control/index.js @@ -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 ( + + + + { item.count } + + + ); + } + + 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 ( + 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; diff --git a/plugins/woocommerce-blocks/assets/js/components/product-category-control/style.scss b/plugins/woocommerce-blocks/assets/js/components/product-category-control/style.scss new file mode 100644 index 00000000000..0a6e71107d4 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/components/product-category-control/style.scss @@ -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; + } +} diff --git a/plugins/woocommerce-blocks/assets/js/components/search-list-control/index.js b/plugins/woocommerce-blocks/assets/js/components/search-list-control/index.js new file mode 100644 index 00000000000..0ce88ceb1d6 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/components/search-list-control/index.js @@ -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, '$&' ); + } + + defaultRenderItem( { getHighlightedName, item, onSelect, search } ) { + return ( + + + + ); + } + + 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 ( +
+ { selectedCount > 0 ? ( +
+
+ { messages.selected( selectedCount ) } + +
+ { selected.map( ( item, i ) => ( + + ) ) } +
+ ) : null } + +
+ setState( { search: value } ) } + /> +
+ + { ! list.length ? ( + noResults + ) : ( + + { list.map( ( item ) => + renderItem( { + getHighlightedName: this.getHighlightedName, + item, + onSelect: this.onSelect, + search, + } ) + ) } + + ) } +
+ ); + } +} + +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 ); diff --git a/plugins/woocommerce-blocks/assets/js/components/search-list-control/style.scss b/plugins/woocommerce-blocks/assets/js/components/search-list-control/style.scss new file mode 100644 index 00000000000..9de9384129e --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/components/search-list-control/style.scss @@ -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; + } + } +} diff --git a/plugins/woocommerce-blocks/assets/js/product-category-block.js b/plugins/woocommerce-blocks/assets/js/product-category-block.js index 8327735391e..c8c55150a1b 100644 --- a/plugins/woocommerce-blocks/assets/js/product-category-block.js +++ b/plugins/woocommerce-blocks/assets/js/product-category-block.js @@ -2,8 +2,8 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; import { Component, Fragment, RawHTML } from '@wordpress/element'; import { BlockAlignmentToolbar, @@ -18,7 +18,7 @@ import { SelectControl, Spinner, Toolbar, - TreeSelect, + withSpokenMessages, } from '@wordpress/components'; import PropTypes from 'prop-types'; import { registerBlockType } from '@wordpress/blocks'; @@ -29,6 +29,7 @@ import { registerBlockType } from '@wordpress/blocks'; import '../css/product-category-block.scss'; import getQuery from './utils/get-query'; import getShortcode from './utils/get-shortcode'; +import ProductCategoryControl from './components/product-category-control'; import ProductPreview from './components/product-preview'; import sharedAttributes from './utils/shared-attributes'; @@ -38,26 +39,16 @@ const validAlignments = [ 'center', 'wide', 'full' ]; /** * Component to handle edit mode of "Products by Category". */ -class ProductByCategoryBlock extends Component { +export default class ProductByCategoryBlock extends Component { constructor() { super( ...arguments ); this.state = { - categoriesList: [], products: [], loaded: false, }; } 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 ) { this.getProducts(); } @@ -90,19 +81,16 @@ class ProductByCategoryBlock extends Component { getInspectorControls() { const { attributes, setAttributes } = this.props; - const { columns, orderby, rows, categories } = attributes; - const { categoriesList } = this.state; + const { columns, orderby, rows } = attributes; return ( - { - setAttributes( { categories: value ? value : [] } ); + { + const ids = value.map( ( { id } ) => id ); + setAttributes( { categories: ids } ); } } /> @@ -164,42 +152,37 @@ class ProductByCategoryBlock extends Component { } renderEditMode() { - const { setAttributes } = this.props; - const { categories } = this.props.attributes; - const { categoriesList } = this.state; + const { attributes, debouncedSpeak, setAttributes } = this.props; + const onDone = () => { + setAttributes( { editMode: false } ); + debouncedSpeak( __( 'Showing product block preview.', 'woocommerce' ) ); + }; return ( { __( 'Display a grid of products from your selected categories', 'woocommerce' ) } - { categoriesList.length ? ( -
- { - setAttributes( { categories: value ? value : [] } ); - } } - /> - -
- ) : ( -
- -
- ) } +
+ { + const ids = value.map( ( { id } ) => id ); + setAttributes( { categories: ids } ); + } } + /> + +
); } @@ -265,9 +248,11 @@ ProductByCategoryBlock.propTypes = { * A callback to update attributes */ 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. @@ -303,7 +288,7 @@ registerBlockType( 'woocommerce/product-category', { * Renders and manages the block. */ edit( props ) { - return ; + return ; }, /** diff --git a/plugins/woocommerce-blocks/webpack.config.js b/plugins/woocommerce-blocks/webpack.config.js index c97aa0d774d..bda1c9c17fb 100644 --- a/plugins/woocommerce-blocks/webpack.config.js +++ b/plugins/woocommerce-blocks/webpack.config.js @@ -58,6 +58,7 @@ const GutenbergBlocksConfig = { includePaths: [ 'assets/css/abstracts' ], data: '@import "_colors"; ' + + '@import "_variables"; ' + '@import "_breakpoints"; ' + '@import "_mixins"; ', },