diff --git a/packages/js/components/src/experimental-select-control/README.md b/packages/js/components/src/experimental-select-control/README.md index 794b30ce8a5..2b33d9457e8 100644 --- a/packages/js/components/src/experimental-select-control/README.md +++ b/packages/js/components/src/experimental-select-control/README.md @@ -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' }, +]; + + + 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 ( @@ -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 diff --git a/packages/js/components/src/experimental-select-control/menu-item.tsx b/packages/js/components/src/experimental-select-control/menu-item.tsx index 719853b491b..1bcb44854da 100644 --- a/packages/js/components/src/experimental-select-control/menu-item.tsx +++ b/packages/js/components/src/experimental-select-control/menu-item.tsx @@ -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 (
  • = { + 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 ( - { items.map( ( item, index: number ) => ( + { renderItems.map( ( item, index: number ) => ( - { item.label } + { getItemLabel( item ) } ) ) } @@ -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 && ( @@ -157,7 +172,11 @@ export const SelectControl = ( { getItemProps, getMenuProps, isOpen, + getItemLabel, + getItemValue, } ) } ); -}; +} + +export { SelectControl }; diff --git a/packages/js/components/src/experimental-select-control/selected-items.tsx b/packages/js/components/src/experimental-select-control/selected-items.tsx index 0948b8f7def..35f8e64047e 100644 --- a/packages/js/components/src/experimental-select-control/selected-items.tsx +++ b/packages/js/components/src/experimental-select-control/selected-items.tsx @@ -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 (
    { 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. */ } () => onRemove( item ) } - label={ itemToString( item ) } + label={ getItemLabel( item ) } /> ) ) } diff --git a/packages/js/components/src/experimental-select-control/stories/index.tsx b/packages/js/components/src/experimental-select-control/stories/index.tsx index 8729528afd9..f4dcc69ed78 100644 --- a/packages/js/components/src/experimental-select-control/stories/index.tsx +++ b/packages/js/components/src/experimental-select-control/stories/index.tsx @@ -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 ) } + + 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, diff --git a/packages/js/components/src/experimental-select-control/types.ts b/packages/js/components/src/experimental-select-control/types.ts index 1553e7e77bd..09999b490bc 100644 --- a/packages/js/components/src/experimental-select-control/types.ts +++ b/packages/js/components/src/experimental-select-control/types.ts @@ -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; diff --git a/packages/js/components/src/experimental-select-control/utils.ts b/packages/js/components/src/experimental-select-control/utils.ts index 6fe6320ea55..8e4be2f8800 100644 --- a/packages/js/components/src/experimental-select-control/utils.ts +++ b/packages/js/components/src/experimental-select-control/utils.ts @@ -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() ) ); }; diff --git a/plugins/woocommerce/changelog/update-34399-select-control b/plugins/woocommerce/changelog/update-34399-select-control new file mode 100644 index 00000000000..e8abde5a49a --- /dev/null +++ b/plugins/woocommerce/changelog/update-34399-select-control @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Allowing generic item type in new experimental SelectControl.