Add Tree Select Control Component (#33384)

* Add Tree Select Control Component

* Tweak test and code

* Fix Story path

* Restore .at

* Changelog

* Fix prettier error

* Update packages/js/components/src/tree-select-control/README.md

Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>

* Update packages/js/components/src/tree-select-control/tags.js

Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>

* Add comment regarding filter length

* Replace translate domain

* Document handleKeydown

* Refactor option object check

* Remove redundant check for disabled state

* Refactor tags & documentation

* Replace wrong domain for translations

Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
This commit is contained in:
Miguel Pérez Pellicer 2022-07-06 02:07:50 +04:00 committed by GitHub
parent 57869a2ab7
commit bdb057c1ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1870 additions and 0 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Tree Select Control Component

View File

@ -0,0 +1,119 @@
# Tree Select Control
A search box which filters options while typing,
allowing a user to select multiple options from a filtered list.
The main advantage of Tree Select Controls is that it allows to distribute the options in nested groups.
## Usage
```jsx
const options = [
{
value: 'EU',
label: 'Europe',
children: [
{ value: 'ES', label: 'Spain' },
{ value: 'FR', label: 'France', children: [] }, // defining children as [] is equivalent to don't have children
],
},
{
value: 'NA',
label: 'North America',
children: [
{ value: 'US', label: 'United States', children: [
{ value: 'TX', label: 'Texas' },
{ value: 'NY', label: 'New York' },
] },
{ value: 'CA', label: 'Canada' },
],
}
];
<TreeSelectControl
label="Select multiple options"
onChange={ ( value ) => setState( { selectedValues: value } ) }
options={ options }
placeholder="Start typing to filter options..."
/>;
```
### Component Props
| Name | Type | Default | Description |
| ------------------------------| ------------------| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `className` | string | `null` | Custom class name applied to the component |
| `disabled` | boolean | `null` | If true, disables the component |
| `help` | string | `null` | Help text under the select input |
| `id` | string | `null` | Custom ID for the component |
| `label` | string | `null` | A label to use for the main input |
| `maxVisibleTags` | number | `null` | The maximum number of tags to show. Undefined, 0 or less than 0 evaluates to unlimited. |
| `onChange` | function | `noop` | Function called when selected results change |
| `onDropdownVisibilityChange` | function | `noop` | Callback when the visibility of the dropdown options is changed. |
| `options` | array | `[]` | (required) An array of objects for the options list. The option along with its key, label and value will be returned in the onChange event |
| `placeholder` | string | `null` | A placeholder for the search input |
| `selectAllLabel` | string\|`false` | `All` | Label for "Select All options" node. False for disable the "Select All options" node. |
| `value` | array | `[]` | An array of objects describing selected values. |
### Option props
| Name | Type | Required | Description |
| ------------------------------| ------------------| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `children` | array | no | The children options inside the node |
| `key` | string | no | Optional unique key for differentiating between duplicated options, for example, same option in multiple groups |
| `label` | string | yes | A label for the option |
| `value` | string | yes | A value for the option |
### OnChange prop
For the component `value` we only need to set the value for the children options, no need to include the label or other props, also the groups are not allowed as `value` item.
In the `onChange` function only returns and array with the selected children `value` on it.
So for example, if the options are like this
```jsx
const options = [
{
value: 'EU',
label: 'Europe',
children: [
{ value: 'ES', label: 'Spain' },
{ value: 'FR', label: 'France', children: [] },
],
},
{
value: 'NA',
label: 'North America',
children: [
{ value: 'US', label: 'United States', children: [
{ value: 'TX', label: 'Texas' },
{ value: 'NY', label: 'New York' },
] },
{ value: 'CA', label: 'Canada' },
],
}
];
```
- If we select New York and Canada `onChange` will have `['NY','CA']`
- If we select Europe `onChange` will have `['ES','FR']`. Hence, `EU` is not a valid option itself and is just a group for all their children
- `value` prop sets the selected options, so it should have same format. Example: `['NY','CA']` to select `New York` and `Canada`
### selectAllLabel prop
The component has an extra checkbox "All" as a root node allowing to select all the options.
You can customize the label for it by just adding the prop `selectAllLabel`
```jsx
<TreeSelectControl
options={ options }
selectAllLabel={ __("Select all options", "my-awesome-plugin-domain") }
/>;
```
You can disable this feature and avoid any root element by setting the prop to `false`
### maxVisibleTags prop
This prop allows to define the maximum number of tags to show in the input.
When the user selects a bigger number the input will show "Show X more", when X is the number of the offset tags hidden.
When clicking on that, the component will display all the tags and will change "Show X more" into "Show less"

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { Icon, check } from '@wordpress/icons';
import { createElement } from '@wordpress/element';
/**
* @typedef {import('./index').Option} Option
*/
/**
* Renders a custom Checkbox
*
* @param {Object} props Component properties
* @param {Option} props.option Option for the checkbox
* @param {string} props.className The className for the component
* @param {boolean} props.checked Defines if the checkbox is checked
* @return {JSX.Element|null} The Checkbox component
*/
const Checkbox = ( { option, checked, className, ...props } ) => {
return (
<div className={ className }>
<div className="components-base-control__field">
<span className="components-checkbox-control__input-container">
<input
id={ `inspector-checkbox-control-${
option.key ?? option.value
}` }
className="components-checkbox-control__input"
type="checkbox"
tabIndex="-1"
value={ option.value }
checked={ checked }
{ ...props }
/>
{ checked && (
<Icon
icon={ check }
role="presentation"
className="components-checkbox-control__checked"
/>
) }
</span>
<label
className="components-checkbox-control__label"
htmlFor={ `inspector-checkbox-control-${
option.key ?? option.value
}` }
>
{ option.label }
</label>
</div>
</div>
);
};
export default Checkbox;

View File

@ -0,0 +1,8 @@
export const ROOT_VALUE = '__WC_TREE_SELECT_COMPONENT_ROOT__';
export const BACKSPACE = 'Backspace';
export const ESCAPE = 'Escape';
export const ENTER = 'Enter';
export const ARROW_UP = 'ArrowUp';
export const ARROW_DOWN = 'ArrowDown';
export const ARROW_LEFT = 'ArrowLeft';
export const ARROW_RIGHT = 'ArrowRight';

View File

@ -0,0 +1,120 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { noop } from 'lodash';
import { forwardRef, createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import Tags from './tags';
import { BACKSPACE } from './constants';
/**
* The Control Component renders a search input and also the Tags.
* It also triggers the setExpand for expanding the options tree on click.
*
* @param {Object} props Component props
* @param {Array} props.tags Array of tags
* @param {string} props.instanceId Id of the component
* @param {string} props.placeholder Placeholder of the search input
* @param {boolean} props.isExpanded True if the tree is expanded
* @param {boolean} props.disabled True if the component is disabled
* @param {number} props.maxVisibleTags The maximum number of tags to show. Undefined, 0 or less than 0 evaluates to "Show All".
* @param {string} props.value The current input value
* @param {Function} props.onFocus On Focus Callback
* @param {Function} props.onTagsChange Callback when the Tags change
* @param {Function} props.onInputChange Callback when the Input value changes
* @param {Function} [props.onControlClick] Callback when clicking on the control.
* @return {JSX.Element} The rendered component
*/
const Control = forwardRef(
(
{
tags = [],
instanceId,
placeholder,
isExpanded,
disabled,
maxVisibleTags,
value = '',
onFocus = () => {},
onTagsChange = () => {},
onInputChange = () => {},
onControlClick = noop,
},
ref
) => {
const hasTags = tags.length > 0;
const showPlaceholder = ! hasTags && ! isExpanded;
/**
* Handles keydown event
*
* Keys:
* When key down is BACKSPACE. Delete the last tag.
*
* @param {Event} event Event object
*/
const handleKeydown = ( event ) => {
if ( BACKSPACE === event.key ) {
if ( value ) return;
onTagsChange( tags.slice( 0, -1 ) );
event.preventDefault();
}
};
return (
/**
* ESLint Disable reason
* https://github.com/woocommerce/woocommerce-admin/blob/main/packages/components/src/select-control/control.js#L200
*/
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div
className={ classnames(
'components-base-control',
'woocommerce-tree-select-control__control',
{
'is-disabled': disabled,
'has-tags': hasTags,
}
) }
onClick={ ( e ) => {
ref.current.focus();
onControlClick( e );
} }
>
{ hasTags && (
<Tags
disabled={ disabled }
tags={ tags }
maxVisibleTags={ maxVisibleTags }
onChange={ onTagsChange }
/>
) }
<div className="components-base-control__field">
<input
ref={ ref }
id={ `woocommerce-tree-select-control-${ instanceId }__control-input` }
type="search"
placeholder={ showPlaceholder ? placeholder : '' }
autoComplete="off"
className="woocommerce-tree-select-control__control-input"
role="combobox"
aria-autocomplete="list"
value={ value }
aria-expanded={ isExpanded }
disabled={ disabled }
onFocus={ onFocus }
onChange={ onInputChange }
onKeyDown={ handleKeydown }
/>
</div>
</div>
);
}
);
export default Control;

View File

@ -0,0 +1,514 @@
/**
* External dependencies
*/
import { noop } from 'lodash';
import { __ } from '@wordpress/i18n';
import { focus } from '@wordpress/dom';
import {
useEffect,
useMemo,
useState,
useRef,
createElement,
} from '@wordpress/element';
import classnames from 'classnames';
import {
__experimentalUseFocusOutside as useFocusOutside,
useInstanceId,
} from '@wordpress/compose';
/**
* Internal dependencies
*/
import useIsEqualRefValue from './useIsEqualRefValue';
import Control from './control';
import Options from './options';
import './index.scss';
import { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE, ROOT_VALUE } from './constants';
/**
* @typedef {Object} CommonOption
* @property {string} value The value for the option
* @property {string} [key] Optional unique key for the Option. It will fallback to the value property if not defined
*/
/**
* @typedef {Object} BaseOption
* @property {string} label The label for the option
* @property {Option[]} [children] The children Option objects
*
* @typedef {CommonOption & BaseOption} Option
*/
/**
* @typedef {Object} BaseInnerOption
* @property {string|JSX.Element} label The label string or label with highlighted react element for the option.
* @property {InnerOption[]|undefined} children The children options. The options are filtered if in searching.
* @property {boolean} hasChildren Whether this option has children.
* @property {InnerOption[]} leaves All leaf options that are flattened under this option. The options are filtered if in searching.
* @property {boolean} checked Whether this option is checked.
* @property {boolean} partialChecked Whether this option is partially checked.
* @property {boolean} expanded Whether this option is expanded.
* @property {boolean} parent The parent of the current option
* @typedef {CommonOption & BaseInnerOption} InnerOption
*/
/**
* Renders a component with a searchable control, tags and a tree selector.
*
* @param {Object} props Component props.
* @param {string} [props.id] Component id
* @param {string} [props.label] Label for the component
* @param {string | false} [props.selectAllLabel] Label for the Select All root element. False for disable.
* @param {string} [props.help] Help text under the select input.
* @param {string} [props.placeholder] Placeholder for the search control input
* @param {string} [props.className] The class name for this component
* @param {boolean} [props.disabled] Disables the component
* @param {Option[]} [props.options] Options to show in the component
* @param {string[]} [props.value] Selected values
* @param {number} [props.maxVisibleTags] The maximum number of tags to show. Undefined, 0 or less than 0 evaluates to "Show All".
* @param {Function} [props.onChange] Callback when the selector changes
* @param {(visible: boolean) => void} [props.onDropdownVisibilityChange] Callback when the visibility of the dropdown options is changed.
* @return {JSX.Element} The component
*/
const TreeSelectControl = ( {
id,
label,
selectAllLabel = __( 'All', 'woocommerce' ),
help,
placeholder,
className,
disabled,
options = [],
value = [],
maxVisibleTags,
onChange = () => {},
onDropdownVisibilityChange = noop,
} ) => {
let instanceId = useInstanceId( TreeSelectControl );
instanceId = id ?? instanceId;
const [ treeVisible, setTreeVisible ] = useState( false );
const [ nodesExpanded, setNodesExpanded ] = useState( [] );
const [ inputControlValue, setInputControlValue ] = useState( '' );
const controlRef = useRef();
const dropdownRef = useRef();
const onDropdownVisibilityChangeRef = useRef();
onDropdownVisibilityChangeRef.current = onDropdownVisibilityChange;
// We will save in a REF previous search filter queries to avoid re-query the tree and save performance
const cacheRef = useRef( { filteredOptionsMap: new Map() } );
cacheRef.current.expandedValues = nodesExpanded;
cacheRef.current.selectedValues = value;
const showTree = ! disabled && treeVisible;
const root =
selectAllLabel !== false
? {
label: selectAllLabel,
value: ROOT_VALUE,
children: options,
}
: null;
const treeOptions = useIsEqualRefValue( root ? [ root ] : options );
const focusOutside = useFocusOutside( () => {
setTreeVisible( false );
} );
const filterQuery = inputControlValue.trim().toLowerCase();
// we only trigger the filter when there are more than 3 characters in the input.
const filter = filterQuery.length >= 3 ? filterQuery : '';
/**
* Optimizes the performance for getting the tags info
*/
const optionsRepository = useMemo( () => {
const repository = {};
// Clear cache if options change
cacheRef.current.filteredOptionsMap.clear();
function loadOption( option, parentId ) {
option.parent = parentId;
option.children?.forEach( ( el ) =>
loadOption( el, option.value )
);
repository[ option.key ?? option.value ] = option;
}
treeOptions.forEach( loadOption );
return repository;
}, [ treeOptions ] );
/*
* Perform the search query filter in the Tree options
*
* 1. Check if the search query is already cached and return it if so.
* 2. Deep copy the tree with adding properties for rendering.
* 3. In case of filter, we apply the filter option function to the tree.
* 4. In the filter function we also highlight the label with the matching letters
* 5. Finally we set the cache with the obtained results and apply the filters
*/
const filteredOptions = useMemo( () => {
const { current: cache } = cacheRef;
const cachedFilteredOptions = cache.filteredOptionsMap.get( filter );
if ( cachedFilteredOptions ) {
return cachedFilteredOptions;
}
const isSearching = Boolean( filter );
const highlightOptionLabel = ( optionLabel, matchPosition ) => {
const matchLength = matchPosition + filter.length;
if ( ! isSearching ) return optionLabel;
return (
<span>
<span>{ optionLabel.substring( 0, matchPosition ) }</span>
<strong>
{ optionLabel.substring( matchPosition, matchLength ) }
</strong>
<span>{ optionLabel.substring( matchLength ) }</span>
</span>
);
};
const descriptors = {
hasChildren: {
/**
* Returns whether this option has children.
*
* @return {boolean} True if has children, false otherwise.
*/
get() {
return this.children?.length > 0;
},
},
leaves: {
/**
* Return all leaf options flattened under this option. The options are filtered if in searching.
*
* @return {InnerOption[]} All leaf options that are flattened under this option. The options are filtered if in searching.
*/
get() {
if ( ! this.hasChildren ) {
return [];
}
return this.children.flatMap( ( option ) => {
return option.hasChildren ? option.leaves : option;
} );
},
},
checked: {
/**
* Returns whether this option is checked.
* A leaf option is checked if its value is selected.
* A parent option is checked if all leaves are checked.
*
* @return {boolean} True if checked, false otherwise.
*/
get() {
if ( this.hasChildren ) {
return this.leaves.every( ( opt ) => opt.checked );
}
return cache.selectedValues.includes( this.value );
},
},
partialChecked: {
/**
* Returns whether this option is partially checked.
* A leaf option always returns false.
* A parent option is partially checked if at least one but not all leaves are checked.
*
* @return {boolean} True if partially checked, false otherwise.
*/
get() {
if ( ! this.hasChildren ) {
return false;
}
return (
! this.checked &&
this.leaves.some(
( opt ) => opt.checked || opt.partialChecked
)
);
},
},
expanded: {
/**
* Returns whether this option is expanded.
* A leaf option always returns false.
*
* @return {boolean} True if expanded, false otherwise.
*/
get() {
return (
isSearching ||
this.value === ROOT_VALUE ||
cache.expandedValues.includes( this.value )
);
},
},
};
const reduceOptions = ( acc, { children = [], ...option } ) => {
if ( children.length ) {
option.children = children.reduce( reduceOptions, [] );
if ( ! option.children.length ) {
return acc;
}
} else if ( isSearching ) {
const match = option.label.toLowerCase().indexOf( filter );
if ( match === -1 ) {
return acc;
}
option.label = highlightOptionLabel( option.label, match );
}
Object.defineProperties( option, descriptors );
acc.push( option );
return acc;
};
const filteredTreeOptions = treeOptions.reduce( reduceOptions, [] );
cache.filteredOptionsMap.set( filter, filteredTreeOptions );
return filteredTreeOptions;
}, [ treeOptions, filter ] );
/**
* Handle key down events in the component
*
* Keys:
* If key down is ESCAPE. Collapse the tree
* If key down is ENTER. Expand the tree
* If key down is ARROW_UP. Navigate up to the previous option
* If key down is ARROW_DOWN. Navigate down to the next option
* If key down is ARROW_DOWN. Navigate down to the next option
*
* @param {Event} event The key down event
*/
const onKeyDown = ( event ) => {
if ( disabled ) return;
if ( ESCAPE === event.key ) {
setTreeVisible( false );
}
if ( ENTER === event.key ) {
setTreeVisible( true );
event.preventDefault();
}
const stepDict = {
[ ARROW_UP ]: -1,
[ ARROW_DOWN ]: 1,
};
const step = stepDict[ event.key ];
if ( step && dropdownRef.current && filteredOptions.length ) {
const elements = focus.focusable
.find( dropdownRef.current )
.filter( ( el ) => el.type === 'checkbox' );
const currentIndex = elements.indexOf( event.target );
const index = Math.max( currentIndex + step, -1 ) % elements.length;
elements.at( index ).focus();
event.preventDefault();
}
};
useEffect( () => {
onDropdownVisibilityChangeRef.current( showTree );
}, [ showTree ] );
/**
* Get formatted Tags from the selected values.
*
* @return {Array<{id: string, label: string|undefined}>} An array of Tags
*/
const tags = useMemo( () => {
if ( ! options.length ) {
return [];
}
return value.map( ( key ) => {
const option = optionsRepository[ key ];
return { id: key, label: option?.label };
} );
}, [ optionsRepository, value, options ] );
/**
* Handle click event on the option expander
*
* @param {Event} e The click event object
*/
const handleExpanderClick = ( e ) => {
const elements = focus.focusable.find( dropdownRef.current );
const index = elements.indexOf( e.currentTarget ) + 1;
elements[ index ].focus();
};
/**
* Expands/Collapses the Option
*
* @param {InnerOption} option The option to be expanded or collapsed.
*/
const handleToggleExpanded = ( option ) => {
setNodesExpanded(
option.expanded
? nodesExpanded.filter( ( el ) => option.value !== el )
: [ ...nodesExpanded, option.value ]
);
};
/**
* Handles a change on the Tree options. Could be a click on a parent option
* or a child option
*
* @param {boolean} checked Indicates if the item should be checked
* @param {InnerOption} option The option to change
*/
const handleOptionsChange = ( checked, option ) => {
if ( option.hasChildren ) {
handleParentChange( checked, option );
} else {
handleSingleChange( checked, option );
}
setInputControlValue( '' );
if ( ! nodesExpanded.includes( option.parent ) ) {
controlRef.current.focus();
}
};
/**
* Handles a change of a child element.
*
* @param {boolean} checked Indicates if the item should be checked
* @param {InnerOption} option The option to change
*/
const handleSingleChange = ( checked, option ) => {
const newValue = checked
? [ ...value, option.value ]
: value.filter( ( el ) => el !== option.value );
onChange( newValue );
};
/**
* Handles a change of a Parent element.
*
* @param {boolean} checked Indicates if the item should be checked
* @param {InnerOption} option The option to change
*/
const handleParentChange = ( checked, option ) => {
let newValue;
const changedValues = option.leaves
.filter( ( opt ) => opt.checked !== checked )
.map( ( opt ) => opt.value );
if ( checked ) {
if ( ! option.expanded ) {
handleToggleExpanded( option );
}
newValue = value.concat( changedValues );
} else {
newValue = value.filter( ( el ) => ! changedValues.includes( el ) );
}
onChange( newValue );
};
/**
* Handles a change of a Tag element. We map them to Value format.
*
* @param {Array} newTags List of new tags
*/
const handleTagsChange = ( newTags ) => {
onChange( [ ...newTags.map( ( el ) => el.id ) ] );
};
/**
* Prepares and sets the search filter.
* Filters of less than 3 characters are not considered, so we convert them to ''
*
* @param {Event} e Event returned by the On Change function in the Input control
*/
const handleOnInputChange = ( e ) => {
setInputControlValue( e.target.value );
};
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
{ ...focusOutside }
onKeyDown={ onKeyDown }
className={ classnames(
'woocommerce-tree-select-control',
className
) }
>
{ !! label && (
<label
htmlFor={ `woocommerce-tree-select-control-${ instanceId }__control-input` }
className="woocommerce-tree-select-control__label"
>
{ label }
</label>
) }
<Control
ref={ controlRef }
disabled={ disabled }
tags={ tags }
isExpanded={ showTree }
onFocus={ () => {
setTreeVisible( true );
} }
onControlClick={ () => {
setTreeVisible( true );
} }
instanceId={ instanceId }
placeholder={ placeholder }
label={ label }
maxVisibleTags={ maxVisibleTags }
value={ inputControlValue }
onTagsChange={ handleTagsChange }
onInputChange={ handleOnInputChange }
/>
{ showTree && (
<div
ref={ dropdownRef }
className="woocommerce-tree-select-control__tree"
role="tree"
tabIndex="-1"
>
<Options
options={ filteredOptions }
onChange={ handleOptionsChange }
onExpanderClick={ handleExpanderClick }
onToggleExpanded={ handleToggleExpanded }
/>
</div>
) }
{ help && (
<div className="woocommerce-tree-select-control__help">
{ help }
</div>
) }
</div>
);
};
export default TreeSelectControl;

View File

@ -0,0 +1,249 @@
$gap-largest: 40px;
$gap-larger: 36px;
$gap-large: 24px;
$gap: 16px;
$gap-small: 12px;
$gap-smaller: 8px;
$gap-smallest: 4px;
// Muriel
$muriel-box-shadow-1dp: 0 2px 1px -1px rgb(0 0 0 / 20%),
0 1px 1px 0 rgb(0 0 0 / 14%), 0 1px 3px 0 rgb(0 0 0 / 12%);
$muriel-box-shadow-6dp: 0 3px 5px rgb(0 0 0 / 20%),
0 1px 18px rgb(0 0 0 / 12%), 0 6px 10px rgb(0 0 0 / 14%);
$muriel-box-shadow-8dp: 0 5px 5px -3px rgb(0 0 0 / 20%),
0 8px 10px 1px rgb(0 0 0 / 14%), 0 3px 14px 2px rgb(0 0 0 / 12%);
// The following is based on SelectControl from @woocommerce/components.
.woocommerce-tree-select-control {
position: relative;
&__label {
display: block;
font-size: 16px;
color: $gray-700;
padding-bottom: $gap-smaller;
}
&__help {
margin-top: $gap-smallest;
line-height: 16px;
font-size: 12px;
color: $gray-700;
}
.components-base-control {
height: auto;
display: flex;
flex-wrap: wrap;
align-items: center;
border: 1px solid $gray-600;
border-radius: 3px;
background: $white;
padding: $gap-small;
position: relative;
.components-base-control__field {
display: flex;
align-items: center;
flex: 1;
flex-basis: content;
margin-bottom: 0;
max-width: 100%;
}
.woocommerce-tree-select-control__control-input {
font-size: 16px;
border: 0;
box-shadow: none;
color: $gray-800;
margin: 0;
padding-left: 0;
padding-right: 0;
width: 100%;
line-height: 24px;
text-align: left;
letter-spacing: inherit;
background: transparent;
&::-webkit-search-cancel-button {
display: none;
}
&:focus {
outline: none;
}
}
i {
color: #636d75;
margin-right: $gap-small;
width: 24px;
}
&.is-active {
box-shadow: 0 0 0 1px var(--wp-admin-theme-color);
border-color: var(--wp-admin-theme-color);
}
&.with-value .components-base-control__label,
&.has-tags .components-base-control__label {
font-size: 12px;
margin-top: -$gap-small;
}
&.is-disabled {
border: 1px solid rgb(167 170 173 / 50%);
background: rgb(255 255 255 / 50%);
.components-base-control__field {
visibility: hidden;
}
.components-base-control__label {
cursor: default;
}
.woocommerce-tag__remove {
cursor: default;
pointer-events: none;
}
}
}
.woocommerce-tree-select-control__autofill-input {
position: absolute;
z-index: -1;
}
.woocommerce-tree-select-control__tags {
position: relative;
margin: 0;
&.has-clear {
padding-right: $gap-large;
}
}
.woocommerce-tree-select-control__show-more {
max-height: 24px;
}
.woocommerce-tag {
max-height: 24px;
}
.woocommerce-tree-select-control__clear {
position: absolute;
right: 10px;
top: calc(50% - 10px);
& > .clear-icon {
color: $gray-400;
}
}
.woocommerce-tree-select-control__tree {
background: $white;
display: flex;
flex-direction: column;
align-items: stretch;
box-shadow: $muriel-box-shadow-6dp;
border-radius: 3px;
position: absolute;
left: 0;
right: 0;
z-index: 10;
overflow-y: auto;
max-height: 350px;
padding: $gap;
&.is-static {
position: static;
}
}
.woocommerce-tree-select-control__node {
&.has-children {
border-bottom: 1px solid #eee;
&:last-child {
border-bottom: 0;
}
}
}
.woocommerce-tree-select-control__children {
padding-left: calc(#{$gap} * 2);
}
.woocommerce-tree-select-control__main {
border-top: 1px solid $gray-200;
padding-left: 0;
}
.woocommerce-tree-select-control__option {
font-size: 16px;
padding: 0 0 0 $gap-smaller;
flex: 1;
text-align: left;
border: none;
min-height: 0;
display: flex;
height: auto;
&.is-selected,
&:hover {
color: var(--wp-admin-theme-color);
}
&.is-partially-checked {
.components-checkbox-control__input {
background: var(--wp-admin-theme-color);
border: $gap-smallest solid $white;
box-shadow: 0 0 0 1px #1e1e1e;
&:focus {
box-shadow: 0 0 0 1px #fff, 0 0 0 3px var(--wp-admin-theme-color);
}
}
}
}
.woocommerce-tree-select-control__expander {
padding: $gap-smallest;
cursor: pointer;
margin-right: 0;
background: transparent;
border: none;
// For nodes without children, we show still the space of the Expand Icon for alignment purposes.
&.is-hidden {
visibility: hidden;
pointer-events: none;
}
}
.components-checkbox-control__label {
width: 100%;
display: flex;
min-height: 56px;
align-items: center;
}
&.is-searchable {
.components-base-control__label {
left: 48px;
}
.components-base-control.is-active .components-base-control__label {
font-size: 12px;
margin-top: -$gap-small;
}
.woocommerce-tree-select-control__control-input {
padding-left: 12px;
}
}
}

View File

@ -0,0 +1,124 @@
/**
* External dependencies
*/
import { noop } from 'lodash';
import { Flex } from '@wordpress/components';
import { Icon, chevronUp, chevronDown } from '@wordpress/icons';
import classnames from 'classnames';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { ARROW_LEFT, ARROW_RIGHT, ROOT_VALUE } from './constants';
import Checkbox from './checkbox';
/**
* @typedef {import('./index').InnerOption} InnerOption
*/
/**
* This component renders a list of options and its children recursively
*
* @param {Object} props Component parameters
* @param {InnerOption[]} props.options List of options to be rendered
* @param {Function} props.onChange Callback when an option changes
* @param {Function} [props.onExpanderClick] Callback when an expander is clicked.
* @param {(option: InnerOption) => void} [props.onToggleExpanded] Callback when requesting an expander to be toggled.
*/
const Options = ( {
options = [],
onChange = () => {},
onExpanderClick = noop,
onToggleExpanded = noop,
} ) => {
/**
* Alters the node with some keys for accessibility
* ArrowRight - Expands the node
* ArrowLeft - Collapses the node
*
* @param {Event} event The KeyDown event
* @param {InnerOption} option The option where the event happened
*/
const handleKeyDown = ( event, option ) => {
if ( ! option.hasChildren ) {
return;
}
if ( event.key === ARROW_RIGHT && ! option.expanded ) {
onToggleExpanded( option );
} else if ( event.key === ARROW_LEFT && option.expanded ) {
onToggleExpanded( option );
}
};
return options.map( ( option ) => {
const isRoot = option.value === ROOT_VALUE;
const { hasChildren, checked, partialChecked, expanded } = option;
if ( ! option?.value ) return null;
return (
<div
key={ `${ option.key ?? option.value }` }
role={ hasChildren ? 'treegroup' : 'treeitem' }
aria-expanded={ hasChildren ? expanded : undefined }
className={ classnames(
'woocommerce-tree-select-control__node',
hasChildren && 'has-children'
) }
>
<Flex justify="flex-start">
{ ! isRoot && (
<button
className={ classnames(
'woocommerce-tree-select-control__expander',
! hasChildren && 'is-hidden'
) }
tabIndex="-1"
onClick={ ( e ) => {
onExpanderClick( e );
onToggleExpanded( option );
} }
>
<Icon icon={ expanded ? chevronUp : chevronDown } />
</button>
) }
<Checkbox
className={ classnames(
'components-base-control',
'woocommerce-tree-select-control__option',
partialChecked && 'is-partially-checked'
) }
option={ option }
checked={ checked }
onChange={ ( e ) => {
onChange( e.target.checked, option );
} }
onKeyDown={ ( e ) => {
handleKeyDown( e, option );
} }
/>
</Flex>
{ hasChildren && expanded && (
<div
className={ classnames(
'woocommerce-tree-select-control__children',
isRoot && 'woocommerce-tree-select-control__main'
) }
>
<Options
options={ option.children }
onChange={ onChange }
onExpanderClick={ onExpanderClick }
onToggleExpanded={ onToggleExpanded }
/>
</div>
) }
</div>
);
} );
};
export default Options;

View File

@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import TreeSelectControl from '../index';
const treeSelectControlOptions = [
{
value: 'EU',
label: 'Europe',
children: [
{ value: 'ES', label: 'Spain' },
{ value: 'FR', label: 'France' },
{ key: 'FR-Colonies', value: 'FR', label: 'France (Colonies)' },
],
},
{
value: 'AS',
label: 'Asia',
children: [
{
value: 'JP',
label: 'Japan',
children: [
{
value: 'TO',
label: 'Tokio',
children: [
{ value: 'SI', label: 'Shibuya' },
{ value: 'GI', label: 'Ginza' },
],
},
{ value: 'OK', label: 'Okinawa' },
],
},
{ value: 'CH', label: 'China' },
{
value: 'MY',
label: 'Malaysia',
children: [ { value: 'KU', label: 'Kuala Lumpur' } ],
},
],
},
{
value: 'NA',
label: 'North America',
children: [
{
value: 'US',
label: 'United States',
children: [
{ value: 'NY', label: 'New York' },
{ value: 'TX', label: 'Texas' },
{ value: 'GE', label: 'Georgia' },
],
},
{
value: 'CA',
label: 'Canada',
},
],
},
];
const Template = ( args ) => {
const [ selected, setSelected ] = useState( [ 'ES' ] );
return (
<TreeSelectControl
{ ...args }
value={ selected }
onChange={ setSelected }
/>
);
};
export const Base = Template.bind( {} );
Base.args = {
id: 'my-id',
label: 'Select Countries',
placeholder: 'Search countries',
disabled: false,
options: treeSelectControlOptions,
maxVisibleTags: 3,
selectAllLabel: 'All countries',
};
export default {
title: 'WooCommerce Admin/components/TreeSelectControl',
component: TreeSelectControl,
};

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useState, createElement } from '@wordpress/element';
import { Button } from '@wordpress/components';
/**
* Internal dependencies
*/
import Tag from '../tag';
/**
* A list of tags to display selected items.
*
* @param {Object} props The component props
* @param {Object[]} [props.tags=[]] The tags
* @param {Function} props.onChange The method called when a tag is removed
* @param {boolean} props.disabled True if the plugin is disabled
* @param {number} [props.maxVisibleTags=0] The maximum number of tags to show. 0 or less than 0 evaluates to "Show All".
*/
const Tags = ( {
tags = [],
disabled,
maxVisibleTags = 0,
onChange = () => {},
} ) => {
const [ showAll, setShowAll ] = useState( false );
const maxTags = Math.max( 0, maxVisibleTags );
const shouldShowAll = showAll || ! maxTags;
const visibleTags = shouldShowAll ? tags : tags.slice( 0, maxTags );
if ( ! tags.length ) {
return null;
}
/**
* Callback to remove a Tag.
* The function is defined this way because in the WooCommerce Tag Component the remove logic
* is defined as `onClick={ remove(key) }` hence we need to do this to avoid calling remove function
* on each render.
*
* @param {string} key The key for the Tag to be deleted
*/
const remove = ( key ) => {
return () => {
if ( disabled ) {
return;
}
onChange( tags.filter( ( tag ) => tag.id !== key ) );
};
};
return (
<div className="woocommerce-tree-select-control__tags">
{ visibleTags.map( ( item, i ) => {
if ( ! item.label ) {
return null;
}
const screenReaderLabel = sprintf(
// translators: 1: Tag Label, 2: Current Tag index, 3: Total amount of tags.
__( '%1$s (%2$d of %3$d)', 'woocommerce' ),
item.label,
i + 1,
tags.length
);
return (
<Tag
key={ item.id }
id={ item.id }
label={ item.label }
screenReaderLabel={ screenReaderLabel }
remove={ remove }
/>
);
} ) }
{ maxTags > 0 && tags.length > maxTags && (
<Button
isTertiary
className="woocommerce-tree-select-control__show-more"
onClick={ () => {
setShowAll( ! showAll );
} }
>
{ showAll
? __( 'Show less', 'woocommerce' )
: sprintf(
// translators: %d: The number of extra tags to show
__( '+ %d more', 'woocommerce' ),
tags.length - maxTags
) }
</Button>
) }
</div>
);
};
export default Tags;

View File

@ -0,0 +1,113 @@
/**
* External dependencies
*/
import { screen, render, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import Control from '../control';
describe( 'TreeSelectControl - Control Component', () => {
const onTagsChange = jest.fn().mockName( 'onTagsChange' );
const ref = {
current: {
focus: jest.fn(),
},
};
it( 'Renders the tags and calls onTagsChange when they change', () => {
const { queryByText, queryByLabelText, rerender } = render(
<Control
ref={ ref }
tags={ [ { id: 'es', label: 'Spain' } ] }
onTagsChange={ onTagsChange }
/>
);
expect( queryByText( 'Spain (1 of 1)' ) ).toBeTruthy();
userEvent.click( queryByLabelText( 'Remove Spain' ) );
expect( onTagsChange ).toHaveBeenCalledTimes( 1 );
expect( onTagsChange ).toHaveBeenCalledWith( [] );
rerender(
<Control
ref={ ref }
tags={ [ { id: 'es', label: 'Spain' } ] }
disabled={ true }
onTagsChange={ onTagsChange }
/>
);
expect( screen.queryByText( 'Spain (1 of 1)' ) ).toBeTruthy();
userEvent.click( screen.queryByLabelText( 'Remove Spain' ) );
expect( onTagsChange ).toHaveBeenCalledTimes( 1 );
} );
it( 'Calls onInputChange when typing', () => {
const onInputChange = jest
.fn()
.mockName( 'onInputChange' )
.mockImplementation( ( e ) => e.target.value );
const { queryByRole } = render(
<Control ref={ ref } onInputChange={ onInputChange } />
);
const input = queryByRole( 'combobox' );
expect( input ).toBeTruthy();
expect( input.hasAttribute( 'disabled' ) ).toBeFalsy();
userEvent.type( input, 'a' );
expect( onInputChange ).toHaveBeenCalledTimes( 1 );
expect( onInputChange ).toHaveNthReturnedWith( 1, 'a' );
fireEvent.change( input, { target: { value: 'test' } } );
expect( onInputChange ).toHaveBeenCalledTimes( 2 );
expect( onInputChange ).toHaveNthReturnedWith( 2, 'test' );
} );
it( 'Allows disabled input', () => {
const onInputChange = jest.fn().mockName( 'onInputChange' );
const { queryByRole } = render(
<Control disabled={ true } onInputChange={ onInputChange } />
);
const input = queryByRole( 'combobox' );
expect( input ).toBeTruthy();
expect( input.hasAttribute( 'disabled' ) ).toBeTruthy();
userEvent.type( input, 'a' );
expect( onInputChange ).not.toHaveBeenCalled();
} );
it( 'Calls onFocus callback when it is focused', () => {
const onFocus = jest.fn().mockName( 'onFocus' );
const { queryByRole } = render(
<Control ref={ ref } onFocus={ onFocus } />
);
userEvent.click( queryByRole( 'combobox' ) );
expect( onFocus ).toHaveBeenCalled();
} );
it( 'Renders placeholder when there are no tags and is not expanded', () => {
const { rerender } = render( <Control placeholder="Select" /> );
let input = screen.queryByRole( 'combobox' );
let placeholder = input.getAttribute( 'placeholder' );
expect( placeholder ).toBe( 'Select' );
rerender(
<Control
placeholder="Select"
tags={ [ { id: 'es', label: 'Spain' } ] }
/>
);
input = screen.queryByRole( 'combobox' );
placeholder = input.getAttribute( 'placeholder' );
expect( placeholder ).toBeFalsy();
rerender( <Control placeholder="Select" isExpanded={ true } /> );
input = screen.queryByRole( 'combobox' );
placeholder = input.getAttribute( 'placeholder' );
expect( placeholder ).toBeFalsy();
} );
} );

View File

@ -0,0 +1,151 @@
/**
* External dependencies
*/
import { fireEvent, render } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import TreeSelectControl from '../index';
const options = [
{
value: 'EU',
label: 'Europe',
children: [
{ value: 'ES', label: 'Spain' },
{ value: 'FR', label: 'France' },
{ value: 'IT', label: 'Italy' },
],
},
{
value: 'AS',
label: 'Asia',
},
];
describe( 'TreeSelectControl Component', () => {
it( 'Expands and collapse the Tree', () => {
const { queryByRole } = render(
<TreeSelectControl options={ options } value={ [] } />
);
const control = queryByRole( 'combobox' );
expect( queryByRole( 'tree' ) ).toBeFalsy();
fireEvent.click( control );
expect( queryByRole( 'tree' ) ).toBeTruthy();
} );
it( 'Calls onChange property with the selected values', () => {
const onChange = jest.fn().mockName( 'onChange' );
const { queryByLabelText, queryByRole, rerender } = render(
<TreeSelectControl
options={ options }
value={ [] }
onChange={ onChange }
/>
);
const control = queryByRole( 'combobox' );
fireEvent.click( control );
let checkbox = queryByLabelText( 'Europe' );
fireEvent.click( checkbox );
expect( onChange ).toHaveBeenCalledWith( [ 'ES', 'FR', 'IT' ] );
checkbox = queryByLabelText( 'Asia' );
fireEvent.click( checkbox );
expect( onChange ).toHaveBeenCalledWith( [ 'AS' ] );
rerender(
<TreeSelectControl
options={ options }
value={ [ 'ES' ] }
onChange={ onChange }
/>
);
checkbox = queryByLabelText( 'Asia' );
fireEvent.click( checkbox );
expect( onChange ).toHaveBeenCalledWith( [ 'ES', 'AS' ] );
} );
it( 'Renders the label', () => {
const { queryByLabelText } = render(
<TreeSelectControl options={ options } label="Select" />
);
expect( queryByLabelText( 'Select' ) ).toBeTruthy();
} );
it( 'Renders the All Options', () => {
const onChange = jest.fn().mockName( 'onChange' );
const { queryByLabelText, queryByRole, rerender } = render(
<TreeSelectControl options={ options } onChange={ onChange } />
);
const control = queryByRole( 'combobox' );
fireEvent.click( control );
const allCheckbox = queryByLabelText( 'All' );
expect( allCheckbox ).toBeTruthy();
fireEvent.click( allCheckbox );
expect( onChange ).toHaveBeenCalledWith( [ 'ES', 'FR', 'IT', 'AS' ] );
rerender(
<TreeSelectControl
value={ [ 'ES', 'FR', 'IT', 'AS' ] }
options={ options }
onChange={ onChange }
/>
);
fireEvent.click( allCheckbox );
expect( onChange ).toHaveBeenCalledWith( [] );
} );
it( 'Renders the All Options custom Label', () => {
const { queryByLabelText, queryByRole } = render(
<TreeSelectControl
options={ options }
selectAllLabel="All countries"
/>
);
const control = queryByRole( 'combobox' );
fireEvent.click( control );
const allCheckbox = queryByLabelText( 'All countries' );
expect( allCheckbox ).toBeTruthy();
} );
it( 'Filters Options on Search', () => {
const { queryByLabelText, queryByRole } = render(
<TreeSelectControl options={ options } />
);
const control = queryByRole( 'combobox' );
fireEvent.click( control );
expect( queryByLabelText( 'Europe' ) ).toBeTruthy();
expect( queryByLabelText( 'Asia' ) ).toBeTruthy();
fireEvent.change( control, { target: { value: 'Asi' } } );
expect( queryByLabelText( 'Europe' ) ).toBeFalsy(); // none of its children match Asi
expect( queryByLabelText( 'Asia' ) ).toBeTruthy(); // match Asi
fireEvent.change( control, { target: { value: 'As' } } ); // doesnt trigger if length < 3
expect( queryByLabelText( 'Europe' ) ).toBeTruthy();
expect( queryByLabelText( 'Asia' ) ).toBeTruthy();
expect( queryByLabelText( 'Spain' ) ).toBeFalsy(); // not expanded
fireEvent.change( control, { target: { value: 'pain' } } );
expect( queryByLabelText( 'Europe' ) ).toBeTruthy(); // contains Spain
expect( queryByLabelText( 'Spain' ) ).toBeTruthy(); // match pain
expect( queryByLabelText( 'France' ) ).toBeFalsy(); // doesn't match pain
expect( queryByLabelText( 'Asia' ) ).toBeFalsy(); // doesn't match pain
} );
} );

View File

@ -0,0 +1,142 @@
/**
* External dependencies
*/
import { fireEvent, render } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import TreeSelectControl from '../index';
/**
* In jsdom, the width and height of all elements are zero,
* so setting `offsetWidth` to avoid them to be filtered out
* by `isVisible` in focusable.
* Ref: https://github.com/WordPress/gutenberg/blob/%40wordpress/dom%403.1.1/packages/dom/src/focusable.js#L42-L48
*/
jest.mock( '@wordpress/dom', () => {
const { focus } = jest.requireActual( '@wordpress/dom' );
const descriptor = { configurable: true, get: () => 1 };
function find( context ) {
context.querySelectorAll( '*' ).forEach( ( element ) => {
Object.defineProperty( element, 'offsetWidth', descriptor );
} );
return focus.focusable.find( ...arguments );
}
return {
focus: {
...focus,
focusable: { ...focus.focusable, find },
},
};
} );
const options = [
{
value: 'EU',
label: 'Europe',
children: [
{ value: 'ES', label: 'Spain' },
{ value: 'FR', label: 'France' },
{ value: 'IT', label: 'Italy' },
],
},
{
value: 'NA',
label: 'North America',
children: [ { value: 'US', label: 'United States' } ],
},
];
describe( 'TreeSelectControl - Options Component', () => {
it( 'Expands and collapses groups', () => {
const { queryAllByRole, queryByText, queryByRole } = render(
<TreeSelectControl options={ options } />
);
const control = queryByRole( 'combobox' );
fireEvent.click( control );
const optionItem = queryByText( 'Europe' );
const option = options[ 0 ];
expect( optionItem ).toBeTruthy();
option.children.forEach( ( child ) => {
const childItem = queryByText( child.label );
expect( childItem ).toBeFalsy();
} );
const button = queryAllByRole( 'button' );
fireEvent.click( button[ 0 ] );
option.children.forEach( ( child ) => {
const childItem = queryByText( child.label );
expect( childItem ).toBeTruthy();
} );
fireEvent.click( button[ 0 ] );
option.children.forEach( ( child ) => {
const childItem = queryByText( child.label );
expect( childItem ).toBeFalsy();
} );
fireEvent.click( optionItem );
option.children.forEach( ( child ) => {
const childItem = queryByText( child.label );
expect( childItem ).toBeTruthy();
} );
} );
it( 'Partially selects groups', () => {
const { queryByRole, queryByText } = render(
<TreeSelectControl options={ options } value={ [ 'ES' ] } />
);
fireEvent.click( queryByRole( 'combobox' ) );
const partiallyCheckedOption = queryByText( 'Europe' );
const unCheckedOption = queryByText( 'North America' );
expect( partiallyCheckedOption ).toBeTruthy();
expect( unCheckedOption ).toBeTruthy();
const partiallyCheckedOptionWrapper = partiallyCheckedOption.closest(
'.woocommerce-tree-select-control__option'
);
const unCheckedOptionWrapper = unCheckedOption.closest(
'.woocommerce-tree-select-control__option'
);
expect( partiallyCheckedOptionWrapper ).toBeTruthy();
expect( unCheckedOptionWrapper ).toBeTruthy();
expect(
partiallyCheckedOptionWrapper.classList.contains(
'is-partially-checked'
)
).toBeTruthy();
expect(
unCheckedOptionWrapper.classList.contains( 'is-partially-checked' )
).toBeFalsy();
} );
it( 'Clears search input when option changes', () => {
const { queryAllByRole, queryByRole } = render(
<TreeSelectControl options={ options } />
);
const input = queryByRole( 'combobox' );
fireEvent.click( input );
fireEvent.change( input, { target: { value: 'Fra' } } );
expect( input.value ).toBe( 'Fra' );
const checkbox = queryAllByRole( 'checkbox' );
fireEvent.click( checkbox[ 0 ] );
expect( input.value ).toBe( '' );
} );
} );

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { fireEvent, render } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import Tags from '../tags';
const tags = [
{ id: 'ES', label: 'Spain' },
{ id: 'FR', label: 'France' },
];
describe( 'TreeSelectControl - Tags Component', () => {
it( 'Shows all tags by default', () => {
const { queryAllByRole } = render( <Tags tags={ tags } /> );
expect( queryAllByRole( 'button' ).length ).toBe( 2 );
} );
it( 'Limit Tags visibility', () => {
const { queryByText } = render(
<Tags tags={ tags } maxVisibleTags={ 1 } />
);
expect( queryByText( 'Spain' ) ).toBeTruthy();
expect( queryByText( 'France' ) ).toBeFalsy();
const showMore = queryByText( '+ 1 more' );
expect( queryByText( 'Show less' ) ).toBeFalsy();
expect( showMore ).toBeTruthy();
fireEvent.click( showMore );
expect( queryByText( 'Spain' ) ).toBeTruthy();
expect( queryByText( 'France' ) ).toBeTruthy();
expect( queryByText( 'Show less' ) ).toBeTruthy();
fireEvent.click( showMore );
expect( queryByText( 'Spain' ) ).toBeTruthy();
expect( queryByText( 'France' ) ).toBeFalsy();
expect( queryByText( 'Show less' ) ).toBeFalsy();
expect( queryByText( '+ 1 more' ) ).toBeTruthy();
} );
} );

View File

@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { isEqual } from 'lodash';
import { useRef } from '@wordpress/element';
/**
* Stores value in a ref. In subsequent render, value will be compared with ref.current using `isEqual` comparison.
* If it is equal, returns ref.current; else, set ref.current to be value.
*
* This is useful for objects used in hook dependencies.
*
* @param {*} value Value to be stored in ref.
* @return {*} Value stored in ref.
*/
const useIsEqualRefValue = ( value ) => {
const optionsRef = useRef( value );
if ( ! isEqual( optionsRef.current, value ) ) {
optionsRef.current = value;
}
return optionsRef.current;
};
export default useIsEqualRefValue;