Created
March 6, 2024 09:43
-
-
Save lightyaer/bf353f0730336e51c5be449f7927ddbb to your computer and use it in GitHub Desktop.
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
"use client"; | |
import * as React from "react"; | |
import { cn } from "~/lib/utils"; | |
import { Check, ChevronsUpDown, Loader2, Search, X } from "lucide-react"; | |
import { Badge } from "./badge"; | |
import { Button } from "./button"; | |
import { Popover, PopoverContent, PopoverTrigger } from "./popover"; | |
import { Separator } from "~/components/ui/separator"; | |
import { ScrollArea } from "~/components/ui/scroll-area"; | |
import { Text } from "~/components/typography/text"; | |
import { PopoverAnchor, PopoverPortal } from "@radix-ui/react-popover"; | |
export type OptionType = Record<"value" | "label", string | null>; | |
interface AsyncMultiSelectProps { | |
options?: Record<"value" | "label", string | null>[]; | |
value?: Record<"value" | "label", string>[]; | |
onChange?: (selected: OptionType[]) => void; | |
onSearch?: (term: string) => void; | |
search?: string; | |
className?: string; | |
loading?: boolean; | |
placeholder?: string; | |
} | |
const AsyncMultiSelect = React.forwardRef< | |
HTMLDivElement, | |
AsyncMultiSelectProps | |
>( | |
( | |
{ | |
options, | |
search, | |
onSearch, | |
value: selected = [], | |
onChange, | |
className, | |
loading, | |
...props | |
}, | |
ref, | |
) => { | |
const [open, setOpen] = React.useState(false); | |
const handleUnselect = (item: Record<"value" | "label", string>) => { | |
onChange && onChange(selected.filter((i) => i.value !== item.value)); | |
}; | |
// // on delete key press, remove last selected item | |
// React.useEffect(() => { | |
// const handleKeyDown = (e: KeyboardEvent) => { | |
// if (e.key === "Backspace" && selected.length > 0 && onChange) { | |
// onChange( | |
// selected.filter((_, index) => index !== selected.length - 1), | |
// ); | |
// } | |
// | |
// // close on escape | |
// if (e.key === "Escape") { | |
// setOpen(false); | |
// } | |
// }; | |
// | |
// document.addEventListener("keydown", handleKeyDown); | |
// | |
// return () => { | |
// document.removeEventListener("keydown", handleKeyDown); | |
// }; | |
// }, [onChange, selected]); | |
const handleSearchChange: React.ChangeEventHandler<HTMLInputElement> = ( | |
event, | |
) => { | |
onSearch && onSearch(event.target.value); | |
}; | |
const onSelect = (option: OptionType) => { | |
onChange && | |
onChange( | |
selected.some((item) => item.value === option.value) | |
? selected.filter((item) => item.value !== option.value) | |
: [...selected, option], | |
); | |
setOpen(true); | |
}; | |
return ( | |
<Popover open={open} onOpenChange={setOpen} modal> | |
<PopoverTrigger asChild className={className}> | |
<div | |
ref={ref} | |
aria-expanded={open} | |
className={`group flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${ | |
selected.length > 1 ? "h-full" : "h-10" | |
}`} | |
onClick={() => setOpen(!open)} | |
> | |
{selected.length ? ( | |
selected.map((item) => ( | |
<Badge | |
variant="outline" | |
key={item.value} | |
className="flex items-center gap-1 group-hover:bg-background" | |
onClick={() => handleUnselect(item)} | |
> | |
{item.label} | |
<Button | |
asChild | |
variant="outline" | |
size="icon" | |
className="border-none" | |
onKeyDown={(e) => { | |
if (e.key === "Enter") { | |
handleUnselect(item); | |
} | |
}} | |
onMouseDown={(e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
}} | |
onClick={(e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
handleUnselect(item); | |
}} | |
> | |
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" /> | |
</Button> | |
</Badge> | |
)) | |
) : ( | |
<Text>{props.placeholder ?? "Select ..."}</Text> | |
)} | |
<div className="flex items-center gap-2"> | |
{loading && <Loader2 className="animate-spin" />} | |
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> | |
</div> | |
</div> | |
</PopoverTrigger> | |
<PopoverAnchor /> | |
<PopoverPortal> | |
<PopoverContent className="max-h-trigger-h w-trigger-w" align="end"> | |
<div> | |
<div className="flex items-center gap-2 p-2"> | |
<Search size={15} /> | |
<input | |
size={15} | |
className="border w-full border-none bg-background p-0 text-sm outline-none placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" | |
value={search} | |
placeholder="search..." | |
onChange={handleSearchChange} | |
/> | |
</div> | |
<Separator /> | |
<ScrollArea className="p-2" type="always" style={{ height: 150 }}> | |
<ul> | |
{Array.isArray(options) && | |
options.map((option) => ( | |
<li | |
onClick={() => onSelect(option)} | |
className="flex cursor-pointer p-2 text-sm hover:bg-secondary/80" | |
key={option.value} | |
> | |
<Check | |
className={cn( | |
"mr-2 h-4 w-4", | |
selected.some((item) => item.value === option.value) | |
? "opacity-100" | |
: "opacity-0", | |
)} | |
/> | |
{option.label} | |
</li> | |
))} | |
</ul> | |
</ScrollArea> | |
</div> | |
</PopoverContent> | |
</PopoverPortal> | |
</Popover> | |
); | |
}, | |
); | |
AsyncMultiSelect.displayName = "AsyncMultiSelect"; | |
export { AsyncMultiSelect }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment