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:
parent
57869a2ab7
commit
bdb057c1ad
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Tree Select Control Component
|
|
@ -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"
|
|
@ -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;
|
|
@ -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';
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -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
|
||||
} );
|
||||
} );
|
|
@ -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( '' );
|
||||
} );
|
||||
} );
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -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;
|
Loading…
Reference in New Issue