Last active
January 3, 2024 12:30
-
-
Save johnnyferreiradev/0b4fc111f6f10ce37dbc1d118f66cda2 to your computer and use it in GitHub Desktop.
Fetch Combobox Component using radix-ui, react-query, tailwindcss and axios.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useMemo, useState } from 'react'; | |
import { useInfiniteQuery } from '@tanstack/react-query'; | |
import { Button, Loader, Popover, ButtonSizes, ButtonThemes } from 'nemea-ui'; | |
import { CaretDown, Check, MagnifyingGlass } from '@phosphor-icons/react'; | |
import { AxiosResponse } from 'axios'; | |
import DebouncedInput from '../DebouncedInput'; | |
import { InfiniteScroll } from '../InfiniteScroll'; | |
import { cn } from '@/utils/cn'; | |
export type FetchComboboxFetchItemsAction = (params: { | |
search?: string; | |
page?: number; | |
details?: 'minimal' | 'full'; | |
}) => Promise<AxiosResponse<any, any>>; | |
export interface FetchComboboxProps<T extends object> { | |
contentClassName?: string; | |
fetchItems: FetchComboboxFetchItemsAction; | |
fetchExtraParams?: T; | |
debounceTime?: number; | |
fetchKey: string; | |
defaultValue?: string; | |
onValueChange?: (value: string | null) => void; | |
triggerClassName?: string; | |
triggerSize?: keyof typeof ButtonSizes; | |
triggerTheme?: keyof typeof ButtonThemes; | |
placeholder?: string; | |
searchPlaceholder?: string; | |
notFoundMessage?: string; | |
errorMessage?: string; | |
} | |
export default function FetchCombobox({ | |
contentClassName = '', | |
fetchItems, | |
debounceTime = 1000, | |
fetchKey, | |
defaultValue = '', | |
onValueChange, | |
triggerClassName = '', | |
triggerSize = 'md', | |
triggerTheme = 'grayDark', | |
placeholder = 'Selecione...', | |
searchPlaceholder = 'Busque por nome', | |
fetchExtraParams, | |
errorMessage = 'Ocorreu um erro ao buscar', | |
notFoundMessage = 'Não encontrado', | |
}: FetchComboboxProps<{}>) { | |
const [open, setOpen] = useState(false); | |
const [search, setSearch] = useState(''); | |
const [value, setValue] = useState(defaultValue); | |
const [label, setLabel] = useState(''); | |
const { data, isPending, isError, hasNextPage, fetchNextPage } = | |
useInfiniteQuery({ | |
queryKey: [`infinite-combobox-fetch-${fetchKey}`, search], | |
queryFn: ({ pageParam = 1 }) => | |
fetchItems({ | |
page: pageParam, | |
search, | |
details: 'minimal', | |
...fetchExtraParams, | |
}), | |
getNextPageParam: (lastPage) => | |
lastPage.data.next | |
? lastPage.data.next.split('page=')[1].split('&')[0] | |
: null, | |
initialPageParam: 1, | |
}); | |
const handleChangeSearch = (currentValue: string) => { | |
setSearch(currentValue); | |
}; | |
const items = useMemo(() => { | |
const currentItems: { pk: string; name: string }[] = []; | |
data?.pages.forEach((group) => { | |
group.data.results.forEach((item: { pk: string; name: string }) => { | |
currentItems.push(item); | |
}); | |
}); | |
return currentItems; | |
}, [data?.pages]); | |
return ( | |
<Popover.Root | |
open={open} | |
onOpenChange={(openValue) => { | |
setOpen(openValue); | |
if (!openValue) { | |
setSearch(''); | |
} | |
}} | |
> | |
<Popover.Trigger asChild> | |
<Button.Root | |
role="combobox" | |
aria-expanded={open} | |
theme={triggerTheme} | |
className={cn( | |
'hover:bg-grayscale-100 hover:border-grayscale-100 !justify-between', | |
'active:bg-grayscale-200 active:text-dark dark:active:bg-grayscale-800 dark:active:text-light', | |
triggerClassName, | |
)} | |
size={triggerSize} | |
> | |
<Button.Label className="mx-0">{label || placeholder}</Button.Label> | |
<Button.Icon> | |
<CaretDown weight="thin" /> | |
</Button.Icon> | |
</Button.Root> | |
</Popover.Trigger> | |
<Popover.Content className={contentClassName}> | |
<DebouncedInput | |
placeholder={searchPlaceholder} | |
value={search} | |
onChange={(currentSearch) => handleChangeSearch(currentSearch)} | |
theme="noBorder" | |
icon={ | |
<MagnifyingGlass className="text-grayscale-400" weight="bold" /> | |
} | |
className={cn( | |
'py-3 px-4 rounded-none !bg-transparent', | |
'!border-t-0 !border-x-0', | |
'!border-b-grayscale-100 dark:!border-b-grayscale-900', | |
)} | |
debounceTime={debounceTime} | |
/> | |
{items.length > 0 && ( | |
<InfiniteScroll.Root | |
fetchDisabled={!hasNextPage || isPending} | |
fetchMoreItems={fetchNextPage} | |
className={cn('p-3', { | |
'h-36': items.length <= 5, | |
'h-72': items.length > 5, | |
})} | |
> | |
{items.map((item) => ( | |
<Button.Root | |
key={item.pk} | |
theme="darkFlat" | |
size="sm" | |
className={cn( | |
'transition-none w-full outline-transparent', | |
'hover:bg-grayscale-100 dark:hover:bg-grayscale-900', | |
)} | |
onClick={() => { | |
setValue(item.pk === value ? '' : item.pk); | |
setLabel(item.pk === value ? placeholder : item.name); | |
onValueChange?.(item.pk === value ? null : item.pk); | |
setSearch(''); | |
setOpen(false); | |
}} | |
> | |
<Button.Label className="w-full text-base m-0 mx-0.5 text-start font-normal"> | |
{item.name} | |
</Button.Label> | |
<Button.Icon> | |
<Check | |
className={cn( | |
'mr-2 h-4 w-4', | |
value === item.pk ? 'opacity-100' : 'opacity-0', | |
)} | |
/> | |
</Button.Icon> | |
</Button.Root> | |
))} | |
</InfiniteScroll.Root> | |
)} | |
{!isPending && items.length === 0 && !isError && ( | |
<p className="text-center p-3 pb-4">{notFoundMessage}</p> | |
)} | |
{isError && <p className="text-center p-3 pb-4">{errorMessage}</p>} | |
{isPending && ( | |
<div className="w-full flex justify-center p-3 pb-4"> | |
<Loader /> | |
</div> | |
)} | |
</Popover.Content> | |
</Popover.Root> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment