Combobox
A simplified wrapper for Shadcn Combobox with flexible option handling and automatic type conversion
Installation
npx shadcn@latest add @glrk-ui/comboboxIf you haven't set up the prerequisites yet, check out Prerequest section.
The Combobox is built using a composition of the <Popover />, <Badge />, <Button /> and the <Command /> components.
See installation instructions for the components from shadcn.
"use client"
import { useState } from "react"
import { Check, ChevronsUpDown, Loader2, Plus } from "lucide-react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn, getKey, getLabel, getValue, isAllowedPrimitive, isGroup, isOption, isSeparator } from "@/lib/utils"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandLoading,
CommandSeparator,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
const extractText = (node: any): string => {
if (node === null || node === undefined) return ""
if (isAllowedPrimitive(node)) return String(node)
if (Array.isArray(node)) return node.map(extractText).join(" ")
if (node.props?.children) return extractText(node.props.children)
return ""
}
const findOptionByValue = (options: optionsT, value: allowedPrimitiveT) => {
for (const item of options) {
if (isGroup(item)) {
const found = item.options.find((opt) => getValue(opt) === value)
if (found) return found
} else if (!isSeparator(item) && getValue(item) === value) {
return item
}
}
return ""
}
const filteredOptions = (options: optionsT, query: string): optionsT => {
const q = query.toLowerCase()
const result: optionsT = []
for (const item of options) {
if (isGroup(item)) {
const filtered = item.options.filter(opt =>
extractText(getLabel(opt)).toLowerCase().includes(q)
)
if (filtered.length) {
result.push({ ...item, options: filtered })
}
continue
}
if (isSeparator(item)) {
result.push(item)
continue
}
if (extractText(getLabel(item)).toLowerCase().includes(q)) {
result.push(item)
}
}
return result
}
type ItemProps = {
option: allowedPrimitiveT | optionT
selected: boolean
className?: string
indicatorAt?: indicatorAtT
onSelect: (value: allowedPrimitiveT) => void
}
function Item({ option, selected, indicatorAt = "right", onSelect, className }: ItemProps) {
const value = getValue(option)
const label = getLabel(option)
const optCls = isOption(option) ? option.className : undefined
if (isSeparator(value)) return <CommandSeparator className={cn("my-0.5", className, "mx-0")} />
return (
<CommandItem
value={`${value}`}
className={cn(indicatorAt === "right" ? "pr-8 pl-2" : "pr-2 pl-8", className, optCls)}
onSelect={() => onSelect(value)}
>
{label}
<Check
className={cn(
"absolute size-4",
selected ? "opacity-100" : "opacity-0",
indicatorAt === "right" ? "right-2" : "left-2",
)}
/>
</CommandItem>
)
}
type base = {
id?: string
options: optionsT
isLoading?: boolean
placeholder?: string
emptyMessage?: string
indicatorAt?: indicatorAtT
triggerCls?: string
contentCls?: string
groupCls?: string
itemCls?: string
matchTriggerWidth?: boolean
open?: boolean
onOpenChange?: (v: boolean) => void
query?: string
onQueryChange?: (v: string) => void
popoverContentProps?: Omit<React.ComponentProps<typeof PopoverPrimitive.Content>, "className">
}
type comboboxProps = base & {
value?: allowedPrimitiveT
canCreateNew?: boolean
onValueChange?: (value: allowedPrimitiveT) => void
}
function Combobox({
id,
options = [],
isLoading,
placeholder,
emptyMessage,
canCreateNew,
matchTriggerWidth = true,
indicatorAt,
triggerCls,
contentCls,
groupCls,
itemCls,
value: o_value,
onValueChange: o_onValueChange,
query: o_query,
onQueryChange: o_onQueryChange,
open: o_open,
onOpenChange: o_onOpenChange,
popoverContentProps,
}: comboboxProps) {
const [i_value, setIValue] = useState("")
const [i_query, setIQuery] = useState("")
const [i_open, setIOpen] = useState(false)
const value = o_value ?? i_value
const query = o_query ?? i_query
const open = o_open ?? i_open
const onValueChange = o_onValueChange ?? setIValue
const onQueryChange = o_onQueryChange ?? setIQuery
const onOpenChange = o_onOpenChange ?? setIOpen
const selectedOption = findOptionByValue(options, value)
const filtered = filteredOptions(options, query)
const label = getLabel(selectedOption)
const showCreate =
canCreateNew &&
query &&
!options.some((o) =>
isGroup(o)
? o.options.some((x) => extractText(getLabel(x)) === query)
: extractText(getLabel(o)) === query
)
function onSelect(v: allowedPrimitiveT) {
onValueChange(v as string)
onOpenChange(false)
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
role="combobox"
variant="outline"
className={cn("font-normal", triggerCls, {
"text-muted-foreground": !value && value !== false,
})}
>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin" />
Loading...
</>
) :
<span className="flex items-center gap-2 truncate">
{
(selectedOption || selectedOption === false)
? label
: placeholder
}
</span>
}
<ChevronsUpDown className="ml-auto size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
{...popoverContentProps}
className={cn("p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]", contentCls)}
>
<Command shouldFilter={false}>
{
!isLoading &&
<CommandInput
placeholder="Search..."
value={query}
onValueChange={onQueryChange}
/>
}
<CommandList className="py-1">
{
isLoading &&
<CommandLoading>
<span className="flex items-center justify-center gap-2">
<Loader2 className="animate-spin size-4" /> Loading...
</span>
</CommandLoading>
}
{
!isLoading &&
<CommandEmpty>{emptyMessage || "No options found"}</CommandEmpty>
}
{!isLoading && filtered.map((item, i) => {
if (isGroup(item)) {
return (
<CommandGroup
key={item.group}
heading={item.group}
className={cn("[&_[cmdk-group-heading]]:pb-0.5", groupCls, item.className)}
>
{item.options.map((opt, j) => (
<Item
key={getKey(opt, j)}
option={opt}
selected={getValue(opt) === value}
onSelect={onSelect}
className={itemCls}
indicatorAt={indicatorAt}
/>
))}
</CommandGroup>
)
}
return (
<Item
key={getKey(item, i)}
option={item}
selected={getValue(item) === value}
onSelect={onSelect}
indicatorAt={indicatorAt}
className={cn("mx-1", itemCls)}
/>
)
})}
{showCreate && (
<CommandGroup>
<CommandItem
value={`__create-${query}`}
onSelect={() => {
onValueChange(query)
onQueryChange("")
onOpenChange(false)
}}
>
<Plus className="mr-2 h-4 w-4" /> Create: {query}
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
type btnLableProps = {
value: allowedPrimitiveT[]
options: optionsT
isLoading?: boolean
placeholder?: string
maxVisibleCount?: number
}
function ButtonLabel({
value,
options,
isLoading,
placeholder,
maxVisibleCount = 2,
}: btnLableProps) {
const labelOf = (val: allowedPrimitiveT) => {
const found = findOptionByValue(options, val)
if (!found) return `${val}`
const label = getLabel(found)
return label
}
if (isLoading)
return (
<>
<Loader2 className="size-4 animate-spin" />
Loading...
</>
)
if (value.length === 0) {
return placeholder
}
if (value.length <= maxVisibleCount) {
return (
<>
{value.map((v) => (
<Badge key={String(v)} variant="secondary" className="rounded-sm px-1 font-normal">
{labelOf(v)}
</Badge>
))}
</>
)
}
return (
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
{value.length} selected
</Badge>
)
}
type multiSelectComboboxProps = base & {
value?: allowedPrimitiveT[]
maxVisibleCount?: number
label?: React.ReactNode
onValueChange?: (v: allowedPrimitiveT[]) => void
}
function MultiSelectCombobox({
id,
options = [],
isLoading,
placeholder,
emptyMessage,
matchTriggerWidth = true,
maxVisibleCount,
indicatorAt,
triggerCls,
contentCls,
groupCls,
itemCls,
label,
value: o_value,
onValueChange: o_onValueChange,
query: o_query,
onQueryChange: o_onQueryChange,
open: o_open,
onOpenChange: o_onOpenChange,
popoverContentProps,
}: multiSelectComboboxProps) {
const [i_value, setIValue] = useState<allowedPrimitiveT[]>([])
const [i_query, setIQuery] = useState("")
const [i_open, setIOpen] = useState(false)
const value = o_value ?? i_value
const query = o_query ?? i_query
const open = o_open ?? i_open
const onValueChange = o_onValueChange ?? setIValue
const onQueryChange = o_onQueryChange ?? setIQuery
const onOpenChange = o_onOpenChange ?? setIOpen
const filtered = filteredOptions(options, query)
const onSelect = (v: allowedPrimitiveT) => {
onValueChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v])
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
role="combobox"
variant="outline"
aria-expanded={open}
className={cn("font-normal", triggerCls, {
"text-muted-foreground": value.length === 0,
})}
>
{label}
<ButtonLabel
value={value}
options={options}
isLoading={isLoading}
placeholder={placeholder}
maxVisibleCount={maxVisibleCount}
/>
<ChevronsUpDown className="ml-auto size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
{...popoverContentProps}
className={cn("p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]", contentCls)}
>
<Command shouldFilter={false}>
{
!isLoading &&
<CommandInput
placeholder="Search..."
value={query}
onValueChange={onQueryChange}
/>
}
<CommandList className="py-1">
{
isLoading &&
<CommandLoading>
<span className="flex items-center justify-center gap-2">
<Loader2 className="animate-spin size-4" /> Loading...
</span>
</CommandLoading>
}
{
!isLoading &&
<CommandEmpty>{emptyMessage || "No options found"}</CommandEmpty>
}
{!isLoading && filtered.map((item, i) => {
if (isGroup(item)) {
return (
<CommandGroup
key={item.group}
heading={item.group}
className={cn("[&_[cmdk-group-heading]]:pb-0.5", groupCls, item.className)}
>
{item.options.map((opt, j) => (
<Item
key={getKey(opt, j)}
option={opt}
selected={value.includes(getValue(opt))}
onSelect={onSelect}
indicatorAt={indicatorAt}
className={itemCls}
/>
))}
</CommandGroup>
)
}
return (
<Item
key={getKey(item, i)}
option={item}
selected={value.includes(getValue(item))}
onSelect={onSelect}
indicatorAt={indicatorAt}
className={cn("mx-1 mb-1", itemCls)}
/>
)
})}
{value.length > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem onSelect={() => onValueChange([])} value="__clear__" className="justify-center">
Clear selection(s)
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export {
Combobox,
MultiSelectCombobox,
type comboboxProps,
type multiSelectComboboxProps,
}Copy and paste the following code shadcn command component.
function CommandLoading({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Loading>) {
return (
<CommandPrimitive.Loading
data-slot="command-loading"
className={cn(
"flex items-center justify-center min-h-20 text-sm",
className
)}
{...props}
/>
)
}Add CommandLoading at the export block.
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
CommandLoading,
}Installation
npm install @radix-ui/react-slot @radix-ui/react-popover cmdkCopy and paste the following code into your project.
general.d.ts
type readOnlyChildren = Readonly<{
children: React.ReactNode;
}>
type allowedPrimitiveT = string | number | boolean
type optionT = {
label: React.ReactNode
value: allowedPrimitiveT
className?: string
}
type groupT = {
group: string
options: (allowedPrimitiveT | optionT)[]
className?: string
}
type optionsT = (allowedPrimitiveT | optionT | groupT)[]
type indicatorAtT = "right" | "left"utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function isAllowedPrimitive(value: unknown): value is allowedPrimitiveT {
return ["string", "number", "boolean"].includes(typeof value)
}
export function parseAllowedPrimitive(value: allowedPrimitiveT): allowedPrimitiveT {
if (typeof value !== "string") return value
const trimmed = value.trim()
if (trimmed === "true") return true
if (trimmed === "false") return false
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
return trimmed
}
export function optionTypeChecker<T>(key: keyof T) {
return (option: any): option is T => !!option && typeof option === "object" && key in option
}
export const isSeparator = (item: any) => item === "---"
export const isOption = optionTypeChecker<optionT>("value")
export const isGroup = optionTypeChecker<groupT>("group")
export const getValue = (item: allowedPrimitiveT | optionT) => typeof item === "object" ? item.value : item
export const getLabel = (item: allowedPrimitiveT | optionT) => typeof item === "object" ? item.label : `${item}`
export function getKey(item: allowedPrimitiveT | optionT, i: number): string {
const val = getValue(item)
if (typeof val === "boolean") return `key-${val}`
if (val === "---") return `${i}`
return `${val}`
}badge.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }popover.tsx
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover(props: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger(props: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor(props: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
type PopoverWrapperProps = {
trigger: React.ReactNode
content: React.ReactNode
triggerCls?: string
contentCls?: string
contentProps?: Omit<React.ComponentProps<typeof PopoverPrimitive.Content>, "className">
} & Omit<React.ComponentProps<typeof PopoverPrimitive.Root>, "children">
function PopoverWrapper({
trigger,
content,
triggerCls,
contentCls,
contentProps,
...props
}: PopoverWrapperProps) {
return (
<Popover {...props}>
<PopoverTrigger
className={triggerCls}
asChild={typeof trigger !== "string"}
>
{trigger}
</PopoverTrigger>
<PopoverContent {...contentProps} className={contentCls}>
{content}
</PopoverContent>
</Popover>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverWrapper,
}command.tsx
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function CommandLoading({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Loading>) {
return (
<CommandPrimitive.Loading
data-slot="command-loading"
className={cn(
"flex items-center justify-center min-h-20 text-sm",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
CommandLoading,
}combobox.tsx
"use client"
import { useState } from "react"
import { Check, ChevronsUpDown, Loader2, Plus } from "lucide-react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn, getKey, getLabel, getValue, isAllowedPrimitive, isGroup, isOption, isSeparator } from "@/lib/utils"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandLoading,
CommandSeparator,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
const extractText = (node: any): string => {
if (node === null || node === undefined) return ""
if (isAllowedPrimitive(node)) return String(node)
if (Array.isArray(node)) return node.map(extractText).join(" ")
if (node.props?.children) return extractText(node.props.children)
return ""
}
const findOptionByValue = (options: optionsT, value: allowedPrimitiveT) => {
for (const item of options) {
if (isGroup(item)) {
const found = item.options.find((opt) => getValue(opt) === value)
if (found) return found
} else if (!isSeparator(item) && getValue(item) === value) {
return item
}
}
return ""
}
const filteredOptions = (options: optionsT, query: string): optionsT => {
const q = query.toLowerCase()
const result: optionsT = []
for (const item of options) {
if (isGroup(item)) {
const filtered = item.options.filter(opt =>
extractText(getLabel(opt)).toLowerCase().includes(q)
)
if (filtered.length) {
result.push({ ...item, options: filtered })
}
continue
}
if (isSeparator(item)) {
result.push(item)
continue
}
if (extractText(getLabel(item)).toLowerCase().includes(q)) {
result.push(item)
}
}
return result
}
type ItemProps = {
option: allowedPrimitiveT | optionT
selected: boolean
className?: string
indicatorAt?: indicatorAtT
onSelect: (value: allowedPrimitiveT) => void
}
function Item({ option, selected, indicatorAt = "right", onSelect, className }: ItemProps) {
const value = getValue(option)
const label = getLabel(option)
const optCls = isOption(option) ? option.className : undefined
if (isSeparator(value)) return <CommandSeparator className={cn("my-0.5", className, "mx-0")} />
return (
<CommandItem
value={`${value}`}
className={cn(indicatorAt === "right" ? "pr-8 pl-2" : "pr-2 pl-8", className, optCls)}
onSelect={() => onSelect(value)}
>
{label}
<Check
className={cn(
"absolute size-4",
selected ? "opacity-100" : "opacity-0",
indicatorAt === "right" ? "right-2" : "left-2",
)}
/>
</CommandItem>
)
}
type base = {
id?: string
options: optionsT
isLoading?: boolean
placeholder?: string
emptyMessage?: string
indicatorAt?: indicatorAtT
triggerCls?: string
contentCls?: string
groupCls?: string
itemCls?: string
matchTriggerWidth?: boolean
open?: boolean
onOpenChange?: (v: boolean) => void
query?: string
onQueryChange?: (v: string) => void
popoverContentProps?: Omit<React.ComponentProps<typeof PopoverPrimitive.Content>, "className">
}
type comboboxProps = base & {
value?: allowedPrimitiveT
canCreateNew?: boolean
onValueChange?: (value: allowedPrimitiveT) => void
}
function Combobox({
id,
options = [],
isLoading,
placeholder,
emptyMessage,
canCreateNew,
matchTriggerWidth = true,
indicatorAt,
triggerCls,
contentCls,
groupCls,
itemCls,
value: o_value,
onValueChange: o_onValueChange,
query: o_query,
onQueryChange: o_onQueryChange,
open: o_open,
onOpenChange: o_onOpenChange,
popoverContentProps,
}: comboboxProps) {
const [i_value, setIValue] = useState("")
const [i_query, setIQuery] = useState("")
const [i_open, setIOpen] = useState(false)
const value = o_value ?? i_value
const query = o_query ?? i_query
const open = o_open ?? i_open
const onValueChange = o_onValueChange ?? setIValue
const onQueryChange = o_onQueryChange ?? setIQuery
const onOpenChange = o_onOpenChange ?? setIOpen
const selectedOption = findOptionByValue(options, value)
const filtered = filteredOptions(options, query)
const label = getLabel(selectedOption)
const showCreate =
canCreateNew &&
query &&
!options.some((o) =>
isGroup(o)
? o.options.some((x) => extractText(getLabel(x)) === query)
: extractText(getLabel(o)) === query
)
function onSelect(v: allowedPrimitiveT) {
onValueChange(v as string)
onOpenChange(false)
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
role="combobox"
variant="outline"
className={cn("font-normal", triggerCls, {
"text-muted-foreground": !value && value !== false,
})}
>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin" />
Loading...
</>
) :
<span className="flex items-center gap-2 truncate">
{
(selectedOption || selectedOption === false)
? label
: placeholder
}
</span>
}
<ChevronsUpDown className="ml-auto size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
{...popoverContentProps}
className={cn("p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]", contentCls)}
>
<Command shouldFilter={false}>
{
!isLoading &&
<CommandInput
placeholder="Search..."
value={query}
onValueChange={onQueryChange}
/>
}
<CommandList className="py-1">
{
isLoading &&
<CommandLoading>
<span className="flex items-center justify-center gap-2">
<Loader2 className="animate-spin size-4" /> Loading...
</span>
</CommandLoading>
}
{
!isLoading &&
<CommandEmpty>{emptyMessage || "No options found"}</CommandEmpty>
}
{!isLoading && filtered.map((item, i) => {
if (isGroup(item)) {
return (
<CommandGroup
key={item.group}
heading={item.group}
className={cn("[&_[cmdk-group-heading]]:pb-0.5", groupCls, item.className)}
>
{item.options.map((opt, j) => (
<Item
key={getKey(opt, j)}
option={opt}
selected={getValue(opt) === value}
onSelect={onSelect}
className={itemCls}
indicatorAt={indicatorAt}
/>
))}
</CommandGroup>
)
}
return (
<Item
key={getKey(item, i)}
option={item}
selected={getValue(item) === value}
onSelect={onSelect}
indicatorAt={indicatorAt}
className={cn("mx-1", itemCls)}
/>
)
})}
{showCreate && (
<CommandGroup>
<CommandItem
value={`__create-${query}`}
onSelect={() => {
onValueChange(query)
onQueryChange("")
onOpenChange(false)
}}
>
<Plus className="mr-2 h-4 w-4" /> Create: {query}
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
type btnLableProps = {
value: allowedPrimitiveT[]
options: optionsT
isLoading?: boolean
placeholder?: string
maxVisibleCount?: number
}
function ButtonLabel({
value,
options,
isLoading,
placeholder,
maxVisibleCount = 2,
}: btnLableProps) {
const labelOf = (val: allowedPrimitiveT) => {
const found = findOptionByValue(options, val)
if (!found) return `${val}`
const label = getLabel(found)
return label
}
if (isLoading)
return (
<>
<Loader2 className="size-4 animate-spin" />
Loading...
</>
)
if (value.length === 0) {
return placeholder
}
if (value.length <= maxVisibleCount) {
return (
<>
{value.map((v) => (
<Badge key={String(v)} variant="secondary" className="rounded-sm px-1 font-normal">
{labelOf(v)}
</Badge>
))}
</>
)
}
return (
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
{value.length} selected
</Badge>
)
}
type multiSelectComboboxProps = base & {
value?: allowedPrimitiveT[]
maxVisibleCount?: number
label?: React.ReactNode
onValueChange?: (v: allowedPrimitiveT[]) => void
}
function MultiSelectCombobox({
id,
options = [],
isLoading,
placeholder,
emptyMessage,
matchTriggerWidth = true,
maxVisibleCount,
indicatorAt,
triggerCls,
contentCls,
groupCls,
itemCls,
label,
value: o_value,
onValueChange: o_onValueChange,
query: o_query,
onQueryChange: o_onQueryChange,
open: o_open,
onOpenChange: o_onOpenChange,
popoverContentProps,
}: multiSelectComboboxProps) {
const [i_value, setIValue] = useState<allowedPrimitiveT[]>([])
const [i_query, setIQuery] = useState("")
const [i_open, setIOpen] = useState(false)
const value = o_value ?? i_value
const query = o_query ?? i_query
const open = o_open ?? i_open
const onValueChange = o_onValueChange ?? setIValue
const onQueryChange = o_onQueryChange ?? setIQuery
const onOpenChange = o_onOpenChange ?? setIOpen
const filtered = filteredOptions(options, query)
const onSelect = (v: allowedPrimitiveT) => {
onValueChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v])
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
role="combobox"
variant="outline"
aria-expanded={open}
className={cn("font-normal", triggerCls, {
"text-muted-foreground": value.length === 0,
})}
>
{label}
<ButtonLabel
value={value}
options={options}
isLoading={isLoading}
placeholder={placeholder}
maxVisibleCount={maxVisibleCount}
/>
<ChevronsUpDown className="ml-auto size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
{...popoverContentProps}
className={cn("p-0", matchTriggerWidth && "w-[var(--radix-popover-trigger-width)]", contentCls)}
>
<Command shouldFilter={false}>
{
!isLoading &&
<CommandInput
placeholder="Search..."
value={query}
onValueChange={onQueryChange}
/>
}
<CommandList className="py-1">
{
isLoading &&
<CommandLoading>
<span className="flex items-center justify-center gap-2">
<Loader2 className="animate-spin size-4" /> Loading...
</span>
</CommandLoading>
}
{
!isLoading &&
<CommandEmpty>{emptyMessage || "No options found"}</CommandEmpty>
}
{!isLoading && filtered.map((item, i) => {
if (isGroup(item)) {
return (
<CommandGroup
key={item.group}
heading={item.group}
className={cn("[&_[cmdk-group-heading]]:pb-0.5", groupCls, item.className)}
>
{item.options.map((opt, j) => (
<Item
key={getKey(opt, j)}
option={opt}
selected={value.includes(getValue(opt))}
onSelect={onSelect}
indicatorAt={indicatorAt}
className={itemCls}
/>
))}
</CommandGroup>
)
}
return (
<Item
key={getKey(item, i)}
option={item}
selected={value.includes(getValue(item))}
onSelect={onSelect}
indicatorAt={indicatorAt}
className={cn("mx-1 mb-1", itemCls)}
/>
)
})}
{value.length > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem onSelect={() => onValueChange([])} value="__clear__" className="justify-center">
Clear selection(s)
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export {
Combobox,
MultiSelectCombobox,
type comboboxProps,
type multiSelectComboboxProps,
}Done
You can now use Combobox
Usage
Basic
import { Combobox, MultiSelectCombobox } from "@/components/ui/combobox"
export function Basic() {
return (
<>
<Combobox
options={["Option 1", "Option 2", "Option 3"]}
placeholder="Select an option"
/>
<MultiSelectCombobox
options={["Option 1", "Option 2", "Option 3"]}
placeholder="Select an option"
/>
</>
)
}Controlled
import { useState } from "react"
import { Combobox, MultiSelectCombobox } from "@/components/ui/combobox"
export function Controlled() {
const [values, setValues] = useState<allowedPrimitiveT[]>([])
const [value, setValue] = useState<allowedPrimitiveT>("")
return (
<>
<Combobox
options={["Option 1", "Option 2", "Option 3"]}
placeholder="Select an option"
value={value}
onValueChange={setValue}
/>
<MultiSelectCombobox
options={["Option 1", "Option 2", "Option 3"]}
placeholder="Select an option"
value={values}
onValueChange={setValues}
/>
</>
)
}
export function Async() {
const { data: options, isLoading } = useAsyncOptions()
return (
<Combobox
options={options || []}
isLoading={isLoading}
/>
)
}Custom Styling
export function Styled() {
return (
<Combobox
options={["Small", "Medium", "Large"]}
placeholder="Select size"
triggerCls="w-[200px] bg-slate-50"
contentCls="min-w-[200px]"
itemCls="cursor-pointer hover:bg-slate-100"
groupCls="py-2"
/>
)
}Reference
Common Properties
Prop
Type
Combobox
Prop
Type
MultiSelectCombobox
Prop
Type