Add SearchControl component (#34159)

* Add initial SearchControl component

* Add async example

* Reorganize getItemPropsType type

* Create separate MenuItem component

* Update items to use value/label pairs

* Add fuzzy matching example

* Use MenuItem component in example

* Add callback method example and onSelect prop

* Add custom render example

* Add styling

* Simplify Menu component

* Add changelog entry

* Update SelectControl to DeprecatedSelectControl

* Rename SearchControl to SelectControl

* Add readme

* Add placeholder prop

* Add icon to combox box

* Rename deprecated SelectControl classes

* Add changelog entries

* Pass menu props to menu to fix ref issues

* Update lock file

* Rebase and update lock file

* Fix up IDs in e2e tests

* Make list structure more semantic

* Fix update conflict with pnpm-lock

* Move new SelectControl to experimental

* Change experimental class name to avoid style conflicts

* Fix up latest lock file from rebase

* Remove onboarding e2e changes

* Update changelogs

* Update lock file again

* Update pnpm-lock and fix lint error

Co-authored-by: Lourens Schep <lourensschep@gmail.com>
This commit is contained in:
Joshua T Flowers 2022-08-18 10:36:20 -07:00 committed by GitHub
parent e9d27c8949
commit 32de8bee6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1011 additions and 136 deletions

View File

@ -0,0 +1,4 @@
Significance: major
Type: add
Create new experimental SelectControl component

View File

@ -59,6 +59,7 @@
"d3-shape": "^1.3.7",
"d3-time-format": "^2.3.0",
"dompurify": "^2.3.6",
"downshift": "^6.1.9",
"emoji-flags": "^1.3.0",
"gridicons": "^3.4.0",
"memoize-one": "^6.0.0",

View File

@ -0,0 +1,74 @@
SelectControl
===
A component that allows searching and selection of one or more items, providing accessibility for item and menu interaction.
## Usage
`SelectControl` expects an array of item objects with `value` and `label` properties by default. However, using the `itemToString` prop will allow you to pass a function to determine the label used and customize this object's shape.
```jsx
const [ selected, setSelected ] = useState< SelectedType >( [] );
const items = [
{ value: 'item-1', label: 'Item 1' },
{ value: 'item-2', label: 'Item 2' },
];
<SelectControl
multiple
items={ items }
label="My select control"
selected={ selected }
onSelect={ ( item ) => item && setSelected( [ ...selected, item ] ) }
onRemove={ () => setSelected( selected.filter( ( i ) => i !== item ) ) }
/>
```
By default, the menu will render selectable items based on the provided items, but by passing a child function you can determine the render of those items.
```jsx
<SelectControl
label="Custom render"
items={ items }
selected={ selected }
onSelect={ ( item ) => setSelectedItem( item ) }
onRemove={ () => setSelectedItem( null ) }
>
{ ( {
items,
isOpen,
highlightedIndex,
getItemProps,
getMenuProps,
} ) => {
return (
<ul { ...getMenuProps() }>
{ isOpen && items.map( ( item, index: number ) => (
<li
key={ `${ item.value }${ index }` }
{ ...getItemProps() }
>
{ item.label }
</li>
) ) }
</ul>
);
} }
</SelectControl>
```
### Props
Name | Type | Default | Description
--- | --- | --- | ---
`children` | Function | `( { items, highlightedIndex, getItemProps, getMenuProps, isOpen } ) => JSX.Element` | A function that renders the menu and menu items
`multiple` | Boolean | `false` | Whether the input should allow multiple selections or a single selection
`items` | Array | `undefined` | The items used in the dropdown as an array of objects with `value` and `label` properties
`label` | String | `undefined` | A string shown above the input
`itemToString` | Function | `( item ) => item.label` | A function used to determine how a selected item should be shown
`getFilteredItems` | Function | `( allItems, inputValue, selectedItems ) => allItems.filter( ( item ) => selectedItems.indexOf( item ) < 0 && item.label.toLowerCase().startsWith( inputValue.toLowerCase() ) )` | A function to determine how items should be filtered based on user input and previously selected items
`onInputChange` | Function | `() => null` | A callback that fires when the user input has changed
`onRemove` | Function | `() => null` | A callback that fires when a selected item has been removed
`onSelect` | Function | `() => null` | A callback that fires when an item has been selected
`selected` | Array or Item | `undefined` | An array of selected items or a single selected item

View File

@ -0,0 +1,15 @@
.woocommerce-experimental-select-control__combox-box {
flex-grow: 1;
display: flex;
align-items: center;
input {
width: 100%;
padding: $gap-smaller 0;
}
}
.woocommerce-experimental-select-control__combox-box-icon {
padding-left: 10px;
margin-right: -2px;
}

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { createElement } from 'react';
import { Icon, search } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { Props } from './types';
type ComboBoxProps = {
comboBoxProps: Props;
inputProps: Props;
};
export const ComboBox = ( { comboBoxProps, inputProps }: ComboBoxProps ) => {
return (
<div
{ ...comboBoxProps }
className="woocommerce-experimental-select-control__combox-box"
>
<input { ...inputProps } />
<Icon
className="woocommerce-experimental-select-control__combox-box-icon"
icon={ search }
/>
</div>
);
};

View File

@ -0,0 +1 @@
export * from './select-control';

View File

@ -0,0 +1,5 @@
.woocommerce-experimental-select-control__menu-item {
padding: $gap-small;
margin: 0;
}

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { createElement, ReactElement } from 'react';
/**
* Internal dependencies
*/
import { ItemType, getItemPropsType } from './types';
type MenuItemProps = {
index: number;
isActive: boolean;
item: ItemType;
children: ReactElement | string;
getItemProps: getItemPropsType;
};
export const MenuItem = ( {
children,
getItemProps,
index,
isActive,
item,
}: MenuItemProps ) => {
return (
<li
style={ isActive ? { backgroundColor: '#bde4ff' } : {} }
{ ...getItemProps( { item, index } ) }
className="woocommerce-experimental-select-control__menu-item"
>
{ children }
</li>
);
};

View File

@ -0,0 +1,17 @@
.woocommerce-experimental-select-control__menu {
position: absolute;
width: 100%;
top: 100%;
box-sizing: border-box;
display: none;
&.is-open {
display: block;
}
.woocommerce-experimental-select-control__menu-inner {
background: $studio-white;
border: 1px solid $studio-gray-5;
border-radius: 3px;
}
}

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { createElement, ReactElement } from 'react';
/**
* Internal dependencies
*/
import { getMenuPropsType } from './types';
type MenuProps = {
children?: JSX.Element | JSX.Element[];
getMenuProps: getMenuPropsType;
isOpen: boolean;
};
export const Menu = ( { children, getMenuProps, isOpen }: MenuProps ) => {
return (
<div
{ ...getMenuProps() }
className={ classnames(
'woocommerce-experimental-select-control__menu',
{
'is-open': isOpen,
}
) }
>
{ isOpen &&
( ! Array.isArray( children ) || !! children.length ) && (
<ul className="woocommerce-experimental-select-control__menu-inner">
{ children }
</ul>
) }
</div>
);
};

View File

@ -0,0 +1,32 @@
@import './combo-box.scss';
@import './menu.scss';
@import './menu-item.scss';
.woocommerce-experimental-select-control {
position: relative;
.woocommerce-experimental-select-control__combo-box-wrapper {
border: 1px solid $studio-gray-20;
border-radius: 3px;
background: $studio-white;
position: relative;
display: flex;
align-items: center;
padding-left: $gap-small;
padding-right: $gap-small;
}
&.is-focused .woocommerce-experimental-select-control__combo-box-wrapper {
box-shadow: 0 0 0 1px var(--wp-admin-theme-color);
border-color: var(--wp-admin-theme-color);
}
.woocommerce-experimental-select-control__input {
border: 0;
box-shadow: none;
&:focus {
outline: none;
}
}
}

View File

@ -0,0 +1,163 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { createElement } from 'react';
import { useCombobox, useMultipleSelection } from 'downshift';
import { useState, Fragment } from '@wordpress/element';
/**
* Internal dependencies
*/
import { ChildrenType, ItemType } from './types';
import { SelectedItems } from './selected-items';
import { ComboBox } from './combo-box';
import { Menu } from './menu';
import { MenuItem } from './menu-item';
import {
itemToString as defaultItemToString,
getFilteredItems as defaultGetFilteredItems,
} from './utils';
type SelectControlProps = {
children?: ChildrenType;
items: ItemType[];
label: string;
initialSelectedItems?: ItemType[];
itemToString?: ( item: ItemType | null ) => string;
getFilteredItems?: (
allItems: ItemType[],
inputValue: string,
selectedItems: ItemType[]
) => ItemType[];
multiple?: boolean;
onInputChange?: ( value: string | undefined ) => void;
onRemove?: ( item: ItemType ) => void;
onSelect?: ( selected: ItemType ) => void;
placeholder?: string;
selected: ItemType | ItemType[] | null;
};
export const SelectControl = ( {
children = ( {
items,
highlightedIndex,
getItemProps,
getMenuProps,
isOpen,
} ) => {
return (
<Menu getMenuProps={ getMenuProps } isOpen={ isOpen }>
{ items.map( ( item, index: number ) => (
<MenuItem
key={ `${ item.value }${ index }` }
index={ index }
isActive={ highlightedIndex === index }
item={ item }
getItemProps={ getItemProps }
>
{ item.label }
</MenuItem>
) ) }
</Menu>
);
},
multiple = false,
items,
label,
itemToString = defaultItemToString,
getFilteredItems = defaultGetFilteredItems,
onInputChange = () => null,
onRemove = () => null,
onSelect = () => null,
placeholder,
selected,
}: SelectControlProps ) => {
const [ isFocused, setIsFocused ] = useState( false );
const [ inputValue, setInputValue ] = useState( '' );
const { getSelectedItemProps, getDropdownProps } = useMultipleSelection();
let selectedItems = selected === null ? [] : selected;
selectedItems = Array.isArray( selectedItems )
? selectedItems
: [ selectedItems ].filter( Boolean );
const filteredItems = getFilteredItems( items, inputValue, selectedItems );
const {
isOpen,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
highlightedIndex,
getItemProps,
} = useCombobox( {
inputValue,
items: filteredItems,
itemToString,
selectedItem: null,
onStateChange: ( { inputValue: value, type, selectedItem } ) => {
switch ( type ) {
case useCombobox.stateChangeTypes.InputChange:
onInputChange( value );
setInputValue( value || '' );
break;
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputBlur:
if ( selectedItem ) {
onSelect( selectedItem );
setInputValue(
multiple ? '' : itemToString( selectedItem )
);
}
break;
default:
break;
}
},
} );
return (
<div
className={ classnames( 'woocommerce-experimental-select-control', {
'is-focused': isFocused,
} ) }
>
{ /* Downshift's getLabelProps handles the necessary label attributes. */ }
{ /* eslint-disable jsx-a11y/label-has-for */ }
<label { ...getLabelProps() }>{ label }</label>
{ /* eslint-enable jsx-a11y/label-has-for */ }
<div className="woocommerce-experimental-select-control__combo-box-wrapper">
{ multiple && (
<SelectedItems
items={ selectedItems }
itemToString={ itemToString }
getSelectedItemProps={ getSelectedItemProps }
onRemove={ onRemove }
/>
) }
<ComboBox
comboBoxProps={ getComboboxProps() }
inputProps={ getInputProps( {
...getDropdownProps( { preventKeyAction: isOpen } ),
className:
'woocommerce-experimental-select-control__input',
onFocus: () => setIsFocused( true ),
onBlur: () => setIsFocused( false ),
placeholder,
} ) }
/>
</div>
{ children( {
items: filteredItems,
highlightedIndex,
getItemProps,
getMenuProps,
isOpen,
} ) }
</div>
);
};

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { createElement } from 'react';
/**
* Internal dependencies
*/
import { ItemType } from './types';
import Tag from '../tag';
type SelectedItemsProps = {
items: ItemType[];
itemToString: ( item: ItemType | null ) => string;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore These are the types provided by Downshift.
getSelectedItemProps: ( { selectedItem: any, index: any } ) => {
[ key: string ]: string;
};
onRemove: ( item: ItemType ) => void;
};
export const SelectedItems = ( {
items,
itemToString,
getSelectedItemProps,
onRemove,
}: SelectedItemsProps ) => {
return (
<div className="woocommerce-experimental-select-control__selected-items">
{ items.map( ( item, index ) => (
<span
key={ `selected-item-${ index }` }
className="woocommerce-experimental-select-control__selected-item"
{ ...getSelectedItemProps( {
selectedItem: item,
index,
} ) }
>
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
{ /* @ts-ignore Additional props are not required. */ }
<Tag
id={ item.value }
remove={ () => () => onRemove( item ) }
label={ itemToString( item ) }
/>
</span>
) ) }
</div>
);
};

View File

@ -0,0 +1,233 @@
/**
* External dependencies
*/
import { CheckboxControl, Spinner } from '@wordpress/components';
import React, { createElement } from 'react';
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { ItemType, SelectedType } from '../types';
import { MenuItem } from '../menu-item';
import { SelectControl } from '../';
import { Menu } from '../menu';
const sampleItems = [
{ value: 'apple', label: 'Apple' },
{ value: 'pear', label: 'Pear' },
{ value: 'orange', label: 'Orange' },
{ value: 'grape', label: 'Grape' },
{ value: 'banana', label: 'Banana' },
];
export const Single: React.FC = () => {
const [ selected, setSelected ] = useState< SelectedType >( null );
return (
<>
Selected: { JSON.stringify( selected ) }
<SelectControl
items={ sampleItems }
label="Single value"
selected={ selected }
onSelect={ ( item ) => item && setSelected( item ) }
onRemove={ () => setSelected( null ) }
/>
</>
);
};
export const Multiple: React.FC = () => {
const [ selected, setSelected ] = useState< ItemType[] >( [] );
return (
<>
<SelectControl
multiple
items={ sampleItems }
label="Multiple values"
selected={ selected }
onSelect={ ( item ) =>
Array.isArray( selected ) &&
setSelected( [ ...selected, item ] )
}
onRemove={ ( item ) =>
setSelected( selected.filter( ( i ) => i !== item ) )
}
/>
</>
);
};
export const FuzzyMatching: React.FC = () => {
const [ selected, setSelected ] = useState< ItemType[] >( [] );
const getFilteredItems = (
allItems: ItemType[],
inputValue: string,
selectedItems: ItemType[]
) => {
const pattern =
'.*' + inputValue.toLowerCase().split( '' ).join( '.*' ) + '.*';
const re = new RegExp( pattern );
return allItems.filter( ( item ) => {
if ( selectedItems.indexOf( item ) >= 0 ) {
return false;
}
return re.test( item.label.toLowerCase() );
} );
};
return (
<SelectControl
multiple
getFilteredItems={ getFilteredItems }
items={ sampleItems }
label="Fuzzy matching"
selected={ selected }
onSelect={ ( item ) => setSelected( [ ...selected, item ] ) }
onRemove={ ( item ) =>
setSelected( selected.filter( ( i ) => i !== item ) )
}
/>
);
};
export const Async: React.FC = () => {
const [ selectedItem, setSelectedItem ] = useState< SelectedType >( null );
const [ fetchedItems, setFetchedItems ] = useState< ItemType[] >( [] );
const [ isFetching, setIsFetching ] = useState( false );
const fetchItems = ( value: string | undefined ) => {
setIsFetching( true );
setTimeout( () => {
const results = sampleItems.sort( () => 0.5 - Math.random() );
setFetchedItems( results );
setIsFetching( false );
}, 1500 );
};
return (
<>
<SelectControl
label="Async"
items={ fetchedItems }
onInputChange={ fetchItems }
selected={ selectedItem }
onSelect={ ( item ) => setSelectedItem( item ) }
onRemove={ () => setSelectedItem( null ) }
placeholder="Start typing..."
>
{ ( {
items,
isOpen,
highlightedIndex,
getItemProps,
getMenuProps,
} ) => {
return (
<Menu isOpen={ isOpen } getMenuProps={ getMenuProps }>
{ isFetching ? (
<Spinner />
) : (
items.map( ( item, index: number ) => (
<MenuItem
key={ `${ item.value }${ index }` }
index={ index }
isActive={ highlightedIndex === index }
item={ item }
getItemProps={ getItemProps }
>
{ item.label }
</MenuItem>
) )
) }
</Menu>
);
} }
</SelectControl>
</>
);
};
export const CustomRender: React.FC = () => {
const [ selected, setSelected ] = useState< ItemType[] >( [] );
const onRemove = ( item ) => {
setSelected( selected.filter( ( i ) => i !== item ) );
};
const onSelect = ( item ) => {
const isSelected = selected.find( ( i ) => i === item );
if ( isSelected ) {
onRemove( item );
return;
}
setSelected( [ ...selected, item ] );
};
return (
<>
<SelectControl
multiple
label="Custom render"
items={ sampleItems }
getFilteredItems={ ( allItems ) => allItems }
selected={ selected }
onSelect={ onSelect }
onRemove={ onRemove }
>
{ ( {
items,
highlightedIndex,
getItemProps,
getMenuProps,
} ) => {
return (
<Menu isOpen={ true } getMenuProps={ getMenuProps }>
{ items.map( ( item, index: number ) => {
const isSelected = selected.includes( item );
return (
<MenuItem
key={ `${ item.value }${ index }` }
index={ index }
isActive={ highlightedIndex === index }
item={ item }
getItemProps={ getItemProps }
>
<>
<CheckboxControl
onChange={ () => null }
checked={ isSelected }
label={
<span
style={ {
fontWeight:
isSelected
? 'bold'
: 'normal',
} }
>
{ item.label }
</span>
}
/>
</>
</MenuItem>
);
} ) }
</Menu>
);
} }
</SelectControl>
</>
);
};
export default {
title: 'WooCommerce Admin/experimental/SelectControl',
component: SelectControl,
};

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { ReactElement, Component } from 'react';
import {
UseComboboxGetItemPropsOptions,
UseComboboxGetMenuPropsOptions,
GetPropsCommonOptions,
} from 'downshift';
export type ItemType = {
value: string;
label: string;
};
export type SelectedType = ItemType | null;
export type Props = {
[ key: string ]: string;
};
export type getItemPropsType = (
options: UseComboboxGetItemPropsOptions< ItemType >
// These are the types provided by Downshift.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => any;
export type getMenuPropsType = (
options?: UseComboboxGetMenuPropsOptions,
otherOptions?: GetPropsCommonOptions
// These are the types provided by Downshift.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => any;
export type ChildrenProps = {
items: ItemType[];
isOpen: boolean;
highlightedIndex: number;
getItemProps: getItemPropsType;
getMenuProps: getMenuPropsType;
};
export type ChildrenType = ( {
items,
isOpen,
highlightedIndex,
}: ChildrenProps ) => ReactElement | Component;

View File

@ -0,0 +1,20 @@
/**
* Internal dependencies
*/
import { ItemType, SelectedType } from './types';
export const itemToString = ( item: ItemType | null ) => {
return item ? item.label : '';
};
export const getFilteredItems = (
allItems: ItemType[],
inputValue: string,
selectedItems: ItemType[]
) => {
return allItems.filter(
( item ) =>
selectedItems.indexOf( item ) < 0 &&
item.label.toLowerCase().startsWith( inputValue.toLowerCase() )
);
};

View File

@ -37,6 +37,8 @@ export { default as SearchListItem } from './search-list-control/item';
export { default as SectionHeader } from './section-header';
export { default as SegmentedSelection } from './segmented-selection';
export { default as SelectControl } from './select-control';
export { SelectControl as __experimentalSelectControl } from './experimental-select-control';
export { MenuItem as __experimentalSelectControlMenuItem } from './experimental-select-control/menu-item';
export { default as ScrollTo } from './scroll-to';
export { SortableList } from './sortable-list';
export { default as Spinner } from './spinner';

View File

@ -14,6 +14,7 @@
@import 'dropdown-button/style.scss';
@import 'ellipsis-menu/style.scss';
@import 'empty-content/style.scss';
@import 'experimental-select-control/select-control.scss';
@import 'advanced-filters/style.scss';
@import 'date-range-filter-picker/style.scss';
@import 'filter-picker/style.scss';

View File

@ -16,7 +16,7 @@ import {
Spinner,
} from '@wordpress/components';
import { withDispatch, withSelect } from '@wordpress/data';
import { SelectControl, Form, TextControl } from '@woocommerce/components';
import { Form, TextControl, SelectControl } from '@woocommerce/components';
import {
ONBOARDING_STORE_NAME,
PLUGINS_STORE_NAME,

File diff suppressed because it is too large Load Diff