Skip to content

Instantly share code, notes, and snippets.

@lightyaer
Created March 6, 2024 09:43
Show Gist options
  • Save lightyaer/bf353f0730336e51c5be449f7927ddbb to your computer and use it in GitHub Desktop.
Save lightyaer/bf353f0730336e51c5be449f7927ddbb to your computer and use it in GitHub Desktop.
"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