137 lines
4.1 KiB
TypeScript

'use client';
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions
} from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/16/solid';
import Spinner from 'components/spinner';
import { useDebounce } from 'hooks/use-debounce';
import get from 'lodash.get';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useInView } from 'react-intersection-observer';
type FilterFieldProps<T extends { [key: string]: unknown }> = {
options: T[];
selectedValue: T | null;
// eslint-disable-next-line no-unused-vars
onChange: (value: T | null) => void;
label: string;
displayKey?: string;
// eslint-disable-next-line no-unused-vars
getId: (option: T) => string;
disabled?: boolean;
autoFocus?: boolean;
isLoading?: boolean;
// eslint-disable-next-line no-unused-vars
loadMore?: (reset?: boolean) => void;
hasNextPage?: boolean;
};
const FilterField = <T extends { [key: string]: unknown }>({
options,
selectedValue,
onChange,
label,
displayKey = 'name',
getId,
disabled,
isLoading,
autoFocus = false,
loadMore,
hasNextPage
}: FilterFieldProps<T>) => {
const [query, setQuery] = useState('');
const getDisplayValue = useCallback(
(option: T | null) => {
if (!option) return '';
if (typeof option[displayKey] === 'string') {
return option[displayKey] as string;
}
return get(option, `${displayKey}.value`) as string;
},
[displayKey]
);
const [scrollTrigger, isInView] = useInView();
const filteredOptions =
query === ''
? options
: options.filter((option) => {
return getDisplayValue(option).toLocaleLowerCase().includes(query.toLowerCase());
});
const loadMoreFnRef = useRef<Function>();
useEffect(() => {
loadMoreFnRef.current = loadMore;
}, [loadMore]);
useEffect(() => {
if (isInView && hasNextPage) {
loadMoreFnRef.current?.();
}
}, [isInView, hasNextPage]);
const debouncedQuery = useDebounce(query);
useEffect(() => {
if (debouncedQuery && !filteredOptions.length) {
loadMoreFnRef.current?.(true);
}
}, [debouncedQuery, filteredOptions.length]);
return (
<div className="w-full">
<Combobox
value={selectedValue}
by={displayKey}
onChange={onChange}
onClose={() => setQuery('')}
immediate
disabled={disabled || isLoading}
>
<div className="relative">
<ComboboxInput
aria-label={label}
displayValue={getDisplayValue}
placeholder={`Select ${label}`}
onChange={(event) => setQuery(event.target.value)}
className="w-full rounded border border-gray-200 py-1.5 pl-3 pr-8 text-sm ring-2 ring-transparent focus:outline-none focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[autofocus]:border-0 data-[focus]:border-transparent data-[disabled]:opacity-50 data-[focus]:ring-2 data-[autofocus]:ring-secondary data-[focus]:ring-secondary data-[focus]:ring-offset-0"
autoFocus={autoFocus}
/>
<ComboboxButton className="group absolute inset-y-0 right-0 px-2.5 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50">
{isLoading ? (
<Spinner className="fill-black/60" />
) : (
<ChevronDownIcon className="fill-black/60 group-data-[hover]:fill-black size-5" />
)}
</ComboboxButton>
</div>
<ComboboxOptions
anchor="bottom"
className="z-10 w-[var(--input-width)] rounded-xl border border-gray-200 bg-white p-1 [--anchor-gap:6px] empty:hidden"
>
{filteredOptions.map((option) => (
<ComboboxOption
key={getId(option)}
value={option}
className="flex cursor-default select-none items-center gap-2 rounded-lg px-3 py-1.5 text-sm/6 data-[focus]:bg-secondary/10"
ref={scrollTrigger}
>
{getDisplayValue(option)}
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
</div>
);
};
export default FilterField;