Allowing generic item type in new experimental SelectControl (#34547)
Co-authored-by: Joel <joel.thiessen@a8c.com>
This commit is contained in:
parent
7cec9b40a9
commit
09bfa0b737
|
@ -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
|
||||
|
|
|
@ -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' } : {} }
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
) ) }
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() )
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Allowing generic item type in new experimental SelectControl.
|
Loading…
Reference in New Issue