Search Components...

Combobox - React Hook Form

A Combobox component that uses react-hook-form Controller.

Installation

This component is dependent on floating-button component. Please add that component first before adding this one.

1. Copy and paste the following code into your project.

"use client";
import * as React from "react";
import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { DropdownMenuLabel } from "@/components/ui/dropdown-menu";
import { FloatingLabelButon } from "@/components/ui/buttons/floating-label-button";
 
// ----------------------------------------------------------------------
 
type TOption = {
	label: string;
	labelNode?: React.ReactNode;
	value: string;
};
 
type TOptions = TOption[] | Record<string, TOption[]>;
 
interface RHFComboboxProps<TFieldValues extends FieldValues>
	extends Omit<React.ComponentPropsWithoutRef<typeof Controller>, "name" | "control" | "render"> {
	name: FieldPath<TFieldValues>;
	btnProps?: Omit<React.ComponentPropsWithoutRef<typeof FloatingLabelButon>, "value" | "label">;
	popoverProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>;
	helperText?: string;
	label?: string;
	placeholder?: string;
	options: TOptions;
	enableCheck?: boolean;
	noResultText?: string;
}
 
export default function CustomRHFCombobox<TFieldValues extends FieldValues>({
	name,
	helperText,
	defaultValue,
	rules,
	label = "Select...",
	placeholder = "Search...",
	options,
	btnProps = {
		variant: "outline",
		size: "md",
		className: "w-full justify-between",
	},
	popoverProps,
	enableCheck = true,
	noResultText = "No item found.",
}: RHFComboboxProps<TFieldValues>) {
	const btnRef = React.useRef<HTMLButtonElement>(null);
	const [open, setOpen] = React.useState(false);
	const { control } = useFormContext();
 
	function getSelected(value: string) {
		if (Array.isArray(options)) {
			return options.find((opt) => opt.value === value)?.label ?? "";
		} else {
			let selectedValue;
			Object.values(options).forEach((opt) => {
				const selected = opt.find((optVal) => optVal.value === value)?.label ?? "";
				if (!!selected) {
					selectedValue = selected;
					return;
				}
			});
			return selectedValue;
		}
	}
 
	const handleSelect = React.useCallback(
		(onChange: (value: string) => void, value: string, isSelected: boolean) => () => {
			onChange(!isSelected ? value : "");
			setOpen(false);
		},
		[],
	);
 
	const popOverStyle = !!btnRef.current ? { width: btnRef.current.clientWidth } : {};
	return (
		<Controller
			name={name}
			control={control}
			defaultValue={defaultValue}
			rules={rules}
			render={({ field, fieldState: { error } }) => {
				const selectedLabel = getSelected(field.value);
				return (
					<div className="flex flex-col">
						<Popover open={open} onOpenChange={setOpen}>
							<PopoverTrigger asChild>
								<FloatingLabelButon
									role={name + "-combobox"}
									aria-expanded={open}
									{...btnProps}
									ref={btnRef}
									label={label}
									value={selectedLabel}
									endIcon={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
									error={!!error}
								/>
							</PopoverTrigger>
							<PopoverContent {...popoverProps} style={popOverStyle} className={cn("w-full p-0", popoverProps?.className)}>
								<Command>
									<CommandInput placeholder={placeholder} />
									<CommandEmpty>{noResultText}</CommandEmpty>
									<CommandList>
										{Array.isArray(options)
											? options.map((option) => {
													const selected = field.value === option.value;
													return (
														<CommandItem
															key={option.value}
															value={option.label}
															onSelect={handleSelect(field.onChange, option.value, selected)}
															className={cn("text-foreground", selected && "bg-accent/60 text-accent-foreground")}>
															{enableCheck && (
																<Check className={cn("mr-2 h-4 w-4", selected ? "opacity-100" : "opacity-0")} />
															)}
															{option?.labelNode ?? option.label}
														</CommandItem>
													);
											  })
											: Object.entries(options).map(([heading, opts], idx) => (
													<React.Fragment key={idx}>
														<CommandGroup>
															<DropdownMenuLabel className="text-common capitalize">{heading}</DropdownMenuLabel>
															{opts.map((option) => {
																const selected = field.value === option.value;
																return (
																	<CommandItem
																		key={option.value}
																		value={option.label + " " + heading}
																		onSelect={handleSelect(field.onChange, option.value, selected)}
																		className={cn(
																			"text-foreground",
																			selected && "bg-accent/60 text-accent-foreground",
																		)}>
																		{enableCheck && (
																			<Check
																				className={cn(
																					"mr-2 h-4 w-4",
																					selected ? "opacity-100" : "opacity-0",
																				)}
																			/>
																		)}
																		{option?.labelNode ?? option.label}
																	</CommandItem>
																);
															})}
														</CommandGroup>
													</React.Fragment>
											  ))}
									</CommandList>
								</Command>
							</PopoverContent>
						</Popover>
						{!!helperText && <p className="text-sm leading-none text-muted-foreground ml-1.5 mt-2">{helperText}</p>}
						{!!error && <p className="text-sm leading-none text-error ml-1.5 mt-2">{error?.message}</p>}
					</div>
				);
			}}
		/>
	);
}

2. Add this to your @/components/ui/hook-form/index.tsx file for easy import.

export { default as RHFCombobox } from "./rhf-combobox";

Shadcn

This combobox component is built on top of the excellent foundation provided by Shadcn/UI.

For a deeper dive into the core concepts and building blocks, check out the original Shadcn combobox component.