Files
kandev/apps/web/components/combobox.tsx
T
2026-04-08 18:46:31 +01:00

176 lines
4.8 KiB
TypeScript

"use client";
import { memo, useState } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Button } from "@kandev/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
export type ComboboxOption = {
value: string;
label: string;
description?: string;
renderLabel?: () => React.ReactNode;
};
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onValueChange: (value: string) => void;
dropdownLabel?: string;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
disabled?: boolean;
className?: string;
triggerClassName?: string;
showSearch?: boolean;
testId?: string;
popoverSide?: "top" | "right" | "bottom" | "left";
popoverAlign?: "start" | "center" | "end";
/** When true, the trigger always renders the plain label text instead of renderLabel. */
plainTrigger?: boolean;
}
function TriggerLabel({
selectedOption,
plainTrigger,
placeholder,
}: {
selectedOption: ComboboxOption | undefined;
plainTrigger: boolean;
placeholder: string;
}) {
if (!plainTrigger && selectedOption?.renderLabel) {
return selectedOption.renderLabel();
}
return <span className="truncate">{selectedOption?.label || placeholder}</span>;
}
function OptionsList({
options,
value,
onSelect,
}: {
options: ComboboxOption[];
value: string;
onSelect: (value: string) => void;
}) {
return (
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label, option.description ?? ""]}
onSelect={() => onSelect(option.value)}
className="relative pr-7"
>
<div className="flex min-w-0 flex-1 items-center">
{option.renderLabel ? option.renderLabel() : option.label}
</div>
<IconCheck
className={cn(
"absolute right-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
);
}
export const Combobox = memo(function Combobox({
options,
value,
onValueChange,
dropdownLabel,
placeholder = "Select option...",
searchPlaceholder = "Search...",
emptyMessage = "No option found.",
disabled = false,
className,
triggerClassName,
showSearch = true,
testId,
popoverSide,
popoverAlign = "start",
plainTrigger = false,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
// Track the highlighted item. Defaults to the selected value so the current
// selection is highlighted when the popover opens (not the first item).
const [highlighted, setHighlighted] = useState("");
const selectedOption = options.find((option) => option.value === value);
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setHighlighted(value);
}}
>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", !disabled && "cursor-pointer", triggerClassName)}
disabled={disabled}
data-testid={testId}
>
<div className="flex min-w-0 flex-1 items-center">
<TriggerLabel
selectedOption={selectedOption}
plainTrigger={plainTrigger}
placeholder={placeholder}
/>
</div>
<IconChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[300px] max-w-none p-0 max-h-[var(--radix-popover-content-available-height)]",
className,
)}
side={popoverSide}
align={popoverAlign}
portal={false}
>
<Command value={highlighted} onValueChange={setHighlighted}>
{dropdownLabel ? (
<div className="text-muted-foreground px-2 py-1.5 text-xs border-b">
{dropdownLabel}
</div>
) : null}
{showSearch && <CommandInput placeholder={searchPlaceholder} className="h-9" />}
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<OptionsList
options={options}
value={value}
onSelect={(v) => {
onValueChange(v === value ? "" : v);
setOpen(false);
}}
/>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});