non-fungible-apps/ui/src/components/core/combobox/combobox.tsx

292 lines
8.3 KiB
TypeScript

import React, {
forwardRef,
Fragment,
useEffect,
useRef,
useState,
} from 'react';
import {
Combobox as ComboboxLib,
ComboboxInputProps as ComboboxLibInputProps,
Transition,
} from '@headlessui/react';
import { Icon, IconName } from '@/components/core/icon';
import { Flex } from '@/components/layout';
import { useDebounce } from '@/hooks/use-debounce';
import { Separator } from '../separator.styles';
import { cleanString } from './combobox.utils';
type ComboboxInputProps = {
/**
* If it's true, the list of options will be displayed
*/
open: boolean;
/**
* Name of the left icon to display in the input
*/
leftIcon: IconName;
/**
* Value to indicate it's invalid
*/
error?: boolean;
} & ComboboxLibInputProps<'input', ComboboxItem>;
const ComboboxInput = ({
open,
leftIcon,
error,
...props
}: ComboboxInputProps) => (
<div className="relative w-full">
<Icon
name={leftIcon}
size="sm"
css={{
position: 'absolute',
left: '$3',
top: '$3',
fontSize: '$xl',
color: 'slate8',
}}
/>
<ComboboxLib.Input
placeholder="Search"
className={`w-full border-solid border h-11 py-3 px-10 text-sm leading-5 text-slate11 outline-none ${
open
? 'border-b-0 rounded-t-xl bg-black border-slate6'
: `rounded-xl bg-transparent cursor-pointer ${
error ? 'border-red9' : 'border-slate7'
}`
}`}
displayValue={(selectedValue: ComboboxItem) => selectedValue.label}
{...props}
/>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-4">
<Icon name="chevron-down" css={{ fontSize: '$xs' }} />
</span>
</div>
);
type ComboboxOptionProps = {
option: ComboboxItem;
};
const ComboboxOption = ({ option }: ComboboxOptionProps) => (
<ComboboxLib.Option
value={option}
className={({ active }) =>
`relative cursor-default select-none py-2 px-3.5 text-slate11 rounded-xl mb-2 text-sm ${
active ? 'bg-slate5 text-slate12' : 'bg-transparent'
}`
}
>
{({ selected, active }) => (
<Flex css={{ justifyContent: 'space-between' }}>
<Flex css={{ flexDirection: 'row', maxWidth: '95%' }}>
{option.icon}
<span
className={`${active ? 'text-slate12' : 'text-slate11'} ${
option.icon ? 'max-w-70' : 'max-w-full'
} whitespace-nowrap text-ellipsis overflow-hidden`}
>
{option.label}
</span>
</Flex>
{selected && <Icon name="check" color="white" />}
</Flex>
)}
</ComboboxLib.Option>
);
export const NoResults = ({ css }: { css?: string }) => (
<div
className={`relative cursor-default select-none pt-2 px-3.5 pb-4 text-slate11 ${css}`}
>
Nothing found.
</div>
);
export type ComboboxItem = {
/**
* The key of the item.
*/
value: string;
/**
* The label to display of the item.
*/
label: string;
/**
* Optional icon to display on the left of the item.
*/
icon?: React.ReactNode;
};
export type ComboboxProps = {
/**
* List of items to be displayed in the combobox.
*/
items: ComboboxItem[];
/**
* The selected value of the combobox.
*/
selectedValue: ComboboxItem | undefined;
/**
* If true, the combobox will add the input if it doesn't exist in the list of items.
*/
withAutocomplete?: boolean;
/**
* Name of the left icon to display in the input. Defualt is "search".
*/
leftIcon?: IconName;
/**
* Callback when the selected value changes.
*/
onChange: (option: ComboboxItem) => void;
/**
* Function to handle the input blur
*/
onBlur?: () => void;
/**
* Value to indicate it's invalid
*/
error?: boolean;
};
export const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
(
{
items,
selectedValue = { value: '', label: '' },
withAutocomplete = false,
leftIcon = 'search',
onChange,
onBlur,
error = false,
},
ref
) => {
const [filteredItems, setFilteredItems] = useState<ComboboxItem[]>([]);
const [autocompleteItems, setAutocompleteItems] = useState<ComboboxItem[]>(
[]
);
useEffect(() => {
// If the selected value doesn't exist in the list of items, we add it
if (
items.filter((item) => item === selectedValue).length === 0 &&
selectedValue.value !== undefined &&
autocompleteItems.length === 0 &&
withAutocomplete
) {
setAutocompleteItems([selectedValue]);
}
}, [selectedValue]);
useEffect(() => {
setFilteredItems(items);
}, [items]);
const buttonRef = useRef<HTMLButtonElement>(null);
const handleSearch = useDebounce((searchValue: string) => {
if (searchValue === '') {
setFilteredItems(items);
if (withAutocomplete) {
setAutocompleteItems([]);
handleComboboxChange({} as ComboboxItem);
}
} else {
const filteredValues = items.filter((item) =>
cleanString(item.label).startsWith(cleanString(searchValue))
);
if (withAutocomplete && filteredValues.length === 0) {
// If the search value doesn't exist in the list of items, we add it
setAutocompleteItems([{ value: searchValue, label: searchValue }]);
}
setFilteredItems(filteredValues);
}
}, 200);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.stopPropagation();
handleSearch(event.target.value);
};
const handleInputClick = () => {
buttonRef.current?.click();
};
const handleComboboxChange = (optionSelected: ComboboxItem) => {
onChange(optionSelected);
};
const handleLeaveTransition = () => {
setFilteredItems(items);
if (selectedValue.value === undefined && withAutocomplete) {
setAutocompleteItems([]);
handleComboboxChange({} as ComboboxItem);
}
};
return (
<ComboboxLib
value={selectedValue}
by="value"
onChange={handleComboboxChange}
>
{({ open }) => (
<div className="relative">
<ComboboxInput
onChange={handleInputChange}
onClick={handleInputClick}
open={open}
leftIcon={leftIcon}
onBlur={onBlur}
error={error}
/>
<ComboboxLib.Button ref={buttonRef} className="hidden" />
<Transition
show={open}
as={Fragment}
enter="transition duration-400 ease-out"
leave="transition ease-out duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={handleLeaveTransition}
>
<ComboboxLib.Options className="absolute max-h-60 w-full z-10 overflow-auto rounded-b-xl border-solid border-slate6 border bg-black pt-2 px-3 text-base focus:outline-none sm:text-sm">
{[...autocompleteItems, ...filteredItems].length === 0 ||
filteredItems === undefined ? (
<NoResults />
) : (
<>
{autocompleteItems.length > 0 && <span>Create new</span>}
{autocompleteItems.map(
(autocompleteOption: ComboboxItem) => (
<ComboboxOption
key={autocompleteOption.value}
option={autocompleteOption}
/>
)
)}
{autocompleteItems.length > 0 &&
filteredItems.length > 0 && (
<Separator css={{ mb: '$2' }} />
)}
{filteredItems.map((option: ComboboxItem) => (
<ComboboxOption key={option.value} option={option} />
))}
</>
)}
</ComboboxLib.Options>
</Transition>
</div>
)}
</ComboboxLib>
);
}
);