Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/combobox.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook2 stories available:
Code
"use client";
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "../command";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
export type ComboboxOption = {
disabled?: boolean;
keywords?: string[];
label: string;
value: string;
};
export type ComboboxProps = {
className?: string;
commandClassName?: string;
emptyText?: string;
onValueChange?: (value: string) => void;
options: ComboboxOption[];
placeholder?: string;
searchPlaceholder?: string;
triggerClassName?: string;
value?: string;
};
function useComboboxValue(
value: string | undefined,
onValueChange?: (value: string) => void,
) {
const [internalValue, setInternalValue] = React.useState(value ?? "");
React.useEffect(() => {
if (value !== undefined) {
setInternalValue(value);
}
}, [value]);
const resolvedValue = value ?? internalValue;
const setResolvedValue = (nextValue: string) => {
if (value === undefined) {
setInternalValue(nextValue);
}
onValueChange?.(nextValue);
};
return { resolvedValue, setResolvedValue };
}
function ComboboxOptionItem({
onSelect,
option,
selectedValue,
}: {
onSelect: (value: string) => void;
option: ComboboxOption;
selectedValue: string;
}) {
const keywords = option.keywords?.join(" ") ?? "";
return (
<CommandItem
disabled={option.disabled}
onSelect={() => {
onSelect(option.value);
}}
value={`${option.label} ${option.value} ${keywords}`}
>
<Check
className={cn(
"mr-2 size-4",
selectedValue === option.value ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{option.label}</span>
</CommandItem>
);
}
function ComboboxListPanel({
className,
commandClassName,
emptyText,
listboxId,
onSelect,
options,
resolvedValue,
searchPlaceholder,
}: {
className?: string;
commandClassName?: string;
emptyText: string;
listboxId: string;
onSelect: (value: string) => void;
options: ComboboxOption[];
resolvedValue: string;
searchPlaceholder: string;
}) {
return (
<PopoverContent
className={cn("w-[var(--radix-popover-trigger-width)] p-0", className)}
>
<Command className={commandClassName}>
<CommandInput placeholder={searchPlaceholder} />
<CommandList id={listboxId}>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<ComboboxOptionItem
key={option.value}
onSelect={onSelect}
option={option}
selectedValue={resolvedValue}
/>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
);
}
const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>(
(
{
className,
commandClassName,
emptyText = "No option found.",
onValueChange,
options,
placeholder = "Select an option",
searchPlaceholder = "Search options...",
triggerClassName,
value,
},
reference,
) => {
const [open, setOpen] = React.useState(false);
const listboxId = React.useId();
const { resolvedValue, setResolvedValue } = useComboboxValue(
value,
onValueChange,
);
const selectedOption = options.find(
(option) => option.value === resolvedValue,
);
const handleSelect = (nextValue: string) => {
setResolvedValue(nextValue === resolvedValue ? "" : nextValue);
setOpen(false);
};
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
aria-controls={listboxId}
aria-expanded={open}
aria-haspopup="listbox"
className={cn("w-full justify-between", triggerClassName)}
ref={reference}
role="combobox"
variant="outline"
>
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<ComboboxListPanel
className={className}
commandClassName={commandClassName}
emptyText={emptyText}
listboxId={listboxId}
onSelect={handleSelect}
options={options}
resolvedValue={resolvedValue}
searchPlaceholder={searchPlaceholder}
/>
</Popover>
);
},
);
Combobox.displayName = "Combobox";
export { Combobox };
typescript