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
This commit is contained in:
Albert Juhé Lluveras 2019-12-03 14:39:11 +01:00 committed by GitHub
parent 1e92555c8c
commit f7c807a0c6
17 changed files with 575 additions and 39 deletions

View File

@ -97,7 +97,7 @@ const CheckboxList = ( {
return (
<Fragment>
{ options.map( ( option, index ) => (
<Fragment key={ option.key }>
<Fragment key={ option.value }>
<li
{ ...shouldTruncateOptions &&
! showExpanded &&
@ -105,13 +105,15 @@ const CheckboxList = ( {
>
<input
type="checkbox"
id={ option.key }
value={ option.key }
onChange={ onChange }
checked={ checked.includes( option.key ) }
id={ option.value }
value={ option.value }
onChange={ ( event ) => {
onChange( event.target.value );
} }
checked={ checked.includes( option.value ) }
disabled={ isDisabled }
/>
<label htmlFor={ option.key }>
<label htmlFor={ option.value }>
{ option.label }
</label>
</li>
@ -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,

View File

@ -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 (
<Downshift
onChange={ onChange }
selectedItem={ null }
stateReducer={ stateReducer }
>
{ ( {
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
highlightedIndex,
inputValue,
isOpen,
openMenu,
} ) => (
<div className={ classes }>
{ /* eslint-disable-next-line jsx-a11y/label-has-for */ }
<label
{ ...getLabelProps( {
className: 'screen-reader-text',
} ) }
>
{ inputLabel }
</label>
<DropdownSelectorInputWrapper
isOpen={ isOpen }
onClick={ () => focusInput( isOpen ) }
>
{ checked.map( ( value ) => {
const option = options.find(
( o ) => o.value === value
);
return (
<DropdownSelectorSelectedChip
key={ value }
onRemoveItem={ ( val ) => {
onChange( val );
focusInput( isOpen );
} }
option={ option }
/>
);
} ) }
<DropdownSelectorInput
attributeLabel={ attributeLabel }
checked={ checked }
getInputProps={ getInputProps }
inputRef={ inputRef }
isDisabled={ isDisabled }
onFocus={ openMenu }
onRemoveItem={ ( val ) => {
onChange( val );
focusInput( isOpen );
} }
value={ inputValue }
/>
</DropdownSelectorInputWrapper>
{ isOpen && ! isDisabled && (
<DropdownSelectorMenu
checked={ checked }
getItemProps={ getItemProps }
getMenuProps={ getMenuProps }
highlightedIndex={ highlightedIndex }
options={ options.filter(
( option ) =>
! inputValue ||
option.value.startsWith( inputValue )
) }
/>
) }
</div>
) }
</Downshift>
);
};
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;

View File

@ -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 */
<div
className="wc-block-dropdown-selector__input-wrapper"
onClick={ onClick }
>
{ children }
</div>
);
};
export default DropdownSelectorInputWrapper;

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
const DropdownSelectorInput = ( {
attributeLabel,
checked,
getInputProps,
inputRef,
isDisabled,
onFocus,
onRemoveItem,
value,
} ) => {
return (
<input
{ ...getInputProps( {
ref: inputRef,
className: 'wc-block-dropdown-selector__input',
disabled: isDisabled,
onFocus,
onKeyDown( e ) {
if (
e.key === 'Backspace' &&
! value &&
checked.length > 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;

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import classNames from 'classnames';
const DropdownSelectorMenu = ( {
checked,
getItemProps,
getMenuProps,
highlightedIndex,
options,
} ) => {
return (
<ul
{ ...getMenuProps( {
className: 'wc-block-dropdown-selector__list',
} ) }
>
{ options.map( ( option, index ) => {
const selected = checked.includes( option.value );
return (
// eslint-disable-next-line react/jsx-key
<li
{ ...getItemProps( {
key: option.value,
className: classNames(
'wc-block-dropdown-selector__list-item',
{
'is-selected': selected,
'is-highlighted':
highlightedIndex === index,
}
),
index,
item: option.value,
'aria-label': selected
? sprintf(
__(
'Remove %s filter',
'woo-gutenberg-products-block'
),
option.name
)
: null,
} ) }
>
{ option.label }
</li>
);
} ) }
</ul>
);
};
export default DropdownSelectorMenu;

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
const DropdownSelectorSelectedChip = ( { onRemoveItem, option } ) => {
return (
<button
className="wc-block-dropdown-selector__selected-chip"
onClick={ ( e ) => {
e.stopPropagation();
onRemoveItem( option.value );
} }
onKeyDown={ ( e ) => {
if ( e.key === 'Backspace' || e.key === 'Delete' ) {
onRemoveItem( option.value );
}
} }
aria-label={ sprintf(
__( 'Remove %s filter', 'woo-gutenberg-products-block' ),
option.name
) }
>
{ option.label }
<span className="wc-block-dropdown-selector__selected-chip__remove">
𝘅
</span>
</button>
);
};
export default DropdownSelectorSelectedChip;

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
.wc-block-active-filters {
margin: 0 0 $gap;
margin-bottom: $gap-large;
overflow: hidden;
.wc-block-active-filters__clear-all {

View File

@ -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 = ( {
<Fragment>
{ name }
{ blockAttributes.showCounts && count !== null && (
<span className="wc-block-attribute-filter-list-count">
{ count }
</span>
<Label
label={ count }
screenReaderLabel={ sprintf(
// translators: %s number of products.
_n(
'%s product',
'%s products',
count,
'woo-gutenberg-products-block'
),
count
) }
wrapperElement="span"
wrapperProps={ {
className:
'wc-block-attribute-filter-list-count',
} }
/>
) }
</Fragment>
);
@ -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 (
<Fragment>
@ -227,18 +274,26 @@ const AttributeFilterBlock = ( {
<TagName>{ blockAttributes.heading }</TagName>
) }
<div className="wc-block-attribute-filter">
<CheckboxList
className={ 'wc-block-attribute-filter-list' }
options={ displayedOptions }
checked={ checked }
onChange={ onChange }
isLoading={
! blockAttributes.isPreview && attributeTermsLoading
}
isDisabled={
! blockAttributes.isPreview && filteredCountsLoading
}
/>
{ blockAttributes.displayStyle === 'dropdown' ? (
<DropdownSelector
attributeLabel={ attributeObject.label }
checked={ checked }
className={ 'wc-block-attribute-filter-dropdown' }
inputLabel={ blockAttributes.heading }
isLoading={ isLoading }
onChange={ onChange }
options={ displayedOptions }
/>
) : (
<CheckboxList
className={ 'wc-block-attribute-filter-list' }
options={ displayedOptions }
checked={ checked }
onChange={ onChange }
isLoading={ isLoading }
isDisabled={ isDisabled }
/>
) }
</div>
</Fragment>
);

View File

@ -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 } ) => {
} )
}
/>
<ToggleButtonControl
label={ __(
'Display Style',
'woo-gutenberg-products-block'
) }
value={ displayStyle }
options={ [
{
label: __(
'List',
'woo-gutenberg-products-block'
),
value: 'list',
},
{
label: __(
'Dropdown',
'woo-gutenberg-products-block'
),
value: 'dropdown',
},
] }
onChange={ ( value ) =>
setAttributes( {
displayStyle: value,
} )
}
/>
</PanelBody>
<PanelBody
title={ __(

View File

@ -17,6 +17,7 @@ const getProps = ( el ) => {
queryType: el.dataset.queryType,
heading: el.dataset.heading,
headingLevel: el.dataset.headingLevel || 3,
displayStyle: el.dataset.displayStyle,
},
};
};

View File

@ -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 (
<div
className={ classNames( 'is-loading', className ) }

View File

@ -1,6 +1,15 @@
.wc-block-attribute-filter {
margin-bottom: $gap-large;
.wc-block-attribute-filter-list-count::before {
content: " (";
}
.wc-block-attribute-filter-list-count::after {
content: ")";
}
.wc-block-attribute-filter-list {
margin: 0 0 $gap;
margin: 0;
li {
text-decoration: underline;
@ -14,11 +23,11 @@
.wc-block-attribute-filter-list-count {
float: right;
}
.wc-block-attribute-filter-list-count::before {
content: " (";
}
.wc-block-attribute-filter-list-count::after {
content: ")";
}
.wc-block-dropdown-selector {
.wc-block-dropdown-selector__list .wc-block-attribute-filter-list-count {
opacity: 0.6;
}
}
}

View File

@ -7106,6 +7106,11 @@
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
"dev": true
},
"compute-scroll-into-view": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.11.tgz",
"integrity": "sha512-uUnglJowSe0IPmWOdDtrlHXof5CTIJitfJEyITHBW6zDVOGu9Pjk5puaLM73SLcwak0L4hEjO7Td88/a6P5i7A=="
},
"computed-style": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/computed-style/-/computed-style-0.1.4.tgz",
@ -8176,6 +8181,17 @@
"is-obj": "^1.0.0"
}
},
"downshift": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/downshift/-/downshift-3.4.3.tgz",
"integrity": "sha512-lk0Q1VF4eTDe4EMzYtdVCPdu58ZRFyK3wxEAGUeKqPRDoHDgoS9/TaxW2w+hEbeh9yBMU2IKX8lQkNn6YTfZ4w==",
"requires": {
"@babel/runtime": "^7.4.5",
"compute-scroll-into-view": "^1.0.9",
"prop-types": "^15.7.2",
"react-is": "^16.9.0"
}
},
"duplexer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",

View File

@ -106,6 +106,7 @@
"dependencies": {
"@woocommerce/components": "4.0.0",
"compare-versions": "3.5.1",
"downshift": "^3.4.2",
"eslint-plugin-woocommerce": "file:bin/eslint-plugin-woocommerce",
"gridicons": "3.3.1",
"react-number-format": "4.3.1",