Skip to content

Commit

Permalink
feat: breakdown searchable props
Browse files Browse the repository at this point in the history
  • Loading branch information
danielsimao committed Sep 18, 2024
1 parent 66b59af commit 93d8c3d
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 132 deletions.
4 changes: 2 additions & 2 deletions packages/ui/src/components/Select/Select.style.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type StyledFieldProps = {
};

type StyledModalProps = {
$showAutoComplete?: boolean;
$isSearchable?: boolean;
};

const StyledTrigger = styled.button<StyledTriggerProps>`
Expand Down Expand Up @@ -87,7 +87,7 @@ const StyledModalBody = styled(ModalBody)`
`;

const StyledModal = styled(Modal)<StyledModalProps>`
height: ${({ $showAutoComplete }) => $showAutoComplete && '700px'};
height: ${({ $isSearchable }) => $isSearchable && '700px'};
`;

const StyledSelectableChip = styled(Chip)`
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type ListboxAttrs = { type?: 'listbox' };

type ModalAttrs = {
type?: 'modal';
modalProps?: { ref?: React.Ref<HTMLDivElement> } & Omit<SelectModalProps, 'state' | 'isOpen' | 'onClose' | 'id'>;
modalProps?: { ref?: ForwardedRef<HTMLInputElement> } & Omit<SelectModalProps, 'state' | 'isOpen' | 'onClose' | 'id'>;
};

type AriaAttrs<T = SelectObject> = Omit<
Expand Down
205 changes: 91 additions & 114 deletions packages/ui/src/components/Select/SelectModal.tsx
Original file line number Diff line number Diff line change
@@ -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<any, any>;

const SelectableChip = ({ onPress, ...props }: ChipProps & { onPress?: (e: PressEvent) => void }) => {
const ref = useRef(null);
Expand All @@ -19,122 +21,97 @@ const SelectableChip = ({ onPress, ...props }: ChipProps & { onPress?: (e: Press
return <StyledSelectableChip ref={ref} borderColor='grey-300' size='lg' {...mergeProps(props, buttonProps)} />;
};

type Props = {
type Props<T = SelectObject> = {
state: SelectState<unknown>;
title?: ReactNode;
listProps?: Omit<ListProps, 'children'>;
showAutoComplete?: boolean;
listProps?: Omit<ListProps, 'children' | 'searchable'>;
searchable?: SelectModalListProps<T>['searchable'];
featuredItems?: Array<Pick<ChipProps, 'startAdornment' | 'endAdornment' | 'children'> & { value: string }>;
};

type InheritAttrs = Omit<ModalProps, keyof Props | 'children'>;

type SelectModalProps = Props & InheritAttrs;

const SelectModal = forwardRef<HTMLDivElement, SelectModalProps>(
({ 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 (
<StyledModal ref={ref} $showAutoComplete={showAutoComplete} onClose={onClose} {...props}>
{title && (
<ModalHeader id={headerId} showDivider={false} size='lg' weight='medium'>
{title}
</ModalHeader>
)}
<ModalDivider />
{(hasFeaturedItems || showAutoComplete) && (
<>
<StyledModalBody gap='lg'>
{showAutoComplete && (
<Input
placeholder='Search'
startAdornment={<MagnifyingGlass color='grey-50' />}
value={search}
onValueChange={handleSearchChange}
/>
)}
{hasFeaturedItems && (
<Flex wrap gap='s'>
{featuredItems.map(({ value, ...item }, key) => (
<SelectableChip
key={key}
aria-label={`select ${value}`}
borderColor='grey-300'
size='lg'
onPress={() => handlePressChip(value)}
{...item}
/>
))}
</Flex>
)}
</StyledModalBody>
<StyledModalDivider />
</>
)}
{hasItems ? (
<StyledList
{...listProps}
aria-labelledby={headerId}
gap='s'
selectionMode='single'
onSelectionChange={handleSelectionChange}
>
{matchedItems.map((item) => (
<ListItem
key={item.key}
alignItems='center'
alignSelf='auto'
gap='xs'
justifyContent='space-between'
textValue={item.textValue}
>
{item.rendered}
</ListItem>
))}
</StyledList>
) : (
<P align='center'>No options</P>
)}
</StyledModal>
);
}
);
type SelectModalProps<T = SelectObject> = Props<T> & InheritAttrs;

const SelectModal = <T extends SelectObject = SelectObject>(
{ state, title, onClose, listProps, searchable, featuredItems, ...props }: SelectModalProps<T>,
ref: ForwardedRef<HTMLDivElement>
): 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<T>[];

return (
<StyledModal ref={ref} $isSearchable={isSearchable} onClose={onClose} {...props}>
{title && (
<ModalHeader id={headerId} showDivider={false} size='lg' weight='medium'>
{title}
</ModalHeader>
)}
<ModalDivider />
{(hasFeaturedItems || isSearchable) && (
<>
<StyledModalBody gap='lg'>
{isSearchable && (
<Input
placeholder='Search'
startAdornment={<MagnifyingGlass color='grey-50' />}
value={search}
onValueChange={handleSearchChange}
/>
)}
{hasFeaturedItems && !search && (
<Flex wrap gap='s'>
{featuredItems.map(({ value, ...item }, key) => (
<SelectableChip
key={key}
aria-label={`select ${value}`}
borderColor='grey-300'
size='lg'
onPress={() => handlePressChip(value)}
{...item}
/>
))}
</Flex>
)}
</StyledModalBody>
<StyledModalDivider />
</>
)}
<SelectModalList<T>
{...listProps}
aria-labelledby={headerId}
items={items}
searchTerm={search}
searchable={searchable}
onSelectionChange={handleSelectionChange}
/>
</StyledModal>
);
};

const _SelectModal = forwardRef(SelectModal) as <T extends SelectObject = SelectObject>(
props: SelectModalProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> }
) => ReturnType<typeof SelectModal>;

SelectModal.displayName = 'SelectModal';

export { SelectModal };
export { _SelectModal as SelectModal };
export type { SelectModalProps };
73 changes: 73 additions & 0 deletions packages/ui/src/components/Select/SelectModalList.tsx
Original file line number Diff line number Diff line change
@@ -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<any, any>;

type SearchableProps<T = SelectObject> = {
items: Node<T>[];
inputValue: string;
onValueChange: (value: string) => void;
};

type SearchableFilter<T = SelectObject> = (value: Node<T>) => boolean;

type Props<T = SelectObject> = {
items: Node<T>[];
searchable?: boolean | SearchableFilter<T> | SearchableProps<T>;
searchTerm?: string;
};

type InheritAttrs = Omit<ListProps, keyof Props | 'children' | 'items'>;

type SelectModalListProps<T = SelectObject> = Props<T> & InheritAttrs;

const SelectModalList = <T extends SelectObject = SelectObject>({
items: itemsProp,
searchable,
searchTerm,
...props
}: SelectModalListProps<T>): 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 (
<P align='center'>{isSearchResultList && searchTerm ? `No results found for ${searchTerm}` : 'No options'}</P>
);

return (
<StyledList {...props} gap='s' selectionMode='single'>
{items.map((item) => (
<ListItem
key={item.key}
alignItems='center'
alignSelf='auto'
gap='xs'
justifyContent='space-between'
textValue={item.textValue}
>
{item.rendered}
</ListItem>
))}
</StyledList>
);
};

export { SelectModalList };
export type { SelectModalListProps };
14 changes: 12 additions & 2 deletions packages/ui/src/components/TokenInput/TokenSelect.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,6 +21,10 @@ type TokenSelectProps = Omit<ModalSelectProps<TokenSelectItemProps>, 'children'
};

const TokenSelect = ({ modalProps, size, featuredItems, ...props }: TokenSelectProps): JSX.Element => {
const { contains } = useFilter({

Check warning on line 24 in packages/ui/src/components/TokenInput/TokenSelect.tsx

View workflow job for this annotation

GitHub Actions / ESLint

'contains' is assigned a value but never used
sensitivity: 'base'
});

return (
<Select<TokenSelectItemProps>
{...props}
Expand All @@ -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: <Avatar size='3xl' src={item.logoUrl} />,
children: item.currency.symbol,
value: item.currency.symbol
}))
})),
// TODO: need to get current search term to compare it
searchable: ({ value }: { value: TokenSelectItemProps }) => {

Check warning on line 44 in packages/ui/src/components/TokenInput/TokenSelect.tsx

View workflow job for this annotation

GitHub Actions / ESLint

'value' is defined but never used. Allowed unused args must match /^_.*?$/u
return;
}
},
modalProps
)}
Expand Down
Loading

0 comments on commit 93d8c3d

Please sign in to comment.