Midday / packages /ui /src /components /combobox-dropdown.tsx
Jules
Final deployment with all fixes and verified content
c09f67c
"use client";
import { CommandList } from "cmdk";
import { Check, ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { cn } from "../utils";
import { Button } from "./button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "./command";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
export type ComboboxItem = {
id: string;
label: string;
disabled?: boolean;
};
type Props<T> = {
placeholder?: React.ReactNode;
searchPlaceholder?: string;
items: T[];
onSelect: (item: T) => void;
selectedItem?: T;
renderSelectedItem?: (selectedItem: T) => React.ReactNode;
renderOnCreate?: (value: string) => React.ReactNode;
renderListItem?: (listItem: {
isChecked: boolean;
item: T;
}) => React.ReactNode;
emptyResults?: React.ReactNode;
popoverProps?: React.ComponentProps<typeof PopoverContent>;
disabled?: boolean;
onCreate?: (value: string) => void;
headless?: boolean;
className?: string;
modal?: boolean;
};
export function ComboboxDropdown<T extends ComboboxItem>({
headless,
placeholder,
searchPlaceholder,
items,
onSelect,
selectedItem: incomingSelectedItem,
renderSelectedItem = (item) => item.label,
renderListItem,
renderOnCreate,
emptyResults,
popoverProps,
disabled,
onCreate,
className,
modal = true,
}: Props<T>) {
const [open, setOpen] = React.useState(false);
const [internalSelectedItem, setInternalSelectedItem] = React.useState<
T | undefined
>();
const [inputValue, setInputValue] = React.useState("");
const selectedItem = incomingSelectedItem ?? internalSelectedItem;
const filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(inputValue.toLowerCase()),
);
const showCreate = onCreate && Boolean(inputValue) && !filteredItems.length;
const Component = (
<Command loop shouldFilter={false}>
<CommandInput
value={inputValue}
onValueChange={setInputValue}
placeholder={searchPlaceholder ?? "Search item..."}
className="px-3"
/>
<CommandGroup>
<CommandList className="max-h-[225px] overflow-auto">
{filteredItems.map((item) => {
const isChecked = selectedItem?.id === item.id;
return (
<CommandItem
disabled={item.disabled}
className={cn("cursor-pointer", className)}
key={item.id}
value={item.id}
onSelect={(id) => {
const foundItem = items.find((item) => item.id === id);
if (!foundItem) {
return;
}
onSelect(foundItem);
setInternalSelectedItem(foundItem);
setOpen(false);
}}
>
{renderListItem ? (
renderListItem({ isChecked, item })
) : (
<>
<Check
className={cn(
"mr-2 h-4 w-4",
isChecked ? "opacity-100" : "opacity-0",
)}
/>
{item.label}
</>
)}
</CommandItem>
);
})}
<CommandEmpty>{emptyResults ?? "No item found"}</CommandEmpty>
{showCreate && (
<CommandItem
key={inputValue}
value={inputValue}
onSelect={() => {
onCreate(inputValue);
setOpen(false);
setInputValue("");
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
{renderOnCreate ? renderOnCreate(inputValue) : null}
</CommandItem>
)}
</CommandList>
</CommandGroup>
</Command>
);
if (headless) {
return Component;
}
return (
<Popover open={open} onOpenChange={setOpen} modal={modal}>
<PopoverTrigger asChild disabled={disabled} className="w-full">
<Button
variant="outline"
aria-expanded={open}
className="w-full justify-between relative font-normal"
>
<span className="truncate text-ellipsis pr-3">
{selectedItem ? (
<span className="items-center overflow-hidden whitespace-nowrap text-ellipsis block">
{renderSelectedItem
? renderSelectedItem(selectedItem)
: selectedItem.label}
</span>
) : (
(placeholder ?? "Select item...")
)}
</span>
<ChevronsUpDown className="size-4 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>
<PopoverContent
{...popoverProps}
className={cn("p-0", popoverProps?.className)}
style={{
width: "var(--radix-popover-trigger-width)",
...popoverProps?.style,
}}
>
{Component}
</PopoverContent>
</Popover>
);
}