Skip to content

Instantly share code, notes, and snippets.

@johnnyferreiradev
Last active January 3, 2024 12:30
Show Gist options
  • Save johnnyferreiradev/0b4fc111f6f10ce37dbc1d118f66cda2 to your computer and use it in GitHub Desktop.
Save johnnyferreiradev/0b4fc111f6f10ce37dbc1d118f66cda2 to your computer and use it in GitHub Desktop.
Fetch Combobox Component using radix-ui, react-query, tailwindcss and axios.
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