diff --git a/packages/ui/src/components/Select/Select.style.tsx b/packages/ui/src/components/Select/Select.style.tsx index 413190844..8704e5047 100644 --- a/packages/ui/src/components/Select/Select.style.tsx +++ b/packages/ui/src/components/Select/Select.style.tsx @@ -30,7 +30,7 @@ type StyledFieldProps = { }; type StyledModalProps = { - $showAutoComplete?: boolean; + $isSearchable?: boolean; }; const StyledTrigger = styled.button` @@ -87,7 +87,7 @@ const StyledModalBody = styled(ModalBody)` `; const StyledModal = styled(Modal)` - height: ${({ $showAutoComplete }) => $showAutoComplete && '700px'}; + height: ${({ $isSearchable }) => $isSearchable && '700px'}; `; const StyledSelectableChip = styled(Chip)` diff --git a/packages/ui/src/components/Select/Select.tsx b/packages/ui/src/components/Select/Select.tsx index 03513c7c5..fb1a90e3b 100644 --- a/packages/ui/src/components/Select/Select.tsx +++ b/packages/ui/src/components/Select/Select.tsx @@ -30,7 +30,7 @@ type ListboxAttrs = { type?: 'listbox' }; type ModalAttrs = { type?: 'modal'; - modalProps?: { ref?: React.Ref } & Omit; + modalProps?: { ref?: ForwardedRef } & Omit; }; type AriaAttrs = Omit< diff --git a/packages/ui/src/components/Select/SelectModal.tsx b/packages/ui/src/components/Select/SelectModal.tsx index 54650e723..2f7c2f319 100644 --- a/packages/ui/src/components/Select/SelectModal.tsx +++ b/packages/ui/src/components/Select/SelectModal.tsx @@ -1,16 +1,18 @@ import { mergeProps, useId } from '@react-aria/utils'; import { SelectState } from '@react-stately/select'; -import { forwardRef, ReactNode, useRef, useState } from 'react'; -import { useFilter } from '@react-aria/i18n'; +import { ForwardedRef, forwardRef, ReactNode, useRef, useState } from 'react'; import { useButton } from '@react-aria/button'; -import { PressEvent } from '@react-types/shared'; +import { Node, PressEvent } from '@react-types/shared'; import { MagnifyingGlass } from '../../icons'; -import { ChipProps, Flex, Input, ModalHeader, ModalProps, P } from '..'; -import { ListItem, ListProps } from '../List'; +import { ChipProps, Flex, Input, ModalHeader, ModalProps } from '..'; +import { ListProps } from '../List'; import { ModalDivider } from '../Modal'; -import { StyledList, StyledModal, StyledModalBody, StyledModalDivider, StyledSelectableChip } from './Select.style'; +import { StyledModal, StyledModalBody, StyledModalDivider, StyledSelectableChip } from './Select.style'; +import { SelectModalList, SelectModalListProps } from './SelectModalList'; + +type SelectObject = Record; const SelectableChip = ({ onPress, ...props }: ChipProps & { onPress?: (e: PressEvent) => void }) => { const ref = useRef(null); @@ -19,122 +21,97 @@ const SelectableChip = ({ onPress, ...props }: ChipProps & { onPress?: (e: Press return ; }; -type Props = { +type Props = { state: SelectState; title?: ReactNode; - listProps?: Omit; - showAutoComplete?: boolean; + listProps?: Omit; + searchable?: SelectModalListProps['searchable']; featuredItems?: Array & { value: string }>; }; type InheritAttrs = Omit; -type SelectModalProps = Props & InheritAttrs; - -const SelectModal = forwardRef( - ({ state, title, onClose, listProps, showAutoComplete, featuredItems, ...props }, ref): JSX.Element => { - const headerId = useId(); - - const [search, setSearch] = useState(''); - - const { contains } = useFilter({ - sensitivity: 'base' - }); - - const handleSelectionChange: ListProps['onSelectionChange'] = (key) => { - const [selectedKey] = [...key]; - - if (!selectedKey) { - return onClose(); - } - - state.selectionManager.setSelectedKeys(key); - onClose(); - }; - - const handleSearchChange = (value: string) => { - setSearch(value); - }; - - const handlePressChip = (value: string) => { - state.selectionManager.setSelectedKeys(new Set([value])); - }; - - const items = [...state.collection]; - - const matchedItems = items.filter((item) => contains(item.textValue, search)); - - const hasItems = !!items.length; - - const hasFeaturedItems = !!featuredItems?.length; - - return ( - - {title && ( - - {title} - - )} - - {(hasFeaturedItems || showAutoComplete) && ( - <> - - {showAutoComplete && ( - } - value={search} - onValueChange={handleSearchChange} - /> - )} - {hasFeaturedItems && ( - - {featuredItems.map(({ value, ...item }, key) => ( - handlePressChip(value)} - {...item} - /> - ))} - - )} - - - - )} - {hasItems ? ( - - {matchedItems.map((item) => ( - - {item.rendered} - - ))} - - ) : ( -

No options

- )} -
- ); - } -); +type SelectModalProps = Props & InheritAttrs; + +const SelectModal = ( + { state, title, onClose, listProps, searchable, featuredItems, ...props }: SelectModalProps, + ref: ForwardedRef +): JSX.Element => { + const headerId = useId(); + + const [search, setSearch] = useState(''); + + const handleSelectionChange: ListProps['onSelectionChange'] = (key) => { + state.selectionManager.setSelectedKeys(key); + }; + + const handleSearchChange = (value: string) => { + setSearch(value); + }; + + const handlePressChip = (value: string) => { + state.selectionManager.setSelectedKeys(new Set([value])); + }; + + const isSearchable = !!searchable; + const hasFeaturedItems = !!featuredItems?.length; + + const items = [...state.collection] as Node[]; + + return ( + + {title && ( + + {title} + + )} + + {(hasFeaturedItems || isSearchable) && ( + <> + + {isSearchable && ( + } + value={search} + onValueChange={handleSearchChange} + /> + )} + {hasFeaturedItems && !search && ( + + {featuredItems.map(({ value, ...item }, key) => ( + handlePressChip(value)} + {...item} + /> + ))} + + )} + + + + )} + + {...listProps} + aria-labelledby={headerId} + items={items} + searchTerm={search} + searchable={searchable} + onSelectionChange={handleSelectionChange} + /> + + ); +}; + +const _SelectModal = forwardRef(SelectModal) as ( + props: SelectModalProps & { ref?: React.ForwardedRef } +) => ReturnType; SelectModal.displayName = 'SelectModal'; -export { SelectModal }; +export { _SelectModal as SelectModal }; export type { SelectModalProps }; diff --git a/packages/ui/src/components/Select/SelectModalList.tsx b/packages/ui/src/components/Select/SelectModalList.tsx new file mode 100644 index 000000000..3557b9336 --- /dev/null +++ b/packages/ui/src/components/Select/SelectModalList.tsx @@ -0,0 +1,73 @@ +import { useFilter } from '@react-aria/i18n'; +import { Node } from '@react-types/shared'; + +import { ListItem, ListProps } from '../List'; +import { P } from '../Text'; + +import { StyledList } from './Select.style'; + +type SelectObject = Record; + +type SearchableProps = { + items: Node[]; + inputValue: string; + onValueChange: (value: string) => void; +}; + +type SearchableFilter = (value: Node) => boolean; + +type Props = { + items: Node[]; + searchable?: boolean | SearchableFilter | SearchableProps; + searchTerm?: string; +}; + +type InheritAttrs = Omit; + +type SelectModalListProps = Props & InheritAttrs; + +const SelectModalList = ({ + items: itemsProp, + searchable, + searchTerm, + ...props +}: SelectModalListProps): JSX.Element => { + const { contains } = useFilter({ + sensitivity: 'base' + }); + + const isSearchResultList = typeof searchable === 'object'; + + const items = isSearchResultList + ? searchable.items + : searchable && searchTerm + ? [...(itemsProp || [])]?.filter( + typeof searchable === 'function' ? searchable : (item) => contains(item.textValue, searchTerm) + ) + : itemsProp; + + if (!items.length) + return ( +

{isSearchResultList && searchTerm ? `No results found for ${searchTerm}` : 'No options'}

+ ); + + return ( + + {items.map((item) => ( + + {item.rendered} + + ))} + + ); +}; + +export { SelectModalList }; +export type { SelectModalListProps }; diff --git a/packages/ui/src/components/TokenInput/TokenSelect.tsx b/packages/ui/src/components/TokenInput/TokenSelect.tsx index ebe8a1de2..6894c1c15 100644 --- a/packages/ui/src/components/TokenInput/TokenSelect.tsx +++ b/packages/ui/src/components/TokenInput/TokenSelect.tsx @@ -1,5 +1,6 @@ import { Currency } from '@gobob/currency'; import { mergeProps } from '@react-aria/utils'; +import { useFilter } from '@react-aria/i18n'; import { Item, ModalSelectProps, Select } from '../Select'; import { Avatar } from '../Avatar'; @@ -20,6 +21,10 @@ type TokenSelectProps = Omit, 'children' }; const TokenSelect = ({ modalProps, size, featuredItems, ...props }: TokenSelectProps): JSX.Element => { + const { contains } = useFilter({ + sensitivity: 'base' + }); + return ( {...props} @@ -28,12 +33,17 @@ const TokenSelect = ({ modalProps, size, featuredItems, ...props }: TokenSelectP modalProps={mergeProps( { title: 'Select Token', - listProps: { maxHeight: '32rem' }, + // TODO: handle height better + // listProps: { maxHeight: '32rem' }, featuredItems: featuredItems?.map((item) => ({ startAdornment: , children: item.currency.symbol, value: item.currency.symbol - })) + })), + // TODO: need to get current search term to compare it + searchable: ({ value }: { value: TokenSelectItemProps }) => { + return; + } }, modalProps )} diff --git a/packages/ui/src/components/TokenInput/stories/SelectableTokenInput.stories.tsx b/packages/ui/src/components/TokenInput/stories/SelectableTokenInput.stories.tsx index d40c3f332..e278021e7 100644 --- a/packages/ui/src/components/TokenInput/stories/SelectableTokenInput.stories.tsx +++ b/packages/ui/src/components/TokenInput/stories/SelectableTokenInput.stories.tsx @@ -8,50 +8,50 @@ import { TokenInput, TokenInputProps } from '..'; const items = [ { balance: 2, - currency: { symbol: 'ETH', decimals: 18 } as Currency, + currency: { symbol: 'ETH', name: 'Ethereum', decimals: 18 } as Currency, logoUrl: 'https://ethereum-optimism.github.io/data/ETH/logo.svg', balanceUSD: 900 }, { balance: 500, - currency: { symbol: 'USDT', decimals: 6 } as Currency, + currency: { symbol: 'USDT', name: 'USDT', decimals: 6 } as Currency, logoUrl: 'https://ethereum-optimism.github.io/data/USDT/logo.png', balanceUSD: 500 }, { balance: 100, - currency: { symbol: 'USDC', decimals: 6 } as Currency, + currency: { symbol: 'USDC', name: 'USDC', decimals: 6 } as Currency, logoUrl: 'https://ethereum-optimism.github.io/data/BridgedUSDC/logo.png', balanceUSD: 100 }, { balance: 100, - currency: { symbol: 'WBTC', decimals: 6 } as Currency, + currency: { symbol: 'WBTC', name: 'Wrapped Bitcoin', decimals: 6 } as Currency, logoUrl: 'https://ethereum-optimism.github.io/data/BridgedUSDC/logo.png', balanceUSD: 100 }, { balance: 100, - currency: { symbol: 'WETH', decimals: 6 } as Currency, + currency: { symbol: 'WETH', name: 'WETH', decimals: 6 } as Currency, logoUrl: 'https://ethereum-optimism.github.io/data/BridgedUSDC/logo.png', balanceUSD: 100 }, { balance: 100, - currency: { symbol: 'BTC', decimals: 6 } as Currency, + currency: { symbol: 'BTC', name: 'Bitcoin', decimals: 6 } as Currency, logoUrl: 'https://ethereum-optimism.github.io/data/BridgedUSDC/logo.png', balanceUSD: 100 }, { balance: 100, - currency: { symbol: 'QWER', decimals: 6 } as Currency, - logoUrl: 'https://ethereum-optimism.github.io/data/BridgedUSDC/logo.png', + currency: { symbol: 'stETH', name: 'Lido Staked Ether', decimals: 6 } as Currency, + logoUrl: 'https://coin-images.coingecko.com/coins/images/13442/large/steth_logo.png?1696513206', balanceUSD: 100 }, { balance: 100, - currency: { symbol: 'QWWR', decimals: 6 } as Currency, - logoUrl: 'https://ethereum-optimism.github.io/data/BridgedUSDC/logo.png', + currency: { symbol: 'rETH', name: 'Rocket Pool ETH', decimals: 6 } as Currency, + logoUrl: 'https://coin-images.coingecko.com/coins/images/20764/large/reth.png?1696520159', balanceUSD: 100 } ]; @@ -127,8 +127,6 @@ export const SelectableErrorMessage: StoryObj = { export const AutoComplete: StoryObj = { args: { featuredItems: items.slice(0, 6), - selectProps: { - modalProps: { showAutoComplete: true } - } + selectProps: {} } };