From f7c807a0c66e1181a61bfa8b0f1b77bd832d4c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Tue, 3 Dec 2019 14:39:11 +0100 Subject: [PATCH] Add dropdown display style to Attribute Filter block (https://github.com/woocommerce/woocommerce-blocks/pull/1255) * Add dropdown display style to Attribute Filter block * Unify filter blocks margin * Show attribute label inside dropdown input * Minor CSS reorganization * Refactor code to smaller files * Preserve input values on blur * Only save data-display-style if it's different than 'list' * Remove inputRef prop in DropdownSelectorInputWrapper * Accessibility: fix missing label * Prevent input field being unselected when removing an item with the backspace * Remove isLoading styles and don't set isDisabled when it's not actually disabled * Accessibility: increase color contrast * Add package-lock.json * Prevent input field being unfocused when removing an item with its chip card * Don't show menu when input is unfocused --- .../js/base/components/checkbox-list/index.js | 16 +- .../components/dropdown-selector/index.js | 163 ++++++++++++++++++ .../dropdown-selector/input-wrapper.js | 13 ++ .../components/dropdown-selector/input.js | 45 +++++ .../base/components/dropdown-selector/menu.js | 56 ++++++ .../dropdown-selector/selected-chip.js | 32 ++++ .../components/dropdown-selector/style.scss | 105 +++++++++++ .../assets/js/base/components/label/index.js | 4 +- .../base/components/price-slider/style.scss | 3 +- .../js/blocks/active-filters/style.scss | 2 +- .../js/blocks/attribute-filter/block.js | 99 ++++++++--- .../assets/js/blocks/attribute-filter/edit.js | 29 ++++ .../js/blocks/attribute-filter/frontend.js | 1 + .../js/blocks/attribute-filter/index.js | 8 + .../js/blocks/attribute-filter/style.scss | 21 ++- plugins/woocommerce-blocks/package-lock.json | 16 ++ plugins/woocommerce-blocks/package.json | 1 + 17 files changed, 575 insertions(+), 39 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/index.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/input-wrapper.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/input.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/menu.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/selected-chip.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/style.scss diff --git a/plugins/woocommerce-blocks/assets/js/base/components/checkbox-list/index.js b/plugins/woocommerce-blocks/assets/js/base/components/checkbox-list/index.js index eaf5b4267fb..d36b1167f2f 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/checkbox-list/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/checkbox-list/index.js @@ -97,7 +97,7 @@ const CheckboxList = ( { return ( { options.map( ( option, index ) => ( - +
  • { + onChange( event.target.value ); + } } + checked={ checked.includes( option.value ) } disabled={ isDisabled } /> -
  • @@ -152,8 +154,8 @@ CheckboxList.propTypes = { onChange: PropTypes.func, options: PropTypes.arrayOf( PropTypes.shape( { - key: PropTypes.string.isRequired, label: PropTypes.node.isRequired, + value: PropTypes.string.isRequired, } ) ), checked: PropTypes.array, diff --git a/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/index.js b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/index.js new file mode 100644 index 00000000000..54d1273c661 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/index.js @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { useRef } from '@wordpress/element'; +import classNames from 'classnames'; +import Downshift from 'downshift'; + +/** + * Internal dependencies + */ +import DropdownSelectorInput from './input'; +import DropdownSelectorInputWrapper from './input-wrapper'; +import DropdownSelectorMenu from './menu'; +import DropdownSelectorSelectedChip from './selected-chip'; +import './style.scss'; + +/** + * State reducer for the downshift component. + * See: https://github.com/downshift-js/downshift#statereducer + */ +const stateReducer = ( state, changes ) => { + switch ( changes.type ) { + case Downshift.stateChangeTypes.keyDownEnter: + case Downshift.stateChangeTypes.clickItem: + return { + ...changes, + highlightedIndex: state.highlightedIndex, + isOpen: true, + inputValue: '', + }; + case Downshift.stateChangeTypes.blurInput: + case Downshift.stateChangeTypes.mouseUp: + return { + ...changes, + inputValue: state.inputValue, + }; + default: + return changes; + } +}; + +/** + * Component used to show an input box with a dropdown with suggestions. + */ +const DropdownSelector = ( { + attributeLabel = '', + className, + checked = [], + inputLabel = '', + isDisabled = false, + isLoading = false, + onChange = () => {}, + options = [], +} ) => { + const inputRef = useRef( null ); + + const classes = classNames( className, 'wc-block-dropdown-selector', { + 'is-disabled': isDisabled, + 'is-loading': isLoading, + } ); + + const focusInput = ( isOpen ) => { + if ( ! isOpen ) { + inputRef.current.focus(); + } + }; + + return ( + + { ( { + getInputProps, + getItemProps, + getLabelProps, + getMenuProps, + highlightedIndex, + inputValue, + isOpen, + openMenu, + } ) => ( +
    + { /* eslint-disable-next-line jsx-a11y/label-has-for */ } + + focusInput( isOpen ) } + > + { checked.map( ( value ) => { + const option = options.find( + ( o ) => o.value === value + ); + return ( + { + onChange( val ); + focusInput( isOpen ); + } } + option={ option } + /> + ); + } ) } + { + onChange( val ); + focusInput( isOpen ); + } } + value={ inputValue } + /> + + { isOpen && ! isDisabled && ( + + ! inputValue || + option.value.startsWith( inputValue ) + ) } + /> + ) } +
    + ) } +
    + ); +}; + +DropdownSelector.propTypes = { + attributeLabel: PropTypes.string, + checked: PropTypes.array, + className: PropTypes.string, + inputLabel: PropTypes.string, + isDisabled: PropTypes.bool, + isLoading: PropTypes.bool, + limit: PropTypes.number, + onChange: PropTypes.func, + options: PropTypes.arrayOf( + PropTypes.shape( { + label: PropTypes.node.isRequired, + value: PropTypes.string.isRequired, + } ) + ), +}; + +export default DropdownSelector; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/input-wrapper.js b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/input-wrapper.js new file mode 100644 index 00000000000..6a8387b8896 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/input-wrapper.js @@ -0,0 +1,13 @@ +const DropdownSelectorInputWrapper = ( { children, onClick } ) => { + return ( + /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ +
    + { children } +
    + ); +}; + +export default DropdownSelectorInputWrapper; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/input.js b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/input.js new file mode 100644 index 00000000000..22a1f23e35d --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/input.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +const DropdownSelectorInput = ( { + attributeLabel, + checked, + getInputProps, + inputRef, + isDisabled, + onFocus, + onRemoveItem, + value, +} ) => { + return ( + 0 + ) { + onRemoveItem( checked[ checked.length - 1 ] ); + } + }, + placeholder: + checked.length === 0 + ? sprintf( + // Translators: %s attribute name. + __( 'Any %s', 'woo-gutenberg-products-block' ), + attributeLabel + ) + : null, + } ) } + /> + ); +}; + +export default DropdownSelectorInput; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/menu.js b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/menu.js new file mode 100644 index 00000000000..9bcc45ab59d --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/menu.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import classNames from 'classnames'; + +const DropdownSelectorMenu = ( { + checked, + getItemProps, + getMenuProps, + highlightedIndex, + options, +} ) => { + return ( +
      + { options.map( ( option, index ) => { + const selected = checked.includes( option.value ); + return ( + // eslint-disable-next-line react/jsx-key +
    • + { option.label } +
    • + ); + } ) } +
    + ); +}; + +export default DropdownSelectorMenu; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/selected-chip.js b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/selected-chip.js new file mode 100644 index 00000000000..4741c916294 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/selected-chip.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +const DropdownSelectorSelectedChip = ( { onRemoveItem, option } ) => { + return ( + + ); +}; + +export default DropdownSelectorSelectedChip; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/style.scss b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/style.scss new file mode 100644 index 00000000000..ce7fe0e4ba1 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/dropdown-selector/style.scss @@ -0,0 +1,105 @@ +.wc-block-dropdown-selector { + max-width: 300px; + position: relative; + width: 100%; +} + +.wc-block-dropdown-selector__input-wrapper { + align-items: baseline; + border: 1px solid #9f9f9f; + border-radius: 4px; + cursor: text; + display: flex; + flex-wrap: wrap; + padding: 2px; + + .is-disabled & { + background-color: $core-grey-light-500; + } +} + +.wc-block-dropdown-selector__placeholder { + font-size: 0.8em; + height: 1.8em; + margin: 0 $gap-smallest; + white-space: nowrap; +} + +.wc-block-dropdown-selector__input { + background: transparent; + border: 0; + flex: 1; + font-size: 0.8em; + height: 1.8em; + min-width: 0; + margin: 1.5px; + + &:hover, + &:focus, + &:active { + outline: 0; + } +} + +.wc-block-dropdown-selector__selected-chip { + background-color: $core-grey-light-600; + border: 1px solid #9f9f9f; + border-radius: 4px; + color: $core-grey-dark-600; + display: inline-block; + font-size: 0.8em; + font-weight: inherit; + height: 1.8em; + margin: 1.5px; + padding: 0 0 0 0.3em; + white-space: nowrap; + + &:hover, + &:focus, + &:active { + background-color: $core-grey-light-600; + border: 1px solid #9f9f9f; + color: $core-grey-dark-600; + } +} + +.wc-block-dropdown-selector__selected-chip__remove { + background-color: transparent; + border: 0; + display: inline-block; + padding: 0 0.3em; +} + +.wc-block-dropdown-selector__list { + list-style: none; + margin: -1px 0 0; + position: absolute; + left: 0; + right: 0; + top: 100%; + max-height: 300px; + overflow-y: auto; + z-index: 1; + + &:not(:empty) { + border: 1px solid #9f9f9f; + } +} + +.wc-block-dropdown-selector__list-item { + background-color: #fff; + color: $core-grey-dark-600; + padding: 0 $gap-smallest; + + &.is-selected { + background-color: $core-grey-light-600; + } + + &:hover, + &:focus, + &.is-highlighted, + &:active { + background-color: #00669e; + color: #fff; + } +} diff --git a/plugins/woocommerce-blocks/assets/js/base/components/label/index.js b/plugins/woocommerce-blocks/assets/js/base/components/label/index.js index bedf8f82ba6..cdee9b1786e 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/label/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/label/index.js @@ -48,8 +48,8 @@ const Label = ( { }; Label.propTypes = { - label: PropTypes.string, - screenReaderLabel: PropTypes.string, + label: PropTypes.node, + screenReaderLabel: PropTypes.node, wrapperElement: PropTypes.elementType, wrapperProps: PropTypes.object, }; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/price-slider/style.scss b/plugins/woocommerce-blocks/assets/js/base/components/price-slider/style.scss index 7b23a871f36..a4db3cfca2a 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/price-slider/style.scss +++ b/plugins/woocommerce-blocks/assets/js/base/components/price-slider/style.scss @@ -47,6 +47,8 @@ } .wc-block-price-filter { + margin-bottom: $gap-large; + .wc-block-price-filter__range-input-wrapper { @include reset; height: 9px; @@ -73,7 +75,6 @@ flex-flow: row nowrap; justify-content: flex-start; align-items: center; - margin: 0 0 20px; .wc-block-price-filter__amount { margin: 0; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/active-filters/style.scss b/plugins/woocommerce-blocks/assets/js/blocks/active-filters/style.scss index 34a8576afd9..cbd1b0d7169 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/active-filters/style.scss +++ b/plugins/woocommerce-blocks/assets/js/blocks/active-filters/style.scss @@ -1,5 +1,5 @@ .wc-block-active-filters { - margin: 0 0 $gap; + margin-bottom: $gap-large; overflow: hidden; .wc-block-active-filters__clear-all { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/block.js b/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/block.js index 9f3178f1e6d..ddfe6bbd374 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/block.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/block.js @@ -1,6 +1,8 @@ /** * External dependencies */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; import { useCollection, useQueryStateByKey, @@ -15,6 +17,8 @@ import { useMemo, } from '@wordpress/element'; import CheckboxList from '@woocommerce/base-components/checkbox-list'; +import DropdownSelector from '@woocommerce/base-components/dropdown-selector'; +import Label from '@woocommerce/base-components/label'; /** * Internal dependencies @@ -39,9 +43,24 @@ const AttributeFilterBlock = ( { { name } { blockAttributes.showCounts && count !== null && ( - - { count } - + ); @@ -62,15 +81,18 @@ const AttributeFilterBlock = ( { blockAttributes.isPreview && ! blockAttributes.attributeId ? [ { - key: 'preview-1', + value: 'preview-1', + name: 'Blue', label: getLabel( 'Blue', 3 ), }, { - key: 'preview-2', + value: 'preview-2', + name: 'Green', label: getLabel( 'Green', 3 ), }, { - key: 'preview-3', + value: 'preview-3', + name: 'Red', label: getLabel( 'Red', 2 ), }, ] @@ -149,7 +171,8 @@ const AttributeFilterBlock = ( { } newOptions.push( { - key: term.slug, + value: term.slug, + name: term.name, label: getLabel( term.name, count ), } ); } ); @@ -183,16 +206,37 @@ const AttributeFilterBlock = ( { * When a checkbox in the list changes, update state. */ const onChange = useCallback( - ( event ) => { - const isChecked = event.target.checked; - const checkedValue = event.target.value; + ( checkedValue ) => { + const isChecked = ! checked.includes( checkedValue ); const newChecked = checked.filter( ( value ) => value !== checkedValue ); + const checkedOption = displayedOptions.find( + ( option ) => option.value === checkedValue + ); if ( isChecked ) { newChecked.push( checkedValue ); newChecked.sort(); + speak( + sprintf( + __( + '%s filter added.', + 'woo-gutenberg-products-block' + ), + checkedOption.name + ) + ); + } else { + speak( + sprintf( + __( + '%s filter removed.', + 'woo-gutenberg-products-block' + ), + checkedOption.name + ) + ); } const newSelectedTerms = getSelectedTerms( newChecked ); @@ -212,6 +256,7 @@ const AttributeFilterBlock = ( { setProductAttributesQuery, attributeObject, blockAttributes, + displayedOptions, ] ); @@ -220,6 +265,8 @@ const AttributeFilterBlock = ( { } const TagName = `h${ blockAttributes.headingLevel }`; + const isLoading = ! blockAttributes.isPreview && attributeTermsLoading; + const isDisabled = ! blockAttributes.isPreview && filteredCountsLoading; return ( @@ -227,18 +274,26 @@ const AttributeFilterBlock = ( { { blockAttributes.heading } ) }
    - + { blockAttributes.displayStyle === 'dropdown' ? ( + + ) : ( + + ) }
    ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/edit.js b/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/edit.js index 1392424b0b3..cd390a76627 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/edit.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/edit.js @@ -33,6 +33,7 @@ const Edit = ( { attributes, setAttributes, debouncedSpeak } ) => { const { attributeId, className, + displayStyle, heading, headingLevel, isPreview, @@ -151,6 +152,34 @@ const Edit = ( { attributes, setAttributes, debouncedSpeak } ) => { } ) } /> + + setAttributes( { + displayStyle: value, + } ) + } + /> { queryType: el.dataset.queryType, heading: el.dataset.heading, headingLevel: el.dataset.headingLevel || 3, + displayStyle: el.dataset.displayStyle, }, }; }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/index.js b/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/index.js index b1499854d50..6f70f38c810 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/index.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/attribute-filter/index.js @@ -53,6 +53,10 @@ registerBlockType( 'woocommerce/attribute-filter', { type: 'number', default: 3, }, + displayStyle: { + type: 'string', + default: 'list', + }, /** * Are we previewing? */ @@ -73,6 +77,7 @@ registerBlockType( 'woocommerce/attribute-filter', { attributeId, heading, headingLevel, + displayStyle, } = attributes; const data = { 'data-attribute-id': attributeId, @@ -81,6 +86,9 @@ registerBlockType( 'woocommerce/attribute-filter', { 'data-heading': heading, 'data-heading-level': headingLevel, }; + if ( displayStyle !== 'list' ) { + data[ 'data-display-style' ] = displayStyle; + } return (