Allowing generic item type in new experimental SelectControl (#34547)

Co-authored-by: Joel <joel.thiessen@a8c.com>
This commit is contained in:
Joel Thiessen 2022-09-16 13:35:14 -07:00 committed by GitHub
parent 7cec9b40a9
commit 09bfa0b737
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 217 additions and 60 deletions

View File

@ -5,7 +5,7 @@ A component that allows searching and selection of one or more items, providing
## 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.
`SelectControl` expects an array of item objects with `value` and `label` properties by default, with the option of passing your own ItemType to the component. Using the `getItemLabel` and `getItemValue` props will allow you to pass a function to determine the respective label and value used and customize this object's shape.
```jsx
const [ selected, setSelected ] = useState< SelectedType >( [] );
@ -25,6 +25,37 @@ const items = [
/>
```
And with a Custom Type to describe the item shape:
```jsx
const [ selected, setSelected ] =
useState< SelectedType< Array< CustomItemType > > >( null );
const customItems = [
{ id: 1, name: 'Joe', email: 'joe@notreally.com' },
{ id: 2, name: 'Jen' },
];
<SelectControl < CustomItemType >
multiple
items={ customItems }
label="CustomItemType value"
selected={ selected }
onSelect={ ( item ) =>
setSelected(
Array.isArray( selected )
? [ ...selected, item ]
: [ item ]
)
}
onRemove={ ( item ) =>
setSelected( selected.filter( ( i ) => i !== item ) )
}
getItemLabel={ ( item ) => item?.name }
getItemValue={ ( item ) => String( item?.id ) }
/>
```
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
@ -41,15 +72,17 @@ By default, the menu will render selectable items based on the provided items, b
highlightedIndex,
getItemProps,
getMenuProps,
getItemLabel,
getItemValue
} ) => {
return (
<ul { ...getMenuProps() }>
{ isOpen && items.map( ( item, index: number ) => (
<li
key={ `${ item.value }${ index }` }
key={ `${ getItemValue(item) }${ index }` }
{ ...getItemProps() }
>
{ item.label }
{ getItemLabel(item) }
</li>
) ) }
</ul>
@ -66,7 +99,8 @@ Name | Type | Default | Description
`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
`getItemLabel` | Function | `( item ) => item.label` | A function used to determine the label for an item
`getItemValue` | Function | `( item ) => item.value` | A function used to determine the value for an item
`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

View File

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

View File

@ -4,31 +4,39 @@
import classnames from 'classnames';
import { createElement } from 'react';
import { useCombobox, useMultipleSelection } from 'downshift';
import { useState, Fragment } from '@wordpress/element';
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { ChildrenType, ItemType } from './types';
import {
ChildrenType,
DefaultItemType,
getItemLabelType,
getItemValueType,
} 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,
defaultGetItemLabel,
defaultGetItemValue,
defaultGetFilteredItems,
} from './utils';
type SelectControlProps = {
children?: ChildrenType;
type SelectControlProps< ItemType > = {
children?: ChildrenType< ItemType >;
items: ItemType[];
label: string;
initialSelectedItems?: ItemType[];
itemToString?: ( item: ItemType | null ) => string;
getItemLabel?: getItemLabelType< ItemType >;
getItemValue?: getItemValueType< ItemType >;
getFilteredItems?: (
allItems: ItemType[],
inputValue: string,
selectedItems: ItemType[]
selectedItems: ItemType[],
getItemLabel: getItemLabelType< ItemType >
) => ItemType[];
multiple?: boolean;
onInputChange?: ( value: string | undefined ) => void;
@ -38,9 +46,11 @@ type SelectControlProps = {
selected: ItemType | ItemType[] | null;
};
export const SelectControl = ( {
function SelectControl< ItemType = DefaultItemType >( {
getItemLabel = defaultGetItemLabel,
getItemValue = defaultGetItemValue,
children = ( {
items,
items: renderItems,
highlightedIndex,
getItemProps,
getMenuProps,
@ -48,15 +58,15 @@ export const SelectControl = ( {
} ) => {
return (
<Menu getMenuProps={ getMenuProps } isOpen={ isOpen }>
{ items.map( ( item, index: number ) => (
{ renderItems.map( ( item, index: number ) => (
<MenuItem
key={ `${ item.value }${ index }` }
key={ `${ getItemValue( item ) }${ index }` }
index={ index }
isActive={ highlightedIndex === index }
item={ item }
getItemProps={ getItemProps }
>
{ item.label }
{ getItemLabel( item ) }
</MenuItem>
) ) }
</Menu>
@ -65,14 +75,13 @@ export const SelectControl = ( {
multiple = false,
items,
label,
itemToString = defaultItemToString,
getFilteredItems = defaultGetFilteredItems,
onInputChange = () => null,
onRemove = () => null,
onSelect = () => null,
placeholder,
selected,
}: SelectControlProps ) => {
}: SelectControlProps< ItemType > ) {
const [ isFocused, setIsFocused ] = useState( false );
const [ inputValue, setInputValue ] = useState( '' );
const { getSelectedItemProps, getDropdownProps } = useMultipleSelection();
@ -80,7 +89,12 @@ export const SelectControl = ( {
selectedItems = Array.isArray( selectedItems )
? selectedItems
: [ selectedItems ].filter( Boolean );
const filteredItems = getFilteredItems( items, inputValue, selectedItems );
const filteredItems = getFilteredItems(
items,
inputValue,
selectedItems,
getItemLabel
);
const {
isOpen,
@ -93,7 +107,7 @@ export const SelectControl = ( {
} = useCombobox( {
inputValue,
items: filteredItems,
itemToString,
itemToString: getItemLabel,
selectedItem: null,
onStateChange: ( { inputValue: value, type, selectedItem } ) => {
switch ( type ) {
@ -108,7 +122,7 @@ export const SelectControl = ( {
if ( selectedItem ) {
onSelect( selectedItem );
setInputValue(
multiple ? '' : itemToString( selectedItem )
multiple ? '' : getItemLabel( selectedItem )
);
}
@ -133,7 +147,8 @@ export const SelectControl = ( {
{ multiple && (
<SelectedItems
items={ selectedItems }
itemToString={ itemToString }
getItemLabel={ getItemLabel }
getItemValue={ getItemValue }
getSelectedItemProps={ getSelectedItemProps }
onRemove={ onRemove }
/>
@ -157,7 +172,11 @@ export const SelectControl = ( {
getItemProps,
getMenuProps,
isOpen,
getItemLabel,
getItemValue,
} ) }
</div>
);
};
}
export { SelectControl };

View File

@ -6,12 +6,13 @@ import { createElement } from 'react';
/**
* Internal dependencies
*/
import { ItemType } from './types';
import Tag from '../tag';
import { getItemLabelType, getItemValueType } from './types';
type SelectedItemsProps = {
type SelectedItemsProps< ItemType > = {
items: ItemType[];
itemToString: ( item: ItemType | null ) => string;
getItemLabel: getItemLabelType< ItemType >;
getItemValue: getItemValueType< ItemType >;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore These are the types provided by Downshift.
getSelectedItemProps: ( { selectedItem: any, index: any } ) => {
@ -20,12 +21,13 @@ type SelectedItemsProps = {
onRemove: ( item: ItemType ) => void;
};
export const SelectedItems = ( {
export const SelectedItems = < ItemType, >( {
items,
itemToString,
getItemLabel,
getItemValue,
getSelectedItemProps,
onRemove,
}: SelectedItemsProps ) => {
}: SelectedItemsProps< ItemType > ) => {
return (
<div className="woocommerce-experimental-select-control__selected-items">
{ items.map( ( item, index ) => (
@ -40,9 +42,9 @@ export const SelectedItems = ( {
{ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ }
{ /* @ts-ignore Additional props are not required. */ }
<Tag
id={ item.value }
id={ getItemValue( item ) }
remove={ () => () => onRemove( item ) }
label={ itemToString( item ) }
label={ getItemLabel( item ) }
/>
</span>
) ) }

View File

@ -8,7 +8,7 @@ import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { ItemType, SelectedType } from '../types';
import { SelectedType, DefaultItemType } from '../types';
import { MenuItem } from '../menu-item';
import { SelectControl } from '../';
import { Menu } from '../menu';
@ -22,7 +22,8 @@ const sampleItems = [
];
export const Single: React.FC = () => {
const [ selected, setSelected ] = useState< SelectedType >( null );
const [ selected, setSelected ] =
useState< SelectedType< DefaultItemType > >( null );
return (
<>
@ -39,7 +40,7 @@ export const Single: React.FC = () => {
};
export const Multiple: React.FC = () => {
const [ selected, setSelected ] = useState< ItemType[] >( [] );
const [ selected, setSelected ] = useState< DefaultItemType[] >( [] );
return (
<>
@ -61,12 +62,12 @@ export const Multiple: React.FC = () => {
};
export const FuzzyMatching: React.FC = () => {
const [ selected, setSelected ] = useState< ItemType[] >( [] );
const [ selected, setSelected ] = useState< DefaultItemType[] >( [] );
const getFilteredItems = (
allItems: ItemType[],
allItems: DefaultItemType[],
inputValue: string,
selectedItems: ItemType[]
selectedItems: DefaultItemType[]
) => {
const pattern =
'.*' + inputValue.toLowerCase().split( '' ).join( '.*' ) + '.*';
@ -96,8 +97,11 @@ export const FuzzyMatching: React.FC = () => {
};
export const Async: React.FC = () => {
const [ selectedItem, setSelectedItem ] = useState< SelectedType >( null );
const [ fetchedItems, setFetchedItems ] = useState< ItemType[] >( [] );
const [ selectedItem, setSelectedItem ] =
useState< SelectedType< DefaultItemType > >( null );
const [ fetchedItems, setFetchedItems ] = useState< DefaultItemType[] >(
[]
);
const [ isFetching, setIsFetching ] = useState( false );
const fetchItems = ( value: string | undefined ) => {
@ -153,7 +157,7 @@ export const Async: React.FC = () => {
};
export const CustomRender: React.FC = () => {
const [ selected, setSelected ] = useState< ItemType[] >( [] );
const [ selected, setSelected ] = useState< DefaultItemType[] >( [] );
const onRemove = ( item ) => {
setSelected( selected.filter( ( i ) => i !== item ) );
@ -227,6 +231,69 @@ export const CustomRender: React.FC = () => {
);
};
type CustomItemType = {
itemId: number;
user: {
name: string;
email?: string;
id: number;
};
};
const customItems: CustomItemType[] = [
{
itemId: 1,
user: {
name: 'Joe',
email: 'joe@a8c.com',
id: 32,
},
},
{
itemId: 2,
user: {
name: 'Jen',
id: 16,
},
},
{
itemId: 3,
user: {
name: 'Jared',
id: 112,
},
},
];
export const CustomItemType: React.FC = () => {
const [ selected, setSelected ] =
useState< SelectedType< Array< CustomItemType > > >( null );
return (
<>
Selected: { JSON.stringify( selected ) }
<SelectControl< CustomItemType >
multiple
items={ customItems }
label="CustomItemType value"
selected={ selected }
onSelect={ ( item ) =>
setSelected(
Array.isArray( selected )
? [ ...selected, item ]
: [ item ]
)
}
onRemove={ ( item ) =>
setSelected( selected.filter( ( i ) => i !== item ) )
}
getItemLabel={ ( item ) => item?.user.name }
getItemValue={ ( item ) => String( item?.itemId ) }
/>
</>
);
};
export default {
title: 'WooCommerce Admin/experimental/SelectControl',
component: SelectControl,

View File

@ -8,18 +8,18 @@ import {
GetPropsCommonOptions,
} from 'downshift';
export type ItemType = {
value: string;
export type DefaultItemType = {
label: string;
value: string | number;
};
export type SelectedType = ItemType | null;
export type SelectedType< ItemType > = ItemType | null;
export type Props = {
[ key: string ]: string;
};
export type getItemPropsType = (
export type getItemPropsType< ItemType > = (
options: UseComboboxGetItemPropsOptions< ItemType >
// These are the types provided by Downshift.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -32,16 +32,24 @@ export type getMenuPropsType = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => any;
export type ChildrenProps = {
export type ChildrenProps< ItemType > = {
items: ItemType[];
isOpen: boolean;
highlightedIndex: number;
getItemProps: getItemPropsType;
getItemProps: getItemPropsType< ItemType >;
getMenuProps: getMenuPropsType;
getItemLabel: getItemLabelType< ItemType >;
getItemValue: getItemValueType< ItemType >;
};
export type ChildrenType = ( {
export type ChildrenType< ItemType > = ( {
items,
isOpen,
highlightedIndex,
}: ChildrenProps ) => ReactElement | Component;
}: ChildrenProps< ItemType > ) => ReactElement | Component;
export type getItemLabelType< ItemType > = ( item: ItemType | null ) => string;
export type getItemValueType< ItemType > = (
item: ItemType | null
) => string | number;

View File

@ -1,20 +1,43 @@
/**
* Internal dependencies
*/
import { ItemType, SelectedType } from './types';
import { getItemLabelType, DefaultItemType } from './types';
export const itemToString = ( item: ItemType | null ) => {
return item ? item.label : '';
function isDefaultItemType< ItemType >(
item: ItemType | DefaultItemType | null
): item is DefaultItemType {
return (
Boolean( item ) &&
( item as DefaultItemType ).label !== undefined &&
( item as DefaultItemType ).value !== undefined
);
}
export const defaultGetItemLabel = < ItemType >( item: ItemType | null ) => {
if ( isDefaultItemType< ItemType >( item ) ) {
return item.label;
}
return '';
};
export const getFilteredItems = (
export const defaultGetItemValue = < ItemType >( item: ItemType | null ) => {
if ( isDefaultItemType< ItemType >( item ) ) {
return item.value;
}
return '';
};
export const defaultGetFilteredItems = < ItemType >(
allItems: ItemType[],
inputValue: string,
selectedItems: ItemType[]
selectedItems: ItemType[],
getItemLabel: getItemLabelType< ItemType >
) => {
return allItems.filter(
( item ) =>
selectedItems.indexOf( item ) < 0 &&
item.label.toLowerCase().startsWith( inputValue.toLowerCase() )
getItemLabel( item )
.toLowerCase()
.startsWith( inputValue.toLowerCase() )
);
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Allowing generic item type in new experimental SelectControl.